diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..43b69ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,347 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + # Reused by `release.yml`, so a tag passes the same gate a pull request does + # rather than a copy of it that can drift. + workflow_call: + +# Least privilege: nothing in this workflow writes to the repository. +permissions: + contents: read + +# One in-flight run per pull request, or per ref outside one. A newer push +# supersedes and cancels the run it replaces. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Runtime job. Verifies the workflows themselves, because an unpinned action + # reference is a supply-chain hole that no other job in this repository looks + # at, and reviewing for it by eye is exactly the check that stops happening. + hygiene: + name: hygiene + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out the workflow tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + + - name: Verify every action reference is SHA-pinned with a version comment + timeout-minutes: 5 + run: | + set -euo pipefail + + mapfile -t files < <( + find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) | sort + ) + + if [ "${#files[@]}" -eq 0 ]; then + echo "hygiene: no workflow files under .github/workflows; this check would verify nothing" + exit 1 + fi + + echo "hygiene: scanning ${#files[@]} workflow file(s)" + printf ' %s\n' "${files[@]}" + + awk ' + BEGIN { + checked = 0 + violations = 0 + sq = sprintf("%c", 39) + } + { + line = $0 + sub(/^[[:space:]]+/, "", line) + + # A commented-out example is documentation, not a live reference. + if (line ~ /^#/) next + + sub(/^-[[:space:]]+/, "", line) + if (line !~ /^uses:[[:space:]]*/) next + sub(/^uses:[[:space:]]*/, "", line) + + # Separate the trailing version comment from the reference itself. + comment = "" + hash = index(line, "#") + if (hash > 0) { + comment = substr(line, hash + 1) + line = substr(line, 1, hash - 1) + } + sub(/[[:space:]]+$/, "", line) + sub(/^[[:space:]]+/, "", comment) + sub(/[[:space:]]+$/, "", comment) + + ref = line + if (length(ref) > 1) { + first = substr(ref, 1, 1) + last = substr(ref, length(ref), 1) + if ((first == "\"" && last == "\"") || (first == sq && last == sq)) + ref = substr(ref, 2, length(ref) - 2) + } + if (ref == "") next + + # A local reusable workflow is this repository, already at this SHA. + if (ref ~ /^\.\//) next + + checked++ + + # Action references carry exactly one "@"; scan from the right so a + # reusable-workflow path never confuses the split. + at = 0 + for (i = length(ref); i > 0; i--) + if (substr(ref, i, 1) == "@") { at = i; break } + + if (at == 0) { + printf " FAIL %s:%d %s -- not pinned to a commit SHA\n", FILENAME, FNR, ref + violations++ + next + } + + sha = substr(ref, at + 1) + if (sha !~ /^[0-9a-f]{40}$/) { + printf " FAIL %s:%d %s -- not pinned to a full 40-character commit SHA\n", FILENAME, FNR, ref + violations++ + next + } + + if (comment == "") { + printf " FAIL %s:%d %s -- SHA pin carries no trailing version comment\n", FILENAME, FNR, ref + violations++ + next + } + + printf " ok %s:%d %s (%s)\n", FILENAME, FNR, ref, comment + } + END { + if (checked == 0) { + print "hygiene: no uses: references found; this check would verify nothing" + exit 1 + } + if (violations > 0) { + printf "hygiene: %d of %d reference(s) rejected; every uses: must read owner/repo@<40-hex-sha> # \n", violations, checked + exit 1 + } + printf "hygiene: %d reference(s) checked, all SHA-pinned with a version comment\n", checked + } + ' "${files[@]}" + + # Runtime job. Bun only, deliberately: this package targets OMP's runtime and + # has no npm runtime dependencies, so a Node job would assert a compatibility + # nobody consumes. + bun: + name: bun + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out the source tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + + - name: Install the pinned Bun toolchain + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + timeout-minutes: 5 + with: + # Pinned, not `latest`: a runtime that changes underneath a previously + # green build makes a red one unexplainable. Bump deliberately, in + # step with `@types/bun` in `package.json` -- that package supplies + # every runtime type this code uses, so the two move together. + bun-version: 1.3.14 + + - name: Install dependencies + timeout-minutes: 5 + # `--frozen-lockfile` is the point of committing `bun.lock`: a + # dependency resolving differently in CI than locally is a failure, not + # an update to perform silently. + run: bun install --frozen-lockfile + + - name: Report the toolchain versions + timeout-minutes: 5 + # Printed, not assumed: every result below belongs to these versions. + run: | + bun --version + bunx tsc --version + tar --version | head -n 1 + + - name: Type-check + timeout-minutes: 5 + # Through the package scripts, not the underlying commands: a flag that + # lives in only one of the two places is a difference between what a + # contributor runs and what the gate runs. + run: bun run typecheck + + - name: Unit tests + timeout-minutes: 10 + run: bun run test:unit + + - name: Build and load the standalone extension bundle + timeout-minutes: 5 + run: bun run test:packaging + + # `test:packaging` rebuilds `dist/index.js` before loading it, so a green + # packaging step proves that a fresh bundle builds and registers what it + # claims -- never that the committed one matches the source beside it. The + # README tells an operator to load the committed file directly after + # `git clone` with no build step, so without this a pull request carrying + # an innocuous source diff and a substituted bundle would merge green. + - name: Verify the committed bundle matches the one built from source + timeout-minutes: 5 + run: | + set -euo pipefail + + # A pathspec matching nothing makes `git diff` exit 0, so the path is + # confirmed tracked before its diff is trusted. + git ls-files --error-unmatch dist/index.js + git diff --exit-code -- dist/index.js + + # Runtime job. Runs the two install commands the README documents, through + # OMP's own CLI, so the documented path and the verified path are one path. + # `omp plugin install` is strictly more than a `bun add`: after resolving the + # spec it runs `#validateInstalledExtensions`, which loads every declared + # entry against a throwaway registration surface and therefore invokes the + # factory. `omp plugin link` registers a checkout without loading it, so that + # half is discovery only -- that the committed bundle loads clean through + # OMP's loader is `test/packaging/bundle.test.ts`. + # + # Not a pull-request job: it installs by ref, and a pull request's merge ref + # does not exist on the remote as an installable ref. It therefore runs on + # manual dispatch and on pushes, which includes the tag push that publishes a + # release, so the documented command is verified against the exact ref an + # operator can install. It is deliberately absent from `ci`'s `needs`: a + # skipped job would otherwise fail the required check on every pull request. + install-check: + name: install check + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out the source tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + + - name: Install the pinned Bun toolchain + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + timeout-minutes: 5 + with: + bun-version: 1.3.14 + + - name: Install the OMP CLI this package is developed against + timeout-minutes: 5 + # Read out of `devDependencies` rather than written again here: the CLI + # that validates the install has to be the one the suite type-checks and + # loads against, and a second copy of the version drifts. + run: | + set -euo pipefail + + version="$(jq -r '.devDependencies["@oh-my-pi/pi-coding-agent"] // empty' package.json)" + case "$version" in + "") + echo "::error::package.json declares no @oh-my-pi/pi-coding-agent devDependency to pin the OMP CLI to" + exit 1 + ;; + # A range would resolve to whatever is newest at job time, which is the + # drift this step reads the manifest to avoid. Only an exact pin will do. + # This arm comes first because the accepting pattern below ends in an + # unanchored `*`: without it `18.0.8 || 19.0.0` matches on its prefix. + *[[:space:]]*|*"|"*) + echo "::error::@oh-my-pi/pi-coding-agent is declared as '$version'; the OMP CLI must be pinned to an exact version, not a range" + exit 1 + ;; + [0-9]*.[0-9]*.[0-9]*) ;; + *) + echo "::error::@oh-my-pi/pi-coding-agent is declared as '$version'; the OMP CLI must be pinned to an exact version, not a range" + exit 1 + ;; + esac + + echo "installing OMP CLI $version" + bun add --global "@oh-my-pi/pi-coding-agent@$version" + omp --version + + - name: Install the documented git spec on a machine with no prior state + timeout-minutes: 10 + env: + SPEC: github:${{ github.repository }}#${{ github.ref_name }} + run: | + set -euo pipefail + + # A scratch HOME, not the runner's. The installer keeps its plugin + # registry under `$HOME/.omp`, and "resolves on a clean machine" is + # only a claim about a machine that holds none of it yet. + HOME="$(mktemp -d)" + export HOME + + echo "installing $SPEC" + omp plugin install "$SPEC" + + # `install` already fails loudly on a spec that will not resolve or an + # entry that will not load; this is the discovery half, that the + # plugin is registered under the name its manifest declares. + omp plugin list > "$HOME/plugins.txt" + cat "$HOME/plugins.txt" + grep -q 'omp-codebase-memory' "$HOME/plugins.txt" + + - name: Link this checkout the way the development install documents + timeout-minutes: 5 + run: | + set -euo pipefail + + # Its own scratch HOME. `link` and `install` register the same package + # name, so linking a checkout on top of the git-spec install above + # collides on the plugin's `node_modules` entry -- two documented + # commands, two clean machines. + HOME="$(mktemp -d)" + export HOME + + omp plugin link . + omp plugin list > "$HOME/plugins.txt" + cat "$HOME/plugins.txt" + grep -q 'omp-codebase-memory' "$HOME/plugins.txt" + + # The single status check branch protection requires. Runtime jobs are never + # named there, so changing the matrix never means editing the ruleset. + ci: + name: ci + needs: [hygiene, bun] + # `always()` is load-bearing. Without it this job is skipped when a + # dependency fails, and a skipped required check blocks a pull request + # rather than failing it -- a stuck merge button instead of a red one. + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require every runtime job to succeed + timeout-minutes: 5 + env: + RESULTS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + + count=$(jq 'length' <<< "$RESULTS") + if [ "$count" -eq 0 ]; then + echo "ci: no aggregated jobs; the gate would report success while verifying nothing" + exit 1 + fi + + echo "ci: aggregating $count job result(s)" + jq -r 'to_entries[] | " \(.key): \(.value.result)"' <<< "$RESULTS" + + failed=$(jq -r 'to_entries[] | select(.value.result != "success") | .key' <<< "$RESULTS") + if [ -n "$failed" ]; then + echo "ci: gate failed; these jobs did not succeed:" + printf ' %s\n' $failed + exit 1 + fi + + echo "ci: all $count job(s) succeeded" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a2d75b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + # A tag is a claim about four things: the tag itself, the package manifest + # version, the catalog plugin version, and the catalog's source ref. An + # operator installing from the marketplace resolves the last one, so a catalog + # that lags the tag installs the previous release under the new version's + # name. Publish only if all four agree. + version-gate: + name: version gate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out the tagged tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + with: + persist-credentials: false + + - name: Tag, manifest and catalog must name one version + timeout-minutes: 5 + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + version="${TAG#v}" + manifest="$(jq -r '.version' package.json)" + catalog="$(jq -r '.plugins[0].version' .omp-plugin/marketplace.json)" + catalog_ref="$(jq -r '.plugins[0].source.ref' .omp-plugin/marketplace.json)" + + printf 'tag=%s\nmanifest=%s\ncatalog=%s\ncatalog_ref=%s\n' \ + "$TAG" "$manifest" "$catalog" "$catalog_ref" + + fail=0 + if [ "$manifest" != "$version" ]; then + echo "::error::package.json version $manifest does not match tag $TAG" + fail=1 + fi + if [ "$catalog" != "$version" ]; then + echo "::error::catalog plugin version $catalog does not match tag $TAG" + fail=1 + fi + if [ "$catalog_ref" != "$TAG" ]; then + echo "::error::catalog source.ref $catalog_ref does not match tag $TAG" + fail=1 + fi + exit "$fail" + + # The same gate a pull request passes, not a copy of it. `ci.yml` declares + # `workflow_call` for exactly this. + checks: + name: checks + uses: ./.github/workflows/ci.yml + + publish: + name: publish + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: [version-gate, checks] + permissions: + contents: write + steps: + - name: Check out the tagged tree + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + timeout-minutes: 5 + # This is the one job that elevates to `contents: write`, so it is the + # one job where a persisted credential matters: `actions/checkout` + # otherwise writes an `AUTHORIZATION: basic ` extraheader into + # `.git/config` and leaves a push-capable credential on disk for every + # later step. Nothing here needs it -- `gh release create` authenticates + # through `GH_TOKEN` below, not through the git remote. + with: + persist-credentials: false + + - name: Create the GitHub release + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + # `--verify-tag` refuses to create a release for a tag that does not + # exist on the remote, so a release can only ever name a verified ref. + run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12c31ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Dependencies. `bun.lock` is committed on purpose: CI installs with +# `--frozen-lockfile`, so the lockfile is an input to the build, not scratch. +node_modules/ + +# Build scratch. `dist/index.js` is committed on purpose -- the README tells an +# operator to load it directly after `git clone`, and CI verifies it is +# byte-identical to a fresh build -- so `dist/` as a whole is NOT ignored. +*.tsbuildinfo + +# Editor and OS noise. +.DS_Store +.idea/ +.vscode/ + +# Local scratch used while verifying acquisition against a throwaway HOME. +tmp/ +tmp_* diff --git a/.omp-plugin/marketplace.json b/.omp-plugin/marketplace.json new file mode 100644 index 0000000..fb79296 --- /dev/null +++ b/.omp-plugin/marketplace.json @@ -0,0 +1,26 @@ +{ + "name": "omp-codebase-memory", + "owner": { + "name": "pashifika" + }, + "metadata": { + "description": "OMP-native wiring for codebase-memory-mcp" + }, + "plugins": [ + { + "name": "omp-codebase-memory", + "description": "Owns the codebase-memory-mcp executable lifecycle -- verified download, version tracking, pinning -- and wires exactly one MCP server entry into OMP's native user configuration. Adopts an existing system installation rather than replacing it.", + "version": "0.1.0", + "source": { + "source": "github", + "repo": "pashifika/omp-codebase-memory", + "ref": "v0.1.0" + }, + "category": "productivity", + "homepage": "https://github.com/pashifika/omp-codebase-memory", + "repository": "https://github.com/pashifika/omp-codebase-memory", + "license": "MIT", + "tags": ["mcp", "codebase-memory", "knowledge-graph", "installer"] + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9960690 --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# omp-codebase-memory + +An [OMP](https://github.com/can1357/oh-my-pi) extension that owns the +[`codebase-memory-mcp`](https://github.com/DeusData/codebase-memory-mcp) (CBM) +executable's lifecycle and wires exactly one MCP server entry into OMP's native +user configuration. + +CBM indexes a repository into a persistent code knowledge graph and exposes it +over MCP. Its own installer configures 44 client surfaces, and OMP is not one of +them: OMP only sees CBM indirectly, by discovering a Claude, Codex, or Gemini +config that some *other* client's installation happened to leave behind. An +OMP-only machine gets nothing. + +This package is the missing path. `omp plugin install` is the whole setup step. + +## What it does + +- **Resolves the executable, system installation first.** An existing + `codebase-memory-mcp` is adopted as-is and never replaced. A managed copy is + downloaded only when none is found. +- **Verifies a download the way upstream's installer does.** Release tag from + the `releases/latest` redirect, `checksums.txt` digest match for the exact + archive name, HTTPS on every redirect hop, a closed four-member archive + namespace, regular-file-not-symlink extraction, the Linux `-portable` build, + macOS quarantine removal and ad-hoc signing, and a `--version` smoke run + before anything is adopted. +- **Owns one key in `~/.omp/agent/mcp.json`.** `codebase-memory-mcp`, written + with the resolved absolute path, corrected when that path changes, removed on + uninstall. No other user file is touched. +- **Tracks versions without fighting CBM's own updater.** A managed copy is + updated by this package. An adopted system copy is only reported on. + +## Install + +### Git spec (primary) + +```sh +omp plugin install github:pashifika/omp-codebase-memory +``` + +CI runs this command, through OMP's own installer, on pushes to `main`, on +release tag pushes, and on manual dispatch — as +`github:pashifika/omp-codebase-memory#`, into a home directory with no +prior plugin state. `omp plugin install` validates what it installed by loading +the declared extension entry, so a ref that installs green also registers green. + +### Marketplace + +```sh +omp plugin marketplace add github:pashifika/omp-codebase-memory +omp plugin install omp-codebase-memory@omp-codebase-memory +``` + +The catalog lives at `.omp-plugin/marketplace.json`. Marketplace installs are +discovered through a different provider than git installs, so this is an +additional entry point rather than a replacement. + +### Development + +```sh +git clone https://github.com/pashifika/omp-codebase-memory +cd omp-codebase-memory +bun install +omp plugin link . +``` + +`omp.extensions` names `./dist/index.js`, which is committed, so a fresh clone +loads without a build step. Run `bun run build` after changing anything under +`src/`; CI fails if the committed bundle is not byte-identical to one built from +the current source. + +CI links its own checkout with this command, so the development install is +verified to be discovered. That the committed bundle then *loads* through OMP's +loader is `test/packaging/bundle.test.ts` — `omp plugin link` registers a +checkout without loading it. + +## Commands + +| Command | What it does | +|---|---| +| `/cbm status` | Resolved source, absolute path, local version, last known upstream version, pin state, resolved agent directory, and whether the MCP entry is present and current | +| `/cbm install [version]` | Downloads, verifies, and adopts a managed copy. Asks for confirmation first when a system executable already resolves | +| `/cbm update` | Updates a managed copy. For an adopted system copy, reports the newer version and points at CBM's own `update` | +| `/cbm pin ` | Holds a version: update checks report but never adopt | +| `/cbm unpin` | Releases the pin | +| `/cbm uninstall` | Removes the managed copy, this package's state, and the owned MCP entry. Leaves an adopted system executable alone | + +No command needs an interactive terminal. In a session with no UI, `/cbm +install` fails with the reason rather than waiting for a confirmation that +cannot arrive. + +## The system-first policy, and why it is not negotiable + +Resolution order is **pin, `PATH`, `~/.local/bin`, managed copy** — system +before managed. + +CBM resolves one canonical per-account cache root, and refuses to run when a +process is configured with a different root while any CBM session or command is +active. Two executables of *different versions* sharing that root produce +mismatched index generations. Giving a managed copy its own private cache root +would avoid the conflict by re-indexing every repository a second time, which +for a large tree is hours of work and gigabytes to hold the same answers twice. + +So the operator's existing installation wins. The cost is that this package +cannot guarantee a version, and that cost is made visible rather than hidden: +`/cbm status` names the source, and an out-of-date system copy produces a +pointer to CBM's own `update` rather than an attempt to perform it. + +`/cbm install` while a system copy resolves is still possible — it is your +machine — but it explains the shared-cache-root consequence and requires +explicit confirmation first. + +## Where things live + +| Path | Owner | Notes | +|---|---|---| +| `~/.omp/codebase-memory/bin//` | this package | Managed executables, one directory per version | +| `~/.omp/codebase-memory/state.json` | this package | Version pointer, digest, pin, last check time | +| `/mcp.json` | the operator | This package owns the single `codebase-memory-mcp` key and nothing else | +| `~/.local/bin/codebase-memory-mcp` | CBM's installer | Read during resolution, **never** written | + +`` is resolved the way OMP resolves it: `PI_CODING_AGENT_DIR` when +set, otherwise `~/.omp/profiles//agent` under `OMP_PROFILE`/`PI_PROFILE`, +otherwise `~/.omp/agent`. A profile-scoped operator therefore gets the entry +only in the active profile — writing every profile would configure profiles you +never asked about — and `/cbm status` names the directory it resolved so the +scope is visible. + +The managed copy lives outside the plugin tree on purpose. OMP caches plugins in +version-qualified directories and replaces them on reinstall, so an executable +stored inside would be discarded by a routine plugin upgrade and re-downloaded +every time. + +## Rollback + +Two steps, in either order: + +```sh +/cbm uninstall # managed copy, state, and the owned MCP entry +omp plugin uninstall omp-codebase-memory +``` + +Neither touches an adopted system executable, CBM's cache, or any other client's +configuration. + +## Requirements + +- **Bun**, which is OMP's runtime. No npm runtime dependencies. +- **`tar`** for archive extraction. +- **`xattr` and `codesign`** on macOS. Both ship with a default install; their + absence is reported as a named prerequisite rather than a mysterious failure. +- macOS and Linux. Windows is deferred, not refused: it needs zip extraction, a + different executable suffix, and its own path handling, and is currently one + explicit unsupported-platform error rather than a half-implemented branch. + +## Known limits + +- **MCP does not pick up a changed `command` without a reload.** A managed + update changes the resolved path mid-session; the entry is corrected + immediately and you are told the session needs `/mcp reload`. +- **A foreign entry of the same name is left alone.** If `mcp.json` already + defines `codebase-memory-mcp` with a `command` this package did not write, the + file is not modified and both paths are reported. CBM's own installer, another + tool, or a hand edit may already own that name. +- **`~/.omp/agent/mcp.json` has two possible writers.** OMP's own `/mcp add` and + this package have no shared lock. The write is a read-modify-write against the + observed content and fails closed on a shape it does not recognise, so a lost + update degrades to "the entry is missing and the next session start rewrites + it" rather than a corrupted file. + +## Development + +```sh +bun install # --frozen-lockfile in CI +bun run typecheck +bun run test:unit +bun run test:packaging # rebuilds dist/index.js, then loads it +bun run build # commit the result +``` + +Tests are written as case tables: one row per case, named by a `scenario` field, +so a failure names the case without anyone reading the table. + +## Licence + +MIT. See [LICENSE](./LICENSE). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..45daabb --- /dev/null +++ b/bun.lock @@ -0,0 +1,403 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "omp-codebase-memory", + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "18.0.8", + "@types/bun": "1.3.14", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], + + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], + + "@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@oh-my-pi/hashline": ["@oh-my-pi/hashline@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" } }, "sha512-trZWvlk6A5vSWaaMfuCl9798Nk2r16CtqhKAZGQW+Yy8ArQEknqYjh39jZkV6eRrFxGzn/pJfaKkA3FkKUEinw=="], + + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-xtraAY/6WPCQn2XACU48BBCc/0zenRKo+5h1Sc7nuUGP6xk6rSSfG/2GmIK6tGctLWXiCEjBYWlTW1ktODqcTQ=="], + + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@18.0.8", "", {}, "sha512-1g1Q8xAnrA62he2sP/JW84zZine7PvaXjOhgHcmauFvU2HSSIJHX5t8iF5+gcvX/s6P0h4wZSS/aGfVCHeMT4w=="], + + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8", "@oh-my-pi/snapcompact": "18.0.8", "@opentelemetry/api": "^1.9.1" } }, "sha512-xGmfu7jNhzaBe+ja5zJNCvxkMQR6cJ7M9SZ2Hwmc46i8C5AY6qV+7DibirL262lNPkMPP/blYvdDIAYZ+LYj2g=="], + + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@18.0.8", "", { "dependencies": { "@oh-my-pi/omptype": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8" } }, "sha512-PRqG3mMeMUzdo+OJs1Mb7K4I2rOYmpE2OxPL9FOdoHkyfXbGLemOPW1sYiUF5qZvbKy3b5Nr9cfRXyIhtOPSRw=="], + + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@18.0.8", "", { "dependencies": { "@oh-my-pi/omptype": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" } }, "sha512-wLSCO7jYlO//tNzWNcGEnnzF9WH9s6VVwUYN1iyU9iQOh1IqKzYrd6RUjPD5PWDySEIdz2pVHcqZn6WhzrEaiQ=="], + + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@18.0.8", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/hashline": "18.0.8", "@oh-my-pi/omp-stats": "18.0.8", "@oh-my-pi/omptype": "18.0.8", "@oh-my-pi/pi-agent-core": "18.0.8", "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-mnemopi": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-tui": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8", "@oh-my-pi/snapcompact": "18.0.8", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-ZdSj93aSrxbjP3PWqm1Q/jbX9hr3LtFNCpmpTb4EwlYwBClroAJChmuPmC0+gDqECf276Xo/m/QoezlrPg9zsA=="], + + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-FHzFbi5iMRcETpumq/i1T9zL7EzJReiVws4pBYvIZ6zN1EECTldle2A63TXHJCW+znbh/UhFc//IZzj4nm53oA=="], + + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@18.0.8", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "18.0.8", "@oh-my-pi/pi-natives-darwin-x64": "18.0.8", "@oh-my-pi/pi-natives-linux-arm64": "18.0.8", "@oh-my-pi/pi-natives-linux-x64": "18.0.8", "@oh-my-pi/pi-natives-win32-x64": "18.0.8" } }, "sha512-H+DkXduyXBotqov9puMV2V14E3LYSPeGyZAx0BPt+HqirXMR9LREWjTmy4I5X3Fgszars+N7J2VQ5t55/kbT4w=="], + + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@18.0.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NUaJkzJwjO/ISewUm3AcW/AdiePGISwJmhLwJ21nwq9MGQFNNFTPxDKTfRSjpnKGJzRYXuu1unJxl6SLG84cgg=="], + + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@18.0.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-/zvIKXgaHrC9lFHLkLsObGQSKbBktv3OLj09TfTD22uFjghZmmZ7iZk0PMts0hq1Kt0S9YQcbaBfKdpFDPs0vw=="], + + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@18.0.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-U5E1hV5sEckmg0/PXzcMOUnMkawe3eMfhSWeL/LFmQPqiioTPRZrM307hFl26PIqKqeqJ3SGCzEgl+nmLqRAHQ=="], + + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@18.0.8", "", { "os": "linux", "cpu": "x64" }, "sha512-t5AWTA4E9R876x+ijgcN5hX27p0np4xdQnAuo20/Vj3uOBEUDwc+a+QQYD9YS54Gh/UhQN7FawRgvVjI4ojgag=="], + + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@18.0.8", "", { "os": "win32", "cpu": "x64" }, "sha512-16KDe4Jjh8jC1GIDMwlw5cQYYXu1WBf5Z/VGHs5QgGRLtaZ/GwPRaWXbdN22k5XAVaFKCCsyXF9Hdswq2oP4YA=="], + + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8" } }, "sha512-61KKt1PDdDk8NNj00QVP82hGvCiBTFfY+nYAXKfVlDC4bILWai2gGHnvmHg8woh8GzQpoN42lyIk1IiSTt6Jyg=="], + + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-natives": "18.0.8" } }, "sha512-JmN6QAyiBpyWIuQ6ZFyntWkRb96C188j+ZdgukDYug7VElnH81lhMlySCoIPs++OIKxXEFz/s5KbmWR/vxnQ0g=="], + + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@18.0.8", "", {}, "sha512-5CTEkud+SHxopT6fgD5ZROZhu7w0m13/miHo2Gsf2RvIWBfhIwaiB5Cqn/d3FMTafjILjTfUMlS66/CsYWDeGg=="], + + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@18.0.8", "", { "dependencies": { "@oh-my-pi/pi-ai": "18.0.8", "@oh-my-pi/pi-catalog": "18.0.8", "@oh-my-pi/pi-natives": "18.0.8", "@oh-my-pi/pi-utils": "18.0.8", "@oh-my-pi/pi-wire": "18.0.8" } }, "sha512-U9fCPYmNdekmrcCTDjlpIPSlmilORjGEwzFkPRESOpx1eTXJrYxpBYYuhgs7ZeNYFlgJu3lLgelBxGxdJaMJcg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.10.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.10.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + + "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + + "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lucide-react": ["lucide-react@1.34.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + + "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + + "onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="], + + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m3QMOGUHVPUl7bAOPc7oJtmzOVdzQVQPsCuTjNuQ2N6zlYuXDZu9NdUR3Er3rxbhHa6SmWa94flUwmUsO1atow=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-3X5FQ9PpwlBmxLqtb2/uvl7/W2HXXzXHT0MLmB9c0TRMANLujzvIF7YbT6VW3k92ClL/1q9sbIOul0rHgxCTmw=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-xSzhqrGFbBrykpbEFt5dLXB3Sp3a8idNzjhf92uylDgUhupBSRHj3ISeYaL9ZZWTNSzszNXBKlM+xH36dHQjPg=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HiR3yolQDl3WoU1zSXn8L0MSXzF9PXLQpMv+Ptozg1y/DJU8HuvoXBasOarX4cer5d9kGimnOUGm0dl47PCN1A=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-CN6rNQqn0wzdSp3XRHxdmNaLUMhP+BgsWVbaASU+V9CYBdx74uutoA/43ThUfh3S3CiArpp8OJtn8dJrPeGHnw=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.6", "", { "os": "win32", "cpu": "x64" }, "sha512-bPMdURD1XCu1Zr3eYYlYx2obc5CMSQhEnd1GQuv7FXjdyOeMJ8jNbQEKFEIAyQdC481FgZvPBb2oSJXaLOOeVw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..e265b5d --- /dev/null +++ b/dist/index.js @@ -0,0 +1,1182 @@ +// @bun +// src/index.ts +import { existsSync } from "fs"; + +// src/platform.ts +import { cpus } from "os"; + +class UnsupportedPlatformError extends Error { + constructor(message) { + super(message); + this.name = "UnsupportedPlatformError"; + } +} +function describeTarget(os, arch) { + const container = os === "windows" ? "zip" : "tar.gz"; + const executable = os === "windows" ? "codebase-memory-mcp.exe" : "codebase-memory-mcp"; + const installer = os === "windows" ? "install.ps1" : "install.sh"; + const portable = os === "linux" ? "-portable" : ""; + const archive = `codebase-memory-mcp-${os}-${arch}${portable}.${container}`; + return { + os, + arch, + archive, + container, + executable, + installer, + members: [executable, "LICENSE", installer, "THIRD_PARTY_NOTICES.md"] + }; +} +function detectTarget(platform, arch, cpuModel) { + let os; + switch (platform) { + case "darwin": + os = "darwin"; + break; + case "linux": + os = "linux"; + break; + case "win32": + throw new UnsupportedPlatformError("Windows is not supported yet: the release archive is a zip this package cannot extract, " + "and the executable suffix and path handling are unimplemented. " + "Install codebase-memory-mcp with upstream's install.ps1 and this package will adopt it from PATH."); + default: + throw new UnsupportedPlatformError(`unsupported operating system: ${platform} (supported: darwin, linux)`); + } + let target; + switch (arch) { + case "arm64": + target = "arm64"; + break; + case "x64": + target = os === "darwin" && /apple/i.test(cpuModel ?? "") ? "arm64" : "amd64"; + break; + default: + throw new UnsupportedPlatformError(`unsupported architecture: ${arch} (supported: arm64, x64)`); + } + return describeTarget(os, target); +} +function hostTarget() { + return detectTarget(process.platform, process.arch, cpus()[0]?.model); +} + +// src/paths.ts +import { homedir } from "os"; +import path from "path"; +function processHost() { + return { home: homedir(), env: process.env }; +} +var EXECUTABLE_NAME = "codebase-memory-mcp"; +var SERVER_NAME = "codebase-memory-mcp"; +function configDirName(host) { + const override = host.env["PI_CONFIG_DIR"]; + return override !== undefined && override !== "" ? override : ".omp"; +} +function agentDir(host) { + const explicit = host.env["PI_CODING_AGENT_DIR"]; + if (explicit !== undefined && explicit !== "") + return path.resolve(explicit); + const omp = host.env["OMP_PROFILE"]; + const profile = omp !== undefined ? omp : host.env["PI_PROFILE"]; + const root = path.join(host.home, configDirName(host)); + return profile !== undefined && profile !== "" ? path.join(root, "profiles", profile, "agent") : path.join(root, "agent"); +} +function mcpConfigPath(host) { + return path.join(agentDir(host), "mcp.json"); +} +function nativeExtensionPath(host) { + return path.join(agentDir(host), "extensions", "codebase-memory.ts"); +} +function packageRoot(host) { + return path.join(host.home, configDirName(host), "codebase-memory"); +} +function managedBinRoot(host) { + return path.join(packageRoot(host), "bin"); +} +function managedExecutable(host, version) { + return path.join(managedBinRoot(host), version, EXECUTABLE_NAME); +} +function insideManagedBinRoot(host, candidate) { + if (!path.isAbsolute(candidate)) + return false; + const inside = path.relative(managedBinRoot(host), candidate); + return inside !== "" && !inside.startsWith("..") && !path.isAbsolute(inside); +} +function statePath(host) { + return path.join(packageRoot(host), "state.json"); +} +function upstreamInstallDir(host) { + return path.join(host.home, ".local", "bin"); +} + +// src/release.ts +var UPSTREAM_REPO = "DeusData/codebase-memory-mcp"; +var RELEASES = `https://github.com/${UPSTREAM_REPO}/releases`; +var LATEST = `${RELEASES}/latest`; +var MAX_REDIRECTS = 5; +var DEFAULT_TIMEOUT_MS = 20000; +var CHECKSUMS_LIMIT_BYTES = 1048576; +async function fetchHttps(url, options = {}) { + const budget = Math.min(options.maxRedirects ?? MAX_REDIRECTS, MAX_REDIRECTS); + let current = requireHttps(url, "request"); + for (let hop = 0;; hop++) { + const response = await fetch(current, { + redirect: "manual", + signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS), + headers: { accept: "*/*" } + }); + const redirected = response.status >= 300 && response.status < 400; + if (!redirected || hop >= budget) + return response; + current = nextHop(current, response.status, response.headers.get("location")); + } +} +function nextHop(current, status, location) { + if (location === null || location === "") { + throw new Error(`${current} answered ${status} with no location header`); + } + return requireHttps(new URL(location, current).href, "redirect"); +} +function requireHttps(url, kind) { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`refusing non-HTTPS ${kind}: ${url}`); + } + return parsed.href; +} +function tagFromLocation(status, location) { + if (status < 300 || status >= 400) { + throw new Error(`expected ${LATEST} to redirect to a tag, got HTTP ${status}`); + } + if (location === null || location === "") { + throw new Error(`${LATEST} answered ${status} with no location header`); + } + const resolved = new URL(location, LATEST); + if (resolved.protocol !== "https:") { + throw new Error(`refusing non-HTTPS release location: ${resolved.href}`); + } + const prefix = `/${UPSTREAM_REPO}/releases/tag/`; + if (resolved.origin !== new URL(LATEST).origin || !resolved.pathname.startsWith(prefix)) { + throw new Error(`unexpected release location: ${resolved.href}`); + } + const tag = decodeURIComponent(resolved.pathname.slice(prefix.length)); + if (tag === "" || tag.includes("/")) { + throw new Error(`unexpected release tag in location: ${resolved.href}`); + } + return tag; +} +async function resolveLatestTag() { + const response = await fetchHttps(LATEST, { maxRedirects: 0 }); + return tagFromLocation(response.status, response.headers.get("location")); +} +function parseChecksums(body, archive) { + if (body.byteLength > CHECKSUMS_LIMIT_BYTES) { + throw new Error(`checksums.txt is ${body.byteLength} bytes, over the ${CHECKSUMS_LIMIT_BYTES} byte safety limit`); + } + const text = new TextDecoder("utf-8", { fatal: false }).decode(body); + let digest; + for (const line of text.split(` +`)) { + const fields = line.trim().split(/\s+/u); + if (fields.length < 2) + continue; + const name = fields[1] ?? ""; + if (name !== archive && name !== `*${archive}`) + continue; + const candidate = (fields[0] ?? "").toLowerCase(); + if (!/^[0-9a-f]{64}$/u.test(candidate)) { + throw new Error(`invalid SHA-256 digest for ${archive}: ${fields[0] ?? ""}`); + } + if (digest !== undefined && digest !== candidate) { + throw new Error(`conflicting SHA-256 digests for ${archive} in checksums.txt`); + } + digest = candidate; + } + if (digest === undefined) { + throw new Error(`no SHA-256 digest for ${archive} in checksums.txt`); + } + return digest; +} +function githubReleaseSource() { + return { + latestTag: resolveLatestTag, + checksums: (tag) => downloadBounded(`${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`, CHECKSUMS_LIMIT_BYTES, "checksums.txt"), + asset: (tag, name) => download(`${RELEASES}/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`) + }; +} +async function download(url) { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} +async function downloadBounded(url, limitBytes, what) { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return await readBounded(response.body, limitBytes, what); +} +async function readBounded(body, limitBytes, what) { + if (body === null) + return new Uint8Array; + const reader = body.getReader(); + const chunks = []; + let total = 0; + try { + for (;; ) { + const { done, value } = await reader.read(); + if (done) + break; + total += value.byteLength; + if (total > limitBytes) { + throw new Error(`${what} is over the ${limitBytes} byte safety limit`); + } + chunks.push(value); + } + } finally { + await reader.cancel().catch(() => {}); + } + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} + +// src/scheduler.ts +function schedulerFrom(ctx) { + return { + after(callback, ms) { + return ctx.setTimeout(callback, ms); + }, + cancel(handle) { + ctx.clearTimer(handle); + } + }; +} + +// src/lifecycle.ts +import { rm as rm4 } from "fs/promises"; + +// src/acquire.ts +import { chmod, lstat, mkdtemp, mkdir, rename, rm } from "fs/promises"; +import { tmpdir } from "os"; +import path2 from "path"; + +// src/exec.ts +var DEFAULT_TIMEOUT_MS2 = 30000; +var OUTPUT_LIMIT_BYTES = 262144; +function capture(stream, onOverflow) { + if (stream === undefined) { + return { captured: Promise.resolve({ text: "", overflowed: false }), release: () => {} }; + } + const reader = stream.getReader(); + const drain = async () => { + const chunks = []; + let total = 0; + let overflowed = false; + try { + for (;; ) { + const { done, value } = await reader.read(); + if (done) + break; + const room = OUTPUT_LIMIT_BYTES - total; + if (value.byteLength > room) { + chunks.push(value.subarray(0, room)); + total = OUTPUT_LIMIT_BYTES; + overflowed = true; + onOverflow(); + break; + } + chunks.push(value); + total += value.byteLength; + } + } finally { + await reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(bytes), overflowed }; + }; + return { + captured: drain(), + release: () => { + reader.cancel().catch(() => {}); + } + }; +} +async function deadlineWon(work, deadline) { + if (deadline.aborted) + return true; + const expired = new Promise((resolve) => { + deadline.addEventListener("abort", () => resolve(true), { once: true }); + }); + return await Promise.race([work.then(() => false), expired]); +} +async function run(argv, options = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2; + const name = argv[0] ?? "the process"; + try { + const deadline = AbortSignal.timeout(timeoutMs); + const child = Bun.spawn([...argv], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + signal: deadline, + detached: true, + ...options.cwd === undefined ? {} : { cwd: options.cwd } + }); + const reap = (signal) => { + try { + process.kill(-child.pid, signal); + } catch {} + child.kill(signal); + }; + const stopFlood = () => { + reap("SIGTERM"); + }; + const stdout = capture(child.stdout, stopFlood); + const stderr = capture(child.stderr, stopFlood); + const drained = Promise.all([stdout.captured, stderr.captured]); + const overran = await deadlineWon(Promise.all([drained, child.exited]), deadline); + if (overran) { + reap("SIGKILL"); + stdout.release(); + stderr.release(); + } + const [out, err] = await drained; + const exitCode = await child.exited; + if (overran) { + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} did not finish within ${timeoutMs}ms and was killed` + }; + } + if (out.overflowed || err.overflowed) { + const flooded = out.overflowed ? "stdout" : "stderr"; + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} wrote more than ${OUTPUT_LIMIT_BYTES} bytes to ${flooded} and was killed` + }; + } + return { ok: exitCode === 0, exitCode, stdout: out.text, stderr: err.text }; + } catch (error) { + return { + ok: false, + exitCode: -1, + stdout: "", + stderr: "", + spawnError: error instanceof Error ? error.message : String(error) + }; + } +} +async function readVersion(executable) { + const result = await run([executable, "--version"], { timeoutMs: 1e4 }); + if (!result.ok) + return null; + const reported = `${result.stdout}${result.stderr}`.trim(); + return reported === "" ? null : reported.split(` +`)[0]?.trim() ?? null; +} +function haveTool(tool, pathEnv) { + return Bun.which(tool, pathEnv === undefined ? {} : { PATH: pathEnv }) !== null; +} + +// src/acquire.ts +var VERSION_PATTERN = /^[0-9][0-9A-Za-z.+-]*$/u; +function normalizeVersion(value) { + const trimmed = value.trim().replace(/^v/iu, ""); + if (!VERSION_PATTERN.test(trimmed)) { + throw new Error(`not a usable version: ${value}`); + } + return trimmed; +} +function tagFor(version) { + return `v${version}`; +} +async function acquire(request) { + const { host, target, source } = request; + if (target.container !== "tar.gz") { + throw new UnsupportedPlatformError(`cannot extract a ${target.container} archive; only tar.gz is implemented`); + } + if (!haveTool("tar", host.env["PATH"])) { + throw new Error("tar is required to extract the release archive, and is not on PATH"); + } + const version = request.version === undefined ? normalizeVersion(await source.latestTag()) : normalizeVersion(request.version); + const tag = tagFor(version); + const expected = parseChecksums(await source.checksums(tag), target.archive); + const bytes = await source.asset(tag, target.archive); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(bytes); + const actual = hasher.digest("hex"); + if (actual !== expected) { + throw new Error(`SHA-256 mismatch for ${target.archive} at ${tag}: published ${expected}, downloaded ${actual}`); + } + const scratch = await mkdtemp(path2.join(tmpdir(), "omp-codebase-memory-")); + try { + const archive = path2.join(scratch, target.archive); + await Bun.write(archive, bytes); + await assertArchiveMembers(archive, target); + await extract(archive, scratch, target); + const candidate = path2.join(scratch, target.executable); + await chmod(candidate, 493); + if (target.os === "darwin") + await repairMacOsSignature(host, candidate); + const reportedVersion = await smokeCheck(candidate, target); + return await adopt(host, { version, digest: expected, candidate, reportedVersion }); + } finally { + await rm(scratch, { recursive: true, force: true }).catch(() => {}); + } +} +async function assertArchiveMembers(archive, target) { + const listed = await run(["tar", "-tzf", archive]); + if (!listed.ok) { + throw new Error(`could not enumerate ${path2.basename(archive)}: ${listed.stderr.trim() || listed.spawnError || `tar exited ${listed.exitCode}`}`); + } + const records = listed.stdout.split(` +`); + if (records[records.length - 1] === "") + records.pop(); + const seen = new Map; + for (const raw of records) { + const member = raw.startsWith("./") ? raw.slice(2) : raw; + if (!target.members.includes(member)) { + throw new Error(`release archive contains unexpected member: ${JSON.stringify(raw)}`); + } + seen.set(member, (seen.get(member) ?? 0) + 1); + } + for (const member of target.members) { + const count = seen.get(member) ?? 0; + if (count === 1) + continue; + throw new Error(count === 0 ? `release archive is missing member: ${member}` : `release archive contains member ${member} ${count} times`); + } +} +async function extract(archive, into, target) { + const extracted = await run(["tar", "--no-same-owner", "-xzf", archive, "-C", into]); + if (!extracted.ok) { + throw new Error(`could not extract ${path2.basename(archive)}: ${extracted.stderr.trim() || extracted.spawnError || `tar exited ${extracted.exitCode}`}`); + } + for (const member of target.members) { + const entry = path2.join(into, member); + let stats; + try { + stats = await lstat(entry); + } catch { + throw new Error(`release member is missing after extraction: ${member}`); + } + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`release member is not a regular file: ${member}`); + } + } +} +function repairHint(candidate) { + return `xattr -cr ${candidate} && codesign --force --sign - ${candidate}`; +} +async function repairMacOsSignature(host, candidate) { + const pathEnv = host.env["PATH"]; + for (const tool of ["xattr", "codesign"]) { + if (!haveTool(tool, pathEnv)) { + throw new Error(`${tool} is required to prepare a macOS binary and is not on PATH; ` + `install the Xcode command line tools, or repair the candidate yourself with \`${repairHint(candidate)}\``); + } + } + const cleared = await run(["xattr", "-cr", candidate], { timeoutMs: 1e4 }); + if (!cleared.ok) { + throw new Error(`could not clear the candidate's extended attributes: ${cleared.stderr.trim() || cleared.spawnError || `xattr exited ${cleared.exitCode}`}. ` + `Repair it by hand with \`${repairHint(candidate)}\``); + } + const signed = await run(["codesign", "--sign", "-", "--force", candidate], { + timeoutMs: 60000 + }); + if (!signed.ok) { + throw new Error(`could not ad-hoc sign the candidate: ${signed.stderr.trim() || `codesign exited ${signed.exitCode}`}. ` + `Repair it by hand with \`${repairHint(candidate)}\``); + } +} +async function smokeCheck(candidate, target) { + const reported = await readVersion(candidate); + if (reported !== null) + return reported; + const suffix = target.os === "darwin" ? ` If macOS is refusing to run it, try \`${repairHint(candidate)}\`.` : ""; + throw new Error(`the downloaded executable failed to run \`--version\`.${suffix}`); +} +async function adopt(host, candidate) { + const binRoot = managedBinRoot(host); + const destination = path2.join(binRoot, candidate.version); + const name = path2.basename(candidate.candidate); + const executable = path2.join(destination, name); + await mkdir(binRoot, { recursive: true }); + const staging = await mkdtemp(path2.join(binRoot, ".staging-")); + try { + const staged = path2.join(staging, name); + await Bun.write(staged, Bun.file(candidate.candidate)); + await chmod(staged, 493); + await mkdir(destination, { recursive: true }); + await rename(staged, executable); + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => {}); + } + return { + version: candidate.version, + digest: candidate.digest, + executable, + reportedVersion: candidate.reportedVersion + }; +} + +// src/mcp-config.ts +import { randomUUID } from "crypto"; +import { chmod as chmod2, mkdir as mkdir2, rename as rename2, rm as rm2, stat } from "fs/promises"; +import path3 from "path"; +var MCP_SCHEMA_URL = "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"; +async function readMcpFile(host) { + const file = mcpConfigPath(host); + let text; + try { + text = await Bun.file(file).text(); + } catch (error) { + const code = error?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + return { + ok: false, + reason: `${file} could not be read (${code ?? "no errno"}), so it was left untouched: ` + `${error instanceof Error ? error.message : String(error)}` + }; + } + return { + ok: true, + file: { path: file, text: null, document: {}, indent: " ", trailingNewline: true } + }; + } + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + return { + ok: false, + reason: `${file} is not parseable JSON, so it was left untouched: ${error instanceof Error ? error.message : String(error)}` + }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, reason: `${file} does not hold a JSON object, so it was left untouched` }; + } + const servers = parsed["mcpServers"]; + const shaped = typeof servers === "object" && servers !== null && !Array.isArray(servers); + if (servers !== undefined && !shaped) { + return { + ok: false, + reason: `${file} holds an mcpServers value that is not a JSON object, so it was left untouched` + }; + } + return { + ok: true, + file: { + path: file, + text, + document: parsed, + indent: detectIndent(text), + trailingNewline: text.endsWith(` +`) + } + }; +} +async function upsertEntry(host, command, previouslyWrote) { + const read = await readMcpFile(host); + if (!read.ok) + return read; + const { file } = read; + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + if (existing !== undefined) { + const currentCommand = commandOf(existing); + const ours = currentCommand === command || currentCommand === previouslyWrote || currentCommand !== undefined && insideManagedBinRoot(host, currentCommand); + if (!ours) { + return { + ok: false, + reason: `${file.path} already defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + `which this package did not write. It was left untouched; the executable this package resolved is ${command}.` + }; + } + if (isCurrentEntry(existing, command)) + return { ok: true, change: "unchanged" }; + } + const next = { ...file.document }; + if (file.text === null) + next["$schema"] = MCP_SCHEMA_URL; + const entry = { + ...typeof existing === "object" && existing !== null && !Array.isArray(existing) ? existing : {}, + type: "stdio", + command, + args: [] + }; + next["mcpServers"] = { ...servers, [SERVER_NAME]: entry }; + await mkdir2(path3.dirname(file.path), { recursive: true }); + await writeDurably(file.path, render(next, file)); + return { ok: true, change: file.text === null ? "created" : "updated" }; +} +async function removeEntry(host, wroteCommand) { + const read = await readMcpFile(host); + if (!read.ok) + return read; + const { file } = read; + if (file.text === null) + return { ok: true, change: "absent" }; + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + if (existing === undefined) + return { ok: true, change: "absent" }; + const currentCommand = commandOf(existing); + const ours = wroteCommand !== undefined && currentCommand === wroteCommand || currentCommand !== undefined && insideManagedBinRoot(host, currentCommand); + if (!ours) { + return { + ok: false, + reason: `${file.path} defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + `which is not what this package wrote (${wroteCommand ?? "nothing recorded"}). It was left in place.` + }; + } + const remaining = { ...servers }; + delete remaining[SERVER_NAME]; + const others = Object.keys(file.document).filter((key) => key !== "mcpServers"); + const ourCreation = others.length === 1 && others[0] === "$schema" && file.document["$schema"] === MCP_SCHEMA_URL; + if (Object.keys(remaining).length === 0 && ourCreation) { + await rm2(file.path, { force: true }); + return { ok: true, change: "removed" }; + } + const next = { ...file.document, mcpServers: remaining }; + await writeDurably(file.path, render(next, file)); + return { ok: true, change: "removed" }; +} +async function entryStatus(host, resolvedCommand) { + const read = await readMcpFile(host); + if (!read.ok) { + return { path: mcpConfigPath(host), present: false, current: false, problem: read.reason }; + } + const existing = serverMap(read.file.document)[SERVER_NAME]; + if (existing === undefined) { + return { path: read.file.path, present: false, current: false }; + } + const command = commandOf(existing); + return { + path: read.file.path, + present: true, + ...command === undefined ? {} : { command }, + current: command !== undefined && command === resolvedCommand + }; +} +function serverMap(document) { + const servers = document["mcpServers"]; + return typeof servers === "object" && servers !== null && !Array.isArray(servers) ? servers : {}; +} +function isCurrentEntry(entry, command) { + if (commandOf(entry) !== command) + return false; + const record = entry; + const args = record["args"]; + return record["type"] === "stdio" && Array.isArray(args) && args.length === 0; +} +function commandOf(entry) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) + return; + const command = entry["command"]; + return typeof command === "string" ? command : undefined; +} +function detectIndent(text) { + const match = /\n([ \t]+)"/u.exec(text); + return match?.[1] ?? " "; +} +function render(document, file) { + const body = JSON.stringify(document, null, file.indent); + return file.trailingNewline ? `${body} +` : body; +} +async function writeDurably(file, contents) { + const staging = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + let mode = 384; + try { + mode = (await stat(file)).mode & 511; + } catch (error) { + const code = error?.code; + if (code !== "ENOENT") + throw error; + } + await Bun.write(staging, contents); + await chmod2(staging, mode); + await rename2(staging, file); + } catch (error) { + await rm2(staging, { force: true }); + throw error; + } +} + +// src/state.ts +import { randomUUID as randomUUID2 } from "crypto"; +import { chmod as chmod3, mkdir as mkdir3, rename as rename3, rm as rm3, stat as stat2 } from "fs/promises"; +import path4 from "path"; +var EMPTY = {}; +async function readState(host) { + const file = Bun.file(statePath(host)); + let text; + try { + text = await file.text(); + } catch { + return EMPTY; + } + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return EMPTY; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return EMPTY; + const record = parsed; + const state = {}; + for (const key of ["managedVersion", "managedDigest", "pin", "upstreamVersion", "wroteCommand"]) { + const value = record[key]; + if (typeof value === "string" && value !== "") + state[key] = value; + } + const lastCheckedAt = record["lastCheckedAt"]; + if (typeof lastCheckedAt === "number" && Number.isFinite(lastCheckedAt)) { + state["lastCheckedAt"] = lastCheckedAt; + } + return state; +} +async function writeState(host, next) { + const file = statePath(host); + await mkdir3(path4.dirname(file), { recursive: true }); + const staging = `${file}.${process.pid}.${randomUUID2()}.tmp`; + try { + let mode = 384; + try { + mode = (await stat2(file)).mode & 511; + } catch (error) { + const code = error?.code; + if (code !== "ENOENT") + throw error; + } + await Bun.write(staging, `${JSON.stringify(next, null, 2)} +`); + await chmod3(staging, mode); + await rename3(staging, file); + } catch (error) { + await rm3(staging, { force: true }); + throw error; + } +} +async function updateState(host, patch) { + const next = { ...await readState(host), ...patch }; + await writeState(host, next); + return next; +} + +// src/resolve.ts +import path5 from "path"; +var NO_EXECUTABLE_REASON = `no ${EXECUTABLE_NAME} executable found on PATH, in ~/.local/bin, or under this package's own root. ` + "Run /cbm install to download a managed copy, or install it yourself with " + "`curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash` " + "and this package will adopt it."; +async function managedCopy(host, state) { + const recorded = (state ?? await readState(host)).managedVersion; + if (recorded === undefined) + return null; + const executable = managedExecutable(host, recorded); + return await Bun.file(executable).exists() ? { version: recorded, executable } : null; +} +async function resolveExecutable(host, state) { + const current = state ?? await readState(host); + const pin = current.pin; + if (pin !== undefined) { + const pinned = managedExecutable(host, pin); + if (await Bun.file(pinned).exists()) { + return { ok: true, resolved: { executable: pinned, source: "pin", origin: pin } }; + } + } + const onPath = Bun.which(EXECUTABLE_NAME, pathOption(host)); + if (onPath !== null) { + return { + ok: true, + resolved: { executable: path5.resolve(onPath), source: "system", origin: "PATH" } + }; + } + const upstream = path5.join(upstreamInstallDir(host), EXECUTABLE_NAME); + if (await Bun.file(upstream).exists()) { + return { + ok: true, + resolved: { executable: upstream, source: "system", origin: "~/.local/bin" } + }; + } + const managed = await managedCopy(host, current); + if (managed !== null) { + return { + ok: true, + resolved: { + executable: managed.executable, + source: "managed", + origin: path5.join(path5.basename(managedBinRoot(host)), managed.version) + } + }; + } + return { ok: false, reason: NO_EXECUTABLE_REASON }; +} +async function resolvedVersion(resolved) { + return await readVersion(resolved.executable); +} +function pathOption(host) { + return { PATH: host.env["PATH"] ?? "" }; +} + +// src/lifecycle.ts +var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +async function status(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + const resolved = resolution.ok ? resolution.resolved : null; + const lines = []; + if (!resolution.ok) { + lines.push(`executable: none (${resolution.reason})`); + } else { + const version = await resolvedVersion(resolution.resolved) ?? "unknown (it did not run)"; + lines.push(`executable: ${resolution.resolved.executable}`); + lines.push(`source: ${resolution.resolved.source} (${resolution.resolved.origin})`); + lines.push(`version: ${version}`); + } + const managed = await managedCopy(host, state); + lines.push(managed === null ? "managed: none under this package's root" : `managed: ${managed.version} at ${managed.executable}` + (resolved?.executable === managed.executable ? "" : " (present, not resolved)")); + lines.push(`upstream: ${state.upstreamVersion ?? "not checked yet"}`); + lines.push(`pin: ${state.pin ?? "none"}`); + lines.push(`agent dir: ${agentDir(host)}`); + const entry = await entryStatus(host, resolved?.executable ?? null); + if (entry.problem !== undefined) { + lines.push(`mcp entry: unreadable -- ${entry.problem}`); + } else if (!entry.present) { + lines.push(`mcp entry: absent from ${entry.path}`); + } else { + lines.push(`mcp entry: ${entry.current ? "current" : `stale, names ${entry.command ?? "(no command)"}`} in ${entry.path}`); + } + return { lines, resolved }; +} +async function installHazard(lifecycle) { + const resolution = await resolveExecutable(lifecycle.host, await readState(lifecycle.host)); + if (!resolution.ok || resolution.resolved.source !== "system") + return null; + return `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}). ` + "CBM resolves one canonical cache root per account and refuses to run when a process is " + "configured with a different root while another CBM session is active, so a second executable " + "of a different version produces mismatched index generations. Adopting the installation you " + "already have is the safe default."; +} +async function confirmedInstall(lifecycle, version, confirmer) { + const hazard = await installHazard(lifecycle); + if (hazard === null) + return await install(lifecycle, version); + if (!confirmer.available) { + return { + ok: false, + message: `${hazard} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.` + }; + } + const confirmed = await confirmer.ask("Install a second codebase-memory-mcp?", hazard); + return confirmed ? await install(lifecycle, version) : { ok: true, message: `${hazard} Nothing was downloaded.` }; +} +async function install(lifecycle, version) { + const { host } = lifecycle; + let acquired; + try { + acquired = await acquire({ + host, + target: lifecycle.target, + source: lifecycle.source, + ...version === undefined ? {} : { version: normalizeVersion(version) } + }); + } catch (error) { + return { ok: false, message: `install failed: ${describe(error)}` }; + } + const state = await updateState(host, { + managedVersion: acquired.version, + managedDigest: acquired.digest, + ...version === undefined ? { upstreamVersion: acquired.version, lastCheckedAt: Date.now() } : {} + }); + const resolution = await resolveExecutable(host, state); + if (!resolution.ok) { + return { + ok: false, + message: `adopted ${acquired.version} at ${acquired.executable}, but resolution then found nothing: ${resolution.reason}` + }; + } + const wiring = await wire(lifecycle, resolution.resolved, state); + const adopted = resolution.resolved.executable === acquired.executable ? `adopted ${acquired.version} (${acquired.reportedVersion}) at ${acquired.executable}` : `adopted ${acquired.version} at ${acquired.executable}, but resolution prefers ` + `${resolution.resolved.executable} (${resolution.resolved.source})`; + return { ok: true, message: `${adopted}. ${wiring.message}` }; +} +async function update(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + if (resolution.ok && resolution.resolved.source === "system") { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: `${resolution.resolved.executable} is a system installation this package adopted, so it is not replaced here. ` + `Run \`${resolution.resolved.executable} update\` to update it. ${check.message}` + }; + } + if (state.pin !== undefined) { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: `version ${state.pin} is pinned, so nothing was adopted. ${check.message} Run /cbm unpin to release it.` + }; + } + return await install(lifecycle); +} +async function pin(lifecycle, version) { + let normalized; + try { + normalized = normalizeVersion(version); + } catch (error) { + return { ok: false, message: `pin failed: ${describe(error)}` }; + } + await updateState(lifecycle.host, { pin: normalized }); + const managed = await managedCopy(lifecycle.host); + const note = managed?.version === normalized ? "It is already on disk, so resolution will prefer it." : `No managed copy of ${normalized} is on disk yet; run \`/cbm install ${normalized}\` to place one.`; + return { ok: true, message: `pinned version ${normalized}. ${note}` }; +} +async function unpin(lifecycle) { + const state = await readState(lifecycle.host); + if (state.pin === undefined) + return { ok: true, message: "no version was pinned." }; + const { pin: _released, ...remaining } = state; + await writeState(lifecycle.host, remaining); + return { ok: true, message: `released the pin on version ${state.pin}.` }; +} +async function uninstall(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const removal = await removeEntry(host, state.wroteCommand); + if (!removal.ok && await wouldDangle(host)) { + return { + ok: false, + message: `left the MCP entry alone: ${removal.reason} The managed copy and this package's state were ` + "kept with it, so nothing is left naming a file this command deleted. Resolve that, then run " + "/cbm uninstall again." + }; + } + const entryMessage = removal.ok ? removal.change === "removed" ? "removed the owned MCP entry" : "there was no owned MCP entry to remove" : `left the MCP entry alone: ${removal.reason}`; + const root = packageRoot(host); + const managed = await managedCopy(host, state); + await rm4(root, { recursive: true, force: true }); + const copyMessage = managed === null ? "no managed copy was present" : `removed the managed copy of ${managed.version}`; + const systemNote = await systemStillPresent(host); + return { ok: true, message: `${copyMessage}, ${entryMessage}, and deleted ${root}.${systemNote}` }; +} +async function syncEntry(lifecycle) { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + if (!resolution.ok) { + return { + kind: "unresolved", + message: `${resolution.reason} No MCP entry was written or changed.` + }; + } + const before = await entryStatus(host, resolution.resolved.executable); + const outcome = await wire(lifecycle, resolution.resolved, state); + if (!outcome.ok) + return { kind: "refused", message: outcome.message }; + if (!before.present) + return { kind: "wired", message: outcome.message }; + return before.current ? { kind: "unchanged", message: outcome.message } : { kind: "rewired", message: outcome.message }; +} +async function checkUpstream(lifecycle, options = {}) { + const { host } = lifecycle; + const now = options.now ?? Date.now(); + const state = await readState(host); + const age = state.lastCheckedAt === undefined ? undefined : now - state.lastCheckedAt; + if (options.force !== true && age !== undefined && age >= 0 && age < CHECK_INTERVAL_MS) { + return { + kind: "skipped", + message: `upstream was last checked ${Math.round(age / 60000)} minutes ago; skipping.` + }; + } + let upstream; + try { + upstream = normalizeVersion(await lifecycle.source.latestTag()); + } catch (error) { + await updateState(host, { lastCheckedAt: now }); + return { kind: "failed", message: `upstream version check failed: ${describe(error)}` }; + } + const next = await updateState(host, { upstreamVersion: upstream, lastCheckedAt: now }); + const resolution = await resolveExecutable(host, next); + const local = resolution.ok ? await resolvedVersion(resolution.resolved) : null; + if (local !== null && local.includes(upstream)) { + return { kind: "current", message: `upstream ${upstream} matches the local executable.` }; + } + const remedy = !resolution.ok ? "Run /cbm install to place a managed copy." : next.pin !== undefined ? `Version ${next.pin} is pinned, so nothing will be adopted. Run /cbm unpin to release it.` : resolution.resolved.source === "system" ? `Run \`${resolution.resolved.executable} update\` to update the system installation.` : "Run /cbm update to adopt it."; + return { + kind: "newer", + message: `upstream release is ${upstream}; local is ${local ?? "unknown"}. ${remedy}` + }; +} +async function wire(lifecycle, resolved, state) { + const outcome = await upsertEntry(lifecycle.host, resolved.executable, state.wroteCommand); + if (!outcome.ok) + return { ok: false, message: outcome.reason }; + if (state.wroteCommand !== resolved.executable) { + await updateState(lifecycle.host, { wroteCommand: resolved.executable }); + } + switch (outcome.change) { + case "created": + return { ok: true, message: `Wrote the MCP entry naming ${resolved.executable}.` }; + case "updated": + return { + ok: true, + message: `Corrected the MCP entry to ${resolved.executable}; run /mcp reload so this session picks it up.` + }; + case "unchanged": + return { ok: true, message: `The MCP entry already names ${resolved.executable}.` }; + } +} +async function wouldDangle(host) { + const entry = await entryStatus(host, null); + if (entry.problem !== undefined) + return true; + return entry.command !== undefined && insideManagedBinRoot(host, entry.command); +} +async function systemStillPresent(host) { + const resolution = await resolveExecutable(host, {}); + return resolution.ok && resolution.resolved.source === "system" ? ` The system installation at ${resolution.resolved.executable} was left in place.` : ""; +} +function describe(error) { + return error instanceof Error ? error.message : String(error); +} + +// src/index.ts +var CHECK_DELAY_MS = 20000; +var SUBCOMMANDS = [ + "status", + "install", + "update", + "pin", + "unpin", + "uninstall" +]; +function ompCodebaseMemory(pi) { + const host = processHost(); + const native = nativeExtensionPath(host); + if (existsSync(native)) { + pi.logger.info("omp-codebase-memory: standing down", { native }); + return; + } + pi.setLabel("Codebase Memory"); + let lifecycle = null; + let unsupported = null; + try { + lifecycle = { host, target: hostTarget(), source: githubReleaseSource() }; + } catch (error) { + unsupported = error instanceof UnsupportedPlatformError ? error.message : `platform detection failed: ${error instanceof Error ? error.message : String(error)}`; + } + const notified = new Set; + const notifyOnce = (ctx, message, type) => { + if (notified.has(message)) + return; + notified.add(message); + notify(ctx, message, type); + }; + const notify = (ctx, message, type) => { + try { + ctx.ui.notify(message, type); + } catch (error) { + pi.logger.error("omp-codebase-memory: notification failed", { + error: error instanceof Error ? error.message : String(error), + message + }); + } + }; + const report = (ctx, outcome) => { + notify(ctx, `/cbm: ${outcome.message}`, outcome.ok ? "info" : "error"); + }; + pi.registerCommand("cbm", { + description: "codebase-memory-mcp lifecycle: status, install, update, pin, unpin, uninstall", + getArgumentCompletions: (prefix) => { + const matches = SUBCOMMANDS.filter((name) => name.startsWith(prefix.trimStart())); + return matches.length === 0 ? null : matches.map((name) => ({ value: name, label: `/cbm ${name}` })); + }, + handler: async (args, ctx) => { + const [subcommand = "status", ...rest] = args.trim().split(/\s+/u).filter((part) => part !== ""); + if (lifecycle === null) { + notify(ctx, `/cbm: ${unsupported ?? "unavailable on this platform"}`, "error"); + return; + } + switch (subcommand) { + case "status": { + const report_ = await status(lifecycle); + notify(ctx, ["codebase-memory-mcp", ...report_.lines].join(` +`), "info"); + return; + } + case "install": + report(ctx, await confirmedInstall(lifecycle, rest[0], confirmerFrom(ctx))); + return; + case "update": + report(ctx, await update(lifecycle)); + return; + case "pin": { + const version = rest[0]; + report(ctx, version === undefined ? { ok: false, message: "pin needs a version, e.g. `/cbm pin 0.10.8`." } : await pin(lifecycle, version)); + return; + } + case "unpin": + report(ctx, await unpin(lifecycle)); + return; + case "uninstall": + report(ctx, await uninstall(lifecycle)); + return; + default: + notify(ctx, `/cbm: unknown subcommand \`${subcommand}\`. Use one of: ${SUBCOMMANDS.join(", ")}.`, "error"); + } + } + }); + const confirmerFrom = (ctx) => ({ + available: ctx.hasUI, + ask: (title, message) => ctx.ui.confirm(title, message) + }); + pi.on("session_start", async (_event, ctx) => { + if (lifecycle === null) + return; + const active = lifecycle; + try { + const sync = await syncEntry(active); + switch (sync.kind) { + case "rewired": + case "wired": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "info"); + break; + case "unresolved": + case "refused": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "warning"); + break; + case "unchanged": + break; + } + } catch (error) { + pi.logger.error("omp-codebase-memory: session start sync failed", { + error: error instanceof Error ? error.message : String(error) + }); + } + const scheduler = schedulerFrom(ctx); + scheduler.after(() => { + checkUpstream(active).then((check) => { + if (check.kind === "newer") + notifyOnce(ctx, `codebase-memory-mcp: ${check.message}`, "info"); + else + pi.logger.info("omp-codebase-memory: version check", { check: check.message }); + }).catch((error) => { + pi.logger.info("omp-codebase-memory: version check failed", { + error: error instanceof Error ? error.message : String(error) + }); + }); + }, CHECK_DELAY_MS); + }); +} +export { + ompCodebaseMemory as default +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..2c0c64d --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "omp-codebase-memory", + "version": "0.1.0", + "private": true, + "license": "MIT", + "type": "module", + "description": "OMP extension that owns the codebase-memory-mcp executable lifecycle and wires exactly one MCP server entry into OMP's native user configuration.", + "repository": { + "type": "git", + "url": "git+https://github.com/pashifika/omp-codebase-memory.git" + }, + "omp": { + "extensions": [ + "./dist/index.js" + ] + }, + "scripts": { + "build": "bun build src/index.ts --target=bun --format=esm --outfile=dist/index.js", + "test": "bun test", + "test:unit": "bun test test/unit", + "test:packaging": "bun run build && bun test test/packaging", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "18.0.8", + "@types/bun": "1.3.14", + "typescript": "5.9.3" + } +} diff --git a/src/acquire.ts b/src/acquire.ts new file mode 100644 index 0000000..ac5237c --- /dev/null +++ b/src/acquire.ts @@ -0,0 +1,345 @@ +import type { Stats } from "node:fs"; +import { chmod, lstat, mkdtemp, mkdir, rename, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { haveTool, readVersion, run } from "./exec.ts"; +import { managedBinRoot, type Host } from "./paths.ts"; +import { parseChecksums, type ReleaseSource } from "./release.ts"; +import { UnsupportedPlatformError, type Target } from "./platform.ts"; + +/** + * Acquisition: everything between "there is a release" and "this package owns a + * verified executable". + * + * The sequence reproduces upstream's `install.sh` step for step. Each step is + * here because that installer has it, and each is a refusal rather than a + * warning, because a step that merely warns is a verification property that has + * been removed while still appearing to be present. + * + * Nothing is written under the package-owned root until every step has passed: + * the candidate lives in a temporary directory, so a failure leaves whatever + * executable was resolved before still resolved. + */ + +/** What a completed acquisition produced. */ +export interface Acquired { + /** The version adopted, which is also its directory name under `bin/`. */ + readonly version: string; + /** The archive digest it was verified against. */ + readonly digest: string; + /** The adopted executable's absolute path. */ + readonly executable: string; + /** What the adopted executable reports for `--version`. */ + readonly reportedVersion: string; +} + +export interface AcquireRequest { + readonly host: Host; + readonly target: Target; + readonly source: ReleaseSource; + /** + * The version to acquire. Omitted means the newest release, resolved through + * the `releases/latest` redirect. + */ + readonly version?: string; +} + +/** + * A version string that is safe as a URL segment and a directory name. + * + * Validated rather than trusted because the value can arrive from a redirect's + * `location` or from an operator's `/cbm install 0.10.8`, and it is used for + * both. Refusing `..` and separators here is what keeps a release tag from + * naming a directory outside `bin/`. + */ +const VERSION_PATTERN = /^[0-9][0-9A-Za-z.+-]*$/u; + +/** `v0.10.8` and `0.10.8` both mean version `0.10.8`. */ +export function normalizeVersion(value: string): string { + const trimmed = value.trim().replace(/^v/iu, ""); + if (!VERSION_PATTERN.test(trimmed)) { + throw new Error(`not a usable version: ${value}`); + } + return trimmed; +} + +/** The release tag for a normalized version. */ +export function tagFor(version: string): string { + return `v${version}`; +} + +/** + * Downloads, verifies, and adopts one release. + * + * @throws when any verification step fails, having written nothing under the + * package-owned root. + */ +export async function acquire(request: AcquireRequest): Promise { + const { host, target, source } = request; + + if (target.container !== "tar.gz") { + throw new UnsupportedPlatformError( + `cannot extract a ${target.container} archive; only tar.gz is implemented`, + ); + } + if (!haveTool("tar", host.env["PATH"])) { + throw new Error("tar is required to extract the release archive, and is not on PATH"); + } + + const version = + request.version === undefined + ? normalizeVersion(await source.latestTag()) + : normalizeVersion(request.version); + const tag = tagFor(version); + + const expected = parseChecksums(await source.checksums(tag), target.archive); + const bytes = await source.asset(tag, target.archive); + + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(bytes); + const actual = hasher.digest("hex"); + if (actual !== expected) { + throw new Error( + `SHA-256 mismatch for ${target.archive} at ${tag}: published ${expected}, downloaded ${actual}`, + ); + } + + const scratch = await mkdtemp(path.join(tmpdir(), "omp-codebase-memory-")); + try { + const archive = path.join(scratch, target.archive); + await Bun.write(archive, bytes); + + await assertArchiveMembers(archive, target); + await extract(archive, scratch, target); + + const candidate = path.join(scratch, target.executable); + await chmod(candidate, 0o755); + + if (target.os === "darwin") await repairMacOsSignature(host, candidate); + + const reportedVersion = await smokeCheck(candidate, target); + return await adopt(host, { version, digest: expected, candidate, reportedVersion }); + } finally { + // Swallowed rather than awaited into the result: by the time this runs the + // adoption has either committed or thrown, and letting a cleanup rejection + // replace a completed adoption's return value would report a working + // managed copy as a failed acquisition. + await rm(scratch, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * Refuses any archive whose member list is not exactly the expected four. + * + * Enumerated before extraction, not after, because the point is to never write + * an unexpected member to disk. Upstream's installer does the same, and treats + * an extra member as a release-integrity failure rather than a sidecar to + * ignore -- a fifth member means the archive is not the artifact this code + * knows how to verify, whatever it contains. + */ +export async function assertArchiveMembers(archive: string, target: Target): Promise { + const listed = await run(["tar", "-tzf", archive]); + if (!listed.ok) { + throw new Error( + `could not enumerate ${path.basename(archive)}: ${listed.stderr.trim() || listed.spawnError || `tar exited ${listed.exitCode}`}`, + ); + } + + // `split("\n")` yields one empty trailing record for the final newline, and + // that record is the delimiter rather than a member. Every other record is + // accounted for or refused: the previous normalization ran `trim()` and + // stripped trailing slashes first, which collapsed the real tar spellings + // `./`, `/`, and a whitespace-only name to the empty string and skipped + // them -- and a record skipped here is a hole in a closed set, because it + // reaches extraction without ever having been compared to the allowlist. + const records = listed.stdout.split("\n"); + if (records[records.length - 1] === "") records.pop(); + + const seen = new Map(); + for (const raw of records) { + // The `./name` spelling is the same member as `name`; nothing else is + // normalized, so a trailing slash, surrounding whitespace, or a bare `.` + // stays a name the allowlist does not hold. + const member = raw.startsWith("./") ? raw.slice(2) : raw; + if (!target.members.includes(member)) { + // Quoted, because the records this refusal exists to catch are `/`, `./` + // and a whitespace-only name, and unquoted those name nothing at all. + throw new Error(`release archive contains unexpected member: ${JSON.stringify(raw)}`); + } + seen.set(member, (seen.get(member) ?? 0) + 1); + } + + for (const member of target.members) { + const count = seen.get(member) ?? 0; + if (count === 1) continue; + throw new Error( + count === 0 + ? `release archive is missing member: ${member}` + : `release archive contains member ${member} ${count} times`, + ); + } +} + +/** + * Extracts into `into` and requires every member to be a plain regular file. + * + * `--no-same-owner` matches upstream's installer: without it a root extraction + * would honour the ownership recorded in the archive. The symlink check is + * separate from the member-name check above because a name can be in the closed + * set while the entry it names is a link -- and a link is how an extraction is + * made to write, or to be read from, somewhere it was never listed as touching. + */ +async function extract(archive: string, into: string, target: Target): Promise { + const extracted = await run(["tar", "--no-same-owner", "-xzf", archive, "-C", into]); + if (!extracted.ok) { + throw new Error( + `could not extract ${path.basename(archive)}: ${extracted.stderr.trim() || extracted.spawnError || `tar exited ${extracted.exitCode}`}`, + ); + } + + for (const member of target.members) { + const entry = path.join(into, member); + let stats: Stats; + try { + stats = await lstat(entry); + } catch { + throw new Error(`release member is missing after extraction: ${member}`); + } + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`release member is not a regular file: ${member}`); + } + } +} + +/** The commands an operator can run by hand to repair a quarantined binary. */ +function repairHint(candidate: string): string { + return `xattr -cr ${candidate} && codesign --force --sign - ${candidate}`; +} + +/** + * Removes the quarantine attribute and applies an ad-hoc signature. + * + * Both are prerequisites rather than optional polish: an unsigned Mach-O binary + * downloaded from the network is refused by Gatekeeper, and the resulting + * failure is a kill signal with no message that explains it. Upstream's + * installer does both and ignores their failures; this one reports a *missing + * tool* by name, because that is a diagnosable host problem. + * + * Exported so a test can reach the two refusals: `run` resolves `xattr` and + * `codesign` from the process's own `PATH` rather than {@link Host}'s, so + * neither can be substituted, and the argument is the only thing a test + * controls. + */ +export async function repairMacOsSignature(host: Host, candidate: string): Promise { + const pathEnv = host.env["PATH"]; + for (const tool of ["xattr", "codesign"] as const) { + if (!haveTool(tool, pathEnv)) { + throw new Error( + `${tool} is required to prepare a macOS binary and is not on PATH; ` + + `install the Xcode command line tools, or repair the candidate yourself with \`${repairHint(candidate)}\``, + ); + } + } + + // `-cr` rather than `-d com.apple.quarantine`: clearing nothing is an exit-0 + // success, so the normal case -- an archive fetched over HTTPS was never + // quarantined and has no attribute to remove -- no longer has to be told + // apart from a real failure by matching `xattr`'s stderr. That distinction + // was not being made at all: the result was discarded, so a non-zero exit, a + // timeout, and an unspawnable `xattr` were all read as "the attribute was not + // there", and quarantine removal was reported as done without having run. It + // is also exactly what `repairHint` tells the operator to run. + const cleared = await run(["xattr", "-cr", candidate], { timeoutMs: 10_000 }); + if (!cleared.ok) { + throw new Error( + `could not clear the candidate's extended attributes: ${cleared.stderr.trim() || cleared.spawnError || `xattr exited ${cleared.exitCode}`}. ` + + `Repair it by hand with \`${repairHint(candidate)}\``, + ); + } + + const signed = await run(["codesign", "--sign", "-", "--force", candidate], { + timeoutMs: 60_000, + }); + if (!signed.ok) { + throw new Error( + `could not ad-hoc sign the candidate: ${signed.stderr.trim() || `codesign exited ${signed.exitCode}`}. ` + + `Repair it by hand with \`${repairHint(candidate)}\``, + ); + } +} + +/** + * Runs the candidate before anything depends on it. + * + * This is the step that catches a build for the wrong glibc, a truncated + * download that still hashed correctly because the digest file was also + * substituted, and a macOS binary the repair above did not fix. On macOS the + * failure names the repair commands, because that is overwhelmingly the cause + * and the operator can act on it directly. + */ +async function smokeCheck(candidate: string, target: Target): Promise { + const reported = await readVersion(candidate); + if (reported !== null) return reported; + + const suffix = + target.os === "darwin" + ? ` If macOS is refusing to run it, try \`${repairHint(candidate)}\`.` + : ""; + throw new Error(`the downloaded executable failed to run \`--version\`.${suffix}`); +} + +/** + * Places the verified candidate under the package-owned root. + * + * The first write outside the temporary directory happens here, after every + * check has passed. The copy is written and made executable inside a unique + * staging directory, and one `rename` onto the final path is the operation's + * only commit point. Writing straight to `bin//` cannot offer that: + * re-adopting the version currently resolved would rewrite the live executable, + * so a failed `chmod` behind that write would leave the previously resolved + * executable truncated or unrunnable -- and the requirement is that every + * failure leaves it in use. + * + * The staging directory sits under `bin/` rather than in `$TMPDIR` because + * `rename` is atomic only within one filesystem, and `$TMPDIR` is routinely on + * another one. `bin//` itself is created before the rename, but an + * empty directory is not a resolvable executable, so nothing can observe it as + * a half-finished adoption. + * + * The previous version's directory is left in place: it is what resolution + * falls back to if this adoption's pointer update does not land, and removing + * it would make a failed update worse than a skipped one. + */ +async function adopt( + host: Host, + candidate: { version: string; digest: string; candidate: string; reportedVersion: string }, +): Promise { + const binRoot = managedBinRoot(host); + const destination = path.join(binRoot, candidate.version); + const name = path.basename(candidate.candidate); + const executable = path.join(destination, name); + + await mkdir(binRoot, { recursive: true }); + const staging = await mkdtemp(path.join(binRoot, ".staging-")); + try { + const staged = path.join(staging, name); + await Bun.write(staged, Bun.file(candidate.candidate)); + await chmod(staged, 0o755); + await mkdir(destination, { recursive: true }); + await rename(staged, executable); + } finally { + // Before the rename this removes a candidate nothing has seen; after it, + // an empty directory. Either way a failure to remove it is not this + // operation's result -- swallowing it is what keeps a committed adoption + // from being reported as a failed one. + await rm(staging, { recursive: true, force: true }).catch(() => {}); + } + + return { + version: candidate.version, + digest: candidate.digest, + executable, + reportedVersion: candidate.reportedVersion, + }; +} diff --git a/src/exec.ts b/src/exec.ts new file mode 100644 index 0000000..d5f032c --- /dev/null +++ b/src/exec.ts @@ -0,0 +1,289 @@ +/** + * The one place this package starts a subprocess. + * + * Acquisition needs `tar`, macOS repair needs `xattr` and `codesign`, and + * resolution needs the candidate's own `--version`. All four go through + * {@link run}, so the timeout, the output capture, and the "is this tool even + * installed" question have one answer rather than four. + */ + +/** Default per-process deadline. Nothing here should take longer. */ +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Per-stream capture cap. + * + * `timeout` bounds how long a child runs, not how much it writes, and both + * pipes are read into this process's memory. The bound has to clear the largest + * output any real invocation produces, and those are tiny: a `tar -tzf` listing + * of a release archive is four names, `--version` is one line, and `codesign`'s + * complaints are a sentence. A quarter of a megabyte is three orders of + * magnitude of headroom over all of them, and small enough that an over-cap + * stream is still quotable in the refusal it causes. + */ +export const OUTPUT_LIMIT_BYTES = 262_144; + +export interface RunResult { + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + /** + * Set when the result is not the process's own answer: it could not be + * started at all (a missing tool), it wrote past + * {@link OUTPUT_LIMIT_BYTES}, or it did not finish inside its deadline. + * In every case the captured output is a fragment and must not be parsed. + */ + readonly spawnError?: string; +} + +export interface RunOptions { + readonly timeoutMs?: number; + readonly cwd?: string; +} + +/** One captured pipe: its text, and whether the cap cut it short. */ +interface Captured { + readonly text: string; + readonly overflowed: boolean; +} + +/** A pipe being drained, and the handle that stops the drain. */ +interface Pipe { + /** Settles when the pipe closes, the cap is reached, or `release` is called. */ + readonly captured: Promise; + /** + * Cancels the reader, which settles a pending `read()` as `done` and so + * settles {@link Pipe.captured} with whatever had arrived. Needed because a + * pipe is closed by its *last* writer: a descendant that inherited it holds + * the read open after the child this code spawned is gone, so the read has + * to be abandonable rather than merely awaited. + */ + release(): void; +} + +/** + * Starts draining one pipe, capped at {@link OUTPUT_LIMIT_BYTES}. + * + * Read chunk by chunk rather than through `new Response(stream).text()`, which + * allocates the whole stream before anything can weigh it. The last chunk is + * clipped to the cap rather than kept whole, so the retained fragment is + * exactly bounded by the constant the refusal names. + * + * The reader never leaves this function: `child.stdout`'s reader type differs + * between the platform and `node:stream/web` declarations, and inference is + * what keeps that difference from having to be named. + */ +function capture(stream: ReadableStream | undefined, onOverflow: () => void): Pipe { + if (stream === undefined) { + return { captured: Promise.resolve({ text: "", overflowed: false }), release: () => {} }; + } + + const reader = stream.getReader(); + const drain = async (): Promise => { + const chunks: Uint8Array[] = []; + let total = 0; + let overflowed = false; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + const room = OUTPUT_LIMIT_BYTES - total; + // `>` rather than `>=`: a stream whose total lands exactly on the cap + // has not overflowed, and the next read settles which it was. + if (value.byteLength > room) { + chunks.push(value.subarray(0, room)); + total = OUTPUT_LIMIT_BYTES; + overflowed = true; + onOverflow(); + break; + } + chunks.push(value); + total += value.byteLength; + } + } finally { + await reader.cancel().catch(() => {}); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(bytes), overflowed }; + }; + + return { + captured: drain(), + release: () => { + void reader.cancel().catch(() => {}); + }, + }; +} + +/** + * Whether `deadline` fired before `work` settled. + * + * `work` is handed back to the caller rather than discarded, so the caller can + * release the readers and then await the same promise: a `Promise.race` against + * a rejecting timer would abandon it still pending. + * + * Built on {@link AbortSignal.timeout} rather than `setTimeout`, for the reason + * `src/scheduler.ts` gives: a raw timer callback that throws escapes handler + * dispatch and takes the session with it. There is no callback here, and + * `src/release.ts` already bounds a request the same way. + */ +async function deadlineWon(work: Promise, deadline: AbortSignal): Promise { + if (deadline.aborted) return true; + const expired = new Promise((resolve) => { + deadline.addEventListener("abort", () => resolve(true), { once: true }); + }); + return await Promise.race([work.then(() => false), expired]); +} + +/** + * Runs `argv` and captures its output. + * + * Never throws for a non-zero exit or a missing executable: both are ordinary + * outcomes here -- a candidate that will not run, a host without `xattr` -- and + * each caller reports them differently. A thrown spawn failure is folded into + * the result as {@link RunResult.spawnError} so the distinction survives, and + * so are the two bounds: {@link OUTPUT_LIMIT_BYTES} and the deadline. + * + * The deadline is authoritative rather than advisory. Draining both pipes + * before observing `child.exited` made it advisory, because a pipe closes when + * its *last* writer does: `sh -c "(sleep 2) & printf ok"` returns its direct + * child immediately and leaves a descendant holding the read, and the read was + * awaited unconditionally. Measured before the fix: 2014 ms against a 100 ms + * timeout, reported as a success. + * + * So the deadline does two things rather than one. It reaps the child's process + * group, which is where an ordinary descendant is, *and* it releases the + * readers. Both are needed: a descendant that starts its own session -- a + * double fork, or another `detached` spawn -- leaves that group and survives + * the reap while still holding the pipe, and there is no portable way to find + * it. Releasing the readers is what makes the bound hold anyway, rather than + * pretending such a descendant is gone. + */ +export async function run(argv: readonly string[], options: RunOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const name = argv[0] ?? "the process"; + try { + // One deadline object, not two: `Bun.spawn` kills the direct child when the + // signal fires, and the same signal is what stops this side waiting on a + // read nothing in the child controls any more. + const deadline = AbortSignal.timeout(timeoutMs); + const child = Bun.spawn([...argv], { + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + signal: deadline, + // `setsid`, so the child leads its own process group and the descendants + // that inherited its pipes can be reaped with it. Without this the + // backgrounded `sleep` above survives every signal reachable from + // `child`. + detached: true, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + }); + + /** + * Signals the child's whole process group, then the child itself. + * + * The deadline path passes SIGKILL, which is why `Bun.spawn` keeps its + * default `killSignal` of SIGTERM rather than being given one: a child + * doing `trap '' TERM` outlives Bun's own kill and is ended here instead. + * Measured both ways -- that child comes back with exit 137, and adding + * `killSignal: "SIGKILL"` changes nothing any test can see, while + * downgrading this call to SIGTERM makes it survive its deadline. + */ + const reap = (signal: NodeJS.Signals): void => { + try { + // Negative pid: the group, which `detached` made this child's own and + // which is therefore the only group this can reach. + process.kill(-child.pid, signal); + } catch { + // ESRCH -- the group is already empty, which is the desired state. + } + child.kill(signal); + }; + + // Killed on overflow rather than merely left unread: a child whose pipe has + // stopped being drained blocks on its next write, so the cap has to end the + // process, not just the reading of it. + const stopFlood = (): void => { + reap("SIGTERM"); + }; + const stdout = capture(child.stdout, stopFlood); + const stderr = capture(child.stderr, stopFlood); + const drained = Promise.all([stdout.captured, stderr.captured]); + + // Both halves, because either one alone leaves a hole. Racing only the + // drain lets a child that hands its pipes back and keeps running set its + // own duration -- `exec 1>/dev/null 2>/dev/null; trap '' TERM; sleep 2` + // settled the reads at once and then took 2012 ms, reported as a success. + // Racing only the exit lets a descendant that inherited a pipe hold the + // read open after the child is gone. + const overran = await deadlineWon(Promise.all([drained, child.exited]), deadline); + if (overran) { + reap("SIGKILL"); + stdout.release(); + stderr.release(); + } + const [out, err] = await drained; + const exitCode = await child.exited; + + if (overran) { + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} did not finish within ${timeoutMs}ms and was killed`, + }; + } + if (out.overflowed || err.overflowed) { + const flooded = out.overflowed ? "stdout" : "stderr"; + return { + ok: false, + exitCode, + stdout: out.text, + stderr: err.text, + spawnError: `${name} wrote more than ${OUTPUT_LIMIT_BYTES} bytes to ${flooded} and was killed`, + }; + } + return { ok: exitCode === 0, exitCode, stdout: out.text, stderr: err.text }; + } catch (error) { + return { + ok: false, + exitCode: -1, + stdout: "", + stderr: "", + spawnError: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * The version string an executable reports, or `null` when it will not run. + * + * `null` rather than a throw because every caller treats "will not run" as a + * fact about the executable rather than an error in this package: resolution + * reports it, acquisition refuses to adopt over it. + */ +export async function readVersion(executable: string): Promise { + const result = await run([executable, "--version"], { timeoutMs: 10_000 }); + if (!result.ok) return null; + const reported = `${result.stdout}${result.stderr}`.trim(); + return reported === "" ? null : reported.split("\n")[0]?.trim() ?? null; +} + +/** + * Whether `tool` exists on `PATH`. + * + * Used to turn a missing prerequisite into a named refusal before a command is + * attempted, rather than a spawn error the operator has to decode. + */ +export function haveTool(tool: string, pathEnv: string | undefined): boolean { + return Bun.which(tool, pathEnv === undefined ? {} : { PATH: pathEnv }) !== null; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6599163 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,251 @@ +import { existsSync } from "node:fs"; + +import { hostTarget, UnsupportedPlatformError } from "./platform.ts"; +import { nativeExtensionPath, processHost } from "./paths.ts"; +import { githubReleaseSource } from "./release.ts"; +import { schedulerFrom } from "./scheduler.ts"; +import { + checkUpstream, + confirmedInstall, + pin, + status, + syncEntry, + uninstall, + unpin, + update, + type ActionReport, + type Confirmer, + type Lifecycle, +} from "./lifecycle.ts"; + +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@oh-my-pi/pi-coding-agent"; + +/** + * The extension entry: registration only, and deliberately narrow. + * + * Two things are absent on purpose. + * + * There is no `tool_call` handler. OMP treats a throwing or blocking + * `tool_call` handler as a refusal of the tool call, so a context provider + * registered there could deny an operator's `grep` because a subprocess timed + * out. A provider that adds nothing is better than one that can take something + * away, so the event is not registered at all -- and the output augmentation a + * later change adds belongs on `tool_result`, which is documented as + * middleware-style and explicitly allowed to replace content. + * + * There is no runtime action called during load. OMP wires those after the + * factory returns, and calling one here throws + * `ExtensionRuntimeNotInitializedError`. + */ + +/** + * How long after session start the version check runs. + * + * Off the blocking path by construction: session start returns, and the check + * happens later on a managed timer. Long enough that a session doing real work + * in its first seconds is not competing with a network request. + */ +const CHECK_DELAY_MS = 20_000; + +/** The subcommands `/cbm` accepts, for the help text and for completions. */ +const SUBCOMMANDS = [ + "status", + "install", + "update", + "pin", + "unpin", + "uninstall", +] as const; + +export default function ompCodebaseMemory(pi: ExtensionAPI): void { + const host = processHost(); + + // A future upstream `--clients=omp` would write its own native extension to + // this path. Extension modules are deduplicated by absolute path, so both + // would load and a later change's output augmentation would be applied twice. + // Cheap to check now; impossible to retrofit once the duplicate is in the + // field. + const native = nativeExtensionPath(host); + if (existsSync(native)) { + pi.logger.info("omp-codebase-memory: standing down", { native }); + return; + } + + pi.setLabel("Codebase Memory"); + + /** + * The lifecycle, or the reason there is none. + * + * Platform detection is the one thing that can fail before any command runs, + * and it must not fail the load: an unsupported platform is a reason to + * report on demand, not a reason for the extension to error out of a session + * it could otherwise leave alone. + */ + let lifecycle: Lifecycle | null = null; + let unsupported: string | null = null; + try { + lifecycle = { host, target: hostTarget(), source: githubReleaseSource() }; + } catch (error) { + unsupported = + error instanceof UnsupportedPlatformError + ? error.message + : `platform detection failed: ${error instanceof Error ? error.message : String(error)}`; + } + + /** Messages already shown in this session, so a repeat is not shown again. */ + const notified = new Set(); + + const notifyOnce = ( + ctx: ExtensionContext, + message: string, + type: "info" | "warning" | "error", + ): void => { + if (notified.has(message)) return; + notified.add(message); + notify(ctx, message, type); + }; + + /** + * Every notification goes through here. + * + * A UI failure is this package's problem, not the operator's: an overlay that + * cannot render must not turn a successful lifecycle action into a thrown + * error, and on `session_start` it must not surface as a failed handler. + */ + const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" | "error"): void => { + try { + ctx.ui.notify(message, type); + } catch (error) { + pi.logger.error("omp-codebase-memory: notification failed", { + error: error instanceof Error ? error.message : String(error), + message, + }); + } + }; + + /** Reports an action, or the reason no action is possible on this platform. */ + const report = (ctx: ExtensionContext, outcome: ActionReport): void => { + notify(ctx, `/cbm: ${outcome.message}`, outcome.ok ? "info" : "error"); + }; + + pi.registerCommand("cbm", { + description: "codebase-memory-mcp lifecycle: status, install, update, pin, unpin, uninstall", + getArgumentCompletions: (prefix) => { + const matches = SUBCOMMANDS.filter((name) => name.startsWith(prefix.trimStart())); + return matches.length === 0 + ? null + : matches.map((name) => ({ value: name, label: `/cbm ${name}` })); + }, + handler: async (args, ctx) => { + const [subcommand = "status", ...rest] = args.trim().split(/\s+/u).filter((part) => part !== ""); + + if (lifecycle === null) { + notify(ctx, `/cbm: ${unsupported ?? "unavailable on this platform"}`, "error"); + return; + } + + switch (subcommand) { + case "status": { + const report_ = await status(lifecycle); + notify(ctx, ["codebase-memory-mcp", ...report_.lines].join("\n"), "info"); + return; + } + case "install": + report(ctx, await confirmedInstall(lifecycle, rest[0], confirmerFrom(ctx))); + return; + case "update": + report(ctx, await update(lifecycle)); + return; + case "pin": { + const version = rest[0]; + report( + ctx, + version === undefined + ? { ok: false, message: "pin needs a version, e.g. `/cbm pin 0.10.8`." } + : await pin(lifecycle, version), + ); + return; + } + case "unpin": + report(ctx, await unpin(lifecycle)); + return; + case "uninstall": + report(ctx, await uninstall(lifecycle)); + return; + default: + notify( + ctx, + `/cbm: unknown subcommand \`${subcommand}\`. Use one of: ${SUBCOMMANDS.join(", ")}.`, + "error", + ); + } + }, + }); + + /** + * Adapts one command context to the lifecycle's confirmation seam. + * + * The decision that needs the answer -- whether a second executable is a + * hazard, what to tell the operator, and what to do when no UI exists -- lives + * in `confirmedInstall`, where a test can reach it. This is the whole of the + * translation. + */ + const confirmerFrom = (ctx: ExtensionCommandContext): Confirmer => ({ + available: ctx.hasUI, + ask: (title, message) => ctx.ui.confirm(title, message), + }); + + /** + * Session start: verify the owned entry, correct it, and never block. + * + * The version check is deferred onto a managed timer rather than awaited + * here, because a session must start at the speed of the filesystem and not + * at the speed of the network. + */ + pi.on("session_start", async (_event, ctx) => { + if (lifecycle === null) return; + const active = lifecycle; + + try { + const sync = await syncEntry(active); + switch (sync.kind) { + case "rewired": + case "wired": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "info"); + break; + case "unresolved": + case "refused": + notifyOnce(ctx, `codebase-memory-mcp: ${sync.message}`, "warning"); + break; + case "unchanged": + break; + } + } catch (error) { + // Session start is not the place to fail. Anything unexpected here is + // recorded and the session continues without a corrected entry. + pi.logger.error("omp-codebase-memory: session start sync failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + + const scheduler = schedulerFrom(ctx); + scheduler.after(() => { + void checkUpstream(active) + .then((check) => { + if (check.kind === "newer") notifyOnce(ctx, `codebase-memory-mcp: ${check.message}`, "info"); + else pi.logger.info("omp-codebase-memory: version check", { check: check.message }); + }) + .catch((error: unknown) => { + // Debug log only: a failed check must not reach the operator, and + // must not reach the session's error channel either. + pi.logger.info("omp-codebase-memory: version check failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + }, CHECK_DELAY_MS); + }); +} diff --git a/src/lifecycle.ts b/src/lifecycle.ts new file mode 100644 index 0000000..60bca3a --- /dev/null +++ b/src/lifecycle.ts @@ -0,0 +1,504 @@ +import { rm } from "node:fs/promises"; + +import { acquire, normalizeVersion, type Acquired } from "./acquire.ts"; +import { agentDir, insideManagedBinRoot, packageRoot, type Host } from "./paths.ts"; +import { entryStatus, removeEntry, upsertEntry } from "./mcp-config.ts"; +import { resolvedVersion, managedCopy, resolveExecutable, type Resolved } from "./resolve.ts"; +import { readState, updateState, writeState, type State } from "./state.ts"; +import type { ReleaseSource } from "./release.ts"; +import type { Target } from "./platform.ts"; + +/** + * The operator-visible lifecycle, composed from the layers below it. + * + * Everything here is a decision an operator can name: what resolves, what gets + * downloaded, what the MCP entry says, what a pin holds. No function here + * touches a UI -- each returns a message and, where a caller needs to branch, a + * discriminant -- so the same operation is reachable from a slash command, from + * session start, and from a test with no terminal at all. + */ + +/** What every lifecycle operation needs to reach the outside world. */ +export interface Lifecycle { + readonly host: Host; + readonly target: Target; + readonly source: ReleaseSource; +} + +/** + * How long a recorded check suppresses the next one. + * + * The check exists to tell an operator a newer release is out; it does not need + * to be fresh to the minute, and a session-start network request per session is + * a cost with no matching benefit. + */ +export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** Everything `/cbm status` reports. */ +export interface StatusReport { + readonly lines: readonly string[]; + readonly resolved: Resolved | null; +} + +/** The outcome of one lifecycle action, ready to show an operator. */ +export interface ActionReport { + readonly ok: boolean; + readonly message: string; +} + +/** What session start did about the owned entry. */ +export interface SyncReport { + readonly kind: "unchanged" | "wired" | "rewired" | "unresolved" | "refused"; + readonly message: string; +} + +/** What one rate-limited upstream check established. */ +export interface CheckReport { + readonly kind: "skipped" | "current" | "newer" | "failed"; + readonly message: string; +} + +/** + * The full resolution, with everything an operator needs to reason about it. + * + * A managed copy is reported even when it is not the resolved one, because + * "system wins over managed" is a policy whose consequences should never be + * invisible: an operator with both should be able to see both. + */ +export async function status(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + const resolved = resolution.ok ? resolution.resolved : null; + + const lines: string[] = []; + if (!resolution.ok) { + lines.push(`executable: none (${resolution.reason})`); + } else { + const version = (await resolvedVersion(resolution.resolved)) ?? "unknown (it did not run)"; + lines.push(`executable: ${resolution.resolved.executable}`); + lines.push(`source: ${resolution.resolved.source} (${resolution.resolved.origin})`); + lines.push(`version: ${version}`); + } + + const managed = await managedCopy(host, state); + lines.push( + managed === null + ? "managed: none under this package's root" + : `managed: ${managed.version} at ${managed.executable}` + + (resolved?.executable === managed.executable ? "" : " (present, not resolved)"), + ); + + lines.push(`upstream: ${state.upstreamVersion ?? "not checked yet"}`); + lines.push(`pin: ${state.pin ?? "none"}`); + lines.push(`agent dir: ${agentDir(host)}`); + + const entry = await entryStatus(host, resolved?.executable ?? null); + if (entry.problem !== undefined) { + lines.push(`mcp entry: unreadable -- ${entry.problem}`); + } else if (!entry.present) { + lines.push(`mcp entry: absent from ${entry.path}`); + } else { + lines.push( + `mcp entry: ${entry.current ? "current" : `stale, names ${entry.command ?? "(no command)"}`} in ${entry.path}`, + ); + } + + return { lines, resolved }; +} + +/** + * How a lifecycle operation asks the operator a yes/no question. + * + * An interface rather than a direct `ctx.ui.confirm` call so the decision that + * needs the answer lives here, under test, instead of in the extension entry + * where the only way to reach it is a real session. `available` is `ctx.hasUI`: + * when there is no interactive UI the operation must report why and stop, never + * block on an answer that cannot arrive. + */ +export interface Confirmer { + readonly available: boolean; + ask(title: string, message: string): Promise; +} + +/** + * The hazard `/cbm install` must explain before a second executable exists. + * + * CBM resolves one canonical per-account cache root and refuses to run when a + * process is configured with a different root while another CBM session is + * active, so two executables of different versions produce mismatched index + * generations. An operator who already has a working installation should learn + * that before the second one exists, not after. + */ +export async function installHazard(lifecycle: Lifecycle): Promise { + const resolution = await resolveExecutable(lifecycle.host, await readState(lifecycle.host)); + if (!resolution.ok || resolution.resolved.source !== "system") return null; + + return ( + `${resolution.resolved.executable} already resolves (${resolution.resolved.origin}). ` + + "CBM resolves one canonical cache root per account and refuses to run when a process is " + + "configured with a different root while another CBM session is active, so a second executable " + + "of a different version produces mismatched index generations. Adopting the installation you " + + "already have is the safe default." + ); +} + +/** + * `install`, gated on explicit confirmation when a system copy already resolves. + * + * With no interactive UI this reports the hazard and downloads nothing rather + * than waiting: no command here may block on input that cannot arrive. + */ +export async function confirmedInstall( + lifecycle: Lifecycle, + version: string | undefined, + confirmer: Confirmer, +): Promise { + const hazard = await installHazard(lifecycle); + if (hazard === null) return await install(lifecycle, version); + + if (!confirmer.available) { + return { + ok: false, + message: `${hazard} This session has no interactive UI, so the confirmation this needs cannot be asked; nothing was downloaded.`, + }; + } + + const confirmed = await confirmer.ask("Install a second codebase-memory-mcp?", hazard); + return confirmed + ? await install(lifecycle, version) + : { ok: true, message: `${hazard} Nothing was downloaded.` }; +} + +/** + * Acquires a version, adopts it, and points the MCP entry at it. + * + * The pointer is only advanced once the copy is on disk, and resolution is then + * re-run rather than assumed: the adopted path is only correct if it is what + * resolution actually returns, and a system executable appearing on `PATH` + * between the download and the pointer update legitimately changes that answer. + */ +export async function install(lifecycle: Lifecycle, version?: string): Promise { + const { host } = lifecycle; + let acquired: Acquired; + try { + acquired = await acquire({ + host, + target: lifecycle.target, + source: lifecycle.source, + ...(version === undefined ? {} : { version: normalizeVersion(version) }), + }); + } catch (error) { + return { ok: false, message: `install failed: ${describe(error)}` }; + } + + // `acquire` returns the version it was asked for, so an explicitly requested + // one is no answer about upstream: recording it would report an old build as + // the newest release and suppress the real check for a day. Only a check -- + // or an install that had to ask what the newest release is -- establishes it. + const state = await updateState(host, { + managedVersion: acquired.version, + managedDigest: acquired.digest, + ...(version === undefined + ? { upstreamVersion: acquired.version, lastCheckedAt: Date.now() } + : {}), + }); + + const resolution = await resolveExecutable(host, state); + if (!resolution.ok) { + return { + ok: false, + message: `adopted ${acquired.version} at ${acquired.executable}, but resolution then found nothing: ${resolution.reason}`, + }; + } + + const wiring = await wire(lifecycle, resolution.resolved, state); + const adopted = + resolution.resolved.executable === acquired.executable + ? `adopted ${acquired.version} (${acquired.reportedVersion}) at ${acquired.executable}` + : `adopted ${acquired.version} at ${acquired.executable}, but resolution prefers ` + + `${resolution.resolved.executable} (${resolution.resolved.source})`; + + return { ok: true, message: `${adopted}. ${wiring.message}` }; +} + +/** + * Updates a managed copy, and only reports on an adopted system one. + * + * The asymmetry is not caution for its own sake. CBM's own `install`/`update` + * drains active sessions and performs a transactional target swap; a second + * writer replacing the same file mid-swap corrupts exactly the thing the + * transaction exists to protect. So a system installation is CBM's to update, + * and this package's job is to say so. + */ +export async function update(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + + if (resolution.ok && resolution.resolved.source === "system") { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: + `${resolution.resolved.executable} is a system installation this package adopted, so it is not replaced here. ` + + `Run \`${resolution.resolved.executable} update\` to update it. ${check.message}`, + }; + } + + if (state.pin !== undefined) { + const check = await checkUpstream(lifecycle, { force: true }); + return { + ok: true, + message: `version ${state.pin} is pinned, so nothing was adopted. ${check.message} Run /cbm unpin to release it.`, + }; + } + + return await install(lifecycle); +} + +/** Records a pin so update checks report without adopting. */ +export async function pin(lifecycle: Lifecycle, version: string): Promise { + let normalized: string; + try { + normalized = normalizeVersion(version); + } catch (error) { + return { ok: false, message: `pin failed: ${describe(error)}` }; + } + + await updateState(lifecycle.host, { pin: normalized }); + const managed = await managedCopy(lifecycle.host); + const note = + managed?.version === normalized + ? "It is already on disk, so resolution will prefer it." + : `No managed copy of ${normalized} is on disk yet; run \`/cbm install ${normalized}\` to place one.`; + return { ok: true, message: `pinned version ${normalized}. ${note}` }; +} + +/** Releases a pin, leaving every managed version on disk. */ +export async function unpin(lifecycle: Lifecycle): Promise { + const state = await readState(lifecycle.host); + if (state.pin === undefined) return { ok: true, message: "no version was pinned." }; + + // Whole-document write, not a merge: a merge can only add or replace a key, + // so releasing a pin has to replace the document that held it. + const { pin: _released, ...remaining } = state; + await writeState(lifecycle.host, remaining); + return { ok: true, message: `released the pin on version ${state.pin}.` }; +} + +/** + * Removes what this package placed, and nothing else. + * + * The entry goes first, because taking it back needs the state that the second + * step deletes. An adopted system executable, CBM's cache, and every other MCP + * server in the file are left exactly as they were. + */ +export async function uninstall(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + + const removal = await removeEntry(host, state.wroteCommand); + if (!removal.ok && (await wouldDangle(host))) { + // Only a refusal whose entry names something this command is about to + // delete makes deleting it harmful: OMP would spawn the removed path at + // every session start, and the same `rm` takes the state that says the + // entry was ever this package's, so the key could never be reclaimed + // either. Keeping both halves is recoverable; keeping only the entry is not. + return { + ok: false, + message: + `left the MCP entry alone: ${removal.reason} The managed copy and this package's state were ` + + "kept with it, so nothing is left naming a file this command deleted. Resolve that, then run " + + "/cbm uninstall again.", + }; + } + + const entryMessage = removal.ok + ? removal.change === "removed" + ? "removed the owned MCP entry" + : "there was no owned MCP entry to remove" + : `left the MCP entry alone: ${removal.reason}`; + + const root = packageRoot(host); + const managed = await managedCopy(host, state); + await rm(root, { recursive: true, force: true }); + + const copyMessage = + managed === null + ? "no managed copy was present" + : `removed the managed copy of ${managed.version}`; + const systemNote = await systemStillPresent(host); + + return { ok: true, message: `${copyMessage}, ${entryMessage}, and deleted ${root}.${systemNote}` }; +} + +/** + * Verifies the owned entry against what resolution returns now, and corrects it. + * + * Correction is required rather than optional because the resolved path + * legitimately changes underneath a written entry: a managed update moves it to + * a new version directory, a system executable appearing on `PATH` is preferred + * over a managed copy, and a profile change moves the file being written. + */ +export async function syncEntry(lifecycle: Lifecycle): Promise { + const { host } = lifecycle; + const state = await readState(host); + const resolution = await resolveExecutable(host, state); + + if (!resolution.ok) { + return { + kind: "unresolved", + message: `${resolution.reason} No MCP entry was written or changed.`, + }; + } + + const before = await entryStatus(host, resolution.resolved.executable); + const outcome = await wire(lifecycle, resolution.resolved, state); + if (!outcome.ok) return { kind: "refused", message: outcome.message }; + + if (!before.present) return { kind: "wired", message: outcome.message }; + return before.current + ? { kind: "unchanged", message: outcome.message } + : { kind: "rewired", message: outcome.message }; +} + +/** + * Asks upstream what the newest release is, at most once per day. + * + * A failure is not propagated. The check is a convenience; a session that + * starts without knowing whether a newer version exists is fully functional, + * and a network that is down must not make it less so. + * + * The attempt time is recorded even when the request failed, so a host with no + * route to GitHub tries once a day rather than once a session. + */ +export async function checkUpstream( + lifecycle: Lifecycle, + options: { readonly force?: boolean; readonly now?: number } = {}, +): Promise { + const { host } = lifecycle; + const now = options.now ?? Date.now(); + const state = await readState(host); + + // Bounded on both sides, because a recorded time in the future is not "under + // 24 hours old": a backwards clock correction -- a restored VM snapshot, an + // NTP step, a host that booted with a bad RTC -- makes the age negative, and a + // one-sided test would then suppress the daily check for the whole skew and + // report a negative age to the operator. + const age = state.lastCheckedAt === undefined ? undefined : now - state.lastCheckedAt; + if (options.force !== true && age !== undefined && age >= 0 && age < CHECK_INTERVAL_MS) { + return { + kind: "skipped", + message: `upstream was last checked ${Math.round(age / 60_000)} minutes ago; skipping.`, + }; + } + + let upstream: string; + try { + upstream = normalizeVersion(await lifecycle.source.latestTag()); + } catch (error) { + await updateState(host, { lastCheckedAt: now }); + return { kind: "failed", message: `upstream version check failed: ${describe(error)}` }; + } + + const next = await updateState(host, { upstreamVersion: upstream, lastCheckedAt: now }); + const resolution = await resolveExecutable(host, next); + const local = resolution.ok ? await resolvedVersion(resolution.resolved) : null; + + if (local !== null && local.includes(upstream)) { + return { kind: "current", message: `upstream ${upstream} matches the local executable.` }; + } + + const remedy = !resolution.ok + ? "Run /cbm install to place a managed copy." + : next.pin !== undefined + ? `Version ${next.pin} is pinned, so nothing will be adopted. Run /cbm unpin to release it.` + : resolution.resolved.source === "system" + ? `Run \`${resolution.resolved.executable} update\` to update the system installation.` + : "Run /cbm update to adopt it."; + + return { + kind: "newer", + message: `upstream release is ${upstream}; local is ${local ?? "unknown"}. ${remedy}`, + }; +} + +/** + * Writes the owned entry for `resolved` and records what was written. + * + * The recorded `command` is what makes the entry decidably ours on a later run, + * including after the resolved path has moved -- which is exactly when an + * ownership test based on the current resolution alone would refuse to correct + * its own entry. + */ +async function wire( + lifecycle: Lifecycle, + resolved: Resolved, + state: State, +): Promise { + const outcome = await upsertEntry(lifecycle.host, resolved.executable, state.wroteCommand); + if (!outcome.ok) return { ok: false, message: outcome.reason }; + + if (state.wroteCommand !== resolved.executable) { + await updateState(lifecycle.host, { wroteCommand: resolved.executable }); + } + + switch (outcome.change) { + case "created": + return { ok: true, message: `Wrote the MCP entry naming ${resolved.executable}.` }; + case "updated": + return { + ok: true, + message: `Corrected the MCP entry to ${resolved.executable}; run /mcp reload so this session picks it up.`, + }; + case "unchanged": + return { ok: true, message: `The MCP entry already names ${resolved.executable}.` }; + } +} + +/** + * Whether removing the package-owned root would leave the owned entry naming a + * file that no longer exists. + * + * Asked only when {@link removeEntry} refused, and it is what separates the two + * kinds of refusal. An entry inside the managed bin root -- or a file this + * package could not read structurally, where it cannot know what the entry + * names -- is the dangerous kind. An entry naming a system CBM is not: that + * entry is correctly wired to an executable uninstall never touches, so + * blocking on it would make the managed copy unremovable by the one command + * whose job is removing it. + * + * The managed-root half is unreachable through {@link removeEntry} as it stands: + * ownership there is decided from the path as well as from recorded state, so a + * readable entry naming something under the managed root is this package's and + * is removed rather than refused. Every refusal that reaches here today is a + * file-level one and answers on `problem`. + * + * It is kept anyway, because this predicate states the property it is named for + * rather than a fact about how `removeEntry` currently decides ownership. + * Dropping it would make uninstall's safety depend on an invariant that lives in + * another function, and a later narrowing of that ownership rule would silently + * reintroduce the dangling entry this guard exists to prevent -- a failure whose + * cost is a deleted executable OMP keeps spawning and a key that can never be + * reclaimed. There is deliberately no test reaching this half: one could only be + * written by contriving a refusal `removeEntry` does not produce, which would + * assert the scaffolding rather than the property. + */ +async function wouldDangle(host: Host): Promise { + const entry = await entryStatus(host, null); + if (entry.problem !== undefined) return true; + return entry.command !== undefined && insideManagedBinRoot(host, entry.command); +} + +/** A note naming an adopted system executable uninstall deliberately left alone. */ +async function systemStillPresent(host: Host): Promise { + const resolution = await resolveExecutable(host, {}); + return resolution.ok && resolution.resolved.source === "system" + ? ` The system installation at ${resolution.resolved.executable} was left in place.` + : ""; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/mcp-config.ts b/src/mcp-config.ts new file mode 100644 index 0000000..3199217 --- /dev/null +++ b/src/mcp-config.ts @@ -0,0 +1,385 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; + +import { insideManagedBinRoot, mcpConfigPath, SERVER_NAME, type Host } from "./paths.ts"; + +/** + * Sole ownership of one key in OMP's native user MCP configuration. + * + * This is the only operator file this package writes, and the write is + * constrained accordingly: one key, idempotent, siblings and formatting + * preserved, fail closed on a `command` value this package did not write, and + * removal only when the value still matches. + * + * It has to be the native file rather than a plugin-root `.mcp.json`. The entry + * must name an absolute path to a home-relative or package-owned executable, + * and a plugin-root MCP file gets no `${VAR}` expansion while `command` gets no + * pre-connect environment resolution at all -- so a committed file cannot + * express the path, and requiring the executable on `PATH` would defeat the + * system-first policy's own fallback. The native file can carry the absolute + * path, and sits at MCP discovery priority 1 so its entry always wins. + */ + +/** The `$schema` OMP writes into its own managed MCP files. */ +export const MCP_SCHEMA_URL = + "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"; + +/** What one read of the file established. */ +export interface McpFile { + readonly path: string; + /** Raw text, or `null` when the file does not exist. */ + readonly text: string | null; + /** Parsed document; an empty object when the file does not exist. */ + readonly document: Record; + /** Indentation the file already used, reproduced on write. */ + readonly indent: string; + /** Whether the file ended with a newline, reproduced on write. */ + readonly trailingNewline: boolean; +} + +export type ReadResult = + | { readonly ok: true; readonly file: McpFile } + /** A structural refusal: the file exists but cannot be parsed. */ + | { readonly ok: false; readonly reason: string }; + +/** What one write attempt did, or refused to do. */ +export type WriteOutcome = + | { readonly ok: true; readonly change: "created" | "updated" | "unchanged" } + | { readonly ok: false; readonly reason: string }; + +/** What one removal attempt did, or refused to do. */ +export type RemoveOutcome = + | { readonly ok: true; readonly change: "removed" | "absent" } + | { readonly ok: false; readonly reason: string }; + +/** What the file currently says about the owned entry. */ +export interface EntryStatus { + readonly path: string; + /** Whether an entry under this package's key exists at all. */ + readonly present: boolean; + /** The `command` that entry names, when present. */ + readonly command?: string; + /** Whether that command equals the currently resolved executable. */ + readonly current: boolean; + /** Set when the file could not be read structurally. */ + readonly problem?: string; +} + +/** + * Reads the file for a read-modify-write. + * + * A missing file is not a problem -- it is the first-run case, and the write + * creates it. An unparseable file is refused rather than replaced: a hand edit + * in progress, or another writer's partial write, is not something to resolve + * by discarding the operator's content. + * + * A file that exists and cannot be read is refused for the same reason. Only + * `ENOENT` and `ENOTDIR` mean "there is no file here"; every other errno -- + * `EACCES` after a `sudo omp`, `EISDIR`, an I/O error -- means the content is + * unknown, and reporting that as an absent entry would tell the operator the + * entry is missing from a file this package could not open, and then write a + * fresh document over whatever was in it. + */ +export async function readMcpFile(host: Host): Promise { + const file = mcpConfigPath(host); + let text: string; + try { + text = await Bun.file(file).text(); + } catch (error) { + const code = (error as { code?: string } | null | undefined)?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + return { + ok: false, + reason: + `${file} could not be read (${code ?? "no errno"}), so it was left untouched: ` + + `${error instanceof Error ? error.message : String(error)}`, + }; + } + return { + ok: true, + file: { path: file, text: null, document: {}, indent: " ", trailingNewline: true }, + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return { + ok: false, + reason: `${file} is not parseable JSON, so it was left untouched: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, reason: `${file} does not hold a JSON object, so it was left untouched` }; + } + + // Refused rather than replaced by an empty map, which is the one malformed + // shape this reader used to recognise by discarding: the write below spreads + // the map it is given, so an `mcpServers` array or scalar would be silently + // deleted by the code path whose entire purpose is refusing to overwrite + // operator content. + const servers = (parsed as Record)["mcpServers"]; + const shaped = typeof servers === "object" && servers !== null && !Array.isArray(servers); + if (servers !== undefined && !shaped) { + return { + ok: false, + reason: `${file} holds an mcpServers value that is not a JSON object, so it was left untouched`, + }; + } + + return { + ok: true, + file: { + path: file, + text, + document: parsed as Record, + indent: detectIndent(text), + trailingNewline: text.endsWith("\n"), + }, + }; +} + +/** + * Upserts the owned entry so it names `command`. + * + * `previouslyWrote` is the `command` this package last wrote, from its own + * state. It is one of the two things that make ownership decidable: an entry + * naming that value, or naming the currently resolved executable, is this + * package's to update. The other is the path itself -- an entry whose `command` + * is inside this package's own managed bin root can only have been written by + * this package, and deciding on state alone would report it as somebody else's + * whenever the state file was lost, which is the one case the self-correcting + * session-start check exists for. + * + * Anything else belongs to CBM's own installer, another tool, or a hand edit, + * and is left alone with both paths reported -- silently replacing it would + * discard a working configuration and hide that two owners exist. + */ +export async function upsertEntry( + host: Host, + command: string, + previouslyWrote: string | undefined, +): Promise { + const read = await readMcpFile(host); + if (!read.ok) return read; + + const { file } = read; + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + + if (existing !== undefined) { + const currentCommand = commandOf(existing); + const ours = + currentCommand === command || + currentCommand === previouslyWrote || + (currentCommand !== undefined && insideManagedBinRoot(host, currentCommand)); + if (!ours) { + return { + ok: false, + reason: + `${file.path} already defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + + `which this package did not write. It was left untouched; the executable this package resolved is ${command}.`, + }; + } + + // The no-op is decided on the parsed entry, never on re-rendered bytes: a + // file this package did not format -- compact, CRLF, mixed indentation -- + // never equals its own rendering, so comparing bytes would reformat the + // operator's file on a run that had nothing to change, and expose it to a + // needless write. + if (isCurrentEntry(existing, command)) return { ok: true, change: "unchanged" }; + } + + const next: Record = { ...file.document }; + if (file.text === null) next["$schema"] = MCP_SCHEMA_URL; + + // The `command` is the only field replaced on an update. Every other field an + // operator added to the entry -- a `timeout`, an `env` -- is theirs to keep. + const entry: Record = { + ...(typeof existing === "object" && existing !== null && !Array.isArray(existing) + ? (existing as Record) + : {}), + type: "stdio", + command, + args: [], + }; + next["mcpServers"] = { ...servers, [SERVER_NAME]: entry }; + + await mkdir(path.dirname(file.path), { recursive: true }); + await writeDurably(file.path, render(next, file)); + return { ok: true, change: file.text === null ? "created" : "updated" }; +} + +/** + * Removes the owned entry, and only when it is still decidably this package's. + * + * Ownership is the same two-sided test the upsert uses: the recorded write, or + * a `command` inside this package's own managed bin root. Deciding on the + * recorded write alone made an entry naming a path only this package writes + * permanently unreclaimable once the state file was gone. + * + * The file itself stays in place whenever anything else remains in it -- other + * servers, a `disabledServers` list, an `$schema` value this package did not + * write. Deleting a file this package did not create in order to take back one + * key would remove configuration it never owned. + */ +export async function removeEntry( + host: Host, + wroteCommand: string | undefined, +): Promise { + const read = await readMcpFile(host); + if (!read.ok) return read; + + const { file } = read; + if (file.text === null) return { ok: true, change: "absent" }; + + const servers = serverMap(file.document); + const existing = servers[SERVER_NAME]; + if (existing === undefined) return { ok: true, change: "absent" }; + + const currentCommand = commandOf(existing); + const ours = + (wroteCommand !== undefined && currentCommand === wroteCommand) || + (currentCommand !== undefined && insideManagedBinRoot(host, currentCommand)); + if (!ours) { + return { + ok: false, + reason: + `${file.path} defines ${SERVER_NAME} with command ${currentCommand ?? "(none)"}, ` + + `which is not what this package wrote (${wroteCommand ?? "nothing recorded"}). It was left in place.`, + }; + } + + const remaining: Record = { ...servers }; + delete remaining[SERVER_NAME]; + + // A file whose only other key is the `$schema` this package writes on create + // is a file this package created, so taking back its last key means removing + // it: rolling back is supposed to return the machine to its pre-install state, + // and an empty `{"mcpServers": {}}` the operator never had is not that. + const others = Object.keys(file.document).filter((key) => key !== "mcpServers"); + const ourCreation = + others.length === 1 && others[0] === "$schema" && file.document["$schema"] === MCP_SCHEMA_URL; + if (Object.keys(remaining).length === 0 && ourCreation) { + await rm(file.path, { force: true }); + return { ok: true, change: "removed" }; + } + + const next: Record = { ...file.document, mcpServers: remaining }; + await writeDurably(file.path, render(next, file)); + return { ok: true, change: "removed" }; +} + +/** What the file says about the owned entry, without writing anything. */ +export async function entryStatus(host: Host, resolvedCommand: string | null): Promise { + const read = await readMcpFile(host); + if (!read.ok) { + return { path: mcpConfigPath(host), present: false, current: false, problem: read.reason }; + } + + const existing = serverMap(read.file.document)[SERVER_NAME]; + if (existing === undefined) { + return { path: read.file.path, present: false, current: false }; + } + + const command = commandOf(existing); + return { + path: read.file.path, + present: true, + ...(command === undefined ? {} : { command }), + current: command !== undefined && command === resolvedCommand, + }; +} + +/** + * The `mcpServers` map, or an empty one when the file has none. + * + * The shape check is what narrows the type; {@link readMcpFile} has already + * refused every value but a map and `undefined`, so the fallback is only the + * missing key. + */ +function serverMap(document: Record): Record { + const servers = document["mcpServers"]; + return typeof servers === "object" && servers !== null && !Array.isArray(servers) + ? (servers as Record) + : {}; +} + +/** Whether the existing entry already says exactly what a write would say. */ +function isCurrentEntry(entry: unknown, command: string): boolean { + if (commandOf(entry) !== command) return false; + const record = entry as Record; + const args = record["args"]; + return record["type"] === "stdio" && Array.isArray(args) && args.length === 0; +} + +/** The `command` an entry names, when it names a usable one. */ +function commandOf(entry: unknown): string | undefined { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined; + const command = (entry as Record)["command"]; + return typeof command === "string" ? command : undefined; +} + +/** + * The file's own indentation, so a rewrite does not reformat it. + * + * JSON cannot carry comments and `JSON.parse` discards whitespace, so + * indentation is as much of the operator's formatting as this package can + * preserve. A two-space file -- which is what OMP's own writer produces -- comes + * back byte-identical apart from the key that changed. + */ +function detectIndent(text: string): string { + const match = /\n([ \t]+)"/u.exec(text); + return match?.[1] ?? " "; +} + +function render(document: Record, file: McpFile): string { + const body = JSON.stringify(document, null, file.indent); + return file.trailingNewline ? `${body}\n` : body; +} + +/** + * Replaces `file` by staging the new content beside it and renaming it in. + * + * `Bun.write` to an existing path truncates it in place, so an interrupted + * write -- a crash, a `SIGKILL` at shutdown, `ENOSPC` -- leaves a half-written + * document. OMP reads this file with a bare `JSON.parse` and drops the whole + * document when it fails, so that window costs the operator every user-level + * MCP server with no warning, and this package then refuses the file forever as + * unparseable. `rename` within one directory is atomic: a reader sees either + * the old document or the new one. + * + * The staging name carries the pid and a random suffix so two concurrent + * writers never share it, which is what OMP's own writer does for the same + * file. It is removed on failure so a failed write leaves nothing behind. + * + * The destination's own mode is reproduced before the rename, because `rename` + * replaces the destination rather than truncating it: the visible file would + * otherwise inherit the staging file's default, which is 0644 under the usual + * umask. This file can carry per-server `env` secrets, remote `headers` and an + * `auth.clientSecret`, and OMP's own writer creates it 0600 -- so any file last + * written by `/mcp add|enable|disable` is 0600 on disk, and publishing it 0644 + * would be a disclosure. A mode the operator widened deliberately is reproduced + * too, not narrowed; only a file this write creates gets 0600 by default. + */ +async function writeDurably(file: string, contents: string): Promise { + const staging = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + let mode = 0o600; + try { + mode = (await stat(file)).mode & 0o777; + } catch (error) { + const code = (error as { code?: string } | null | undefined)?.code; + if (code !== "ENOENT") throw error; + } + + await Bun.write(staging, contents); + await chmod(staging, mode); + await rename(staging, file); + } catch (error) { + await rm(staging, { force: true }); + throw error; + } +} diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..02b9061 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,139 @@ +import { homedir } from "node:os"; +import path from "node:path"; + +/** + * Where this package reads and writes, and how it decides. + * + * Every path is derived from an explicit {@link Host} rather than from ambient + * `process.env` and `os.homedir()`, so a test can run the whole lifecycle + * against a scratch `HOME` and then assert what was *not* written -- which is + * the only way to prove `~/.local/bin` is left alone. + */ + +/** The ambient facts every path derives from. */ +export interface Host { + readonly home: string; + readonly env: Readonly>; +} + +/** The real host: this process's home directory and environment. */ +export function processHost(): Host { + return { home: homedir(), env: process.env }; +} + +/** The executable's name on PATH and inside the release archive. */ +export const EXECUTABLE_NAME = "codebase-memory-mcp"; + +/** The MCP server key this package owns, and nothing else in the file. */ +export const SERVER_NAME = "codebase-memory-mcp"; + +/** + * OMP's config directory name, `PI_CONFIG_DIR` included. + * + * Honoured because every path below hangs off it: a process started with + * `PI_CONFIG_DIR` set writes its agent configuration somewhere else entirely, + * and an entry written to `~/.omp/agent/mcp.json` would be read by nobody. + */ +function configDirName(host: Host): string { + const override = host.env["PI_CONFIG_DIR"]; + return override !== undefined && override !== "" ? override : ".omp"; +} + +/** + * The active OMP agent directory, resolved the way OMP resolves it. + * + * `PI_CODING_AGENT_DIR` first is not a guess about precedence: OMP's CLI calls + * `setProfile(...)` on every start -- with the `--profile` value, or with + * `OMP_PROFILE`/`PI_PROFILE` from the environment -- and a named profile makes + * that call write `PI_CODING_AGENT_DIR` back as the profile's own agent + * directory. So inside a session the variable is already OMP's answer, and + * reading it first reproduces that answer exactly. + * + * The profile branch is the fallback for a process that never went through + * OMP's CLI: `OMP_PROFILE` is canonical and `PI_PROFILE` is consulted only when + * `OMP_PROFILE` is undefined, which is OMP's own rule -- an explicitly empty + * `OMP_PROFILE` selects the default profile rather than inheriting the legacy + * variable. + */ +export function agentDir(host: Host): string { + const explicit = host.env["PI_CODING_AGENT_DIR"]; + if (explicit !== undefined && explicit !== "") return path.resolve(explicit); + + const omp = host.env["OMP_PROFILE"]; + const profile = omp !== undefined ? omp : host.env["PI_PROFILE"]; + const root = path.join(host.home, configDirName(host)); + return profile !== undefined && profile !== "" + ? path.join(root, "profiles", profile, "agent") + : path.join(root, "agent"); +} + +/** The native user MCP file this package owns one key in. */ +export function mcpConfigPath(host: Host): string { + return path.join(agentDir(host), "mcp.json"); +} + +/** + * The path a future upstream `--clients=omp` would write its own extension to. + * + * Checked on load so a CBM-written native extension and this package's entry + * cannot both apply the next change's output augmentation. Extension modules + * are deduplicated by absolute path, so both would load. + */ +export function nativeExtensionPath(host: Host): string { + return path.join(agentDir(host), "extensions", "codebase-memory.ts"); +} + +/** + * The root this package owns, and the only place it writes an executable. + * + * Outside the plugin tree on purpose: OMP caches plugins in version-qualified + * directories and replaces them on reinstall, so a managed executable stored + * inside would be discarded by a routine plugin upgrade and re-downloaded every + * time. Outside the agent directory too, so it survives a profile switch. + */ +export function packageRoot(host: Host): string { + return path.join(host.home, configDirName(host), "codebase-memory"); +} + +/** Where managed versions live, one directory per version. */ +export function managedBinRoot(host: Host): string { + return path.join(packageRoot(host), "bin"); +} + +/** The managed executable for one version. */ +export function managedExecutable(host: Host, version: string): string { + return path.join(managedBinRoot(host), version, EXECUTABLE_NAME); +} + +/** + * Whether `candidate` names a file under {@link managedBinRoot}. + * + * The one path predicate that decides ownership: nothing but this package ever + * writes under that root, so a `command` inside it is decidably this package's + * own even after the state that recorded it was lost. `path.relative` rather + * than a `startsWith` on the raw string, because a sibling directory whose name + * merely shares the prefix -- `bin-backup`, `bin.old` -- passes the string test + * and is not inside the root; adopting one would be exactly the silent + * overwrite the ownership test exists to prevent. A relative `command` is never + * ours: every path this package writes is absolute. + */ +export function insideManagedBinRoot(host: Host, candidate: string): boolean { + if (!path.isAbsolute(candidate)) return false; + const inside = path.relative(managedBinRoot(host), candidate); + return inside !== "" && !inside.startsWith("..") && !path.isAbsolute(inside); +} + +/** This package's state file: source, version, digest, pin, last check. */ +export function statePath(host: Host): string { + return path.join(packageRoot(host), "state.json"); +} + +/** + * Upstream's own install directory, which this package reads and never writes. + * + * Present here so the resolution order can consult it, and so the test that + * proves it is untouched has one name to check rather than a literal. + */ +export function upstreamInstallDir(host: Host): string { + return path.join(host.home, ".local", "bin"); +} diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..aab0d14 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,137 @@ +/** + * The one seam that knows how a release asset is named and what it contains. + * + * Every platform-specific fact about upstream's release layout lives here: + * archive naming, the container format, the executable's name inside it, and + * the closed member set the archive must match. Acquisition, verification, and + * resolution all read those facts from a {@link Target} rather than deriving + * them again, so adding a platform means extending this file and nothing else. + */ +import { cpus } from "node:os"; + +/** The operating systems upstream publishes an archive for. */ +export type TargetOs = "darwin" | "linux" | "windows"; + +/** The architectures upstream publishes an archive for. */ +export type TargetArch = "arm64" | "amd64"; + +/** Everything downstream code needs to know about one release target. */ +export interface Target { + readonly os: TargetOs; + readonly arch: TargetArch; + /** Release asset name, exactly as `checksums.txt` spells it. */ + readonly archive: string; + /** Container format, which decides the enumeration and extraction tool. */ + readonly container: "tar.gz" | "zip"; + /** The executable's name inside the archive, and on disk once adopted. */ + readonly executable: string; + /** The platform installer script inside the archive. */ + readonly installer: string; + /** + * The complete set of members the archive may contain, in no particular + * order. Upstream's installer treats anything outside it as a release + * integrity failure rather than a sidecar to ignore, and so does this + * package; see {@link enumerateArchiveMembers}. + */ + readonly members: readonly string[]; +} + +/** + * Raised for a platform this package names but does not implement. + * + * Windows reaches this: {@link describeTarget} knows its archive naming, so a + * later change has one seam to extend rather than a shape to reverse-engineer, + * but zip extraction, the executable suffix on disk, and Windows path handling + * are absent. One explicit refusal is honest about that; a half-implemented + * branch would download an archive it cannot open. + */ +export class UnsupportedPlatformError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedPlatformError"; + } +} + +/** + * The release target for one OS/architecture pair. + * + * Pure, and deliberately total over {@link TargetOs}: the Windows shape is + * described here and refused in {@link detectTarget}, which keeps the naming + * under test without claiming support for it. + * + * The Linux `-portable` suffix is not cosmetic. The standard Linux build + * dynamically links glibc 2.38 or newer and fails outright on Debian 11, + * RHEL 8, and Ubuntu 20.04; the portable build is fully static. macOS and + * Windows publish no such variant, so the suffix must be absent there or the + * asset name names nothing. + */ +export function describeTarget(os: TargetOs, arch: TargetArch): Target { + const container = os === "windows" ? "zip" : "tar.gz"; + const executable = os === "windows" ? "codebase-memory-mcp.exe" : "codebase-memory-mcp"; + const installer = os === "windows" ? "install.ps1" : "install.sh"; + const portable = os === "linux" ? "-portable" : ""; + const archive = `codebase-memory-mcp-${os}-${arch}${portable}.${container}`; + + return { + os, + arch, + archive, + container, + executable, + installer, + members: [executable, "LICENSE", installer, "THIRD_PARTY_NOTICES.md"], + }; +} + +/** + * The release target for the host this process runs on. + * + * `platform` and `arch` are `process.platform` and `process.arch`; `cpuModel` + * is the host's CPU brand string (`os.cpus()[0]?.model`). The last one exists + * for Rosetta: an x64 process on Apple Silicon reports `arch === "x64"`, and + * selecting the amd64 archive there installs a translated binary that works + * but runs slowly beside a native one. Upstream's installer makes the same + * correction from the same brand string. + */ +export function detectTarget(platform: string, arch: string, cpuModel: string | undefined): Target { + let os: TargetOs; + switch (platform) { + case "darwin": + os = "darwin"; + break; + case "linux": + os = "linux"; + break; + case "win32": + throw new UnsupportedPlatformError( + "Windows is not supported yet: the release archive is a zip this package cannot extract, " + + "and the executable suffix and path handling are unimplemented. " + + "Install codebase-memory-mcp with upstream's install.ps1 and this package will adopt it from PATH.", + ); + default: + throw new UnsupportedPlatformError( + `unsupported operating system: ${platform} (supported: darwin, linux)`, + ); + } + + let target: TargetArch; + switch (arch) { + case "arm64": + target = "arm64"; + break; + case "x64": + target = os === "darwin" && /apple/i.test(cpuModel ?? "") ? "arm64" : "amd64"; + break; + default: + throw new UnsupportedPlatformError( + `unsupported architecture: ${arch} (supported: arm64, x64)`, + ); + } + + return describeTarget(os, target); +} + +/** The release target for this process, read from `process` and `os.cpus()`. */ +export function hostTarget(): Target { + return detectTarget(process.platform, process.arch, cpus()[0]?.model); +} diff --git a/src/release.ts b/src/release.ts new file mode 100644 index 0000000..466d4dc --- /dev/null +++ b/src/release.ts @@ -0,0 +1,301 @@ +/** + * Upstream's release surface: the only network entry point in this package, + * plus the two pieces of published metadata acquisition depends on. + * + * Every verification property here exists because upstream's `install.sh` has + * it. A step dropped is a security property silently removed, so each one + * names the upstream behaviour it reproduces. + */ + +/** The repository this package acquires from. */ +export const UPSTREAM_REPO = "DeusData/codebase-memory-mcp"; + +const RELEASES = `https://github.com/${UPSTREAM_REPO}/releases`; + +/** Where `releases/latest` redirects a tag out of. */ +const LATEST = `${RELEASES}/latest`; + +/** + * Redirect hops `fetchHttps` will follow. + * + * Matches the `--max-redirs 5` upstream's installer passes to curl. The bound + * exists so a redirect loop fails as a bounded error rather than hanging. + */ +const MAX_REDIRECTS = 5; + +/** Default per-request deadline. A version check must not hang a session. */ +const DEFAULT_TIMEOUT_MS = 20_000; + +/** + * `checksums.txt` above this size is refused unread. + * + * Upstream's installer refuses the same 1 MiB, for the same reason: the digest + * file is a few kilobytes of text, so anything larger is not the file this + * package believes it is parsing. + * + * Enforced twice on purpose. {@link readBounded} stops the download at the + * limit, which is the only place the memory is actually saved, and + * {@link parseChecksums} refuses an over-limit buffer whatever produced it -- + * because the parser is reachable from a {@link ReleaseSource} that never went + * through the download at all. + */ +export const CHECKSUMS_LIMIT_BYTES = 1_048_576; + +export interface FetchOptions { + /** + * Redirect hops to follow, capped at {@link MAX_REDIRECTS}. + * + * `0` returns the first response as received, redirect status and `location` + * header included -- which is how the release tag is read. + */ + readonly maxRedirects?: number; + readonly timeoutMs?: number; +} + +/** + * The only place this package opens a network connection. + * + * HTTPS is required for the initial request and re-checked on every redirect + * hop, because a redirect is the one place a transport downgrade can arrive + * from outside the URL this code chose. `redirect: "manual"` is what makes + * that possible: the platform's own redirect following would take the hop + * before this code could look at it. + */ +export async function fetchHttps(url: string, options: FetchOptions = {}): Promise { + const budget = Math.min(options.maxRedirects ?? MAX_REDIRECTS, MAX_REDIRECTS); + let current = requireHttps(url, "request"); + + for (let hop = 0; ; hop++) { + const response = await fetch(current, { + redirect: "manual", + signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS), + headers: { accept: "*/*" }, + }); + + const redirected = response.status >= 300 && response.status < 400; + if (!redirected || hop >= budget) return response; + + current = nextHop(current, response.status, response.headers.get("location")); + } +} + +/** + * The URL one redirect hop leads to, or a refusal. + * + * Separated from {@link fetchHttps} because it is the whole transport-downgrade + * defence and the only part of it a test can reach without a TLS origin that + * redirects to plain HTTP. A relative `location` is resolved against the URL it + * came from, which is also what makes a scheme-relative `//host/path` inherit + * HTTPS rather than slip through as protocol-less. + */ +export function nextHop(current: string, status: number, location: string | null): string { + if (location === null || location === "") { + throw new Error(`${current} answered ${status} with no location header`); + } + return requireHttps(new URL(location, current).href, "redirect"); +} + +/** `url` when it is HTTPS; otherwise a refusal naming which hop downgraded. */ +function requireHttps(url: string, kind: "request" | "redirect"): string { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw new Error(`refusing non-HTTPS ${kind}: ${url}`); + } + return parsed.href; +} + +/** + * The release tag a `releases/latest` response names. + * + * Separated from {@link resolveLatestTag} for the same reason as + * {@link nextHop}: this is validation of attacker-adjacent input -- the tag it + * yields is interpolated into a download URL and used as an on-disk directory + * name -- and a function is the only way to test the refusals without standing + * up a server that impersonates GitHub. + */ +export function tagFromLocation(status: number, location: string | null): string { + if (status < 300 || status >= 400) { + throw new Error(`expected ${LATEST} to redirect to a tag, got HTTP ${status}`); + } + if (location === null || location === "") { + throw new Error(`${LATEST} answered ${status} with no location header`); + } + + const resolved = new URL(location, LATEST); + if (resolved.protocol !== "https:") { + throw new Error(`refusing non-HTTPS release location: ${resolved.href}`); + } + + // The origin is checked as well as the path. Without it a redirect to + // `https://elsewhere/DeusData/codebase-memory-mcp/releases/tag/v9.9.9` would + // be mined for a version string this package then treats as the newest + // release -- the path prefix alone says nothing about who answered. + const prefix = `/${UPSTREAM_REPO}/releases/tag/`; + if (resolved.origin !== new URL(LATEST).origin || !resolved.pathname.startsWith(prefix)) { + throw new Error(`unexpected release location: ${resolved.href}`); + } + + const tag = decodeURIComponent(resolved.pathname.slice(prefix.length)); + if (tag === "" || tag.includes("/")) { + throw new Error(`unexpected release tag in location: ${resolved.href}`); + } + return tag; +} + +/** + * The newest release tag, read from the `releases/latest` redirect. + * + * Not the GitHub API: that answer is rate-limited per IP, which turns a + * background version check into an intermittent failure on a shared network + * and would need a token for something requiring no authentication at all. + * The redirect needs neither, and asset downloads already use the same + * `releases/` mechanism. + */ +export async function resolveLatestTag(): Promise { + const response = await fetchHttps(LATEST, { maxRedirects: 0 }); + return tagFromLocation(response.status, response.headers.get("location")); +} + +/** + * The published SHA-256 digest for exactly `archive`. + * + * Reproduces upstream's `awk '$2 == archive || $2 == "*" archive'` selection, + * and its three refusals. Exactness is load-bearing rather than pedantic: + * a real `checksums.txt` lists `codebase-memory-mcp-ui-darwin-arm64.tar.gz` + * beside `codebase-memory-mcp-darwin-arm64.tar.gz`, so a substring or prefix + * match would silently verify one asset's bytes against another's line. + */ +export function parseChecksums(body: Uint8Array, archive: string): string { + if (body.byteLength > CHECKSUMS_LIMIT_BYTES) { + throw new Error( + `checksums.txt is ${body.byteLength} bytes, over the ${CHECKSUMS_LIMIT_BYTES} byte safety limit`, + ); + } + + const text = new TextDecoder("utf-8", { fatal: false }).decode(body); + let digest: string | undefined; + + for (const line of text.split("\n")) { + const fields = line.trim().split(/\s+/u); + if (fields.length < 2) continue; + + const name = fields[1] ?? ""; + if (name !== archive && name !== `*${archive}`) continue; + + const candidate = (fields[0] ?? "").toLowerCase(); + if (!/^[0-9a-f]{64}$/u.test(candidate)) { + throw new Error(`invalid SHA-256 digest for ${archive}: ${fields[0] ?? ""}`); + } + if (digest !== undefined && digest !== candidate) { + throw new Error(`conflicting SHA-256 digests for ${archive} in checksums.txt`); + } + digest = candidate; + } + + if (digest === undefined) { + throw new Error(`no SHA-256 digest for ${archive} in checksums.txt`); + } + return digest; +} + +/** + * The published artifacts acquisition reads. + * + * An interface rather than three free functions so the failure paths -- digest + * mismatch, an unexpected archive member, a candidate that will not run -- are + * reachable from a test without a network or a mutated release. + */ +export interface ReleaseSource { + /** The newest release tag. */ + latestTag(): Promise; + /** `checksums.txt` for `tag`, as bytes so its size can be refused unread. */ + checksums(tag: string): Promise; + /** Release asset `name` published under `tag`. */ + asset(tag: string, name: string): Promise; +} + +/** The real release source, over {@link fetchHttps}. */ +export function githubReleaseSource(): ReleaseSource { + return { + latestTag: resolveLatestTag, + checksums: (tag) => + downloadBounded( + `${RELEASES}/download/${encodeURIComponent(tag)}/checksums.txt`, + CHECKSUMS_LIMIT_BYTES, + "checksums.txt", + ), + asset: (tag, name) => + download(`${RELEASES}/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`), + }; +} + +async function download(url: string): Promise { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); +} + +/** + * A {@link download} whose body is refused the moment it passes `limitBytes`. + * + * Separated from {@link download} because only the digest file has a published + * size to hold it to; the archive's size is whatever upstream built. + */ +async function downloadBounded(url: string, limitBytes: number, what: string): Promise { + const response = await fetchHttps(url); + if (!response.ok) { + throw new Error(`GET ${url} answered HTTP ${response.status}`); + } + return await readBounded(response.body, limitBytes, what); +} + +/** + * A response body, read no further than `limitBytes`. + * + * The bound is enforced while the body arrives rather than once it has, which + * is the whole point of having one: `response.arrayBuffer()` allocates every + * byte before anything can look at the total, so a limit checked afterwards + * refuses a body the process has already paid for. A `content-length` + * precheck is not a substitute either -- a chunked response carries no length + * -- so the count is kept over the chunks themselves. + * + * Exported for the same reason as {@link nextHop}: the refusal is only + * reachable from a test that can hand it a body, and standing up an HTTPS + * origin that streams a megabyte is not something a unit test should need. + */ +export async function readBounded( + body: ReadableStream | null, + limitBytes: number, + what: string, +): Promise { + if (body === null) return new Uint8Array(); + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limitBytes) { + throw new Error(`${what} is over the ${limitBytes} byte safety limit`); + } + chunks.push(value); + } + } finally { + // Cancelling is what drops the connection on the refusal path; on the + // ordinary path the stream is already done and this is a no-op. + await reader.cancel().catch(() => {}); + } + + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..c1c87c5 --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,151 @@ +import { readVersion } from "./exec.ts"; +import { + EXECUTABLE_NAME, + managedBinRoot, + managedExecutable, + upstreamInstallDir, + type Host, +} from "./paths.ts"; +import { readState, type State } from "./state.ts"; + +import path from "node:path"; + +/** + * Which of the four places an executable was found. + * + * `system` covers both `PATH` and `~/.local/bin`: they are the same policy + * decision -- an installation this package did not place and must not touch -- + * and {@link Resolved.origin} names which one it was for the status report. + */ +export type ResolutionSource = "pin" | "system" | "managed"; + +export interface Resolved { + /** Absolute path to the executable. */ + readonly executable: string; + readonly source: ResolutionSource; + /** Where it was found, for the status report: `PATH`, `~/.local/bin`, a version. */ + readonly origin: string; +} + +/** A managed copy on disk, whether or not it is the resolved one. */ +export interface ManagedCopy { + readonly version: string; + readonly executable: string; +} + +export type Resolution = + | { readonly ok: true; readonly resolved: Resolved } + | { readonly ok: false; readonly reason: string }; + +/** + * The remedy an unresolved lookup names. + * + * Both paths are offered because they lead to different outcomes and the + * operator owns the choice: `/cbm install` places a copy this package manages + * and can update, upstream's installer places one at `~/.local/bin` that CBM's + * own `update` owns. This package adopts either. + */ +const NO_EXECUTABLE_REASON = + `no ${EXECUTABLE_NAME} executable found on PATH, in ~/.local/bin, or under this package's own root. ` + + "Run /cbm install to download a managed copy, or install it yourself with " + + "`curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash` " + + "and this package will adopt it."; + +/** + * The managed copy the state's pointer names, when it is still on disk. + * + * The pointer is a field of the state file rather than a separate symlink or + * `current` file. One document means one write to keep consistent, and it + * sidesteps the question of what a symlink means on a platform this package + * will eventually support. + */ +export async function managedCopy(host: Host, state?: State): Promise { + const recorded = (state ?? (await readState(host))).managedVersion; + if (recorded === undefined) return null; + + const executable = managedExecutable(host, recorded); + return (await Bun.file(executable).exists()) ? { version: recorded, executable } : null; +} + +/** + * The executable this package will point the MCP entry at. + * + * Order is pin, `PATH`, `~/.local/bin`, managed copy -- system before managed, + * deliberately, and the reason is the index rather than tidiness. CBM resolves + * one canonical per-account cache root and refuses to run when a process is + * configured with a different root while any CBM session or command is active. + * Two executables of different versions sharing that root produce mismatched + * index generations, so adopting whatever the operator already runs is the only + * safe default. A private cache root for the managed copy would avoid the + * conflict by re-indexing every repository a second time, which for a large + * tree is hours of work to hold the same answers twice. + * + * A pin comes first because it is explicit operator intent, and it can only + * ever select a managed copy -- this package does not relocate or re-version a + * system installation. + * + * Nothing here executes the candidate: a file that is present, named and + * executable is adopted without being run. Deliberate -- this runs on the + * session-start path, and spawning an unknown binary on every session start is a + * worse default than adopting a path that turns out not to run. The version is + * read separately by {@link resolvedVersion}, which `/cbm status` calls and + * reports as "unknown (it did not run)" when the candidate will not execute. + */ +export async function resolveExecutable(host: Host, state?: State): Promise { + const current = state ?? (await readState(host)); + + const pin = current.pin; + if (pin !== undefined) { + const pinned = managedExecutable(host, pin); + if (await Bun.file(pinned).exists()) { + return { ok: true, resolved: { executable: pinned, source: "pin", origin: pin } }; + } + } + + const onPath = Bun.which(EXECUTABLE_NAME, pathOption(host)); + if (onPath !== null) { + return { + ok: true, + resolved: { executable: path.resolve(onPath), source: "system", origin: "PATH" }, + }; + } + + const upstream = path.join(upstreamInstallDir(host), EXECUTABLE_NAME); + if (await Bun.file(upstream).exists()) { + return { + ok: true, + resolved: { executable: upstream, source: "system", origin: "~/.local/bin" }, + }; + } + + const managed = await managedCopy(host, current); + if (managed !== null) { + return { + ok: true, + resolved: { + executable: managed.executable, + source: "managed", + origin: path.join(path.basename(managedBinRoot(host)), managed.version), + }, + }; + } + + return { ok: false, reason: NO_EXECUTABLE_REASON }; +} + +/** The version the resolved executable reports, or `null` when it will not run. */ +export async function resolvedVersion(resolved: Resolved): Promise { + return await readVersion(resolved.executable); +} + +/** + * `Bun.which`'s options for this host. + * + * Threaded from {@link Host} rather than read from `process.env` so a test can + * point `PATH` at a scratch directory; `Bun.which` falls back to the process + * environment when no `PATH` is supplied, which would make such a test read the + * developer's own installation. + */ +function pathOption(host: Host): { PATH: string } { + return { PATH: host.env["PATH"] ?? "" }; +} diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..840bad0 --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,41 @@ +import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent"; + +/** + * The deferred-work seam, over OMP's managed timers. + * + * Extensions run in-process with no isolation, and a raw `setTimeout` callback + * that throws escapes handler dispatch entirely: it surfaces as a process-level + * `uncaughtException`, and OMP's postmortem handler treats that as fatal and + * tears down the whole session. The handler context's timer methods run their + * callback with the same isolation as handler dispatch, are `unref`'d, and are + * cleared on `session_shutdown`. + * + * So the platform timer globals are not called anywhere in this package, and + * this adapter is the reason there is nowhere convenient to call them from. + */ + +/** + * An opaque handle to one scheduled callback. + * + * `Timer` is the type OMP's managed methods return, and a global in `bun-types`. + */ +export type TimerHandle = Timer; + +export interface Scheduler { + /** Runs `callback` once, after `ms`. */ + after(callback: () => void, ms: number): TimerHandle; + /** Cancels a callback that has not run yet. */ + cancel(handle: TimerHandle): void; +} + +/** The scheduler backed by one handler context's managed timers. */ +export function schedulerFrom(ctx: ExtensionContext): Scheduler { + return { + after(callback, ms) { + return ctx.setTimeout(callback, ms); + }, + cancel(handle) { + ctx.clearTimer(handle); + }, + }; +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..ef56729 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,121 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; + +import { statePath, type Host } from "./paths.ts"; + +/** + * This package's own state, outside the plugin tree because the managed + * executable it describes outlives a plugin reinstall. + * + * Every field is optional and every read tolerates a missing or unreadable + * file. State here is a cache and a record of operator intent, never a + * prerequisite: resolution works from an empty state, and the worst a lost + * state file costs is one extra version check and a forgotten pin. + */ +export interface State { + /** The managed version the current pointer names, when one was adopted. */ + readonly managedVersion?: string; + /** The archive digest that version was verified against. */ + readonly managedDigest?: string; + /** A version the operator pinned; update checks report but never adopt. */ + readonly pin?: string; + /** The newest upstream version the last successful check saw. */ + readonly upstreamVersion?: string; + /** When the last upstream check completed, successfully or not, in epoch ms. */ + readonly lastCheckedAt?: number; + /** The absolute `command` this package last wrote into `mcp.json`. */ + readonly wroteCommand?: string; +} + +const EMPTY: State = {}; + +/** + * The recorded state, or an empty state. + * + * A missing file is the first-run case. An unparseable one is treated the same + * way rather than refused: the file is this package's own cache, so the + * recoverable reading is "forget what was cached", and failing a session start + * over a corrupted cache entry would be a worse outcome than re-checking. + */ +export async function readState(host: Host): Promise { + const file = Bun.file(statePath(host)); + let text: string; + try { + text = await file.text(); + } catch { + return EMPTY; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return EMPTY; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return EMPTY; + + const record = parsed as Record; + const state: Record = {}; + for (const key of ["managedVersion", "managedDigest", "pin", "upstreamVersion", "wroteCommand"]) { + const value = record[key]; + if (typeof value === "string" && value !== "") state[key] = value; + } + const lastCheckedAt = record["lastCheckedAt"]; + if (typeof lastCheckedAt === "number" && Number.isFinite(lastCheckedAt)) { + state["lastCheckedAt"] = lastCheckedAt; + } + return state as State; +} + +/** + * Replaces the recorded state with `next`. + * + * Whole-document rather than field-wise because the document is small and one + * writer owns it; a partial update would need a lock this package does not + * have. Callers read, spread, and write. + * + * Staged beside the file and renamed in, rather than truncated in place: a + * write interrupted by a crash or a `SIGKILL` at shutdown leaves a document + * that no longer parses, and this file degrades silently -- the reader above + * falls back to the empty state, which forgets the operator's pin and the + * `wroteCommand` that decides whether the MCP entry is this package's to take + * back. `rename` within one directory is atomic, so a reader sees either the + * old document or the new one. The staging name carries the pid and a random + * suffix so two writers never share it. + * + * The destination's own mode is reproduced before the rename, because `rename` + * replaces the destination rather than truncating it and the visible file would + * otherwise inherit the staging file's default. Nothing recorded here is a + * secret, but a package-private cache is not the operator's to have widened by a + * write they did not ask for; a file this write creates gets 0600. + */ +export async function writeState(host: Host, next: State): Promise { + const file = statePath(host); + await mkdir(path.dirname(file), { recursive: true }); + + const staging = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + let mode = 0o600; + try { + mode = (await stat(file)).mode & 0o777; + } catch (error) { + const code = (error as { code?: string } | null | undefined)?.code; + if (code !== "ENOENT") throw error; + } + + await Bun.write(staging, `${JSON.stringify(next, null, 2)}\n`); + await chmod(staging, mode); + await rename(staging, file); + } catch (error) { + await rm(staging, { force: true }); + throw error; + } +} + +/** Merges `patch` into the recorded state and writes the result. */ +export async function updateState(host: Host, patch: State): Promise { + const next = { ...(await readState(host)), ...patch }; + await writeState(host, next); + return next; +} diff --git a/test/fixtures/checksums-v0.10.8.txt b/test/fixtures/checksums-v0.10.8.txt new file mode 100644 index 0000000..7f2490b --- /dev/null +++ b/test/fixtures/checksums-v0.10.8.txt @@ -0,0 +1,25 @@ +2b193085410af3801634a522f4b17dcd6699695e015a068393c87817c1d260d4 codebase-memory-mcp-darwin-amd64.tar.gz +9bd840dfb3ec7eaef4f310382057adaa5b0e904df883104d03ffcf39836afd07 codebase-memory-mcp-darwin-arm64.tar.gz +6eef49652bc0c7820f43114125044d40bf7f4d97c11b2592f6b0f6a307702325 codebase-memory-mcp-linux-amd64-portable.tar.gz +e5cba4cad6ca8254a85f45041fc8a831908d7d5cb64f98fc3f8eb70a58671793 codebase-memory-mcp-linux-amd64.tar.gz +5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781 codebase-memory-mcp-linux-arm64-portable.tar.gz +e2804a20f5a6fc392af361525a232703e351b7d1aacb81b88eef806eec5959fa codebase-memory-mcp-linux-arm64.tar.gz +b43ad982994c4d829670749e08d3b622a74bb20041fc0a7d02bef6113f81c34d codebase-memory-mcp-windows-amd64.zip +254b26e819f00bab7f430c5f809d37d22b07bb3eb6427e290e5a27ba5b8e983e codebase-memory-mcp-windows-arm64.zip +110f85f12632d7f6749a6e8e07f8ab66719bbf779c2e53a2b5e08291d86df1c7 codebase-memory-mcp-darwin-amd64.mcpb +3a810bb817f628ae4e88634f985305428a887745f437295871bbacd2edf2deb2 codebase-memory-mcp-darwin-arm64.mcpb +13a33ed0ee74d15c2b3ec49c8e0dc66d2ef61e980731786db25b8d61af3522b3 codebase-memory-mcp-linux-amd64-portable.mcpb +33a0999b986c9d6c566db5360393807b3a6c767fdc0ac2d669a257c8709a781f codebase-memory-mcp-linux-arm64-portable.mcpb +7bcc606174bd4d0730ea5d51ab90cae296c50968599eceaf3a2c1db61357e214 codebase-memory-mcp-windows-amd64.mcpb +a3916edabb4e99dabe0c9d3d4eccce0e0df7247d927bb011bc98900e67ef9948 codebase-memory-mcp-windows-arm64.mcpb +05192881288fd561de482e069f014657dd7f1e05d8484158e6a41c81cdc6ee78 release-candidates.tsv +159e63784246b6d4a5d23e6854fe5ba06e3086eddedba3442c45f8d9924dc54f virustotal-candidate-results.tsv +c73fd277802b0d8b499a71600d1abcf3230b147ae1cbec8a5cbe69c8b04b4fbe release-selection.tsv +2b193085410af3801634a522f4b17dcd6699695e015a068393c87817c1d260d4 codebase-memory-mcp-ui-darwin-amd64.tar.gz +9bd840dfb3ec7eaef4f310382057adaa5b0e904df883104d03ffcf39836afd07 codebase-memory-mcp-ui-darwin-arm64.tar.gz +6eef49652bc0c7820f43114125044d40bf7f4d97c11b2592f6b0f6a307702325 codebase-memory-mcp-ui-linux-amd64-portable.tar.gz +e5cba4cad6ca8254a85f45041fc8a831908d7d5cb64f98fc3f8eb70a58671793 codebase-memory-mcp-ui-linux-amd64.tar.gz +5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781 codebase-memory-mcp-ui-linux-arm64-portable.tar.gz +e2804a20f5a6fc392af361525a232703e351b7d1aacb81b88eef806eec5959fa codebase-memory-mcp-ui-linux-arm64.tar.gz +b43ad982994c4d829670749e08d3b622a74bb20041fc0a7d02bef6113f81c34d codebase-memory-mcp-ui-windows-amd64.zip +254b26e819f00bab7f430c5f809d37d22b07bb3eb6427e290e5a27ba5b8e983e codebase-memory-mcp-ui-windows-arm64.zip diff --git a/test/packaging/bundle.test.ts b/test/packaging/bundle.test.ts new file mode 100644 index 0000000..3aade52 --- /dev/null +++ b/test/packaging/bundle.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { copyFile, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; + +import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent"; + +/** + * The committed bundle, loaded the way OMP loads it. + * + * `test:packaging` rebuilds `dist/index.js` first, so a green run here proves a + * fresh bundle builds and registers what it claims. It says nothing about + * whether the *committed* file matches the source beside it -- that is the + * `git diff --exit-code -- dist/index.js` step in CI, and the two checks are + * not substitutes for each other. + */ + +const MANIFEST = "package.json"; +const BUNDLE = "dist/index.js"; + +interface Manifest { + readonly omp?: { readonly extensions?: readonly string[] }; +} + +async function declaredEntries(): Promise { + const manifest = (await Bun.file(MANIFEST).json()) as Manifest; + return manifest.omp?.extensions ?? []; +} + +/** + * Loads the bundle from a directory holding nothing else. + * + * The isolation is the point, and it has two halves. The module half: a bundle + * that had quietly kept a runtime dependency would fail to import rather than + * resolve it from this repository's own `node_modules`. The environment half: + * loading *runs* the factory, which stands down when + * `/extensions/codebase-memory.ts` exists, so against the + * developer's real agent directory every assertion below is decided by state + * outside this repository -- and would go red the day upstream ships the + * native extension that guard exists to detect, for a reason having nothing to + * do with the bundle. The scratch directory is the agent directory too; it + * holds `index.js` and nothing else, which is what the assertion below proves. + */ +async function loadIsolated(): Promise { + const directory = await mkdtemp(join(tmpdir(), "cbm-bundle-")); + const previousAgentDir = process.env["PI_CODING_AGENT_DIR"]; + try { + const copied = join(directory, "index.js"); + await copyFile(resolve(BUNDLE), copied); + expect(await readdir(directory)).toEqual(["index.js"]); + + process.env["PI_CODING_AGENT_DIR"] = directory; + return await loadExtensions([copied], directory); + } finally { + // Restored, not deleted: an unset variable and one set to something else + // are different environments, and the suite has no licence to change which + // one the tests after it run in. + if (previousAgentDir === undefined) { + delete process.env["PI_CODING_AGENT_DIR"]; + } else { + process.env["PI_CODING_AGENT_DIR"] = previousAgentDir; + } + // The loaded module is already in memory, so the copy on disk has no + // reader left. Every call here makes a directory; none of them outlive it. + await rm(directory, { recursive: true, force: true }); + } +} + +describe("the declared extension entries", () => { + test("every omp.extensions entry resolves on disk in the built tree", async () => { + const entries = await declaredEntries(); + expect(entries.length).toBeGreaterThan(0); + + for (const entry of entries) { + expect(await Bun.file(resolve(entry)).exists()).toBe(true); + } + }); + + test("every declared entry default-exports a factory function", async () => { + const entries = await declaredEntries(); + expect(entries.length).toBeGreaterThan(0); + + for (const entry of entries) { + // Dynamic by necessity: the specifier is whatever the manifest declares, + // which is the thing under test. A static import would check a literal + // this test wrote instead of the entry OMP's installer will resolve. + const module = (await import(resolve(entry))) as { default?: unknown }; + expect(typeof module.default).toBe("function"); + } + }); +}); + +describe("the standalone bundle", () => { + test("loads through OMP's own loader with no errors", async () => { + const loaded = await loadIsolated(); + expect(loaded.errors).toEqual([]); + expect(loaded.extensions).toHaveLength(1); + }); + + test("registers the /cbm command and no tools", async () => { + const loaded = await loadIsolated(); + expect([...(loaded.extensions[0]?.commands.keys() ?? [])]).toEqual(["cbm"]); + expect([...(loaded.extensions[0]?.tools.keys() ?? [])]).toEqual([]); + }); + + /** + * The load-bearing negative. OMP treats a throwing or blocking `tool_call` + * handler as a refusal of the tool call, so a handler registered here could + * deny an operator's `grep` because a subprocess timed out. The event is not + * registered at all, and this is the assertion that keeps it that way. + * + * Asserted as the whole registered set rather than as + * `not.toContain("tool_call")`: that form also passes on an *empty* handler + * list, so it would report success for a factory that registered nothing. + */ + test("registers no tool_call handler", async () => { + const loaded = await loadIsolated(); + const handlers = [...(loaded.extensions[0]?.handlers.keys() ?? [])]; + expect(handlers.sort()).toEqual(["session_start"]); + }); +}); diff --git a/test/support/release.ts b/test/support/release.ts new file mode 100644 index 0000000..9b843ad --- /dev/null +++ b/test/support/release.ts @@ -0,0 +1,156 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { run } from "../../src/exec.ts"; +import type { ReleaseSource } from "../../src/release.ts"; +import type { Target } from "../../src/platform.ts"; + +/** + * Release archives built with the same `tar` acquisition reads them with. + * + * A hand-written archive parser in the test would agree with a hand-written one + * in the source; a real archive built by the real tool is what makes "reject an + * unexpected member" and "reject a symlinked member" claims about behaviour + * rather than about two implementations of the same guess. + */ + +/** One member of a built archive. */ +export interface Member { + readonly name: string; + /** File contents; mutually exclusive with {@link Member.symlinkTo}. */ + readonly contents?: string; + /** Makes the member a symbolic link to this target instead of a file. */ + readonly symlinkTo?: string; + readonly mode?: number; +} + +/** An archive on disk, with its digest. */ +export interface BuiltArchive { + readonly file: string; + readonly bytes: Uint8Array; + readonly digest: string; +} + +/** + * The four members a genuine release archive holds, with an executable that + * runs and prints `version`. + */ +export function releaseMembers(target: Target, version: string): Member[] { + return [ + { + name: target.executable, + contents: `#!/bin/sh\necho "codebase-memory-mcp ${version}"\n`, + mode: 0o755, + }, + { name: "LICENSE", contents: "MIT\n" }, + { name: target.installer, contents: "#!/usr/bin/env bash\nexit 0\n", mode: 0o755 }, + { name: "THIRD_PARTY_NOTICES.md", contents: "# notices\n" }, + ]; +} + +/** + * One staging root for every archive this process builds. + * + * A `mkdtemp` per archive had no matching removal, so every case that built one + * left a `.tar.gz` and an extracted tree in `$TMPDIR` -- 25 per unit run, and + * unbounded over a machine's life. One root means the cleanup is one removal + * rather than one per case, and it means a test file that forgets + * {@link dropBuiltArchives} leaks a single directory instead of a directory per + * archive. `process.on("exit")` would have needed no caller cooperation at all, + * but `bun test` never fires it. + */ +let stagingRoot: string | undefined; + +function stagingRootOnce(): string { + if (stagingRoot === undefined) { + // Created synchronously so two concurrent builds cannot each allocate a + // root and leave one of them without an owner. + stagingRoot = mkdtempSync(path.join(tmpdir(), "cbm-archive-")); + } + return stagingRoot; +} + +/** + * Removes everything {@link buildArchive} staged, for an `afterAll`. + * + * Re-arming rather than one-shot: `bun test` evaluates this module once for the + * whole run, so each test file's `afterAll` sees a root the previous file's + * `afterAll` already removed, and the next `buildArchive` has to be able to + * allocate a fresh one. + */ +export function dropBuiltArchives(): void { + if (stagingRoot === undefined) return; + rmSync(stagingRoot, { recursive: true, force: true }); + stagingRoot = undefined; +} + +/** Builds a `.tar.gz` holding exactly `members`. */ +export async function buildArchive(name: string, members: readonly Member[]): Promise { + const staging = await mkdtemp(path.join(stagingRootOnce(), "archive-")); + const content = path.join(staging, "content"); + await mkdir(content, { recursive: true }); + + for (const member of members) { + const entry = path.join(content, member.name); + await mkdir(path.dirname(entry), { recursive: true }); + if (member.symlinkTo !== undefined) { + await symlink(member.symlinkTo, entry); + continue; + } + await Bun.write(entry, member.contents ?? ""); + if (member.mode !== undefined) await chmod(entry, member.mode); + } + + const file = path.join(staging, name); + const packed = await run([ + "tar", + "-czf", + file, + "-C", + content, + ...members.map((member) => member.name), + ]); + if (!packed.ok) { + throw new Error(`could not build ${name}: ${packed.stderr || packed.spawnError}`); + } + + const bytes = new Uint8Array(await Bun.file(file).arrayBuffer()); + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(bytes); + return { file, bytes, digest: hasher.digest("hex") }; +} + +export interface FakeSourceOptions { + readonly tag: string; + readonly archiveName: string; + readonly bytes: Uint8Array; + /** + * The digest `checksums.txt` publishes. Defaults to the archive's real + * digest; a different value is how the mismatch path is reached. + */ + readonly publishedDigest: string; +} + +/** A {@link ReleaseSource} serving one prepared archive, with a call log. */ +export interface FakeSource extends ReleaseSource { + /** Every asset name requested, in order. */ + readonly requested: string[]; +} + +export function fakeSource(options: FakeSourceOptions): FakeSource { + const requested: string[] = []; + const encoder = new TextEncoder(); + return { + requested, + latestTag: async () => options.tag, + checksums: async () => + encoder.encode(`${options.publishedDigest} ${options.archiveName}\n`), + asset: async (_tag, name) => { + requested.push(name); + if (name !== options.archiveName) throw new Error(`no such asset: ${name}`); + return options.bytes; + }, + }; +} diff --git a/test/support/scratch.ts b/test/support/scratch.ts new file mode 100644 index 0000000..2cb5445 --- /dev/null +++ b/test/support/scratch.ts @@ -0,0 +1,82 @@ +import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { Host } from "../../src/paths.ts"; + +/** + * A throwaway `HOME` and `PATH`, so a test can run the real lifecycle and then + * assert what was *not* written. + * + * Every path in this package derives from an explicit {@link Host} rather than + * from `os.homedir()` and `process.env`, which is what makes this possible -- + * and what makes "no code path writes to `~/.local/bin`" a checkable claim + * instead of a code-reading exercise. + */ +export interface Scratch { + /** The temporary root holding everything this scratch owns. */ + readonly root: string; + /** The fake home directory. */ + readonly home: string; + /** The only directory on the scratch `PATH`. */ + readonly pathDir: string; + /** The host every call under test is given. */ + readonly host: Host; +} + +/** + * The system directories acquisition's own prerequisites live in: `tar` + * everywhere, plus `xattr` and `codesign` on macOS. + * + * Kept out of the default `PATH` so a resolution test cannot be decided by a + * real `codebase-memory-mcp` installation on the machine running the suite. + * A test that needs the tools asks for them, and says so by asking. + */ +const SYSTEM_TOOL_PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + +export interface ScratchOptions { + /** Appends {@link SYSTEM_TOOL_PATH} to the scratch `PATH`. */ + readonly systemTools?: boolean; + /** Extra environment variables, e.g. `OMP_PROFILE`. */ + readonly env?: Readonly>; +} + +/** + * Creates a scratch host. + * + * `PATH` names exactly one empty directory unless a test opts into the system + * tool directories. Inheriting the developer's own `PATH` would let a real + * `codebase-memory-mcp` installation decide the outcome of a resolution test. + */ +export async function makeScratch(options: ScratchOptions = {}): Promise { + const root = await mkdtemp(path.join(tmpdir(), "cbm-scratch-")); + const home = path.join(root, "home"); + const pathDir = path.join(root, "path-bin"); + await mkdir(home, { recursive: true }); + await mkdir(pathDir, { recursive: true }); + + const searchPath = [pathDir, ...(options.systemTools === true ? SYSTEM_TOOL_PATH : [])]; + return { + root, + home, + pathDir, + host: { home, env: { HOME: home, PATH: searchPath.join(":"), ...options.env } }, + }; +} + +/** Removes a scratch root. */ +export async function dropScratch(scratch: Scratch): Promise { + await rm(scratch.root, { recursive: true, force: true }); +} + +/** + * Writes an executable shell script at `file`, creating its parent. + * + * Stands in for the real binary wherever a test only needs something that runs + * and prints a version -- resolution ordering, the smoke check, adoption. + */ +export async function writeFakeExecutable(file: string, body: string): Promise { + await mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, `#!/bin/sh\n${body}\n`); + await chmod(file, 0o755); +} diff --git a/test/unit/acquire.test.ts b/test/unit/acquire.test.ts new file mode 100644 index 0000000..9f13b9d --- /dev/null +++ b/test/unit/acquire.test.ts @@ -0,0 +1,360 @@ +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmod, readdir, rm, stat } from "node:fs/promises"; +import path from "node:path"; + +import { acquire, normalizeVersion, repairMacOsSignature } from "../../src/acquire.ts"; +import { describeTarget } from "../../src/platform.ts"; +import { managedBinRoot, managedExecutable, packageRoot } from "../../src/paths.ts"; +import { + buildArchive, + dropBuiltArchives, + fakeSource, + releaseMembers, + type BuiltArchive, + type Member, +} from "../support/release.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +/** + * The host target is used rather than a fixed one so the macOS repair branch -- + * `xattr` then `codesign` -- is exercised where it exists and skipped where it + * does not. Both are the real path for the machine running the suite. + */ +const TARGET = describeTarget(process.platform === "darwin" ? "darwin" : "linux", "arm64"); +const VERSION = "0.10.8"; +const NEWER = "0.11.0"; +const TAG = `v${VERSION}`; + +let scratch: Scratch; + +beforeEach(async () => { + // Acquisition shells out to `tar`, and on macOS to `xattr` and `codesign`. + scratch = await makeScratch({ systemTools: true }); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +afterAll(() => { + dropBuiltArchives(); +}); + +/** Whether anything at all exists under the root this package owns. */ +async function packageRootEntries(): Promise { + try { + return await readdir(packageRoot(scratch.host)); + } catch { + return []; + } +} + +interface AbortCase { + readonly scenario: string; + /** The archive members, which is what each failure is really about. */ + readonly members: (target: typeof TARGET) => Member[]; + /** + * The digest `checksums.txt` publishes, resolved from the built archive. + * + * A column rather than a sentinel the body decodes: every case but the + * mismatch publishes the archive's own digest, and a literal is how the + * mismatch path is reached without also corrupting the archive. + */ + readonly publishedDigest: (archive: BuiltArchive) => string; + /** The text the refusal must name. */ + readonly reported: RegExp; +} + +const aborts: AbortCase[] = [ + { + scenario: "a digest mismatch aborts and names both digests", + members: (target) => releaseMembers(target, VERSION), + publishedDigest: () => "0".repeat(64), + reported: /SHA-256 mismatch for .*: published 0{64}, downloaded [0-9a-f]{64}/u, + }, + { + scenario: "an unexpected archive member aborts before extraction", + members: (target) => [ + ...releaseMembers(target, VERSION), + { name: "postinstall.sh", contents: "#!/bin/sh\nexit 0\n", mode: 0o755 }, + ], + publishedDigest: (archive) => archive.digest, + reported: /unexpected member: "postinstall\.sh"/u, + }, + { + scenario: "a duplicated archive member aborts rather than last-one-wins", + members: (target) => [...releaseMembers(target, VERSION), { name: "LICENSE", contents: "MIT\n" }], + publishedDigest: (archive) => archive.digest, + reported: /contains member LICENSE 2 times/u, + }, + { + scenario: "a missing archive member aborts", + members: (target) => releaseMembers(target, VERSION).filter((m) => m.name !== "LICENSE"), + publishedDigest: (archive) => archive.digest, + reported: /missing member: LICENSE/u, + }, + { + scenario: "a symlinked executable aborts even though its name is expected", + members: (target) => [ + ...releaseMembers(target, VERSION).filter((m) => m.name !== target.executable), + { name: target.executable, symlinkTo: "/bin/sh" }, + ], + publishedDigest: (archive) => archive.digest, + reported: /not a regular file: codebase-memory-mcp/u, + }, + { + scenario: "an executable that fails --version aborts before adoption", + members: (target) => [ + ...releaseMembers(target, VERSION).filter((m) => m.name !== target.executable), + { name: target.executable, contents: "#!/bin/sh\nexit 3\n", mode: 0o755 }, + ], + publishedDigest: (archive) => archive.digest, + reported: /failed to run `--version`/u, + }, + { + // `./`, `/`, and a whitespace-only name are all real tar spellings, and all + // three collapsed to the empty string under the previous normalization and + // were skipped -- which let an archive holding a fifth member satisfy a + // four-member closed set. This is the one of the three a real file on disk + // can be made to produce. + scenario: "a member whose name is only whitespace is refused rather than skipped", + members: (target) => [...releaseMembers(target, VERSION), { name: " ", contents: "ws\n" }], + publishedDigest: (archive) => archive.digest, + reported: /unexpected member: " "/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = aborts.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("a failed acquisition writes nothing under the package-owned root", () => { + test.each(aborts)("$scenario", async ({ members, publishedDigest, reported }) => { + const archive = await buildArchive(TARGET.archive, members(TARGET)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: publishedDigest(archive), + }); + + const attempt = acquire({ host: scratch.host, target: TARGET, source }); + await expect(attempt).rejects.toThrow(reported); + expect(await packageRootEntries()).toEqual([]); + }); +}); + +describe("a verified acquisition", () => { + test("adopts the executable under bin/ and reports what it runs", async () => { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + const acquired = await acquire({ host: scratch.host, target: TARGET, source }); + + expect(acquired.version).toBe(VERSION); + expect(acquired.digest).toBe(archive.digest); + expect(acquired.reportedVersion).toBe(`codebase-memory-mcp ${VERSION}`); + expect(acquired.executable).toBe( + `${managedBinRoot(scratch.host)}/${VERSION}/${TARGET.executable}`, + ); + expect(await Bun.file(acquired.executable).exists()).toBe(true); + expect(source.requested).toEqual([TARGET.archive]); + }); + + test("resolves the newest tag when no version is requested", async () => { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + expect((await acquire({ host: scratch.host, target: TARGET, source })).version).toBe(VERSION); + }); + + test("the ./ spelling every tar writes for a member is accepted", async () => { + const members = releaseMembers(TARGET, VERSION).map((member) => ({ + ...member, + name: `./${member.name}`, + })); + const archive = await buildArchive(TARGET.archive, members); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + const acquired = await acquire({ host: scratch.host, target: TARGET, source }); + + expect(acquired.version).toBe(VERSION); + expect(await Bun.file(acquired.executable).exists()).toBe(true); + }); + + test("adopting a newer version leaves the previous version's executable in place", async () => { + const first = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + await acquire({ + host: scratch.host, + target: TARGET, + source: fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: first.bytes, + publishedDigest: first.digest, + }), + }); + + const second = await buildArchive(TARGET.archive, releaseMembers(TARGET, NEWER)); + const acquired = await acquire({ + host: scratch.host, + target: TARGET, + source: fakeSource({ + tag: `v${NEWER}`, + archiveName: TARGET.archive, + bytes: second.bytes, + publishedDigest: second.digest, + }), + }); + + expect(acquired.version).toBe(NEWER); + expect(await Bun.file(managedExecutable(scratch.host, NEWER)).exists()).toBe(true); + // The previous version is what resolution falls back to if the pointer + // update does not land, so adopting over it is a worse failure than not + // adopting at all. + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + }); + + test("re-adopting a version replaces its executable instead of rewriting it in place", async () => { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, VERSION)); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + const first = await acquire({ host: scratch.host, target: TARGET, source }); + const before = await stat(first.executable); + const second = await acquire({ host: scratch.host, target: TARGET, source }); + const after = await stat(second.executable); + + // A different inode is the observable difference between "staged, then + // committed by one rename" and "written straight onto the live path". Only + // the first leaves the previously resolved executable whole when the write + // or the chmod behind it fails. + expect(after.ino).not.toBe(before.ino); + expect(after.mode & 0o777).toBe(0o755); + }); + + test("a scratch directory that cannot be removed does not fail a committed adoption", async () => { + // The smoke check runs the candidate, which is the only place a test can + // reach acquisition's own temporary directory. The candidate reports its + // directory and then makes it unremovable, so the `finally` cleanup fails + // after the executable has already been adopted. + const marker = path.join(scratch.root, "candidate-dir"); + const members: Member[] = [ + { + name: TARGET.executable, + mode: 0o755, + contents: + `#!/bin/sh\necho "codebase-memory-mcp ${VERSION}"\n` + + `dir=$(dirname "$0")\nprintf '%s' "$dir" > "${marker}"\nchmod 0500 "$dir"\n`, + }, + ...releaseMembers(TARGET, VERSION).filter((member) => member.name !== TARGET.executable), + ]; + const archive = await buildArchive(TARGET.archive, members); + const source = fakeSource({ + tag: TAG, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); + + try { + const acquired = await acquire({ host: scratch.host, target: TARGET, source }); + expect(await Bun.file(acquired.executable).exists()).toBe(true); + } finally { + if (await Bun.file(marker).exists()) { + const locked = await Bun.file(marker).text(); + await chmod(locked, 0o700); + await rm(locked, { recursive: true, force: true }); + } + } + }); +}); + +/** + * `xattr` and `codesign` exist only on macOS, and `run` resolves them from the + * process's own `PATH` rather than the scratch one, so this pair cannot be + * substituted. A path that does not exist is what makes both tools fail for a + * reason the test controls. + */ +describe.skipIf(process.platform !== "darwin")("the macOS repair step", () => { + test("a failed quarantine removal is refused rather than read as a missing attribute", async () => { + const missing = path.join(scratch.root, "not-extracted", "codebase-memory-mcp"); + + // Named `xattr`, not `codesign`: before the fix the discarded `xattr` + // result let the failure surface two steps later as a signing failure, + // which reports the wrong cause and only by accident reports at all. + await expect(repairMacOsSignature(scratch.host, missing)).rejects.toThrow( + /could not clear the candidate's extended attributes/u, + ); + }); +}); + +interface VersionCase { + readonly scenario: string; + readonly input: string; + readonly expected: string; +} + +const versions: VersionCase[] = [ + { scenario: "a bare version passes through", input: "0.10.8", expected: "0.10.8" }, + { scenario: "a leading v is stripped", input: "v0.10.8", expected: "0.10.8" }, + { scenario: "surrounding whitespace is trimmed", input: " v1.2.3 ", expected: "1.2.3" }, + { + scenario: "a prerelease suffix survives", + input: "v1.2.3-rc.1", + expected: "1.2.3-rc.1", + }, +]; + +interface BadVersionCase { + readonly scenario: string; + readonly input: string; +} + +/** + * A version reaches the filesystem as a directory name and the network as a URL + * segment, and it can arrive from a redirect's `location`. Each of these would + * escape `bin/` or name something else entirely. + */ +const badVersions: BadVersionCase[] = [ + { scenario: "a parent-directory traversal is refused", input: "../../etc" }, + { scenario: "an embedded separator is refused", input: "1.0/../../evil" }, + { scenario: "an empty version is refused", input: "" }, + { scenario: "a version that is only a v is refused", input: "v" }, + { scenario: "a leading dot is refused", input: ".hidden" }, +]; + +test("every version case names itself distinctly", () => { + const scenarios = [...versions, ...badVersions].map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("version normalization", () => { + test.each(versions)("$scenario", ({ input, expected }) => { + expect(normalizeVersion(input)).toBe(expected); + }); + + test.each(badVersions)("$scenario", ({ input }) => { + expect(() => normalizeVersion(input)).toThrow(/not a usable version/u); + }); +}); diff --git a/test/unit/checksums.test.ts b/test/unit/checksums.test.ts new file mode 100644 index 0000000..097d5e0 --- /dev/null +++ b/test/unit/checksums.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; + +import { CHECKSUMS_LIMIT_BYTES, parseChecksums } from "../../src/release.ts"; + +/** The `checksums.txt` v0.10.8 actually published, byte for byte. */ +const PUBLISHED = await Bun.file("test/fixtures/checksums-v0.10.8.txt").text(); + +const encoder = new TextEncoder(); + +const DARWIN_ARM64 = "codebase-memory-mcp-darwin-arm64.tar.gz"; +const DARWIN_ARM64_DIGEST = "9bd840dfb3ec7eaef4f310382057adaa5b0e904df883104d03ffcf39836afd07"; + +/** Matches the digest column of the darwin/arm64 line, for mutating it. */ +const DARWIN_ARM64_LINE = /^[0-9a-f]{64}(\s+codebase-memory-mcp-darwin-arm64\.tar\.gz)$/mu; + +interface PublishedDigestCase { + readonly scenario: string; + readonly archive: string; + readonly digest: string; +} + +const publishedDigests: PublishedDigestCase[] = [ + { + scenario: "the darwin/arm64 archive resolves to its published digest", + archive: DARWIN_ARM64, + digest: DARWIN_ARM64_DIGEST, + }, + { + scenario: "the darwin/amd64 archive resolves to its published digest", + archive: "codebase-memory-mcp-darwin-amd64.tar.gz", + digest: "2b193085410af3801634a522f4b17dcd6699695e015a068393c87817c1d260d4", + }, + { + scenario: "the linux/arm64 portable archive resolves to its published digest", + archive: "codebase-memory-mcp-linux-arm64-portable.tar.gz", + digest: "5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781", + }, + { + scenario: "the linux/amd64 portable archive resolves to its published digest", + archive: "codebase-memory-mcp-linux-amd64-portable.tar.gz", + digest: "6eef49652bc0c7820f43114125044d40bf7f4d97c11b2592f6b0f6a307702325", + }, + { + // The published file lists the non-portable Linux names beside the portable + // ones. A prefix match would verify one asset's bytes against the other's + // line, so the distinct digest is the assertion that selection is exact. + scenario: "the non-portable linux/arm64 archive resolves to a different digest", + archive: "codebase-memory-mcp-linux-arm64.tar.gz", + digest: "e2804a20f5a6fc392af361525a232703e351b7d1aacb81b88eef806eec5959fa", + }, + { + // Upstream publishes a `-ui-` alias for every asset, carrying the same + // bytes and therefore the same digest as the archive it aliases (fixture + // lines 5 and 22 are identical but for the name). So this case can only + // show the alias entry is there and gets selected -- a parser that resolved + // it through its neighbour's line would return the same digest and pass. + // Exactness is proven by the case above, whose digest genuinely differs + // from the name it sits beside. + scenario: "the -ui- alias for linux/arm64 portable is present and selectable", + archive: "codebase-memory-mcp-ui-linux-arm64-portable.tar.gz", + digest: "5697d986d9716c913163b4bff7b3a294287f3b843e993bc1ff71e78dcdc21781", + }, +]; + +interface RefusalCase { + readonly scenario: string; + /** The body handed to the parser, built from the published file where relevant. */ + readonly body: Uint8Array; + readonly archive: string; + /** The text the refusal must name. */ + readonly reported: RegExp; +} + +const refusals: RefusalCase[] = [ + { + scenario: "an archive absent from the file is refused rather than defaulted", + body: encoder.encode(PUBLISHED), + archive: "codebase-memory-mcp-plan9-arm64.tar.gz", + reported: /no SHA-256 digest for codebase-memory-mcp-plan9-arm64\.tar\.gz/u, + }, + { + scenario: "an archive whose line was removed is refused", + body: encoder.encode( + PUBLISHED.split("\n") + .filter((line) => !line.includes(DARWIN_ARM64)) + .join("\n"), + ), + archive: DARWIN_ARM64, + reported: /no SHA-256 digest/u, + }, + { + scenario: "a digest holding a non-hex character is refused rather than truncated", + body: encoder.encode( + PUBLISHED.replace( + DARWIN_ARM64_LINE, + `${DARWIN_ARM64_DIGEST.slice(0, 62)}ZZ$1`, + ), + ), + archive: DARWIN_ARM64, + reported: /invalid SHA-256 digest/u, + }, + { + scenario: "a digest of the wrong length is refused", + body: encoder.encode( + PUBLISHED.replace(DARWIN_ARM64_LINE, `${DARWIN_ARM64_DIGEST.slice(0, 62)}$1`), + ), + archive: DARWIN_ARM64, + reported: /invalid SHA-256 digest/u, + }, + { + scenario: "two different digests for one archive are refused rather than last-wins", + body: encoder.encode( + `${PUBLISHED}${"0".repeat(64)} ${DARWIN_ARM64}\n`, + ), + archive: DARWIN_ARM64, + reported: /conflicting SHA-256 digests/u, + }, + { + scenario: "a body over the 1 MiB limit is refused unread", + body: new Uint8Array(CHECKSUMS_LIMIT_BYTES + 1), + archive: DARWIN_ARM64, + reported: /over the 1048576 byte safety limit/u, + }, +]; + +interface AcceptedSpellingCase { + readonly scenario: string; + /** One `checksums.txt` line, spelled the way the case is about. */ + readonly line: string; + readonly digest: string; +} + +const acceptedSpellings: AcceptedSpellingCase[] = [ + { + scenario: "an uppercase digest is normalized rather than rejected", + line: `${DARWIN_ARM64_DIGEST.toUpperCase()} ${DARWIN_ARM64}`, + digest: DARWIN_ARM64_DIGEST, + }, + { + // Upstream's installer accepts `$2 == "*" archive`, which is how sha256sum + // spells a binary-mode line. + scenario: "the BSD binary-mode marker before the name is accepted", + line: `${DARWIN_ARM64_DIGEST} *${DARWIN_ARM64}`, + digest: DARWIN_ARM64_DIGEST, + }, + { + scenario: "the same digest repeated for one archive is accepted", + line: `${DARWIN_ARM64_DIGEST} ${DARWIN_ARM64}\n${DARWIN_ARM64_DIGEST} ${DARWIN_ARM64}`, + digest: DARWIN_ARM64_DIGEST, + }, + { + scenario: "a trailing field after the name does not prevent selection", + line: `${DARWIN_ARM64_DIGEST} ${DARWIN_ARM64} ignored`, + digest: DARWIN_ARM64_DIGEST, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [...publishedDigests, ...refusals, ...acceptedSpellings].map( + (entry) => entry.scenario, + ); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("the real published checksums.txt", () => { + test.each(publishedDigests)("$scenario", ({ archive, digest }) => { + expect(parseChecksums(encoder.encode(PUBLISHED), archive)).toBe(digest); + }); +}); + +describe("refusals", () => { + test.each(refusals)("$scenario", ({ body, archive, reported }) => { + expect(() => parseChecksums(body, archive)).toThrow(reported); + }); +}); + +describe("accepted digest spellings", () => { + test.each(acceptedSpellings)("$scenario", ({ line, digest }) => { + expect(parseChecksums(encoder.encode(`${line}\n`), DARWIN_ARM64)).toBe(digest); + }); +}); diff --git a/test/unit/exec.test.ts b/test/unit/exec.test.ts new file mode 100644 index 0000000..ce2c018 --- /dev/null +++ b/test/unit/exec.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; + +import { OUTPUT_LIMIT_BYTES, run } from "../../src/exec.ts"; + +/** + * The one place this package starts a subprocess, and the two bounds it owes + * its callers: a deadline, and a byte cap. + * + * The child is `process.execPath` -- whatever is running this suite can also + * run `-e` -- rather than a coreutils pipeline, so the flood is the same size + * on every host and needs nothing on `PATH`. + * + * These cases measure the real clock, which the project otherwise avoids in + * tests. There is no deterministic substitute: the properties are that a real + * subprocess is *ended* rather than waited out and that a real deadline wins + * over a real kernel pipe, and a fake clock advances neither a process's + * lifetime nor a descendant's grip on a file descriptor. The cost is paid only + * when the bound is broken -- every assertion below passes in tens of + * milliseconds and only a regression makes the suite wait. + */ + +/** Comfortably past the cap, and small enough to produce in milliseconds. */ +const FLOOD_BYTES = OUTPUT_LIMIT_BYTES * 3; + +/** + * How long a lingering child stays alive after it has written. + * + * Long enough that waiting for it instead of killing it is unmistakable in the + * wall clock, and short enough that a broken run still ends. + */ +const LINGER_MS = 8_000; + +interface FloodCase { + readonly scenario: string; + /** What the child writes, and which pipe it writes it to. */ + readonly emit: string; + /** The stream that must come back bounded. */ + readonly stream: "stdout" | "stderr"; + /** The refusal must name the stream that overflowed. */ + readonly reported: RegExp; +} + +const floods: FloodCase[] = [ + { + scenario: "a stdout flood is capped and the child is killed", + emit: `process.stdout.write("a".repeat(${FLOOD_BYTES}))`, + stream: "stdout", + reported: /wrote more than \d+ bytes to stdout/u, + }, + { + scenario: "a stderr flood is capped and the child is killed", + emit: `process.stderr.write("a".repeat(${FLOOD_BYTES}))`, + stream: "stderr", + reported: /wrote more than \d+ bytes to stderr/u, + }, + { + // The case that separates "stopped reading it" from "ended it". A child + // that floods and then goes on living costs `run` the child's whole + // remaining lifetime -- inside its deadline the entire time, so no other + // bound catches it. Without the kill the reviewer measured 8022 ms here. + scenario: "a child still running after it floods is killed rather than waited out", + emit: `process.stdout.write("a".repeat(${FLOOD_BYTES})); setTimeout(() => {}, ${LINGER_MS})`, + stream: "stdout", + reported: /wrote more than \d+ bytes to stdout/u, + }, +]; + +/** + * Children that close both pipes at once and then keep running. + * + * `exec 1>/dev/null 2>/dev/null` ends both reads immediately, so a bound that + * races only the drain lets the deadline pass unnoticed -- process lifetime has + * to be inside the bound too. Measured before the fix: 2012 ms and `ok: true` + * for the trapping form, and 102 ms with no `spawnError` at all for the other, + * which reported a deadline as an anonymous signal death. + */ +const closedPipes = [ + { + scenario: "a child that closes its pipes and traps SIGTERM is killed at the deadline", + script: "exec 1>/dev/null 2>/dev/null; trap '' TERM; sleep 2", + }, + { + scenario: "a child that closes its pipes and dies on SIGTERM still reports the deadline", + script: "exec 1>/dev/null 2>/dev/null; sleep 2", + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [...floods, ...closedPipes].map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("captured output", () => { + test.each(floods)("$scenario", async ({ emit, stream, reported }) => { + const started = Date.now(); + const result = await run([process.execPath, "-e", emit], { timeoutMs: 20_000 }); + const elapsed = Date.now() - started; + + // The deadline bounds how long a child runs, not how much it writes, so a + // candidate that answers `--version` with a gigabyte would be inside its + // timeout the whole time it was exhausting this process's memory. + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(reported); + expect(result[stream].length).toBeLessThanOrEqual(OUTPUT_LIMIT_BYTES); + expect(elapsed).toBeLessThan(LINGER_MS / 2); + }); + + test("output under the cap comes back whole", async () => { + const result = await run([process.execPath, "-e", 'process.stdout.write("small")']); + + expect(result.ok).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("small"); + expect(result.spawnError).toBeUndefined(); + }); + + test("a missing executable is a result rather than a throw", async () => { + const result = await run(["definitely-not-a-tool-on-this-host"]); + + expect(result.ok).toBe(false); + expect(result.spawnError).toBeDefined(); + }); +}); + +describe("the deadline", () => { + test("a descendant holding the pipe open does not outlast the deadline", async () => { + const started = Date.now(); + // `sh` exits at once; the backgrounded `sleep` inherits the pipe and keeps + // it open. Draining both pipes before observing the child's exit made the + // deadline advisory rather than authoritative -- 2014 ms measured against a + // 100 ms timeout, with `ok` returned as if the read had completed. + const result = await run(["sh", "-c", "(sleep 2) & printf ok"], { timeoutMs: 100 }); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(1_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 100ms/u); + }); + + test("a child ignoring SIGTERM is gone when the deadline fires", async () => { + const started = Date.now(); + // An ignored disposition survives `exec`, so the whole pipeline ignores + // SIGTERM. A deadline a `trap` can outlive is not a deadline. + const result = await run(["sh", "-c", "trap '' TERM; sleep 5"], { timeoutMs: 200 }); + const elapsed = Date.now() - started; + + expect(elapsed).toBeLessThan(2_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 200ms/u); + }); + + test("a descendant is reaped with the child rather than left running", async () => { + // `sh` backgrounds the sleep, prints its pid, and exits. The pid is on + // stdout even though the deadline fired, because those bytes had already + // arrived -- releasing a read abandons what has not come, not what has. + const held = await run(["sh", "-c", 'sleep 30 & printf %s "$!"'], { timeoutMs: 200 }); + + expect(held.ok).toBe(false); + const descendant = held.stdout.trim(); + expect(descendant).toMatch(/^\d+$/u); + + // `kill -0` asks whether the pid is still there without signalling it, so + // this needs no wait: the group kill has already happened or it has not. + const alive = await run( + ["sh", "-c", `kill -0 ${descendant} 2>/dev/null && echo alive || echo gone`], + { timeoutMs: 5_000 }, + ); + + expect(alive.stdout.trim()).toBe("gone"); + }); + + test("a descendant the reap cannot reach still does not hold the deadline", async () => { + // The residual case, and the one that separates releasing the readers from + // only killing. The grandchild is itself `detached`, so it leads its own + // session, leaves the group the reap can signal, and survives -- while + // still holding the stdout it inherited. Nothing portable can find it, so + // the bound has to come from abandoning the read. + const escaping = + 'const held = Bun.spawn(["sleep", "30"], ' + + '{ stdout: "inherit", stderr: "ignore", stdin: "ignore", detached: true }); ' + + "process.stdout.write(String(held.pid));"; + + const started = Date.now(); + const result = await run([process.execPath, "-e", escaping], { timeoutMs: 200 }); + const elapsed = Date.now() - started; + + try { + expect(elapsed).toBeLessThan(2_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 200ms/u); + } finally { + // It outlives the reap by design, so this test has to end it itself. + await run(["sh", "-c", `kill -9 ${result.stdout.trim()} 2>/dev/null || true`], { + timeoutMs: 5_000, + }); + } + }); + + test.each(closedPipes)("$scenario", async ({ script }) => { + const started = Date.now(); + const result = await run(["sh", "-c", script], { timeoutMs: 100 }); + const elapsed = Date.now() - started; + + // The read settling is not the process finishing. Racing only the drain + // left `child.exited` awaited with nothing bounding it, so a child that + // hands back its pipes and lives on set its own duration. + expect(elapsed).toBeLessThan(1_000); + expect(result.ok).toBe(false); + expect(result.spawnError).toMatch(/did not finish within 100ms/u); + }); +}); diff --git a/test/unit/lifecycle.test.ts b/test/unit/lifecycle.test.ts new file mode 100644 index 0000000..f991e65 --- /dev/null +++ b/test/unit/lifecycle.test.ts @@ -0,0 +1,562 @@ +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { + checkUpstream, + CHECK_INTERVAL_MS, + confirmedInstall, + install, + installHazard, + pin, + status, + syncEntry, + uninstall, + unpin, + update, + type Confirmer, + type Lifecycle, +} from "../../src/lifecycle.ts"; +import { entryStatus } from "../../src/mcp-config.ts"; +import { + EXECUTABLE_NAME, + managedExecutable, + mcpConfigPath, + packageRoot, + upstreamInstallDir, +} from "../../src/paths.ts"; +import { describeTarget, type Target } from "../../src/platform.ts"; +import { readState, updateState } from "../../src/state.ts"; +import { buildArchive, dropBuiltArchives, fakeSource, releaseMembers } from "../support/release.ts"; +import { dropScratch, makeScratch, writeFakeExecutable, type Scratch } from "../support/scratch.ts"; + +import type { ReleaseSource } from "../../src/release.ts"; + +const TARGET: Target = describeTarget(process.platform === "darwin" ? "darwin" : "linux", "arm64"); +const VERSION = "0.10.8"; + +let scratch: Scratch; + +beforeEach(async () => { + // The lifecycle shells out to `tar`, and on macOS to `xattr` and `codesign`. + scratch = await makeScratch({ systemTools: true }); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +// `bun test` never fires `process.on("exit")`, so the archive staging root has +// to be dropped per test file or it outlives the run. +afterAll(() => { + dropBuiltArchives(); +}); + +/** A release source serving one genuine archive for `version`. */ +async function servedSource(version: string): Promise { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, version)); + return fakeSource({ + tag: `v${version}`, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: archive.digest, + }); +} + +async function lifecycleFor(version: string): Promise { + return { host: scratch.host, target: TARGET, source: await servedSource(version) }; +} + +/** + * A lifecycle whose source serves a genuine archive under a digest that does + * not match it. + * + * The cheapest way to fail an acquisition at the last verification step that + * still involves the network, an archive and a real `tar` -- which is what a + * digest mismatch, an unexpected member and a failed smoke check all are from + * the lifecycle's point of view. + */ +async function mismatchedLifecycleFor(version: string): Promise { + const archive = await buildArchive(TARGET.archive, releaseMembers(TARGET, version)); + return { + host: scratch.host, + target: TARGET, + source: fakeSource({ + tag: `v${version}`, + archiveName: TARGET.archive, + bytes: archive.bytes, + publishedDigest: "0".repeat(64), + }), + }; +} + +/** A source whose every call fails the test: it must never be reached. */ +const forbiddenSource: ReleaseSource = { + latestTag: async () => { + throw new Error("the network was reached when it should not have been"); + }, + checksums: async () => { + throw new Error("the network was reached when it should not have been"); + }, + asset: async () => { + throw new Error("the network was reached when it should not have been"); + }, +}; + +describe("no lifecycle operation writes to ~/.local/bin", () => { + /** + * `~/.local/bin` is upstream's install directory. This package reads it -- it + * is third in the resolution order -- and must never write it: an executable + * there belongs to CBM's own installer and updater, whose activation path + * drains sessions and swaps the target transactionally. + */ + test("running install, sync, check, pin, unpin and uninstall leaves it absent", async () => { + const lifecycle = await lifecycleFor(VERSION); + + expect((await install(lifecycle)).ok).toBe(true); + expect((await syncEntry(lifecycle)).kind).toBe("unchanged"); + expect((await checkUpstream(lifecycle, { force: true })).kind).not.toBe("failed"); + expect((await pin(lifecycle, VERSION)).ok).toBe(true); + expect((await update(lifecycle)).ok).toBe(true); + expect((await unpin(lifecycle)).ok).toBe(true); + expect((await status(lifecycle)).resolved).not.toBeNull(); + expect((await uninstall(lifecycle)).ok).toBe(true); + + // Positively, by code: a bare `rejects.toThrow()` is satisfied by any + // rejection, including the ENOTDIR a lifecycle operation would raise by + // creating `~/.local/bin` as a regular file -- which is the very write this + // case exists to forbid. + expect( + await Bun.file(path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME)).exists(), + ).toBe(false); + await expect(readdir(upstreamInstallDir(scratch.host))).rejects.toThrow(/ENOENT/u); + }); + + test("an executable already there is neither replaced nor removed", async () => { + const adopted = path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME); + await writeFakeExecutable(adopted, `echo "codebase-memory-mcp 0.9.0"`); + const before = await stat(adopted); + const contents = await Bun.file(adopted).text(); + + const lifecycle = await lifecycleFor(VERSION); + expect((await syncEntry(lifecycle)).kind).toBe("wired"); + // The system copy resolves, so update only reports. + expect((await update(lifecycle)).message).toContain("is a system installation"); + expect((await uninstall(lifecycle)).ok).toBe(true); + + expect(await Bun.file(adopted).exists()).toBe(true); + expect(await Bun.file(adopted).text()).toBe(contents); + expect((await stat(adopted)).mtimeMs).toBe(before.mtimeMs); + }); +}); + +describe("the update check is rate-limited", () => { + test("a check recorded under 24 hours ago makes no network request", async () => { + const now = Date.now(); + await updateState(scratch.host, { lastCheckedAt: now - CHECK_INTERVAL_MS + 60_000 }); + + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: forbiddenSource }, + { now }, + ); + expect(report.kind).toBe("skipped"); + }); + + test("a check recorded over 24 hours ago is performed", async () => { + const now = Date.now(); + await updateState(scratch.host, { lastCheckedAt: now - CHECK_INTERVAL_MS - 1 }); + + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: await servedSource("0.11.0") }, + { now }, + ); + expect(report.kind).toBe("newer"); + expect((await readState(scratch.host)).upstreamVersion).toBe("0.11.0"); + }); + + test("a check timestamp in the future reads as stale rather than suppressing the check", async () => { + const now = Date.now(); + // A restored VM snapshot, an NTP step or a bad RTC moves the clock + // backwards, and a one-sided age test then reads the recorded time as + // "checked in 30 days' time" and honours it for the whole skew. + await updateState(scratch.host, { lastCheckedAt: now + 30 * CHECK_INTERVAL_MS }); + + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: await servedSource("0.11.0") }, + { now }, + ); + expect(report.kind).toBe("newer"); + expect((await readState(scratch.host)).lastCheckedAt).toBe(now); + }); + + /** + * `acquire` returns the version it was asked for, so recording it as the + * newest upstream release would report an old build as the newest one and + * suppress the real check for a day. Only a check establishes that field. + */ + test("an explicitly requested version is not recorded as the newest upstream release", async () => { + const report = await install(await lifecycleFor("0.9.0"), "0.9.0"); + expect(report.ok).toBe(true); + + const state = await readState(scratch.host); + expect(state.managedVersion).toBe("0.9.0"); + expect(state.managedDigest).toBeDefined(); + expect(state.upstreamVersion).toBeUndefined(); + expect(state.lastCheckedAt).toBeUndefined(); + }); + + test("a failed check is recorded so a broken network is retried daily, not per session", async () => { + const now = Date.now(); + const report = await checkUpstream( + { host: scratch.host, target: TARGET, source: forbiddenSource }, + { now }, + ); + + expect(report.kind).toBe("failed"); + expect((await readState(scratch.host)).lastCheckedAt).toBe(now); + }); + + test("a pinned version is reported rather than adopted", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + await pin(lifecycle, VERSION); + + const newer = { host: scratch.host, target: TARGET, source: await servedSource("0.11.0") }; + const report = await checkUpstream(newer, { force: true }); + expect(report.kind).toBe("newer"); + expect(report.message).toContain("is pinned"); + + // The pin holds: `update` reports and adopts nothing. + expect((await update(newer)).message).toContain("is pinned"); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + }); +}); + +describe("install wires the MCP entry", () => { + test("the entry names the adopted absolute path and re-running changes nothing", async () => { + const lifecycle = await lifecycleFor(VERSION); + const first = await install(lifecycle); + expect(first.ok).toBe(true); + + const file = mcpConfigPath(scratch.host); + const written = await Bun.file(file).text(); + const state = await readState(scratch.host); + const adopted = path.join(packageRoot(scratch.host), "bin", VERSION, EXECUTABLE_NAME); + + expect(state.wroteCommand).toBe(adopted); + expect((await entryStatus(scratch.host, adopted)).current).toBe(true); + + expect((await syncEntry(lifecycle)).kind).toBe("unchanged"); + expect(await Bun.file(file).text()).toBe(written); + }); + + test("a managed update moves the entry and says the session needs a reload", async () => { + await install(await lifecycleFor(VERSION)); + const report = await update(await lifecycleFor("0.11.0")); + + expect(report.ok).toBe(true); + expect(report.message).toContain("/mcp reload"); + expect((await readState(scratch.host)).managedVersion).toBe("0.11.0"); + }); + + /** + * Asserted through `syncEntry` rather than through `update`, because the + * discriminant is what the extension entry branches on: `rewired` is notified + * and `unchanged` is silent. A session that rewrites `mcp.json` to a new + * version directory and says nothing leaves MCP talking to the previous path + * for the rest of the session. + */ + test("session start rewires a drifted entry and reports the discriminant", async () => { + await install(await lifecycleFor(VERSION)); + + const moved = managedExecutable(scratch.host, "0.11.0"); + await writeFakeExecutable(moved, `echo "codebase-memory-mcp 0.11.0"`); + await updateState(scratch.host, { managedVersion: "0.11.0" }); + + const report = await syncEntry({ + host: scratch.host, + target: TARGET, + source: forbiddenSource, + }); + expect(report.kind).toBe("rewired"); + expect(report.message).toContain("/mcp reload"); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]?.command).toBe(moved); + }); +}); + +/** + * A failed acquisition is the case in which everything already working has to + * survive: the managed copy resolution falls back to, the operator's pin, the + * receipt that decides whether the MCP entry is this package's to take back, + * and the entry itself. + */ +describe("a failed acquisition leaves the working installation alone", () => { + test("install keeps the managed copy, the pin, the receipt and mcp.json", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + await pin(lifecycle, VERSION); + + const file = mcpConfigPath(scratch.host); + const before = await Bun.file(file).text(); + const recorded = await readState(scratch.host); + + const report = await install(await mismatchedLifecycleFor("0.11.0"), "0.11.0"); + expect(report.ok).toBe(false); + expect(report.message).toContain("install failed"); + + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + const after = await readState(scratch.host); + expect(after.managedVersion).toBe(VERSION); + expect(after.pin).toBe(VERSION); + expect(after.wroteCommand).toBe(recorded.wroteCommand); + expect(await Bun.file(file).text()).toBe(before); + }); + + test("update keeps the managed copy, the receipt and mcp.json", async () => { + await install(await lifecycleFor(VERSION)); + + const file = mcpConfigPath(scratch.host); + const before = await Bun.file(file).text(); + const recorded = await readState(scratch.host); + + const report = await update(await mismatchedLifecycleFor("0.11.0")); + expect(report.ok).toBe(false); + + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + const after = await readState(scratch.host); + expect(after.managedVersion).toBe(VERSION); + expect(after.wroteCommand).toBe(recorded.wroteCommand); + expect(await Bun.file(file).text()).toBe(before); + }); +}); + +describe("uninstall", () => { + test("removes the managed copy, its state, and the owned entry", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const report = await uninstall(lifecycle); + expect(report.ok).toBe(true); + expect(report.message).toContain("removed the owned MCP entry"); + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + expect((await entryStatus(scratch.host, null)).present).toBe(false); + }); + + test("leaves an unrelated MCP server in place", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const file = mcpConfigPath(scratch.host); + const document = JSON.parse(await Bun.file(file).text()) as { + mcpServers: Record; + }; + document.mcpServers["filesystem"] = { command: "npx", args: [] }; + await Bun.write(file, `${JSON.stringify(document, null, 2)}\n`); + + await uninstall(lifecycle); + + const after = JSON.parse(await Bun.file(file).text()) as { + mcpServers: Record; + }; + expect(Object.keys(after.mcpServers)).toEqual(["filesystem"]); + }); + + test("succeeds when nothing was ever installed", async () => { + const report = await uninstall({ host: scratch.host, target: TARGET, source: forbiddenSource }); + expect(report.ok).toBe(true); + expect(report.message).toContain("no managed copy was present"); + }); + + /** + * The two halves go together only when keeping the entry would leave it + * naming a file this command deleted. A file this package cannot read is that + * case: it cannot know what the entry names, and deleting the managed copy + * plus the state that identifies it would leave nothing able to reclaim the + * key either. Removing neither half is recoverable; removing only one is not. + */ + test("an unreadable file keeps the managed copy and the state that identifies it", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const file = mcpConfigPath(scratch.host); + const unparseable = '{ "mcpServers": { "codebase-memory-mcp": '; + await Bun.write(file, unparseable); + + const report = await uninstall(lifecycle); + expect(report.ok).toBe(false); + expect(report.message).toMatch(/not parseable JSON/u); + expect(report.message).toContain("were kept"); + + expect(await Bun.file(managedExecutable(scratch.host, VERSION)).exists()).toBe(true); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + expect(await Bun.file(file).text()).toBe(unparseable); + }); + + /** + * A refused entry naming a system CBM is a different case entirely: it is + * correctly wired to an executable this command never touches, so nothing + * dangles when the managed copy goes. Blocking here would make the managed + * copy unremovable by the one command whose job is removing it, and would + * tell the operator to re-point an entry that is already right. + */ + test("a foreign entry naming a system path still lets the managed copy go", async () => { + const lifecycle = await lifecycleFor(VERSION); + await install(lifecycle); + + const file = mcpConfigPath(scratch.host); + const foreign = `{\n "mcpServers": {\n "codebase-memory-mcp": {\n "command": "/usr/local/bin/codebase-memory-mcp"\n }\n }\n}\n`; + await Bun.write(file, foreign); + + const report = await uninstall(lifecycle); + expect(report.ok).toBe(true); + expect(report.message).toContain(`removed the managed copy of ${VERSION}`); + expect(report.message).toContain("left the MCP entry alone"); + expect(report.message).toContain("/usr/local/bin/codebase-memory-mcp"); + + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + expect(await Bun.file(file).text()).toBe(foreign); + }); +}); + +describe("status", () => { + test("reports a managed copy that is present but not resolved", async () => { + await install(await lifecycleFor(VERSION)); + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + + const report = await status({ host: scratch.host, target: TARGET, source: forbiddenSource }); + const text = report.lines.join("\n"); + + expect(text).toContain("source: system (PATH)"); + expect(text).toContain(`managed: ${VERSION}`); + expect(text).toContain("(present, not resolved)"); + }); + + test("names the resolved agent directory so a profile-scoped write is visible", async () => { + const report = await status({ host: scratch.host, target: TARGET, source: forbiddenSource }); + expect(report.lines.join("\n")).toContain(`agent dir: ${path.join(scratch.home, ".omp/agent")}`); + }); +}); + +/** A confirmer whose answer is fixed, recording whether it was consulted. */ +function fixedConfirmer(available: boolean, answer: boolean): Confirmer & { asked: string[] } { + const asked: string[] = []; + return { + available, + asked, + ask: async (_title, message) => { + asked.push(message); + return answer; + }, + }; +} + +describe("install is gated on confirmation when a system copy already resolves", () => { + test("with nothing resolving there is no hazard and no question is asked", async () => { + const confirmer = fixedConfirmer(true, false); + expect(await installHazard(await lifecycleFor(VERSION))).toBeNull(); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(true); + expect(confirmer.asked).toEqual([]); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + }); + + test("the hazard names the resolved executable and the shared cache root", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + + const hazard = await installHazard(await lifecycleFor(VERSION)); + expect(hazard).toContain(path.join(scratch.pathDir, EXECUTABLE_NAME)); + expect(hazard).toContain("one canonical cache root per account"); + }); + + test("declining downloads nothing", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + const confirmer = fixedConfirmer(true, false); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(true); + expect(report.message).toContain("Nothing was downloaded"); + expect(confirmer.asked).toHaveLength(1); + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + }); + + test("accepting downloads and adopts a second copy", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + const confirmer = fixedConfirmer(true, true); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(true); + expect(confirmer.asked).toHaveLength(1); + expect((await readState(scratch.host)).managedVersion).toBe(VERSION); + }); + + /** + * The spec is explicit that no command may block on input that cannot arrive. + * A session with no interactive UI must therefore report the hazard and stop. + */ + test("with no interactive UI it reports the reason and never asks", async () => { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + const confirmer = fixedConfirmer(false, true); + + const report = await confirmedInstall(await lifecycleFor(VERSION), undefined, confirmer); + expect(report.ok).toBe(false); + expect(report.message).toContain("no interactive UI"); + expect(report.message).toContain("nothing was downloaded"); + expect(confirmer.asked).toEqual([]); + expect(await Bun.file(packageRoot(scratch.host)).exists()).toBe(false); + }); +}); + +describe("session start with nothing resolving", () => { + test("writes no entry and names the install command", async () => { + const report = await syncEntry({ + host: scratch.host, + target: TARGET, + source: forbiddenSource, + }); + + expect(report.kind).toBe("unresolved"); + expect(report.message).toContain("/cbm install"); + expect(report.message).toContain("No MCP entry was written or changed"); + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(false); + }); + + test("a pre-existing foreign entry is refused rather than corrected", async () => { + const file = mcpConfigPath(scratch.host); + const foreign = `{\n "mcpServers": {\n "codebase-memory-mcp": {\n "command": "/opt/homebrew/bin/codebase-memory-mcp"\n }\n }\n}\n`; + await Bun.write(file, foreign); + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp 0.9.0"`, + ); + + const report = await syncEntry({ + host: scratch.host, + target: TARGET, + source: forbiddenSource, + }); + + expect(report.kind).toBe("refused"); + expect(report.message).toContain("/opt/homebrew/bin/codebase-memory-mcp"); + expect(await Bun.file(file).text()).toBe(foreign); + }); +}); diff --git a/test/unit/mcp-config.test.ts b/test/unit/mcp-config.test.ts new file mode 100644 index 0000000..4969e4b --- /dev/null +++ b/test/unit/mcp-config.test.ts @@ -0,0 +1,628 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { entryStatus, MCP_SCHEMA_URL, removeEntry, upsertEntry } from "../../src/mcp-config.ts"; +import { managedBinRoot, managedExecutable, mcpConfigPath } from "../../src/paths.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +const OURS = "/home/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp"; +const MOVED = "/home/scratch/.omp/codebase-memory/bin/0.10.9/codebase-memory-mcp"; + +/** + * The two unrelated servers and the denylist, spelled the way OMP's own writer + * spells them: two-space indentation, trailing newline. + * + * Held as separate constants so a test can assert each one appears verbatim + * after a write, which is the property the whole "one owned key" constraint + * exists to deliver. + */ +const FILESYSTEM_BLOCK = ` "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/srv" + ] + }`; + +const GITHUB_BLOCK = ` "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + }`; + +const DISABLED_BLOCK = ` "disabledServers": [ + "github" + ]`; + +const NEIGHBOURS = `{ + "$schema": "${MCP_SCHEMA_URL}", + "mcpServers": { +${FILESYSTEM_BLOCK}, +${GITHUB_BLOCK} + }, +${DISABLED_BLOCK} +} +`; + +/** + * A file holding nothing but the `$schema` this package writes and the owned + * entry -- which is exactly what a file this package created looks like. + */ +const OURS_ONLY = `{ + "$schema": "${MCP_SCHEMA_URL}", + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "${OURS}", + "args": [] + } + } +} +`; + +/** The owned entry beside a `disabledServers` list the operator added. */ +const OURS_WITH_DENYLIST = `{ + "$schema": "${MCP_SCHEMA_URL}", + "mcpServers": { + "codebase-memory-mcp": { + "command": "${OURS}" + } + }, + "disabledServers": [] +} +`; + +/** The owned entry in a file whose `$schema` this package did not write. */ +const OURS_WITH_FOREIGN_SCHEMA = `{ + "$schema": "./mcp-schema.json", + "mcpServers": { + "codebase-memory-mcp": { + "command": "${OURS}" + } + } +} +`; + +/** + * How the file looks before the operation under test. + * + * `"none"` is no file at all. `"owned"` is an entry written through this + * package, which is the only way an entry it can decidably take back comes to + * exist. Anything else is literal file contents. + */ +type Before = "none" | "owned" | (string & {}); + +let scratch: Scratch; + +beforeEach(async () => { + scratch = await makeScratch(); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +/** Puts the scratch agent directory into the state `before` describes. */ +async function arrange(before: Before): Promise { + if (before === "none") return; + if (before === "owned") { + await upsertEntry(scratch.host, OURS, undefined); + return; + } + const file = mcpConfigPath(scratch.host); + await mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, before); +} + +/** + * A file whose owned entry names `command`, written by hand rather than through + * this package. + * + * Needed wherever the `command` is only known once a scratch host exists -- a + * path under that host's own managed bin root -- which a module-level literal + * cannot spell. + */ +function entryNaming(command: string): string { + return `{ + "mcpServers": { + "codebase-memory-mcp": { + "type": "stdio", + "command": "${command}", + "args": [] + } + } +} +`; +} + +interface WriteCase { + readonly scenario: string; + readonly before: Before; + /** The `command` this package's state says it last wrote. */ + readonly previouslyWrote: string | undefined; + readonly command: string; + readonly change: "created" | "updated" | "unchanged"; +} + +const writes: WriteCase[] = [ + { + scenario: "creating the file writes the single owned entry", + before: "none", + previouslyWrote: undefined, + command: OURS, + change: "created", + }, + { + scenario: "adding to a file with unrelated servers reports an update", + before: NEIGHBOURS, + previouslyWrote: undefined, + command: OURS, + change: "updated", + }, + { + scenario: "an entry that already names the resolved path is left as it is", + before: "owned", + previouslyWrote: OURS, + command: OURS, + change: "unchanged", + }, + { + scenario: "a moved executable updates the command", + before: "owned", + previouslyWrote: OURS, + command: MOVED, + change: "updated", + }, + { + // An entry naming the currently resolved path is ours to adopt even with + // nothing recorded, which is how a lost state file recovers. + scenario: "an entry naming the resolved path is adopted with no recorded write", + before: "owned", + previouslyWrote: undefined, + command: OURS, + change: "unchanged", + }, +]; + +interface WriteRefusalCase { + readonly scenario: string; + readonly before: string; + readonly previouslyWrote: string | undefined; + /** The text the refusal must name. */ + readonly reported: RegExp; +} + +const writeRefusals: WriteRefusalCase[] = [ + { + scenario: "an entry this package did not write is refused with both paths named", + before: `{ + "mcpServers": { + "codebase-memory-mcp": { + "command": "/opt/homebrew/bin/codebase-memory-mcp" + } + } +} +`, + previouslyWrote: undefined, + reported: + /already defines codebase-memory-mcp with command \/opt\/homebrew\/bin\/codebase-memory-mcp/u, + }, + { + scenario: "an unparseable file is refused as a structural problem naming the path", + before: '{ "mcpServers": { "codebase-memory-mcp": ', + previouslyWrote: OURS, + reported: /mcp\.json is not parseable JSON, so it was left untouched/u, + }, + { + scenario: "a document that is not a JSON object is refused", + before: "[]\n", + previouslyWrote: OURS, + reported: /does not hold a JSON object/u, + }, + { + // The one malformed shape the reader used to recognise by discarding: an + // `mcpServers` value that is not a map was replaced wholesale, taking the + // operator's content with it. + scenario: "an mcpServers value that is not an object is refused rather than replaced", + before: `{ + "mcpServers": [ + { "name": "keeper", "command": "/bin/true" } + ], + "other": 1 +} +`, + previouslyWrote: OURS, + reported: /mcpServers value that is not a JSON object/u, + }, +]; + +interface RemovalCase { + readonly scenario: string; + readonly before: Before; + readonly wroteCommand: string | undefined; + readonly change: "removed" | "absent"; +} + +const removals: RemovalCase[] = [ + { + scenario: "removal deletes the owned key", + before: "owned", + wroteCommand: OURS, + change: "removed", + }, + { + scenario: "removal succeeds when there is no file at all", + before: "none", + wroteCommand: OURS, + change: "absent", + }, + { + scenario: "removal succeeds when the file holds no owned key", + before: NEIGHBOURS, + wroteCommand: OURS, + change: "absent", + }, +]; + +interface RemovalRefusalCase { + readonly scenario: string; + readonly wroteCommand: string | undefined; + readonly reported: RegExp; +} + +const removalRefusals: RemovalRefusalCase[] = [ + { + scenario: "removal refuses an entry whose command someone else changed", + wroteCommand: MOVED, + reported: /is not what this package wrote/u, + }, + { + scenario: "removal refuses when nothing was ever recorded as written", + wroteCommand: undefined, + reported: /nothing recorded/u, + }, +]; + +interface RemovalFateCase { + readonly scenario: string; + readonly before: string; + /** Whether the file itself is still there once the owned key is gone. */ + readonly fileRemains: boolean; +} + +/** + * What removal does with the file, not just with the key. + * + * Rolling back is supposed to return the machine to its pre-install state, so a + * file this package created has to go with its last key -- while anything the + * operator had, including a `$schema` value this package did not write, is + * content it never owned and must survive. + */ +const removalFates: RemovalFateCase[] = [ + { + scenario: "a file holding only this package's schema and entry is removed with the key", + before: OURS_ONLY, + fileRemains: false, + }, + { + scenario: "a file holding a disabledServers list the operator added is kept", + before: OURS_WITH_DENYLIST, + fileRemains: true, + }, + { + scenario: "a file whose schema this package did not write is kept", + before: OURS_WITH_FOREIGN_SCHEMA, + fileRemains: true, + }, +]; + +interface UnreadableCase { + readonly scenario: string; + /** Puts something at `file` that exists and cannot be read. */ + readonly place: (file: string) => Promise; + /** The errno the report must name. */ + readonly errno: RegExp; +} + +/** + * A file that is present but unreadable is not an absent entry. + * + * Reachable without a hand edit: root-owned after a `sudo omp`, or mode 0600 + * under another uid. Reporting it as "absent" tells the operator the entry is + * missing from a file this package could not open, where the entry may well be. + */ +const unreadables: UnreadableCase[] = [ + { + scenario: "a file the process may not open is reported with its errno", + place: async (file) => { + await Bun.write(file, OURS_ONLY); + await chmod(file, 0o000); + }, + errno: /EACCES/u, + }, + { + scenario: "a directory where the file belongs is reported with its errno", + place: async (file) => { + await mkdir(file, { recursive: true }); + }, + errno: /EISDIR/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [ + ...writes, + ...writeRefusals, + ...removals, + ...removalRefusals, + ...removalFates, + ...unreadables, + ].map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("upsert", () => { + test.each(writes)("$scenario", async ({ before, previouslyWrote, command, change }) => { + await arrange(before); + + const outcome = await upsertEntry(scratch.host, command, previouslyWrote); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.change).toBe(change); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]).toEqual({ + type: "stdio", + command, + args: [], + }); + }); + + test.each(writeRefusals)("$scenario", async ({ before, previouslyWrote, reported }) => { + await arrange(before); + const outcome = await upsertEntry(scratch.host, OURS, previouslyWrote); + + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.reason).toMatch(reported); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(before); + }); + + test("a created file carries the schema OMP writes for its own managed files", async () => { + await upsertEntry(scratch.host, OURS, undefined); + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + $schema?: string; + }; + expect(written.$schema).toBe(MCP_SCHEMA_URL); + }); + + test("unrelated servers and disabledServers survive a write byte for byte", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + + const after = await Bun.file(mcpConfigPath(scratch.host)).text(); + expect(after).toContain(FILESYSTEM_BLOCK); + expect(after).toContain(GITHUB_BLOCK); + expect(after).toContain(DISABLED_BLOCK); + expect(after.endsWith("\n")).toBe(true); + }); + + test("a no-op re-run leaves the file byte-identical", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + const first = await Bun.file(file).text(); + + await upsertEntry(scratch.host, OURS, OURS); + expect(await Bun.file(file).text()).toBe(first); + }); + + test("a path change rewrites the command and nothing else", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + const before = await Bun.file(mcpConfigPath(scratch.host)).text(); + + await upsertEntry(scratch.host, MOVED, OURS); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(before.replace(OURS, MOVED)); + }); + + test("a four-space file keeps its own indentation", async () => { + await arrange('{\n "mcpServers": {}\n}\n'); + await upsertEntry(scratch.host, OURS, undefined); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toContain('\n "mcpServers"'); + }); + + test("fields the operator added to the owned entry are preserved", async () => { + await arrange( + `{\n "mcpServers": {\n "codebase-memory-mcp": {\n "command": "${OURS}",\n "timeout": 120000\n }\n }\n}\n`, + ); + await upsertEntry(scratch.host, MOVED, OURS); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]?.timeout).toBe(120000); + expect(written.mcpServers["codebase-memory-mcp"]?.command).toBe(MOVED); + }); + + /** + * A lost `state.json` -- `readState` falls back to an empty state on a + * truncated file, and uninstall deletes the same file -- leaves no recorded + * write, and then only the path can decide ownership. Nothing but this + * package ever writes under its own managed bin root. + */ + test("an entry under this package's own bin root is ours with nothing recorded", async () => { + const older = managedExecutable(scratch.host, "0.10.8"); + const newer = managedExecutable(scratch.host, "0.10.9"); + await arrange(entryNaming(older)); + + const outcome = await upsertEntry(scratch.host, newer, undefined); + expect(outcome.ok && outcome.change).toBe("updated"); + + const written = JSON.parse(await Bun.file(mcpConfigPath(scratch.host)).text()) as { + mcpServers: Record; + }; + expect(written.mcpServers["codebase-memory-mcp"]?.command).toBe(newer); + }); + + test("a directory whose name merely shares the bin root's prefix is still foreign", async () => { + const lookalike = path.join(`${managedBinRoot(scratch.host)}-backup`, "0.10.8", "cbm"); + await arrange(entryNaming(lookalike)); + + const resolved = managedExecutable(scratch.host, "0.10.9"); + const outcome = await upsertEntry(scratch.host, resolved, undefined); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.reason).toContain(lookalike); + }); + + test("a compact file whose entry already names the resolved path is not rewritten", async () => { + const compact = `{"$schema":"s","mcpServers":{"codebase-memory-mcp":{"type":"stdio","command":"${OURS}","args":[]}}}`; + await arrange(compact); + + const outcome = await upsertEntry(scratch.host, OURS, OURS); + expect(outcome.ok && outcome.change).toBe("unchanged"); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(compact); + }); + + test("a rewrite replaces the file rather than truncating it in place", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + const before = await stat(file); + + await upsertEntry(scratch.host, MOVED, OURS); + + // A durable write stages a temp file beside the target and renames it into + // place, so the visible file is a new inode. An in-place truncate keeps the + // inode, and the window it opens is one in which OMP reads a half-written + // document and silently loads zero user-level MCP servers. + expect((await stat(file)).ino).not.toBe(before.ino); + }); + + /** + * `rename` replaces the destination rather than truncating it, so the visible + * file takes the staging file's mode unless it is set first. This file can + * carry per-server `env` secrets, remote `headers` and an + * `auth.clientSecret`, and OMP's own writer creates it 0600 -- so any + * `mcp.json` last written by `/mcp add|enable|disable` is 0600 on disk, and a + * session-start correction that published it 0644 would be a disclosure. + */ + test("a rewrite preserves the destination's own mode", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + await chmod(file, 0o600); + + await upsertEntry(scratch.host, MOVED, OURS); + expect((await stat(file)).mode & 0o777).toBe(0o600); + }); + + test("a mode the operator widened is reproduced rather than narrowed", async () => { + await arrange("owned"); + const file = mcpConfigPath(scratch.host); + await chmod(file, 0o644); + + await upsertEntry(scratch.host, MOVED, OURS); + expect((await stat(file)).mode & 0o777).toBe(0o644); + }); + + test("a created file is not world-readable", async () => { + await upsertEntry(scratch.host, OURS, undefined); + expect((await stat(mcpConfigPath(scratch.host))).mode & 0o777).toBe(0o600); + }); + + test("a write leaves no staging file behind", async () => { + await arrange("owned"); + await upsertEntry(scratch.host, MOVED, OURS); + expect(await readdir(path.dirname(mcpConfigPath(scratch.host)))).toEqual(["mcp.json"]); + }); +}); + +describe("removal", () => { + test.each(removals)("$scenario", async ({ before, wroteCommand, change }) => { + await arrange(before); + + const outcome = await removeEntry(scratch.host, wroteCommand); + expect(outcome.ok).toBe(true); + if (!outcome.ok) return; + expect(outcome.change).toBe(change); + }); + + test.each(removalRefusals)("$scenario", async ({ wroteCommand, reported }) => { + await arrange("owned"); + const before = await Bun.file(mcpConfigPath(scratch.host)).text(); + + const outcome = await removeEntry(scratch.host, wroteCommand); + expect(outcome.ok).toBe(false); + if (outcome.ok) return; + expect(outcome.reason).toMatch(reported); + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(before); + }); + + test("every other key is byte-identical after the owned key is removed", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + await removeEntry(scratch.host, OURS); + + expect(await Bun.file(mcpConfigPath(scratch.host)).text()).toBe(NEIGHBOURS); + }); + + test("the file stays in place when other servers remain", async () => { + await arrange(NEIGHBOURS); + await upsertEntry(scratch.host, OURS, undefined); + await removeEntry(scratch.host, OURS); + + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(true); + }); + + test.each(removalFates)("$scenario", async ({ before, fileRemains }) => { + await arrange(before); + + const outcome = await removeEntry(scratch.host, OURS); + expect(outcome.ok && outcome.change).toBe("removed"); + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(fileRemains); + }); + + test("removal takes back an entry under this package's bin root with nothing recorded", async () => { + await arrange(entryNaming(managedExecutable(scratch.host, "0.10.8"))); + + const outcome = await removeEntry(scratch.host, undefined); + expect(outcome.ok && outcome.change).toBe("removed"); + }); +}); + +describe("status", () => { + test("reports an absent entry without creating one", async () => { + const reported = await entryStatus(scratch.host, OURS); + expect(reported.present).toBe(false); + expect(reported.current).toBe(false); + expect(await Bun.file(mcpConfigPath(scratch.host)).exists()).toBe(false); + }); + + test("reports a stale entry as present but not current", async () => { + await arrange("owned"); + const reported = await entryStatus(scratch.host, MOVED); + expect(reported.present).toBe(true); + expect(reported.command).toBe(OURS); + expect(reported.current).toBe(false); + }); + + test("reports an unreadable file as a problem rather than an absent entry", async () => { + await arrange("{ oops"); + expect((await entryStatus(scratch.host, OURS)).problem).toMatch(/not parseable JSON/u); + }); + + test.each(unreadables)("$scenario", async ({ place, errno }) => { + const file = mcpConfigPath(scratch.host); + await mkdir(path.dirname(file), { recursive: true }); + await place(file); + + const reported = await entryStatus(scratch.host, OURS); + expect(reported.problem).toMatch(errno); + expect(reported.problem).toContain(file); + + // And no write is attempted against a file whose content is unknown. + expect((await upsertEntry(scratch.host, OURS, OURS)).ok).toBe(false); + }); +}); diff --git a/test/unit/paths.test.ts b/test/unit/paths.test.ts new file mode 100644 index 0000000..cc302c2 --- /dev/null +++ b/test/unit/paths.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import path from "node:path"; + +import { upsertEntry } from "../../src/mcp-config.ts"; +import { agentDir, mcpConfigPath, packageRoot } from "../../src/paths.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +const COMMAND = "/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp"; + +interface AgentDirCase { + readonly scenario: string; + /** The environment beyond `HOME` and `PATH`. */ + readonly env: Readonly>; + /** The agent directory this environment must resolve to. */ + readonly expected: (scratch: Scratch) => string; +} + +/** + * OMP's own precedence, reproduced. + * + * `PI_CODING_AGENT_DIR` first is not a guess: OMP's CLI calls `setProfile(...)` + * on every start, and a named profile makes that call write the variable back + * as the profile's own agent directory. Inside a session the variable is + * therefore already OMP's answer. The profile branch is the fallback for a + * process that never went through that CLI. + */ +const agentDirs: AgentDirCase[] = [ + { + scenario: "no profile and no override resolves the default agent directory", + env: {}, + expected: (scratch) => path.join(scratch.home, ".omp/agent"), + }, + { + scenario: "OMP_PROFILE resolves that profile's agent directory", + env: { OMP_PROFILE: "work" }, + expected: (scratch) => path.join(scratch.home, ".omp/profiles/work/agent"), + }, + { + scenario: "PI_PROFILE is honoured as the legacy fallback", + env: { PI_PROFILE: "legacy" }, + expected: (scratch) => path.join(scratch.home, ".omp/profiles/legacy/agent"), + }, + { + // OMP resolves the canonical variable first and consults the legacy one only + // when the canonical one is undefined. + scenario: "OMP_PROFILE wins over PI_PROFILE", + env: { OMP_PROFILE: "work", PI_PROFILE: "legacy" }, + expected: (scratch) => path.join(scratch.home, ".omp/profiles/work/agent"), + }, + { + // An explicitly empty OMP_PROFILE selects the default profile rather than + // silently inheriting the legacy variable, which is OMP's own rule. + scenario: "an empty OMP_PROFILE selects the default profile, not PI_PROFILE", + env: { OMP_PROFILE: "", PI_PROFILE: "legacy" }, + expected: (scratch) => path.join(scratch.home, ".omp/agent"), + }, + { + scenario: "PI_CODING_AGENT_DIR takes precedence over a profile variable", + env: { PI_CODING_AGENT_DIR: "/tmp/cbm-explicit-agent", OMP_PROFILE: "work" }, + expected: () => "/tmp/cbm-explicit-agent", + }, + { + scenario: "PI_CONFIG_DIR renames the config directory every path hangs off", + env: { PI_CONFIG_DIR: ".omp-alt" }, + expected: (scratch) => path.join(scratch.home, ".omp-alt/agent"), + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = agentDirs.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("agent directory resolution", () => { + test.each(agentDirs)("$scenario", async ({ env, expected }) => { + const scratch = await makeScratch({ env }); + try { + expect(agentDir(scratch.host)).toBe(expected(scratch)); + } finally { + await dropScratch(scratch); + } + }); +}); + +describe("the package-owned root follows the config directory", () => { + test("the default config directory puts it under ~/.omp", async () => { + const scratch = await makeScratch(); + try { + expect(packageRoot(scratch.host)).toBe(path.join(scratch.home, ".omp", "codebase-memory")); + } finally { + await dropScratch(scratch); + } + }); + + test("a profile does not move it, so a managed copy survives a profile switch", async () => { + const plain = await makeScratch(); + const profiled = await makeScratch({ env: { OMP_PROFILE: "work" } }); + try { + expect(path.relative(plain.home, packageRoot(plain.host))).toBe( + path.relative(profiled.home, packageRoot(profiled.host)), + ); + } finally { + await dropScratch(plain); + await dropScratch(profiled); + } + }); +}); + +describe("the write target follows the agent directory", () => { + let scratch: Scratch; + + beforeEach(async () => { + scratch = await makeScratch({ env: { OMP_PROFILE: "work" } }); + }); + + afterEach(async () => { + await dropScratch(scratch); + }); + + test("an active profile is written to, and the default agent directory is not", async () => { + const outcome = await upsertEntry(scratch.host, COMMAND, undefined); + expect(outcome.ok && outcome.change).toBe("created"); + + const profileFile = path.join(scratch.home, ".omp/profiles/work/agent/mcp.json"); + const defaultFile = path.join(scratch.home, ".omp/agent/mcp.json"); + + expect(mcpConfigPath(scratch.host)).toBe(profileFile); + expect(await Bun.file(profileFile).exists()).toBe(true); + expect(await Bun.file(defaultFile).exists()).toBe(false); + }); + + test("an explicit agent directory is written to instead of the profile's", async () => { + const explicit = path.join(scratch.root, "explicit-agent"); + const host = { ...scratch.host, env: { ...scratch.host.env, PI_CODING_AGENT_DIR: explicit } }; + + const outcome = await upsertEntry(host, COMMAND, undefined); + expect(outcome.ok && outcome.change).toBe("created"); + + expect(await Bun.file(path.join(explicit, "mcp.json")).exists()).toBe(true); + expect( + await Bun.file(path.join(scratch.home, ".omp/profiles/work/agent/mcp.json")).exists(), + ).toBe(false); + }); +}); diff --git a/test/unit/platform.test.ts b/test/unit/platform.test.ts new file mode 100644 index 0000000..b216771 --- /dev/null +++ b/test/unit/platform.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; + +import { + describeTarget, + detectTarget, + UnsupportedPlatformError, + type TargetArch, + type TargetOs, +} from "../../src/platform.ts"; + +/** + * Every asset name `checksums.txt` listed for v0.10.8. + * + * Read from the recorded fixture rather than rebuilt from a template: the + * property under test is that this package's construction agrees with what + * upstream actually published, and a table built the same way as the + * implementation would agree with a typo in it too. + */ +const PUBLISHED_NAMES = new Set( + (await Bun.file("test/fixtures/checksums-v0.10.8.txt").text()) + .split("\n") + .map((line) => line.trim().split(/\s+/u)[1]) + .filter((name): name is string => name !== undefined), +); + +interface ArchiveNameCase { + readonly scenario: string; + readonly os: TargetOs; + readonly arch: TargetArch; + /** The asset name upstream publishes for this pair. */ + readonly archive: string; + /** + * Whether the name carries the `-portable` suffix. + * + * Linux must: the standard Linux build dynamically links glibc 2.38 or newer + * and fails on Debian 11, RHEL 8, and Ubuntu 20.04. macOS must not: no such + * variant is published, so a suffix there names nothing. + */ + readonly portable: boolean; + /** Whether the name appears in the recorded published fixture. */ + readonly published: boolean; +} + +const archiveNames: ArchiveNameCase[] = [ + { + scenario: "darwin/arm64 selects the plain macOS archive", + os: "darwin", + arch: "arm64", + archive: "codebase-memory-mcp-darwin-arm64.tar.gz", + portable: false, + published: true, + }, + { + scenario: "darwin/amd64 selects the plain macOS archive", + os: "darwin", + arch: "amd64", + archive: "codebase-memory-mcp-darwin-amd64.tar.gz", + portable: false, + published: true, + }, + { + scenario: "linux/arm64 selects the portable Linux archive", + os: "linux", + arch: "arm64", + archive: "codebase-memory-mcp-linux-arm64-portable.tar.gz", + portable: true, + published: true, + }, + { + scenario: "linux/amd64 selects the portable Linux archive", + os: "linux", + arch: "amd64", + archive: "codebase-memory-mcp-linux-amd64-portable.tar.gz", + portable: true, + published: true, + }, + { + // Described so a later change extends one seam rather than reverse-engineering + // the naming; `detectTarget` refuses the platform, so nothing acquires it. + scenario: "windows/amd64 names the zip archive it does not yet support", + os: "windows", + arch: "amd64", + archive: "codebase-memory-mcp-windows-amd64.zip", + portable: false, + published: true, + }, +]; + +interface ArchiveContentsCase { + readonly scenario: string; + readonly os: TargetOs; + readonly arch: TargetArch; + readonly container: "tar.gz" | "zip"; + readonly executable: string; + /** The closed member set, sorted; anything outside it is an integrity failure. */ + readonly members: readonly string[]; +} + +const archiveContents: ArchiveContentsCase[] = [ + { + scenario: "a POSIX target names the executable, licence, shell installer and notices", + os: "darwin", + arch: "arm64", + container: "tar.gz", + executable: "codebase-memory-mcp", + members: ["LICENSE", "THIRD_PARTY_NOTICES.md", "codebase-memory-mcp", "install.sh"], + }, + { + scenario: "a Windows target names the .exe and the PowerShell installer", + os: "windows", + arch: "amd64", + container: "zip", + executable: "codebase-memory-mcp.exe", + members: ["LICENSE", "THIRD_PARTY_NOTICES.md", "codebase-memory-mcp.exe", "install.ps1"], + }, +]; + +interface DetectionCase { + readonly scenario: string; + /** `process.platform` as the host reports it. */ + readonly platform: string; + /** `process.arch` as the host reports it. */ + readonly arch: string; + /** `os.cpus()[0]?.model`, which is how Rosetta is detected. */ + readonly cpuModel: string | undefined; + readonly expected: string; +} + +const detections: DetectionCase[] = [ + { + scenario: "an arm64 process on Apple Silicon resolves natively", + platform: "darwin", + arch: "arm64", + cpuModel: "Apple M1 Pro", + expected: "codebase-memory-mcp-darwin-arm64.tar.gz", + }, + { + scenario: "an x64 process on Apple Silicon is corrected to arm64 rather than translated", + platform: "darwin", + arch: "x64", + cpuModel: "Apple M1 Pro", + expected: "codebase-memory-mcp-darwin-arm64.tar.gz", + }, + { + scenario: "an x64 process on an Intel Mac stays amd64", + platform: "darwin", + arch: "x64", + cpuModel: "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz", + expected: "codebase-memory-mcp-darwin-amd64.tar.gz", + }, + { + scenario: "Linux never applies the Rosetta correction, whatever the brand string says", + platform: "linux", + arch: "x64", + cpuModel: "Apple M1 Pro", + expected: "codebase-memory-mcp-linux-amd64-portable.tar.gz", + }, + { + scenario: "a missing brand string leaves an x64 Mac on amd64", + platform: "darwin", + arch: "x64", + cpuModel: undefined, + expected: "codebase-memory-mcp-darwin-amd64.tar.gz", + }, +]; + +interface RefusalCase { + readonly scenario: string; + readonly platform: string; + readonly arch: string; + /** The text the refusal must name, so the operator knows what to do next. */ + readonly reported: RegExp; +} + +const refusals: RefusalCase[] = [ + { + scenario: "Windows is refused explicitly rather than half-implemented", + platform: "win32", + arch: "x64", + reported: /Windows is not supported yet/u, + }, + { + scenario: "an unsupported operating system is refused by name", + platform: "freebsd", + arch: "arm64", + reported: /unsupported operating system: freebsd/u, + }, + { + scenario: "an unsupported architecture is refused by name", + platform: "linux", + arch: "riscv64", + reported: /unsupported architecture: riscv64/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [ + ...archiveNames, + ...archiveContents, + ...detections, + ...refusals, + ].map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("archive name construction", () => { + test.each(archiveNames)("$scenario", ({ os, arch, archive, portable, published }) => { + const target = describeTarget(os, arch); + expect(target.archive).toBe(archive); + expect(target.archive.includes("-portable")).toBe(portable); + expect(PUBLISHED_NAMES.has(target.archive)).toBe(published); + }); +}); + +describe("archive contents", () => { + test.each(archiveContents)("$scenario", ({ os, arch, container, executable, members }) => { + const target = describeTarget(os, arch); + expect(target.container).toBe(container); + expect(target.executable).toBe(executable); + expect([...target.members].sort()).toEqual([...members]); + }); +}); + +describe("host detection", () => { + test.each(detections)("$scenario", ({ platform, arch, cpuModel, expected }) => { + expect(detectTarget(platform, arch, cpuModel).archive).toBe(expected); + }); + + test.each(refusals)("$scenario", ({ platform, arch, reported }) => { + expect(() => detectTarget(platform, arch, undefined)).toThrow(UnsupportedPlatformError); + expect(() => detectTarget(platform, arch, undefined)).toThrow(reported); + }); +}); diff --git a/test/unit/release-transport.test.ts b/test/unit/release-transport.test.ts new file mode 100644 index 0000000..5671f76 --- /dev/null +++ b/test/unit/release-transport.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from "bun:test"; + +import { + CHECKSUMS_LIMIT_BYTES, + fetchHttps, + nextHop, + readBounded, + tagFromLocation, +} from "../../src/release.ts"; + +/** + * The transport-downgrade defence and the release-tag parser. + * + * Both validate input this package does not control -- a redirect's `location` + * header -- and the tag one of them yields is interpolated into a download URL + * and used as an on-disk directory name. Neither is reachable through a real + * request without a TLS origin that redirects to plain HTTP, which is why the + * validation is a function. + */ + +const HTTPS_ORIGIN = "https://github.com/DeusData/codebase-memory-mcp/releases/latest"; + +/** One chunk of a streamed body, the size a real one arrives in. */ +const CHUNK_BYTES = 64 * 1024; + +interface HopCase { + readonly scenario: string; + /** The `location` header the hop carries. */ + readonly location: string | null; + /** The URL the hop must lead to. */ + readonly expected: string; +} + +const acceptedHops: HopCase[] = [ + { + scenario: "an absolute HTTPS location is followed as given", + location: "https://objects.githubusercontent.com/asset", + expected: "https://objects.githubusercontent.com/asset", + }, + { + scenario: "a path-relative location is resolved against the URL it came from", + location: "/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + expected: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + }, + { + // A scheme-relative location inherits the current scheme, which is HTTPS -- + // resolving against the current URL is what makes that true rather than + // leaving the hop protocol-less. + scenario: "a scheme-relative location inherits HTTPS", + location: "//objects.githubusercontent.com/asset", + expected: "https://objects.githubusercontent.com/asset", + }, +]; + +interface HopRefusalCase { + readonly scenario: string; + readonly location: string | null; + readonly reported: RegExp; +} + +const refusedHops: HopRefusalCase[] = [ + { + scenario: "a plain HTTP location is refused without being followed", + location: "http://objects.githubusercontent.com/asset", + reported: /refusing non-HTTPS redirect: http:\/\/objects\.githubusercontent\.com\/asset/u, + }, + { + scenario: "a loopback HTTP location is refused like any other downgrade", + location: "http://127.0.0.1:8080/asset", + reported: /refusing non-HTTPS redirect/u, + }, + { + scenario: "a non-HTTP scheme is refused", + location: "file:///etc/passwd", + reported: /refusing non-HTTPS redirect: file:\/\/\/etc\/passwd/u, + }, + { + scenario: "a missing location header is refused rather than retried", + location: null, + reported: /answered 302 with no location header/u, + }, + { + scenario: "an empty location header is refused", + location: "", + reported: /answered 302 with no location header/u, + }, +]; + +interface TagCase { + readonly scenario: string; + readonly status: number; + readonly location: string; + readonly tag: string; +} + +const acceptedTags: TagCase[] = [ + { + // The value GitHub actually answered when this was measured. + scenario: "the measured 302 to a tag yields that tag", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + tag: "v0.10.8", + }, + { + scenario: "a relative location to a tag yields that tag", + status: 302, + location: "/DeusData/codebase-memory-mcp/releases/tag/v1.0.0", + tag: "v1.0.0", + }, + { + scenario: "a 301 is accepted as a redirect", + status: 301, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.9.0", + tag: "v0.9.0", + }, + { + scenario: "a percent-encoded tag is decoded", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v1.0.0%2Bbuild", + tag: "v1.0.0+build", + }, +]; + +interface TagRefusalCase { + readonly scenario: string; + readonly status: number; + readonly location: string | null; + readonly reported: RegExp; +} + +const refusedTags: TagRefusalCase[] = [ + { + scenario: "a 200 is refused, because a tag can only come from a redirect", + status: 200, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + reported: /to redirect to a tag, got HTTP 200/u, + }, + { + scenario: "a 404 is refused", + status: 404, + location: null, + reported: /got HTTP 404/u, + }, + { + scenario: "a redirect with no location is refused", + status: 302, + location: null, + reported: /no location header/u, + }, + { + scenario: "a non-HTTPS release location is refused", + status: 302, + location: "http://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + reported: /refusing non-HTTPS release location/u, + }, + { + // A redirect that leads somewhere else entirely must not be mined for a + // path segment that happens to look like a version. + scenario: "a location outside the repository's tag path is refused", + status: 302, + location: "https://evil.example/DeusData/codebase-memory-mcp/releases/tag/v0.10.8", + reported: /unexpected release location/u, + }, + { + scenario: "a location naming another repository is refused", + status: 302, + location: "https://github.com/attacker/repo/releases/tag/v0.10.8", + reported: /unexpected release location/u, + }, + { + scenario: "a location with no tag after the prefix is refused", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/", + reported: /unexpected release tag in location/u, + }, + { + // The tag becomes a directory name under `bin/`, so a separator in it is a + // traversal attempt whatever it decodes from. + scenario: "a tag holding a path separator is refused", + status: 302, + location: "https://github.com/DeusData/codebase-memory-mcp/releases/tag/v1%2F..%2F..%2Fetc", + reported: /unexpected release tag in location/u, + }, +]; + +test("every case names itself distinctly", () => { + const scenarios = [...acceptedHops, ...refusedHops, ...acceptedTags, ...refusedTags].map( + (entry) => entry.scenario, + ); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("redirect hops", () => { + test.each(acceptedHops)("$scenario", ({ location, expected }) => { + expect(nextHop(HTTPS_ORIGIN, 302, location)).toBe(expected); + }); + + test.each(refusedHops)("$scenario", ({ location, reported }) => { + expect(() => nextHop(HTTPS_ORIGIN, 302, location)).toThrow(reported); + }); +}); + +describe("the initial request", () => { + test("a non-HTTPS URL is refused before any connection is opened", async () => { + await expect(fetchHttps("http://127.0.0.1:1/never")).rejects.toThrow( + /refusing non-HTTPS request: http:\/\/127\.0\.0\.1:1\/never/u, + ); + }); +}); + +describe("release tag resolution", () => { + test.each(acceptedTags)("$scenario", ({ status, location, tag }) => { + expect(tagFromLocation(status, location)).toBe(tag); + }); + + test.each(refusedTags)("$scenario", ({ status, location, reported }) => { + expect(() => tagFromLocation(status, location)).toThrow(reported); + }); +}); + +/** A body of `chunks` 64 KiB chunks that records how many were pulled. */ +function countedBody(chunks: number): { body: ReadableStream; pulled: () => number } { + const chunk = new Uint8Array(CHUNK_BYTES).fill(0x61); + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + pulled += 1; + if (pulled > chunks) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }); + return { body, pulled: () => pulled }; +} + +describe("the bounded body reader", () => { + test("a body under the limit is returned whole", async () => { + const { body } = countedBody(2); + expect((await readBounded(body, CHECKSUMS_LIMIT_BYTES, "checksums.txt")).byteLength).toBe( + 2 * CHUNK_BYTES, + ); + }); + + test("an oversized body is refused without being read to the end", async () => { + // 4 MiB offered against a 128 KiB limit. Counting the pulls is what + // separates a limit enforced while the body arrives from one checked after + // `arrayBuffer()` has already allocated every byte of it -- both refuse, + // and only one of them refuses before paying. + const { body, pulled } = countedBody(64); + const limit = 2 * CHUNK_BYTES; + + await expect(readBounded(body, limit, "checksums.txt")).rejects.toThrow( + /checksums\.txt is over the 131072 byte safety limit/u, + ); + expect(pulled()).toBeLessThanOrEqual(4); + }); + + test("a response with no body at all reads as empty", async () => { + expect((await readBounded(null, CHECKSUMS_LIMIT_BYTES, "checksums.txt")).byteLength).toBe(0); + }); +}); diff --git a/test/unit/resolve.test.ts b/test/unit/resolve.test.ts new file mode 100644 index 0000000..c798fc5 --- /dev/null +++ b/test/unit/resolve.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import path from "node:path"; + +import { + EXECUTABLE_NAME, + managedExecutable, + upstreamInstallDir, +} from "../../src/paths.ts"; +import { managedCopy, resolveExecutable } from "../../src/resolve.ts"; +import { writeState } from "../../src/state.ts"; +import { dropScratch, makeScratch, writeFakeExecutable, type Scratch } from "../support/scratch.ts"; + +/** + * Which copies exist for one case. + * + * Written as the world an operator would have -- something on `PATH`, something + * upstream installed, something this package downloaded -- rather than as the + * state fields resolution happens to read. + */ +interface Layout { + /** A copy on the scratch `PATH`, reporting this version. */ + readonly onPath?: string; + /** A copy at `~/.local/bin`, where upstream's installer puts one. */ + readonly localBin?: string; + /** A managed copy under this package's root, recorded as the pointer. */ + readonly managed?: string; + /** A recorded pin. */ + readonly pin?: string; + /** A recorded pointer with no file behind it, e.g. after a manual delete. */ + readonly danglingPointer?: string; + /** + * A copy on the scratch `PATH` that runs and exits non-zero -- a stub, a + * half-written file, a shim -- instead of reporting a version. + */ + readonly brokenOnPath?: boolean; +} + +async function place(scratch: Scratch, layout: Layout): Promise { + if (layout.onPath !== undefined) { + await writeFakeExecutable( + path.join(scratch.pathDir, EXECUTABLE_NAME), + `echo "codebase-memory-mcp ${layout.onPath}"`, + ); + } + if (layout.brokenOnPath === true) { + await writeFakeExecutable(path.join(scratch.pathDir, EXECUTABLE_NAME), "exit 7"); + } + if (layout.localBin !== undefined) { + await writeFakeExecutable( + path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME), + `echo "codebase-memory-mcp ${layout.localBin}"`, + ); + } + if (layout.managed !== undefined) { + await writeFakeExecutable( + managedExecutable(scratch.host, layout.managed), + `echo "codebase-memory-mcp ${layout.managed}"`, + ); + } + const pointer = layout.danglingPointer ?? layout.managed; + await writeState(scratch.host, { + ...(pointer === undefined ? {} : { managedVersion: pointer }), + ...(layout.pin === undefined ? {} : { pin: layout.pin }), + }); +} + +interface OrderCase { + readonly scenario: string; + readonly layout: Layout; + readonly source: "pin" | "system" | "managed"; + readonly origin: string; + /** The absolute path resolution must return for this layout. */ + readonly executable: (scratch: Scratch) => string; +} + +const ordering: OrderCase[] = [ + { + scenario: "a copy on PATH is adopted as a system installation", + layout: { onPath: "0.10.8" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, + { + scenario: "a copy in ~/.local/bin is adopted when PATH has none", + layout: { localBin: "0.10.8" }, + source: "system", + origin: "~/.local/bin", + executable: (scratch) => path.join(upstreamInstallDir(scratch.host), EXECUTABLE_NAME), + }, + { + scenario: "PATH wins over ~/.local/bin", + layout: { onPath: "0.10.8", localBin: "0.9.0" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, + { + scenario: "a managed copy resolves when no system copy exists", + layout: { managed: "0.10.8" }, + source: "managed", + origin: "bin/0.10.8", + executable: (scratch) => managedExecutable(scratch.host, "0.10.8"), + }, + { + // The whole point of the ordering: CBM owns one canonical cache root, so + // adopting the operator's existing installation is the only safe default. + scenario: "a system copy beats a managed copy that is also present", + layout: { onPath: "0.9.0", managed: "0.10.8" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, + { + scenario: "a pin overrides both a system and a managed copy", + layout: { onPath: "0.9.0", managed: "0.10.8", pin: "0.10.8" }, + source: "pin", + origin: "0.10.8", + executable: (scratch) => managedExecutable(scratch.host, "0.10.8"), + }, + { + scenario: "a pin with no managed copy behind it falls through to PATH", + layout: { onPath: "0.9.0", pin: "0.10.8" }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, + { + // Resolution deliberately never runs the candidate -- see the note on + // `resolveExecutable`. Adopting a path that turns out not to run is the + // chosen trade against spawning an unknown binary on every session start, + // and `/cbm status` is where a candidate that will not execute becomes + // visible, by reading its version separately. + scenario: "an executable on PATH that exits non-zero is still adopted", + layout: { brokenOnPath: true }, + source: "system", + origin: "PATH", + executable: (scratch) => path.join(scratch.pathDir, EXECUTABLE_NAME), + }, +]; + +let scratch: Scratch; + +beforeEach(async () => { + scratch = await makeScratch(); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +test("every case names itself distinctly", () => { + const scenarios = ordering.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("resolution order", () => { + test.each(ordering)("$scenario", async ({ layout, source, origin, executable }) => { + await place(scratch, layout); + + const resolution = await resolveExecutable(scratch.host); + expect(resolution.ok).toBe(true); + if (!resolution.ok) return; + + expect(resolution.resolved.source).toBe(source); + expect(resolution.resolved.origin).toBe(origin); + expect(resolution.resolved.executable).toBe(executable(scratch)); + }); +}); + +describe("a managed copy is reported even when it is not resolved", () => { + test("resolution prefers the system copy while the managed one stays on disk", async () => { + await place(scratch, { onPath: "0.9.0", managed: "0.10.8" }); + + const resolution = await resolveExecutable(scratch.host); + const managed = await managedCopy(scratch.host); + + expect(resolution.ok && resolution.resolved.source).toBe("system"); + expect(managed?.version).toBe("0.10.8"); + expect(await Bun.file(managed?.executable ?? "").exists()).toBe(true); + }); + + test("a pointer with no file behind it reports no managed copy", async () => { + await place(scratch, { onPath: "0.9.0", danglingPointer: "0.10.8" }); + expect(await managedCopy(scratch.host)).toBeNull(); + }); +}); + +describe("nothing resolves", () => { + test("the failure names both remedies", async () => { + await place(scratch, {}); + + const resolution = await resolveExecutable(scratch.host); + expect(resolution.ok).toBe(false); + if (resolution.ok) return; + + expect(resolution.reason).toContain("/cbm install"); + expect(resolution.reason).toContain("install.sh"); + }); +}); diff --git a/test/unit/state.test.ts b/test/unit/state.test.ts new file mode 100644 index 0000000..c4b1e7e --- /dev/null +++ b/test/unit/state.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +import { statePath } from "../../src/paths.ts"; +import { readState, updateState, writeState, type State } from "../../src/state.ts"; +import { dropScratch, makeScratch, type Scratch } from "../support/scratch.ts"; + +/** + * What `readState` must return for a document it did not write. + * + * The filtering this table pins is the only thing between a corrupted + * `state.json` and code that joins a recorded value straight into a path + * (`managedExecutable(host, state.managedVersion)`) or does arithmetic on it + * (the 24-hour check interval). The file is this package's own cache, so the + * recoverable reading of every malformed value is "forget what was cached" + * rather than a refusal that would fail a session start. + */ +interface SanitizationCase { + readonly scenario: string; + /** The file's raw bytes, written verbatim. */ + readonly document: string; + /** Everything the read must return, and nothing else. */ + readonly state: State; +} + +const sanitizations: SanitizationCase[] = [ + { + scenario: "a truncated document reads as empty rather than throwing", + document: '{"managedVersion":"0.10.8","pi', + state: {}, + }, + { + scenario: "a JSON array reads as empty", + document: "[]\n", + state: {}, + }, + { + scenario: "a JSON string reads as empty", + document: '"0.10.8"\n', + state: {}, + }, + { + scenario: "a JSON null reads as empty", + document: "null\n", + state: {}, + }, + { + scenario: "a numeric pin is dropped so no non-string reaches a path segment", + document: '{"managedVersion":"0.10.8","pin":10.8}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "an object managedVersion is dropped rather than joined into a path", + document: '{"managedVersion":{"toString":"0.10.8"},"pin":"0.10.8"}', + state: { pin: "0.10.8" }, + }, + { + scenario: "an empty-string managedVersion is dropped so the pointer never names bin/", + document: '{"managedVersion":"","pin":"0.10.8"}', + state: { pin: "0.10.8" }, + }, + { + // `1e999` is valid JSON and parses to `Infinity`, which is how a non-finite + // number reaches the rate-limit arithmetic without a hand-written literal. + scenario: "a non-finite lastCheckedAt is dropped so the check interval never reads Infinity", + document: '{"managedVersion":"0.10.8","lastCheckedAt":1e999}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "a string lastCheckedAt is dropped rather than subtracted from a timestamp", + document: '{"managedVersion":"0.10.8","lastCheckedAt":"1700000000000"}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "keys this package does not recognise are not carried through", + document: '{"managedVersion":"0.10.8","source":"github","nested":{"pin":"0.9.0"}}', + state: { managedVersion: "0.10.8" }, + }, + { + scenario: "a fully valid document survives field for field", + document: `{ + "managedVersion": "0.10.8", + "managedDigest": "e2804a20", + "pin": "0.10.8", + "upstreamVersion": "0.11.0", + "lastCheckedAt": 1700000000000, + "wroteCommand": "/home/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp" +} +`, + state: { + managedVersion: "0.10.8", + managedDigest: "e2804a20", + pin: "0.10.8", + upstreamVersion: "0.11.0", + lastCheckedAt: 1700000000000, + wroteCommand: "/home/scratch/.omp/codebase-memory/bin/0.10.8/codebase-memory-mcp", + }, + }, +]; + +let scratch: Scratch; + +beforeEach(async () => { + scratch = await makeScratch(); +}); + +afterEach(async () => { + await dropScratch(scratch); +}); + +test("every case names itself distinctly", () => { + const scenarios = sanitizations.map((entry) => entry.scenario); + expect(new Set(scenarios).size).toBe(scenarios.length); +}); + +describe("a recorded state is sanitized on the way in", () => { + test.each(sanitizations)("$scenario", async ({ document, state }) => { + const file = statePath(scratch.host); + await mkdir(path.dirname(file), { recursive: true }); + await Bun.write(file, document); + + expect(await readState(scratch.host)).toEqual(state); + }); + + test("a missing file is the first-run case, not a failure", async () => { + expect(await readState(scratch.host)).toEqual({}); + }); +}); + +describe("the state is written durably", () => { + test("a rewrite replaces the file rather than truncating it in place", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + const before = await stat(statePath(scratch.host)); + + await writeState(scratch.host, { managedVersion: "0.11.0" }); + + // A durable write stages the document beside the target and renames it in, + // so the visible file is a new inode. An in-place truncate keeps the inode, + // and an interrupted one leaves a document that no longer parses -- which + // this reader degrades to the empty state, forgetting the operator's pin and + // the receipt that decides whether the MCP entry is ours to take back. + expect((await stat(statePath(scratch.host))).ino).not.toBe(before.ino); + }); + + /** + * `rename` replaces the destination rather than truncating it, so the visible + * file takes the staging file's mode unless it is set first. Nothing here is + * a secret today, but a package-private cache is not the operator's to have + * widened by a write they did not ask for. + */ + test("a rewrite preserves the destination's own mode", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + await chmod(statePath(scratch.host), 0o600); + + await writeState(scratch.host, { managedVersion: "0.11.0" }); + expect((await stat(statePath(scratch.host))).mode & 0o777).toBe(0o600); + }); + + /** + * The row that distinguishes reproducing the destination's mode from + * hardcoding a narrow one: 0600 arranged and 0600 asserted are the same + * assertion a `chmod(staging, 0o600)` would satisfy, so without a widened + * case this file cannot tell the two apart. + */ + test("a mode the operator widened is reproduced rather than narrowed", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + await chmod(statePath(scratch.host), 0o644); + + await writeState(scratch.host, { managedVersion: "0.11.0" }); + expect((await stat(statePath(scratch.host))).mode & 0o777).toBe(0o644); + }); + + test("a created state file is not world-readable", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + expect((await stat(statePath(scratch.host))).mode & 0o777).toBe(0o600); + }); + + test("a write leaves no staging file behind", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8" }); + expect(await readdir(path.dirname(statePath(scratch.host)))).toEqual(["state.json"]); + }); + + test("a merge keeps the fields it was not given and replaces the ones it was", async () => { + await writeState(scratch.host, { managedVersion: "0.10.8", pin: "0.10.8" }); + + expect(await updateState(scratch.host, { managedVersion: "0.11.0" })).toEqual({ + managedVersion: "0.11.0", + pin: "0.10.8", + }); + expect(await readState(scratch.host)).toEqual({ + managedVersion: "0.11.0", + pin: "0.10.8", + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0fc149a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,43 @@ +{ + // Type checking only. Bun executes TypeScript directly and `bun build` + // produces `dist/index.js`, so this project never emits through tsc: + // `tsc --noEmit` is the type gate, `bun test` is the behavior gate. + // + // `@types/bun` supplies every runtime type this package uses -- `Bun.which`, + // `Bun.spawn`, `Bun.CryptoHasher`, `Bun.file` -- so its version moves in step + // with the Bun version `.github/workflows/ci.yml` pins. + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "module": "Preserve", + "moduleResolution": "bundler", + "moduleDetection": "force", + "types": ["bun"], + + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + + // `strict` alone permits two mistakes this package cannot afford. + // + // `noUncheckedIndexedAccess` matters for `checksums.txt` parsing and + // archive-member accounting, both of which index into split results whose + // length they have just tested; without it a wrong test still type-checks. + // + // `exactOptionalPropertyTypes` matters because the owned `mcp.json` entry + // must be written with absent fields *omitted* rather than present as + // `undefined`: `JSON.stringify` drops an explicit `undefined`, so without + // this flag a key the writer believes it set can silently vanish from the + // operator's configuration. + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}