diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 70c42f4db..1b00ccfe4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -87,6 +87,15 @@ "metadata": { "recommended": false } + }, + { + "name": "aidd-maintenance", + "source": "./plugins/aidd-maintenance", + "description": "Framework maintenance: show the installed marketplace version and update available installations.", + "strict": true, + "metadata": { + "recommended": false + } } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07278c741..188a167cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,6 +258,7 @@ jobs: aidd-ui, aidd-telemetry, aidd-qa, + aidd-maintenance, ] steps: - name: Check if this plugin was released diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2335bfd65..9b75dcf1d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -9,5 +9,6 @@ "plugins/aidd-ui": "0.2.1-alpha.0", "plugins/aidd-telemetry": "0.4.0", "plugins/aidd-qa": "1.0.0", + "plugins/aidd-maintenance": "0.1.0", "cli": "5.6.0" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10a253ba9..1beaa5346 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,7 @@ Fork the repository, or branch from it if you have write access. | `make setup` | Installs dependencies and git hooks, registers the marketplace, installs the plugins into Claude Code and Codex | | `make doctor` | Checks your environment | | `make reload` | Reloads your checkout into your tools | +| `make check-hosts` | Tables what each installed tool really loads of the plugins | | `make check` | Runs the pre-commit checks | ## 3. The rules diff --git a/Makefile b/Makefile index 24aa053b7..ff9ff31f9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := help -.PHONY: help setup doctor check reload +.PHONY: help setup doctor check reload check-hosts help: ## List targets @grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed 's/:.*## /\t/' | sort @@ -15,6 +15,9 @@ doctor: ## Check the local toolchain check: ## Run the pre-commit checks pnpm exec lefthook run pre-commit -reload: ## Dev: reload all plugins into Claude, Codex, OpenCode, Copilot, and Cursor +reload: ## Dev: reload all plugins into Claude, Codex, OpenCode, Kilo, Copilot, and Cursor via aidd setup --global pnpm --dir cli build scripts/dev-sync.sh + +check-hosts: ## Dev: table what each installed tool really loads of the plugins + node scripts/check-hosts.cjs diff --git a/README.md b/README.md index 3ea77aa35..baf642f17 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best 🧱 **IDE agnostic** · 🏗️ **Legacy systems** · 🌱 **Token-optimized** · 🇫🇷 **Made in France**

- 9 plugins · 52 skills · 4 agents + 10 plugins · 53 skills · 4 agents

[![Open Source](https://img.shields.io/badge/Open_Source-Yes-yellow?logo=open-source-initiative&logoColor=white)](https://opensource.org/) @@ -207,6 +207,18 @@ Acceptance QA with video evidence + + + +preview +

🔄 aidd-maintenance

+1 skill · 1 hook
+Framework version and updates +
+ + + + diff --git a/aidd_docs/memory/architecture.md b/aidd_docs/memory/architecture.md index e8ae0d0aa..7ded30853 100644 --- a/aidd_docs/memory/architecture.md +++ b/aidd_docs/memory/architecture.md @@ -10,13 +10,13 @@ The macro technical shape: the stack, how the pieces fit, and the decisions behi | --- | --- | | Product | markdown — skills, agents, rules, templates. No framework runtime; an LLM interprets them. | | Delivery | Node `>=22.12`, pnpm. `cli/` is the `aidd` binary; `kanban/` is a private package. | -| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 9 plugins, no version among them. Versions are release-please's, in `deployment.md`. | +| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 10 plugins, no version among them. Versions are release-please's, in `deployment.md`. | ## How it fits together ```mermaid flowchart LR - Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 9"] + Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 10"] Plugins -->|ships| Surfaces["skills · agents · commands · hooks · rules"] CLI["cli/ · aidd"] -->|reads| Manifest CLI -->|installs| Target["a project's AI tool dir"] @@ -40,6 +40,6 @@ The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../do ## Gotchas -- 9 plugins ship, 3 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in, `aidd-qa` new and unproven outside this repository. +- 10 plugins ship, 3 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in, `aidd-qa` new and unproven outside this repository. - A skill never links outside itself: the tree ships both flat and as a marketplace, so no relative path survives both. - Bundled hooks run Node. No `node` on `PATH`, no memory refresh and no question about the task. diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 89190d715..4b95a2a54 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -58,7 +58,7 @@ flowchart TD - The umbrella stays a draft until every asset is attached; its publication is the event n8n listens to. - A failed video build keeps the release a draft, so nothing is announced without its video. A failed YouTube upload does not stop Discord and Skool: they post without the video link. Their text is the video's programme, one section per theme, so the announcements say what the video shows. -Config: `release-please-config.json`, eleven packages. Manifest: `.release-please-manifest.json`. +Config: `release-please-config.json`, twelve packages. Manifest: `.release-please-manifest.json`. ## Gotchas @@ -66,7 +66,7 @@ Config: `release-please-config.json`, eleven packages. Manifest: `.release-pleas ## Local reload -`make reload` syncs all plugins and generic delegation guidance to installed hosts; details in `scripts/dev-sync.sh`. +`make reload` installs every plugin into the installed hosts with `aidd setup --global` from this checkout, then syncs generic delegation guidance; details in `scripts/dev-sync.sh`. ## Monitoring diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md index dc2bb5f41..f1afc7e3d 100644 --- a/aidd_docs/memory/project-brief.md +++ b/aidd_docs/memory/project-brief.md @@ -33,7 +33,7 @@ What this project is, the problem it solves, and its domain language. The non-de | Capability | Entry | | --- | --- | -| Install and refresh a tool's configuration | `aidd setup --ai --ide ` | +| Install and refresh a tool's configuration | `aidd setup --ide `; `--global` for every project on the machine | | Install plugins from a marketplace | `aidd plugin install`, `aidd marketplace add` | | Build a target-native distribution | `aidd translate --to --out ` | | Bootstrap and refresh project memory | `aidd-context:02-project-memory` | diff --git a/cli/README.md b/cli/README.md index a0d93a103..1c4de4420 100644 --- a/cli/README.md +++ b/cli/README.md @@ -25,13 +25,19 @@ Check: `aidd --version`. | Step | Run | | --- | --- | -| Bootstrap the project | `aidd setup --ai claude --ide vscode --plugins recommended --yes` | +| Bootstrap the project | `aidd setup --ide claude,vscode --plugins recommended --yes` | | Add a plugin | `aidd plugin install aidd-dev` | | Check the state | `aidd doctor` | | Restore owned files | `aidd sync` | | Update the CLI | `aidd update` | `setup` writes the manifest, the default marketplace, the tool configs and the plugins. +`--global` installs for the machine instead, and writes nothing under the project. + +| Command | Lands in | +| --- | --- | +| `aidd setup --ide claude` | `.claude/` and `.aidd/` in this project | +| `aidd setup --global --ide opencode --plugins all` | `~/.config/opencode/` and `~/.config/aidd/`, nothing in this project | ## Commands @@ -46,13 +52,14 @@ Every flag: `aidd --help`, then a group's own `--help`. | `aidd sync` | Rewrite owned files from the manifest, then re-drive native activation | | `aidd clean` | Remove every AIDD-managed file from the project | | `aidd update` | Update the CLI itself (alias `upgrade`) | +| `aidd run opencode -- ` / `aidd run kilo -- ` | Run headless with the installed maintenance notice on stderr | ### Framework | Command | Does | | --- | --- | | `aidd framework install` | Write one tool's runtime configuration from the bundled assets | -| `aidd framework update` | Move installed tools to this CLI's assets (all of them without `--tool`) | +| `aidd framework update` | Update tool configs from this CLI; `--release ` updates project plugins after confirmation, or global flat copies with `--scope user` | | `aidd framework remove` | Delete the generated configuration files of one tool | | `aidd framework rules` | List the rules installed across every AI tool (`--json` for a machine) | @@ -101,31 +108,39 @@ Counts the tokens of Claude Code and Codex sessions, locally. Details: [FAQ](../ ## Scope -`setup`, `doctor`, `sync` and `clean` accept `--scope `. +`setup`, `doctor`, `sync` and `clean` accept `--global`, or its former spelling `--scope user`. | Scope | Acts on | | --- | --- | -| `project` (default) | This project alone | -| `user` | The machine: registers the framework source and supported tools, writes nothing under the project | +| project (default) | This project alone | +| `--global` | Every project on the machine; writes nothing under the project | -`setup --scope user` needs: +`setup --global` needs: -- An explicit `--ai` list of supported tools (`--ai all` is refused). -- `--plugins none`, or no `--plugins` in a scripted run. -- No `--ide`. +- An explicit `--ide` list of supported tools, no editor (`--ide all` is refused). +- `--plugins all`, `recommended`, `none` or comma-separated names. -| Tool | `setup --scope user` | +| Tool | `setup --global --plugins …` | | --- | --- | -| `claude`, `codex`, `copilot` | Registers the source and drives the tool's own CLI | -| `cursor` | Records the source and tool only, no native activation | -| `opencode`, `kilo` | Unsupported: use project scope | +| `claude`, `copilot` | Registers the source and enables the plugins through the tool's own CLI | +| `codex` | The same, plus the plugins' agents in `~/.codex/agents/` | +| `opencode`, `kilo` | Copies the plugins' skills and agents into `~/.config/opencode/` or `~/.config/kilo/` | +| `cursor` | Copies the plugins into `~/.cursor/plugins/local/`; Cursor must allow local plugin imports | + +Files without recorded AIDD ownership are refused even when their content matches. This includes +copies from an older `make reload`, or files left by an interrupted install before ownership was +saved. Move the named files aside, then rerun the original setup or reload command. + +Copilot needs a CLI whose `plugin marketplace list` accepts `--json` (verified with 1.0.98; 1.0.83 refuses it). Native activation needs the tool's executable on `PATH`. Without it, registration succeeds with a warning and activation stays unrun. -Install it, then run `aidd sync --scope user`. +Install it, then rerun the original `aidd setup --global` command with its `--ide` and `--plugins` selections. ```sh -aidd setup --scope user --ai claude,codex,copilot --plugins none --yes +aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes +aidd sync --global # rebuild the copied files and refresh the hosts from the framework source +aidd clean --global --force # remove what --global installed and purge the shared source; without --force it previews or asks ``` ## Authentication @@ -137,6 +152,8 @@ Needed for a private marketplace only. | `stored` | The token itself | `--token ` | | `external` | The provider name. The token is asked from it at fetch time | `--gh` | +For Git clones, AIDD sends these tokens only to `github.com`. Configure a [Git credential helper](https://git-scm.com/docs/gitcredentials) for GitLab, Bitbucket or other hosts (documentation read 2026-11-06). + The token resolves from the first hit: 1. `AIDD_TOKEN` @@ -175,7 +192,7 @@ The token resolves from the first hit: | Variable | Effect | | --- | --- | -| `AIDD_TOKEN` | Token for every fetch, ahead of any stored credential | +| `AIDD_TOKEN` | GitHub token, ahead of any stored credential | | `AIDD_USER_CONFIG_DIR` | Relocates the user config directory, credentials included | | `XDG_CONFIG_HOME` | Names the config root when `AIDD_USER_CONFIG_DIR` is unset | | `AIDD_SKIP_UPDATE_CHECK` | `1` skips the self-update check before a command | @@ -201,7 +218,7 @@ Under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, else `~/.config/aid | `auth.json` | The user-level credential | | `marketplaces.json` | The user-scope registry | | `references.json` | The projects claiming it | -| `manifest.json` | What `--scope user` owns | +| `manifest.json` | What `--global` owns | | `cache/built//` | One built tree per CLI version | ### Settings written per tool diff --git a/cli/aidd_docs/memory/architecture.md b/cli/aidd_docs/memory/architecture.md index 091081633..a8447bb59 100644 --- a/cli/aidd_docs/memory/architecture.md +++ b/cli/aidd_docs/memory/architecture.md @@ -51,3 +51,4 @@ flowchart LR - Configs are inlined at build time, schemas are not: five JSON files ship beside the binary. Drop them from `files` and the CLI breaks. - The build empties its output directory; `AIDD_BUILD_OUT_DIR` accepts two shapes only. - `git` exports `GIT_*` into everything it spawns. Strip them before reading a repository. +- User-lock recovery requires the same boot and process namespace. References read 2026-11-05: [Linux kernel identity](https://kernel.org/doc/html/v6.6/admin-guide/sysctl/kernel.html), [Darwin boot session](https://github.com/apple/darwin-xnu/blob/main/iokit/IOKit/pwr_mgt/IOPM.h). Windows recovery is not verified and refuses to reap an owner. diff --git a/cli/aidd_docs/memory/cli.md b/cli/aidd_docs/memory/cli.md index 600aebf2e..0d269d157 100644 --- a/cli/aidd_docs/memory/cli.md +++ b/cli/aidd_docs/memory/cli.md @@ -8,6 +8,7 @@ Thirty leaf commands. Read them live: `aidd --help`, then each group's. `scripts - `setup`, `doctor`, `sync`, `clean`: bring a project to a correct state, keep it there. - `framework install | update | remove | rules`, chosen by `--tool `. +- `framework update --release ` lists project plugin/tool pairs and asks before updating their source; `--yes` accepts non-interactively, and `--allow-shared` separately approves a shared marketplace. - `plugin install | list | remove | search | update`. - `marketplace add | list | remove | refresh | check`. - `auth login | logout | status`. diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index 752a48546..4f83c9c3f 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -22,6 +22,8 @@ src/ │ │ │ ├── install/ │ │ │ │ └── content/ │ │ │ ├── ownership/ # machine plugin claims and safe project detach +│ │ │ │ ├── release/ # recoverable machine release updates +│ │ │ │ └── tui/ # owned startup TUI configuration │ │ │ ├── plugin/ │ │ │ ├── recipes/ │ │ │ ├── restore/ @@ -91,7 +93,8 @@ src/ │ ├── ports/ # a port two or more contexts both need │ └── reading/ # getting at a file's location and its content safely ├── presentation/ # everything that talks to a human — depends on contexts, never the reverse -│ ├── commands/ # one file per command, wiring only +│ ├── commands/ # command wiring +│ │ └── run/ # headless tool launcher │ ├── display/ # rendering a result │ │ └── telemetry/ # the telemetry report, which outgrew one file beside the others │ └── prompts/ # asking the user; the decision stays in the context diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index be260e1e8..336952902 100644 --- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -47,15 +47,16 @@ One registration of `aidd-framework` per machine, shared by every project. Read ## `setup --scope user` -- Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse it. +- Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Copilot proves its source through `plugin marketplace list --json` (1.0.98); a version rejecting `--json` (1.0.83) refuses it. - Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. +- `--plugins all|recommended|` picks plugins from the shared source's catalog. A tool with native activation gets them through `MarketplaceSyncSettingsOptions.enablePlugins`; a tool declaring `PluginsCapability.globalFiles` (opencode's and kilo's skills and agents, codex's agents) or installing into a user plugins directory (cursor) gets them copied there as `scope: "user"` plugins, written by `UserPluginFileUpdater`, so `sync --scope user` rebuilds them and `clean --scope user` removes them. An interactive pick is refused (`UserScopePluginModeError`). - Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. - `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only. - Each AI tool gets a manifest entry with an empty file list, so `MarketplaceSyncSettingsUseCase` has something to iterate. - Records no `references.json` claim: absence is the state until `clean --scope user`. - `doctor --scope user` runs only `DoctorRegistrationUseCase`. -- `sync --scope user` resolves the same manifest. +- `sync --scope user` resolves the same manifest, rebuilds its `scope: "user"` plugin files, then drives native activation. - `--scope ` on all three, default `project`. - Refuses an `--ide` tool (`UserScopeIdeToolsError`): IDE config is project-relative. -- Refuses an AI tool without machine-wide activation (`UserScopeUnsupportedAiToolsError`; `registry.ts`'s `supportsUserScopeActivation`, false for opencode alone). +- Refuses an AI tool without machine-wide activation (`UserScopeUnsupportedAiToolsError`; `registry.ts`'s `supportsUserScopeActivation`); every registered AI tool supports it today. - Both refusals fire in `SetupFlow`'s constructor; `--ai all` is unusable at this scope. diff --git a/cli/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md index 227bd6445..e638e049f 100644 --- a/cli/aidd_docs/memory/testing.md +++ b/cli/aidd_docs/memory/testing.md @@ -22,7 +22,7 @@ How this package is tested: the layers, the tools, the conventions. - Doubles from `tests/helpers/ports/`. Substitute at the seam; never mock functional behaviour. - Fixtures in `tests/fixtures/`, read-only, excluded from JSON and link checks. Mutate a copy in a temp directory. - `.gitattributes`'s `tests/fixtures/** text eol=lf` keeps byte-for-byte comparisons (`manifest-round-trip.unit.test.ts`) passing on Windows. -- A temp directory: `await mkdtemp(join(tmpdir(), "aidd--"))`, never a fixed name (a plantable symlink). The prefix is what `scripts/sweep-stale-test-dirs.cjs` reclaims. +- A temp directory: `await mkdtemp(join(tmpdir(), "aidd--"))`, never a fixed name (a plantable symlink). Clean up only the directory the test created. - A test name is a phrase of observable behaviour; nested `describe` reads as a sentence. `E2E:` is a recurring legacy prefix, not one to copy. - A golden snapshot is machine-independent, never derived from an absolute path: [`golden-machine-independence.md`](../../.claude/skills/test/references/golden-machine-independence.md). - `describe.concurrent` appears in no unit or integration file; in e2e the golden and command-matrix suites use it. An observed split, not a rule. diff --git a/cli/package.json b/cli/package.json index 58d2105c7..98cc2a640 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 722, + "bundleBudgetKB": 826, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", @@ -86,6 +86,8 @@ "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "commander": "^15.0.0", + "cross-spawn": "7.0.6", + "jsonc-parser": "^3.3.1", "simple-git": "^4.0.2", "smol-toml": "^1.6.1" }, @@ -95,6 +97,7 @@ "@commitlint/config-conventional": "^21.2.0", "@stryker-mutator/core": "^10.0.0", "@stryker-mutator/vitest-runner": "^10.0.0", + "@types/cross-spawn": "6.0.6", "@types/node": "^26.1.1", "@vitest/coverage-v8": "^5.0.0", "fast-check": "^4.7.0", diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml index 1a3402328..43ff0fe24 100644 --- a/cli/pnpm-lock.yaml +++ b/cli/pnpm-lock.yaml @@ -193,6 +193,12 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 + cross-spawn: + specifier: 7.0.6 + version: 7.0.6 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 simple-git: specifier: ^4.0.2 version: 4.0.2(supports-color@7.2.0) @@ -215,6 +221,9 @@ importers: '@stryker-mutator/vitest-runner': specifier: ^10.0.0 version: 10.0.0(patch_hash=9e824f46a81c33f801b2120a27ca4232020bd90800df64696bdec8980afc567f)(@stryker-mutator/core@10.0.0(@types/node@26.6.5))(vitest@5.0.5) + '@types/cross-spawn': + specifier: 6.0.6 + version: 6.0.6 '@types/node': specifier: ^26.1.1 version: 26.6.5 @@ -232,13 +241,13 @@ importers: version: 6.41.0 tsup: specifier: ^9.0.0 - version: 9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.0) + version: 9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.1) typescript: specifier: ^7.0.2 version: 7.0.2 vitest: specifier: ^5.0.0 - version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) packages: @@ -1585,6 +1594,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2234,6 +2246,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + knip@6.41.0: resolution: {integrity: sha512-DrhQlIuV6xScd5Q+WTF1znd1w41QzxFGCtAHXCajsH/ndGMotHoNYbdXbuFtzW3mIOmniHtZ7zCtvnQkUmJtQQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2815,11 +2830,6 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} - engines: {node: '>= 14.6'} - hasBin: true - yaml@2.9.1: resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} @@ -3893,7 +3903,7 @@ snapshots: '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@tybys/wasm-util@0.10.3': dependencies: @@ -3905,6 +3915,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 26.6.5 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -3989,7 +4003,7 @@ snapshots: obug: 3.0.0 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -3997,14 +4011,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.5 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) '@vitest/spy@5.0.5': {} @@ -4424,6 +4438,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + knip@6.41.0: dependencies: formatly: 0.11.4 @@ -4643,13 +4659,13 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.1): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 postcss: 8.5.28 - yaml: 2.9.0 + yaml: 2.9.1 postcss@8.5.28: dependencies: @@ -4887,7 +4903,7 @@ snapshots: tslib@2.8.1: {} - tsup@9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.0): + tsup@9.0.0(jiti@2.7.0)(postcss@8.5.28)(typescript@7.0.2)(yaml@2.9.1): dependencies: bundle-require: 5.1.0(esbuild@0.28.2) cac: 7.1.0 @@ -4898,7 +4914,7 @@ snapshots: joycon: 3.1.1 obug: 3.0.0 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(yaml@2.9.1) resolve-from: 5.0.0 rollup: 4.64.7 source-map-js: 1.2.2 @@ -4967,7 +4983,7 @@ snapshots: verkit@0.3.2: {} - vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0): + vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -4979,12 +4995,12 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 - yaml: 2.9.0 + yaml: 2.9.1 - vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): + vitest@5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 5.0.5(vite@8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -4995,7 +5011,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.3.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.5 @@ -5021,9 +5037,6 @@ snapshots: y18n@5.0.8: {} - yaml@2.9.0: - optional: true - yaml@2.9.1: {} yargs-parser@22.0.0: {} diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index a9deb0584..33bce2da5 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -55,6 +55,21 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // (+5.2 KB over its 712.9 KB skills profile); 0.3% measured headroom. // 722 KB: 720.2 KB, Antigravity portable agents and direct-local installation // (+2.1 KB over its 718.1 KB hooks build); 0.3% measured headroom. +// 747 KB: 732.4 KB, the machine-wide install of `setup --global` (+12.2 KB over the 720.2 KB +// above); 2.0% measured headroom. +// Maintenance branch before integration with Antigravity: +// 734 KB: 718.8 KB, the machine-wide install of `setup --global` (+8.0 KB over 710.8 KB); +// 2.1% measured headroom. +// 730 KB: 721.5 KB, maintenance release upgrades and session notifications; 1.2% headroom. +// 770 KB: 755.6 KB, global ownership, maintenance launchers and Kilo TUI; 1.9% headroom. +// 793 KB: 777.5 KB, pinned machine releases and recoverable file updates; 2.0% headroom. +// 796 KB: 794.9 KB, shared Claude update preview and source revalidation; 0.1% headroom. +// 798 KB: 797.3 KB, release titles and recorded global installations; 0.1% headroom. +// 801 KB: 799.4 KB, authenticated Git clones without credentials in remotes or logs; 0.2% headroom. +// 826 KB: 810.3 KB, combined Antigravity support and maintenance; 1.9% measured headroom. +// 826 KB: 811.3 KB, project hook directory permission preflight; 1.8% measured headroom. +// 826 KB: 811.4 KB, handling a hook closing stdin after success; 1.8% measured headroom. +// 826 KB: 811.7 KB, safe headless hook failure diagnostics; 1.7% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/scripts/smoke-real.sh b/cli/scripts/smoke-real.sh index 51ff408b9..52def735c 100644 --- a/cli/scripts/smoke-real.sh +++ b/cli/scripts/smoke-real.sh @@ -167,11 +167,12 @@ PROJ_U=$(mktemp -d "$TMPROOT/proj-user.XXXXXX") (cd "$PROJ_U" && git init -q) # `setup --scope user` exits 1 for a tool declaring no machine-wide activation -# (`registry.ts`'s `supportsUserScopeActivation`; opencode today), before anything is +# (`registry.ts`'s `supportsUserScopeActivation`; none today), before anything is # written — so the machine-scope phases carry their own tool list, never `ai_list`. +# kilo is listed for parity but never measured: `AI_TOOLS` does not probe for it. user_ai_list() { local ids=() - for t in claude codex copilot cursor; do + for t in claude codex copilot cursor kilo opencode; do [[ -n "${PRESENT[$t]:-}" ]] && ids+=("$t") done (IFS=,; echo "${ids[*]}") diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index fe1cb498c..88075dc01 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -22,7 +22,7 @@ IDE_TOOLS=(vscode) # Canonical leaf-command surface. Coverage = exercised / total. ALL_COMMANDS=( - "setup" "doctor" "sync" "translate" "update" "clean" + "setup" "doctor" "sync" "translate" "update" "clean" "run" "framework install" "framework update" "framework remove" "framework rules" "framework validate-recipes" "plugin remove" "plugin list" "plugin install" "plugin search" "plugin update" @@ -232,6 +232,7 @@ if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --ch # Compare exact file paths and contents before/after: `--dry-run` must write nothing. P_DRY=$(new_project) (cd "$P_DRY" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) +run "run rejects unsupported tools" 1 "Supported tools" "$P_DRY" -- run unsupported before_dry=$(project_tree_hash "$P_DRY"); before_dry_rc=$? run "update --dry-run" "0|1" "" "$P_DRY" -- update --dry-run after_dry=$(project_tree_hash "$P_DRY"); after_dry_rc=$? diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 3c2ce3bb3..f231e1696 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -7,6 +7,7 @@ import { registerFrameworkCommand } from "./presentation/commands/framework.js"; import { registerMarketplaceCommand } from "./presentation/commands/marketplace.js"; import { runMenuLoop } from "./presentation/commands/menu.js"; import { registerPluginCommand } from "./presentation/commands/plugin.js"; +import { registerRunCommand } from "./presentation/commands/run/command.js"; import { registerSetupCommand } from "./presentation/commands/setup.js"; import { registerSyncCommand } from "./presentation/commands/sync.js"; import { registerTelemetryCommand } from "./presentation/commands/telemetry.js"; @@ -41,6 +42,7 @@ registerUpdateCommand(program); registerDoctorCommand(program); registerCleanCommand(program); registerTelemetryCommand(program); +registerRunCommand(program); // Commands already paying for network I/O, so the update-check refresh rides one of them. // `marketplace remove` is offline and `update` already resolves the latest version itself. @@ -54,6 +56,7 @@ const ONLINE_COMMAND_PATHS = new Set([ program.hook("preAction", async (_thisCommand, actionCommand) => { if (process.env.AIDD_SKIP_UPDATE_CHECK === "1") return; + if (resolveCommandPath(actionCommand) === "run") return; // A hook runs `telemetry ingest --quiet`; an update notice would break its silence. if (resolveCommandPath(actionCommand).startsWith("telemetry")) return; const opts = program.opts<{ verbose?: boolean }>(); diff --git a/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts b/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts index e3c4f4ae2..ef07fe329 100644 --- a/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts +++ b/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts @@ -1,5 +1,4 @@ import { join, resolve } from "node:path"; -import { marketplaceCacheDir } from "../../../kernel/paths.js"; import type { FileReader } from "../../../kernel/ports/file-reader.js"; import type { Logger } from "../../../kernel/ports/logger.js"; import { @@ -61,7 +60,7 @@ export class MarketplaceRefreshUseCase implements MarketplaceRefresh { // through the result, never thrown. private async refreshOne(projectRoot: string, m: Marketplace): Promise { try { - const cacheDir = marketplaceCacheDir(projectRoot, m.name); + const cacheDir = this.resolveMarketplace.cacheDirOf(m, projectRoot); await this.warnIfStale(m.name, cacheDir); this.logger?.info(`Fetching marketplace '${m.name}'...`); const { catalog } = await this.resolveMarketplace.execute({ diff --git a/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts b/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts index 9089e0c0d..296060165 100644 --- a/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts +++ b/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts @@ -1,4 +1,4 @@ -import { marketplaceCacheDir } from "../../../kernel/paths.js"; +import { fetchedMarketplaceDir } from "../../../kernel/paths.js"; import type { PluginCatalog } from "../domain/catalog.js"; import type { Marketplace } from "../domain/marketplace.js"; import type { PluginCatalogRepository } from "../domain/ports/plugin-catalog-repository.js"; @@ -24,11 +24,12 @@ export interface ResolveMarketplace { export class ResolveMarketplaceUseCase implements ResolveMarketplace { constructor( private readonly fetchMarketplaceSource: FetchMarketplaceSourceUseCase, - private readonly catalogRepo: PluginCatalogRepository + private readonly catalogRepo: PluginCatalogRepository, + private readonly userConfigDir?: () => string ) {} async execute(options: ResolveMarketplaceOptions): Promise { - const cacheDir = marketplaceCacheDir(options.projectRoot, options.marketplace.name); + const cacheDir = this.cacheDirOf(options.marketplace, options.projectRoot); const localPath = await this.fetchMarketplaceSource.execute({ marketplace: options.marketplace, cacheDir, @@ -37,4 +38,8 @@ export class ResolveMarketplaceUseCase implements ResolveMarketplace { const catalog = await this.catalogRepo.load(localPath); return { marketplace: options.marketplace, localPath, catalog }; } + + cacheDirOf(marketplace: Marketplace, projectRoot: string): string { + return fetchedMarketplaceDir(marketplace, projectRoot, this.userConfigDir?.()); + } } diff --git a/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts b/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts index 8a87e6f0d..fb94f176c 100644 --- a/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts +++ b/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts @@ -2,6 +2,8 @@ import type { PluginSource } from "../../../../kernel/source.js"; export interface PluginFetchOptions { forceRefresh?: boolean; + /** Use consistent Git line-ending settings for this isolated checkout. */ + canonicalCheckout?: boolean; } export interface PluginFetcher { diff --git a/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts index b00db5dae..2d7ccafd3 100644 --- a/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts +++ b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts @@ -5,19 +5,46 @@ import { simpleGit } from "simple-git"; import { PluginFetchError } from "../../../kernel/errors.js"; import type { FileReader } from "../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../kernel/ports/file-writer.js"; -import type { - PluginSource, - PluginSourceGitHub, - PluginSourceGitSubdir, - PluginSourceNpm, - PluginSourceUrl, +import { + type PluginSource, + type PluginSourceGitHub, + type PluginSourceGitSubdir, + type PluginSourceNpm, + type PluginSourceUrl, + SHA_REGEX, } from "../../../kernel/source.js"; import type { TokenProvider } from "../../../runtime/auth/ports/token-provider.js"; -import { injectTokenIntoUrl, withoutCredentials } from "../../../runtime/git/inject-token.js"; +import { gitHttpAuthentication, withoutCredentials } from "../../../runtime/git/inject-token.js"; import type { PluginFetcher, PluginFetchOptions } from "../domain/ports/plugin-fetcher.js"; const execFile = promisify(execFileCb); +// The credential goes to the wrapper on stdin, never in the parent process argv or env. +const AUTHENTICATED_GIT = `let input="";process.stdin.setEncoding("utf8");process.stdin.on("data",x=>input+=x);process.stdin.on("end",()=>{const child=require("node:child_process").spawn("git",process.argv.slice(1),{env:JSON.parse(input),stdio:["ignore","inherit","inherit"]});child.on("error",()=>{process.stderr.write("Unable to start git.");process.exitCode=1});child.on("close",code=>{process.exitCode=code??1})})`; + +function gitEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { GIT_TERMINAL_PROMPT: "0" }; + for (const key of [ + "PATH", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "SYSTEMROOT", + "TMP", + "TEMP", + "TMPDIR", + "XDG_CONFIG_HOME", + "SSH_AUTH_SOCK", + "LANG", + "LC_ALL", + "LC_CTYPE", + ]) { + if (process.env[key] !== undefined) env[key] = process.env[key]; + } + return env; +} + const AUTH_ERROR_PATTERN = /Authentication failed|403|could not read Username|Permission denied \(publickey\)|Repository not found/i; @@ -33,15 +60,16 @@ export class PluginFetcherAdapter implements PluginFetcher { options?: PluginFetchOptions ): Promise { const forceRefresh = options?.forceRefresh ?? false; + const canonicalCheckout = options?.canonicalCheckout ?? false; switch (source.kind) { case "local": return this.fetchLocal(source.path); case "github": - return this.fetchGitHub(source, cacheDir, forceRefresh); + return this.fetchGitHub(source, cacheDir, forceRefresh, canonicalCheckout); case "url": - return this.fetchUrl(source, cacheDir, forceRefresh); + return this.fetchUrl(source, cacheDir, forceRefresh, canonicalCheckout); case "git-subdir": - return this.fetchGitSubdir(source, cacheDir, forceRefresh); + return this.fetchGitSubdir(source, cacheDir, forceRefresh, canonicalCheckout); case "npm": return this.fetchNpm(source, cacheDir, forceRefresh); } @@ -58,60 +86,169 @@ export class PluginFetcherAdapter implements PluginFetcher { private async fetchGitHub( source: PluginSourceGitHub, cacheDir: string, - forceRefresh: boolean + forceRefresh: boolean, + canonicalCheckout: boolean ): Promise { const baseUrl = `https://github.com/${source.repo}.git`; const key = `github-${source.repo.replace("/", "-")}-${source.ref ?? "HEAD"}`; const targetDir = join(cacheDir, key); - await this.bustCacheIfNeeded(targetDir, forceRefresh); + await this.prepareGitCache(targetDir, forceRefresh, source.sha); if (!(await this.fs.fileExists(targetDir))) { const token = (await this.tokenProvider?.resolve()) ?? undefined; - await this.cloneShallow(injectTokenIntoUrl(baseUrl, token), baseUrl, targetDir, source.ref); + await this.cloneShallow( + gitHttpAuthentication(baseUrl, token), + baseUrl, + targetDir, + source.ref, + canonicalCheckout || source.sha !== undefined + ); } + await this.verifyPinnedCheckout(targetDir, source.sha); return targetDir; } private async fetchUrl( source: PluginSourceUrl, cacheDir: string, - forceRefresh: boolean + forceRefresh: boolean, + canonicalCheckout: boolean ): Promise { const key = `${encodeKey(withoutCredentials(source.url))}${source.ref ? `-${source.ref}` : "-HEAD"}`; const targetDir = join(cacheDir, key); - await this.bustCacheIfNeeded(targetDir, forceRefresh); + await this.prepareGitCache(targetDir, forceRefresh, source.sha); if (!(await this.fs.fileExists(targetDir))) { const token = (await this.tokenProvider?.resolve()) ?? undefined; - const authUrl = source.url.startsWith("git@") - ? source.url - : injectTokenIntoUrl(source.url, token); - await this.cloneShallow(authUrl, source.url, targetDir, source.ref); + await this.cloneShallow( + gitHttpAuthentication(source.url, token), + source.url, + targetDir, + source.ref, + canonicalCheckout || source.sha !== undefined + ); } + await this.verifyPinnedCheckout(targetDir, source.sha); return targetDir; } private async fetchGitSubdir( source: PluginSourceGitSubdir, cacheDir: string, - forceRefresh: boolean + forceRefresh: boolean, + canonicalCheckout: boolean ): Promise { const { url, path: subpath, ref } = source; const key = `${encodeKey(withoutCredentials(url))}-subdir-${subpath.replace(/\//g, "_")}-${ref ?? "HEAD"}`; const targetDir = join(cacheDir, key); - await this.bustCacheIfNeeded(targetDir, forceRefresh); + await this.prepareGitCache(targetDir, forceRefresh, source.sha, subpath); if (!(await this.fs.fileExists(targetDir))) { const token = (await this.tokenProvider?.resolve()) ?? undefined; - const authUrl = url.startsWith("git@") ? url : injectTokenIntoUrl(url, token); - await this.cloneSparse(authUrl, url, targetDir, subpath, ref); + await this.cloneSparse( + gitHttpAuthentication(url, token), + url, + targetDir, + subpath, + ref, + canonicalCheckout || source.sha !== undefined + ); } + await this.verifyPinnedCheckout(targetDir, source.sha, subpath); return join(targetDir, subpath); } + /** A pinned cache is usable only while both its commit and its working tree match the pin. */ + private async verifyPinnedCheckout( + directory: string, + sha: string | undefined, + subpath?: string + ): Promise { + if (sha === undefined) return; + if (!SHA_REGEX.test(sha)) throw new PluginFetchError("invalid pinned Git SHA."); + let commit: string; + let changes: string; + let index: string; + try { + const env: NodeJS.ProcessEnv = { + ...gitEnvironment(), + GIT_CONFIG_NOSYSTEM: "1", + // Git documents /dev/null for this variable, including Git for Windows; Node's + // Windows devNull is \\.\nul, which Git for Windows cannot open as a config file. + GIT_CONFIG_GLOBAL: "/dev/null", + }; + const run = (args: string[]) => + execFile( + "git", + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "-c", + "core.trustctime=true", + "-c", + "core.checkstat=default", + "-c", + "core.autocrlf=false", + "-c", + "core.eol=lf", + "--literal-pathspecs", + ...args, + ], + { cwd: directory, env } + ); + commit = (await run(["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim(); + changes = ( + await run([ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored=matching", + "--ignore-submodules=none", + "--", + ]) + ).stdout; + index = ( + await run(["ls-files", "-v", "-z", "--", ...(subpath === undefined ? [] : [subpath])]) + ).stdout; + } catch { + // A Git error can contain credentials from remote configuration. The path itself may + // also have been supplied by the user, so neither is needed in this diagnostic. + throw new PluginFetchError("unable to verify the pinned Git checkout."); + } + if (commit !== sha) + throw new PluginFetchError("the checked-out Git commit does not match the pinned SHA."); + if (changes.trim()) + throw new PluginFetchError( + "the pinned Git cache cannot be verified: it may contain modified, untracked or ignored files, or noncanonical line endings. Inspect the cache manually before retrying." + ); + if ( + index + .split("\0") + .some((line) => line && (line[0] === "S" || line[0] === line[0]?.toLowerCase())) + ) + throw new PluginFetchError("the pinned Git checkout hides tracked file changes."); + } + private async bustCacheIfNeeded(targetDir: string, forceRefresh: boolean): Promise { if (forceRefresh && (await this.fs.fileExists(targetDir))) { await this.fs.deleteDirectory(targetDir); } } + /** Do not discard edits made to a pinned checkout even on an explicit refresh. */ + private async prepareGitCache( + targetDir: string, + forceRefresh: boolean, + sha: string | undefined, + subpath?: string + ): Promise { + if (sha !== undefined && !SHA_REGEX.test(sha)) + throw new PluginFetchError("invalid pinned Git SHA."); + if (forceRefresh && sha !== undefined && (await this.fs.fileExists(targetDir))) { + await this.verifyPinnedCheckout(targetDir, sha, subpath); + } + await this.bustCacheIfNeeded(targetDir, forceRefresh); + } + private async fetchNpm( source: PluginSourceNpm, cacheDir: string, @@ -133,43 +270,112 @@ export class PluginFetcherAdapter implements PluginFetcher { private gitWithNoPrompt(baseDir?: string): ReturnType { const git = simpleGit({ baseDir, allowEnvironment: ["GIT_TERMINAL_PROMPT"] }); - return git.env("GIT_TERMINAL_PROMPT", "0"); + for (const [key, value] of Object.entries(gitEnvironment())) { + if (value !== undefined) git.env(key, value); + } + return git; + } + + private async authenticatedGit( + auth: { url: string; authorization: string }, + args: string[], + baseDir?: string + ): Promise { + const env = gitEnvironment(); + const input = JSON.stringify({ + ...env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `http.${auth.url}.extraheader`, + GIT_CONFIG_VALUE_0: auth.authorization, + }); + await new Promise((resolve, reject) => { + const child = execFileCb( + process.execPath, + [`--eval=${AUTHENTICATED_GIT}`, ...args], + { cwd: baseDir, env, windowsHide: true }, + (error) => (error ? reject(error) : resolve()) + ); + child.stdin?.on("error", () => {}); + child.stdin?.end(input); + }); } private async cloneShallow( - authUrl: string, + auth: { url: string; authorization?: string }, displayUrl: string, targetDir: string, - ref?: string + ref?: string, + canonicalCheckout = false ): Promise { - const args = ["--depth", "1", ...(ref ? ["--branch", ref] : [])]; + const args = [ + "--depth", + "1", + ...(ref ? ["--branch", ref] : []), + ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []), + ]; try { - await this.gitWithNoPrompt().clone(authUrl, targetDir, args); + if (auth.authorization) { + if (ref?.startsWith("-")) throw new Error("Invalid Git reference."); + await this.authenticatedGit({ ...auth, authorization: auth.authorization }, [ + "clone", + "--depth", + "1", + ...(ref ? [`--branch=${ref}`] : []), + ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []), + "--", + auth.url, + targetDir, + ]); + } else { + await this.gitWithNoPrompt().clone(auth.url, targetDir, args); + } } catch (err) { - this.classifyAndThrow(err, displayUrl); + this.classifyAndThrow(err, displayUrl, auth.authorization); } } private async cloneSparse( - authUrl: string, + auth: { url: string; authorization?: string }, displayUrl: string, targetDir: string, subpath: string, - ref?: string + ref?: string, + canonicalCheckout = false ): Promise { try { - await this.gitWithNoPrompt().clone(authUrl, targetDir, [ - "--filter=blob:none", - "--no-checkout", - ]); - await this.gitWithNoPrompt(targetDir).raw(["sparse-checkout", "set", subpath]); - await this.gitWithNoPrompt(targetDir).checkout(ref ?? "HEAD"); + if (auth.authorization) { + if (ref?.startsWith("-")) throw new Error("Invalid Git reference."); + const authenticated = { ...auth, authorization: auth.authorization }; + await this.authenticatedGit(authenticated, [ + "clone", + "--filter=blob:none", + "--no-checkout", + ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []), + "--", + auth.url, + targetDir, + ]); + await this.authenticatedGit( + authenticated, + ["sparse-checkout", "set", "--", subpath], + targetDir + ); + await this.authenticatedGit(authenticated, ["checkout", ref ?? "HEAD", "--"], targetDir); + } else { + await this.gitWithNoPrompt().clone(auth.url, targetDir, [ + "--filter=blob:none", + "--no-checkout", + ...(canonicalCheckout ? ["-c", "core.autocrlf=false", "-c", "core.eol=lf"] : []), + ]); + await this.gitWithNoPrompt(targetDir).raw(["sparse-checkout", "set", subpath]); + await this.gitWithNoPrompt(targetDir).checkout(ref ?? "HEAD"); + } } catch (err) { - this.classifyAndThrow(err, displayUrl); + this.classifyAndThrow(err, displayUrl, auth.authorization); } } - private classifyAndThrow(err: unknown, displayUrl: string): never { + private classifyAndThrow(err: unknown, displayUrl: string, authorization?: string): never { const msg = err instanceof Error ? err.message : String(err); // The URL the user typed may carry their own credential; the message must not. const safeUrl = withoutCredentials(displayUrl); @@ -178,14 +384,25 @@ export class PluginFetcherAdapter implements PluginFetcher { `Authentication failed for "${safeUrl}". ${this.authHint(safeUrl)}` ); } - throw new PluginFetchError(`git clone failed for "${safeUrl}": ${scrubCredentials(msg)}`); + let safeMessage = scrubCredentials(msg); + if (authorization) { + const encoded = authorization.slice("Authorization: Basic ".length); + const decoded = Buffer.from(encoded, "base64").toString(); + const separator = decoded.indexOf(":"); + const secret = decoded.slice(separator + 1) || decoded.slice(0, separator); + safeMessage = safeMessage.replaceAll(encoded, "[redacted]"); + if (secret) safeMessage = safeMessage.replaceAll(secret, "[redacted]"); + } + throw new PluginFetchError(`git clone failed for "${safeUrl}": ${safeMessage}`); } private authHint(url: string): string { - if (url.startsWith("git@")) { + if (url.startsWith("git@") || url.startsWith("ssh://")) { return "Check your SSH key is registered on the remote host."; } - return "Set AIDD_TOKEN, run `aidd auth login`, or pass --token . Required scope depends on the host (github: `repo`, gitlab: `read_repository`, bitbucket: `repository:read`)."; + if (url.startsWith("https://github.com/")) + return "Set AIDD_TOKEN, run `aidd auth login`, or pass --token . GitHub private repositories require repository read access."; + return "Configure a Git credential helper for this host."; } } diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index 22e1b3826..619450461 100644 --- a/cli/src/contexts/framework/application/clean-use-case.ts +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -46,7 +46,11 @@ import { detachNativePluginRefs, machineNativePluginClaim, } from "./ownership/native-plugin-ownership.js"; -import { assertProjectMcpEntriesRemovable } from "./ownership/project-plugin-cleanup.js"; +import { + assertProjectMcpEntriesRemovable, + planProjectKiloTuiRemoval, + removeProjectKiloTui, +} from "./ownership/project-plugin-cleanup.js"; import { detachUserPlugin } from "./ownership/user-plugin-ownership.js"; import { deletePluginFilesForTool } from "./plugin/plugin-helpers.js"; import { bestEffortNativeCall } from "./shared/best-effort-native-call.js"; @@ -176,6 +180,7 @@ export class CleanUseCase { }); } await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, options.projectRoot); + await planProjectKiloTuiRemoval(this.fs, plugin, toolId, options.projectRoot); } } // Read the other projects before host cleanup, but keep this project's claim until every @@ -272,7 +277,8 @@ export class CleanUseCase { this.hostPluginRegistries.get(toolId), registration.hostName, new Set([...registrations.pluginRefs, ...machineRefs]), - projectRoot + projectRoot, + toolId ); } } @@ -604,7 +610,8 @@ export class CleanUseCase { reader, sourceRegistration.hostName, new Set([...registrations.pluginRefs, ...machineRefs]), - projectRoot + projectRoot, + toolId ); } catch (error) { this.logger.warn(`${binary}: '${ref}' left enabled; ${String(error)}`); @@ -693,6 +700,11 @@ export class CleanUseCase { for (const toolId of manifest.getInstalledToolIds()) { count += await this.deleteMachineLocalSettingsFiles(toolId, projectRoot); count += await this.removeProjectHooksForTool(manifest, toolId, projectRoot, hooksGuard); + if (isAiToolId(toolId)) { + for (const plugin of manifest.getPlugins(toolId)) { + if (await removeProjectKiloTui(this.fs, plugin, toolId, projectRoot)) count++; + } + } } return count; } diff --git a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts index e02784dd0..4e3fb5d30 100644 --- a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts +++ b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts @@ -22,6 +22,10 @@ import { assertNoForeignNativeRefs, inspectNativeMarketplaceSource, } from "../ownership/native-marketplace-source-proof.js"; +import { + planUserKiloTuiRemoval, + removeUserKiloTui, +} from "../ownership/tui/user-kilo-tui-removal.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; import { bestEffortNativeCall } from "../shared/best-effort-native-call.js"; import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; @@ -210,6 +214,7 @@ export class CleanUserScopeUseCase { if (!isAiToolId(toolId)) continue; for (const plugin of manifest.getPlugins(toolId)) { if (plugin.scope !== "user") continue; + await planUserKiloTuiRemoval(this.fs, toolId, plugin, this.homeDir()); const safe = await userScopeFilesSafeToDelete( this.fs, this.logger, @@ -327,7 +332,8 @@ export class CleanUserScopeUseCase { this.hostPluginRegistries.get(toolId), registration.hostName, owned, - projectRoot + projectRoot, + toolId ); for (const ref of refsByHost.get(registration.hostName) ?? []) { if (hostRefs.get(ref)?.enabled !== true) @@ -403,6 +409,7 @@ export class CleanUserScopeUseCase { if (!isAiToolId(toolId)) continue; for (const plugin of manifest.getPlugins(toolId)) { if (plugin.scope !== "user") continue; + await removeUserKiloTui(this.fs, toolId, plugin, this.homeDir()); const files = await userScopeFilesSafeToDelete( this.fs, this.logger, @@ -419,6 +426,15 @@ export class CleanUserScopeUseCase { await this.purgeWhitelistedPath("cache/built", "cache/built", (candidate) => this.fs.deleteDirectory(candidate) ); + // Other user marketplaces keep their registered sources. Plugin fetches belong to the + // machine manifest being removed, whose active dependents were checked before any purge. + const frameworkSource = `cache/marketplaces/${FRAMEWORK_MARKETPLACE_NAME}`; + for (const path of [frameworkSource, "cache/plugin-cache"]) { + await this.purgeWhitelistedPath(path, path, (candidate) => + this.fs.deleteDirectory(candidate) + ); + } + await this.purgeEmptyCacheShell("cache/marketplaces"); // The self-update check cache, written into the same `cache/` directory by any online // command: left behind it keeps the shell below non-empty, the one occupant that made // "leaves nothing of aidd's on the machine" false in the ordinary case. @@ -444,17 +460,15 @@ export class CleanUserScopeUseCase { await this.userManifestRepo.delete(); } - /** `cache/built/` and `cache/update-check.json` are this whitelist's only occupants of - * `userConfigDir()/cache/`. The shell around them is removed only once proven empty, under the - * same containment as every other candidate — never assumed from having just purged its only - * children, since a future writer under `cache/` would make that assumption stale silently. */ - private async purgeEmptyCacheShell(): Promise { + /** Remove a cache directory only once proven empty, with the same containment as every + * whitelist candidate: another marketplace or a future cache writer may still use it. */ + private async purgeEmptyCacheShell(path = "cache"): Promise { const candidate = await resolveCacheCandidate( this.fs, this.logger, this.userConfigDir(), - "cache", - "user scope: cache" + path, + `user scope: ${path}` ); if (candidate === null) return; let entries: string[]; @@ -466,7 +480,7 @@ export class CleanUserScopeUseCase { } if (entries.length > 0) return; await this.fs.deleteDirectory(candidate); - this.logger.info(`user scope: cache purged: ${candidate}`); + this.logger.info(`user scope: ${path} purged: ${candidate}`); } /** Never `userConfigDir()` itself, and never on a manifest's word: every fixed whitelist entry diff --git a/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts index 0f5ec75a3..66f1e6754 100644 --- a/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts @@ -35,6 +35,8 @@ export interface DoctorRegistrationOptions { manifest: Manifest; projectRoot: string; allowedIds: Set | null; + /** A global install declares nothing in a project's settings, so it skips that check. */ + globalInstall?: boolean; } /** One plugin `doctor` expects a native-activation tool's own registry to carry, and the ref to @@ -84,7 +86,7 @@ export class DoctorRegistrationUseCase { async execute(options: DoctorRegistrationOptions): Promise { return [ - ...(await this.checkDeclaredMarketplaces(options)), + ...(options.globalInstall === true ? [] : await this.checkDeclaredMarketplaces(options)), ...(await this.checkNativeRegistrations(options)), ...(await this.checkMarketplaceSources(options)), ]; diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index 458151b01..0e288bed6 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -38,6 +38,7 @@ import type { } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; import { + hostRefInUse, nativeActivationOf, pluginEnablementIsMachineGlobal, resolvePluginsCapability, @@ -95,6 +96,16 @@ export interface MarketplaceSyncSettingsOptions { * never a fallback to every one. */ marketplaceNames?: readonly string[]; + /** + * Framework plugins a tool with native activation also enables at this run's scope, on top + * of the ones its manifest records — what `setup --global --plugins` asks for, since a + * machine-scope install records no plugin a host loads by itself. + */ + enablePlugins?: readonly string[]; + /** Refresh already-owned native refs after an explicitly approved framework release update. */ + refreshOwnedPlugins?: readonly { toolId: AiToolId; pluginName: string }[]; + /** Claude refs shown in the approved release preview; never ownership claims. */ + approvedRefs?: readonly string[]; } interface ActivationOutcome { @@ -217,6 +228,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { // A user-scope run lands nothing under `projectRoot`, so no project settings file mirrors it. if (scope === "project") { for (const toolId of toolIds) { + if (options.refreshOwnedPlugins !== undefined) continue; if ( resolvePluginsCapability(toolId)?.marketplaceSettings ?.declarativePluginActivationRequiresNativeProof === true @@ -231,13 +243,27 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, toolIds, - scope + scope, + options.enablePlugins ?? [], + options.refreshOwnedPlugins ?? [], + options.approvedRefs ?? [] ); let declarativeChanged = false; if (scope === "project") { for (const [toolId, outcome] of activation.outcomes) { const settings = resolvePluginsCapability(toolId)?.marketplaceSettings; - if (settings?.declarativePluginActivationRequiresNativeProof !== true) continue; + // A targeted native update preserves Claude's own enabled/disabled settings. + if ( + options.refreshOwnedPlugins !== undefined && + nativeActivationOf(toolId)?.releaseUpdateVerb + ) + continue; + if ( + settings == null || + (options.refreshOwnedPlugins === undefined && + settings.declarativePluginActivationRequiresNativeProof !== true) + ) + continue; const hostNames = new Set(outcome.marketplaces.map((entry) => entry.hostName)); const provenRefs = outcome.pluginRefs.filter((ref) => [...hostNames].some((hostName) => ref.endsWith(`@${hostName}`)) @@ -250,7 +276,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, settings, - provenRefs + provenRefs, + options.refreshOwnedPlugins ?? [] )) ) declarativeChanged = true; @@ -259,24 +286,34 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { if (declarativeChanged) await manifestRepo.save(manifest); // A refused or unproven host registration cannot establish a new shared-source claim. // User-scope runs have no project manifest for a later `clean` to decrement it. + // A release preview authorizes the update, not a new ownership claim. + const claimOutcomes = options.approvedRefs?.length + ? new Map( + [...activation.outcomes].filter( + ([toolId]) => !nativeActivationOf(toolId)?.releaseUpdateVerb + ) + ) + : activation.outcomes; if (scope !== "user") { - await this.recordSharedSourceReference(projectRoot, marketplaces, activation.outcomes); + await this.recordSharedSourceReference(projectRoot, marketplaces, claimOutcomes); } const wroteHashes = await this.recordWhatActivationWrote(projectRoot, manifest, [ ...activation.outcomes.keys(), ]); const wroteRegistrations = this.recordNativeRegistrations( manifest, - activation.outcomes, - options.marketplaceNames !== undefined + claimOutcomes, + options.marketplaceNames !== undefined, + options.refreshOwnedPlugins ?? [] ); if (wroteHashes || wroteRegistrations) await manifestRepo.save(manifest); await this.recordMachinePluginClaims( manifest, - activation.outcomes, + claimOutcomes, marketplaces, scope, - scope === "project" ? projectRoot : undefined + scope === "project" ? projectRoot : undefined, + options.refreshOwnedPlugins ?? [] ); if (options.recreateFrameworkIfMissing === true && scope === "project") { await this.purgeStaleProjectCache(projectRoot, marketplaces, activation); @@ -403,7 +440,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest: Manifest, marketplaces: readonly Marketplace[], toolIds: readonly ToolId[], - scope: MarketplaceScope + scope: MarketplaceScope, + enablePlugins: readonly string[], + refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: string }[], + approvedRefs: readonly string[] ): Promise { const outcomes = new Map(); const binaryMissing: { toolId: ToolId; binary: string }[] = []; @@ -414,7 +454,11 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { const activator = binary === undefined ? undefined : this.activators.get(binary); if (binary === undefined || activator === undefined) continue; if (!activator.isAvailable()) { - this.logger.warn(`${binary} CLI not found on PATH — skipping native plugin activation.`); + this.logger.warn( + `${binary} CLI not found on PATH — skipping native plugin activation.${ + enablePlugins.length > 0 ? " Rerun `aidd setup --global` once it is installed." : "" + }` + ); binaryMissing.push({ toolId, binary }); continue; } @@ -426,7 +470,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, warnings, - scope + scope, + enablePlugins, + refreshOwnedPlugins, + nativeActivationOf(toolId)?.releaseUpdateVerb ? approvedRefs : [] ); outcomes.set(toolId, outcome); } catch (error) { @@ -444,7 +491,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { private recordNativeRegistrations( manifest: Manifest, activated: ReadonlyMap, - narrowed: boolean + narrowed: boolean, + selected: readonly { toolId: AiToolId; pluginName: string }[] ): boolean { let changed = false; for (const [toolId, outcome] of activated) { @@ -467,8 +515,12 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ) : []; const preservedHostNames = new Set(outcome.preservedMarketplaces.map((m) => m.hostName)); - const preservedRefs = (existing?.pluginRefs ?? []).filter((ref) => - [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`)) + const preservedRefs = (existing?.pluginRefs ?? []).filter( + (ref) => + [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`)) || + (selected.length > 0 && + (nativeActivationOf(toolId)?.releaseUpdateVerb !== undefined || + !this.isSelectedRef(manifest, toolId, ref, selected))) ); const registrations: NativeRegistrations = { binary, @@ -529,7 +581,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest: Manifest, marketplaces: readonly Marketplace[], warnings: string[], - scope: MarketplaceScope + scope: MarketplaceScope, + enablePlugins: readonly string[], + refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: string }[], + approvedRefs: readonly string[] ): Promise { // Every known marketplace, never only the ones a plugin points at: declaring a marketplace // and installing a plugin from it are two acts. Measured against the real `claude` binary — @@ -541,6 +596,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { const preservedMarketplaces: NativeMarketplaceRegistration[] = []; const catalogClaims: NativeMarketplaceRegistration[] = []; const addedHostNames = new Set(); + const skippedHostNames = new Set(); + const refreshSources = new Map(); const recorded = manifest.getNativeRegistrations(toolId)?.marketplaces; let buildFailed = false; for (const marketplace of marketplaces) { @@ -550,21 +607,35 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { marketplace, projectRoot, warnings, - catalogClaims + catalogClaims, + approvedRefs ); if (registration.outcome === "build-failed") buildFailed = true; if (registration.addedThisRun === true) addedHostNames.add(registration.hostName); + if (registration.skipNativeWrites) skippedHostNames.add(registration.hostName); + if (registration.refreshable && registration.provenance) + refreshSources.set(registration.hostName, registration.provenance); const entry = { alias: marketplace.name, hostName: registration.hostName, ...(registration.provenance === undefined ? {} : { provenance: registration.provenance }), }; const earlier = recorded?.find((m) => m.alias === marketplace.name); - if (registration.outcome === "registered" && registration.claimable) - ownMarketplaces.push(entry); - if (registration.claimable) catalogClaims.push(entry); - else if (earlier !== undefined) { - const proof = await this.nativeCatalogProof(toolId, earlier.hostName, projectRoot); + if (registration.outcome === "registered" && registration.claimable) { + if (registration.skipNativeWrites) { + if (earlier !== undefined) preservedMarketplaces.push(earlier); + } else ownMarketplaces.push(entry); + } + if (registration.claimable) { + if (!registration.skipNativeWrites) catalogClaims.push(entry); + } else if (earlier !== undefined) { + const proof = await this.nativeCatalogProof( + toolId, + earlier.hostName, + projectRoot, + undefined, + approvedRefs + ); if ( proof.canonical && proof.hostReadable && @@ -585,12 +656,14 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { }; const stillProven: NativeMarketplaceRegistration[] = []; for (const registration of ownMarketplaces) { + if (skippedHostNames.has(registration.hostName)) continue; const fresh = catalogClaims.find((claim) => claim.hostName === registration.hostName); const proof = await this.nativeCatalogProof( toolId, registration.hostName, projectRoot, - fresh + fresh, + approvedRefs ); if ( !proof.canonical || @@ -608,14 +681,89 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); } const hostNameByAlias = new Map(stillProven.map((m) => [m.alias, m.hostName])); - const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables( - toolId, - this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), - manifest, - projectRoot - ); + const frameworkHost = hostNameByAlias.get(FRAMEWORK_MARKETPLACE_NAME); + const requested = + frameworkHost === undefined ? [] : enablePlugins.map((name) => `${name}@${frameworkHost}`); + const candidateRefs = [ + ...new Set([ + ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias, scope), + ...requested, + ]), + ]; + const { refsToEnable, alreadyOwnedRefs } = approvedRefs.length + ? { refsToEnable: [], alreadyOwnedRefs: [] } + : await this.refsThisProjectEnables(toolId, candidateRefs, manifest, projectRoot); const enabledRefs = [...alreadyOwnedRefs]; + const sourceStillMatches = async (ref: string): Promise => { + const registration = stillProven.find((entry) => ref.endsWith(`@${entry.hostName}`)); + if (!registration) return false; + const expected = refreshSources.get(registration.hostName); + if (!expected) return false; + const current = await this.nativeCatalogProof( + toolId, + registration.hostName, + projectRoot, + registration, + approvedRefs + ); + if ( + !current.canonical || + !current.hostReadable || + current.sourceStatus !== "owned" || + current.foreignRef !== undefined || + !current.source || + !sameNativeMarketplaceSource(expected, current.source) + ) { + const message = `${toolId}: native source changed before refreshing '${ref}'; plugin left untouched.`; + this.logger.warn(message); + warnings.push(message); + return false; + } + return true; + }; + if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.refreshOwnedOnReleaseUpdate) { + for (const ref of alreadyOwnedRefs) { + if ( + !this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) || + !(await sourceStillMatches(ref)) + ) + continue; + this.bestEffort( + () => activator.enablePlugin(ref, scope), + `refresh plugin '${ref}'`, + warnings + ); + } + } + if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb) { + for (const ref of candidateRefs) { + if (!this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins)) continue; + if (!approvedRefs.includes(ref)) { + const message = `${toolId}: '${ref}' was not in the approved preview; release refresh skipped.`; + this.logger.warn(message); + warnings.push(message); + continue; + } + if (!(await sourceStillMatches(ref))) continue; + if (activator.refreshReleasePlugin === undefined) + throw new Error(`${toolId}: targeted release refresh is unavailable for '${ref}'.`); + this.bestEffort( + () => activator.refreshReleasePlugin?.(ref, scope, projectRoot), + `refresh plugin '${ref}'`, + warnings + ); + } + } for (const ref of refsToEnable) { + if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb) { + continue; + } + if ( + refreshOwnedPlugins.length && + (!this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) || + !(await sourceStillMatches(ref))) + ) + continue; if ( this.bestEffort( () => activator.enablePlugin(ref, scope), @@ -675,7 +823,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { outcomes: ReadonlyMap, marketplaces: readonly Marketplace[], scope: MarketplaceScope, - projectRoot?: string + projectRoot?: string, + selected: readonly { toolId: AiToolId; pluginName: string }[] = [] ): Promise { if (this.userManifestRepo === undefined) return; const machine = (await this.userManifestRepo.load()) ?? Manifest.create(); @@ -683,7 +832,11 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { let changed = false; for (const [toolId, outcome] of outcomes) { const machineRefs = - scope === "user" || pluginEnablementIsMachineGlobal(toolId) ? outcome.pluginRefs : []; + scope === "user" || pluginEnablementIsMachineGlobal(toolId) + ? outcome.pluginRefs.filter( + (ref) => !selected.length || this.isSelectedRef(project, toolId, ref, selected) + ) + : []; const machineMarketplaces = outcome.catalogClaims.filter((registration) => marketplaces.some( (marketplace) => @@ -759,7 +912,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId: ToolId, manifest: Manifest, marketplaces: readonly Marketplace[], - hostNameByAlias: ReadonlyMap + hostNameByAlias: ReadonlyMap, + scope: MarketplaceScope ): string[] { const byName = new Map(marketplaces.map((m) => [m.name, m])); const refs: string[] = []; @@ -770,6 +924,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { if (hostName === undefined) continue; refs.push(`${plugin.name}@${hostName}`); } + // Native-only global installs record successful activations, not copied plugin files. + // Claims also recover a selection an older sync dropped from its projection. + if (scope === "user") { + const native = manifest.getNativeRegistrations(toolId); + const provenHosts = [...hostNameByAlias.values()]; + for (const ref of [ + ...(native?.pluginRefs ?? []), + ...(native?.pluginClaims ?? []).map((claim) => claim.ref), + ]) { + if (provenHosts.some((hostName) => ref.endsWith(`@${hostName}`))) refs.push(ref); + } + } return refs; } @@ -777,7 +943,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId: ToolId, hostName: string, projectRoot: string, - fresh?: NativeMarketplaceRegistration + fresh?: NativeMarketplaceRegistration, + approvedRefs: readonly string[] = [] ): Promise { if (!isAiToolId(toolId)) return { canonical: false, hostReadable: false, sourceStatus: "unproven" }; @@ -822,9 +989,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ? [] : (project?.getNativeRegistrations(toolId)?.pluginRefs ?? [])), ]); - const foreignRef = [...reading.refs.keys()].find( - (ref) => ref.endsWith(`@${hostName}`) && !claimed.has(ref) - ); + const foreignRef = [...reading.refs].find( + ([ref, entry]) => + hostRefInUse(toolId, entry.enabled) && + ref.endsWith(`@${hostName}`) && + !claimed.has(ref) && + !approvedRefs.includes(ref) + )?.[0]; return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) }; } @@ -836,15 +1007,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { marketplace: Marketplace, projectRoot: string, warnings: string[], - freshClaims: readonly NativeMarketplaceRegistration[] + freshClaims: readonly NativeMarketplaceRegistration[], + approvedRefs: readonly string[] ): Promise<{ hostName: string; outcome: "registered" | "refused" | "build-failed"; claimable: boolean; addedThisRun?: boolean; + skipNativeWrites?: boolean; + refreshable?: boolean; provenance?: NativeMarketplaceSource; }> { - const builtDir = await this.buildForTool(toolId, marketplace, projectRoot); + const builtDir = await this.buildForTool(toolId, marketplace, projectRoot, warnings); if (builtDir === null) return { hostName: marketplace.name, outcome: "build-failed", claimable: false }; const requestedIdentity = await readMarketplaceCatalogIdentity(this.fs, toolId, builtDir); @@ -854,11 +1028,20 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); } const hostName = requestedIdentity.name; + const approvedSource = + nativeActivationOf(toolId)?.marketplaceRegistry && approvedRefs.length + ? { + alias: marketplace.name, + hostName, + provenance: { kind: "registry" as const, source: await this.fs.realpath(builtDir) }, + } + : undefined; const proof = await this.nativeCatalogProof( toolId, hostName, projectRoot, - freshClaims.find((claim) => claim.hostName === hostName) + freshClaims.find((claim) => claim.hostName === hostName) ?? approvedSource, + approvedRefs ); if ( proof.sourceStatus === "unproven" || @@ -928,7 +1111,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { samePath(proof.source.root, requestedSource) && proof.source.sourceType === "local")) ) - return { hostName, outcome: "registered", claimable: true, provenance: proof.source }; + return { + hostName, + outcome: "registered", + claimable: true, + provenance: proof.source, + refreshable: true, + }; const decision = await this.guardAgainstConflict( toolId, builtDir, @@ -945,6 +1134,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { outcome: "registered", claimable: proof.sourceStatus === "owned", provenance: proof.source, + skipNativeWrites: true, }; try { activator.addMarketplace(builtDir, marketplace.scope); @@ -974,6 +1164,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { claimable: afterReclaim !== undefined, addedThisRun: afterReclaim !== undefined, provenance: afterReclaim, + refreshable: afterReclaim !== undefined, }; } const afterAdd = await this.proveAddedSource(toolId, hostName, builtDir, projectRoot, warnings); @@ -983,6 +1174,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { claimable: afterAdd !== undefined, addedThisRun: afterAdd !== undefined, provenance: afterAdd, + refreshable: afterAdd !== undefined, }; } @@ -1169,7 +1361,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { private async buildForTool( toolId: ToolId, marketplace: Marketplace, - projectRoot: string + projectRoot: string, + warnings?: string[] ): Promise { try { const { builtDir } = await this.ensureBuilt.execute({ @@ -1180,9 +1373,9 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { }); return builtDir; } catch (error) { - this.logger.warn( - `Native plugin activation — build '${marketplace.name}' for ${toolId} skipped: ${(error as Error).message}` - ); + const message = `Native plugin activation — build '${marketplace.name}' for ${toolId} skipped: ${(error as Error).message}`; + this.logger.warn(message); + warnings?.push(message); return null; } } @@ -1272,11 +1465,22 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest: Manifest, marketplaces: readonly Marketplace[], settings: MarketplaceSettings, - provenRefs?: readonly string[] + provenRefs?: readonly string[], + selected: readonly { toolId: AiToolId; pluginName: string }[] = [] ): Promise { const pluginsPath = resolve(projectRoot, settings.settingsPath); const json = await this.loadSettings(pluginsPath); - if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces, provenRefs)) + if ( + !this.mergeEnabledPlugins( + json, + settings, + toolId, + manifest, + marketplaces, + provenRefs, + selected + ) + ) return false; const content = JSON.stringify(json, null, 2); await this.fs.writeFile(pluginsPath, content); @@ -1290,14 +1494,20 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId: ToolId, manifest: Manifest, marketplaces: readonly Marketplace[], - provenRefs?: readonly string[] + provenRefs?: readonly string[], + selected: readonly { toolId: AiToolId; pluginName: string }[] = [] ): boolean { const pluginsKey = settings.enabledPluginsKey; if (pluginsKey == null) return false; const existing = this.existingRecord(json, pluginsKey); const toAdd: Record = {}; if (provenRefs !== undefined) { - for (const ref of provenRefs) if (!(ref in existing)) toAdd[ref] = true; + for (const ref of provenRefs) + if ( + !(ref in existing) && + (!selected.length || this.isSelectedRef(manifest, toolId, ref, selected)) + ) + toAdd[ref] = true; if (Object.keys(toAdd).length === 0) return false; json[pluginsKey] = { ...existing, ...toAdd }; return true; @@ -1320,6 +1530,22 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { return true; } + private isSelectedRef( + manifest: Manifest, + toolId: ToolId, + ref: string, + selected: readonly { toolId: AiToolId; pluginName: string }[] + ): boolean { + return manifest + .getPlugins(toolId) + .some( + (plugin) => + plugin.scope === "project" && + ref.startsWith(`${plugin.name}@`) && + selected.some((target) => target.toolId === toolId && target.pluginName === plugin.name) + ); + } + private existingRecord( json: Record, settingsKey: string diff --git a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts index 4c6696729..0ceefd32a 100644 --- a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts @@ -23,8 +23,10 @@ import type { ReadonlySkipList } from "../../../../translate/domain/plugin-trans import type { Manifest } from "../../../domain/manifest.js"; import { InstalledPlugin, + type KiloTuiProvenance, type ProjectHooksProvenance, } from "../../../domain/plugins/installed-plugin.js"; +import { planKiloTuiInstall } from "../../ownership/tui/kilo-tui-config.js"; import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js"; import { resolveBaseDirFromRecord, @@ -60,7 +62,8 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { previousMcpEntries: ReadonlyMap = new Map(), userScopeDirTaken = false, previousProjectHooks?: ProjectHooksProvenance, - hooksGuard: ProjectHooksGuard = {} + hooksGuard: ProjectHooksGuard = {}, + previousTui?: KiloTuiProvenance ): Promise<{ skipped: ReadonlySkipList; written?: number }> { const resolved = marketplace === undefined ? null : await this.findMarketplace(marketplace, projectRoot); @@ -75,9 +78,17 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { previousMcpEntries, userScopeDirTaken, previousProjectHooks, - hooksGuard + hooksGuard, + previousTui ); } + const tui = resolvePluginsCapability(toolId)?.startupTui; + const isKiloTui = tui?.plugin === dist.manifest.name && !userScopeDirTaken; + previousTui ??= manifest + .getPlugins(toolId) + .find((p) => p.name === dist.manifest.name)?.tuiPlugin; + if (isKiloTui) + await planKiloTuiInstall(this.fs, join(projectRoot, tui.directory), projectRoot, previousTui); const mode = frameworkBuildModeFor(toolId); const { builtDir } = await this.ensureBuilt.execute({ projectRoot, @@ -121,6 +132,21 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { : resolveBaseDirFromRecord(scope, toolId, projectRoot, this.homedir); const owned = userScopeDirTaken ? [] : files; const written = await this.writeChangedFiles(owned, baseDir); + let tuiPlugin = previousTui; + if (isKiloTui) { + const plan = await planKiloTuiInstall( + this.fs, + join(projectRoot, tui.directory), + projectRoot, + previousTui + ); + if ( + !(await this.fs.fileExists(plan.path)) || + (await this.fs.readFile(plan.path)) !== plan.content + ) + await this.fs.writeFile(plan.path, plan.content); + tuiPlugin = plan.provenance; + } manifest.addPlugin( toolId, InstalledPlugin.withProjectHooks( @@ -134,7 +160,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { marketplace ), hooks.projectHooks - ) + ).withTuiPlugin(tuiPlugin) ); return { skipped: [...mcp.mcpSkips, ...hooks.skipped], written }; } @@ -159,11 +185,11 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { // sibling by relative path, under `skills//` or, `single-level`, `skills/-/`. // Hooks land under `flatHooksDir//`, except the loader's own runtime module, renamed to // the plugin's name in the loader's directory — so hook paths are matched by path, not by name. - private async readFlatFiles( + async readFlatFiles( builtDir: string, dist: PluginDistribution, toolId: AiToolId, - deliversHooksToProject: boolean + deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project" ): Promise { const name = dist.manifest.name; const hookPaths = deliversHooksToProject @@ -204,13 +230,15 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { const paths = new Set( dist.components.hooks .filter((f) => f.relativePath !== "hooks/hooks.json") - .map((f) => - flatHooksPathWithLoaderEntry( - flatHooksDir, - plugins.flatHooksLoaderEntry, - name, - f.relativePath - ) + .map( + (f) => + plugins.flatHooksPath?.(name, f.relativePath) ?? + flatHooksPathWithLoaderEntry( + flatHooksDir, + plugins.flatHooksLoaderEntry, + name, + f.relativePath + ) ) ); const bridge = plugins.flatHooksBridge; diff --git a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts index 3d05e378c..14002596d 100644 --- a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts @@ -7,16 +7,22 @@ import type { Hasher } from "../../../../../kernel/ports/hasher.js"; import type { PluginSource } from "../../../../../kernel/source.js"; import type { AiToolId } from "../../../../../kernel/tool.js"; import type { PluginsCapability } from "../../../../tools/domain/capabilities/plugins-capability.js"; -import { getToolConfig, isAiTool } from "../../../../tools/domain/registry.js"; +import { + getToolConfig, + isAiTool, + resolvePluginsCapability, +} from "../../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; import type { Manifest } from "../../../domain/manifest.js"; import { InstalledPlugin, + type KiloTuiProvenance, type PluginScope, type ProjectHooksProvenance, } from "../../../domain/plugins/installed-plugin.js"; +import { planKiloTuiInstall } from "../../ownership/tui/kilo-tui-config.js"; import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js"; import { resolveBaseDirFromRecord, @@ -54,10 +60,18 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { previousMcpEntries: ReadonlyMap = new Map(), userScopeDirTaken = false, previousProjectHooks?: ProjectHooksProvenance, - hooksGuard: ProjectHooksGuard = {} + hooksGuard: ProjectHooksGuard = {}, + previousTui?: KiloTuiProvenance ): Promise<{ skipped: ReadonlySkipList; written?: number }> { const ctx = this.resolveFlatToolContext(toolId, dist, projectRoot); if (ctx === null) return { skipped: [] }; + const tui = resolvePluginsCapability(toolId)?.startupTui; + const isKiloTui = tui?.plugin === dist.manifest.name && !userScopeDirTaken; + previousTui ??= manifest + .getPlugins(toolId) + .find((p) => p.name === dist.manifest.name)?.tuiPlugin; + if (isKiloTui) + await planKiloTuiInstall(this.fs, join(projectRoot, tui.directory), projectRoot, previousTui); const mcp = await materializeFlatMcp( this.fs, this.hasher, @@ -90,6 +104,22 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { ctx.scope, manifest ); + if (isKiloTui) { + const plan = await planKiloTuiInstall( + this.fs, + join(projectRoot, tui.directory), + projectRoot, + previousTui + ); + if ( + !(await this.fs.fileExists(plan.path)) || + (await this.fs.readFile(plan.path)) !== plan.content + ) + await this.fs.writeFile(plan.path, plan.content); + const plugin = manifest.getPlugins(toolId).find((p) => p.name === dist.manifest.name); + if (plugin !== undefined) + manifest.updatePlugin(toolId, plugin.withTuiPlugin(plan.provenance)); + } return { skipped: allSkipped, written }; } diff --git a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts index e0ae16eb0..faea499ff 100644 --- a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts @@ -28,6 +28,7 @@ export interface PluginTranslator { previousMcpEntries?: ReadonlyMap, userScopeDirTaken?: boolean, previousProjectHooks?: ProjectHooksProvenance, - hooksGuard?: ProjectHooksGuard + hooksGuard?: ProjectHooksGuard, + previousTui?: import("../../../domain/plugins/installed-plugin.js").KiloTuiProvenance ): Promise<{ skipped: ReadonlySkipList; written?: number }>; } diff --git a/cli/src/contexts/framework/application/ownership/global-plugin-files.ts b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts new file mode 100644 index 000000000..42665e7c6 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts @@ -0,0 +1,97 @@ +import { dirname, join, posix } from "node:path"; +import { InstallationFile } from "../../../../kernel/file.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { globalFilesOf } from "../../../tools/domain/registry.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-containment.js"; + +/** + * A plugin's machine-scope files, read from the tool's built tree and keyed by their path under + * the tool's user configuration directory. Empty for a tool that copies none. + */ +export async function readGlobalPluginFiles( + fs: FileReader, + hasher: Hasher, + builtDir: string, + toolId: AiToolId, + dist: PluginDistribution +): Promise { + const machine = globalFilesOf(toolId); + if (machine === null) return []; + const plugin = { + name: dist.manifest.name, + agents: new Set(dist.components.agents.map((file) => posix.basename(file.relativePath, ".md"))), + }; + const files: InstallationFile[] = []; + for (const abs of await fs.listFilesRecursive(builtDir)) { + const builtPath = posix.normalize( + abs + .slice(builtDir.length + 1) + .split("\\") + .join("/") + ); + const relativePath = machine.map(builtPath, plugin); + if (relativePath === null) continue; + const content = await fs.readFile(abs); + files.push(new InstallationFile({ relativePath, content, hash: hasher.hash(content) })); + } + return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +/** + * Refuses a machine-scope write before any byte lands: every path must stay a normalized relative + * path, its nearest existing parent must resolve strictly inside the tool's user directory, and a + * file already there that this plugin does not own is never overwritten. Creates the user + * directory itself when the host has not yet. + */ +export async function assertGlobalFilesWritable( + fs: FileReader & FileWriter, + toolId: AiToolId, + plugin: Pick | undefined, + pluginName: string, + files: readonly InstallationFile[], + homedir: string +): Promise { + const machine = globalFilesOf(toolId); + if (machine === null) throw new Error(`${toolId}: no machine-scope directory is declared.`); + const boundary = machine.dir(homedir); + await fs.createDirectory(boundary); + const resolvedBoundary = await fs.realpath(boundary); + for (const file of files) { + const rel = file.relativePath; + if (posix.isAbsolute(rel) || rel.includes("\\") || posix.normalize(rel) !== rel) { + throw new Error(`${toolId}: '${pluginName}' path '${rel}' escapes ${boundary}.`); + } + let parent = dirname(join(boundary, rel)); + while (parent !== boundary) { + const resolved = await tryRealpath(fs, parent); + if (resolved !== null) { + if (!isStrictlyWithinUserScope(resolved, resolvedBoundary)) { + throw new Error(`${toolId}: '${pluginName}' parent '${parent}' escapes ${boundary}.`); + } + break; + } + parent = dirname(parent); + } + if (plugin?.files.has(rel) === true) continue; + const target = join(boundary, rel); + if (!(await fs.fileExists(target))) continue; + throw new Error( + `${toolId}: '${target}' already exists and was not installed by aidd; move or delete it, then rerun.` + ); + } + return boundary; +} + +async function tryRealpath(fs: FileReader, path: string): Promise { + try { + return await fs.realpath(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} diff --git a/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts index 5c36dce71..4c98c0f61 100644 --- a/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts +++ b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts @@ -95,7 +95,8 @@ export class NativeHostRegistrationGate { reader, registration.hostName, owned, - projectRoot + projectRoot, + toolId ); const refs = (registrations.pluginClaims ?? []).filter((claim) => claim.ref.endsWith(`@${registration.hostName}`) diff --git a/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts index 126784df5..0f0014b34 100644 --- a/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts +++ b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts @@ -1,3 +1,4 @@ +import type { ToolId } from "../../../../kernel/tool.js"; import type { HostPluginRegistryEntry, HostPluginRegistryReader, @@ -8,6 +9,7 @@ import type { NativeMarketplaceSourceReader, NativeMarketplaceSourceReading, } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; +import { hostRefInUse } from "../../../tools/domain/registry.js"; import type { NativeMarketplaceRegistration } from "../../domain/manifest/native-registrations.js"; export interface NativeMarketplaceSourceInspection { @@ -80,7 +82,8 @@ export async function assertNoForeignNativeRefs( reader: HostPluginRegistryReader | undefined, hostName: string, ownedRefs: ReadonlySet, - projectRoot: string + projectRoot: string, + toolId: ToolId ): Promise> { if (reader === undefined) throw new Error( @@ -99,9 +102,10 @@ export async function assertNoForeignNativeRefs( `Catalogue '${hostName}': host plugin registry unreadable (${reading.unreadable ?? reading.location}); no host mutation made.` ); const refs = reading.refs ?? new Map(); - const foreign = [...refs.keys()].find( - (ref) => ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref) - ); + const foreign = [...refs].find( + ([ref, entry]) => + hostRefInUse(toolId, entry.enabled) && ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref) + )?.[0]; if (foreign !== undefined) throw new Error( `Catalogue '${hostName}' includes foreign host ref '${foreign}'; no host mutation made.` diff --git a/cli/src/contexts/framework/application/ownership/project-path-boundary.ts b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts index 71b04503c..8510bc1d6 100644 --- a/cli/src/contexts/framework/application/ownership/project-path-boundary.ts +++ b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts @@ -1,4 +1,5 @@ -import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { samePath } from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; /** Resolve an existing file or its nearest existing parent before mutating project-local content. */ @@ -25,6 +26,10 @@ export async function assertProjectPathWithinRoot( return; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + // A dangling symlink exists even though realpath cannot resolve it. Never treat it + // as an absent path whose parent is safe to write through. + if (await fs.entryExists?.(candidate)) + throw new Error(`'${candidate}' is an unresolved symlink; local mutation refused.`); if (candidate === lexicalRoot) { throw new Error(`Project root '${projectRoot}' disappeared; local mutation refused.`); } @@ -33,6 +38,35 @@ export async function assertProjectPathWithinRoot( } } +/** A tracked file or disposable cache is not owned if any component became a symlink. */ +export async function assertNoSymlinksWithinRoot( + fs: FileReader, + root: string, + path: string +): Promise { + const lexicalRoot = resolve(root); + const lexicalPath = resolve(path); + if (!strictlyWithin(lexicalRoot, lexicalPath)) + throw new Error(`'${path}' escapes its owned root '${root}'.`); + let canonical = await fs.realpath(lexicalRoot); + let candidate = lexicalRoot; + for (const segment of relative(lexicalRoot, lexicalPath).split(sep)) { + candidate = join(candidate, segment); + canonical = join(canonical, segment); + try { + if (!samePath(await fs.realpath(candidate), canonical)) + throw new Error(`'${candidate}' is a symlink; refusing to mutate it.`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (!fs.entryExists || (await fs.entryExists(candidate))) + throw new Error( + `'${candidate}' cannot be verified without following an unresolved symlink.` + ); + return; + } + } +} + function within(root: string, path: string): boolean { const rel = relative(root, path); return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); diff --git a/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts index d2bb2b54c..c636ab609 100644 --- a/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts +++ b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts @@ -4,7 +4,11 @@ import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js"; -import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import { + getToolConfig, + isAiTool, + resolvePluginsCapability, +} from "../../../tools/domain/registry.js"; import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; @@ -12,8 +16,46 @@ import { isFrameworkPrimeFlatMcp } from "../plugin/plugin-target-resolution.js"; import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js"; import { detachNativePluginRefs } from "./native-plugin-ownership.js"; import { assertProjectPathWithinRoot } from "./project-path-boundary.js"; +import { planKiloTuiInstall, planKiloTuiRemoval } from "./tui/kilo-tui-config.js"; import { detachUserPlugin } from "./user-plugin-ownership.js"; +export async function assertProjectTuiUnchanged( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + if (plugin.tuiPlugin === undefined) return; + const tui = resolvePluginsCapability(toolId)?.startupTui; + if (tui?.plugin !== plugin.name || plugin.scope !== "project") + throw new Error(`${toolId}: cannot locate the owned TUI configuration.`); + await planKiloTuiInstall(fs, join(projectRoot, tui.directory), projectRoot, plugin.tuiPlugin); +} + +export async function planProjectKiloTuiRemoval( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +) { + const tui = resolvePluginsCapability(toolId)?.startupTui; + if (tui?.plugin !== plugin.name || plugin.scope !== "project" || plugin.tuiPlugin === undefined) + return undefined; + return planKiloTuiRemoval(fs, join(projectRoot, tui.directory), projectRoot, plugin.tuiPlugin); +} + +export async function removeProjectKiloTui( + fs: FileReader & FileWriter, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + const plan = await planProjectKiloTuiRemoval(fs, plugin, toolId, projectRoot); + if (plan === undefined) return false; + await fs.writeFile(plan.path, plan.content); + return true; +} + /** Read-only OpenCode MCP proof, shared by Clean and local plugin removals before any detach. */ export async function assertProjectMcpEntriesRemovable( fs: FileReader, @@ -60,6 +102,7 @@ export class ProjectPluginCleanup { ): Promise { await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot); await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot); + await planProjectKiloTuiRemoval(this.fs, plugin, toolId, projectRoot); } async removeLocalIntegration( @@ -68,6 +111,7 @@ export class ProjectPluginCleanup { projectRoot: string ): Promise { const mcpUpdate = await planProjectMcpRemoval(this.fs, plugin, toolId, projectRoot); + await removeProjectKiloTui(this.fs, plugin, toolId, projectRoot); await removeProjectHooks(this.fs, plugin, toolId, projectRoot); if (mcpUpdate !== undefined) { await assertProjectPathWithinRoot(this.fs, projectRoot, mcpUpdate.output); diff --git a/cli/src/contexts/framework/application/ownership/release/pinned-user-framework.ts b/cli/src/contexts/framework/application/ownership/release/pinned-user-framework.ts new file mode 100644 index 000000000..57b51551b --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/release/pinned-user-framework.ts @@ -0,0 +1,21 @@ +import type { InstallationFile } from "../../../../../kernel/file.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; + +export interface PinnedUserFrameworkResult { + readonly sha: string; + readonly dist: PluginDistribution; + readonly files: readonly InstallationFile[]; +} + +/** The catalogue, plugin manifest and built files come from one isolated checkout. */ +export interface PinnedUserFramework { + build( + ref: string, + toolId: AiToolId, + pluginName: string, + sha?: string + ): Promise; + /** Reuse private checkout/build trees only for this operation. */ + withSession?(action: (session: PinnedUserFramework) => Promise): Promise; +} diff --git a/cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts b/cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts new file mode 100644 index 000000000..d58f95082 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts @@ -0,0 +1,178 @@ +import type { Logger } from "../../../../../kernel/ports/logger.js"; +import { compareSemver, isSemver } from "../../../../../kernel/semver.js"; +import { type AiToolId, isAiToolId } from "../../../../../kernel/tool.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../../../distribution/domain/marketplace.js"; +import { frameworkBuildModeFor, globalFilesOf } from "../../../../tools/domain/registry.js"; +import { Manifest } from "../../../domain/manifest.js"; +import { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; +import type { UserPluginFileUpdater } from "../user-plugin-file-updater.js"; +import type { PinnedUserFramework } from "./pinned-user-framework.js"; +import type { UserReleaseJournal } from "./user-release-journal.js"; + +export interface UserReleasePreview { + readonly release: string; + readonly title: string | null; + readonly targets: readonly { + toolId: AiToolId; + name: string; + currentRef: string; + currentVersion: string; + targetVersion: string; + dependents: readonly string[]; + }[]; + readonly nativeSkipped: number; +} + +/** Updates the individually owned flat copies, without moving a shared marketplace or native claim. */ +export class UpgradeUserFrameworkUseCase { + constructor( + private readonly deps: { + repo: ManifestRepository; + builder: PinnedUserFramework; + updater: UserPluginFileUpdater; + journal: UserReleaseJournal; + logger: Logger; + resolvePublishedRelease: ( + repo: string, + release: string + ) => Promise<{ title: string | null } | null>; + } + ) {} + + async execute(options: { + projectRoot: string; + release: string; + confirm: (preview: UserReleasePreview) => Promise; + }): Promise<{ updated: string[]; nativeSkipped: number; cancelled: boolean }> { + const run = () => { + const builder = this.deps.builder; + return builder.withSession + ? builder.withSession((session) => this.executeLocked(options, session)) + : this.executeLocked(options, builder); + }; + return this.deps.repo.withExclusiveAccess ? this.deps.repo.withExclusiveAccess(run) : run(); + } + + private async executeLocked( + options: { + projectRoot: string; + release: string; + confirm: (preview: UserReleasePreview) => Promise; + }, + builder: PinnedUserFramework + ): Promise<{ updated: string[]; nativeSkipped: number; cancelled: boolean }> { + const { release, projectRoot, confirm } = options; + if (!release.startsWith("v") || !isSemver(release)) + throw new Error("A root release tag such as v5.13.0 is required."); + const loaded = await this.deps.repo.load(); + if (!loaded) throw new Error("No machine installation manifest exists."); + let manifest = Manifest.fromJSON(loaded.toJSON()); + const publishedRelease = await this.deps.resolvePublishedRelease( + "ai-driven-dev/framework", + release + ); + if (!publishedRelease) + throw new Error(`Release ${release} is not available from the official source.`); + const nativeSkipped = manifest + .getInstalledToolIds() + .reduce( + (total, tool) => + total + + (isAiToolId(tool) + ? (manifest.getNativeRegistrations(tool)?.pluginClaims ?? []).filter((claim) => + claim.ref.endsWith(`@${FRAMEWORK_MARKETPLACE_NAME}`) + ).length + : 0), + 0 + ); + const targets = manifest.getInstalledToolIds().flatMap((toolId) => { + if (!isAiToolId(toolId) || !globalFilesOf(toolId) || frameworkBuildModeFor(toolId) !== "flat") + return []; + return manifest + .getPlugins(toolId) + .filter( + (plugin) => plugin.scope === "user" && plugin.marketplace === FRAMEWORK_MARKETPLACE_NAME + ) + .map((plugin) => ({ toolId, plugin })); + }); + if (targets.length === 0) + throw new Error( + "No flat machine-scoped AIDD plugins found; native installations need their owning tool." + ); + const plans = []; + let sha: string | undefined; + for (const { toolId, plugin } of targets) { + const source = plugin.source; + if (plugin.mcpEntries.size || plugin.projectHooks !== undefined || plugin.componentPaths.size) + throw new Error( + `${toolId}:${plugin.name}: machine ownership includes unsupported contributions; update refused.` + ); + if ( + source.kind !== "git-subdir" || + source.url !== "https://github.com/ai-driven-dev/framework.git" || + source.path !== `plugins/${plugin.name}` || + !source.ref || + !source.ref.startsWith("v") || + !isSemver(source.ref) + ) + throw new Error( + `${toolId}:${plugin.name}: current source is not a pinned official release.` + ); + if (compareSemver(release, source.ref) < 0) + throw new Error(`${toolId}:${plugin.name}: refusing a release downgrade.`); + const prepared = await builder.build(release, toolId, plugin.name, sha); + sha ??= prepared.sha; + if (compareSemver(prepared.dist.manifest.version, plugin.version) < 0) + throw new Error(`${toolId}:${plugin.name}: refusing a plugin downgrade.`); + const candidate = InstalledPlugin.fromJSON({ + ...plugin.toJSON(), + source: { ...source, ref: release, sha: prepared.sha }, + }); + const plan = await this.deps.updater.planUpdate( + candidate, + toolId, + projectRoot, + this.deps.logger, + true, + prepared + ); + if (!plan) throw new Error(`${toolId}:${plugin.name}: unable to plan machine update.`); + if (JSON.stringify(plugin.tuiPlugin) !== JSON.stringify(plan.next.tuiPlugin)) + throw new Error( + `${toolId}:${plugin.name}: release would change a shared TUI configuration; update refused.` + ); + plans.push({ toolId, plugin, plan, currentRef: source.ref }); + } + const preview: UserReleasePreview = { + release, + title: publishedRelease.title, + nativeSkipped, + targets: plans.map(({ toolId, plugin, plan, currentRef }) => ({ + toolId, + name: plugin.name, + currentRef, + currentVersion: plugin.version, + targetVersion: plan.next.version, + dependents: plugin.dependents, + })), + }; + if (!(await confirm(preview))) return { updated: [], nativeSkipped, cancelled: true }; + const updated: string[] = []; + for (const { toolId, plugin, plan } of plans) { + const next = Manifest.fromJSON(manifest.toJSON()); + next.updatePlugin(toolId, plan.next); + try { + await this.deps.journal.commit(manifest, next, toolId, plugin.name, plan.files); + } catch (error) { + throw new Error( + `Machine release update incomplete. Completed: ${updated.join(", ") || "none"}. Retry to recover.`, + { cause: error } + ); + } + manifest = next; + updated.push(`${toolId}:${plugin.name}`); + } + return { updated, nativeSkipped, cancelled: false }; + } +} diff --git a/cli/src/contexts/framework/application/ownership/release/user-release-journal.ts b/cli/src/contexts/framework/application/ownership/release/user-release-journal.ts new file mode 100644 index 000000000..6a43f1fa6 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/release/user-release-journal.ts @@ -0,0 +1,240 @@ +import { join, posix } from "node:path"; +import type { InstallationFile } from "../../../../../kernel/file.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import { type AiToolId, isAiToolId } from "../../../../../kernel/tool.js"; +import { globalFilesOf } from "../../../../tools/domain/registry.js"; +import { Manifest } from "../../../domain/manifest.js"; +import { PLUGIN_NAME_REGEX } from "../../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; +import { assertProjectPathWithinRoot } from "../project-path-boundary.js"; + +interface JournalFile { + readonly path: string; + readonly before: string | null; + readonly after: string | null; + readonly content: string | null; +} + +interface Journal { + readonly tool: AiToolId; + readonly plugin: string; + readonly boundary: string; + readonly oldManifest: unknown; + readonly nextManifest: unknown; + readonly files: readonly JournalFile[]; +} + +export interface UserReleaseJournalStore { + read(): Promise; + create(content: string): Promise; + remove(): Promise; +} + +const digest = /^[a-f0-9]{32}$/; + +function object(value: unknown): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error("Invalid user release journal; refusing recovery."); + return value as Record; +} + +function parseJournal(raw: string): Journal { + const value = object(JSON.parse(raw)); + if ( + typeof value.tool !== "string" || + !isAiToolId(value.tool) || + typeof value.plugin !== "string" || + !PLUGIN_NAME_REGEX.test(value.plugin) || + typeof value.boundary !== "string" || + !Array.isArray(value.files) + ) + throw new Error("Invalid user release journal; refusing recovery."); + const files = value.files.map((entry): JournalFile => { + const file = object(entry); + if ( + typeof file.path !== "string" || + posix.isAbsolute(file.path) || + file.path.includes("\\") || + posix.normalize(file.path) !== file.path || + file.path === "." || + file.path.startsWith("../") || + !(file.before === null || (typeof file.before === "string" && digest.test(file.before))) || + !(file.after === null || (typeof file.after === "string" && digest.test(file.after))) || + !(file.content === null || typeof file.content === "string") || + (file.after === null) !== (file.content === null) + ) + throw new Error("Invalid user release journal file; refusing recovery."); + return { path: file.path, before: file.before, after: file.after, content: file.content }; + }); + return { + tool: value.tool, + plugin: value.plugin, + boundary: value.boundary, + oldManifest: value.oldManifest, + nextManifest: value.nextManifest, + files, + }; +} + +/** Roll forward from recorded old/new bytes; detect edits between checks and retry attempts. */ +export class UserReleaseJournal { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly hasher: Hasher, + private readonly repo: ManifestRepository, + private readonly context: { home: () => string; store: UserReleaseJournalStore } + ) {} + + async commit( + oldManifest: Manifest, + nextManifest: Manifest, + tool: AiToolId, + plugin: string, + files: readonly InstallationFile[] + ): Promise { + if ((await this.context.store.read()) !== null) + throw new Error("A user release update needs recovery first."); + const old = oldManifest.getPlugins(tool).find((item) => item.name === plugin); + const next = nextManifest.getPlugins(tool).find((item) => item.name === plugin); + const machine = globalFilesOf(tool); + if (!old || !next || !machine || old.scope !== "user" || next.scope !== "user") + throw new Error("Cannot journal an unowned machine plugin."); + const boundary = await this.fs.realpath(machine.dir(this.context.home())); + const byPath = new Map(files.map((file) => [file.relativePath, file])); + if (byPath.size !== files.length || byPath.size !== next.files.size) + throw new Error("Duplicate or missing machine plugin files."); + const paths = new Set([...old.files.keys(), ...next.files.keys()]); + const entries: JournalFile[] = []; + for (const path of paths) { + const file = byPath.get(path); + const after = next.files.get(path) ?? null; + if ( + (file?.hash.value ?? null) !== after || + (file && this.hasher.hash(file.content).value !== after) + ) + throw new Error(`Unverified target file '${path}'.`); + entries.push({ + path, + before: old.files.get(path) ?? null, + after, + content: file?.content ?? null, + }); + } + const journal = parseJournal( + JSON.stringify({ + tool, + plugin, + boundary, + oldManifest: oldManifest.toJSON(), + nextManifest: nextManifest.toJSON(), + files: entries, + }) + ); + await this.validate(journal, true); + await this.context.store.create(JSON.stringify(journal)); + await this.apply(journal); + } + + async recover(): Promise { + const content = await this.context.store.read(); + if (content === null) return; + await this.apply(parseJournal(content)); + } + + private async validate(journal: Journal, starting = false): Promise { + const old = Manifest.fromJSON(journal.oldManifest); + const next = Manifest.fromJSON(journal.nextManifest); + const beforePlugin = old.getPlugins(journal.tool).find((item) => item.name === journal.plugin); + const afterPlugin = next.getPlugins(journal.tool).find((item) => item.name === journal.plugin); + const machine = globalFilesOf(journal.tool); + if ( + !beforePlugin || + !afterPlugin || + !machine || + beforePlugin.scope !== "user" || + afterPlugin.scope !== "user" + ) + throw new Error("Journal does not identify an owned machine plugin."); + const reverted = Manifest.fromJSON(next.toJSON()); + reverted.updatePlugin(journal.tool, beforePlugin); + if (JSON.stringify(reverted.toJSON()) !== JSON.stringify(old.toJSON())) + throw new Error("Journal changes other machine ownership; recovery refused."); + const root = machine.dir(this.context.home()); + if ((await this.fs.realpath(root)) !== journal.boundary) + throw new Error("Machine directory changed; release recovery refused."); + const paths = new Set([...beforePlugin.files.keys(), ...afterPlugin.files.keys()]); + if ( + new Set(journal.files.map((file) => file.path)).size !== paths.size || + journal.files.length !== paths.size + ) + throw new Error("Journal file list does not match machine ownership."); + const foreign = new Set( + old + .getPlugins(journal.tool) + .filter((item) => item.name !== journal.plugin) + .flatMap((item) => [...item.files.keys()]) + ); + for (const file of old.getToolFiles(journal.tool)) foreign.add(file.relativePath); + for (const file of old.getMergeFiles(journal.tool)) foreign.add(file.relativePath); + for (const file of journal.files) { + if ( + !paths.has(file.path) || + foreign.has(file.path) || + (beforePlugin.files.get(file.path) ?? null) !== file.before || + (afterPlugin.files.get(file.path) ?? null) !== file.after || + (file.content !== null && this.hasher.hash(file.content).value !== file.after) + ) + throw new Error(`Journal ownership or digest mismatch for '${file.path}'.`); + await assertProjectPathWithinRoot(this.fs, root, join(root, file.path)); + } + const current = await this.repo.load(); + if ( + !current || + ![old, next].some( + (item) => JSON.stringify(item.toJSON()) === JSON.stringify(current.toJSON()) + ) + ) + throw new Error("Machine manifest changed during release update; recovery refused."); + if (starting && JSON.stringify(current.toJSON()) !== JSON.stringify(old.toJSON())) + throw new Error("Machine manifest changed before release update."); + for (const file of journal.files) await this.assertState(root, file, starting); + } + + private async assertState(root: string, file: JournalFile, starting = false): Promise { + const path = join(root, file.path); + await assertProjectPathWithinRoot(this.fs, root, path); + const current = (await this.fs.fileExists(path)) + ? (await this.fs.readFileHash(path)).value + : null; + if (current !== file.before && (starting || current !== file.after)) + throw new Error(`'${path}' changed during release update; recovery refused.`); + } + + private async apply(journal: Journal): Promise { + await this.validate(journal); + const machine = globalFilesOf(journal.tool); + if (!machine) throw new Error("Machine tool is no longer available."); + const root = machine.dir(this.context.home()); + for (const file of journal.files) { + await this.assertState(root, file); + const path = join(root, file.path); + const current = (await this.fs.fileExists(path)) + ? (await this.fs.readFileHash(path)).value + : null; + if (current === file.after) continue; + if (file.content === null) await this.fs.deleteFile(path); + else await this.fs.writeFile(path, file.content); + const applied = (await this.fs.fileExists(path)) + ? (await this.fs.readFileHash(path)).value + : null; + if (applied !== file.after) throw new Error(`Failed to apply '${path}'; retry to recover.`); + } + // Other AIDD writes use the same lock. A non-cooperating editor may still change the + // manifest, so check it again immediately before saving rather than discard its changes. + await this.validate(journal); + await this.repo.save(Manifest.fromJSON(journal.nextManifest)); + await this.context.store.remove(); + } +} diff --git a/cli/src/contexts/framework/application/ownership/tui/kilo-tui-config.ts b/cli/src/contexts/framework/application/ownership/tui/kilo-tui-config.ts new file mode 100644 index 000000000..e55cc309b --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/tui/kilo-tui-config.ts @@ -0,0 +1,99 @@ +import { join } from "node:path"; +import { applyEdits, createScanner, modify, parse, SyntaxKind } from "jsonc-parser"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { KiloTuiProvenance } from "../../../domain/plugins/installed-plugin.js"; +import { assertProjectPathWithinRoot } from "../project-path-boundary.js"; + +export interface KiloTuiPlan { + readonly path: string; + readonly content: string; + readonly provenance: KiloTuiProvenance; +} + +const entry = "./hooks/aidd-maintenance/kilo-tui.js" as const; + +function comments(source: string): string[] { + const scanner = createScanner(source); + const result: string[] = []; + for (let token = scanner.scan(); token !== SyntaxKind.EOF; token = scanner.scan()) { + if (token === SyntaxKind.LineCommentTrivia || token === SyntaxKind.BlockCommentTrivia) + result.push(scanner.getTokenValue()); + } + return result; +} + +function edit(source: string, index: number, value: string | undefined): string { + const updated = applyEdits( + source, + modify(source, ["plugin", index], value, { + isArrayInsertion: value !== undefined, + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, + }) + ); + if (JSON.stringify(comments(source)) !== JSON.stringify(comments(updated))) + throw new Error("Kilo TUI config: refusing an edit that would change user comments."); + return updated; +} + +async function readConfig( + fs: FileReader, + dir: string, + boundary: string, + previous?: KiloTuiProvenance +) { + await assertProjectPathWithinRoot(fs, boundary, join(dir, "tui.json")); + await assertProjectPathWithinRoot(fs, boundary, join(dir, "tui.jsonc")); + const json = await fs.fileExists(join(dir, "tui.json")); + const jsonc = await fs.fileExists(join(dir, "tui.jsonc")); + if (json && jsonc) throw new Error("Kilo TUI: both tui.json and tui.jsonc exist."); + const config = json ? "tui.json" : jsonc ? "tui.jsonc" : (previous?.config ?? "tui.jsonc"); + if (previous !== undefined && previous.config !== config) + throw new Error("Kilo TUI: the tracked configuration changed; refusing to modify it."); + const path = join(dir, config); + const source = json || jsonc ? await fs.readFile(path) : "{}\n"; + const errors: { error: number; offset: number; length: number }[] = []; + const value: unknown = parse(source, errors, { allowTrailingComma: true }); + if (errors.length || value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error("Kilo TUI: invalid configuration; refusing to modify it."); + const plugins = (value as Record).plugin; + if ( + plugins !== undefined && + (!Array.isArray(plugins) || plugins.some((p) => typeof p !== "string")) + ) + throw new Error("Kilo TUI: plugin must be an array of strings."); + return { path, source, plugins: (plugins ?? []) as string[], config }; +} + +/** Plans without writing, so collision checks precede all installation mutations. */ +export async function planKiloTuiInstall( + fs: FileReader, + dir: string, + boundary: string, + previous?: KiloTuiProvenance +): Promise { + if (previous !== undefined && previous.entry !== entry) + throw new Error("Kilo TUI: invalid recorded entry."); + const { path, source, plugins, config } = await readConfig(fs, dir, boundary, previous); + const count = plugins.filter((p) => p === entry).length; + if (count > 1 || (count === 1 && previous === undefined)) + throw new Error("Kilo TUI: maintenance entry already exists without ownership."); + if (count === 0 && previous !== undefined) + throw new Error("Kilo TUI: tracked entry was removed; refusing to restore it."); + const content = count === 1 ? source : edit(source, plugins.length, entry); + return { path, content, provenance: { config, entry } }; +} + +/** Refuses changed or duplicate entries, and removes only the tracked entry. */ +export async function planKiloTuiRemoval( + fs: FileReader, + dir: string, + boundary: string, + provenance: KiloTuiProvenance +): Promise { + if (provenance.entry !== entry) throw new Error("Kilo TUI: invalid recorded entry."); + const { path, source, plugins } = await readConfig(fs, dir, boundary, provenance); + const index = plugins.indexOf(entry); + if (index === -1 || plugins.lastIndexOf(entry) !== index) + throw new Error("Kilo TUI: tracked entry changed; refusing removal."); + return { path, content: edit(source, index, undefined), provenance }; +} diff --git a/cli/src/contexts/framework/application/ownership/tui/user-kilo-tui-removal.ts b/cli/src/contexts/framework/application/ownership/tui/user-kilo-tui-removal.ts new file mode 100644 index 000000000..f5a069804 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/tui/user-kilo-tui-removal.ts @@ -0,0 +1,33 @@ +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import { globalFilesOf, resolvePluginsCapability } from "../../../../tools/domain/registry.js"; +import type { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js"; +import { planKiloTuiRemoval } from "./kilo-tui-config.js"; + +export async function planUserKiloTuiRemoval( + fs: FileReader, + toolId: AiToolId, + plugin: InstalledPlugin, + home: string +): Promise { + if (plugin.tuiPlugin === undefined) return; + const machine = globalFilesOf(toolId); + if (resolvePluginsCapability(toolId)?.startupTui?.plugin !== plugin.name || machine === null) + throw new Error(`${toolId}: cannot locate the owned TUI configuration.`); + await planKiloTuiRemoval(fs, machine.dir(home), home, plugin.tuiPlugin); +} + +export async function removeUserKiloTui( + fs: FileReader & FileWriter, + toolId: AiToolId, + plugin: InstalledPlugin, + home: string +): Promise { + if (plugin.tuiPlugin === undefined) return; + const machine = globalFilesOf(toolId); + if (resolvePluginsCapability(toolId)?.startupTui?.plugin !== plugin.name || machine === null) + throw new Error(`${toolId}: cannot locate the owned TUI configuration.`); + const plan = await planKiloTuiRemoval(fs, machine.dir(home), home, plugin.tuiPlugin); + await fs.writeFile(plan.path, plan.content); +} diff --git a/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts index dfe15769b..c31329ffb 100644 --- a/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts +++ b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts @@ -20,6 +20,7 @@ import type { import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js"; +import { planUserKiloTuiRemoval, removeUserKiloTui } from "./tui/user-kilo-tui-removal.js"; export class UserMarketplaceRemoveUseCase { constructor( @@ -99,6 +100,7 @@ export class UserMarketplaceRemoveUseCase { ); const safeFiles = await Promise.all( plugins.map(async ({ toolId, plugin }) => { + await planUserKiloTuiRemoval(this.fs, toolId, plugin, resolveHomeDir()); const files = await userScopeFilesSafeToDelete( this.fs, { debug() {}, info() {}, warn() {} }, @@ -132,6 +134,7 @@ export class UserMarketplaceRemoveUseCase { activator.removeMarketplace(registration.hostName, "user"); } for (const { toolId, plugin, files } of safeFiles) { + await removeUserKiloTui(this.fs, toolId, plugin, resolveHomeDir()); await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user"); manifest.removePlugin(toolId, plugin.name); } diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index 5966418f4..a9c10ba80 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -1,20 +1,22 @@ import { homedir as nodeHomedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, relative } from "node:path"; import type { InstallationFile } from "../../../../kernel/file.js"; +import { samePath } from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; -import { compareSemver } from "../../../../kernel/semver.js"; +import { compareSemver, isSemver } from "../../../../kernel/semver.js"; import type { AiToolId } from "../../../../kernel/tool.js"; import { frameworkBuildModeFor, getToolConfig, + globalFilesOf, resolvePluginsCapability, } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; -import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { InstalledPlugin, PLUGIN_NAME_REGEX } from "../../domain/plugins/installed-plugin.js"; import { readBuiltUserPluginFiles, withoutHooksPrefix, @@ -28,6 +30,12 @@ import { assertUserScopeWriteBoundary, userScopeFilesSafeToDelete, } from "../shared/user-scope-plugin-files.js"; +import { assertGlobalFilesWritable, readGlobalPluginFiles } from "./global-plugin-files.js"; +import type { + PinnedUserFramework, + PinnedUserFrameworkResult, +} from "./release/pinned-user-framework.js"; +import { planKiloTuiInstall } from "./tui/kilo-tui-config.js"; export interface PlannedUserPluginFileUpdate { readonly plugin: InstalledPlugin; @@ -47,6 +55,11 @@ export class UserPluginFileUpdater { private readonly builtDeps?: BuiltMaterializationDeps ) {} + async withPinnedSession(action: (pinned?: PinnedUserFramework) => Promise): Promise { + const pinned = this.builtDeps?.pinnedFramework; + return pinned?.withSession ? pinned.withSession(action) : action(pinned); + } + async update( plugin: InstalledPlugin, toolId: AiToolId, @@ -61,26 +74,57 @@ export class UserPluginFileUpdater { plugin: InstalledPlugin, toolId: AiToolId, projectRoot: string, - logger: Logger + logger: Logger, + rebuild = false, + prepared?: PinnedUserFrameworkResult, + session?: PinnedUserFramework ): Promise { - const dist = await this.loader.loadLatest(plugin, projectRoot); - if (compareSemver(dist.manifest.version, plugin.version) <= 0) return null; - const { files, componentPaths } = await this.materializeFiles( - dist, - toolId, - plugin, - projectRoot - ); + const source = plugin.source; + const cacheRoot = this.builtDeps?.userCacheRoot?.() ?? projectRoot; + const builder = session ?? this.builtDeps?.pinnedFramework; + const pinned = + prepared ?? + (source.kind === "git-subdir" && + source.url === "https://github.com/ai-driven-dev/framework.git" && + source.path === `plugins/${plugin.name}` && + source.ref?.startsWith("v") && + isSemver(source.ref) && + frameworkBuildModeFor(toolId) === "flat" && + globalFilesOf(toolId) !== null && + builder !== undefined + ? await builder.build(source.ref, toolId, plugin.name, source.sha) + : undefined); + if (prepared && (source.kind !== "git-subdir" || source.sha !== prepared.sha)) + throw new Error("Prepared release does not match the selected plugin commit."); + const dist = pinned?.dist ?? (await this.loader.loadLatest(plugin, cacheRoot)); + if (!rebuild && compareSemver(dist.manifest.version, plugin.version) <= 0) return null; + const { files, componentPaths } = pinned + ? { files: [...pinned.files], componentPaths: new Map() } + : await this.materializeFiles(dist, toolId, plugin, cacheRoot); const home = (this.builtDeps?.homedir ?? nodeHomedir)(); - const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, plugin, toolId, home); - if (oldSafe.size !== plugin.files.size) { + const tui = resolvePluginsCapability(toolId)?.startupTui; + const isKiloMaintenance = tui?.plugin === plugin.name; + const hasTui = files.some((file) => file.relativePath === tui?.module); + const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home); + if (isKiloMaintenance && plugin.tuiPlugin !== undefined && !hasTui) { throw new Error( - `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` + "Kilo TUI: the new plugin no longer contains the owned TUI module; update refused." ); } - await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, files, home); - const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home); - await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); + const tuiPlan = + isKiloMaintenance && hasTui + ? await planKiloTuiInstall(this.fs, baseDir, home, plugin.tuiPlugin) + : undefined; + const oldSafe = await this.inspectOwnedFiles( + plugin, + toolId, + home, + baseDir, + files, + logger, + prepared === undefined + ); + await this.assertWritable(plugin, toolId, files, home); return { plugin, toolId, @@ -90,36 +134,43 @@ export class UserPluginFileUpdater { oldSafe, next: InstalledPlugin.fromDistribution( dist, - plugin.source, + pinned && source.kind === "git-subdir" ? { ...source, sha: pinned.sha } : plugin.source, files, "user", componentPaths, plugin.marketplace - ).withDependents(plugin.dependents), + ) + .withDependents(plugin.dependents) + .withTuiPlugin(tuiPlan?.provenance), }; } async applyUpdate(plan: PlannedUserPluginFileUpdate, logger: Logger): Promise { - const oldSafe = await userScopeFilesSafeToDelete( - this.fs, - logger, + const oldSafe = await this.inspectOwnedFiles( plan.plugin, plan.toolId, - plan.home + plan.home, + plan.baseDir, + plan.files, + logger, + plan.oldSafe.size < plan.plugin.files.size ); - if (oldSafe.size !== plan.plugin.files.size || oldSafe.size !== plan.oldSafe.size) { + if ( + oldSafe.size !== plan.oldSafe.size || + [...oldSafe.keys()].some((path) => !plan.oldSafe.has(path)) + ) { throw new Error( `${plan.toolId}: '${plan.plugin.name}' has unsafe recorded files since preflight; update refused before writing.` ); } - await assertUserScopeWriteBoundary( - this.fs, - plan.toolId, - plan.plugin.name, - [...plan.files], - plan.home - ); - await this.assertNewFilesDoNotCollide(plan.plugin, plan.toolId, plan.baseDir, plan.files); + await this.assertWritable(plan.plugin, plan.toolId, plan.files, plan.home); + const tuiPlan = + plan.next.tuiPlugin === undefined + ? undefined + : await planKiloTuiInstall(this.fs, plan.baseDir, plan.home, plan.plugin.tuiPlugin); + if (tuiPlan !== undefined && tuiPlan.provenance.config !== plan.next.tuiPlugin?.config) { + throw new Error("Kilo TUI: configuration changed since planning; update refused."); + } await writePluginFiles([...plan.files], plan.baseDir, this.fs); const newPaths = new Set(plan.files.map((file) => file.relativePath)); await deleteOldFiles( @@ -127,9 +178,113 @@ export class UserPluginFileUpdater { plan.baseDir, this.fs ); + if (tuiPlan !== undefined) { + const current = await planKiloTuiInstall( + this.fs, + plan.baseDir, + plan.home, + plan.plugin.tuiPlugin + ); + if (current.provenance.config !== plan.next.tuiPlugin?.config) { + throw new Error("Kilo TUI: configuration changed since planning; update incomplete."); + } + await this.fs.writeFile(current.path, current.content); + } return plan.next; } + private async inspectOwnedFiles( + plugin: InstalledPlugin, + toolId: AiToolId, + home: string, + baseDir: string, + files: readonly InstallationFile[], + logger: Logger, + restoreMissing: boolean + ): Promise> { + const missing = new Set(); + if (restoreMissing && plugin.files.size > 0) { + const targetPaths = new Set(files.map((file) => file.relativePath)); + for (const [path, hash] of plugin.files) { + if (!targetPaths.has(path) || !/^[0-9a-f]{32}$/.test(hash)) continue; + const target = join(baseDir, path); + if (await this.fs.fileExists(target)) continue; + if (!this.fs.entryExists) throw new Error("Cannot safely inspect missing machine files."); + if (!(await this.fs.entryExists(target))) missing.add(path); + } + } + const present = plugin.withFiles( + new Map([...plugin.files].filter(([path]) => !missing.has(path))) + ); + const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, present, toolId, home); + if (oldSafe.size + missing.size !== plugin.files.size) { + throw new Error( + `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` + ); + } + if (oldSafe.size > 0) { + const root = await this.fs.realpath(baseDir); + for (const path of oldSafe.keys()) { + if (!samePath(await this.fs.realpath(join(baseDir, path)), join(root, path))) { + throw new Error( + `${toolId}: '${plugin.name}' file '${path}' resolves through a symlink; update refused.` + ); + } + } + } + return oldSafe; + } + + /** A tool's machine files spread across its user directory; a user-scope plugin directory + * holds one plugin per subdirectory. Each layout has its own containment proof. */ + private async assertWritable( + plugin: InstalledPlugin, + toolId: AiToolId, + files: readonly InstallationFile[], + home: string + ): Promise { + const globalFiles = globalFilesOf(toolId); + if (globalFiles !== null) { + await assertGlobalFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); + await this.assertDirectPaths(toolId, plugin.name, files, globalFiles.dir(home)); + return; + } + const baseDir = resolveBaseDirFromRecord("user", toolId, "", () => home); + // A first machine-wide install owns no file yet: its directory is created so the + // containment proof has a real path to resolve. The name is one safe path segment. + if (PLUGIN_NAME_REGEX.test(plugin.name)) { + await this.fs.createDirectory(join(baseDir, plugin.name)); + } + await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home); + await this.assertDirectPaths(toolId, plugin.name, files, baseDir); + await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); + } + + /** Existing destinations and parents must not redirect writes through user symlinks. */ + private async assertDirectPaths( + toolId: AiToolId, + pluginName: string, + files: readonly InstallationFile[], + base: string + ): Promise { + const root = await this.fs.realpath(base); + for (const file of files) { + let candidate = join(base, file.relativePath); + while (candidate !== base && !(await this.fs.fileExists(candidate))) { + if (!this.fs.entryExists) throw new Error("Cannot safely inspect machine file paths."); + if (await this.fs.entryExists(candidate)) + throw new Error( + `${toolId}: '${pluginName}' path '${file.relativePath}' contains an unresolved symlink; update refused.` + ); + candidate = dirname(candidate); + } + if (!samePath(await this.fs.realpath(candidate), join(root, relative(base, candidate)))) + throw new Error( + `${toolId}: '${pluginName}' path '${file.relativePath}' resolves through a symlink; update refused.` + ); + } + } + private async assertNewFilesDoNotCollide( plugin: InstalledPlugin, toolId: AiToolId, @@ -138,11 +293,11 @@ export class UserPluginFileUpdater { ): Promise { for (const file of files) { if (plugin.files.has(file.relativePath)) continue; - if (await this.fs.fileExists(join(baseDir, file.relativePath))) { - throw new Error( - `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` - ); - } + const target = join(baseDir, file.relativePath); + if (!(await this.fs.fileExists(target))) continue; + throw new Error( + `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` + ); } } @@ -165,6 +320,10 @@ export class UserPluginFileUpdater { target: toolId, mode: frameworkBuildModeFor(toolId), }); + if (globalFilesOf(toolId) !== null) { + const files = await readGlobalPluginFiles(this.fs, this.hasher, builtDir, toolId, dist); + return { files, componentPaths: new Map() }; + } const built = await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, plugin.name); const files = resolvePluginsCapability(toolId)?.hooksDestination === "project" @@ -172,6 +331,12 @@ export class UserPluginFileUpdater { : built; return { files, componentPaths: new Map() }; } + if (globalFilesOf(toolId) !== null) { + throw new Error( + `${toolId}: the shared framework source is not registered, so '${plugin.name}' cannot be rebuilt; run ` + + "`aidd setup --global`." + ); + } const toolConfig = getToolConfig(toolId); const forGlobalFiles = resolvePluginsCapability(toolId)?.hooksDestination === "project" ? withoutHooks(dist) : dist; diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts index 88ab22c8c..75f25ceeb 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts @@ -2,7 +2,7 @@ import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { AiToolId } from "../../../../kernel/tool.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -/** Release only this project's claim; machine files remain until explicit user cleanup. */ +/** Release only this project's claim; global files remain until explicit user cleanup. */ export async function detachUserPlugin( repo: ManifestRepository | undefined, fs: FileReader, diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts index 2978e3fcf..04e2b8485 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts @@ -6,6 +6,7 @@ import type { ManifestRepository } from "../../domain/ports/manifest-repository. import { resolvePluginToolIds } from "../plugin/plugin-target-resolution.js"; import type { PluginUpdateOptions } from "../plugin/plugin-update-use-case.js"; import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js"; +import type { PinnedUserFramework } from "./release/pinned-user-framework.js"; import type { UserPluginFileUpdater } from "./user-plugin-file-updater.js"; export class UserPluginUpdateUseCase { @@ -17,11 +18,15 @@ export class UserPluginUpdateUseCase { ) {} async execute(options: PluginUpdateOptions): Promise { - const run = () => this.updateLocked(options); + const run = () => + this.fileUpdater.withPinnedSession((session) => this.updateLocked(options, session)); return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run); } - private async updateLocked(options: PluginUpdateOptions): Promise { + private async updateLocked( + options: PluginUpdateOptions, + session?: PinnedUserFramework + ): Promise { const loaded = await this.repo.load(); if (loaded === null) throw new Error( @@ -39,7 +44,9 @@ export class UserPluginUpdateUseCase { ) .map((plugin) => ({ toolId, plugin })) ); - const nativeTargets = toolIds.flatMap((toolId) => + // A rebuild re-materializes the files aidd wrote; a host enabling plugins itself picks the + // rebuilt tree up through its own marketplace refresh, which activation drives. + const nativeTargets = (options.rebuild === true ? [] : toolIds).flatMap((toolId) => (manifest.getNativeRegistrations(toolId)?.pluginClaims ?? []) .filter( (claim) => @@ -64,13 +71,20 @@ export class UserPluginUpdateUseCase { "Multiple native catalogues match this plugin name; use an exact @ ref for targeted update." ); } - const filePlans = await Promise.all( - targets.map(async ({ toolId, plugin }) => ({ - toolId, + // One plan at a time: plans for one tool share its built tree, which the first one builds. + const filePlans = []; + for (const { toolId, plugin } of targets) { + const plan = await this.fileUpdater.planUpdate( plugin, - plan: await this.fileUpdater.planUpdate(plugin, toolId, options.projectRoot, this.logger), - })) - ); + toolId, + options.projectRoot, + this.logger, + options.rebuild === true, + undefined, + session + ); + filePlans.push({ toolId, plugin, plan }); + } const nativeUpdates = await Promise.all( nativeTargets.map(async ({ toolId, claim, registrations }) => ({ toolId, diff --git a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts index 940f4849b..7b5943ad5 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts @@ -23,13 +23,17 @@ import type { ReadonlySkipList } from "../../../translate/domain/plugin-translat import { Manifest } from "../../domain/manifest.js"; import { InstalledPlugin, + type KiloTuiProvenance, type ProjectHooksProvenance, } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; import type { InstallRuntimeConfigUseCase } from "../install/install-runtime-config-use-case.js"; -import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js"; +import { + assertProjectMcpEntriesRemovable, + assertProjectTuiUnchanged, +} from "../ownership/project-plugin-cleanup.js"; import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; import { assertProjectHooksRemovable } from "../shared/remove-project-hooks.js"; import type { PluginDistributionLoader } from "./plugin-distribution-loader.js"; @@ -47,6 +51,8 @@ export interface PluginAddOptions { interactive: boolean; marketplace?: string; requiredVersion?: string; + /** Re-fetch the distribution instead of trusting a previously cached copy. */ + freshSource?: boolean; pluginMetadata?: { name: string; version?: string; strict: boolean }; /** When true, drop any existing entry with the same name before adding (idempotent re-add for setup re-runs). */ replace?: boolean; @@ -121,11 +127,12 @@ export class PluginAddUseCase implements PluginAdd { manifest, options.projectRoot ); - if (options.replace === true) this.dropExistingPlugin(pluginMetadata.name, toolIds, manifest); - else this.validateNoDuplicates(pluginMetadata.name, toolIds, manifest); const adapterMap = this.buildAdapterMap(toolIds); const flatToolIds = toolIds.filter((id) => adapterMap.get(id)?.mode === "flat"); const nativeToolIds = toolIds.filter((id) => adapterMap.get(id)?.mode !== "flat"); + if (options.replace === true) + this.dropExistingPlugin(pluginMetadata.name, nativeToolIds, manifest); + else this.validateNoDuplicates(pluginMetadata.name, toolIds, manifest); if (flatToolIds.length > 0) { await this.addLocalPlugin( options, @@ -176,17 +183,40 @@ export class PluginAddUseCase implements PluginAdd { options: PluginAddOptions, pluginMetadata: NonNullable ): Promise { - if (pluginMetadata.version) return pluginMetadata.version; - const dist = await this.readDistribution(options.source, options.projectRoot); - return dist.manifest.version; + if (pluginMetadata.version) { + this.assertPluginVersionMatches( + pluginMetadata.name, + pluginMetadata.version, + options.requiredVersion + ); + if (!options.requiredVersion) return pluginMetadata.version; + } + const distribution = await this.readDistribution( + options.source, + options.projectRoot, + options.freshSource + ); + if (distribution.manifest.name !== pluginMetadata.name) + throw new Error(`Cannot verify the source of '${pluginMetadata.name}'.`); + this.assertPluginVersionMatches( + pluginMetadata.name, + distribution.manifest.version, + options.requiredVersion + ); + return distribution.manifest.version; } private async readDistribution( source: PluginSource, - projectRoot: string + projectRoot: string, + freshSource?: boolean ): Promise { const cacheDir = join(projectRoot, PLUGIN_CACHE_SUBDIR); - return this.distributionLoader.load(source, cacheDir); + return this.distributionLoader.load( + source, + cacheDir, + freshSource ? { forceRefresh: true } : undefined + ); } private async addLocalPlugin( @@ -198,7 +228,7 @@ export class PluginAddUseCase implements PluginAdd { machine: Manifest ): Promise { const { marketplace, requiredVersion, replace, pluginMetadata } = options; - const read = await this.readDistribution(source, projectRoot); + const read = await this.readDistribution(source, projectRoot, options.freshSource); const dist = pluginMetadata === undefined ? read : read.withStrict(pluginMetadata.strict); const pluginName = dist.manifest.name; this.assertPluginVersionMatches(pluginName, dist.manifest.version, requiredVersion); @@ -209,7 +239,7 @@ export class PluginAddUseCase implements PluginAdd { manifest, projectRoot ); - const { prevMcpMap, prevHooksMap } = this.prepareForInstall( + const { prevMcpMap, prevHooksMap, prevTuiMap } = this.prepareForInstall( pluginName, resolvedToolIds, manifest, @@ -224,6 +254,7 @@ export class PluginAddUseCase implements PluginAdd { marketplace, prevMcpMap, prevHooksMap, + prevTuiMap, machine ); } @@ -239,6 +270,7 @@ export class PluginAddUseCase implements PluginAdd { if (previous === undefined) continue; await assertProjectMcpEntriesRemovable(this.fs, previous, toolId, projectRoot); await assertProjectHooksRemovable(this.fs, previous, toolId, projectRoot); + await assertProjectTuiUnchanged(this.fs, previous, toolId, projectRoot); } } @@ -250,21 +282,25 @@ export class PluginAddUseCase implements PluginAdd { ): { prevMcpMap: Map>; prevHooksMap: Map; + prevTuiMap: Map; } { const prevMcpMap = this.collectPreviousMcpEntries(pluginName, toolIds, manifest); const prevHooksMap = new Map(); + const prevTuiMap = new Map(); for (const toolId of toolIds) { const projectHooks = manifest .getPlugins(toolId) .find((p) => p.name === pluginName)?.projectHooks; if (projectHooks !== undefined) prevHooksMap.set(toolId, projectHooks); + const tui = manifest.getPlugins(toolId).find((p) => p.name === pluginName)?.tuiPlugin; + if (tui !== undefined) prevTuiMap.set(toolId, tui); } if (replace === true) { this.dropExistingPlugin(pluginName, toolIds, manifest); } else { this.validateNoDuplicates(pluginName, toolIds, manifest); } - return { prevMcpMap, prevHooksMap }; + return { prevMcpMap, prevHooksMap, prevTuiMap }; } private async installPluginForAllTools( @@ -276,6 +312,7 @@ export class PluginAddUseCase implements PluginAdd { marketplace: string | undefined, prevMcpMap: Map>, prevHooksMap: Map, + prevTuiMap: Map, machine: Manifest ): Promise { const allSkipped: ReadonlySkipList[] = []; @@ -303,8 +340,10 @@ export class PluginAddUseCase implements PluginAdd { marketplace, prev, foreignDir !== undefined || - machine.getPlugins(toolId).some((p) => p.name === dist.manifest.name), - prevHooksMap.get(toolId) + (resolveScopeForInstall(toolId) === "user" && + machine.getPlugins(toolId).some((p) => p.name === dist.manifest.name)), + prevHooksMap.get(toolId), + prevTuiMap.get(toolId) ); allSkipped.push(skipped); allNotices.push(notices); @@ -408,7 +447,8 @@ export class PluginAddUseCase implements PluginAdd { marketplace: string | undefined, previousMcpEntries: ReadonlyMap, userScopeDirTaken: boolean, - previousProjectHooks?: ProjectHooksProvenance + previousProjectHooks?: ProjectHooksProvenance, + previousTui?: KiloTuiProvenance ): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> { const toolConfig = getToolConfig(toolId); if (!isAiTool(toolConfig)) return { skipped: [], notices: [] }; @@ -423,7 +463,9 @@ export class PluginAddUseCase implements PluginAdd { marketplace, previousMcpEntries, userScopeDirTaken, - previousProjectHooks + previousProjectHooks, + undefined, + previousTui ); return { ...result, notices: [] }; } diff --git a/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts b/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts index d02888ea9..97576fa08 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts @@ -12,7 +12,9 @@ import type { PluginDistributionReader } from "../../domain/ports/plugin-distrib export class PluginDistributionLoader { constructor( private readonly fetcher: PluginFetcher, - private readonly reader: PluginDistributionReader + private readonly reader: PluginDistributionReader, + /** A global install's fetches: it writes nothing in the project. */ + private readonly globalCacheDir?: () => string ) {} async load( @@ -25,6 +27,7 @@ export class PluginDistributionLoader { } loadLatest(plugin: InstalledPlugin, projectRoot: string): Promise { - return this.load(plugin.source, join(projectRoot, PLUGIN_CACHE_SUBDIR), { forceRefresh: true }); + const cacheDir = this.globalCacheDir?.() ?? join(projectRoot, PLUGIN_CACHE_SUBDIR); + return this.load(plugin.source, cacheDir, { forceRefresh: true }); } } diff --git a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts index b65d1d349..91ab353bd 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts @@ -108,7 +108,8 @@ export async function materializeViaTranslator( plugin.mcpEntries, false, plugin.projectHooks, - hooksGuard + hooksGuard, + plugin.tuiPlugin ); return written ?? 0; } diff --git a/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts index 8a1e40389..f66b01d36 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts @@ -5,6 +5,7 @@ import { } from "../../../../kernel/errors.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { PluginSourceGitSubdir } from "../../../../kernel/source.js"; import type { AiToolId } from "../../../../kernel/tool.js"; import type { ResolveMarketplaceUseCase } from "../../../distribution/application/resolve-marketplace-use-case.js"; import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js"; @@ -28,6 +29,8 @@ export interface PluginInstallFromMarketplaceOptions { /** When true, drop existing plugin entry before adding (idempotent setup re-runs). */ replace?: boolean; requestedVersionPolicy?: RequestedVersionPolicy; + /** Refuse a catalogue source that changed after a release update was reviewed. */ + expectedSource?: PluginSourceGitSubdir; } export interface PluginInstallFromMarketplaceResult { @@ -81,6 +84,15 @@ export class PluginInstallFromMarketplaceUseCase implements PluginInstallFromMar chosen.marketplace, chosen.localPath ); + const expected = options.expectedSource; + if ( + expected && + (resolvedSource.kind !== "git-subdir" || + resolvedSource.url !== expected.url || + resolvedSource.path !== expected.path || + resolvedSource.ref !== expected.ref) + ) + throw new Error(`The source of '${options.pluginName}' changed before installation.`); await this.pluginAddUseCase.execute({ source: resolvedSource, toolIds: options.toolIds, @@ -88,6 +100,7 @@ export class PluginInstallFromMarketplaceUseCase implements PluginInstallFromMar interactive: options.interactive, marketplace: chosen.marketplace.name, requiredVersion: effectiveVersion, + freshSource: options.expectedSource !== undefined, pluginMetadata: { name: chosen.entry.name, version: chosen.entry.version, diff --git a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts index 66d5cd22d..dd0c1f986 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts @@ -38,7 +38,15 @@ import { isMachineOwnedNativeRef, } from "../ownership/native-plugin-ownership.js"; import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js"; -import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js"; +import { + assertProjectMcpEntriesRemovable, + planProjectKiloTuiRemoval, + removeProjectKiloTui, +} from "../ownership/project-plugin-cleanup.js"; +import { + planUserKiloTuiRemoval, + removeUserKiloTui, +} from "../ownership/tui/user-kilo-tui-removal.js"; import { detachUserPlugin } from "../ownership/user-plugin-ownership.js"; import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js"; @@ -112,6 +120,7 @@ export class PluginRemoveUseCase { if (plugin === undefined) continue; await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot); await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot); + await planProjectKiloTuiRemoval(this.fs, plugin, toolId, projectRoot); await this.assertProjectNativeSource( plugin, toolId, @@ -249,6 +258,7 @@ export class PluginRemoveUseCase { } const safeFiles = await Promise.all( targets.map(async ({ toolId, plugin }) => { + await planUserKiloTuiRemoval(this.fs, toolId, plugin, resolveHomeDir()); const files = await userScopeFilesSafeToDelete( this.fs, this.logger, @@ -307,6 +317,7 @@ export class PluginRemoveUseCase { }); } for (const { toolId, plugin, files } of safeFiles) { + await removeUserKiloTui(this.fs, toolId, plugin, resolveHomeDir()); await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user"); manifest.removePlugin(toolId, plugin.name); } @@ -324,6 +335,7 @@ export class PluginRemoveUseCase { const plugins = manifest.getPlugins(toolId); const plugin = plugins.find((p) => p.name === pluginName); if (plugin === undefined) continue; + await removeProjectKiloTui(this.fs, plugin, toolId, projectRoot); const registrations = manifest.getNativeRegistrations(toolId); const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); const confirmed = await this.removeNativeActivation( diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts index 2673b27cd..8a1058ea7 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -7,7 +7,11 @@ import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; import { compareSemver } from "../../../../kernel/semver.js"; import type { AiToolId } from "../../../../kernel/tool.js"; -import { getToolConfig, type ToolConfig } from "../../../tools/domain/registry.js"; +import { + getToolConfig, + resolvePluginsCapability, + type ToolConfig, +} from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; import type { Manifest } from "../../domain/manifest.js"; @@ -16,6 +20,7 @@ import type { ManifestRepository } from "../../domain/ports/manifest-repository. import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; import type { InstallRuntimeConfigUseCase } from "../install/install-runtime-config-use-case.js"; +import { assertProjectTuiUnchanged } from "../ownership/project-plugin-cleanup.js"; import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; import type { PluginDistributionLoader } from "./plugin-distribution-loader.js"; import { @@ -31,6 +36,9 @@ export interface PluginUpdateOptions { toolIds: AiToolId[] | "all"; projectRoot: string; scope?: "project" | "user"; + /** Re-materialize at the same version: the source changed, not the plugin's version. Read by + * the machine-scope update, which `sync --global` drives. */ + rebuild?: boolean; } export class PluginUpdateUseCase { @@ -109,10 +117,16 @@ export class PluginUpdateUseCase { manifest: Manifest ): Promise { const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); + await assertProjectTuiUnchanged(this.fs, plugin, toolId, projectRoot); await deleteOldFiles(plugin.files, baseDir, this.fs); const toolConfig = getToolConfig(toolId); const translator = this.resolveTranslator(toolConfig); - if (translator !== null && plugin.marketplace !== undefined) { + if ( + translator !== null && + (plugin.marketplace !== undefined || + (translator.mode === "flat" && + resolvePluginsCapability(toolId)?.startupTui?.plugin === plugin.name)) + ) { await materializeViaTranslator(translator, dist, toolId, plugin, projectRoot, manifest); return; } diff --git a/cli/src/contexts/framework/application/setup-use-case.ts b/cli/src/contexts/framework/application/setup-use-case.ts index 1424928bc..3604b9762 100644 --- a/cli/src/contexts/framework/application/setup-use-case.ts +++ b/cli/src/contexts/framework/application/setup-use-case.ts @@ -24,12 +24,16 @@ export type SetupResult = install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; + /** Set by a global run: the plugins it installed for every project. */ + globalPlugins?: readonly string[]; } | { kind: "up-to-date"; install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; + /** Set by a global run: the plugins it installed for every project. */ + globalPlugins?: readonly string[]; }; export class SetupUseCase { diff --git a/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts new file mode 100644 index 000000000..7cb41176f --- /dev/null +++ b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts @@ -0,0 +1,136 @@ +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import type { ResolveMarketplace } from "../../../distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import { copiesPluginFilesGlobally } from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { resolvePluginSourceFromMarketplace } from "../../domain/plugins/plugin-source-resolver.js"; +import type { PluginInstallMode } from "../../domain/setup-flow.js"; +import type { PinnedUserFramework } from "../ownership/release/pinned-user-framework.js"; +import type { + PlannedUserPluginFileUpdate, + UserPluginFileUpdater, +} from "../ownership/user-plugin-file-updater.js"; + +export interface InstallGlobalPluginsOptions { + readonly projectRoot: string; + readonly toolIds: readonly ToolId[]; + readonly mode: PluginInstallMode; + readonly pluginNames: readonly string[]; +} + +export interface InstallGlobalPluginsResult { + /** The framework plugins this run chose, which a tool with native activation enables next. */ + readonly plugins: readonly string[]; +} + +/** + * `setup --global --plugins …`: picks the framework plugins from the shared source's catalog and + * copies each one's files into the tool's user configuration directory (OpenCode's and Kilo's + * skills and agents, Codex's agents) or user plugins directory (Cursor), recording them in the machine manifest as user-scope plugins so + * `plugin update --scope user`, `sync --scope user` and `clean --scope user` own them. A first + * install is a rebuild of a plugin that owns no file yet, so both go through one writer. A tool + * that enables plugins through its own CLI is handed the chosen names by the caller instead. + * + * Every plan is checked before the first write, and `record` runs after each plugin, so a run that + * stops part way still owns what it wrote and a later update can replace it. + */ +export class InstallGlobalPluginsUseCase { + constructor( + private readonly registry: Pick, + private readonly resolveMarketplace: ResolveMarketplace, + private readonly fileUpdater: Pick & + Partial>, + private readonly logger: Logger + ) {} + + async execute( + manifest: Manifest, + options: InstallGlobalPluginsOptions, + record: (manifest: Manifest) => Promise + ): Promise { + if (options.mode === "none" || options.mode === "interactive") return { plugins: [] }; + return this.fileUpdater.withPinnedSession + ? this.fileUpdater.withPinnedSession((session) => + this.executeInSession(manifest, options, record, session) + ) + : this.executeInSession(manifest, options, record); + } + + private async executeInSession( + manifest: Manifest, + options: InstallGlobalPluginsOptions, + record: (manifest: Manifest) => Promise, + session?: PinnedUserFramework + ): Promise { + const marketplace = (await this.registry.list(options.projectRoot)).find( + (entry) => entry.name === FRAMEWORK_MARKETPLACE_NAME && entry.scope === "user" + ); + if (marketplace === undefined) { + throw new Error( + "--global --plugins installs from the shared framework source, and none is registered; drop --no-default-marketplace." + ); + } + const { catalog, localPath } = await this.resolveMarketplace.execute({ + marketplace, + projectRoot: options.projectRoot, + }); + const entries = selectEntries(catalog?.plugins ?? [], options); + const plans: { readonly plan: PlannedUserPluginFileUpdate; readonly owned: boolean }[] = []; + for (const toolId of options.toolIds) { + if (!copiesPluginFilesGlobally(toolId)) continue; + for (const entry of entries) { + const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); + const resolved = InstalledPlugin.fromMetadata( + entry.name, + entry.version ?? "0.0.0", + resolvePluginSourceFromMarketplace(entry.source, marketplace, localPath), + entry.strict, + "user", + marketplace.name + ); + const plugin = + owned === undefined + ? resolved + : owned.source.kind === "git-subdir" && + owned.source.url === "https://github.com/ai-driven-dev/framework.git" && + owned.source.path === `plugins/${owned.name}` && + owned.source.sha !== undefined + ? owned + : resolved + .withFiles(owned.files) + .withDependents(owned.dependents) + .withTuiPlugin(owned.tuiPlugin); + const args = [plugin, toolId as AiToolId, options.projectRoot, this.logger, true] as const; + const plan = session + ? await this.fileUpdater.planUpdate(...args, undefined, session) + : await this.fileUpdater.planUpdate(...args); + if (plan === null || (plan.files.length === 0 && owned === undefined)) continue; + plans.push({ plan, owned: owned !== undefined }); + } + } + for (const { plan, owned } of plans) { + const next = await this.fileUpdater.applyUpdate(plan, this.logger); + if (owned) manifest.updatePlugin(plan.toolId, next); + else manifest.addPlugin(plan.toolId, next); + await record(manifest); + } + return { plugins: entries.map((entry) => entry.name) }; + } +} + +function selectEntries( + catalog: readonly PluginCatalogEntry[], + options: InstallGlobalPluginsOptions +): readonly PluginCatalogEntry[] { + if (options.mode === "all") return catalog; + if (options.mode === "recommended") return catalog.filter((entry) => entry.recommended); + const missing = options.pluginNames.filter((name) => !catalog.some((e) => e.name === name)); + if (missing.length > 0) { + throw new Error(`Not in the framework catalog: ${missing.join(", ")}.`); + } + return catalog.filter((entry) => options.pluginNames.includes(entry.name)); +} diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts index 9ba14ac66..a62034e9e 100644 --- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -1,19 +1,22 @@ +import { dirname } from "node:path"; import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; import { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import type { SetupFlow } from "../../domain/setup-flow.js"; +import { SetupFlow } from "../../domain/setup-flow.js"; import type { MarketplaceSyncSettings, MarketplaceSyncSettingsResult, } from "../flows/marketplace-sync-settings-use-case.js"; import type { SetupResult } from "../setup-use-case.js"; import type { SetupMarketplaceRegistrationUseCase } from "../shared/setup-marketplace-registration-use-case.js"; +import type { InstallGlobalPluginsUseCase } from "./install-global-plugins-use-case.js"; import type { SetupToolsResult } from "./setup-tools-use-case.js"; /** - * `--scope user`: registers the shared framework source and drives native activation machine-wide, - * writing nothing under `flow.projectRoot` at all — no tool content install, no plugin prompt, no - * project-context detection. + * `--scope user`: registers the shared framework source, installs the requested plugins in each + * tool's user configuration directory and drives native activation machine-wide, writing nothing + * under `flow.projectRoot` at all — no project tool content, no plugin prompt, no project-context + * detection. * * Records no shared-source reference: `references.json` tracks which *project* still claims the * shared source, and a `--scope user` run has no project-scope manifest for a later `clean` to read @@ -25,7 +28,8 @@ export class SetupMachineScopeUseCase { private readonly userManifestRepo: ManifestRepository, private readonly setupMarketplaceRegistration: SetupMarketplaceRegistrationUseCase, private readonly marketplaceSyncSettingsUseCase: MarketplaceSyncSettings, - private readonly currentVersionProvider: VersionReader + private readonly currentVersionProvider: VersionReader, + private readonly installGlobalPluginsUseCase?: InstallGlobalPluginsUseCase ) {} async execute(flow: SetupFlow): Promise { @@ -40,19 +44,42 @@ export class SetupMachineScopeUseCase { flow: SetupFlow, source: Awaited> ): Promise { + // A machine installation has no project cache; resolve and build under its own config root. + flow = new SetupFlow({ ...flow, projectRoot: dirname(this.userManifestRepo.path) }); const isNew = await this.initUserManifest(); await this.setupMarketplaceRegistration.registerIfPresent(flow, source); await this.registerUserScopeTools(flow); + const plugins = await this.installGlobalPlugins(flow); const activation: MarketplaceSyncSettingsResult = await this.marketplaceSyncSettingsUseCase.execute({ projectRoot: flow.projectRoot, scope: "user", manifestRepo: this.userManifestRepo, + toolIds: flow.aiTools, + enablePlugins: plugins, }); const install: SetupToolsResult = { results: [] }; - return isNew - ? { kind: "initialized", install, activation, context: undefined } - : { kind: "up-to-date", install, activation, context: undefined }; + const outcome = { install, activation, context: undefined, globalPlugins: plugins }; + return isNew ? { kind: "initialized", ...outcome } : { kind: "up-to-date", ...outcome }; + } + + /** Copies the chosen plugins' global files and returns their names, which every tool with + * native activation then enables machine-wide. */ + private async installGlobalPlugins(flow: SetupFlow): Promise { + if (this.installGlobalPluginsUseCase === undefined) return []; + const manifest = await this.userManifestRepo.load(); + if (manifest === null) return []; + const { plugins } = await this.installGlobalPluginsUseCase.execute( + manifest, + { + projectRoot: flow.projectRoot, + toolIds: flow.aiTools, + mode: flow.pluginMode, + pluginNames: flow.pluginNames, + }, + (updated) => this.userManifestRepo.save(updated) + ); + return plugins; } private async initUserManifest(): Promise { diff --git a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts index 7775f761c..e7396a68d 100644 --- a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts +++ b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts @@ -13,6 +13,7 @@ import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; +import type { PinnedUserFramework } from "../ownership/release/pinned-user-framework.js"; import { deleteOldFiles, isPluginFileAtDesiredState, @@ -38,6 +39,8 @@ export interface BuiltMaterializationDeps { ensureBuilt: EnsureBuiltMarketplace; marketplaceRegistry: MarketplaceRegistry; homedir: () => string; + pinnedFramework?: PinnedUserFramework; + userCacheRoot?: () => string; } export class ApplyPluginFilesUseCase { diff --git a/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts index 09cf30f4c..81c7be1fa 100644 --- a/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts +++ b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { @@ -13,6 +14,7 @@ import type { Marketplace } from "../../../distribution/domain/marketplace.js"; import type { FrameworkBuildMode } from "../../../tools/domain/registry.js"; import type { FrameworkBuild } from "../../../translate/application/translate-source.js"; import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js"; +import { assertNoSymlinksWithinRoot } from "../ownership/project-path-boundary.js"; /** `undefined` when the target/mode pair has no build. */ export type FrameworkBuildFor = ( @@ -67,10 +69,14 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { // `resolve()`, matching `sourceDir` below: on Windows a drive-less `projectRoot` yields a // drive-less `builtDir` here while the build resolves its own outDir copy for validation only, // leaving the write target diverging from the path that gets checked. Both scopes need it. + const userRoot = options.marketplace.scope === "user" ? this.userCacheRoot() : null; + const cacheBoundary = resolve(userRoot ?? options.projectRoot); + if (userRoot !== null && !(await this.fs.fileExists(cacheBoundary))) + await this.fs.createDirectory(cacheBoundary); const builtDir = resolve( - options.marketplace.scope === "user" + userRoot !== null ? userBuiltMarketplaceDir( - this.userCacheRoot(), + userRoot, this.version.get(), options.marketplace.name, options.target @@ -85,13 +91,15 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { const sentinel = this.sentinelValue(resolved.catalog?.version); const memoKey = `${options.marketplace.name}:${options.target}:${sentinel}`; const memoized = this.memo.get(memoKey); + await assertNoSymlinksWithinRoot(this.fs, cacheBoundary, builtDir); if (memoized !== undefined) return memoized; const result = await this.ensure( options, builtDir, resolve(resolved.localPath), sentinel, - this.versionIsTrustworthy(options) + this.versionIsTrustworthy(options), + cacheBoundary ); this.memo.set(memoKey, result); return result; @@ -120,13 +128,14 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { builtDir: string, sourceDir: string, sentinel: string, - versionIsTrustworthy: boolean + versionIsTrustworthy: boolean, + cacheBoundary: string ): Promise { const version = sentinel.split(":")[1]; if (versionIsTrustworthy && (await this.isFresh(builtDir, sentinel))) { return { builtDir, version, rebuilt: false }; } - await this.build(options.target, options.mode, sourceDir, builtDir); + await this.build(options.target, options.mode, sourceDir, builtDir, cacheBoundary); await this.fs.writeFile(join(builtDir, SENTINEL_FILE), sentinel); return { builtDir, version, rebuilt: true }; } @@ -143,12 +152,16 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { target: FrameworkBuildTarget, mode: FrameworkBuildMode, sourceDir: string, - builtDir: string + builtDir: string, + cacheBoundary: string ): Promise { if (this.nested(sourceDir, builtDir)) { - await this.buildViaTemp(target, mode, sourceDir, builtDir); + await this.buildViaTemp(target, mode, sourceDir, builtDir, cacheBoundary); return; } + // This is an AIDD-owned disposable cache; a new build must not inherit deleted files. + await assertNoSymlinksWithinRoot(this.fs, cacheBoundary, builtDir); + await this.fs.deleteDirectory(builtDir); await this.runBuild(target, mode, sourceDir, builtDir); } @@ -160,14 +173,20 @@ export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { target: FrameworkBuildTarget, mode: FrameworkBuildMode, sourceDir: string, - builtDir: string + builtDir: string, + cacheBoundary: string ): Promise { - const temp = join(tmpdir(), `aidd-built-${target}-${mode}`); - await this.fs.deleteDirectory(temp); - await this.runBuild(target, mode, sourceDir, temp); - await this.fs.deleteDirectory(builtDir); - await this.copyDir(temp, builtDir); - await this.fs.deleteDirectory(temp); + const temp = join(tmpdir(), `aidd-built-${target}-${mode}-${randomUUID()}`); + if (await this.fs.entryExists?.(temp)) + throw new Error("Temporary build directory already exists."); + try { + await this.runBuild(target, mode, sourceDir, temp); + await assertNoSymlinksWithinRoot(this.fs, cacheBoundary, builtDir); + await this.fs.deleteDirectory(builtDir); + await this.copyDir(temp, builtDir); + } finally { + await this.fs.deleteDirectory(temp); + } } // Every outDir reaching this method is either `builtMarketplaceDir()` or a temp dir this class diff --git a/cli/src/contexts/framework/application/upgrade-file-guard.ts b/cli/src/contexts/framework/application/upgrade-file-guard.ts new file mode 100644 index 000000000..e67e5f69a --- /dev/null +++ b/cli/src/contexts/framework/application/upgrade-file-guard.ts @@ -0,0 +1,42 @@ +import { resolve } from "node:path"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import { assertNoSymlinksWithinRoot } from "./ownership/project-path-boundary.js"; + +export async function assertUpgradeWrite( + fs: FileReader, + projectRoot: string, + path: string, + tracked: ReadonlyMap +): Promise { + await assertNoSymlinksWithinRoot(fs, projectRoot, path); + const expected = tracked.get(resolve(path)); + if (await fs.fileExists(path)) { + if (expected === undefined) throw new Error(`Cannot overwrite untracked file '${path}'.`); + if ((await fs.readFileHash(path)).value !== expected) + throw new Error(`Cannot overwrite modified file '${path}'.`); + } else if (expected !== undefined) { + throw new Error(`Cannot overwrite missing tracked file '${path}'.`); + } +} + +/** Protect project content while replacing a plugin; the original hashes are its ownership proof. */ +export function protectUpgradeWrites( + fs: T, + projectRoot: string, + tracked: ReadonlyMap +): T { + const owned = new Map([...tracked].map(([path, hash]) => [resolve(projectRoot, path), hash])); + return new Proxy(fs, { + get(target, property, receiver) { + if (property === "writeFile") { + return async (path: string, content: string) => { + await assertUpgradeWrite(target, projectRoot, path, owned); + await target.writeFile(path, content); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} diff --git a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts new file mode 100644 index 000000000..e4a0839f4 --- /dev/null +++ b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts @@ -0,0 +1,362 @@ +import { compareSemver, isSemver } from "../../../kernel/semver.js"; +import type { PluginSource, PluginSourceGitSubdir } from "../../../kernel/source.js"; +import { type AiToolId, isAiToolId } from "../../../kernel/tool.js"; +import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../../distribution/domain/marketplace.js"; +import type { Manifest } from "../domain/manifest.js"; +import { resolvePluginSourceFromMarketplace } from "../domain/plugins/plugin-source-resolver.js"; + +export interface FrameworkUpgradeTarget { + readonly toolId: AiToolId; + readonly pluginName: string; +} + +interface FrameworkUpgradePreviewTarget extends FrameworkUpgradeTarget { + readonly currentRef: string; + readonly currentVersion: string; + readonly targetVersion?: string; +} + +export interface FrameworkUpgradePreview { + readonly currentRef: string; + readonly release: string; + readonly title: string | null; + readonly targets: readonly FrameworkUpgradePreviewTarget[]; + readonly globalSkipped: number; + readonly unverifiedUserRefs: number; + readonly globalPlugins: readonly { + toolId: AiToolId; + pluginName: string; + currentVersion: string; + source: PluginSource; + }[]; + readonly shared: boolean; + readonly impact: readonly ClaudeInstallation[]; +} + +export interface ClaudeInstallation { + readonly ref: string; + readonly scope: "local" | "project" | "user"; + readonly projectPath?: string; +} + +interface UpgradeDeps { + listMarketplaces(projectRoot: string): Promise; + saveMarketplace(projectRoot: string, marketplace: Marketplace): Promise; + loadManifest(): Promise; + loadUserManifest?(): Promise; + resolvePublishedRelease(repo: string, release: string): Promise<{ title: string | null } | null>; + resolveTarget( + marketplace: Marketplace, + projectRoot: string + ): Promise<{ + catalog: { + version?: string; + plugins: readonly { name: string; source: PluginSource; version?: string }[]; + } | null; + localPath: string; + }>; + readTargetVersion(localPath: string, pluginName: string): Promise; + preflight( + manifest: Manifest, + projectRoot: string, + targets: readonly FrameworkUpgradeTarget[], + marketplace: Marketplace, + localPath: string + ): Promise; + verifyImpact?( + projectRoot: string, + impact: readonly ClaudeInstallation[], + source?: string + ): Promise; + install(options: { + pluginName: string; + version: string; + expectedSource: PluginSourceGitSubdir; + toolIds: AiToolId[]; + projectRoot: string; + fromMarketplace: string; + interactive: boolean; + autoSelect: boolean; + replace: boolean; + }): Promise; + sync( + projectRoot: string, + toolIds: readonly AiToolId[], + targets: readonly FrameworkUpgradeTarget[], + approvedRefs: readonly string[] + ): Promise<{ + warnings: readonly string[]; + errors: readonly { scope: string; message: string }[]; + binaryMissing: readonly { toolId: string; binary: string }[]; + }>; +} + +export interface FrameworkUpgradeOptions { + readonly projectRoot: string; + readonly release: string; + readonly allowShared: boolean; + readonly confirm: (preview: FrameworkUpgradePreview) => Promise; +} + +/** Upgrades project-owned AIDD plugins without adding them to tools that never had them. */ +export class UpgradeFrameworkUseCase { + constructor(private readonly deps: UpgradeDeps) {} + + async execute( + options: FrameworkUpgradeOptions + ): Promise<{ updated: string[]; globalSkipped: number; cancelled: boolean }> { + const { projectRoot, release, allowShared, confirm } = options; + if (!release.startsWith("v") || !isSemver(release)) + throw new Error("A root release tag such as v5.13.0 is required."); + const marketplace = (await this.deps.listMarketplaces(projectRoot)).find( + (m) => m.name === FRAMEWORK_MARKETPLACE_NAME + ); + if (!marketplace) throw new Error("The AIDD marketplace is not registered for this project."); + if ( + marketplace.source.kind !== "github" || + marketplace.source.repo !== "ai-driven-dev/framework" + ) { + throw new Error( + "The AIDD marketplace source is local or unsupported; its release cannot be upgraded automatically." + ); + } + const currentRef = marketplace.source.ref; + if (!currentRef || !currentRef.startsWith("v") || !isSemver(currentRef)) { + throw new Error( + "The AIDD marketplace source is a branch or unversioned; its release cannot be upgraded automatically." + ); + } + if (compareSemver(release, currentRef) < 0) + throw new Error("The selected release is older than the configured marketplace."); + const manifest = await this.deps.loadManifest(); + if (!manifest) + throw new Error("No project installation manifest exists; there is nothing to upgrade."); + const targets: FrameworkUpgradePreviewTarget[] = []; + let globalSkipped = 0; + const userRefs: FrameworkUpgradeTarget[] = []; + for (const id of manifest.getInstalledToolIds()) { + if (!isAiToolId(id)) continue; + for (const plugin of manifest.getPlugins(id)) { + if (plugin.marketplace !== FRAMEWORK_MARKETPLACE_NAME) continue; + if (plugin.scope === "user") { + globalSkipped++; + userRefs.push({ toolId: id, pluginName: plugin.name }); + continue; + } + const source = plugin.source; + if ( + source.kind !== "git-subdir" || + source.url !== "https://github.com/ai-driven-dev/framework.git" || + source.path !== `plugins/${plugin.name}` || + !source.ref || + !source.ref.startsWith("v") || + !isSemver(source.ref) + ) { + throw new Error( + `Cannot upgrade ${id}:${plugin.name}: its source is not a versioned official AIDD plugin.` + ); + } + if (compareSemver(source.ref, release) > 0) + throw new Error(`Cannot downgrade ${id}:${plugin.name} from ${source.ref}.`); + targets.push({ + toolId: id, + pluginName: plugin.name, + currentRef: source.ref, + currentVersion: plugin.version, + }); + } + } + if (targets.length === 0) + throw new Error( + "No project-scoped AIDD plugins found; global installations must be updated separately." + ); + const publishedRelease = await this.deps.resolvePublishedRelease( + marketplace.source.repo, + release + ); + if (!publishedRelease) + throw new Error(`Release ${release} is not available from the configured source.`); + const nextData = marketplace.toJSON(); + nextData.source = { ...marketplace.source, ref: release }; + delete nextData.version; + delete nextData.lastFetched; + const next = Marketplace.fromJSON(nextData); + const { catalog, localPath } = await this.deps.resolveTarget(next, projectRoot); + if ( + !catalog || + !catalog.version || + !isSemver(catalog.version) || + compareSemver(catalog.version, release) !== 0 + ) { + throw new Error( + `Release ${release} does not contain the expected versioned AIDD marketplace.` + ); + } + const names = new Set(catalog.plugins.map((plugin) => plugin.name)); + const missing = targets.filter((target) => !names.has(target.pluginName)); + if (missing.length) + throw new Error( + `Release ${release} is missing installed plugins: ${missing.map((x) => x.pluginName).join(", ")}.` + ); + const versions = new Map(); + const previewTargets: FrameworkUpgradePreviewTarget[] = []; + for (const target of targets) { + const entry = catalog.plugins.find((plugin) => plugin.name === target.pluginName); + if (!entry) throw new Error(`Cannot verify source of ${target.pluginName}.`); + const resolved = resolvePluginSourceFromMarketplace(entry.source, next, localPath); + if ( + resolved.kind !== "git-subdir" || + resolved.url !== "https://github.com/ai-driven-dev/framework.git" || + resolved.path !== `plugins/${target.pluginName}` || + resolved.ref !== release + ) + throw new Error(`Cannot verify source of ${target.pluginName} in ${release}.`); + let targetVersion = versions.get(target.pluginName); + if (targetVersion === undefined) { + targetVersion = await this.deps.readTargetVersion(localPath, target.pluginName); + if (!isSemver(targetVersion)) + throw new Error(`Cannot verify the target version of ${target.pluginName}.`); + versions.set(target.pluginName, targetVersion); + } + if (entry.version && entry.version !== targetVersion) + throw new Error(`The catalog version of ${target.pluginName} does not match its manifest.`); + if ( + !isSemver(target.currentVersion) || + compareSemver(targetVersion, target.currentVersion) < 0 + ) + throw new Error( + `Cannot downgrade ${target.toolId}:${target.pluginName} from ${target.currentVersion} to ${targetVersion}.` + ); + previewTargets.push({ ...target, targetVersion }); + } + const preflight = await this.deps.preflight(manifest, projectRoot, targets, next, localPath); + const userManifest = await this.deps.loadUserManifest?.(); + const globalPlugins = (userManifest?.getInstalledToolIds() ?? []).flatMap((toolId) => + !isAiToolId(toolId) + ? [] + : (userManifest?.getPlugins(toolId) ?? []) + .filter( + (plugin) => + plugin.scope === "user" && + (plugin.marketplace === FRAMEWORK_MARKETPLACE_NAME || + (plugin.marketplace === undefined && + plugin.source.kind === "git-subdir" && + plugin.source.url === "https://github.com/ai-driven-dev/framework.git" && + plugin.source.path === `plugins/${plugin.name}`)) + ) + .map((plugin) => ({ + toolId, + pluginName: plugin.name, + currentVersion: plugin.version, + source: plugin.source, + })) + ); + const unverifiedUserRefs = userRefs.filter( + (ref) => + !globalPlugins.some( + (entry) => entry.toolId === ref.toolId && entry.pluginName === ref.pluginName + ) + ).length; + const impact = preflight?.impact ?? []; + const shared = marketplace.scope === "user" || preflight?.shared === true; + if ( + !(await confirm({ + currentRef, + release, + title: publishedRelease.title, + targets: previewTargets, + globalSkipped, + unverifiedUserRefs, + globalPlugins, + shared, + impact, + })) + ) { + return { updated: [], globalSkipped, cancelled: true }; + } + if (shared && !allowShared) { + throw new Error( + "The AIDD marketplace is shared with other projects; pass --allow-shared after reviewing the impact." + ); + } + await this.deps.verifyImpact?.(projectRoot, impact, preflight?.source); + nextData.version = catalog.version; + await this.deps.saveMarketplace(projectRoot, Marketplace.fromJSON(nextData)); + const updated: string[] = []; + let nativeChecked = false; + try { + for (const target of previewTargets) { + if (target.toolId === preflight?.tool && !nativeChecked) { + await this.deps.verifyImpact?.(projectRoot, impact, preflight?.source); + nativeChecked = true; + } + if (target.targetVersion === undefined) + throw new Error(`Cannot verify ${target.pluginName} version.`); + await this.deps.install({ + pluginName: target.pluginName, + version: target.targetVersion, + expectedSource: { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: `plugins/${target.pluginName}`, + ref: release, + }, + toolIds: [target.toolId], + projectRoot, + fromMarketplace: FRAMEWORK_MARKETPLACE_NAME, + interactive: false, + autoSelect: true, + replace: true, + }); + const installed = (await this.deps.loadManifest()) + ?.getPlugins(target.toolId) + .find((plugin) => plugin.name === target.pluginName); + if ( + !installed || + installed.version !== target.targetVersion || + installed.scope !== "project" || + installed.marketplace !== FRAMEWORK_MARKETPLACE_NAME || + installed.source.kind !== "git-subdir" || + installed.source.url !== "https://github.com/ai-driven-dev/framework.git" || + installed.source.path !== `plugins/${target.pluginName}` || + installed.source.ref !== release + ) { + throw new Error( + `Unable to verify the installed source for ${target.toolId}:${target.pluginName}.` + ); + } + updated.push(`${target.toolId}:${target.pluginName}`); + } + const activation = await this.deps.sync( + projectRoot, + [...new Set(targets.map((target) => target.toolId))], + targets, + [...new Set(impact.map((entry) => entry.ref))] + ); + if ( + activation.errors.length || + activation.binaryMissing.length || + activation.warnings.length + ) { + const problems = [ + ...activation.warnings, + ...activation.errors.map((error) => `${error.scope}: ${error.message}`), + ...activation.binaryMissing.map( + (error) => `${error.toolId}: ${error.binary} is unavailable` + ), + ]; + throw new Error(problems.join("; ")); + } + } catch (error) { + throw new Error( + `Upgrade incomplete after the shared marketplace changed to ${release}. Plugin sources updated: ${updated.join(", ") || "none"}. ${error instanceof Error ? error.message : String(error)}` + ); + } + return { updated, globalSkipped, cancelled: false }; + } +} diff --git a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts index 1753f048c..1380808dd 100644 --- a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts +++ b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts @@ -41,6 +41,11 @@ export type ComponentPathMap = BrandedMap<"ComponentPathMap">; /** MCP server name → MD5 hash of the contributed server JSON (OpenCode merge tracking). */ export type McpDigestMap = BrandedMap<"McpDigestMap">; +export interface KiloTuiProvenance { + readonly config: "tui.json" | "tui.jsonc"; + readonly entry: "./hooks/aidd-maintenance/kilo-tui.js"; +} + /** Exact merged Cursor hook commands and copied project scripts recorded at install. */ export interface ProjectHooksProvenance { entries: readonly { event: string; command: string; digest: string }[]; @@ -76,6 +81,7 @@ export interface PluginEntryData { componentPaths?: Record; mcpEntries?: Record; projectHooks?: ProjectHooksEntryData; + tuiPlugin?: KiloTuiProvenance; marketplace?: string; /** Canonical project roots using machine-owned user-scope files. Only the user manifest owns this list. */ dependents?: string[]; @@ -91,6 +97,7 @@ export class InstalledPlugin { readonly componentPaths: ComponentPathMap; readonly mcpEntries: McpDigestMap; readonly projectHooks?: ProjectHooksProvenance; + readonly tuiPlugin?: KiloTuiProvenance; readonly marketplace?: string; readonly dependents: readonly string[]; @@ -104,6 +111,7 @@ export class InstalledPlugin { componentPaths: ComponentPathMap; mcpEntries: McpDigestMap; projectHooks?: ProjectHooksProvenance; + tuiPlugin?: KiloTuiProvenance; marketplace?: string; dependents: readonly string[]; }) { @@ -116,6 +124,7 @@ export class InstalledPlugin { this.componentPaths = params.componentPaths; this.mcpEntries = params.mcpEntries; this.projectHooks = params.projectHooks; + this.tuiPlugin = params.tuiPlugin; this.marketplace = params.marketplace; this.dependents = params.dependents; } @@ -154,6 +163,7 @@ export class InstalledPlugin { componentPaths: plugin.componentPaths, mcpEntries: asMcpDigestMap(mcpEntries), projectHooks: plugin.projectHooks, + tuiPlugin: plugin.tuiPlugin, marketplace: plugin.marketplace, dependents: plugin.dependents, }); @@ -173,6 +183,7 @@ export class InstalledPlugin { componentPaths: plugin.componentPaths, mcpEntries: plugin.mcpEntries, projectHooks, + tuiPlugin: plugin.tuiPlugin, marketplace: plugin.marketplace, dependents: plugin.dependents, }); @@ -257,6 +268,7 @@ export class InstalledPlugin { entries: data.projectHooks.entries, scripts: new Map(Object.entries(data.projectHooks.scripts)), }, + tuiPlugin: data.tuiPlugin, marketplace: data.marketplace, dependents: data.dependents ?? [], }); @@ -280,6 +292,7 @@ export class InstalledPlugin { }; } if (this.marketplace !== undefined) data.marketplace = this.marketplace; + if (this.tuiPlugin !== undefined) data.tuiPlugin = this.tuiPlugin; if (this.dependents.length > 0) data.dependents = [...this.dependents]; return data; } @@ -288,6 +301,10 @@ export class InstalledPlugin { return this.files.has(relPath); } + withTuiPlugin(provenance: KiloTuiProvenance | undefined): InstalledPlugin { + return InstalledPlugin.fromJSON({ ...this.toJSON(), tuiPlugin: provenance }); + } + withVersion(v: string): InstalledPlugin { return new InstalledPlugin({ name: this.name, @@ -299,6 +316,7 @@ export class InstalledPlugin { componentPaths: this.componentPaths, mcpEntries: this.mcpEntries, projectHooks: this.projectHooks, + tuiPlugin: this.tuiPlugin, marketplace: this.marketplace, dependents: this.dependents, }); @@ -315,6 +333,7 @@ export class InstalledPlugin { componentPaths: this.componentPaths, mcpEntries: this.mcpEntries, projectHooks: this.projectHooks, + tuiPlugin: this.tuiPlugin, marketplace: this.marketplace, dependents: this.dependents, }); @@ -331,6 +350,7 @@ export class InstalledPlugin { componentPaths: this.componentPaths, mcpEntries: this.mcpEntries, projectHooks: this.projectHooks, + tuiPlugin: this.tuiPlugin, marketplace: this.marketplace, dependents: [...new Set(dependents)], }); diff --git a/cli/src/contexts/framework/domain/setup-flow.ts b/cli/src/contexts/framework/domain/setup-flow.ts index c63a8bacd..ac6a0cea5 100644 --- a/cli/src/contexts/framework/domain/setup-flow.ts +++ b/cli/src/contexts/framework/domain/setup-flow.ts @@ -76,10 +76,10 @@ export class SetupFlow { } // `--scope user` writes nothing under `projectRoot`: an IDE tool's project-relative config has - // nowhere to land, an AI tool with neither native activation nor a user-scope install directory - // has nowhere to be registered, an empty `--ai` list registers the shared source for no tool at - // all, and no manifest entry exists yet to enable a plugin against. Each is refused rather than - // silently dropped. + // nowhere to land, an AI tool with neither native activation nor a user-scope directory has + // nowhere to be registered, an empty `--ai` list registers the shared source for no tool at + // all, and an interactive pick has no project to offer it in. Each is refused rather than + // silently dropped; named, recommended and all plugins install machine-wide. private validateScope( scope: SetupScope, aiTools: readonly ToolId[], @@ -91,7 +91,7 @@ export class SetupFlow { if (aiTools.length === 0) throw new UserScopeNoToolsError(); const unsupported = aiTools.filter((id) => !supportsUserScopeActivation(id)); if (unsupported.length > 0) throw new UserScopeUnsupportedAiToolsError(unsupported); - if (pluginMode !== "none") throw new UserScopePluginModeError(); + if (pluginMode === "interactive") throw new UserScopePluginModeError(); } private validatePluginMode(mode: PluginInstallMode, names: readonly string[]): void { diff --git a/cli/src/contexts/framework/infrastructure/pinned-user-framework-adapter.ts b/cli/src/contexts/framework/infrastructure/pinned-user-framework-adapter.ts new file mode 100644 index 000000000..dcfae0b59 --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/pinned-user-framework-adapter.ts @@ -0,0 +1,154 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { Hasher } from "../../../kernel/ports/hasher.js"; +import { compareSemver, isSemver } from "../../../kernel/semver.js"; +import type { AiToolId } from "../../../kernel/tool.js"; +import { Marketplace } from "../../distribution/domain/marketplace.js"; +import type { PluginCatalogRepository } from "../../distribution/domain/ports/plugin-catalog-repository.js"; +import type { PluginFetcher } from "../../distribution/domain/ports/plugin-fetcher.js"; +import { frameworkBuildModeFor } from "../../tools/domain/registry.js"; +import { readGlobalPluginFiles } from "../application/ownership/global-plugin-files.js"; +import type { + PinnedUserFramework, + PinnedUserFrameworkResult, +} from "../application/ownership/release/pinned-user-framework.js"; +import type { FrameworkBuildFor } from "../application/shared/ensure-built-marketplace-use-case.js"; +import { PLUGIN_NAME_REGEX } from "../domain/plugins/installed-plugin.js"; +import { resolvePluginSourceFromMarketplace } from "../domain/plugins/plugin-source-resolver.js"; +import type { PluginDistributionReader } from "../domain/ports/plugin-distribution-reader.js"; + +const execFile = promisify(execFileCallback); + +interface Checkout { + readonly path: string; + readonly cache: string; + readonly sha: string; +} + +export class PinnedUserFrameworkAdapter implements PinnedUserFramework { + constructor( + private readonly fs: FileReader, + private readonly hasher: Hasher, + private readonly fetcher: PluginFetcher, + private readonly catalogs: PluginCatalogRepository, + private readonly plugins: PluginDistributionReader, + private readonly buildFor: FrameworkBuildFor + ) {} + + async build( + ref: string, + toolId: AiToolId, + pluginName: string, + sha?: string + ): Promise { + return this.withSession((session) => session.build(ref, toolId, pluginName, sha)); + } + + async withSession(action: (session: PinnedUserFramework) => Promise): Promise { + const temporary = await mkdtemp(join(tmpdir(), "aidd-user-release-")); + const checkouts = new Map>(); + const builds = new Map>(); + try { + return await action({ + build: (ref, toolId, pluginName, sha) => + this.buildInSession(temporary, checkouts, builds, ref, toolId, pluginName, sha), + }); + } finally { + await rm(temporary, { recursive: true, force: true }); + } + } + + private async buildInSession( + temporary: string, + checkouts: Map>, + builds: Map>, + ref: string, + toolId: AiToolId, + pluginName: string, + sha?: string + ): Promise { + if (!ref.startsWith("v") || !isSemver(ref) || !PLUGIN_NAME_REGEX.test(pluginName)) + throw new Error("Expected a versioned official framework release and plugin name."); + const source = { + kind: "github", + repo: "ai-driven-dev/framework", + ref, + ...(sha ? { sha } : {}), + } as const; + let pending = checkouts.get(ref); + if (pending === undefined) { + const cache = join(temporary, `source-${checkouts.size}`); + pending = (async () => { + const path = await this.fetcher.fetch(source, cache, { canonicalCheckout: true }); + const head = (await execFile("git", ["-C", path, "rev-parse", "HEAD"])).stdout.trim(); + if (!/^[a-f0-9]{40}$/.test(head) || (sha !== undefined && head !== sha)) + throw new Error("The framework checkout does not match the recorded release commit."); + return { path, cache, sha: head }; + })(); + checkouts.set(ref, pending); + } else { + // Before each reuse, reject a changed checkout, even when the build is already cached. + const prior = await pending; + await this.fetcher.fetch({ ...source, sha: prior.sha }, prior.cache); + } + const acquired = await pending; + if (sha !== undefined && acquired.sha !== sha) + throw new Error("The framework checkout does not match the recorded release commit."); + const checkout = acquired.path; + const head = acquired.sha; + const catalog = await this.catalogs.load(checkout); + if ( + !catalog?.version || + !isSemver(catalog.version) || + compareSemver(catalog.version, ref) !== 0 + ) + throw new Error(`Release ${ref} has an invalid framework marketplace version.`); + const entry = catalog.plugins.find((item) => item.name === pluginName); + if (!entry) throw new Error(`Release ${ref} does not contain '${pluginName}'.`); + const marketplace = Marketplace.create({ + name: "aidd-framework", + source, + scope: "project", + addedAt: "1970-01-01T00:00:00.000Z", + }); + const resolved = resolvePluginSourceFromMarketplace(entry.source, marketplace, checkout); + if ( + resolved.kind !== "git-subdir" || + resolved.url !== "https://github.com/ai-driven-dev/framework.git" || + resolved.path !== `plugins/${pluginName}` || + resolved.ref !== ref + ) + throw new Error(`Release ${ref} has an unverified source for '${pluginName}'.`); + const dist = await this.plugins.read(join(checkout, "plugins", pluginName)); + if ( + dist.manifest.name !== pluginName || + !isSemver(dist.manifest.version) || + (entry.version && entry.version !== dist.manifest.version) + ) + throw new Error(`Release ${ref} has inconsistent metadata for '${pluginName}'.`); + const mode = frameworkBuildModeFor(toolId); + if (mode !== "flat") + throw new Error(`${toolId}: this release update supports flat machine installations only.`); + const buildKey = `${ref}\u0000${head}\u0000${toolId}`; + let built = builds.get(buildKey); + if (built === undefined) { + const outDir = join(temporary, `build-${builds.size}`); + built = (async () => { + await mkdir(outDir); + const build = this.buildFor(toolId, mode, outDir); + if (!build) throw new Error(`${toolId}: no compatible framework build.`); + await build.execute({ sourceDir: checkout, outDir, target: toolId, mode }); + return outDir; + })(); + builds.set(buildKey, built); + } + const outDir = await built; + const files = await readGlobalPluginFiles(this.fs, this.hasher, outDir, toolId, dist); + await this.fetcher.fetch({ ...source, sha: head }, acquired.cache); + return { sha: head, dist, files }; + } +} diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts index a41330a39..599cdc002 100644 --- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts +++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts @@ -1,11 +1,27 @@ -import { mkdir, rm, rmdir } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + link, + lstat, + mkdir, + readFile, + readlink, + rm, + rmdir, + unlink, + writeFile, +} from "node:fs/promises"; +import { hostname } from "node:os"; import { dirname } from "node:path"; import { setTimeout as pause } from "node:timers/promises"; +import { promisify } from "node:util"; import { userManifestPath } from "../../../kernel/paths.js"; import type { Manifest } from "../domain/manifest.js"; import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; import { readManifestFile } from "./manifest-file-io.js"; +const execFile = promisify(execFileCallback); + /** * The user-scope counterpart of `ManifestRepositoryAdapter` — same schema, same version and refusal * rule, same file I/O. Only the path differs, and what a version-refusal message names to fix it. @@ -18,7 +34,8 @@ import { readManifestFile } from "./manifest-file-io.js"; export class UserManifestRepositoryAdapter implements ManifestRepository { constructor( private readonly userConfigDir: () => string, - private readonly atomicWriter: (path: string, content: string) => Promise + private readonly atomicWriter: (path: string, content: string) => Promise, + private readonly beforeAction?: () => Promise ) {} get path(): string { @@ -29,7 +46,7 @@ export class UserManifestRepositoryAdapter implements ManifestRepository { return readManifestFile({ path: this.path, location: "for this machine", - reinstallCommand: "aidd setup --scope user", + reinstallCommand: "aidd setup --global", }); } @@ -46,24 +63,130 @@ export class UserManifestRepositoryAdapter implements ManifestRepository { const lock = `${this.path}.lock`; await mkdir(dirname(lock), { recursive: true }); const deadline = Date.now() + 30_000; - while (true) { + const realm = await localProcessRealm(); + // The link is atomic and already contains its owner at the instant it becomes visible. + // A directory lock left by an older CLI remains unrecognized and is never removed here. + const claim = `${lock}.${process.pid}-${randomBytes(12).toString("hex")}`; + await writeFile(claim, JSON.stringify({ pid: process.pid, host: hostname(), realm }), { + mode: 0o600, + flag: "wx", + }); + try { + while (true) { + const acquired = await withLockGuard(lock, deadline, async () => { + try { + await link(claim, lock); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + await removeDeadOwner(lock, realm); + return false; + } + }); + if (acquired) break; + await waitForLock(lock, deadline); + } try { - await mkdir(lock); - break; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - if (Date.now() >= deadline) { - throw new Error( - `User manifest is busy: ${lock}. Retry after the other AIDD operation finishes.` - ); - } - await pause(100); + await this.beforeAction?.(); + return await action(); + } finally { + // Hold the same short guard as recovery: ownership cannot change between a reaper's + // liveness check and unlink. If this guard cannot be acquired, leave the lock intact. + await withLockGuard(lock, Date.now() + 30_000, () => unlink(lock)); } + } finally { + await unlink(claim); } + } +} + +async function waitForLock(lock: string, deadline: number): Promise { + if (Date.now() >= deadline) + throw new Error( + `User manifest is busy: ${lock}. Wait for other AIDD operations to finish. If none are running, inspect and remove this stale lock path, then retry; never remove a lock held by an active process.` + ); + await pause(100); +} + +/** Serialize lock acquisition, recovery and release so a stale observation cannot remove a new owner. */ +async function withLockGuard( + lock: string, + deadline: number, + action: () => Promise +): Promise { + const guard = `${lock}.guard`; + while (true) { try { - return await action(); - } finally { - await rmdir(lock); + await mkdir(guard); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + await waitForLock(guard, deadline); + } + } + try { + return await action(); + } finally { + await rmdir(guard); + } +} + +/** Unknown, foreign-host, legacy and live locks stay untouched. PID reuse only delays recovery. */ +async function removeDeadOwner(lock: string, realm: string | undefined): Promise { + if (realm === undefined) return; + let owner: unknown; + try { + const stat = await lstat(lock); + if (!stat.isFile() || stat.size > 1024) return; + owner = JSON.parse(await readFile(lock, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return; + throw error; + } + if ( + owner === null || + typeof owner !== "object" || + !("pid" in owner) || + !("host" in owner) || + !("realm" in owner) + ) + return; + const pid = owner.pid; + if ( + typeof pid !== "number" || + !Number.isSafeInteger(pid) || + pid <= 0 || + owner.host !== hostname() || + owner.realm !== realm + ) + return; + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") await unlink(lock); + } +} + +/** A PID means the same thing only in the same boot and PID namespace. Unknown realms fail closed. */ +async function localProcessRealm(): Promise { + try { + if (process.platform === "linux") { + const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(); + const namespace = await readlink("/proc/self/ns/pid"); + if (/^[0-9a-f-]{36}$/.test(boot) && /^pid:\[\d+\]$/.test(namespace)) + return `linux:${boot}:${namespace}`; + } + if (process.platform === "darwin") { + const { stdout } = await execFile("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { + timeout: 1000, + maxBuffer: 256, + }); + const boot = stdout.trim(); + if (/^[0-9a-f-]{36}$/i.test(boot)) return `darwin:${boot}`; } + } catch { + // Recovery is optional: acquisition still works, but an unverifiable owner is never reaped. + return undefined; } + return undefined; } diff --git a/cli/src/contexts/framework/infrastructure/user-release-journal-store.ts b/cli/src/contexts/framework/infrastructure/user-release-journal-store.ts new file mode 100644 index 000000000..e8429c33b --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/user-release-journal-store.ts @@ -0,0 +1,68 @@ +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { type FileHandle, link, lstat, mkdir, open, realpath, unlink } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import type { UserReleaseJournalStore } from "../application/ownership/release/user-release-journal.js"; + +/** A private, exclusively created journal adjacent to the machine manifest. */ +export class UserReleaseJournalStoreAdapter implements UserReleaseJournalStore { + constructor(private readonly manifestPath: () => string) {} + private get path(): string { + return `${this.manifestPath()}.release-journal`; + } + + async read(): Promise { + let handle: FileHandle; + try { + const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW; + handle = await open(this.path, constants.O_RDONLY | noFollow); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + try { + const info = await handle.stat(); + const pathInfo = await lstat(this.path); + const dir = await realpath(dirname(this.path)); + if ( + !info.isFile() || + !pathInfo.isFile() || + info.ino !== pathInfo.ino || + info.dev !== pathInfo.dev || + (process.platform !== "win32" && + ((info.mode & 0o077) !== 0 || info.uid !== process.getuid?.())) || + (await realpath(this.path)) !== join(dir, basename(this.path)) + ) { + throw new Error("User release journal is not a private owned file; recovery refused."); + } + return await handle.readFile("utf8"); + } finally { + await handle.close(); + } + } + + async create(content: string): Promise { + const dir = dirname(this.path); + await mkdir(dir, { recursive: true }); + const temp = `${this.path}.${process.pid}-${randomBytes(12).toString("hex")}.tmp`; + const handle = await open(temp, "wx", 0o600); + try { + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + // link is an atomic no-replace operation. Another journal is never overwritten. + await link(temp, this.path); + } finally { + await unlink(temp); + } + } + + async remove(): Promise { + if ((await this.read()) === null) + throw new Error("User release journal disappeared before completion."); + await unlink(this.path); + } +} diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index b488a508f..4596ff41b 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -22,6 +22,15 @@ export interface NativeActivation { * Claude's *local* scope — the registration names an absolute path, wrong for everyone * else in the shared project settings. Omit where the registry has no scopes. */ scopeArgs?: Readonly>; + /** Scope arguments `plugin marketplace remove` takes at user scope instead of + * `scopeArgs.user` when no project uses the marketplace, run from the home directory. + * Measured on Claude 2.1.318: `remove --scope user` drops the settings declaration and keeps + * the registration and a project's install; a remove naming no scope deletes the registration + * and uninstalls that project's plugins too, with their saved options. */ + userRemoveArgs?: readonly string[]; + /** The host keeps an uninstalled plugin's ref, disabled, so a disabled ref cannot be told + * from one the person disabled and is not taken for a plugin in use. */ + uninstallLeavesRefDisabled?: boolean; /** Arguments that make `plugin marketplace remove` succeed with plugins installed from it. * Permits reclaiming a name, so declare it only alongside `sourceCheckVerb`. */ forceRemoveArgs?: readonly string[]; @@ -35,6 +44,10 @@ export interface NativeActivation { upgradeVerb?: string; /** Exact host command for updating one installed plugin ref. Copilot declares `update`; Codex does not. */ updateVerb?: string; + /** Targeted refresh of an existing installation after a framework release update. */ + releaseUpdateVerb?: string; + /** Reinstall an already-owned plugin from the rebuilt local catalogue after an explicit release update. */ + refreshOwnedOnReleaseUpdate?: boolean; /** Verb this CLI uses to enable a plugin, after `plugin`. Omit when the tool loads plugins * from a project file this CLI writes. */ enableVerb?: string; @@ -70,6 +83,50 @@ export interface ProjectHooksDelivery { projectHooksFormat?: ProjectHooksFormat; } +/** + * What a machine-scope install (`setup --global`) copies out of this tool's built tree into + * the user's own configuration directory, for a host that loads those files from there rather + * than from a plugin it enables itself: OpenCode's and Kilo's skills and agents, Codex's agents. + */ +export interface GlobalFiles { + /** The user configuration directory the files land in, given a homedir. */ + readonly dir: (homedir: string) => string; + /** A built-tree path (relative to the tool's built dir) to its path under `dir` for one + * plugin, or `null` when that file is not this plugin's or is not installed machine-wide. */ + readonly map: (builtPath: string, plugin: GlobalPlugin) => string | null; +} + +/** The plugin a machine file is read for: its name, and the agents its source ships, by base + * name. A flat build prefixes an agent with its plugin, so a prefix alone cannot tell + * `aidd-dev`'s agents from those of a plugin named `aidd-dev-tools`. */ +export interface GlobalPlugin { + readonly name: string; + readonly agents: ReadonlySet; +} + +/** + * The machine-file mapping of a flat tool whose user configuration directory takes the same + * `skills/` and `agents/` its project directory does (OpenCode, Kilo): a plugin's skills and + * agents under `directory` keep their path below it. Hooks, commands and MCP entries stay + * project-scoped. + */ +export function flatGlobalFilePath(directory: string): GlobalFiles["map"] { + return (builtPath, plugin) => { + if (!builtPath.startsWith(directory)) return null; + const relative = builtPath.slice(directory.length); + if (relative.startsWith(`skills/${plugin.name}/`)) return relative; + const agent = /^agents\/(.+)\.md$/.exec(relative)?.[1]; + if (agent === undefined || !agent.startsWith(`${plugin.name}-`)) return null; + return plugin.agents.has(agent.slice(plugin.name.length + 1)) ? relative : null; + }; +} + +/** Shared by every mode: where a tool's machine-scope files go, when it has any. */ +interface GlobalFilesParams { + globalFiles?: GlobalFiles; + startupTui?: { readonly plugin: string; readonly directory: string; readonly module: string }; +} + export interface NativePluginsParams extends ProjectHooksDelivery { mode: "native"; pluginsDir: string; @@ -121,6 +178,8 @@ export type FlatHooksSupport = * a bridge must be told to run. Omit where the loader has no such convention. */ flatHooksLoaderEntry?: FlatHooksLoaderEntry; + /** Host-specific placement for hook files, shared by build and direct installation. */ + flatHooksPath?: (plugin: string, relativePath: string) => string; /** See {@link FlatHooksBridge}. Omit when this loader triggers a plugin's hooks some * other way. */ flatHooksBridge?: FlatHooksBridge; @@ -153,10 +212,12 @@ export type HooksSupport = | { acceptsHooks: true; hooksTrustNotice?: string } | { acceptsHooks: false; hooksUnsupportedReason: string }; -type PluginsParams = +type PluginsParams = ( | (NativePluginsParams & HooksSupport) | FlatPluginsParams - | UnsupportedPluginsParams; + | UnsupportedPluginsParams +) & + GlobalFilesParams; export class PluginsCapability { readonly mode: PluginsMode; @@ -183,6 +244,7 @@ export class PluginsCapability { readonly flatHooksDir: string | null; /** See {@link FlatHooksSupport.flatHooksLoaderEntry}. */ readonly flatHooksLoaderEntry: FlatHooksLoaderEntry | null; + readonly flatHooksPath: ((plugin: string, relativePath: string) => string) | null; /** See {@link FlatHooksBridge}, or `null` when this capability declares none. */ readonly flatHooksBridge: FlatHooksBridge | null; readonly flatSkillLayout: FlatSkillLayout; @@ -190,11 +252,20 @@ export class PluginsCapability { readonly nativeActivation: NativeActivation | null; readonly translationMode: PluginTranslationMode | null; readonly installScope: "project" | "user"; + /** See {@link GlobalFiles}, or `null` when a machine-scope install copies no file. */ + readonly globalFiles: GlobalFiles | null; + readonly startupTui: { + readonly plugin: string; + readonly directory: string; + readonly module: string; + } | null; private readonly _userPluginsDir?: (homedir: string) => string; constructor(params: PluginsParams) { this.mode = params.mode; + this.globalFiles = params.globalFiles ?? null; + this.startupTui = params.startupTui ?? null; this.translationMode = PluginsCapability.resolveTranslationMode(params); this.installScope = PluginsCapability.resolveInstallScope(params); PluginsCapability.validateUserScope(params); @@ -216,6 +287,7 @@ export class PluginsCapability { this.projectHooksFormat = params.projectHooksFormat ?? null; this.flatHooksDir = null; this.flatHooksLoaderEntry = null; + this.flatHooksPath = null; this.flatHooksBridge = null; this.flatSkillLayout = "nested"; this.marketplaceSettings = params.marketplaceSettings ?? null; @@ -231,6 +303,7 @@ export class PluginsCapability { this.flatHooksLoaderEntry = params.acceptsHooks ? (params.flatHooksLoaderEntry ?? null) : null; + this.flatHooksPath = params.acceptsHooks ? (params.flatHooksPath ?? null) : null; this.flatHooksBridge = params.acceptsHooks ? (params.flatHooksBridge ?? null) : null; this.flatSkillLayout = params.flatSkillLayout ?? "nested"; this.hooksTrustNotice = null; @@ -257,6 +330,7 @@ export class PluginsCapability { this.hooksUnsupportedReason = params.hooksUnsupportedReason; this.flatHooksDir = null; this.flatHooksLoaderEntry = null; + this.flatHooksPath = null; this.flatHooksBridge = null; this.flatSkillLayout = "nested"; this.hooksTrustNotice = null; @@ -285,7 +359,7 @@ export class PluginsCapability { * independently of this capability's own `installScope`: a caller resolving a *recorded* * scope still needs the directory that scope means. */ userPluginsBaseDir(homedir: string): string | null { - return this._userPluginsDir?.(homedir) ?? null; + return this._userPluginsDir?.(homedir) ?? this.globalFiles?.dir(homedir) ?? null; } pluginOutputDir(pluginName: string): string | null { diff --git a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts deleted file mode 100644 index a963fc32b..000000000 --- a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Copilot-supported frontmatter keys for agent files. Order is preserved on serialization, - * for deterministic output. */ -export const COPILOT_AGENT_FRONTMATTER_KEYS: readonly [ - "name", - "description", - "model", - "reasoningEffort", - "include-custom-instructions", -] = ["name", "description", "model", "reasoningEffort", "include-custom-instructions"]; - -/** Cursor-supported frontmatter keys: Cursor documents only name, description and model — - * never tools or color. */ -export const CURSOR_AGENT_FRONTMATTER_KEYS: readonly ["name", "description", "model"] = [ - "name", - "description", - "model", -]; - -/** Keys of a host whose agent ID is its file path (OpenCode, Kilo): no `name`. */ -const PATH_ID_AGENT_FRONTMATTER_KEYS: readonly ["description", "model"] = ["description", "model"]; - -/** Pick only the given keys, preserving their order. A key whose value is undefined is - * omitted. */ -export function pickFrontmatterKeys( - fm: Record, - keys: readonly string[] -): Record { - const result: Record = {}; - for (const key of keys) { - if (fm[key] !== undefined) { - result[key] = fm[key]; - } - } - return result; -} - -/** Copilot reads Claude's `effort` as `reasoningEffort`, and gives a subagent the repository - * instructions only when `include-custom-instructions` is set. Lossy, so there is no inverse. */ -export function stripCopilotAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys( - { - ...fm, - reasoningEffort: fm.effort, - "include-custom-instructions": fm["include-custom-instructions"] ?? true, - }, - COPILOT_AGENT_FRONTMATTER_KEYS - ); -} - -/** Only the Cursor-supported keys. Tools, color and argument-hint are discarded. */ -export function stripCursorAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, CURSOR_AGENT_FRONTMATTER_KEYS); -} - -/** OpenCode and Kilo name an agent after its file and give it every tool by default; Kilo - * passes any unknown key to the model provider, so nothing else is carried. */ -export function stripPathIdAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, PATH_ID_AGENT_FRONTMATTER_KEYS); -} diff --git a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts index eb01d94e7..82df4bc65 100644 --- a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts +++ b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts @@ -34,6 +34,7 @@ export interface NativePluginActivator { */ enablePlugin(pluginRef: string, scope?: MarketplaceScope): void; updatePlugin?(pluginRef: string): void; + refreshReleasePlugin?(pluginRef: string, scope: MarketplaceScope, projectRoot: string): void; /** * Uninstalls a plugin referenced as `@`, the counterpart of * {@link enablePlugin}. May throw when the plugin is already absent from the tool's own diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts index a09f3778f..36bea408d 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -117,9 +117,11 @@ export const claude: AiTool join(homedir, ".codex"), map: codexGlobalFilePath }, acceptsMcp: true, translationMode: "marketplace", // Codex only enables plugins from its user-global config plus its own plugin cache; a @@ -190,7 +192,9 @@ export const codex: AiTool< pluginRootToken: PLUGIN_ROOT_TOKEN, nativeActivation: { binary: "codex", - upgradeVerb: "upgrade", + // Codex rejects `marketplace upgrade` for the local catalogue we compile. Re-adding an + // owned plugin refreshes its installed cache from that rebuilt catalogue instead. + refreshOwnedOnReleaseUpdate: true, enableVerb: "add", disableVerb: "remove", // Codex's own `plugin remove` deletes a marketplace's cached content but leaves the diff --git a/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts index f3803b181..8ee8ab9aa 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts @@ -1,9 +1,42 @@ -import type { NativeMarketplaceSourceListContract } from "../../ports/native-marketplace-source-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceListContract, +} from "../../ports/native-marketplace-source-reader.js"; + +const LOCAL_SOURCE_PREFIX = "Local: "; + +/** + * Copilot 1.0.98 answers `--json` with `[{ name, source, isDefault }]`, `source` reading + * `Local: ` or `GitHub: `. Only a local source is proven; any other stays + * named but unproven. Installed Copilot 1.0.83 rejects `--json` and stays unreadable. + */ +function parseCopilotMarketplaceSources( + output: string +): ReadonlyMap { + const parsed: unknown = JSON.parse(output); + if (!Array.isArray(parsed)) throw new Error("unexpected Copilot JSON shape"); + const entries = new Map(); + for (const item of parsed) { + const row: unknown = item; + if (row === null || typeof row !== "object") throw new Error("ambiguous Copilot JSON row"); + const { name, source } = row as Record; + if (typeof name !== "string" || name === "" || typeof source !== "string" || entries.has(name)) + throw new Error("ambiguous Copilot marketplace JSON row"); + const path = source.startsWith(LOCAL_SOURCE_PREFIX) + ? source.slice(LOCAL_SOURCE_PREFIX.length) + : ""; + entries.set( + name, + path === "" ? null : { kind: "effective-list", root: path, sourceType: "local", source: path } + ); + } + return entries; +} -/** Installed Copilot 1.0.83 rejects --json; no structured source shape is verified yet. */ export const copilotMarketplaceSourceListContract: NativeMarketplaceSourceListContract = { binary: "copilot", args: ["plugin", "marketplace", "list", "--json"], + parse: parseCopilotMarketplaceSources, unavailableMessage: "Copilot CLI marketplace list --json unavailable; upgrade to a supported binary and retry", unverifiedMessage: diff --git a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts index b798a7a01..e7f49e249 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts @@ -288,6 +288,9 @@ export const copilot: AiTool< disableVerb: "uninstall", sourceCheckVerb: "update", forceRemoveArgs: ["--force"], + // Measured on 1.0.98: `plugin uninstall` sets the ref to `false` in enabledPlugins + // rather than deleting it. + uninstallLeavesRefDisabled: true, // Where `copilot plugin marketplace add`/`install` land, measured against the real // binary. Never written by aidd; named here for a diagnostic alone. userSettingsPath: (h) => join(h, ".copilot", "settings.json"), diff --git a/cli/src/contexts/tools/domain/profiles/kilo/headless-run.ts b/cli/src/contexts/tools/domain/profiles/kilo/headless-run.ts new file mode 100644 index 000000000..55fb5d9de --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/kilo/headless-run.ts @@ -0,0 +1,7 @@ +export const kiloHeadlessRun = { + configDirEnv: "KILO_CONFIG_DIR", + pureEnv: "KILO_PURE", + pureFlag: "--pure", + directoryArg: "--dir", + interactiveFlags: ["--interactive", "-i", "--interactive=true"] as readonly string[], +}; diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts index e7be273fb..82e1c8c81 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts @@ -26,13 +26,16 @@ interface ParsedHookCall { readonly matcher?: string; } -const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/; +// Read the canonical Claude-format manifest before translating it to Kilo. +const COMMAND_PATTERN = + /^node\s+(?:"\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)"|\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+))(.*)$/; function parseCommand(command: string | undefined): ParsedHookCall | null { if (typeof command !== "string") return null; const match = COMMAND_PATTERN.exec(command.trim()); if (match === null) return null; - const [, script, rest] = match; + const [, quotedScript, bareScript, rest] = match; + const script = quotedScript ?? bareScript; return { script, args: rest.trim().length === 0 ? [] : rest.trim().split(/\s+/) }; } @@ -132,7 +135,7 @@ function stopReasonOf(script, args, payload, directory) { }); } -function answerOf(script, args, payload, directory) { +function answerOf(script, args, payload, directory, systemMessage = false) { return new Promise((resolve) => { let stdout = ""; let stderr = ""; @@ -153,7 +156,12 @@ function answerOf(script, args, payload, directory) { fail("exited with code " + String(code) + (signal === null ? "" : " (" + signal + ")")); return; } - resolve(answerFrom(code, stdout, stderr)); + if (systemMessage) { + try { resolve(String(JSON.parse(stdout)?.systemMessage ?? "").trim()); } + catch { resolve(""); } + } else { + resolve(answerFrom(code, stdout, stderr)); + } }); child.stdin.on("error", () => {}); child.stdin.end(JSON.stringify(payload)); @@ -166,9 +174,10 @@ function reportHookFailure(script, error) { function sessionStartCallsFor(event, directory) { if (event?.type !== "session.created") return []; - return SESSION_START.map((hook) => ({ + return SESSION_START${plugin === "aidd-maintenance" ? '.filter((hook) => hook.matcher !== "startup")' : ""}.map((hook) => ({ script: hook.script, args: hook.args, + ...(hook.matcher === "startup" ? { notify: true } : {}), payload: { hook_event_name: "SessionStart", session_id: event.properties?.sessionID ?? null, cwd: event.properties?.info?.directory ?? directory }, })); } @@ -206,7 +215,16 @@ const ${identifier} = async ({ directory, client }) => { } } for (const call of calls) { - runHook(call.script, call.args, call.payload, call.payload.cwd); + if (call.notify) { + const message = await answerOf(call.script, call.args, { ...call.payload, tool: "kilo" }, call.payload.cwd, true); + if (message && typeof client?.tui?.showToast === "function") { + await client.tui.showToast({ body: { message, variant: "info" } }); + } else if (message) { + console.warn("[aidd] Kilo cannot display the startup message: " + message); + } + } else { + runHook(call.script, call.args, call.payload, call.payload.cwd); + } } } catch (error) { console.warn("[aidd] Kilo hook dispatch failed: " + String(error)); diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts index e3ec31b80..ed5f43cef 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-paths.ts @@ -4,8 +4,22 @@ import { join } from "node:path"; import { KiloDualConfigError } from "../../../../../kernel/errors.js"; import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import { flatGlobalFilePath } from "../../capabilities/plugins-capability.js"; export const KILO_DIRECTORY = ".kilo/"; +const flatMachinePath = flatGlobalFilePath(KILO_DIRECTORY); +const maintenanceStartup = new Set([ + ".kilo/hooks/aidd-maintenance/session-start.cjs", + ".kilo/hooks/aidd-maintenance/kilo-tui.js", + ".kilo/plugin/aidd-maintenance-hooks.js", +]); + +/** Only maintenance's startup files are installed globally; other hooks remain project-scoped. */ +export const kiloGlobalFilePath: ReturnType = (path, plugin) => { + if (plugin.name === "aidd-maintenance" && maintenanceStartup.has(path)) + return path.slice(KILO_DIRECTORY.length); + return flatMachinePath(path, plugin); +}; export const KILO_HOOKS_DIR = `${KILO_DIRECTORY}hooks/`; export const KILO_PLUGIN_DIR = `${KILO_DIRECTORY}plugin/`; export const KILO_PLUGIN_ENTRY_BASENAME = "kilo-plugin.js"; diff --git a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts index 708236ad2..81eb71373 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { AgentsCapability } from "../../capabilities/agents-capability.js"; import { CommandsCapability } from "../../capabilities/commands-capability.js"; import { CONFIG_MCP } from "../../capabilities/config-refs.js"; @@ -29,6 +30,7 @@ import { KILO_HOOKS_DIR, KILO_PLUGIN_DIR, KILO_PLUGIN_ENTRY_BASENAME, + kiloGlobalFilePath, makeKiloHooksBridgePath, resolveKiloConfigPath, } from "./kilo-paths.js"; @@ -89,6 +91,15 @@ export const kilo: AiTool join(homedir, ".config", "kilo"), + map: kiloGlobalFilePath, + }, + startupTui: { + plugin: "aidd-maintenance", + directory: KILO_DIRECTORY, + module: "hooks/aidd-maintenance/kilo-tui.js", + }, acceptsHooks: true, flatHooksDir: KILO_HOOKS_DIR, flatHooksLoaderEntry: { dir: KILO_PLUGIN_DIR, baseName: KILO_PLUGIN_ENTRY_BASENAME }, diff --git a/cli/src/contexts/tools/domain/profiles/opencode/build.ts b/cli/src/contexts/tools/domain/profiles/opencode/build.ts index c7462d7ea..0ce9ff449 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/build.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/build.ts @@ -7,7 +7,6 @@ import { InvalidMcpServerConfigError, McpConfigError } from "../../../../../kernel/errors.js"; import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; import { - flatHooksPathWithLoaderEntry, flatMcpKeyPrefix, genericFlatAgentPath, genericFlatSkillTreePath, @@ -24,8 +23,8 @@ import { OPENCODE_EVENTS_ASSET, OPENCODE_EVENTS_PATH, OPENCODE_FLAT_HOOKS_DIR, - OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, + opencodeFlatHooksPath, } from "./opencode-paths.js"; type FsType = FileReader & FileWriter; @@ -90,27 +89,13 @@ function opencodeFlatSkillPath(plugin: string, rel: string): string { return genericFlatSkillTreePath(".opencode/skills/", plugin, rel.replace(/^skills\//, "")); } -/** OpenCode's loader scans one flat directory for its own plugin modules, never a "hooks" - * family, so a plugin's hook scripts are namespaced under `OPENCODE_HOOKS_DIR` instead. The one - * exception is a script named `OPENCODE_PLUGIN_ENTRY_BASENAME`: that loader's own module, - * delivered flat and renamed to the plugin's own name. */ -function makeOpencodeFlatHooksPath(): (plugin: string, rel: string) => string { - return (plugin, rel) => - flatHooksPathWithLoaderEntry( - OPENCODE_HOOKS_DIR, - { dir: OPENCODE_FLAT_HOOKS_DIR, baseName: OPENCODE_PLUGIN_ENTRY_BASENAME }, - plugin, - rel - ); -} - /** Supported exactly when the profile names a directory to deliver into — the same * declaration `acceptsHooks` is paired with, read once rather than restated here. */ function buildOpencodeFlatHooksArtifact(): ArtifactContract { return { supported: true, source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: makeOpencodeFlatHooksPath(), + path: opencodeFlatHooksPath, skipHooksJson: true, hooksBridge: { generate: generateOpencodeHooksBridge, diff --git a/cli/src/contexts/tools/domain/profiles/opencode/headless-run.ts b/cli/src/contexts/tools/domain/profiles/opencode/headless-run.ts new file mode 100644 index 000000000..9b2ae58a6 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/opencode/headless-run.ts @@ -0,0 +1,7 @@ +export const opencodeHeadlessRun = { + configDirEnv: "OPENCODE_CONFIG_DIR", + pureEnv: undefined, + pureFlag: undefined, + directoryArg: undefined, + interactiveFlags: [] as readonly string[], +}; diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts index be0adf5e9..c0c5fd630 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts @@ -7,9 +7,9 @@ * * Only three hook events are replayed: * - * - `SessionStart` runs once per server/directory at factory initialization and requires - * idempotent commands. V1 did not deliver `session.created` to this event hook; - * the V2 adapter delivers it. + * - Unmatched `SessionStart` runs once per server/directory at initialization and requires + * idempotent commands. Hooks matching `startup` run for each delivered session.created. + * V1 did not always deliver this event; V2 does. * - `Stop` maps to V1's `session.idle` or V2's execution terminal, once per turn. * - `PostToolUse` maps to V1's `tool.execute.after`, the one hook that waits for the script and * can append its exit-2 stderr to the tool's output, which is how a problem reaches the model. @@ -48,16 +48,16 @@ interface ClaudeHooksShape { readonly hooks?: Record; } -// Only a command this generator can actually replay: `node ${CLAUDE_PLUGIN_ROOT}/hooks/ -// [args...]`. Anything else is a shape only Claude's own settings.json target ever runs, and is -// dropped here rather than guessed at. -const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/; +// Read replayable commands from the canonical Claude-format manifest. +const COMMAND_PATTERN = + /^node\s+(?:"\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)"|\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+))(.*)$/; function parseCommand(command: string | undefined): ParsedHookCall | null { if (typeof command !== "string") return null; const match = COMMAND_PATTERN.exec(command.trim()); if (!match) return null; - const [, script, rest] = match; + const [, quotedScript, bareScript, rest] = match; + const script = quotedScript ?? bareScript; const args = rest.trim().length > 0 ? rest.trim().split(/\s+/) : []; return { script, args }; } @@ -74,10 +74,7 @@ function parseGroups(groups: readonly ClaudeMatcherGroup[] | undefined): ParsedP return calls; } -/** A plugin's raw hooks.json — still carrying `${CLAUDE_PLUGIN_ROOT}`, since the generated - * bridge resolves its scripts from its own `import.meta.url` and the outDir-relative rewrite - * every other flat target needs buys this one nothing — reduced to the three mapped events. - * Exported for the generator's own unit test. */ +/** Read source hooks; the generated bridge resolves scripts beside itself. */ export function parseHooksJsonForBridge(rawHooksJson: string): OpencodeHookTable { const parsed = JSON.parse(rawHooksJson) as ClaudeHooksShape; const hooks = parsed.hooks ?? {}; @@ -123,6 +120,7 @@ export function generateOpencodeHooksBridge(rawHooksJson: string, plugin: string // the mapping this generator applies and the measurements behind it. import { setupOpencodeEvents } from "../hooks/opencode-events.js"; import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; // Never process.execPath: OpenCode ships as its own standalone binary - that path names \`opencode\` @@ -240,11 +238,10 @@ function postToolUseCallsFor(call, directory) { export const ${ident} = async (input) => { // Sessions whose last Stop asked the model to continue: their next Stop carries stop_hook_active. const continued = new Set(); - // SessionStart's own approximation (module doc comment above): fired once here, never - // per session. Silent on purpose: a memory refresh - // that breaks OpenCode's own startup is worse than one that never ran. + const started = new Set(); + // Existing unmatched hooks are intentionally still run at initialization. try { - for (const hook of SESSION_START) { + for (const hook of SESSION_START.filter((hook) => hook.matcher !== "startup")) { runHook( hook.script, hook.args, @@ -258,7 +255,25 @@ export const ${ident} = async (input) => { return { event: async ({ event }) => { try { - const directory = event?.properties?.directory ?? input.directory; + const directory = event?.properties?.info?.directory ?? event?.properties?.directory ?? input.directory; + const id = event?.properties?.sessionID ?? event?.properties?.info?.id; + if (event?.type === "session.deleted") started.delete(id); + if (!input.skipStartup && event?.type === "session.created" && typeof id === "string" && !started.has(id)) { + started.add(id); + for (const hook of SESSION_START.filter((hook) => hook.matcher === "startup")) { + const { code, stdout } = await runCapture(hook.script, hook.args, + { hook_event_name: "SessionStart", session_id: id, cwd: directory, tool: "opencode" }, directory); + let message = ""; + if (code === 0) { + try { message = String(JSON.parse(stdout)?.systemMessage ?? "").trim(); } catch { message = ""; } + } + if (message && typeof input.client?.tui?.showToast === "function") { + await input.client.tui.showToast({ body: { message, variant: "info" } }); + } else if (message) { + console.warn("[aidd] OpenCode cannot display the startup message: " + message); + } + } + } for (const call of stopCallsFor(event, directory)) { const sessionId = call.payload.session_id; const payload = { ...call.payload, stop_hook_active: continued.has(sessionId) }; @@ -296,7 +311,10 @@ ${ident}.postToolUseCallsFor = postToolUseCallsFor; export default { id: ${JSON.stringify(`${plugin}-hooks`)}, server: ${ident}, - setup: (ctx) => setupOpencodeEvents(ctx, ${ident}), + setup: (ctx) => setupOpencodeEvents(ctx, (input) => ${ident}({ + ...input, + skipStartup: existsSync(fileURLToPath(new URL("../plugins/${plugin}-tui/tui.js", import.meta.url))), + })), }; `; } diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts index 7380bcbdd..022a76369 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -1,9 +1,27 @@ +import { flatHooksPathWithLoaderEntry } from "../../../../../kernel/materialization/flat-paths.js"; + /** Where OpenCode keeps what this CLI writes for it. Held apart from the profile so the build * contract can read the same values: the profile imports the contract, so the contract cannot * import the profile back. */ +import { flatGlobalFilePath } from "../../capabilities/plugins-capability.js"; + export const OPENCODE_DIRECTORY = ".opencode/"; +const flatMachinePath = flatGlobalFilePath(OPENCODE_DIRECTORY); +const maintenanceStartup = new Set([ + ".opencode/hooks/aidd-maintenance/session-start.cjs", + ".opencode/plugins/aidd-maintenance-tui/server.js", + ".opencode/plugins/aidd-maintenance-tui/tui.js", +]); + +/** The maintenance TUI and its script are self-contained; the shared event bridge stays project-scoped. */ +export const opencodeGlobalFilePath: ReturnType = (path, plugin) => { + if (plugin.name === "aidd-maintenance" && maintenanceStartup.has(path)) + return path.slice(OPENCODE_DIRECTORY.length); + return flatMachinePath(path, plugin); +}; + /** * The directory OpenCode's own plugin loader scans (`{plugin,plugins}/*.{ts,js}`, one level, no * `hooks` family). Not where a plugin's hook scripts land: only a script literally named @@ -23,6 +41,21 @@ export const OPENCODE_EVENTS_PATH = `${OPENCODE_HOOKS_DIR}${OPENCODE_EVENTS_ASSE * runtime the loader is meant to import — rather than a script an external bridge must run. */ export const OPENCODE_PLUGIN_ENTRY_BASENAME = "opencode-plugin.js"; +/** OpenCode V2 discovers adjacent server.js and tui.js as one native plugin. Other hooks + * remain in the normal per-plugin directory, shared by every materialization path. */ +export function opencodeFlatHooksPath(plugin: string, rel: string): string { + if (rel === "hooks/opencode-server.js" || rel === "hooks/opencode-tui.js") { + const entry = rel === "hooks/opencode-server.js" ? "server.js" : "tui.js"; + return `.opencode/plugins/${plugin}-tui/${entry}`; + } + return flatHooksPathWithLoaderEntry( + OPENCODE_HOOKS_DIR, + { dir: OPENCODE_FLAT_HOOKS_DIR, baseName: OPENCODE_PLUGIN_ENTRY_BASENAME }, + plugin, + rel + ); +} + /** Where a plugin's generated event bridge lands: flat, in the directory the loader scans, * renamed per plugin so two plugins each getting one cannot collide. Read by both * flat-materialization routes, so the path is one fact rather than two that could drift. */ diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index 6805756d9..62cd6023e 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -33,6 +33,8 @@ import { OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, + opencodeFlatHooksPath, + opencodeGlobalFilePath, } from "./opencode-paths.js"; const DIRECTORY = OPENCODE_DIRECTORY; @@ -106,8 +108,13 @@ export const opencode: AiTool< plugins: new PluginsCapability({ mode: "flat", flatNamespacePrefix: "aidd-", + globalFiles: { + dir: (homedir) => join(homedir, ".config", "opencode"), + map: opencodeGlobalFilePath, + }, acceptsHooks: true, flatHooksDir: OPENCODE_HOOKS_DIR, + flatHooksPath: opencodeFlatHooksPath, flatHooksLoaderEntry: { dir: OPENCODE_FLAT_HOOKS_DIR, baseName: OPENCODE_PLUGIN_ENTRY_BASENAME, diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index 237e60676..ef9e47d09 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -16,6 +16,7 @@ import { import type { ToolBuildContract } from "./build-contract.js"; import type { EnvironmentReader, + GlobalFiles, NativeActivation, PluginsCapability, } from "./capabilities/plugins-capability.js"; @@ -118,13 +119,38 @@ export function pluginEnablementIsMachineGlobal(toolId: ToolId): boolean { return nativeActivationOf(toolId)?.scopeArgs === undefined; } -/** Whether `--scope user` has anywhere to point this tool: either it drives its own CLI - * machine-wide, or it installs a plugin's files into a user-scope directory. `false` for a - * tool declaring neither — opencode today, with no such directory and no CLI to drive. */ +/** Whether `--scope user` has anywhere to point this tool: it drives its own CLI machine-wide, + * installs a plugin's files into a user-scope directory, or copies global files into the + * user's configuration directory. `false` for a tool declaring none. */ export function supportsUserScopeActivation(toolId: ToolId): boolean { const capability = resolvePluginsCapability(toolId); if (capability === null) return false; - return capability.nativeActivation !== null || capability.installScope === "user"; + return ( + capability.nativeActivation !== null || + capability.installScope === "user" || + capability.globalFiles !== null + ); +} + +/** Whether a ref the host's registry lists is a plugin in use: an enabled one always; a disabled + * one unless the host's uninstall itself leaves refs disabled, where the two cannot be told + * apart and the ref is taken for what an uninstall left behind. */ +export function hostRefInUse(toolId: ToolId, enabled: boolean): boolean { + return enabled || nativeActivationOf(toolId)?.uninstallLeavesRefDisabled !== true; +} + +/** Whether `setup --global` writes a plugin's files itself for this tool, into its user plugins + * directory or its user configuration directory, instead of only enabling the plugin through + * the tool's own CLI. */ +export function copiesPluginFilesGlobally(toolId: ToolId): boolean { + const capability = resolvePluginsCapability(toolId); + if (capability === null) return false; + return capability.installScope === "user" || capability.globalFiles !== null; +} + +/** See {@link GlobalFiles}: `null` for a tool whose machine-scope install copies no file. */ +export function globalFilesOf(toolId: ToolId): GlobalFiles | null { + return resolvePluginsCapability(toolId)?.globalFiles ?? null; } /** The tool's declared build contract for one framework-build mode, or undefined when the tool diff --git a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts index 109588375..4c58e922a 100644 --- a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts @@ -1,4 +1,5 @@ import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import { homedir } from "node:os"; import { NativePluginCliError } from "../../../kernel/errors.js"; import type { MarketplaceScope } from "../../../kernel/scope.js"; import type { NativePluginActivator } from "../domain/ports/native-plugin-activator.js"; @@ -28,9 +29,15 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi * quoted; anything else is spawned by its bare name. */ private spawn( args: readonly string[], - stdio: ["ignore", "ignore" | "pipe", "ignore" | "pipe"] + stdio: ["ignore", "ignore" | "pipe", "ignore" | "pipe"], + cwd?: string ): SpawnSyncReturns { - const options = { timeout: COMMAND_TIMEOUT_MS, stdio, encoding: "utf-8" as const }; + const options = { + timeout: COMMAND_TIMEOUT_MS, + stdio, + encoding: "utf-8" as const, + ...(cwd === undefined ? {} : { cwd }), + }; const executable = resolveExecutableOnPath(this.binary, hostExecutableLookup()); if (executable !== undefined && runsThroughShell(executable)) { return spawnSync(windowsCommandLine(executable, args), { ...options, shell: true }); @@ -42,6 +49,9 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi protected abstract scopeArgsFor(scope: MarketplaceScope): readonly string[]; /** Arguments that force a removal past installed plugins, empty when unsupported. */ protected abstract forceRemoveArgs(): readonly string[]; + /** Scope arguments a user-scope remove takes instead, run from the home directory; + * `undefined` when the user scope's own arguments undo the add. */ + protected abstract userRemoveArgs(): readonly string[] | undefined; addMarketplace(source: string, scope: MarketplaceScope): void { this.run( @@ -52,12 +62,41 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi removeMarketplace(name: string, scope: MarketplaceScope, options?: { force?: boolean }): void { const force = options?.force === true ? this.forceRemoveArgs() : []; + const userArgs = scope === "user" ? this.userRemoveArgs() : undefined; + // Removing from every scope also uninstalls what a project installed from this marketplace, + // with its saved options: only a marketplace no project uses goes that far. + const everywhere = userArgs !== undefined && !this.usedAtAnotherScope(name); this.run( - ["plugin", "marketplace", "remove", name, ...this.scopeArgsFor(scope), ...force], - `marketplace remove ${name}` + [ + "plugin", + "marketplace", + "remove", + name, + ...(everywhere ? userArgs : this.scopeArgsFor(scope)), + ...force, + ], + `marketplace remove ${name}`, + everywhere ? homedir() : undefined ); } + /** Whether a plugin of this marketplace is installed at a scope other than the user's, in any + * project. A list it cannot read counts as yes, since that keeps the narrower removal. */ + private usedAtAnotherScope(name: string): boolean { + const result = this.spawn( + ["plugin", "list", "--json"], + ["ignore", "pipe", "ignore"], + homedir() + ); + if (result.error !== undefined || result.status !== 0) return true; + try { + const plugins = JSON.parse(result.stdout) as { id?: string; scope?: string }[]; + return plugins.some((plugin) => plugin.id?.endsWith(`@${name}`) && plugin.scope !== "user"); + } catch { + return true; + } + } + abstract enablesPlugins(): boolean; abstract registrationState(name: string): "live" | "dead" | "unknown"; abstract upgradeMarketplaces(name: string): void; @@ -70,8 +109,8 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi return result.error === undefined && result.status === 0; } - protected run(args: readonly string[], label: string): void { - const result = this.spawn(args, ["ignore", "pipe", "pipe"]); + protected run(args: readonly string[], label: string, cwd?: string): void { + const result = this.spawn(args, ["ignore", "pipe", "pipe"], cwd); if (result.error) { throw new NativePluginCliError(`${this.binary} ${label} failed: ${result.error.message}`); } diff --git a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts index 60d84bd63..7e25faa84 100644 --- a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts @@ -60,9 +60,8 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { return failedRead(this.path, error); } try { - const parsed = JSON.parse(content) as { plugins?: Record }; - const plugins = parsed.plugins; - if (plugins === undefined || typeof plugins !== "object") { + const plugins = parseClaudePlugins(content); + if (plugins === undefined) { return { location: this.path, unreadable: "no `plugins` object" }; } const refs = new Map(); @@ -78,6 +77,46 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { } } +/** Claude's recorded installations sharing a marketplace. Presence does not prove enablement. */ +export async function claudeInstallations( + configDir: string, + catalogue: string +): Promise<{ ref: string; scope: "user" | "project" | "local"; projectPath?: string }[]> { + const content = await readFile(join(configDir, "plugins", "installed_plugins.json"), "utf8"); + const plugins = parseClaudePlugins(content); + if (!plugins) throw new Error("Cannot inspect Claude's installed plugins."); + const installations: { + ref: string; + scope: "user" | "project" | "local"; + projectPath?: string; + }[] = []; + for (const [ref, entries] of Object.entries(plugins).sort(([a], [b]) => a.localeCompare(b))) { + if (!ref.endsWith(`@${catalogue}`)) continue; + if (!Array.isArray(entries)) throw new Error(`Cannot inspect Claude installation '${ref}'.`); + for (const entry of entries) { + if (entry?.scope === "user") installations.push({ ref, scope: "user" }); + else if ( + (entry?.scope === "local" || entry?.scope === "project") && + typeof entry.projectPath === "string" + ) + installations.push({ + ref, + scope: entry.scope, + projectPath: await resolvedPath(entry.projectPath), + }); + else throw new Error(`Cannot inspect Claude installation '${ref}'.`); + } + } + return installations; +} + +function parseClaudePlugins(content: string): Record | undefined { + const parsed = JSON.parse(content) as { plugins?: Record }; + return parsed.plugins && typeof parsed.plugins === "object" && !Array.isArray(parsed.plugins) + ? parsed.plugins + : undefined; +} + /** * Codex's own registry, line-scanned rather than parsed: the file carries arbitrary nested * tables and multi-line values this adapter has no business understanding, and most of it is diff --git a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts index dca4ee676..5998b5b19 100644 --- a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts @@ -4,10 +4,12 @@ import { AbstractNativePluginCliAdapter } from "./abstract-native-plugin-cli-ada /** Everything about a tool's plugin CLI that differs between tools, read off its profile. */ export interface NativePluginCliShape { readonly scopeArgs?: Readonly>; + readonly userRemoveArgs?: readonly string[]; readonly forceRemoveArgs?: readonly string[]; readonly sourceCheckVerb?: string; readonly upgradeVerb?: string; readonly updateVerb?: string; + readonly releaseUpdateVerb?: string; readonly enableVerb?: string; /** How the tool spells removing a plugin it installed: `remove` for codex, `uninstall` for * claude and copilot. Absent where this CLI enables plugins through a file it writes. */ @@ -67,6 +69,16 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { this.run(["plugin", verb, pluginRef], `plugin ${verb} ${pluginRef}`); } + refreshReleasePlugin(pluginRef: string, scope: MarketplaceScope, projectRoot: string): void { + const verb = this.shape.releaseUpdateVerb; + if (verb === undefined) throw new Error(`${this.binary} does not support release refresh.`); + this.run( + ["plugin", verb, pluginRef, ...this.scopeArgsFor(scope)], + `plugin ${verb} ${pluginRef}`, + projectRoot + ); + } + /** Undoes what `enablePlugin` did. `scope` must match what `enablePlugin` was called with: a * tool that installed at one scope and uninstalls at its default would silently miss the * entry it wrote. */ @@ -86,4 +98,8 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { protected forceRemoveArgs(): readonly string[] { return this.shape.forceRemoveArgs ?? []; } + + protected userRemoveArgs(): readonly string[] | undefined { + return this.shape.userRemoveArgs; + } } diff --git a/cli/src/contexts/translate/domain/content-translator.ts b/cli/src/contexts/translate/domain/content-translator.ts index b688db564..a01114644 100644 --- a/cli/src/contexts/translate/domain/content-translator.ts +++ b/cli/src/contexts/translate/domain/content-translator.ts @@ -278,12 +278,13 @@ export class PluginContentTranslator { .filter((file) => file.relativePath !== `${PLUGIN_HOOKS_DIR}/hooks.json`) .map((file) => this.makeFile( - flatHooksPathWithLoaderEntry( - flatHooksDir, - flatHooksLoaderEntry, - dist.manifest.name, - file.relativePath - ), + tool.capabilities.plugins.flatHooksPath?.(dist.manifest.name, file.relativePath) ?? + flatHooksPathWithLoaderEntry( + flatHooksDir, + flatHooksLoaderEntry, + dist.manifest.name, + file.relativePath + ), file.content ) ); diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index 9b95ed1f7..1b2461350 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -484,9 +484,9 @@ export class UserScopeUnavailableError extends Error { export class UserScopeIdeToolsError extends Error { constructor(ideTools: readonly string[]) { super( - `--scope user installs no project files, so an IDE tool (${ideTools.join(", ")}) has ` + - "nothing to install at user scope. Drop --ide, or run `aidd setup --ide ` " + - "separately at project scope." + `--global installs no project files, so an editor (${ideTools.join(", ")}) has ` + + "nothing to install machine-wide. Drop it from --ide, or run `aidd setup --ide ` " + + "separately in the project." ); this.name = "UserScopeIdeToolsError"; } @@ -495,9 +495,8 @@ export class UserScopeIdeToolsError extends Error { export class UserScopeUnsupportedAiToolsError extends Error { constructor(aiTools: readonly string[]) { super( - `--scope user drives native activation machine-wide, and ${aiTools.join(", ")} declares ` + - "no user-scope settings this CLI can point at. Drop it from --ai, or run " + - "`aidd setup --ai ` separately at project scope." + `--global installs through an IDE's own plugin CLI or its user configuration directory, and neither exists for: ${aiTools.join(", ")}. ` + + "Drop them from --ide, or run `aidd setup --ide ` in the project instead." ); this.name = "UserScopeUnsupportedAiToolsError"; } @@ -506,8 +505,8 @@ export class UserScopeUnsupportedAiToolsError extends Error { export class UserScopeNoToolsError extends Error { constructor() { super( - "--scope user with no --ai registers the shared source for no tool at all. Pass " + - "`--ai ` naming which tool to activate machine-wide." + "--global with no --ide registers the shared source for no tool at all. Pass " + + "`--ide ` naming which tool to activate machine-wide." ); this.name = "UserScopeNoToolsError"; } @@ -516,9 +515,8 @@ export class UserScopeNoToolsError extends Error { export class UserScopePluginModeError extends Error { constructor() { super( - "--scope user has no manifest entry a plugin can be recorded against yet, so " + - "--plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` " + - "separately at project scope." + "--global cannot ask which plugins to install. Pass --plugins all, recommended, " + + "none, or comma-separated names." ); this.name = "UserScopePluginModeError"; } diff --git a/cli/src/kernel/paths.ts b/cli/src/kernel/paths.ts index 43107d5ac..6dcd7b439 100644 --- a/cli/src/kernel/paths.ts +++ b/cli/src/kernel/paths.ts @@ -18,6 +18,18 @@ export function marketplaceCacheDir(projectRoot: string, marketplaceName: string return join(projectRoot, MARKETPLACE_CACHE_SUBDIR, marketplaceName); } +/** Where a marketplace's fetched source lives: a user-scope one belongs to the whole machine, so a + * run from any directory writes nothing there. */ +export function fetchedMarketplaceDir( + marketplace: { readonly name: string; readonly scope: string }, + projectRoot: string, + userConfigDir: string | undefined +): string { + return marketplace.scope === "user" && userConfigDir !== undefined + ? join(userConfigDir, "cache", "marketplaces", marketplace.name) + : marketplaceCacheDir(projectRoot, marketplace.name); +} + export function builtMarketplaceDir( projectRoot: string, marketplaceName: string, diff --git a/cli/src/kernel/ports/file-reader.ts b/cli/src/kernel/ports/file-reader.ts index 5f673d041..ecd949863 100644 --- a/cli/src/kernel/ports/file-reader.ts +++ b/cli/src/kernel/ports/file-reader.ts @@ -7,6 +7,8 @@ export interface FileReader { * platform, so Windows' native separator must never reach a caller. */ listDirectory(path: string): Promise; fileExists(path: string): Promise; + /** Whether a directory entry exists, including a dangling symbolic link; required for restoration. */ + entryExists?(path: string): Promise; readFileHash(path: string): Promise; listFilesRecursive(dirPath: string): Promise; diff --git a/cli/src/presentation/commands/clean.ts b/cli/src/presentation/commands/clean.ts index 081434994..91e608ad4 100644 --- a/cli/src/presentation/commands/clean.ts +++ b/cli/src/presentation/commands/clean.ts @@ -3,13 +3,14 @@ import { createDeps } from "../../runtime/wiring/framework.js"; import { printProjectCleanOutcome, printUserScopeCleanOutcome } from "../display/clean-display.js"; import { ErrorHandler } from "../error-handler.js"; import type { CLIOutput } from "../output.js"; -import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; +import { GLOBAL_FLAG_HELP, parseGlobalOptions, resolveScopeFlags } from "./global-options.js"; type Deps = Awaited>; interface CleanCmdOptions { force: boolean; scope?: string; + global?: boolean; } async function runProjectScopeClean( @@ -56,10 +57,11 @@ export function registerCleanCommand(program: Command): void { "project (default) cleans this project alone; user undoes the machine-wide " + "registration setup --scope user wrote and purges the shared source itself" ) + .option("--global", `${GLOBAL_FLAG_HELP}, and purge the shared source`) .action(async (cmdOptions: CleanCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); - const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + const scope = resolveScopeFlags(cmdOptions, output) ?? "project"; try { const deps = await createDeps(projectRoot, { verbose }, output); diff --git a/cli/src/presentation/commands/doctor.ts b/cli/src/presentation/commands/doctor.ts index de8aba506..c3c493b9f 100644 --- a/cli/src/presentation/commands/doctor.ts +++ b/cli/src/presentation/commands/doctor.ts @@ -16,7 +16,7 @@ import { } from "../display/doctor-display.js"; import { ErrorHandler } from "../error-handler.js"; import type { CLIOutput } from "../output.js"; -import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; +import { GLOBAL_FLAG_HELP, parseGlobalOptions, resolveScopeFlags } from "./global-options.js"; type Deps = Awaited>; @@ -109,14 +109,14 @@ async function runUserScopeDoctor( projectRoot: string, cmdOptions: DoctorCmdOptions ): Promise { - // No plugin is tracked at user scope yet — there is nothing `--plugin` could narrow — - // so it is refused rather than silently read and discarded. + // At user scope doctor checks registrations, not plugin files — there is nothing `--plugin` + // could narrow — so it is refused rather than silently read and discarded. if (cmdOptions.plugin !== undefined) { throw new UserScopeFilterUnsupportedError("--plugin", "doctor --plugin "); } const manifest = await deps.userManifestRepo.load(); if (manifest === null) { - output.success("Nothing registered at user scope yet — run `aidd setup --scope user` first."); + output.success("Nothing registered at user scope yet — run `aidd setup --global` first."); return; } const toolId = cmdOptions.tool as ToolId | undefined; @@ -139,6 +139,7 @@ async function runUserScopeDoctor( manifest, projectRoot, allowedIds, + globalInstall: true, }); printScopeIssues(output, "User scope", { issues }); const healthy = issues.every((i) => i.severity !== "error"); @@ -153,6 +154,7 @@ interface DoctorCmdOptions { tool?: string; plugin?: string; scope?: string; + global?: boolean; } export function registerDoctorCommand(program: Command): void { @@ -168,12 +170,13 @@ export function registerDoctorCommand(program: Command): void { "project (default) checks this project's own manifest; user checks the " + "machine-wide manifest --scope user setup wrote" ) + .option("--global", GLOBAL_FLAG_HELP) .action(async (cmdOptions: DoctorCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { const deps = await createDeps(projectRoot, { verbose }, output); - const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + const scope = resolveScopeFlags(cmdOptions, output) ?? "project"; if (scope === "user") { await runUserScopeDoctor(deps, output, projectRoot, cmdOptions); } else if (cmdOptions.tool !== undefined) { diff --git a/cli/src/presentation/commands/framework.ts b/cli/src/presentation/commands/framework.ts index 506a4e849..778dc4b7f 100644 --- a/cli/src/presentation/commands/framework.ts +++ b/cli/src/presentation/commands/framework.ts @@ -4,6 +4,7 @@ import { Manifest } from "../../contexts/framework/domain/manifest.js"; import { isIdeToolId } from "../../contexts/tools/domain/registry.js"; import type { AiToolId, IdeToolId, ToolId } from "../../kernel/tool.js"; import { isAiToolId, VALID_TOOL_IDS } from "../../kernel/tool.js"; +import { assertMachineConfigDirectorySupported } from "../../runtime/machine-config-directory.js"; import { createDeps } from "../../runtime/wiring/framework.js"; import { printToolAlreadyInstalled, @@ -22,6 +23,8 @@ import { parseGlobalOptions } from "./global-options.js"; import { reportSyncActivation } from "./sync-native-activation.js"; type Deps = Awaited>; +type UpgradeOptions = Parameters[0]; +type FrameworkUpgradePreview = Parameters[0]; export function assertKnownToolId(toolId: string): asserts toolId is ToolId { if (!isAiToolId(toolId) && !isIdeToolId(toolId)) { @@ -162,6 +165,168 @@ async function runFrameworkUpdate( ); } +function printReleaseLinks( + output: CLIOutput, + release: string, + currentRefs: readonly string[], + title: string | null +): void { + const repository = "https://github.com/ai-driven-dev/framework"; + output.info(title ? `Release title: ${title}` : "Release title unavailable."); + output.info(`Release notes: ${repository}/releases/tag/${encodeURIComponent(release)}`); + const distinctRefs = [...new Set(currentRefs)].filter((ref) => ref !== release); + if (distinctRefs.length === 0) return; + output.info( + "Repository changes between current tags (not limited to installed plugins; tags can move):" + ); + for (const ref of distinctRefs) { + output.info( + ` ${ref} -> ${release}: ${repository}/compare/${encodeURIComponent(ref)}...${encodeURIComponent(release)}` + ); + } +} + +function printReleasePreview( + output: CLIOutput, + projectRoot: string, + preview: FrameworkUpgradePreview +): void { + output.info(`Project: ${projectRoot}`); + output.info(`Marketplace: ${preview.currentRef} -> ${preview.release}`); + output.info("Installed project plugins:"); + for (const target of preview.targets) { + const version = target.targetVersion + ? `${target.currentVersion} -> ${target.targetVersion}` + : target.currentVersion; + output.info( + ` ${target.toolId}:${target.pluginName} (plugin ${version}; source ${target.currentRef} -> ${preview.release})` + ); + } + printReleaseLinks( + output, + preview.release, + [preview.currentRef, ...preview.targets.map((target) => target.currentRef)], + preview.title + ); + if (preview.globalPlugins?.length) { + output.info("Global AIDD CLI installations (not updated by this command):"); + for (const plugin of preview.globalPlugins) { + const source = plugin.source; + const sourceName = + source.kind === "git-subdir" && + source.url === "https://github.com/ai-driven-dev/framework.git" + ? `ai-driven-dev/framework@${source.ref ?? "unknown"}` + : `${source.kind} (source unverified)`; + output.info( + ` ${plugin.toolId}:${plugin.pluginName} (plugin ${plugin.currentVersion}; source ${sourceName})` + ); + } + } + if (preview.unverifiedUserRefs) { + output.warn( + `Project references to user-scope plugins not verified in the machine manifest: ${preview.unverifiedUserRefs}.` + ); + } + output.info("Native user installations are not fully inventoried by this command."); + if (preview.impact?.length) { + output.info("Claude installations sharing this marketplace:"); + for (const entry of preview.impact) + output.info( + ` ${entry.ref} (${entry.scope}${entry.projectPath ? `, ${entry.projectPath}` : ""})` + ); + output.info( + "Their shared catalogue files may change. Other dependents may exist; enabled states are unverified." + ); + } + if (preview.shared) output.warn("This marketplace is shared; other projects may be affected."); +} + +async function runFrameworkReleaseUpdate( + deps: Deps, + output: CLIOutput, + projectRoot: string, + options: { release: string; yes: boolean; allowShared: boolean } +): Promise { + const interactive = process.stdout.isTTY ?? false; + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot, + release: options.release, + allowShared: options.allowShared || (interactive && !options.yes), + confirm: async (preview) => { + printReleasePreview(output, projectRoot, preview); + if (options.yes) return true; + if (!interactive) + throw new Error( + "A release update requires confirmation; pass --yes after reviewing its impact." + ); + return deps.prompter.confirm( + "Update these project plugins and the displayed marketplace?", + false + ); + }, + }); + if (result.cancelled) { + output.info("Update cancelled; no installation changes were made."); + return; + } + output.success( + `Updated project plugin sources to ${options.release}: ${result.updated.join(", ")}.` + ); + output.info( + "Restart the affected tools to load the updated plugins. Session reload and every component were not verified." + ); +} + +async function runUserFrameworkReleaseUpdate( + deps: Deps, + output: CLIOutput, + projectRoot: string, + options: { release: string; yes: boolean } +): Promise { + assertMachineConfigDirectorySupported(); + const interactive = process.stdout.isTTY ?? false; + const result = await deps.upgradeUserFrameworkUseCase.execute({ + projectRoot, + release: options.release, + confirm: async (preview) => { + output.info(`Machine release: ${preview.release}`); + for (const target of preview.targets) { + output.info( + ` ${target.toolId}:${target.name} (plugin ${target.currentVersion} -> ${target.targetVersion}; source ${target.currentRef} -> ${preview.release})` + ); + if (target.dependents.length) + output.info(` Dependent projects: ${target.dependents.join(", ")}`); + } + printReleaseLinks( + output, + preview.release, + preview.targets.map((target) => target.currentRef), + preview.title + ); + if (preview.nativeSkipped) + output.warn( + `${preview.nativeSkipped} native plugin claims are not updated by this command.` + ); + if (options.yes) return true; + if (!interactive) + throw new Error( + "A release update requires confirmation; pass --yes after reviewing its impact." + ); + return deps.prompter.confirm("Update these machine plugin copies?", false); + }, + }); + if (result.cancelled) { + output.info("Update cancelled; no installation changes were made."); + return; + } + output.success( + `Updated machine plugin sources to ${options.release}: ${result.updated.join(", ")}.` + ); + output.info( + "Restart the affected tools to load updated plugins; dependent projects may need a refresh." + ); +} + export function registerFrameworkCommand(program: Command): void { const framework = program .command("framework") @@ -205,27 +370,69 @@ export function registerFrameworkCommand(program: Command): void { framework .command("update") - .description( - "Re-install tool configs from bundled CLI assets, moving to a new version (all installed tools if --tool is omitted; see `marketplace refresh`, which re-fetches catalogs instead)" - ) + .description("Update installed tool configs from the CLI, or plugins with --release") .option("--tool ", "Limit update to a specific AI or IDE tool") .option("-f, --force", "Overwrite modified files without prompting", false) - .action(async (cmdOptions: { tool?: string; force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - await runFrameworkUpdate( - deps, - output, - projectRoot, - cmdOptions.tool as ToolId | undefined, - cmdOptions - ); - } catch (error) { - errorHandler.handle(error); + .option("--release ", "Update plugins to a root release, e.g. v5.13.0") + .option( + "--scope ", + "Choose project or machine plugin installations (with --release)" + ) + .option("--yes", "Confirm the displayed release update without prompting", false) + .option("--allow-shared", "Confirm a marketplace change shared with other projects", false) + .action( + async (cmdOptions: { + tool?: string; + force: boolean; + release?: string; + scope?: string; + yes: boolean; + allowShared: boolean; + }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + if (!cmdOptions.release && (cmdOptions.yes || cmdOptions.allowShared)) + throw new Error("--yes and --allow-shared require --release."); + if ( + cmdOptions.scope !== undefined && + cmdOptions.scope !== "project" && + cmdOptions.scope !== "user" + ) + throw new Error("--scope must be project or user."); + if (cmdOptions.scope && !cmdOptions.release) + throw new Error("--scope requires --release."); + if (cmdOptions.scope === "user" && cmdOptions.allowShared) + throw new Error("--allow-shared is not needed for machine plugin copies."); + if (cmdOptions.release && (cmdOptions.tool || cmdOptions.force)) + throw new Error("--tool and --force cannot be combined with --release."); + const deps = await createDeps(projectRoot, { verbose }, output); + if (cmdOptions.release) { + if (cmdOptions.scope === "user") + await runUserFrameworkReleaseUpdate(deps, output, projectRoot, { + release: cmdOptions.release, + yes: cmdOptions.yes, + }); + else + await runFrameworkReleaseUpdate(deps, output, projectRoot, { + release: cmdOptions.release, + yes: cmdOptions.yes, + allowShared: cmdOptions.allowShared, + }); + return; + } + await runFrameworkUpdate( + deps, + output, + projectRoot, + cmdOptions.tool as ToolId | undefined, + cmdOptions + ); + } catch (error) { + errorHandler.handle(error); + } } - }); + ); framework .command("rules") diff --git a/cli/src/presentation/commands/global-options.ts b/cli/src/presentation/commands/global-options.ts index c5f31167e..efb2091fd 100644 --- a/cli/src/presentation/commands/global-options.ts +++ b/cli/src/presentation/commands/global-options.ts @@ -20,6 +20,16 @@ export function parseGlobalOptions(program: Command): GlobalOptions { /** Returns the flag as given, `undefined` included: each caller applies its own default * rather than this validation guessing one for all of them. */ +/** `--global` is the spelling a person types; `--scope user` stays for scripts. */ +export const GLOBAL_FLAG_HELP = "act on the machine-wide install `setup --global` wrote"; + +export function resolveScopeFlags( + options: { scope?: string; global?: boolean }, + output: CLIOutput +): MarketplaceScope | undefined { + return options.global === true ? "user" : parseScopeFlag(options.scope, output); +} + export function parseScopeFlag( raw: string | undefined, output: CLIOutput diff --git a/cli/src/presentation/commands/marketplace.ts b/cli/src/presentation/commands/marketplace.ts index 86294c1b2..bfeb6e5ce 100644 --- a/cli/src/presentation/commands/marketplace.ts +++ b/cli/src/presentation/commands/marketplace.ts @@ -40,7 +40,7 @@ export function registerMarketplaceCommand(program: Command): void { .option("--scope ", "Registration scope (default: project)", "project") .option("--yes", "Skip the trust + cleanup prompts") .option("--overwrite", "Replace an existing marketplace with the same name") - .option("--token ", "Auth token (host detected from source URL at fetch time)") + .option("--token ", "GitHub token for this operation") .action( async ( nameArg: string | undefined, diff --git a/cli/src/presentation/commands/plugin.ts b/cli/src/presentation/commands/plugin.ts index 15e69dd72..02483079b 100644 --- a/cli/src/presentation/commands/plugin.ts +++ b/cli/src/presentation/commands/plugin.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { parseInstallScope } from "../../contexts/framework/domain/install-scope.js"; -import { assertValidAiToolId, parseToolOption } from "../../kernel/tool.js"; +import { type AiToolId, assertValidAiToolId, parseToolOption } from "../../kernel/tool.js"; +import { assertMachineConfigDirectorySupported } from "../../runtime/machine-config-directory.js"; import { createDeps, createMenuDeps } from "../../runtime/wiring/framework.js"; import { printInstalledPlugins, @@ -82,7 +83,7 @@ export function registerPluginCommand(program: Command): void { .description("Install a plugin (marketplace name, local path, or interactive pick)") .option("--from ", "Marketplace name (when multiple match)") .option("--tool ", "Target AI tool (default: all installed)") - .option("--token ", "Auth token (host detected from source URL at fetch time)") + .option("--token ", "GitHub token for this operation") .option("--scope ", "Install scope; must match the tool's supported scope") .option("--yes", "Auto-resolve interactive prompts (CI mode)") .action( @@ -158,6 +159,23 @@ export function registerPluginCommand(program: Command): void { assertValidAiToolId(cmdOptions.tool); const scope = parseInstallScope(cmdOptions.scope) ?? "project"; const deps = await createDeps(projectRoot, { verbose }, output); + if (scope === "user") { + if (cmdOptions.tool !== undefined) { + assertMachineConfigDirectorySupported([cmdOptions.tool]); + } else { + const machine = await deps.userManifestRepo.load(); + assertMachineConfigDirectorySupported( + (machine?.getInstalledToolIds() ?? []).filter((tool) => + machine + ?.getPlugins(tool as AiToolId) + .some( + (plugin) => + plugin.scope === "user" && (name === undefined || plugin.name === name) + ) + ) + ); + } + } const updated = await (scope === "user" ? deps.userPluginUpdateUseCase : deps.pluginUpdateUseCase diff --git a/cli/src/presentation/commands/run/command.ts b/cli/src/presentation/commands/run/command.ts new file mode 100644 index 000000000..55f56b82d --- /dev/null +++ b/cli/src/presentation/commands/run/command.ts @@ -0,0 +1,199 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir, constants as osConstants } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import type { Command } from "commander"; +import spawn from "cross-spawn"; +import { Manifest } from "../../../contexts/framework/domain/manifest.js"; +import { userConfigDir } from "../../../runtime/user-config-dir.js"; + +import { headlessRunTools } from "../../../runtime/wiring/tools.js"; + +type Tool = keyof typeof headlessRunTools; + +function readManifest(path: string): Manifest | null { + if (!existsSync(path)) return null; + return Manifest.fromJSON(JSON.parse(readFileSync(path, "utf8"))); +} + +function projectManifest(cwd: string): { root: string; manifest: Manifest } | null { + let root = resolve(cwd); + while (true) { + const manifest = readManifest(join(root, ".aidd", "manifest.json")); + if (manifest) return { root, manifest }; + if (existsSync(join(root, ".git"))) return null; + const parent = dirname(root); + if (parent === root) return null; + root = parent; + } +} + +function hasMaintenanceFiles(root: string): boolean { + return [ + "hooks/aidd-maintenance/session-start.cjs", + "plugin/aidd-maintenance-hooks.js", + "plugins/aidd-maintenance-tui/server.js", + ].some((path) => existsSync(join(root, path))); +} + +function hasProjectMaintenance(cwd: string, tool: Tool): boolean { + let root = resolve(cwd); + while (true) { + if (hasMaintenanceFiles(join(root, `.${tool}`))) return true; + if (existsSync(join(root, ".git"))) return false; + const parent = dirname(root); + if (parent === root) return false; + root = parent; + } +} + +function installedHook(tool: Tool, cwd: string): string { + const override = headlessRunTools[tool].configDirEnv; + if (process.env[override]) + throw new Error(`Cannot verify the active maintenance plugin while ${override} is set.`); + const globalRoot = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), tool); + if (hasMaintenanceFiles(globalRoot) && hasProjectMaintenance(cwd, tool)) + throw new Error( + `Cannot determine the active maintenance plugin for ${tool}: project and global copies coexist.` + ); + const project = projectManifest(cwd); + const projectPlugin = project?.manifest + .getPlugins(tool) + .find((p) => p.name === "aidd-maintenance"); + const global = + projectPlugin?.scope === "project" + ? null + : readManifest(join(userConfigDir(), "manifest.json")); + const plugin = + projectPlugin?.scope === "project" + ? projectPlugin + : global?.getPlugins(tool).find((p) => p.name === "aidd-maintenance" && p.scope === "user"); + const rel = + plugin?.scope === "project" + ? `.${tool}/hooks/aidd-maintenance/session-start.cjs` + : "hooks/aidd-maintenance/session-start.cjs"; + const expected = plugin?.files.get(rel); + if (!plugin || !expected) + throw new Error(`The aidd-maintenance hook is not installed for ${tool}.`); + const root = plugin.scope === "project" && project ? project.root : globalRoot; + const path = join(root, rel); + if (!existsSync(path)) throw new Error(`The aidd-maintenance hook is not installed for ${tool}.`); + const hash = createHash("md5").update(readFileSync(path)).digest("hex"); + if (hash !== expected) + throw new Error(`The installed aidd-maintenance hook for ${tool} was modified.`); + return path; +} + +function directoryFor(tool: Tool, args: readonly string[]): string { + const settings = headlessRunTools[tool]; + if (settings.pureEnv && process.env[settings.pureEnv] === "1") + throw new Error(`${tool} pure mode disables the maintenance plugin.`); + let directory = process.cwd(); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--") break; + if ( + arg === "--attach" || + arg.startsWith("--attach=") || + arg === "--server" || + arg.startsWith("--server=") + ) + throw new Error("Remote servers are not supported by aidd run."); + if (settings.interactiveFlags.includes(arg)) + throw new Error(`Use ${tool} directly for interactive sessions.`); + if (settings.pureFlag && (arg === settings.pureFlag || arg === `${settings.pureFlag}=true`)) + throw new Error(`${tool} pure mode disables the maintenance plugin.`); + if (settings.directoryArg && arg === settings.directoryArg) { + if (!args[i + 1]) throw new Error("--dir requires a directory."); + directory = args[++i]; + } else if (settings.directoryArg && arg.startsWith(`${settings.directoryArg}=`)) + directory = arg.slice(settings.directoryArg.length + 1); + } + return isAbsolute(directory) ? directory : resolve(directory); +} + +function printNotice(tool: Tool, cwd: string): void { + const script = installedHook(tool, cwd); + const result = spawnSync(process.execPath, [script], { + cwd, + input: JSON.stringify({ hook_event_name: "SessionStart", cwd, tool }), + encoding: "utf8", + timeout: 10000, + }); + const code = (result.error as NodeJS.ErrnoException | undefined)?.code; + const closedInput = code === "EPIPE"; + if (result.status !== 0 || (result.error && !closedInput)) { + const details = [ + code && Object.hasOwn(osConstants.errno, code) ? code : result.error ? "unknown error" : null, + Number.isInteger(result.status) ? `exit ${result.status}` : null, + result.signal && Object.hasOwn(osConstants.signals, result.signal) + ? `signal ${result.signal}` + : null, + ].filter(Boolean); + throw new Error( + `The aidd-maintenance hook failed for ${tool}${details.length ? ` (${details.join(", ")})` : ""}.` + ); + } + let message: unknown; + try { + message = (JSON.parse(result.stdout) as { systemMessage?: unknown }).systemMessage; + } catch { + throw new Error(`Invalid aidd-maintenance hook output for ${tool}.`); + } + if (typeof message !== "string" || !message.trim()) + throw new Error(`Empty aidd-maintenance hook output for ${tool}.`); + process.stderr.write(`${message.trim()}\n`); +} + +async function run(tool: Tool, args: readonly string[]): Promise { + const cwd = directoryFor(tool, args); + printNotice(tool, cwd); + await new Promise((resolvePromise) => { + const child = spawn(tool, ["run", ...args], { stdio: "inherit" }); + let failed = false; + const signals = ["SIGINT", "SIGTERM"] as const; + const listeners = signals.map((signal) => { + const listener = () => { + child.kill(signal); + }; + process.on(signal, listener); + return listener; + }); + child.once("error", (error) => { + failed = true; + process.stderr.write(`Unable to start ${tool}: ${error.message}\n`); + process.exitCode = 1; + }); + child.once("close", (code, signal) => { + signals.forEach((name, i) => { + process.off(name, listeners[i]); + }); + if (failed) { + resolvePromise(); + return; + } + if (signal && process.platform !== "win32") process.kill(process.pid, signal); + else if (signal) process.exitCode = 1; + else if (code !== null) process.exitCode = code; + resolvePromise(); + }); + }); +} + +export function registerRunCommand(program: Command): void { + program + .command("run [args...]") + .description("Run OpenCode or Kilo non-interactively with the installed maintenance notice") + .allowUnknownOption() + .action(async (tool: string, args: string[]) => { + try { + if (!Object.hasOwn(headlessRunTools, tool)) + throw new Error(`Supported tools: ${Object.keys(headlessRunTools).join(", ")}.`); + await run(tool as Tool, args); + } catch (error) { + process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts index b789ffe52..bf9ed8be0 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -1,15 +1,19 @@ import { resolve } from "node:path"; -import type { Command } from "commander"; +import { type Command, Option } from "commander"; import { MarketplaceSourceMode } from "../../contexts/distribution/domain/marketplace-source-mode.js"; import { SetupUseCase } from "../../contexts/framework/application/setup-use-case.js"; import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; import { assertToolIdsMatchCategory, + copiesPluginFilesGlobally, getAllRegisteredTools, + nativeActivationOf, + resolvePluginsCapability, supportsUserScopeActivation, } from "../../contexts/tools/domain/registry.js"; import type { ToolId } from "../../kernel/tool.js"; import { AI_TOOL_IDS, IDE_TOOL_IDS } from "../../kernel/tool.js"; +import { assertMachineConfigDirectorySupported } from "../../runtime/machine-config-directory.js"; import { createDeps } from "../../runtime/wiring/framework.js"; import { printDetectedContext, @@ -32,6 +36,7 @@ interface SetupCmdOptions { yes?: boolean; defaultMarketplace?: boolean; scope?: string; + global?: boolean; } export function parseSourceFlag( @@ -58,22 +63,24 @@ export function expandAllKeyword(raw: string | undefined, all: readonly ToolId[] .filter(Boolean) as ToolId[]; } +/** `--ide` takes every tool, AI assistants and editors alike; `--ai`, its former spelling, + * still takes AI tools alone. Each id lands in the list its category decides. */ export function parseToolIds( cmdOptions: SetupCmdOptions, errorHandler: ErrorHandler ): { aiTools: ToolId[]; ideTools: ToolId[] } | null { const aiIds = expandAllKeyword(cmdOptions.ai, AI_TOOL_IDS); - const ideIds = expandAllKeyword(cmdOptions.ide, IDE_TOOL_IDS); + const ids = expandAllKeyword(cmdOptions.ide, [...AI_TOOL_IDS, ...IDE_TOOL_IDS]); try { if (aiIds.length > 0 && cmdOptions.ai?.trim() !== "all") assertToolIdsMatchCategory(aiIds, "ai"); - if (ideIds.length > 0 && cmdOptions.ide?.trim() !== "all") - assertToolIdsMatchCategory(ideIds, "ide"); } catch (e) { errorHandler.handle(e); return null; } - return { aiTools: aiIds, ideTools: ideIds }; + const isIde = (id: ToolId) => (IDE_TOOL_IDS as readonly string[]).includes(id); + const unique = [...new Set([...aiIds, ...ids])]; + return { aiTools: unique.filter((id) => !isIde(id)), ideTools: unique.filter(isIde) }; } type PluginsMode = "interactive" | "all" | "recommended" | "named" | "none"; @@ -94,6 +101,23 @@ export function parsePluginsFlag( return { mode: "named", names }; } +/** What `--global` does for each registered tool, read from its profile. */ +export function globalSetupHelp(): string { + const tools = [...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort(); + const native = tools.filter((id) => nativeActivationOf(id) !== undefined); + const copying = tools.filter(copiesPluginFilesGlobally); + const where = (id: ToolId) => + resolvePluginsCapability(id)?.userPluginsBaseDir("~")?.replaceAll("\\", "/"); + return [ + "", + "Global:", + ` Pass --ide ${tools.join(",")} (one or more, no editor) and --plugins all, recommended, none or names.`, + ` ${native.join(", ")} enable the plugins through their own CLI, which must be on PATH.`, + ` ${copying.map((id) => `${id} gets their files in ${where(id)}`).join(", ")}.`, + " `aidd sync --global` refreshes them from the framework source.", + ].join("\n"); +} + export function registerSetupCommand(program: Command): void { program .command("setup") @@ -103,8 +127,8 @@ export function registerSetupCommand(program: Command): void { .option("--source ", "Framework source: remote or local") .option("--path ", "Absolute path to local framework (required with --source local)") .option("--release ", "Marketplace release tag to fetch (e.g., v1.2.3)") - .option("--ai ", "Comma-separated AI tool IDs, or 'all' (e.g., claude,cursor or all)") - .option("--ide ", "Comma-separated IDE tool IDs, or 'all' (e.g., vscode or all)") + .option("--ide ", "Comma-separated tool IDs, or 'all' (e.g., claude,vscode or all)") + .addOption(new Option("--ai ", "Former spelling of --ide, for AI tools").hideHelp()) .option( "--plugins ", "Plugin install mode: none | all | recommended | comma-separated names" @@ -115,25 +139,18 @@ export function registerSetupCommand(program: Command): void { ) .option("--yes", "Accept defaults without prompting") .option( - "--scope ", - "project (default) installs into this project alone; user registers the shared " + - "framework source and supported tools machine-wide, writing nothing under this project; " + - "see user-scope requirements below" - ) - .addHelpText( - "after", - () => - "\nUser scope:\n" + - ` Pass --ai ${[...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort().join(",")} (choose one or more), --plugins none, and no --ide.\n` + - " Native activation requires the corresponding host CLI on PATH.\n" + - " Tools without native activation are registered only; setup installs no plugins or tool configuration files." + "--global", + "install for every project on this machine instead of into this project; see the " + + "requirements below" ) + .addOption(new Option("--scope ", "Former spelling: user means --global").hideHelp()) + .addHelpText("after", globalSetupHelp) .action(async (cmdOptions: SetupCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); const source = parseSourceFlag(cmdOptions, output); - const scope = parseScopeFlag(cmdOptions.scope, output); + const scope = cmdOptions.global === true ? "user" : parseScopeFlag(cmdOptions.scope, output); const toolIds = parseToolIds(cmdOptions, errorHandler); if (toolIds === null) return; @@ -143,7 +160,8 @@ export function registerSetupCommand(program: Command): void { cmdOptions.ai || cmdOptions.ide || cmdOptions.plugins || - cmdOptions.yes + cmdOptions.yes || + cmdOptions.global ); const interactive = process.stdout.isTTY && !hasScriptingFlags; @@ -167,6 +185,10 @@ export function registerSetupCommand(program: Command): void { scope, }); + if (scope === "user") { + assertMachineConfigDirectorySupported([...toolIds.aiTools, ...toolIds.ideTools]); + } + if (interactive) printWelcomeBanner(output); const deps = await createDeps(projectRoot, { verbose }, output); diff --git a/cli/src/presentation/commands/sync.ts b/cli/src/presentation/commands/sync.ts index fd7faf47b..d1b11f581 100644 --- a/cli/src/presentation/commands/sync.ts +++ b/cli/src/presentation/commands/sync.ts @@ -4,7 +4,8 @@ import { SyncFailedError, UserScopeFilterUnsupportedError, } from "../../kernel/errors.js"; -import type { ToolId } from "../../kernel/tool.js"; +import type { AiToolId, ToolId } from "../../kernel/tool.js"; +import { assertMachineConfigDirectorySupported } from "../../runtime/machine-config-directory.js"; import { createDeps } from "../../runtime/wiring/framework.js"; import { printActivationOutcome, @@ -14,17 +15,19 @@ import { } from "../display/sync-display.js"; import { ErrorHandler } from "../error-handler.js"; import type { CLIOutput } from "../output.js"; -import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; +import { GLOBAL_FLAG_HELP, parseGlobalOptions, resolveScopeFlags } from "./global-options.js"; interface SyncCmdOptions { force: boolean; tool?: string; plugin?: string; scope?: string; + global?: boolean; } -/** A user-scope install writes nothing under any project, so this skips `restoreAllUseCase` - * and drives only native activation, through `deps.userManifestRepo` and never this +/** A user-scope install writes nothing under any project, so this skips `restoreAllUseCase`: + * it rebuilds the global files aidd copied into each tool's user directory from the shared + * source, then drives native activation, through `deps.userManifestRepo` and never this * project's own manifest. */ async function runUserScopeSync( deps: Awaited>, @@ -42,6 +45,20 @@ async function runUserScopeSync( throw new UserScopeFilterUnsupportedError("a file argument", "sync "); } const toolIds = cmdOptions.tool !== undefined ? [cmdOptions.tool as ToolId] : undefined; + const machine = await deps.userManifestRepo.load(); + const withFiles = (toolIds ?? machine?.getInstalledToolIds() ?? []).filter((toolId) => + machine?.getPlugins(toolId as AiToolId).some((plugin) => plugin.scope === "user") + ) as AiToolId[]; + assertMachineConfigDirectorySupported(withFiles); + const refreshed = + withFiles.length === 0 + ? [] + : await deps.userPluginUpdateUseCase.execute({ + toolIds: withFiles, + projectRoot, + scope: "user", + rebuild: true, + }); const activation = await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot, scope: "user", @@ -51,7 +68,7 @@ async function runUserScopeSync( }); printActivationOutcome(output, activation); if (activation.errors.length > 0) throw new SyncFailedError(activation.errors); - printUserScopeSyncOutcome(output, activation.activated); + printUserScopeSyncOutcome(output, activation.activated, [...new Set(refreshed)]); } async function runSyncAction( @@ -63,7 +80,7 @@ async function runSyncAction( const errorHandler = new ErrorHandler(output); try { const deps = await createDeps(projectRoot, { verbose }, output); - const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + const scope = resolveScopeFlags(cmdOptions, output) ?? "project"; if (scope === "user") { await runUserScopeSync(deps, output, projectRoot, fileArgs, cmdOptions); @@ -145,6 +162,7 @@ export function registerSyncCommand(program: Command): void { "project (default) resolves this project's own manifest; user resolves the " + "machine-wide manifest --scope user setup wrote, restoring no project files" ) + .option("--global", `${GLOBAL_FLAG_HELP}, rebuilding its files from the framework source`) .action(async (fileArgs: string[], cmdOptions: SyncCmdOptions) => { await runSyncAction(program, fileArgs, cmdOptions); }); diff --git a/cli/src/presentation/display/setup-display.ts b/cli/src/presentation/display/setup-display.ts index d6889f5f2..05ff05d5a 100644 --- a/cli/src/presentation/display/setup-display.ts +++ b/cli/src/presentation/display/setup-display.ts @@ -11,6 +11,7 @@ interface ToolInstallOutcome { interface SetupOutcome { readonly kind: "initialized" | "up-to-date"; readonly install: { readonly results: readonly ToolInstallOutcome[] }; + readonly globalPlugins?: readonly string[]; } function displayInstall( @@ -37,6 +38,14 @@ function displayInstall( } export function printSetupOutcome(output: CLIOutput, result: SetupOutcome, verbose: boolean): void { + if (result.globalPlugins !== undefined) { + output.success( + result.globalPlugins.length === 0 + ? "Registered for every project on this machine." + : `Installed for every project on this machine: ${result.globalPlugins.join(", ")}.` + ); + return; + } switch (result.kind) { case "initialized": { output.success("Project initialized."); diff --git a/cli/src/presentation/display/sync-display.ts b/cli/src/presentation/display/sync-display.ts index 75964f28a..5ba4453ba 100644 --- a/cli/src/presentation/display/sync-display.ts +++ b/cli/src/presentation/display/sync-display.ts @@ -67,10 +67,15 @@ export function printActivationOutcome(output: CLIOutput, activation: Activation for (const e of activation.errors) output.warn(`[${e.scope}] ${e.message}`); } -export function printUserScopeSyncOutcome(output: CLIOutput, activated: readonly string[]): void { - if (activated.length === 0) { - output.success("Nothing to sync — no tool is registered at user scope yet."); - } else { +export function printUserScopeSyncOutcome( + output: CLIOutput, + activated: readonly string[], + refreshed: readonly string[] = [] +): void { + if (refreshed.length > 0) output.success(`Refreshed global files for: ${refreshed.join(", ")}`); + if (activated.length > 0) { output.success(`Synced native activation for: ${activated.join(", ")}`); + } else if (refreshed.length === 0) { + output.success("Nothing to sync — no tool is registered at user scope yet."); } } diff --git a/cli/src/runtime/filesystem/file-adapter.ts b/cli/src/runtime/filesystem/file-adapter.ts index 0e5ebbdd2..3e8be0b10 100644 --- a/cli/src/runtime/filesystem/file-adapter.ts +++ b/cli/src/runtime/filesystem/file-adapter.ts @@ -1,5 +1,15 @@ import { constants } from "node:fs"; -import { access, mkdir, readdir, readFile, realpath, rm, rmdir, stat } from "node:fs/promises"; +import { + access, + lstat, + mkdir, + readdir, + readFile, + realpath, + rm, + rmdir, + stat, +} from "node:fs/promises"; import { dirname, join } from "node:path"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import { JsonParseError } from "../../kernel/errors.js"; @@ -18,6 +28,10 @@ import { stripJsonComments } from "../../kernel/reading/jsonc.js"; import { atomicWriteFile } from "./atomic-write.js"; export class FileAdapter implements FileReader, FileWriter, FileMerger { + async assertWritableDirectory(path: string): Promise { + await access(path, constants.W_OK); + } + async isExecutable(path: string): Promise { try { await access(path, constants.X_OK); @@ -112,6 +126,16 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { } } + async entryExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + } + async realpath(path: string): Promise { return realpath(path); } diff --git a/cli/src/runtime/git/inject-token.ts b/cli/src/runtime/git/inject-token.ts index d95f32277..7ec0364ba 100644 --- a/cli/src/runtime/git/inject-token.ts +++ b/cli/src/runtime/git/inject-token.ts @@ -1,41 +1,36 @@ -interface HostMatcher { - /** The forge's registered domain. Matched against the URL's own host, never its text. */ - host: string; - authPrefix: string; -} - -const HOST_MATCHERS: readonly HostMatcher[] = [ - { host: "github.com", authPrefix: "x-access-token:" }, - { host: "gitlab.com", authPrefix: "oauth2:" }, - { host: "bitbucket.org", authPrefix: "x-token-auth:" }, - { host: "dev.azure.com", authPrefix: ":" }, -]; - -/** The host a URL addresses, never its text: `evil.example/github.com/x` and - * `notgithub.com/x` both contain `github.com` and must get no GitHub credential. */ -function hostnameOf(url: string): string | null { +/** Remove URL userinfo before using a URL in a Git remote, cache key or diagnostic. */ +export function withoutCredentials(url: string): string { + if (!/^https?:\/\//i.test(url)) return url; try { - return new URL(url).hostname.toLowerCase(); + const parsed = new URL(url); + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); } catch { - return null; + return url.replace(/^(https?:\/\/)[^/]*@/i, "$1"); } } -function matcherFor(hostname: string): HostMatcher | undefined { - return HOST_MATCHERS.find((m) => hostname === m.host || hostname.endsWith(`.${m.host}`)); -} - -export function injectTokenIntoUrl(url: string, token: string | undefined): string { - if (!token || !url.startsWith("https://")) return url; - const hostname = hostnameOf(url); - if (hostname === null) return url; - const matcher = matcherFor(hostname); - const authPrefix = matcher?.authPrefix ?? ""; - return url.replace("https://", `https://${authPrefix}${token}@`); -} - -/** A credential typed into a source URL otherwise travels into the error a failed clone - * prints and into the cache directory's name, where it is written to disk and stays. */ -export function withoutCredentials(url: string): string { - return url.replace(/^(https?:\/\/)[^/@]*@/, "$1"); +export function gitHttpAuthentication( + url: string, + token: string | undefined +): { url: string; authorization?: string } { + if (!/^https?:\/\//i.test(url)) return { url }; + const parsed = new URL(url); + let credentials: string | undefined; + if (parsed.username || parsed.password) { + credentials = `${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`; + } else if ( + token && + parsed.protocol === "https:" && + parsed.hostname.toLowerCase() === "github.com" + ) { + credentials = `x-access-token:${token}`; + } + return { + url: withoutCredentials(url), + ...(credentials === undefined + ? {} + : { authorization: `Authorization: Basic ${Buffer.from(credentials).toString("base64")}` }), + }; } diff --git a/cli/src/runtime/machine-config-directory.ts b/cli/src/runtime/machine-config-directory.ts new file mode 100644 index 000000000..98b8882cc --- /dev/null +++ b/cli/src/runtime/machine-config-directory.ts @@ -0,0 +1,32 @@ +import { realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { globalFilesOf } from "../contexts/tools/domain/registry.js"; +import { pathContainsOrEquals, samePath } from "../kernel/paths.js"; +import { resolveHomeDir } from "../kernel/reading/home-dir.js"; +import { AI_TOOL_IDS, isAiToolId } from "../kernel/tool.js"; + +function realOrResolved(path: string): string { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +/** Current flat machine paths use ~/.config. Do not claim success when the host reads elsewhere. */ +export function assertMachineConfigDirectorySupported(toolIds?: readonly string[]): void { + const xdg = process.env.XDG_CONFIG_HOME; + if (!xdg) return; + const home = resolveHomeDir(); + const expected = join(home, ".config"); + if (samePath(realOrResolved(xdg), realOrResolved(expected))) return; + const affected = (toolIds ?? AI_TOOL_IDS).filter((tool) => { + if (!isAiToolId(tool)) return false; + const machine = globalFilesOf(tool); + return machine !== null && pathContainsOrEquals(resolve(expected), resolve(machine.dir(home))); + }); + if (affected.length === 0) return; + throw new Error( + `${affected.join(", ")} global installation requires XDG_CONFIG_HOME to be unset or point to ~/.config. Restore that setting for setup or sync; existing installations can still be removed.` + ); +} diff --git a/cli/src/runtime/self-update/github-release-resolver-adapter.ts b/cli/src/runtime/self-update/github-release-resolver-adapter.ts index a23fb496d..a2fa5da7a 100644 --- a/cli/src/runtime/self-update/github-release-resolver-adapter.ts +++ b/cli/src/runtime/self-update/github-release-resolver-adapter.ts @@ -16,7 +16,8 @@ const ROOT_RELEASE_TAG_REGEX = /^v\d/; export class GitHubReleaseResolverAdapter implements LatestReleaseResolver { constructor( private readonly http: HttpGet, - private readonly tokenProvider?: TokenProvider + private readonly tokenProvider?: TokenProvider, + private readonly fetchAsset: typeof fetch = fetch ) {} async resolveLatest(repo: string): Promise { @@ -53,6 +54,51 @@ export class GitHubReleaseResolverAdapter implements LatestReleaseResolver { } } + /** Checks the exact published tag, even when component releases fill the first page. */ + async resolvePublishedRelease( + repo: string, + tag: string + ): Promise<{ title: string | null } | null> { + const url = `${GITHUB_API_BASE}/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`; + const token = (await this.tokenProvider?.resolve()) ?? undefined; + try { + const { body } = await this.http.get(url, { token }); + if (typeof body !== "object" || body === null || Array.isArray(body)) return null; + const release = body as { + tag_name?: unknown; + draft?: unknown; + name?: unknown; + assets?: unknown; + }; + if (release.tag_name !== tag || release.draft !== false) return null; + let title = cleanTitle(release.name); + if (title === tag) title = ""; + if ( + Array.isArray(release.assets) && + (release.assets as { name?: unknown }[]).some( + (asset) => asset?.name === "release-video.json" + ) + ) { + try { + const encodedRepo = repo.split("/").map(encodeURIComponent).join("/"); + const response = await this.fetchAsset( + `https://github.com/${encodedRepo}/releases/download/${encodeURIComponent(tag)}/release-video.json`, + { signal: AbortSignal.timeout(1500) } + ); + if (response.ok) { + const video = (await response.json()) as { title?: unknown } | null; + title = cleanTitle(video?.title) || title; + } + } catch { + return { title: title || null }; + } + } + return { title: title || null }; + } catch (err) { + return this.handleError(err, url); + } + } + async isRepoPublic(repo: string): Promise { // Deliberately tokenless: only a 404 unambiguously means "auth required", so any other // error resolves true and a rate-limited or offline public user is not sent to login. @@ -72,3 +118,14 @@ export class GitHubReleaseResolverAdapter implements LatestReleaseResolver { throw new CatalogFetchError(url, detail); } } + +function cleanTitle(value: unknown): string { + if (typeof value !== "string") return ""; + return ( + value + // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters must not reach the terminal. + .replace(/[\x00-\x1f\x7f]+/g, " ") + .trim() + .slice(0, 180) + ); +} diff --git a/cli/src/runtime/wiring/distribution.ts b/cli/src/runtime/wiring/distribution.ts index eaf9f87a4..4222369bb 100644 --- a/cli/src/runtime/wiring/distribution.ts +++ b/cli/src/runtime/wiring/distribution.ts @@ -20,6 +20,7 @@ import type { Hasher } from "../../kernel/ports/hasher.js"; import type { Logger } from "../../kernel/ports/logger.js"; import type { AuthReaderAdapter } from "../auth/auth-reader-adapter.js"; import type { HttpClient } from "../http/http-client.js"; +import { userConfigDir } from "../user-config-dir.js"; export interface DistributionWiringShared { fs: FileReader & FileWriter & FileMerger; @@ -59,7 +60,8 @@ export function wireDistribution(shared: DistributionWiringShared): Distribution ); const resolveMarketplaceUseCase = new ResolveMarketplaceUseCase( fetchMarketplaceSource, - pluginCatalogRepository + pluginCatalogRepository, + userConfigDir ); const marketplaceListUseCase = new MarketplaceListUseCase( marketplaceRegistry, diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index cd97898a1..a75b1067d 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -1,5 +1,7 @@ -import { homedir } from "node:os"; +import { mkdtemp } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; import "../../contexts/tools/domain/profiles/antigravity/profile.js"; +import { dirname, join, resolve } from "node:path"; import "../../contexts/tools/domain/profiles/claude/profile.js"; import "../../contexts/tools/domain/profiles/codex/profile.js"; import "../../contexts/tools/domain/profiles/copilot/profile.js"; @@ -11,6 +13,7 @@ import { MarketplaceAddUseCase } from "../../contexts/distribution/application/m import type { MarketplaceListUseCase } from "../../contexts/distribution/application/marketplace-list-use-case.js"; import type { MarketplaceRefreshUseCase } from "../../contexts/distribution/application/marketplace-refresh-use-case.js"; import type { MarketplaceRegisterFrameworkUseCase } from "../../contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { Marketplace } from "../../contexts/distribution/domain/marketplace.js"; import { CleanUserScopeUseCase } from "../../contexts/framework/application/clean/clean-user-scope-use-case.js"; import { CleanUseCase } from "../../contexts/framework/application/clean-use-case.js"; import { DoctorLayoutUseCase } from "../../contexts/framework/application/doctor/doctor-layout-use-case.js"; @@ -23,6 +26,7 @@ import { DoctorUseCase } from "../../contexts/framework/application/doctor/docto import { MarketplaceCheckUseCase } from "../../contexts/framework/application/flows/marketplace-check-use-case.js"; import { MarketplaceRemoveUseCase } from "../../contexts/framework/application/flows/marketplace-remove-use-case.js"; import { MarketplaceSyncSettingsUseCase } from "../../contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { BuiltTreeMaterializationTranslator } from "../../contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; import { GitignoreUseCase } from "../../contexts/framework/application/gitignore-use-case.js"; import { DoctorAllUseCase } from "../../contexts/framework/application/global/doctor-all-use-case.js"; import { ResolveUpdateDecisionUseCase } from "../../contexts/framework/application/global/resolve-update-decision-use-case.js"; @@ -38,7 +42,15 @@ import { InstallRuntimeConfigUseCase } from "../../contexts/framework/applicatio import { PostInstallPipelineUseCase } from "../../contexts/framework/application/install/post-install-pipeline-use-case.js"; import { ListInstalledRulesUseCase } from "../../contexts/framework/application/list-installed-rules-use-case.js"; import { NativeHostRegistrationGate } from "../../contexts/framework/application/ownership/native-host-registration-gate.js"; -import { ProjectPluginCleanup } from "../../contexts/framework/application/ownership/project-plugin-cleanup.js"; +import { inspectNativeMarketplaceSource } from "../../contexts/framework/application/ownership/native-marketplace-source-proof.js"; +import { assertNoSymlinksWithinRoot } from "../../contexts/framework/application/ownership/project-path-boundary.js"; +import { + assertProjectMcpEntriesRemovable, + assertProjectTuiUnchanged, + ProjectPluginCleanup, +} from "../../contexts/framework/application/ownership/project-plugin-cleanup.js"; +import { UpgradeUserFrameworkUseCase } from "../../contexts/framework/application/ownership/release/upgrade-user-framework-use-case.js"; +import { UserReleaseJournal } from "../../contexts/framework/application/ownership/release/user-release-journal.js"; import { UserMarketplaceRemoveUseCase } from "../../contexts/framework/application/ownership/user-marketplace-remove-use-case.js"; import { UserPluginFileUpdater } from "../../contexts/framework/application/ownership/user-plugin-file-updater.js"; import { UserPluginUpdateUseCase } from "../../contexts/framework/application/ownership/user-plugin-update-use-case.js"; @@ -52,6 +64,7 @@ import { PluginSearchUseCase } from "../../contexts/framework/application/plugin import { PluginUpdateUseCase } from "../../contexts/framework/application/plugin/plugin-update-use-case.js"; import { ValidateRecipesUseCase } from "../../contexts/framework/application/recipes/validate-recipes-use-case.js"; import { RestoreUseCase } from "../../contexts/framework/application/restore/restore-use-case.js"; +import { InstallGlobalPluginsUseCase } from "../../contexts/framework/application/setup/install-global-plugins-use-case.js"; import { ProjectContextDetectorUseCase } from "../../contexts/framework/application/setup/project-context-detector-use-case.js"; import { SetupMachineScopeUseCase } from "../../contexts/framework/application/setup/setup-machine-scope-use-case.js"; import { SetupMarketplaceSourceUseCase } from "../../contexts/framework/application/setup/setup-marketplace-source-use-case.js"; @@ -61,35 +74,54 @@ import { EnsureBuiltMarketplaceUseCase, type FrameworkBuildFor, } from "../../contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { assertProjectHooksRemovable } from "../../contexts/framework/application/shared/remove-project-hooks.js"; import { SetupMarketplaceRegistrationUseCase } from "../../contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; import { StatusUseCase } from "../../contexts/framework/application/status-use-case.js"; import { UninstallIdeUseCase } from "../../contexts/framework/application/uninstall/uninstall-ide-use-case.js"; import { UninstallToolsUseCase } from "../../contexts/framework/application/uninstall/uninstall-tools-use-case.js"; import { UninstallUseCase } from "../../contexts/framework/application/uninstall/uninstall-use-case.js"; +import { + assertUpgradeWrite, + protectUpgradeWrites, +} from "../../contexts/framework/application/upgrade-file-guard.js"; +import { UpgradeFrameworkUseCase } from "../../contexts/framework/application/upgrade-framework-use-case.js"; +import { getToolSupportedScope } from "../../contexts/framework/domain/install-scope.js"; import type { Environment } from "../../contexts/framework/domain/ports/environment.js"; import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../contexts/framework/domain/ports/user-source-references.js"; import { EnvironmentAdapter } from "../../contexts/framework/infrastructure/environment-adapter.js"; import { ManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/manifest-repository-adapter.js"; +import { PinnedUserFrameworkAdapter } from "../../contexts/framework/infrastructure/pinned-user-framework-adapter.js"; import { PluginDistributionReaderAdapter } from "../../contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { RecipeFilesAdapter } from "../../contexts/framework/infrastructure/recipe-files-adapter.js"; import { UserManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/user-manifest-repository-adapter.js"; +import { UserReleaseJournalStoreAdapter } from "../../contexts/framework/infrastructure/user-release-journal-store.js"; import { UserSourceReferencesAdapter } from "../../contexts/framework/infrastructure/user-source-references-adapter.js"; +import { claudeConfigDir } from "../../contexts/telemetry/domain/claude-projects-root.js"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import type { NativeMarketplaceSourceReader } from "../../contexts/tools/domain/ports/native-marketplace-source-reader.js"; import { codexMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/codex/native-marketplace-source.js"; import { copilotMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/copilot/native-marketplace-source.js"; -import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import { + frameworkBuildModeFor, + projectHooksDeliveryOf, +} from "../../contexts/tools/domain/registry.js"; +import { + claudeInstallations, + hostPluginRegistryReaders, +} from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; import { HostRegistryMarketplaceSourceReaderAdapter, NativeMarketplaceSourceReaderAdapter, } from "../../contexts/tools/infrastructure/native-marketplace-source-reader-adapter.js"; +import { builtMarketplaceDir, userBuiltMarketplaceDir } from "../../kernel/paths.js"; import type { AssetProvider } from "../../kernel/ports/asset-provider.js"; import type { FileReader } from "../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../kernel/ports/file-writer.js"; import type { Logger } from "../../kernel/ports/logger.js"; import type { Prompter } from "../../kernel/ports/prompter.js"; import type { VersionReader } from "../../kernel/ports/version-reader.js"; +import { resolveHomeDir } from "../../kernel/reading/home-dir.js"; import type { AiToolId } from "../../kernel/tool.js"; import { CLIOutput } from "../../presentation/output.js"; import { PluginPickUseCase } from "../../presentation/prompts/plugin-pick-use-case.js"; @@ -144,6 +176,8 @@ interface Deps { pluginRemoveUseCase: PluginRemoveUseCase; pluginListUseCase: PluginListUseCase; pluginUpdateUseCase: PluginUpdateUseCase; + upgradeFrameworkUseCase: UpgradeFrameworkUseCase; + upgradeUserFrameworkUseCase: UpgradeUserFrameworkUseCase; userPluginUpdateUseCase: UserPluginUpdateUseCase; marketplaceAddUseCase: MarketplaceAddUseCase; marketplaceListUseCase: MarketplaceListUseCase; @@ -213,7 +247,14 @@ export async function createDeps( const fs = new FileAdapter(hasher, logger); const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); const manifestRepo = new ManifestRepositoryAdapter(projectRoot); - const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir, atomicWriteFile); + let userReleaseJournal: UserReleaseJournal; + const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir, atomicWriteFile, () => + userReleaseJournal.recover() + ); + userReleaseJournal = new UserReleaseJournal(fs, hasher, userManifestRepo, { + home: homedir, + store: new UserReleaseJournalStoreAdapter(() => userManifestRepo.path), + }); const http = new HttpClient(); const authStorage = new AuthStorage(); const ghCliAdapter = new GhCliAdapter(); @@ -257,11 +298,14 @@ export async function createDeps( } // Read once, reused wherever a use case needs the scope a plugin is actually registered // at: removal, clean, and doctor's own registration check. + const hostHome = resolveHomeDir(); + const claudeDir = claudeConfigDir(process.env.CLAUDE_CONFIG_DIR, hostHome); const hostPluginRegistries = hostPluginRegistryReaders(); // Built ahead of every use case that reads a shared-source claim: it depends only on `fs` // and `userConfigDir`, neither of which distribution's own wiring produces. const userSourceReferences = new UserSourceReferencesAdapter(fs, userConfigDir); const { + pluginCatalogRepository, pluginFetcher, marketplaceRegistry, marketplaceTrustStore, @@ -457,6 +501,338 @@ export async function createDeps( doctorRegistrationUseCase ); const releaseResolver = new GitHubReleaseResolverAdapter(http, authReader); + const upgradeFrameworkUseCase = new UpgradeFrameworkUseCase({ + listMarketplaces: (root) => marketplaceRegistry.list(root), + saveMarketplace: (root, marketplace) => marketplaceRegistry.save(root, marketplace), + loadManifest: () => manifestRepo.load(), + loadUserManifest: () => userManifestRepo.load(), + resolvePublishedRelease: (repo, release) => + releaseResolver.resolvePublishedRelease(repo, release), + resolveTarget: (marketplace, root) => + resolveMarketplaceUseCase.execute({ marketplace, projectRoot: root, forceRefresh: true }), + readTargetVersion: async (localPath, pluginName) => { + const distribution = await pluginDistributionReader.read( + join(localPath, "plugins", pluginName) + ); + if (distribution.manifest.name !== pluginName) + throw new Error(`Cannot verify the target plugin '${pluginName}'.`); + return distribution.manifest.version; + }, + preflight: async (manifest, root, targets, marketplace, localPath) => { + const claudeTargets = targets.filter((target) => target.toolId === "claude"); + let impact: Awaited> = []; + let shared = false; + let sourceSnapshot: string | undefined; + if (claudeTargets.length) { + if (resolve(claudeDir) !== resolve(hostHome, ".claude")) + throw new Error( + "Cannot upgrade Claude with a custom CLAUDE_CONFIG_DIR: its marketplace paths are not supported." + ); + const registration = manifest.getNativeRegistrations("claude"); + const catalogues = registration?.marketplaces.filter( + (entry) => entry.alias === marketplace.name + ); + const catalogue = + catalogues?.length === 1 + ? catalogues[0] + : catalogues?.length + ? undefined + : { alias: marketplace.name, hostName: marketplace.name }; + if (!catalogue) throw new Error("Cannot verify Claude marketplace identity."); + const hostName = catalogue.hostName; + sourceSnapshot = await fs.realpath( + marketplace.scope === "user" + ? userBuiltMarketplaceDir( + userConfigDir(), + currentVersionProvider.get(), + marketplace.name, + "claude" + ) + : builtMarketplaceDir(root, marketplace.name, "claude") + ); + const source = await inspectNativeMarketplaceSource(nativeSources.get("claude"), root, { + ...catalogue, + provenance: { kind: "registry", source: sourceSnapshot }, + }); + if (source.status !== "owned") + throw new Error( + `Cannot verify Claude marketplace source: ${source.reason ?? source.status}` + ); + const refs = claudeTargets.map((target) => `${target.pluginName}@${hostName}`); + impact = await claudeInstallations(claudeDir, hostName); + const here = await fs.realpath(root).catch(() => root); + for (const target of claudeTargets) { + const ref = `${target.pluginName}@${hostName}`; + if ( + !impact.some( + (entry) => entry.ref === ref && entry.scope === "local" && entry.projectPath === here + ) + ) { + throw new Error( + `Cannot upgrade claude:${target.pluginName}: its local installation was not found.` + ); + } + } + shared = impact.some( + (entry) => + !refs.includes(entry.ref) || entry.scope !== "local" || entry.projectPath !== here + ); + } + for (const target of targets) { + if (getToolSupportedScope(target.toolId) !== "project") + throw new Error( + `Cannot upgrade ${target.toolId}:${target.pluginName}: the tool's current install scope differs from the recorded project scope.` + ); + const plugin = manifest + .getPlugins(target.toolId) + .find((entry) => entry.name === target.pluginName); + if (!plugin) + throw new Error( + `Plugin ${target.toolId}:${target.pluginName} is missing from the manifest.` + ); + for (const path of plugin.files.keys()) + await assertNoSymlinksWithinRoot(fs, root, resolve(root, path)); + for (const path of plugin.projectHooks?.scripts.keys() ?? []) + await assertNoSymlinksWithinRoot(fs, root, resolve(root, path)); + const drift = await detectPluginDriftUseCase.execute({ + manifest, + projectRoot: root, + toolIds: [target.toolId], + pluginName: target.pluginName, + }); + if (drift.length) + throw new Error( + `Cannot upgrade ${target.toolId}:${target.pluginName}: tracked files are modified or missing (${drift.flatMap((entry) => entry.files.map((file) => file.relativePath)).join(", ")}).` + ); + await assertProjectMcpEntriesRemovable(fs, plugin, target.toolId, root); + await assertProjectHooksRemovable(fs, plugin, target.toolId, root); + await assertProjectTuiUnchanged(fs, plugin, target.toolId, root); + } + // Build the target in a disposable, project-scoped cache, without moving a shared + // marketplace. This is the same reader the real install uses for its output paths. + const temporary = await mkdtemp(join(tmpdir(), "aidd-upgrade-preview-")); + try { + const previewMarketplace = Marketplace.fromJSON({ + ...marketplace.toJSON(), + scope: "project", + }); + const previewBuilder = new EnsureBuiltMarketplaceUseCase( + fs, + { + execute: async ({ marketplace: selected }) => ({ + marketplace: selected, + localPath, + catalog: null, + }), + }, + frameworkBuildFor, + currentVersionProvider, + userConfigDir + ); + const reader = new BuiltTreeMaterializationTranslator( + fs, + hasher, + homedir, + previewBuilder, + marketplaceRegistry + ); + const planned = new Map(); + for (const target of targets) { + if (frameworkBuildModeFor(target.toolId) !== "flat") continue; + const plugin = manifest + .getPlugins(target.toolId) + .find((entry) => entry.name === target.pluginName); + if (!plugin) + throw new Error( + `Plugin ${target.toolId}:${target.pluginName} is missing from the manifest.` + ); + const dist = await pluginDistributionReader.read( + join(localPath, "plugins", target.pluginName) + ); + const { builtDir } = await previewBuilder.execute({ + projectRoot: temporary, + marketplace: previewMarketplace, + target: target.toolId, + mode: "flat", + }); + const tracked = new Map( + [...plugin.files].map(([path, hash]) => [resolve(root, path), hash]) + ); + for (const [path, hash] of plugin.projectHooks?.scripts ?? []) + tracked.set(resolve(root, path), hash); + const paths = (await reader.readFlatFiles(builtDir, dist, target.toolId)).map( + (file) => file.relativePath + ); + const delivery = projectHooksDeliveryOf(target.toolId); + const hasProjectHooks = dist.components.hooks.some( + (file) => file.relativePath === "hooks/hooks.json" + ); + if (delivery && (hasProjectHooks || plugin.projectHooks !== undefined)) { + const directories = new Set([dirname(resolve(root, delivery.relativePath))]); + for (const file of dist.components.hooks) { + if (hasProjectHooks && file.relativePath !== "hooks/hooks.json") + directories.add( + dirname( + resolve(root, delivery.format.scriptPath(dist.manifest.name, file.relativePath)) + ) + ); + } + for (const path of plugin.projectHooks?.scripts.keys() ?? []) + directories.add(dirname(resolve(root, path))); + for (let directory of directories) { + while (!(await fs.entryExists(directory)) && directory !== root) + directory = dirname(directory); + try { + await fs.assertWritableDirectory(directory); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EACCES" && code !== "EPERM") throw error; + throw new Error( + `Cannot write project hooks directory '${directory}' (${code}); grant write access and retry before updating. In Codex CLI, restart with --add-dir pointing to this directory.` + ); + } + } + } + if (delivery && hasProjectHooks) { + for (const file of dist.components.hooks) { + if (file.relativePath !== "hooks/hooks.json") + paths.push(delivery.format.scriptPath(dist.manifest.name, file.relativePath)); + } + } + for (const relativePath of paths) { + const path = resolve(root, relativePath); + const owner = `${target.toolId}:${target.pluginName}`; + const shared = manifest + .getInstalledToolIds() + .some( + (id) => + manifest.getToolFiles(id).some((entry) => entry.relativePath === relativePath) || + manifest.getMergeFiles(id).some((entry) => entry.relativePath === relativePath) || + manifest + .getPlugins(id) + .some( + (entry) => + (id !== target.toolId || entry.name !== target.pluginName) && + entry.scope === "project" && + (entry.files.has(relativePath) || + entry.projectHooks?.scripts.has(relativePath) === true) + ) + ); + if (shared) + throw new Error( + `Cannot upgrade ${owner}: target file '${relativePath}' is claimed by another installation.` + ); + if (planned.has(path) && planned.get(path) !== owner) + throw new Error( + `Cannot upgrade ${owner}: target file '${relativePath}' is claimed by another plugin.` + ); + planned.set(path, owner); + await assertUpgradeWrite(fs, root, path, tracked); + } + } + } finally { + await fs.deleteDirectory(temporary); + } + return { impact, shared, source: sourceSnapshot, tool: claudeTargets[0]?.toolId }; + }, + verifyImpact: async (root, impact, source) => { + if (!impact.length) return; + const catalogue = impact[0]?.ref.split("@").at(-1); + if ( + !catalogue || + JSON.stringify(await claudeInstallations(claudeDir, catalogue)) !== JSON.stringify(impact) + ) + throw new Error("Claude installations changed after the preview; review the update again."); + const registration = (await manifestRepo.load()) + ?.getNativeRegistrations("claude") + ?.marketplaces.find((item) => item.hostName === catalogue) ?? { + alias: catalogue, + hostName: catalogue, + }; + if ( + !registration || + !source || + ( + await inspectNativeMarketplaceSource(nativeSources.get("claude"), root, { + ...registration, + provenance: { kind: "registry", source }, + }) + ).status !== "owned" + ) + throw new Error( + "Claude marketplace source changed after the preview; review the update again." + ); + }, + install: async (options) => { + const manifest = await manifestRepo.load(); + const plugin = manifest + ?.getPlugins(options.toolIds[0]) + .find((entry) => entry.name === options.pluginName); + if (!plugin) throw new Error(`Plugin ${options.pluginName} disappeared before installation.`); + const tracked = new Map(plugin.files); + for (const [path, hash] of plugin.projectHooks?.scripts ?? []) tracked.set(path, hash); + const delivery = projectHooksDeliveryOf(options.toolIds[0]); + if (delivery) { + const path = resolve(options.projectRoot, delivery.relativePath); + await assertNoSymlinksWithinRoot(fs, options.projectRoot, path); + if (await fs.fileExists(path)) + tracked.set(delivery.relativePath, (await fs.readFileHash(path)).value); + } + const guardedFs = protectUpgradeWrites(fs, options.projectRoot, tracked); + const guardedAdd = new PluginAddUseCase( + guardedFs, + manifestRepo, + new PluginDistributionLoader(pluginFetcher, pluginDistributionReader), + hasher, + logger, + marketplaceRegistry, + ensureBuiltMarketplaceUseCase, + userManifestRepo, + installRuntimeConfigUseCase + ); + const installer = new PluginInstallFromMarketplaceUseCase( + resolveMarketplaceUseCase, + marketplaceRegistry, + guardedAdd, + prompter, + logger + ); + await installer.execute(options); + const result = await manifestRepo.load(); + const installed = result + ?.getPlugins(options.toolIds[0]) + .find((entry) => entry.name === options.pluginName); + if ( + !result || + !installed || + installed.version !== options.version || + installed.source.kind !== "git-subdir" || + installed.source.url !== options.expectedSource.url || + installed.source.path !== options.expectedSource.path || + installed.source.ref !== options.expectedSource.ref + ) + throw new Error(`Unable to verify the installed source for ${options.pluginName}.`); + for (const [relativePath, expected] of plugin.files) { + // A path claimed by any other tool or plugin remains theirs to manage. + if (result.isFileTracked(relativePath)) continue; + const path = resolve(options.projectRoot, relativePath); + await assertNoSymlinksWithinRoot(fs, options.projectRoot, path); + if (!(await fs.fileExists(path)) || (await fs.readFileHash(path)).value !== expected) + throw new Error(`Cannot remove modified or missing file '${relativePath}'.`); + await fs.deleteFile(path); + if (await fs.fileExists(path)) + throw new Error(`Cannot remove obsolete file '${relativePath}'.`); + } + }, + sync: (root, toolIds, targets, approvedRefs) => + marketplaceSyncSettingsUseCase.execute({ + projectRoot: root, + toolIds, + marketplaceNames: ["aidd-framework"], + refreshOwnedPlugins: targets, + approvedRefs, + }), + }); const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( prompter, releaseResolver @@ -486,20 +862,45 @@ export async function createDeps( releaseResolver, userSourceReferences ); - const setupMachineScopeUseCase = new SetupMachineScopeUseCase( - userManifestRepo, - setupMarketplaceRegistration, - marketplaceSyncSettingsUseCase, - currentVersionProvider - ); - const statusUseCase = new StatusUseCase(fs, manifestRepo, hasher, detectPluginDriftUseCase); // Restore re-materializes through the build pipeline, matching what install wrote: // rewriting raw content instead would itself be drift. + const pinnedUserFramework = new PinnedUserFrameworkAdapter( + fs, + hasher, + pluginFetcher, + pluginCatalogRepository, + pluginDistributionReader, + frameworkBuildFor + ); const builtMaterializationDeps = { ensureBuilt: ensureBuiltMarketplaceUseCase, marketplaceRegistry, homedir, + userCacheRoot: userConfigDir, + pinnedFramework: pinnedUserFramework, }; + // One writer for a machine-scope plugin's files, whether setup installs it or update rebuilds it. + const userPluginFileUpdater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader(pluginFetcher, pluginDistributionReader, () => + join(userConfigDir(), "cache", "plugin-cache") + ), + hasher, + builtMaterializationDeps + ); + const setupMachineScopeUseCase = new SetupMachineScopeUseCase( + userManifestRepo, + setupMarketplaceRegistration, + marketplaceSyncSettingsUseCase, + currentVersionProvider, + new InstallGlobalPluginsUseCase( + marketplaceRegistry, + resolveMarketplaceUseCase, + userPluginFileUpdater, + logger + ) + ); + const statusUseCase = new StatusUseCase(fs, manifestRepo, hasher, detectPluginDriftUseCase); const pluginUpdateUseCase = new PluginUpdateUseCase( fs, manifestRepo, @@ -510,15 +911,19 @@ export async function createDeps( ); const userPluginUpdateUseCase = new UserPluginUpdateUseCase( userManifestRepo, - new UserPluginFileUpdater( - fs, - new PluginDistributionLoader(pluginFetcher, pluginDistributionReader), - hasher, - builtMaterializationDeps - ), + userPluginFileUpdater, logger, nativeHostRegistrationGate ); + const upgradeUserFrameworkUseCase = new UpgradeUserFrameworkUseCase({ + repo: userManifestRepo, + builder: pinnedUserFramework, + updater: userPluginFileUpdater, + journal: userReleaseJournal, + logger, + resolvePublishedRelease: (repo, release) => + releaseResolver.resolvePublishedRelease(repo, release), + }); const restoreUseCase = new RestoreUseCase( fs, manifestRepo, @@ -604,7 +1009,9 @@ export async function createDeps( pluginRemoveUseCase, pluginListUseCase, pluginUpdateUseCase, + upgradeFrameworkUseCase, userPluginUpdateUseCase, + upgradeUserFrameworkUseCase, marketplaceAddUseCase, marketplaceListUseCase, marketplaceRemoveUseCase, diff --git a/cli/src/runtime/wiring/tools.ts b/cli/src/runtime/wiring/tools.ts index f5a612bff..955d7fe7d 100644 --- a/cli/src/runtime/wiring/tools.ts +++ b/cli/src/runtime/wiring/tools.ts @@ -10,6 +10,8 @@ import "../../contexts/tools/domain/profiles/opencode/profile.js"; import "../../contexts/tools/domain/profiles/vscode/profile.js"; import type { HostMarketplaceRegistryReader } from "../../contexts/tools/domain/ports/host-marketplace-registry-reader.js"; import type { NativePluginActivator } from "../../contexts/tools/domain/ports/native-plugin-activator.js"; +import { kiloHeadlessRun } from "../../contexts/tools/domain/profiles/kilo/headless-run.js"; +import { opencodeHeadlessRun } from "../../contexts/tools/domain/profiles/opencode/headless-run.js"; import { nativeActivationOf } from "../../contexts/tools/domain/registry.js"; import { hostMarketplaceRegistryReaders } from "../../contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.js"; import { NativePluginCliAdapter } from "../../contexts/tools/infrastructure/native-plugin-cli-adapter.js"; @@ -31,3 +33,6 @@ export function wireTools(): { ]); return { nativePluginActivators, hostMarketplaceRegistries: hostMarketplaceRegistryReaders() }; } + +/** Tools whose headless run command cannot display their session-start notice. */ +export const headlessRunTools = { opencode: opencodeHeadlessRun, kilo: kiloHeadlessRun } as const; diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 783d3405e..5c901470e 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -20,6 +20,13 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] // `setup` brings a project from nothing to correct, so it names each stage: registration, // tool install, plugin prompt, settings sync, version, plus the machine-scope handoff. { path: "src/contexts/framework/application/setup-use-case.ts", injected: 11 }, + // Its machine-scope counterpart names the same stages for the machine: the machine manifest, + // source registration, the plugins' global files, native activation, and the version each + // registered tool records. + { + path: "src/contexts/framework/application/setup/setup-machine-scope-use-case.ts", + injected: 5, + }, // Five section generators reached inline through a switch on section name, plus the config // generator. It resolves by registering a generator per section type, not by dropping one. { diff --git a/cli/tests/architecture/vitest-startup-preserves-temp.arch.test.ts b/cli/tests/architecture/vitest-startup-preserves-temp.arch.test.ts new file mode 100644 index 000000000..ad2a4960d --- /dev/null +++ b/cli/tests/architecture/vitest-startup-preserves-temp.arch.test.ts @@ -0,0 +1,62 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { expect, it } from "vitest"; +import { CLI_ROOT } from "./helpers.js"; + +it("preserves old user directories when Vitest starts", () => { + const root = mkdtempSync(join(tmpdir(), "sweep-isolated-")); + try { + const profile = join(root, "profile"); + mkdirSync(profile); + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT; + const names = [ + "aidd-my-worktree", + "auth-storage-test-user", + "marketplace-registry-user", + "mkt-list-user", + "trust-store-user", + ]; + for (const name of names) { + const dir = join(root, name); + mkdirSync(dir); + writeFileSync(join(dir, "user-file"), "keep"); + if (name === "aidd-my-worktree") + writeFileSync(join(dir, ".git"), "gitdir: /example/worktree"); + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + utimesSync(dir, old, old); + } + execFileSync( + process.execPath, + [ + join(CLI_ROOT, "node_modules", "vitest", "vitest.mjs"), + "run", + "--project=unit", + "tests/helpers/throwaway-profile.unit.test.ts", + ], + { + cwd: CLI_ROOT, + encoding: "utf8", + env: { + PATH: dirname(process.execPath), + HOME: profile, + USERPROFILE: profile, + TMPDIR: root, + TMP: root, + TEMP: root, + CI: "true", + ...(systemRoot === undefined ? {} : { SystemRoot: systemRoot }), + }, + timeout: 60000, + } + ); + for (const name of names) + expect(readFileSync(join(root, name, "user-file"), "utf8")).toBe("keep"); + expect(readFileSync(join(root, "aidd-my-worktree", ".git"), "utf8")).toBe( + "gitdir: /example/worktree" + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts index 76e64c2b4..951bb0ab3 100644 --- a/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts @@ -37,6 +37,27 @@ function makeMarketplace(name: string): Marketplace { } describe("ResolveMarketplaceUseCase", () => { + it("keeps a user-scope source's clone out of the project, and a project-scope one in it", () => { + const useCase = new ResolveMarketplaceUseCase( + new FetchMarketplaceSourceUseCase(new FixturePluginFetcher()), + new PluginCatalogRepositoryAdapter(new InMemoryFileAdapter({}, new DeterministicHasher())), + () => "/config/aidd" + ); + const shared = Marketplace.create({ + name: "shared", + source: { kind: "local", path: MARKETPLACE_DIR }, + scope: "user", + addedAt: "2026-05-01T10:00:00.000Z", + }); + + expect(useCase.cacheDirOf(shared, PROJECT_ROOT)).toBe( + join("/config/aidd", "cache", "marketplaces", "shared") + ); + expect(useCase.cacheDirOf(makeMarketplace("own"), PROJECT_ROOT)).toBe( + join(PROJECT_ROOT, ".aidd", "cache", "marketplaces", "own") + ); + }); + it("returns the parsed catalog for a local marketplace", async () => { const hasher = new DeterministicHasher(); const fs = new InMemoryFileAdapter({}, hasher); diff --git a/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts index 2715b5a66..a5a4386b0 100644 --- a/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts +++ b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts @@ -1,6 +1,7 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -33,8 +34,9 @@ function gitEnv(): NodeJS.ProcessEnv { async function setupBareRepoWithPlugin(repoPath: string, workPath: string): Promise { const env = gitEnv(); + const options = { env, timeout: 15000 }; await mkdir(repoPath, { recursive: true }); - await execFileAsync("git", ["init", "--bare", "-b", "main", repoPath], { env }); + await execFileAsync("git", ["init", "--bare", "-b", "main", repoPath], options); await mkdir(join(workPath, ".claude-plugin"), { recursive: true }); await writeFile( join(workPath, ".claude-plugin/plugin.json"), @@ -45,10 +47,10 @@ async function setupBareRepoWithPlugin(repoPath: string, workPath: string): Prom join(workPath, "commands/hello.md"), "---\nname: hello\ndescription: Hi\n---\n\nHello.\n" ); - await execFileAsync("git", ["init", "-b", "main"], { cwd: workPath, env }); - await execFileAsync("git", ["add", "."], { cwd: workPath, env }); - await execFileAsync("git", ["commit", "-m", "init"], { cwd: workPath, env }); - await execFileAsync("git", ["push", repoPath, "main"], { cwd: workPath, env }); + await execFileAsync("git", ["init", "-b", "main"], { ...options, cwd: workPath }); + await execFileAsync("git", ["add", "."], { ...options, cwd: workPath }); + await execFileAsync("git", ["commit", "-m", "init"], { ...options, cwd: workPath }); + await execFileAsync("git", ["push", repoPath, "main"], { ...options, cwd: workPath }); } function makeAdapter(): PluginFetcherAdapter { @@ -56,6 +58,265 @@ function makeAdapter(): PluginFetcherAdapter { } describe("PluginFetcherAdapter", () => { + it("redacts credentials reflected by a real Git server", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "aidd-git-reflect-")); + const token = "debug-test-secret"; + const encoded = Buffer.from(`tester:${token}`).toString("base64"); + let reached = false; + const server = createServer((req, res) => { + reached = true; + const message = `ERR reflected ${req.headers.authorization}\n`; + const packet = `${(Buffer.byteLength(message) + 4).toString(16).padStart(4, "0")}${message}`; + res.writeHead(200, { "Content-Type": "application/x-git-upload-pack-advertisement" }); + res.end(`${packet}0000`); + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing server address"); + const url = `http://tester:${token}@127.0.0.1:${address.port}/remote.git`; + const message = await makeAdapter() + .fetch({ kind: "url", url }, join(sandbox, "cache")) + .then( + () => "unexpected success", + (error: Error) => error.message + ); + expect(reached).toBe(true); + expect(message).not.toBe("unexpected success"); + expect(message).not.toContain(token); + expect(message).not.toContain(encoded); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await rm(sandbox, { recursive: true, force: true }); + } + }); + it("does not forward credentials to a different redirect origin", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "aidd-git-redirect-")); + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + let seen: string | undefined; + let reached = false; + const destination = createServer((req, res) => { + reached = true; + seen = req.headers.authorization; + res.writeHead(401, { "WWW-Authenticate": 'Basic realm="test"' }).end(); + }); + destination.setTimeout(20000, (socket) => socket.destroy()); + await new Promise((resolve) => destination.listen(0, resolve)); + const destAddress = destination.address(); + if (!destAddress || typeof destAddress === "string") + throw new Error("missing destination address"); + const redirect = createServer((_req, res) => { + res + .writeHead(302, { + Location: `http://localhost:${destAddress.port}/remote.git/info/refs?service=git-upload-pack`, + }) + .end(); + }); + redirect.setTimeout(20000, (socket) => socket.destroy()); + try { + // Reset system helpers, including Git Credential Manager on Windows, so its GUI + // cannot intercept the deliberate 401 from the redirect destination. + await writeFile(join(sandbox, ".gitconfig"), "[credential]\n\thelper =\n"); + process.env.HOME = sandbox; + process.env.USERPROFILE = sandbox; + await new Promise((resolve) => redirect.listen(0, "127.0.0.1", resolve)); + const address = redirect.address(); + if (!address || typeof address === "string") throw new Error("missing redirect address"); + const url = `http://tester:redirect-secret@127.0.0.1:${address.port}/remote.git`; + await expect( + makeAdapter().fetch({ kind: "url", url }, join(sandbox, "cache")) + ).rejects.toThrow(); + expect(reached).toBe(true); + expect(seen).toBeUndefined(); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + redirect.closeAllConnections(); + destination.closeAllConnections(); + await new Promise((resolve) => redirect.close(() => resolve())); + await new Promise((resolve) => destination.close(() => resolve())); + await rm(sandbox, { recursive: true, force: true }); + } + }); + it("authenticates a real Git HTTP clone without persisting the credential", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "aidd-git-auth-")); + const repoPath = join(sandbox, "remote.git"); + const workPath = join(sandbox, "work"); + const cacheDir = join(sandbox, "cache"); + const password = "test-only-secret"; + const expected = `Basic ${Buffer.from(`tester:${password}`).toString("base64")}`; + let authenticatedRequests = 0; + let injectedHeader = false; + const gitConfigKeys = [ + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_CONFIG_KEY_1", + "GIT_CONFIG_VALUE_1", + ] as const; + const previousGitConfig = gitConfigKeys.map((key) => process.env[key]); + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + const server = createServer((req, res) => { + injectedHeader ||= req.headers["x-injected"] !== undefined; + if (req.headers.authorization !== expected) { + res.writeHead(401, { "WWW-Authenticate": 'Basic realm="test"' }).end(); + return; + } + authenticatedRequests++; + const url = new URL(req.url ?? "/", "http://localhost"); + const backend = spawn("git", ["http-backend"], { + timeout: 15000, + env: { + ...gitEnv(), + GIT_PROJECT_ROOT: sandbox, + GIT_HTTP_EXPORT_ALL: "1", + PATH_INFO: url.pathname, + QUERY_STRING: url.search.slice(1), + REQUEST_METHOD: req.method, + CONTENT_TYPE: req.headers["content-type"], + CONTENT_LENGTH: req.headers["content-length"], + REMOTE_USER: "tester", + }, + }); + backend.stderr.resume(); + backend.stdin.on("error", () => res.destroy()); + req.pipe(backend.stdin); + let pending = Buffer.alloc(0); + let started = false; + backend.stdout.on("data", (chunk: Buffer) => { + if (started) { + res.write(chunk); + return; + } + pending = Buffer.concat([pending, chunk]); + const boundary = pending.indexOf("\r\n\r\n"); + if (boundary < 0) return; + const headers: Record = {}; + let status = 200; + for (const line of pending.subarray(0, boundary).toString().split("\r\n")) { + const separator = line.indexOf(":"); + if (separator < 0) continue; + const key = line.slice(0, separator); + const value = line.slice(separator + 1).trim(); + if (key.toLowerCase() === "status") status = Number(value.slice(0, 3)); + else headers[key] = value; + } + res.writeHead(status, headers); + res.write(pending.subarray(boundary + 4)); + started = true; + }); + backend.stdout.on("end", () => { + if (!started) res.writeHead(502); + res.end(started ? undefined : "git http-backend did not produce CGI headers"); + }); + backend.on("error", () => res.destroy()); + }); + server.setTimeout(20000, (socket) => socket.destroy()); + try { + await mkdir(workPath, { recursive: true }); + await setupBareRepoWithPlugin(repoPath, workPath); + await execFileAsync( + "git", + ["--git-dir", repoPath, "config", "uploadpack.allowFilter", "true"], + { env: gitEnv(), timeout: 15000 } + ); + await execFileAsync( + "git", + ["--git-dir", repoPath, "config", "uploadpack.allowAnySHA1InWant", "true"], + { env: gitEnv(), timeout: 15000 } + ); + process.env.HOME = sandbox; + process.env.USERPROFILE = sandbox; + // An empty helper resets system-level helpers before installing our own. + await execFileAsync("git", ["config", "--global", "--add", "credential.helper", ""], { + env: gitEnv(), + timeout: 15000, + }); + await execFileAsync( + "git", + [ + "config", + "--global", + "--add", + "credential.helper", + `!f() { echo username=tester; echo password=${password}; }; f`, + ], + { env: gitEnv(), timeout: 15000 } + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing server address"); + const base = `http://127.0.0.1:${address.port}/remote.git`; + const source = `HTTP://tester:${password}@127.0.0.1:${address.port}/remote.git`; + process.env.GIT_CONFIG_COUNT = "2"; + process.env.GIT_CONFIG_KEY_0 = "http.extraHeader"; + process.env.GIT_CONFIG_VALUE_0 = "X-Injected: zero"; + process.env.GIT_CONFIG_KEY_1 = "http.extraHeader"; + process.env.GIT_CONFIG_VALUE_1 = "X-Injected: one"; + const local = await makeAdapter().fetch({ kind: "url", url: source, ref: "main" }, cacheDir); + const config = await readFile(join(local, ".git/config"), "utf8"); + expect(authenticatedRequests).toBeGreaterThan(0); + expect(injectedHeader).toBe(false); + expect(config).toContain(base); + expect(config).not.toContain(password); + expect(config).not.toContain(Buffer.from(`tester:${password}`).toString("base64")); + expect(existsSync(join(local, ".claude-plugin/plugin.json"))).toBe(true); + + const helperClone = await new PluginFetcherAdapter(new FileAdapter(new HasherAdapter()), { + resolve: async () => "github-token-not-for-this-host", + }).fetch({ kind: "url", url: base, ref: "main" }, join(sandbox, "helper-cache")); + expect(existsSync(join(helperClone, ".claude-plugin/plugin.json"))).toBe(true); + + const beforeSparse = authenticatedRequests; + const sparse = await makeAdapter().fetch( + { kind: "git-subdir", url: source, path: "commands", ref: "main" }, + cacheDir + ); + expect(authenticatedRequests).toBeGreaterThan(beforeSparse); + expect(existsSync(join(sparse, "hello.md"))).toBe(true); + const sparseConfig = await readFile(join(sparse, "..", ".git/config"), "utf8"); + expect(sparseConfig).toContain(base); + expect(sparseConfig).not.toContain(password); + + const failure = await makeAdapter() + .fetch( + { kind: "git-subdir", url: source, path: "commands", ref: "missing-branch" }, + cacheDir + ) + .then( + () => "unexpected success", + (error: Error) => error.message + ); + expect(failure).not.toBe("unexpected success"); + expect(failure).not.toContain(password); + expect(failure).not.toContain(Buffer.from(`tester:${password}`).toString("base64")); + for (const name of await readdir(cacheDir)) { + const configPath = join(cacheDir, name, ".git/config"); + if (existsSync(configPath)) { + const saved = await readFile(configPath, "utf8"); + expect(saved).not.toContain(password); + expect(saved).not.toContain(Buffer.from(`tester:${password}`).toString("base64")); + } + } + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + for (const [index, key] of gitConfigKeys.entries()) { + const previous = previousGitConfig[index]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(sandbox, { recursive: true, force: true }); + } + }); describe("local source", () => { it("returns absolute path for existing local plugin", async () => { const adapter = makeAdapter(); @@ -179,4 +440,204 @@ describe("PluginFetcherAdapter", () => { } }); }); + + describe("a pinned Git commit", () => { + it("uses canonical line endings despite user Git config and refuses an intentional CRLF edit", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "aidd-fetcher-crlf-")); + const repoPath = join(sandbox, "remote.git"); + const workPath = join(sandbox, "work"); + const cacheDir = join(sandbox, "cache"); + const globalConfig = join(sandbox, "gitconfig"); + const env = gitEnv(); + const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; + try { + await mkdir(workPath, { recursive: true }); + await setupBareRepoWithPlugin(repoPath, workPath); + const sha = ( + await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workPath, env }) + ).stdout.trim(); + await writeFile(globalConfig, "[core]\n autocrlf = true\n"); + process.env.GIT_CONFIG_GLOBAL = globalConfig; + const source = { kind: "url" as const, url: `file://${repoPath}`, ref: "main" }; + const adapter = makeAdapter(); + // The release checkout is first acquired before its SHA is known. + const clone = await adapter.fetch(source, cacheDir, { canonicalCheckout: true }); + const localAutocrlf = await execFileAsync( + "git", + ["config", "--local", "--get", "core.autocrlf"], + { cwd: clone, env } + ); + expect(localAutocrlf.stdout.trim()).toBe("false"); + expect( + ( + await execFileAsync("git", ["config", "--local", "--get", "core.eol"], { + cwd: clone, + env, + }) + ).stdout.trim() + ).toBe("lf"); + const file = join(clone, "commands/hello.md"); + const original = await readFile(file, "utf8"); + expect(original).not.toContain("\r\n"); + await expect(adapter.fetch({ ...source, sha }, cacheDir)).resolves.toBe(clone); + // An edited checkout config must not hide a CRLF-only change to the cached file. + await execFileAsync("git", ["config", "--local", "core.autocrlf", "true"], { + cwd: clone, + env, + }); + await writeFile(file, original.replaceAll("\n", "\r\n")); + await expect(adapter.fetch({ ...source, sha }, cacheDir)).rejects.toThrow(/modified/i); + expect(await readFile(file, "utf8")).toBe(original.replaceAll("\n", "\r\n")); + + // A pre-existing noncanonical checkout cannot prove its original bytes; never rewrite it. + const oldCache = join(sandbox, "old-cache"); + const oldCheckout = join(oldCache, "github-example-project-main"); + await mkdir(oldCache); + await execFileAsync( + "git", + ["-c", "core.autocrlf=true", "clone", `file://${repoPath}`, oldCheckout], + { env } + ); + const oldFile = join(oldCheckout, "commands/hello.md"); + expect(await readFile(oldFile, "utf8")).toContain("\r\n"); + const oldSource = { kind: "github" as const, repo: "example/project", ref: "main", sha }; + await expect(adapter.fetch(oldSource, oldCache)).rejects.toThrow( + /cannot be verified.*noncanonical line endings.*inspect.*manually/i + ); + await expect(adapter.fetch(oldSource, oldCache, { forceRefresh: true })).rejects.toThrow( + /cannot be verified.*inspect.*manually/i + ); + expect(await readFile(oldFile, "utf8")).toContain("\r\n"); + } finally { + if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; + await rm(sandbox, { recursive: true, force: true }); + } + }); + + it("verifies a real checkout and cache, including a moved tag and local edits", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "aidd-fetcher-pinned-")); + const repoPath = join(sandbox, "remote.git"); + const workPath = join(sandbox, "work"); + const cacheDir = join(sandbox, "cache"); + const env = gitEnv(); + try { + await mkdir(workPath, { recursive: true }); + await setupBareRepoWithPlugin(repoPath, workPath); + const sha = ( + await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workPath, env }) + ).stdout.trim(); + await execFileAsync("git", ["tag", "v1"], { cwd: workPath, env }); + await execFileAsync("git", ["push", repoPath, "v1"], { cwd: workPath, env }); + const source = { + kind: "git-subdir" as const, + url: `file://${repoPath}`, + path: "commands", + ref: "v1", + sha, + }; + const adapter = makeAdapter(); + const plugin = await adapter.fetch(source, cacheDir); + expect(existsSync(join(plugin, "hello.md"))).toBe(true); + expect( + ( + await execFileAsync("git", ["config", "--local", "--get", "core.autocrlf"], { + cwd: plugin, + env, + }) + ).stdout.trim() + ).toBe("false"); + expect(await adapter.fetch(source, cacheDir)).toBe(plugin); + + await writeFile(join(plugin, "hello.md"), "locally changed\n"); + await expect(adapter.fetch(source, cacheDir)).rejects.toThrow(/modified|untracked/i); + await expect(adapter.fetch(source, cacheDir, { forceRefresh: true })).rejects.toThrow( + /modified|untracked/i + ); + expect(await readFile(join(plugin, "hello.md"), "utf8")).toBe("locally changed\n"); + await execFileAsync("git", ["checkout", "--", "hello.md"], { cwd: plugin, env }); + await writeFile(join(plugin, "extra.md"), "not from the commit\n"); + await expect(adapter.fetch(source, cacheDir)).rejects.toThrow(/modified|untracked/i); + await rm(join(plugin, "extra.md")); + await appendFile(join(plugin, "..", ".git", "info", "exclude"), "\nignored.cache\n"); + await writeFile(join(plugin, "ignored.cache"), "not from the commit\n"); + await expect(adapter.fetch(source, cacheDir)).rejects.toThrow(/ignored/i); + await rm(join(plugin, "ignored.cache")); + await execFileAsync("git", ["update-index", "--assume-unchanged", "hello.md"], { + cwd: plugin, + env, + }); + await writeFile(join(plugin, "hello.md"), "hidden change\n"); + await expect(adapter.fetch(source, cacheDir)).rejects.toThrow(/hides tracked/i); + await execFileAsync("git", ["update-index", "--no-assume-unchanged", "hello.md"], { + cwd: plugin, + env, + }); + await execFileAsync("git", ["checkout", "--", "hello.md"], { cwd: plugin, env }); + + await writeFile(join(workPath, "commands/hello.md"), "release moved\n"); + await execFileAsync("git", ["add", "."], { cwd: workPath, env }); + await execFileAsync("git", ["commit", "-m", "next"], { cwd: workPath, env }); + await execFileAsync("git", ["tag", "-f", "v1"], { cwd: workPath, env }); + await execFileAsync("git", ["push", "--force", repoPath, "v1"], { cwd: workPath, env }); + await expect(adapter.fetch(source, cacheDir, { forceRefresh: true })).rejects.toThrow( + /commit|sha/i + ); + } finally { + await rm(sandbox, { recursive: true, force: true }); + } + }); + + it("refuses a GitHub cache whose actual checkout differs from its pinned SHA", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "aidd-fetcher-github-pin-")); + const repoPath = join(sandbox, "remote.git"); + const workPath = join(sandbox, "work"); + const cacheDir = join(sandbox, "cache"); + const cached = join(cacheDir, "github-example-project-v1"); + const env = gitEnv(); + try { + await mkdir(workPath, { recursive: true }); + await setupBareRepoWithPlugin(repoPath, workPath); + await mkdir(cacheDir, { recursive: true }); + // A managed pinned checkout records canonical EOL settings in its own Git config. + await execFileAsync( + "git", + ["clone", "-c", "core.autocrlf=false", `file://${repoPath}`, cached], + { env } + ); + const adapter = makeAdapter(); + const sha = ( + await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: cached, env }) + ).stdout.trim(); + const badGlobalConfig = join(sandbox, "invalid-global-gitconfig"); + await writeFile(badGlobalConfig, "[invalid\n"); + await expect( + execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: cached, + env: { ...env, GIT_CONFIG_GLOBAL: badGlobalConfig }, + }) + ).rejects.toThrow(); + const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = badGlobalConfig; + try { + // Verification must ignore the user's global config, including a malformed one. + await expect( + adapter.fetch({ kind: "github", repo: "example/project", ref: "v1", sha }, cacheDir) + ).resolves.toBe(cached); + } finally { + if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; + } + const source = { + kind: "github" as const, + repo: "example/project", + ref: "v1", + sha: "0".repeat(40), + }; + await expect(adapter.fetch(source, cacheDir)).rejects.toThrow(/commit|sha/i); + } finally { + await rm(sandbox, { recursive: true, force: true }); + } + }); + }); }); diff --git a/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-cache-and-auth.unit.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-cache-and-auth.unit.test.ts index ddab64382..0ce416fcf 100644 --- a/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-cache-and-auth.unit.test.ts +++ b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-cache-and-auth.unit.test.ts @@ -24,19 +24,44 @@ vi.mock("simple-git", () => ({ })); const mockExecFile = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); +const mockAuthInput = vi.fn<(input: string) => void>(); +const mockAuthSpawn = vi.fn<(cmd: string, args: string[], options: object) => void>(); vi.mock("node:child_process", () => ({ execFile: ( cmd: string, args: string[], - cb: (err: unknown, result?: { stdout: string; stderr: string }) => void + optionsOrCallback: + | object + | ((err: unknown, result?: { stdout: string; stderr: string }) => void), + callback?: (err: unknown) => void ) => { + if (typeof optionsOrCallback !== "function") { + mockAuthSpawn(cmd, args, optionsOrCallback); + const stdin = { + on: () => stdin, + end: (input: string) => { + mockAuthInput(input); + const separator = args.lastIndexOf("--"); + const operation = + args[1] === "clone" + ? mockCloneFn(args[separator + 1], args[separator + 2], args.slice(2, separator)) + : Promise.resolve(); + operation.then( + () => callback?.(null), + (error: Error) => callback?.(error) + ); + }, + }; + return { stdin }; + } try { mockExecFile(cmd, args); - cb(null, { stdout: "", stderr: "" }); + optionsOrCallback(null, { stdout: "", stderr: "" }); } catch (err) { - cb(err); + optionsOrCallback(err); } + return undefined; }, })); @@ -50,11 +75,14 @@ import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapt const CACHE = "/tmp/cache"; function reset(): void { + mockEnvFn.mockClear(); mockCloneFn.mockClear().mockResolvedValue(undefined); mockRawFn.mockClear().mockResolvedValue(undefined); mockCheckoutFn.mockClear().mockResolvedValue(undefined); mockSimpleGit.mockClear(); mockExecFile.mockClear().mockReturnValue(undefined); + mockAuthInput.mockClear(); + mockAuthSpawn.mockClear(); } let lastFs: InMemoryFileAdapter; @@ -71,11 +99,18 @@ function clonedInto(): string { return mockCloneFn.mock.calls[0]?.[1] as string; } -/** The URL the clone was given — the one that may legitimately carry a token. */ +/** The URL the clone was given; credentials must never appear there. */ function clonedFrom(): string { return mockCloneFn.mock.calls[0]?.[0] as string; } +function authHeader(): string | undefined { + const input = mockAuthInput.mock.calls[0]?.[0]; + return input + ? (JSON.parse(input) as { GIT_CONFIG_VALUE_0?: string }).GIT_CONFIG_VALUE_0 + : undefined; +} + describe("where a fetched source lands in the cache", () => { describe("a github repo", () => { it("keys on owner, repo and ref, so two refs of one repo do not share a tree", async () => { @@ -159,19 +194,56 @@ describe("where a fetched source lands in the cache", () => { expect(clonedInto()).toBe(join(CACHE, "https___example_com_private_git-HEAD")); }); - it("still hands the credential to git, which is what it is for", async () => { + it("passes credentials separately and leaves the cloned URL clean", async () => { await adapter().fetch({ kind: "url", url }, CACHE); - expect(clonedFrom()).toBe(url); + expect(clonedFrom()).toBe("https://example.com/private.git"); + expect(authHeader()).toBe( + `Authorization: Basic ${Buffer.from("user:ghp_SECRET").toString("base64")}` + ); + }); + + it("removes userinfo containing multiple at-signs before cloning", async () => { + await adapter().fetch( + { kind: "url", url: "https://user:pw@part@example.com/private.git" }, + CACHE + ); + expect(clonedFrom()).toBe("https://example.com/private.git"); + expect(authHeader()).toBe( + `Authorization: Basic ${Buffer.from("user:pw@part").toString("base64")}` + ); }); }); }); describe("the token the CLI adds for the user", () => { - it("injects a resolved token into an https url", async () => { + it("passes the resolved token without putting it in the clone URL", async () => { await adapter("tok").fetch({ kind: "github", repo: "acme/widgets" }, CACHE); - expect(clonedFrom()).toBe("https://x-access-token:tok@github.com/acme/widgets.git"); + expect(clonedFrom()).toBe("https://github.com/acme/widgets.git"); + expect(authHeader()).toBe( + `Authorization: Basic ${Buffer.from("x-access-token:tok").toString("base64")}` + ); + }); + + it("keeps authentication out of the parent process arguments and environment", async () => { + const token = "synthetic-private-token"; + const encoded = Buffer.from(`x-access-token:${token}`).toString("base64"); + await adapter(token).fetch({ kind: "github", repo: "acme/widgets" }, CACHE); + const spawned = JSON.stringify(mockAuthSpawn.mock.calls); + expect(spawned).not.toContain(token); + expect(spawned).not.toContain(encoded); + expect(authHeader()).toContain(encoded); + }); + + it("does not treat an authenticated ref as a Git option", async () => { + await expect( + adapter("tok").fetch( + { kind: "github", repo: "acme/widgets", ref: "--upload-pack=evil" }, + CACHE + ) + ).rejects.toThrow(/Invalid Git reference/); + expect(mockAuthSpawn).not.toHaveBeenCalled(); }); it("leaves an ssh url alone, where a token means nothing", async () => { @@ -180,19 +252,32 @@ describe("the token the CLI adds for the user", () => { expect(clonedFrom()).toBe("git@example.com:acme/repo.git"); }); - it("injects it into a bare https url too, not only a github shorthand", async () => { + it("does not send a resolved token to an unrelated https host", async () => { await adapter("tok").fetch({ kind: "url", url: "https://example.com/private.git" }, CACHE); - expect(clonedFrom()).toBe("https://tok@example.com/private.git"); + expect(clonedFrom()).toBe("https://example.com/private.git"); + expect(authHeader()).toBeUndefined(); + }); + + it.each([ + "https://gitlab.com/group/private.git", + "https://bitbucket.org/group/private.git", + "https://github.com.attacker.example/private.git", + "http://github.com/group/private.git", + ])("never forwards an automatically resolved token to %s", async (url) => { + await adapter("sensitive-token").fetch({ kind: "url", url }, CACHE); + expect(authHeader()).toBeUndefined(); + expect(clonedFrom()).not.toContain("sensitive-token"); }); - it("injects it when only a subdirectory of a private repo is wanted", async () => { + it("does not send a resolved token to an unrelated sparse repo", async () => { await adapter("tok").fetch( { kind: "git-subdir", url: "https://example.com/mono.git", path: "packages/one" }, CACHE ); - expect(clonedFrom()).toBe("https://tok@example.com/mono.git"); + expect(clonedFrom()).toBe("https://example.com/mono.git"); + expect(authHeader()).toBeUndefined(); }); it("clones unauthenticated when no provider is wired", async () => { @@ -295,6 +380,22 @@ describe("what the user is told when a clone fails", () => { ); }); + it("redacts an authentication header or token echoed by a failed Git process", async () => { + const token = "test-only-token"; + const encoded = Buffer.from(`x-access-token:${token}`).toString("base64"); + const fetcher = adapter(token); + mockCloneFn.mockRejectedValue( + new Error(`remote echoed ${token} and Authorization: Basic ${encoded}`) + ); + const message = await fetcher.fetch({ kind: "github", repo: "acme/widgets" }, CACHE).then( + () => "unexpected success", + (error: Error) => error.message + ); + expect(message).not.toBe("unexpected success"); + expect(message).not.toContain(token); + expect(message).not.toContain(encoded); + }); + it("keeps the rest of git's message, so the failure is still diagnosable", async () => { const fetcher = adapter("injected-token"); mockCloneFn.mockRejectedValue( @@ -307,13 +408,21 @@ describe("what the user is told when a clone fails", () => { }); describe("when the remote refused the credentials", () => { - it("tells an https user how to supply a token", async () => { + it("tells an unrelated host user to configure Git credentials", async () => { const fetcher = adapter(); mockCloneFn.mockRejectedValue(new Error("remote: Repository not found")); await expect( fetcher.fetch({ kind: "url", url: "https://example.com/private.git" }, CACHE) - ).rejects.toThrow(/aidd auth login/); + ).rejects.toThrow(/Git credential helper/); + }); + + it("tells a GitHub user how to supply a token", async () => { + const fetcher = adapter(); + mockCloneFn.mockRejectedValue(new Error("remote: Repository not found")); + await expect(fetcher.fetch({ kind: "github", repo: "acme/private" }, CACHE)).rejects.toThrow( + /aidd auth login/ + ); }); it("tells an ssh user to check their key instead", async () => { diff --git a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts index 100c0232c..7cc5ac6b1 100644 --- a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts @@ -6,6 +6,7 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { FRAMEWORK_MARKETPLACE_NAME, Marketplace, @@ -31,8 +32,63 @@ const USER_CONFIG_DIR = "/fake-home/.config/aidd"; const HOME = "/fake-home"; const CLAUDE_CACHE = join(HOME, ".claude", "plugins", "cache"); const CODEX_CACHE = join(HOME, ".codex", "plugins", "cache"); + +describe("user clean of Kilo maintenance TUI registration", () => { + function fixture(config: string) { + const fs = new RecordingFileAdapter(); + const path = join(HOME, ".config/kilo/tui.jsonc"); + fs.setFile(path, config); + const manifest = Manifest.create(); + manifest.addTool("kilo", "1.0.0", []); + manifest.addPlugin( + "kilo", + InstalledPlugin.fromMetadata( + "aidd-maintenance", + "1.0.0", + { kind: "local", path: "/source" }, + false, + "user" + ).withTuiPlugin({ config: "tui.jsonc", entry: "./hooks/aidd-maintenance/kilo-tui.js" }) + ); + const repo = new InMemoryManifestRepository(manifest); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + return { fs, path, repo, useCase }; + } + + it("removes only the owned entry and preserves other config", async () => { + const f = fixture( + '{\n // comment\n "plugin": ["other", "./hooks/aidd-maintenance/kilo-tui.js"]\n}\n' + ); + await f.useCase.execute({ projectRoot: "/project", force: true }); + const config = await f.fs.readFile(f.path); + expect(config).toContain("// comment"); + expect(config).toContain("other"); + expect(config).not.toContain("./hooks/aidd-maintenance/kilo-tui.js"); + }); + + it("refuses an unprovable TUI entry before any purge", async () => { + const f = fixture('{"plugin":["other"]}'); + await expect(f.useCase.execute({ projectRoot: "/project", force: true })).rejects.toThrow( + /tracked entry changed/ + ); + expect(f.fs.order).toEqual([]); + expect(f.repo.getCurrent()).not.toBeNull(); + }); +}); const WHITELIST_PURGE_MESSAGES = [ `user scope: cache/built purged: ${join(USER_CONFIG_DIR, "cache", "built")}`, + `user scope: cache/marketplaces/aidd-framework purged: ${join(USER_CONFIG_DIR, "cache", "marketplaces", FRAMEWORK_MARKETPLACE_NAME)}`, + `user scope: cache/plugin-cache purged: ${join(USER_CONFIG_DIR, "cache", "plugin-cache")}`, + `user scope: cache/marketplaces purged: ${join(USER_CONFIG_DIR, "cache", "marketplaces")}`, `user scope: cache/update-check.json purged: ${join(USER_CONFIG_DIR, "cache", "update-check.json")}`, `user scope: cache purged: ${join(USER_CONFIG_DIR, "cache")}`, `user scope: update-check.json purged: ${join(USER_CONFIG_DIR, "update-check.json")}`, @@ -176,6 +232,72 @@ function seedClaudeCache(fs: InMemoryFileAdapter, hostName = "aidd-framework"): } describe("clean --scope user", () => { + it.each([false, true])( + "Copilot cleans past an unclaimed uninstall tombstone, but protects a foreign enabled=%s ref", + async (foreignEnabled) => { + const manifest = Manifest.create(); + manifest.addTool("copilot", "1.0.0", []); + const source = { + kind: "effective-list" as const, + root: "/built/copilot", + sourceType: "local", + source: "/built/copilot", + }; + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework", provenance: source }], + pluginRefs: ["aidd-dev@aidd-framework"], + pluginClaims: [{ ref: "aidd-dev@aidd-framework", dependents: [] }], + }); + const repo = new InMemoryManifestRepository(manifest); + const hostRefs = new Map([ + ["aidd-dev@aidd-framework", { enabled: true }], + ["aidd-pm@aidd-framework", { enabled: foreignEnabled }], + ]); + const activator = new FakeNativePluginActivator({ available: true }); + const clean = new CleanUserScopeUseCase( + new InMemoryFileAdapter(), + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["copilot", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "marketplace list", + entries: new Map([["aidd-framework", source]]), + }), + }, + ], + ]), + new Map([ + ["copilot", new FakeHostPluginRegistryReader({ location: "plugins", refs: hostRefs })], + ]) + ); + + const result = clean.execute({ projectRoot: "/project", force: true }); + + if (foreignEnabled) { + await expect(result).rejects.toThrow(/foreign host ref 'aidd-pm@aidd-framework'/); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()).not.toBeNull(); + } else { + await expect(result).resolves.toMatchObject({ dryRun: false }); + expect(activator.uninstalledPlugins).toEqual(["aidd-dev@aidd-framework"]); + expect(activator.removedMarketplaces).toEqual(["aidd-framework"]); + expect(repo.getCurrent()).toBeNull(); + } + } + ); + it("reads canonical dependents after acquiring the machine lock", async () => { const machine = manifestWithClaude(); class LockedRepository extends InMemoryManifestRepository { @@ -195,6 +317,16 @@ describe("clean --scope user", () => { const cacheMarker = join(seedClaudeCache(fs), "marker.json"); const sourceMarker = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); fs.setFile(sourceMarker, "source"); + const fetched = join( + USER_CONFIG_DIR, + "cache", + "marketplaces", + FRAMEWORK_MARKETPLACE_NAME, + "source" + ); + const plugins = join(USER_CONFIG_DIR, "cache", "plugin-cache", "source"); + fs.setFile(fetched, "framework clone"); + fs.setFile(plugins, "plugin clone"); const activator = new FakeNativePluginActivator({ available: true }); const useCase = new CleanUserScopeUseCase( fs, @@ -219,6 +351,8 @@ describe("clean --scope user", () => { expect(activator.removedMarketplaces).toEqual([]); expect(fs.getFile(sourceMarker)).toBe("source"); expect(fs.getFile(cacheMarker)).toBe("{}"); + expect(fs.getFile(fetched)).toBe("framework clone"); + expect(fs.getFile(plugins)).toBe("plugin clone"); expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ { ref: "aidd-context@aidd-framework", dependents: ["/project-b"] }, ]); @@ -611,7 +745,94 @@ describe("clean --scope user", () => { }); describe("whitelist", () => { - it("deletes exactly cache/built, manifest.json, references.json and the aidd-framework marketplaces.json entry — nothing else", async () => { + it("purges fetched framework and plugin caches while preserving another marketplace's source", async () => { + const fs = new RecordingFileAdapter(); + const framework = join(USER_CONFIG_DIR, "cache", "marketplaces", FRAMEWORK_MARKETPLACE_NAME); + const plugins = join(USER_CONFIG_DIR, "cache", "plugin-cache"); + const other = join(USER_CONFIG_DIR, "cache", "marketplaces", "other-marketplace", "source"); + fs.setFile(join(framework, "source"), "framework clone"); + fs.setFile(join(plugins, "source"), "plugin clone"); + fs.setFile(other, "another marketplace's clone"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/wherever", + Marketplace.create({ + name: "other-marketplace", + source: { kind: "local", path: other }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + registry, + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(await fs.fileExists(framework)).toBe(false); + expect(await fs.fileExists(plugins)).toBe(false); + expect(fs.getFile(other)).toBe("another marketplace's clone"); + expect(registry.getAll("/wherever").map((marketplace) => marketplace.name)).toEqual([ + "other-marketplace", + ]); + }); + + it("removes the cache shell once the fetched framework and plugin caches are gone", async () => { + const fs = new RecordingFileAdapter(); + fs.setFile( + join(USER_CONFIG_DIR, "cache", "marketplaces", FRAMEWORK_MARKETPLACE_NAME, "source"), + "framework clone" + ); + fs.setFile(join(USER_CONFIG_DIR, "cache", "plugin-cache", "source"), "plugin clone"); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(await fs.fileExists(join(USER_CONFIG_DIR, "cache"))).toBe(false); + expect(fs.order).toContain( + `deleteDirectory:${join(USER_CONFIG_DIR, "cache", "marketplaces")}` + ); + expect(fs.order).toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache")}`); + }); + + it.each(["cache/marketplaces/aidd-framework", "cache/plugin-cache"])( + "preserves a fetched source resolving outside user config through %s", + async (relativePath) => { + const fs = new RecordingFileAdapter(); + const cache = join(USER_CONFIG_DIR, relativePath); + const marker = join(cache, "source"); + fs.setFile(marker, "external clone"); + fs.setSymlink(cache, "/elsewhere/clone"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.getFile(marker)).toBe("external clone"); + expect(fs.order).not.toContain(`deleteDirectory:${cache}`); + expect(logger.warnMessages).toContain( + `user scope: ${relativePath} does not resolve inside ${USER_CONFIG_DIR}; left in place: ${cache}` + ); + } + ); + + it("removes framework state and keeps other marketplace registrations", async () => { const fs = new RecordingFileAdapter(); fs.setFile( join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "aidd-framework", "claude", "x"), @@ -1799,6 +2020,9 @@ describe("clean --scope user", () => { expect(fs.has(join(cacheDir, "built", "1.0.0", "aidd-framework", "x"))).toBe(true); expect(logger.warnMessages).toStrictEqual([ `user scope: cache/built does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "built")}`, + `user scope: cache/marketplaces/aidd-framework does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "marketplaces", FRAMEWORK_MARKETPLACE_NAME)}`, + `user scope: cache/plugin-cache does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "plugin-cache")}`, + `user scope: cache/marketplaces does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "marketplaces")}`, `user scope: cache/update-check.json does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "update-check.json")}`, `user scope: cache does not resolve inside ${USER_CONFIG_DIR}; left in place: ${cacheDir}`, ]); diff --git a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts index 43f2dcb4d..c9f6ce142 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -32,9 +32,14 @@ function settingsDeclaring(names: string[]): string { async function issuesFor( settings: string | null, - options: { toolId?: ToolId; toolInstalled?: boolean; allowedIds?: Set | null } = {} + options: { + toolId?: ToolId; + toolInstalled?: boolean; + allowedIds?: Set | null; + machine?: boolean; + } = {} ) { - const { toolId = "claude", toolInstalled = true, allowedIds = null } = options; + const { toolId = "claude", toolInstalled = true, allowedIds = null, machine } = options; const fs = new InMemoryFileAdapter(); if (settings !== null) await fs.writeFile(LOCAL_SETTINGS, settings); const registry = new InMemoryMarketplaceRegistry(); @@ -64,6 +69,7 @@ async function issuesFor( manifest, projectRoot: PROJECT_ROOT, allowedIds, + globalInstall: machine, }); } @@ -81,6 +87,10 @@ describe("DoctorRegistrationUseCase", () => { expect(await issuesFor(settingsDeclaring([]))).toStrictEqual([UNDECLARED]); }); + it("asks no project to declare a machine-wide install, which writes nothing there", async () => { + expect(await issuesFor(null, { machine: true })).toStrictEqual([]); + }); + it("reports it when the whole file is gone — nothing else would notice", async () => { expect(await issuesFor(null)).toStrictEqual([UNDECLARED]); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts new file mode 100644 index 000000000..e93f835d5 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts @@ -0,0 +1,124 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { HostPluginRegistryEntry } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const REF = "test-plugin@aidd-framework"; + +async function globalActivation(toolId: "claude" | "copilot") { + const refs = new Map(); + class StatefulActivator extends FakeNativePluginActivator { + override enablePlugin(ref: string, scope: MarketplaceScope = "project"): void { + super.enablePlugin(ref, scope); + refs.set(ref, { enabled: true, scope }); + } + } + const activator = new StatefulActivator({ available: true }); + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + const userRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/project", + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const builtDir = `/built/${toolId}`; + const catalogDir = toolId === "claude" ? ".claude-plugin" : ".plugin"; + const fs = new InMemoryFileAdapter({ + [`${builtDir}/${catalogDir}/marketplace.json`]: JSON.stringify({ + name: "aidd-framework", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ location: "catalogues", entries: new Map() }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([[toolId, new FakeHostPluginRegistryReader({ location: "plugins", refs })]]), + userRepo, + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + toolId === "claude" ? "registry" : "effective-list", + (path) => (path === builtDir ? "aidd-framework" : undefined), + new Map() + ), + ], + ]) + ); + const options = { projectRoot: "/project", scope: "user" as const, manifestRepo: userRepo }; + const setup = await sync.execute({ ...options, enablePlugins: ["test-plugin"] }); + expect(setup.errors).toEqual([]); + expect(setup.warnings).toEqual([]); + expect(userRepo.getCurrent()?.getPlugins(toolId)).toEqual([]); + expect(userRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([REF]); + return { refs, activator, userRepo, sync, options }; +} + +describe.each(["claude", "copilot"] as const)("global %s native plugins", (toolId) => { + it.each(["enabled", "missing", "disabled", "claims only"] as const)( + "sync retains and restores a successful selection when the ref is %s", + async (state) => { + const { refs, activator, userRepo, sync, options } = await globalActivation(toolId); + if (state === "missing") refs.delete(REF); + if (state === "disabled") refs.set(REF, { enabled: false, scope: "user" }); + if (state === "claims only") { + const manifest = await userRepo.load(); + const registrations = manifest?.getNativeRegistrations(toolId); + if (manifest === null || registrations === undefined) + throw new Error("missing setup record"); + manifest.setNativeRegistrations(toolId, { ...registrations, pluginRefs: [] }); + await userRepo.save(manifest); + refs.delete(REF); + } + + const result = await sync.execute(options); + + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(refs.get(REF)?.enabled).toBe(true); + expect(userRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([REF]); + expect(userRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect(activator.enabledPluginScopes).toEqual( + state === "enabled" ? ["user"] : ["user", "user"] + ); + } + ); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts index f81091f24..f08756caa 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts @@ -7,6 +7,8 @@ import { } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { resolvePluginsCapability } from "../../../../../src/contexts/tools/domain/registry.js"; import { BUILT_CACHE_SUBDIR, userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; @@ -37,6 +39,8 @@ async function sync(options: { name?: string; recreateFrameworkIfMissing?: boolean; staleCacheFile?: string; + testPlugin?: boolean; + refreshOwnedPlugins?: boolean; }) { const name = options.name ?? MARKETPLACE_NAME; const fs = new InMemoryFileAdapter(); @@ -45,6 +49,30 @@ async function sync(options: { const logger = new CapturingLogger(); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); + if (options.testPlugin) + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + name + ) + ); + if (options.testPlugin && options.registeredPath) + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: name, + hostName: name, + provenance: { kind: "registry", source: options.registeredPath }, + }, + ], + pluginRefs: [`test-plugin@${name}`], + }); const machine = Manifest.create(); machine.addTool("claude", "test", []); machine.setNativeRegistrations("claude", { @@ -59,7 +87,9 @@ async function sync(options: { }, ], pluginRefs: [], - pluginClaims: [], + pluginClaims: options.testPlugin + ? [{ ref: `test-plugin@${name}`, dependents: [PROJECT_ROOT, "/other-project"] }] + : [], }); await manifestRepo.save(manifest); await registry.save( @@ -83,12 +113,17 @@ async function sync(options: { ); } if (options.staleCacheFile !== undefined) await fs.writeFile(options.staleCacheFile, "stale"); - const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const activator = new FakeNativePluginActivator({ + available: true, + enablesPlugins: options.testPlugin === true, + }); const hostReader = new FakeHostMarketplaceRegistryReader({ location: REGISTRY_LOCATION, entries: options.registeredPath === undefined ? new Map() : new Map([[name, options.registeredPath]]), }); + const machineRepo = new InMemoryManifestRepository(machine); + const sourceReferences: string[] = []; const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -100,18 +135,28 @@ async function sync(options: { new Map([["claude", hostReader]]), () => USER_CACHE_ROOT, undefined, - undefined, - undefined, + options.testPlugin + ? { + addReference: async (_version, root) => { + sourceReferences.push(root); + }, + removeReference: async () => {}, + listAllReferencingProjects: async () => [], + } + : undefined, + options.testPlugin ? { get: () => options.requestedVersion } : undefined, new Map([ [ "claude", new FakeHostPluginRegistryReader({ location: "/home/.claude/plugins/installed_plugins.json", - refs: new Map(), + refs: options.testPlugin + ? new Map([[`test-plugin@${name}`, { enabled: true }]]) + : new Map(), }), ], ]), - new InMemoryManifestRepository(machine), + machineRepo, new Map([ [ "claude", @@ -129,11 +174,46 @@ async function sync(options: { const result = await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: options.recreateFrameworkIfMissing, + ...(options.refreshOwnedPlugins + ? { refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }] } + : {}), }); - return { result, activator, logger, manifestRepo, fs }; + return { result, activator, logger, manifestRepo, machineRepo, sourceReferences, fs }; } describe("the sync write path refuses to roll a host back to an older aidd-framework build", () => { + it("does not touch a shared native plugin when another project uses a newer build", async () => { + const activation = resolvePluginsCapability("claude")?.nativeActivation; + if (!activation) throw new Error("missing native activation"); + activation.refreshOwnedOnReleaseUpdate = true; + try { + const { result, activator, manifestRepo, machineRepo, sourceReferences } = await sync({ + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0", FRAMEWORK_MARKETPLACE_NAME), + registeredVersion: "2.0.0", + name: FRAMEWORK_MARKETPLACE_NAME, + testPlugin: true, + refreshOwnedPlugins: true, + }); + expect(result.warnings.length).toBeGreaterThan(0); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(activator.upgradeCount).toBe(0); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toEqual([ + `test-plugin@${FRAMEWORK_MARKETPLACE_NAME}`, + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { + ref: `test-plugin@${FRAMEWORK_MARKETPLACE_NAME}`, + dependents: [PROJECT_ROOT, "/other-project"], + }, + ]); + expect(machineRepo.saveCount).toBe(0); + expect(sourceReferences).toEqual([]); + } finally { + delete activation.refreshOwnedOnReleaseUpdate; + } + }); // The host already follows a newer build of the shared source than this run would request, so // writing anyway would silently repoint it backward. it("writes nothing and warns naming both versions and `aidd update`, when the host already follows a newer shared build", async () => { @@ -201,7 +281,7 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame expect(logger.warnMessages).toStrictEqual([refusal]); }); - it("records the marketplace it left on the newer build as registered under the host's own name", async () => { + it("does not claim the newer host marketplace when the project did not own it", async () => { const { manifestRepo } = await sync({ requestedVersion: "1.0.0", registeredPath: sharedPath("2.0.0"), @@ -210,13 +290,7 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ binary: "claude", - marketplaces: [ - { - alias: MARKETPLACE_NAME, - hostName: MARKETPLACE_NAME, - provenance: { kind: "registry", source: sharedPath("2.0.0") }, - }, - ], + marketplaces: [], pluginRefs: [], }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts index 0b9ceed2f..3231fc218 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts @@ -8,6 +8,7 @@ import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/fram import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { NativePluginCliError } from "../../../../../src/kernel/errors.js"; import type { PluginSource } from "../../../../../src/kernel/source.js"; @@ -52,6 +53,8 @@ interface SyncSetup { /** Explicitly prove the catalogue is already AIDD-owned before testing exact host refresh. */ readonly ownedCatalog?: true; readonly marketplaceSource?: (name: string) => PluginSource; + readonly userPlugin?: boolean; + readonly releaseUpdate?: boolean; } class ActivatorFailingAtUpgrade extends FakeNativePluginActivator { @@ -125,6 +128,18 @@ async function sync(setup: SyncSetup = {}) { manifest, names[0] ); + if (setup.userPlugin) + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "global-plugin", + "1.0.0", + { kind: "local", path: "/global" }, + true, + "user", + names[0] + ) + ); await manifestRepo.save(manifest); for (const name of names) { await registry.save( @@ -204,7 +219,12 @@ async function sync(setup: SyncSetup = {}) { ], ]) ); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + ...(setup.releaseUpdate + ? { refreshOwnedPlugins: [{ toolId: "claude", pluginName: "aidd-context" }] } + : {}), + }); const written = (await fs.fileExists(SHARED_SETTINGS)) ? (JSON.parse(await fs.readFile(SHARED_SETTINGS)) as Record) : undefined; @@ -214,6 +234,17 @@ async function sync(setup: SyncSetup = {}) { /** This flow writes into a file it does not own: whatever a person put there survives, and a * trailing comma they left behind must not take the whole sync down with it. */ describe("the settings file a user also edits", () => { + it("preserves project plugin settings during a Claude release update", async () => { + const { written } = await sync({ + userPlugin: true, + releaseUpdate: true, + enablesPlugins: true, + settings: JSON.stringify({ enabledPlugins: { "another@outside": false } }), + }); + expect(written?.enabledPlugins).toEqual({ + "another@outside": false, + }); + }); it("writes the enabled plugin into a file that did not exist", async () => { const { written } = await sync(); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts index 6b0a0a9d2..fe0cb832b 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts @@ -1,6 +1,7 @@ import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; @@ -24,10 +25,12 @@ describe("native catalogue source provenance", () => { async function hostPluginProofFixture( reading?: HostPluginRegistryReading, options: { - toolId?: "claude" | "codex"; + toolId?: "claude" | "codex" | "copilot"; catalogue?: HostMarketplaceRegistryReading; marketplaceScope?: "project" | "user"; plugin?: boolean; + failUpdate?: boolean; + failBuild?: boolean; } = {} ) { const toolId = options.toolId ?? "codex"; @@ -59,22 +62,32 @@ describe("native catalogue source provenance", () => { }) ); const fs = new InMemoryFileAdapter({ - [`${builtDir}/${toolId === "codex" ? ".agents/plugins" : ".claude-plugin"}/marketplace.json`]: + [`${builtDir}/${toolId === "codex" ? ".agents/plugins" : toolId === "copilot" ? ".plugin" : ".claude-plugin"}/marketplace.json`]: JSON.stringify({ name: "same-name", plugins: [{ name: "test-plugin" }], }), }); fs.setFile("/foreign/cache/marker.json", "foreign bytes"); - const activator = new FakeNativePluginActivator({ available: true }); + const activator = new FakeNativePluginActivator({ + available: true, + failOnUpdate: options.failUpdate ? ["test-plugin@same-name"] : [], + }); + const logger = new CapturingLogger(); const sync = new MarketplaceSyncSettingsUseCase( fs, projectRepo, registry, new DeterministicHasher(), - new CapturingLogger(), + logger, new Map([[toolId, activator]]), - fakeEnsureBuiltMarketplace(), + options.failBuild + ? { + execute: async () => { + throw new Error("catalog unavailable"); + }, + } + : fakeEnsureBuiltMarketplace(), options.catalogue === undefined ? new Map() : new Map([[toolId, new FakeHostMarketplaceRegistryReader(options.catalogue)]]), @@ -91,16 +104,318 @@ describe("native catalogue source provenance", () => { toolId, new FakeNativeMarketplaceSourceReader( activator, - toolId === "codex" ? "effective-list" : "registry", + toolId === "claude" ? "registry" : "effective-list", (path) => (path === builtDir ? "same-name" : undefined), new Map() ), ], ]) ); - return { sync, fs, activator, projectRepo, machineRepo }; + return { sync, fs, activator, projectRepo, machineRepo, logger }; } + it("returns a failed catalogue build to the release upgrade caller", async () => { + const f = await hostPluginProofFixture( + { location: "host", refs: new Map() }, + { toolId: "claude", failBuild: true } + ); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], + approvedRefs: ["test-plugin@same-name"], + }); + expect(result.warnings.join(" ")).toContain("catalog unavailable"); + expect(f.activator.releaseRefreshes).toEqual([]); + }); + + it("refreshes an owned Claude plugin at project scope without changing its settings", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }, { toolId: "claude" }); + await f.sync.execute({ projectRoot: "/A" }); + refs.set("test-plugin@same-name", { enabled: true }); + const settings = resolve("/A", ".claude/settings.json"); + f.fs.setFile(settings, JSON.stringify({ enabledPlugins: { "test-plugin@same-name": false } })); + const before = await f.fs.readFile(settings); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], + approvedRefs: ["test-plugin@same-name"], + }); + expect(result.warnings).toEqual([]); + expect(f.activator.releaseRefreshes).toEqual([ + { ref: "test-plugin@same-name", scope: "project" }, + ]); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect(await f.fs.readFile(settings)).toEqual(before); + }); + + it("does not install a missing owned Claude plugin during a release refresh", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }, { toolId: "claude" }); + await f.sync.execute({ projectRoot: "/A" }); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], + }); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect(f.activator.releaseRefreshes).toEqual([]); + expect(result.warnings.join(" ")).toContain("release refresh skipped"); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toContain( + "test-plugin@same-name" + ); + }); + + it("does not refresh a local Claude installation when setup copied a machine claim", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }, { toolId: "claude" }); + await f.sync.execute({ projectRoot: "/A" }); + const project = f.projectRepo.getCurrent(); + const projectNative = project?.getNativeRegistrations("claude"); + const machine = f.machineRepo.getCurrent(); + if (!project || !projectNative || !machine) throw new Error("missing registrations"); + project.setNativeRegistrations("claude", { ...projectNative, pluginRefs: [] }); + machine.addTool("claude", "1.0.0", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: projectNative.marketplaces, + pluginRefs: [], + pluginClaims: [{ ref: "test-plugin@same-name", dependents: [] }], + }); + refs.set("test-plugin@same-name", { enabled: true }); + await f.sync.execute({ projectRoot: "/A" }); + expect(project.getNativeRegistrations("claude")?.pluginRefs).toContain("test-plugin@same-name"); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], + }); + expect(f.activator.releaseRefreshes).toEqual([]); + expect(result.warnings.join(" ")).toContain("not in the approved preview"); + expect(project.getNativeRegistrations("claude")?.pluginRefs).toContain("test-plugin@same-name"); + }); + + it("reports a Claude native update failure and retains its ownership claim", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture( + { location: "host", refs }, + { toolId: "claude", failUpdate: true } + ); + await f.sync.execute({ projectRoot: "/A" }); + refs.set("test-plugin@same-name", { enabled: true }); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], + approvedRefs: ["test-plugin@same-name"], + }); + expect(result.warnings.join(" ")).toContain("plugin update 'test-plugin@same-name' failed"); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toContain( + "test-plugin@same-name" + ); + }); + + it("does not refresh Claude when the catalogue has a foreign ref", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }, { toolId: "claude" }); + await f.sync.execute({ projectRoot: "/A" }); + refs.set("test-plugin@same-name", { enabled: true }); + refs.set("foreign@same-name", { enabled: true }); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], + }); + expect(f.activator.releaseRefreshes).toEqual([]); + expect(result.warnings.length + result.errors.length).toBeGreaterThan(0); + }); + + it("refreshes an explicitly approved Claude ref without claiming it or another approved ref", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }, { toolId: "claude" }); + await f.sync.execute({ projectRoot: "/A" }); + const project = f.projectRepo.getCurrent(); + const registration = project?.getNativeRegistrations("claude"); + if (!project || !registration) throw new Error("missing registration"); + const legacy = { + ...registration, + marketplaces: registration.marketplaces.map(({ alias, hostName }) => ({ alias, hostName })), + pluginRefs: [], + }; + project.setNativeRegistrations("claude", legacy); + f.logger.reset(); + refs.set("test-plugin@same-name", { enabled: true }); + refs.set("foreign@same-name", { enabled: true }); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], + approvedRefs: ["test-plugin@same-name", "foreign@same-name"], + }); + expect(result.warnings).toEqual([]); + expect(result.errors).toEqual([]); + expect(f.activator.releaseRefreshes).toEqual([ + { ref: "test-plugin@same-name", scope: "project" }, + ]); + expect(project.getNativeRegistrations("claude")).toEqual(legacy); + expect(f.logger.infoMessages.join(" ")).not.toContain("already enabled"); + }); + + it("refreshes only an owned Codex plugin after an explicit framework release update", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }); + await f.sync.execute({ projectRoot: "/A" }); + refs.set("test-plugin@same-name", { enabled: true }); + refs.set("foreign-plugin@another-catalogue", { enabled: true }); + await f.sync.execute({ projectRoot: "/A" }); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "codex", pluginName: "test-plugin" }], + }); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name", "test-plugin@same-name"]); + }); + + it("does not refresh an owned Codex plugin when its catalogue has a foreign ref", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }); + await f.sync.execute({ projectRoot: "/A" }); + refs.set("test-plugin@same-name", { enabled: true }); + refs.set("foreign-plugin@same-name", { enabled: true }); + const result = await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "codex", pluginName: "test-plugin" }], + }); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect(result.warnings.length + result.errors.length).toBeGreaterThan(0); + }); + + it("does not refresh a user-scoped plugin excluded from the project upgrade", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }); + await f.sync.execute({ projectRoot: "/A" }); + const project = f.projectRepo.getCurrent(); + project?.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "global-plugin", + "1.0.0", + { kind: "local", path: "/global" }, + true, + "user", + "alias" + ) + ); + const machine = f.machineRepo.getCurrent(); + const existing = machine?.getNativeRegistrations("codex"); + if (!machine || !existing) throw new Error("missing native claim"); + machine.setNativeRegistrations("codex", { + ...existing, + pluginClaims: [ + ...(existing.pluginClaims ?? []), + { ref: "global-plugin@same-name", dependents: [] }, + ], + }); + refs.set("test-plugin@same-name", { enabled: true }); + refs.set("global-plugin@same-name", { enabled: true }); + await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "codex", pluginName: "test-plugin" }], + }); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name", "test-plugin@same-name"]); + expect( + f.machineRepo + .getCurrent() + ?.getNativeRegistrations("codex") + ?.pluginClaims?.find((claim) => claim.ref === "global-plugin@same-name")?.dependents + ).toEqual([]); + }); + + it("does not activate a disabled user-scoped plugin excluded from the project upgrade", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }); + await f.sync.execute({ projectRoot: "/A" }); + f.projectRepo + .getCurrent() + ?.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "global-plugin", + "1.0.0", + { kind: "local", path: "/global" }, + true, + "user", + "alias" + ) + ); + const machine = f.machineRepo.getCurrent(); + const existing = machine?.getNativeRegistrations("codex"); + if (!machine || !existing) throw new Error("missing native claim"); + machine.setNativeRegistrations("codex", { + ...existing, + pluginClaims: [ + ...(existing.pluginClaims ?? []), + { ref: "global-plugin@same-name", dependents: [] }, + ], + }); + refs.set("test-plugin@same-name", { enabled: true }); + refs.set("global-plugin@same-name", { enabled: false }); + const project = f.projectRepo.getCurrent(); + const native = project?.getNativeRegistrations("codex"); + if (!project || !native) throw new Error("missing project registration"); + project.setNativeRegistrations("codex", { + ...native, + pluginRefs: [...native.pluginRefs, "global-plugin@same-name"], + }); + await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "codex", pluginName: "test-plugin" }], + }); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name", "test-plugin@same-name"]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toContain( + "global-plugin@same-name" + ); + }); + + it("does not add an already-enabled global Copilot plugin to project settings during an upgrade", async () => { + const refs = new Map(); + const f = await hostPluginProofFixture({ location: "host", refs }, { toolId: "copilot" }); + await f.sync.execute({ projectRoot: "/A" }); + const project = f.projectRepo.getCurrent(); + project?.addPlugin( + "copilot", + InstalledPlugin.fromMetadata( + "global-plugin", + "1.0.0", + { kind: "local", path: "/global" }, + true, + "user", + "alias" + ) + ); + const machine = f.machineRepo.getCurrent(); + const existing = machine?.getNativeRegistrations("copilot"); + if (!machine || !existing) throw new Error("missing native claim"); + machine.setNativeRegistrations("copilot", { + ...existing, + pluginClaims: [ + ...(existing.pluginClaims ?? []), + { ref: "global-plugin@same-name", dependents: [] }, + ], + }); + refs.set("test-plugin@same-name", { enabled: true }); + refs.set("global-plugin@same-name", { enabled: true }); + await f.sync.execute({ + projectRoot: "/A", + refreshOwnedPlugins: [{ toolId: "copilot", pluginName: "test-plugin" }], + }); + const settings = JSON.parse( + await f.fs.readFile(resolve("/A", ".github/copilot/settings.json")) + ); + expect(settings.enabledPlugins).toEqual({ "test-plugin@same-name": true }); + expect( + f.machineRepo + .getCurrent() + ?.getNativeRegistrations("copilot") + ?.pluginClaims?.find((claim) => claim.ref === "global-plugin@same-name")?.dependents + ).toEqual([]); + }); + it.each(["project", "user"] as const)( "refuses a %s-scope Claude catalogue if its registry becomes unreadable after the source check", async (marketplaceScope) => { @@ -537,7 +852,7 @@ describe("native catalogue source provenance", () => { plugins: [{ name: "test-plugin" }], }), }); - const settingsPath = `${projectRoot}/.github/copilot/settings.json`; + const settingsPath = resolve(projectRoot, ".github/copilot/settings.json"); const foreignSettings = JSON.stringify({ enabledPlugins: { "foreign@foreign-catalog": true }, extraKnownMarketplaces: { "foreign-catalog": { source: "github" } }, diff --git a/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts new file mode 100644 index 000000000..a4018c3b8 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts @@ -0,0 +1,149 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { + assertGlobalFilesWritable, + readGlobalPluginFiles, +} from "../../../../../src/contexts/framework/application/ownership/global-plugin-files.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const BUILT = "/cache/built/opencode"; + +function distribution(name: string, agents: string[]): PluginDistribution { + const files = agents.map((relativePath) => ({ relativePath, content: "agent" })); + return new PluginDistribution({ + manifest: { name, version: "1.0.0", strict: true }, + format: "claude", + files, + components: { skills: [], commands: [], agents: files, rules: [], hooks: [], mcp: [] }, + }); +} + +const tree = new InMemoryFileAdapter({ + [`${BUILT}/.opencode/skills/aidd-dev/01-plan/SKILL.md`]: "skill", + [`${BUILT}/.opencode/agents/aidd-dev-reviewer.md`]: "reviewer", + [`${BUILT}/.opencode/agents/aidd-dev-tools-reviewer.md`]: "another plugin's", + [`${BUILT}/.opencode/agents/aidd-pm-reviewer.md`]: "another plugin's", + [`${BUILT}/.opencode/commands/aidd-dev-plan.md`]: "command", +}); + +describe("readGlobalPluginFiles", () => { + it("reads the plugin's skills and the agents its source ships, keyed under the user directory", async () => { + const files = await readGlobalPluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "opencode", + distribution("aidd-dev", ["agents/reviewer.md", "agents/nested/ignored.md", "skills/x.md"]) + ); + + expect(files.map((file) => [file.relativePath, file.content])).toEqual([ + ["agents/aidd-dev-reviewer.md", "reviewer"], + ["skills/aidd-dev/01-plan/SKILL.md", "skill"], + ]); + }); + + it("reads nothing for a plugin whose source ships no agent and no skill in the tree", async () => { + const files = await readGlobalPluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "opencode", + distribution("aidd-qa", []) + ); + + expect(files).toEqual([]); + }); + + it("reads nothing for a tool that copies no global file, which has no directory to write either", async () => { + await expect( + assertGlobalFilesWritable( + new InMemoryFileAdapter(), + "claude", + undefined, + "aidd-dev", + [], + "/h" + ) + ).rejects.toThrow("claude: no machine-scope directory is declared."); + }); + + it("reads nothing for a tool that copies no global file", async () => { + const files = await readGlobalPluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "claude", + distribution("aidd-dev", ["agents/reviewer.md"]) + ); + + expect(files).toEqual([]); + }); +}); + +describe("assertGlobalFilesWritable", () => { + const hasher = new DeterministicHasher(); + const HOME = "/home/me"; + const DIR = join(HOME, ".config", "opencode"); + const under = (...parts: string[]) => join(DIR, ...parts); + const file = (relativePath: string, content = "new") => + new InstallationFile({ relativePath, content, hash: hasher.hash(content) }); + + it("returns the user directory when every path stays inside it", async () => { + const fs = new InMemoryFileAdapter({}, hasher); + + await expect( + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + ).resolves.toBe(DIR); + }); + + it.each(["/etc/passwd", "agents/../../escape.md", "agents\\a.md"])( + "refuses the path %s that is not a plain relative path", + async (path) => { + const fs = new InMemoryFileAdapter({}, hasher); + + await expect( + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file(path)], HOME) + ).rejects.toThrow(`opencode: 'aidd-dev' path '${path}' escapes ${DIR}.`); + } + ); + + it("refuses a parent that resolves outside the user directory", async () => { + const fs = new InMemoryFileAdapter({ [under("agents", "kept.md")]: "x" }, hasher); + fs.setSymlink(under("agents"), "/elsewhere"); + + await expect( + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + ).rejects.toThrow(`opencode: 'aidd-dev' parent '${under("agents")}' escapes ${DIR}.`); + }); + + it.each(["mine", "new"])("refuses an unowned file holding %s bytes", async (content) => { + const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: content }, hasher); + + await expect( + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + ).rejects.toThrow( + `opencode: '${under("agents", "a.md")}' already exists and was not installed by aidd` + ); + }); + + it("allows rewriting a file the plugin owns", async () => { + const fs = new InMemoryFileAdapter({ [under("agents", "owned.md")]: "old" }, hasher); + const owned = InstalledPlugin.fromMetadata( + "aidd-dev", + "1.0.0", + { kind: "local", path: "/fw/plugins/aidd-dev" }, + true, + "user" + ).withFiles(new Map([["agents/owned.md", "0".repeat(32)]])); + + await expect( + assertGlobalFilesWritable(fs, "opencode", owned, "aidd-dev", [file("agents/owned.md")], HOME) + ).resolves.toBe(DIR); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/kilo-tui-config.unit.test.ts b/cli/tests/contexts/framework/application/ownership/kilo-tui-config.unit.test.ts new file mode 100644 index 000000000..a2d578cab --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/kilo-tui-config.unit.test.ts @@ -0,0 +1,66 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + planKiloTuiInstall, + planKiloTuiRemoval, +} from "../../../../../src/contexts/framework/application/ownership/tui/kilo-tui-config.js"; +import { FileAdapter } from "../../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../../src/runtime/filesystem/hasher-adapter.js"; + +const dirs: string[] = []; +const fs = new FileAdapter(new HasherAdapter()); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "kilo-tui-")); + dirs.push(root); + const dir = join(root, ".kilo"); + await mkdir(dir); + return { root, dir }; +} +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("Kilo TUI shared configuration", () => { + it("preserves comments, other plugins and unrelated settings through install and removal", async () => { + const { root, dir } = await fixture(); + const path = join(dir, "tui.jsonc"); + await writeFile( + path, + '{\n // User plugins\n "plugin": ["./one.js", "./two.js"],\n /* preferences */ "theme": "dark",\n}\n' + ); + const installed = await planKiloTuiInstall(fs, dir, root); + expect(installed.content).toContain("// User plugins"); + expect(installed.content).toContain("/* preferences */"); + expect(installed.content).toContain('"./one.js"'); + expect(installed.content).toContain('"./two.js"'); + await writeFile(path, installed.content); + const removed = await planKiloTuiRemoval(fs, dir, root, installed.provenance); + expect(removed.content).toContain("// User plugins"); + expect(removed.content).toContain("/* preferences */"); + expect(removed.content).toContain('"./one.js"'); + expect(removed.content).toContain('"./two.js"'); + expect(removed.content).not.toContain("aidd-maintenance"); + }); + + it("refuses unowned entries and later manual removal", async () => { + const { root, dir } = await fixture(); + const plan = await planKiloTuiInstall(fs, dir, root); + await writeFile(plan.path, plan.content); + await expect(planKiloTuiInstall(fs, dir, root)).rejects.toThrow("without ownership"); + await writeFile(plan.path, "{}\n"); + await expect(planKiloTuiInstall(fs, dir, root, plan.provenance)).rejects.toThrow("was removed"); + }); + + it("refuses symlink escapes before reading or writing", async () => { + const { root, dir } = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "kilo-outside-")); + dirs.push(outside); + await writeFile(join(outside, "settings.json"), "{}"); + await symlink(join(outside, "settings.json"), join(dir, "tui.json")); + await expect(planKiloTuiInstall(fs, dir, root)).rejects.toThrow("outside project root"); + expect(await readFile(join(outside, "settings.json"), "utf8")).toBe("{}"); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts index 64e82c173..6dfb9d01f 100644 --- a/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { assertNoForeignNativeRefs, inspectNativeMarketplaceSource, @@ -29,6 +32,22 @@ function reader( } describe("current native marketplace source proof", () => { + it.each(["claude", "codex", "copilot"] as const)( + "%s protects a foreign disabled ref unless uninstall leaves that tombstone", + async (toolId) => { + const refs = new Map([["foreign@real-catalog", { enabled: false }]]); + const result = assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", refs }) }, + "real-catalog", + new Set(), + "/project-b", + toolId + ); + if (toolId === "copilot") await expect(result).resolves.toEqual(refs); + else await expect(result).rejects.toThrow(/foreign host ref/); + } + ); + it.each([new Error("source permission denied"), "source permission denied"])( "reports host source read failures as unproven without throwing", async (error) => { @@ -80,21 +99,28 @@ describe("current native marketplace source proof", () => { }, }; await expect( - assertNoForeignNativeRefs(host, "real-catalog", new Set(["mine@real-catalog"]), "/project-b") + assertNoForeignNativeRefs( + host, + "real-catalog", + new Set(["mine@real-catalog"]), + "/project-b", + "claude" + ) ).rejects.toThrow(/foreign host ref 'foreign@real-catalog'/); expect(requestedRoot).toBe("/project-b"); }); it("fails closed on unreadable or missing host plugin readers, but accepts explicit absence", async () => { await expect( - assertNoForeignNativeRefs(undefined, "real-catalog", new Set(), "/project-b") + assertNoForeignNativeRefs(undefined, "real-catalog", new Set(), "/project-b", "claude") ).rejects.toThrow(/reader unavailable/); await expect( assertNoForeignNativeRefs( { read: async () => ({ location: "host plugins", unreadable: "EACCES" }) }, "real-catalog", new Set(), - "/project-b" + "/project-b", + "claude" ) ).rejects.toThrow(/EACCES/); await expect( @@ -106,7 +132,8 @@ describe("current native marketplace source proof", () => { }, "real-catalog", new Set(), - "/project-b" + "/project-b", + "claude" ) ).rejects.toThrow(/real-catalog.*host plugin registry read failed.*EACCES/); expect( @@ -114,7 +141,8 @@ describe("current native marketplace source proof", () => { { read: async () => ({ location: "host plugins", absent: true }) }, "real-catalog", new Set(), - "/project-b" + "/project-b", + "claude" ) ).toEqual(new Map()); }); @@ -129,7 +157,8 @@ describe("current native marketplace source proof", () => { { read: async () => ({ location: "host plugins", refs }) }, "real-catalog", new Set(["mine@real-catalog"]), - "/project-b" + "/project-b", + "claude" ) ).toEqual(refs); }); diff --git a/cli/tests/contexts/framework/application/ownership/upgrade-user-framework-use-case.integration.test.ts b/cli/tests/contexts/framework/application/ownership/upgrade-user-framework-use-case.integration.test.ts new file mode 100644 index 000000000..b9d4be423 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/upgrade-user-framework-use-case.integration.test.ts @@ -0,0 +1,511 @@ +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import type { PinnedUserFramework } from "../../../../../src/contexts/framework/application/ownership/release/pinned-user-framework.js"; +import { UpgradeUserFrameworkUseCase } from "../../../../../src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.js"; +import { UserReleaseJournal } from "../../../../../src/contexts/framework/application/ownership/release/user-release-journal.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const SHA = "1".repeat(40); +const HOME = "/test-home"; +const PROJECT = "/current-project"; +const FILE = "skills/aidd-context/00-onboard/SKILL.md"; +const SOURCE = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", +}; + +async function fixture(options: { currentRef?: string; targetVersion?: string } = {}) { + const hasher = new DeterministicHasher(); + const fs = new InMemoryFileAdapter({}, hasher); + const logger = new CapturingLogger(); + const manifest = Manifest.create(); + const registry = new InMemoryMarketplaceRegistry(); + const marketplace = Marketplace.fromJSON({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework", ref: "v5.12.0" }, + scope: "user", + addedAt: "2026-01-01", + version: "5.12.0", + }); + await registry.save(PROJECT, marketplace); + for (const tool of ["opencode", "kilo"] as const) { + manifest.addTool(tool, "5.0.0", []); + const plugin = InstalledPlugin.fromMetadata( + "aidd-context", + "2.9.0", + { ...SOURCE, ref: options.currentRef ?? SOURCE.ref }, + true, + "user", + "aidd-framework" + ) + .withFiles(new Map([[FILE, hasher.hash("old").value]])) + .withDependents(["/project-a", "/project-b"]); + manifest.addPlugin(tool, plugin); + await fs.writeFile(join(HOME, ".config", tool, FILE), "old"); + } + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [], + pluginRefs: ["aidd-context@aidd-framework", "other@different"], + pluginClaims: [ + { ref: "aidd-context@aidd-framework", dependents: ["/native-project"] }, + { ref: "other@different", dependents: [] }, + ], + }); + const native = manifest.getNativeRegistrations("codex"); + const repo = new InMemoryManifestRepository(manifest); + const dist = new PluginDistribution({ + manifest: { name: "aidd-context", version: options.targetVersion ?? "2.10.0" }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }); + const file = new InstallationFile({ + relativePath: FILE, + content: "new", + hash: hasher.hash("new"), + }); + const build = vi.fn(async () => ({ + sha: SHA, + dist, + files: [file], + })); + const sessionEvents: string[] = []; + const builder: PinnedUserFramework = { + build, + async withSession(action: (session: PinnedUserFramework) => Promise): Promise { + sessionEvents.push("opened"); + try { + return await action({ build }); + } finally { + sessionEvents.push("closed"); + } + }, + }; + const updater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), + hasher, + { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: registry, + homedir: () => HOME, + } + ); + let journalContent: string | null = null; + const store = { + read: vi.fn(async () => journalContent), + create: vi.fn(async (content: string) => { + journalContent = content; + }), + remove: vi.fn(async () => { + journalContent = null; + }), + }; + const journal = new UserReleaseJournal(fs, hasher, repo, { home: () => HOME, store }); + const resolvePublishedRelease = vi.fn( + async (_repo: string, _release: string): Promise<{ title: string | null } | null> => ({ + title: "Release title", + }) + ); + const useCase = new UpgradeUserFrameworkUseCase({ + repo, + builder, + updater, + journal, + logger, + resolvePublishedRelease, + }); + const writes = vi.spyOn(fs, "writeFile"); + const deletes = vi.spyOn(fs, "deleteFile"); + return { + useCase, + repo, + manifest, + registry, + marketplace, + native, + fs, + store, + build, + sessionEvents, + updater, + resolvePublishedRelease, + writes, + deletes, + }; +} + +const accept = async () => true; + +describe("machine-scoped framework release update", () => { + it("previews versions, refs, dependents and skipped native claims without mutation when declined", async () => { + const f = await fixture(); + const before = f.manifest.toJSON(); + const confirm = vi.fn(async () => false); + const result = await f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm }); + expect(f.sessionEvents).toEqual(["opened", "closed"]); + expect(confirm).toHaveBeenCalledWith({ + release: "v5.13.0", + title: "Release title", + nativeSkipped: 1, + targets: ["opencode", "kilo"].map((toolId) => ({ + toolId, + name: "aidd-context", + currentRef: "v5.12.0", + currentVersion: "2.9.0", + targetVersion: "2.10.0", + dependents: ["/project-a", "/project-b"], + })), + }); + expect(result).toEqual({ updated: [], nativeSkipped: 1, cancelled: true }); + expect(f.repo.getCurrent()?.toJSON()).toEqual(before); + expect(f.repo.saveCount).toBe(0); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.deletes).not.toHaveBeenCalled(); + expect((await f.registry.list(PROJECT))[0]?.toJSON()).toEqual(f.marketplace.toJSON()); + }); + + it("commits only flat-owned plugins at the same pinned commit and preserves native claims and marketplace", async () => { + const f = await fixture(); + const result = await f.useCase.execute({ + projectRoot: PROJECT, + release: "v5.13.0", + confirm: accept, + }); + expect(result).toEqual({ + updated: ["opencode:aidd-context", "kilo:aidd-context"], + nativeSkipped: 1, + cancelled: false, + }); + expect(f.build).toHaveBeenNthCalledWith(1, "v5.13.0", "opencode", "aidd-context", undefined); + expect(f.build).toHaveBeenNthCalledWith(2, "v5.13.0", "kilo", "aidd-context", SHA); + expect(f.resolvePublishedRelease).toHaveBeenCalledWith("ai-driven-dev/framework", "v5.13.0"); + for (const tool of ["opencode", "kilo"] as const) { + const plugin = f.repo.getCurrent()?.getPlugins(tool)[0]; + expect(plugin?.source).toEqual({ ...SOURCE, ref: "v5.13.0", sha: SHA }); + expect(plugin?.version).toBe("2.10.0"); + expect(plugin?.dependents).toEqual(["/project-a", "/project-b"]); + expect(await f.fs.readFile(join(HOME, ".config", tool, FILE))).toBe("new"); + } + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")).toEqual(f.native); + expect((await f.registry.list(PROJECT))[0]?.toJSON()).toEqual(f.marketplace.toJSON()); + expect(f.store.create).toHaveBeenCalledTimes(2); + expect(f.store.remove).toHaveBeenCalledTimes(2); + }); + + it.each([ + { kind: "local", path: "/checkout" }, + { ...SOURCE, url: "https://example.com/foreign.git" }, + { ...SOURCE, path: "plugins/other" }, + { ...SOURCE, ref: "main" }, + ])("refuses a non-official or unpinned source before changing files: %s", async (source) => { + const f = await fixture(); + const old = f.manifest.getPlugins("opencode")[0]; + f.manifest.updatePlugin("opencode", InstalledPlugin.fromJSON({ ...old.toJSON(), source })); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm: accept }) + ).rejects.toThrow(/pinned official/); + expect(f.build).not.toHaveBeenCalled(); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses a downgrade before changing files", async () => { + const f = await fixture(); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.11.0", confirm: accept }) + ).rejects.toThrow(/downgrade/); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.saveCount).toBe(0); + }); + + it.each([ + { currentRef: "v5.12.0", target: "v5.13.0-beta.1" }, + { currentRef: "v5.12.0-alpha.1", target: "v5.13.0-beta.1" }, + ])( + "updates to an explicitly selected channel after confirmation ($currentRef → $target)", + async ({ currentRef, target }) => { + const declined = await fixture({ currentRef }); + const confirm = vi.fn(async () => false); + const cancelled = await declined.useCase.execute({ + projectRoot: PROJECT, + release: target, + confirm, + }); + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ release: target })); + expect(cancelled.cancelled).toBe(true); + expect(declined.store.create).not.toHaveBeenCalled(); + expect(declined.writes).not.toHaveBeenCalled(); + + const accepted = await fixture({ currentRef }); + const result = await accepted.useCase.execute({ + projectRoot: PROJECT, + release: target, + confirm: accept, + }); + expect(result.updated).toHaveLength(2); + expect(accepted.repo.getCurrent()?.getPlugins("opencode")[0]?.source).toEqual({ + ...SOURCE, + ref: target, + sha: SHA, + }); + } + ); + + it("accepts a newer release on the existing prerelease channel", async () => { + const f = await fixture({ currentRef: "v5.12.0-beta.1" }); + const result = await f.useCase.execute({ + projectRoot: PROJECT, + release: "v5.13.0-beta.1", + confirm: accept, + }); + expect(result.updated).toHaveLength(2); + expect(f.repo.getCurrent()?.getPlugins("opencode")[0]?.source).toEqual({ + ...SOURCE, + ref: "v5.13.0-beta.1", + sha: SHA, + }); + }); + + it("treats build metadata as part of neither prerelease channel nor version ordering", async () => { + const f = await fixture({ currentRef: "v5.12.0+build-1" }); + const result = await f.useCase.execute({ + projectRoot: PROJECT, + release: "v5.13.0+build-2", + confirm: accept, + }); + expect(result.updated).toHaveLength(2); + }); + + it("refuses a target with an older plugin version or an unpublished release", async () => { + const old = await fixture({ targetVersion: "2.8.0" }); + await expect( + old.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm: accept }) + ).rejects.toThrow(/plugin downgrade/); + expect(old.store.create).not.toHaveBeenCalled(); + const unpublished = await fixture(); + unpublished.resolvePublishedRelease.mockResolvedValue(null); + await expect( + unpublished.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm: accept }) + ).rejects.toThrow(/not available/); + expect(unpublished.build).not.toHaveBeenCalled(); + expect(unpublished.store.create).not.toHaveBeenCalled(); + }); + + it.each(["5.13.0", "vnot-a-release", "main"])( + "refuses invalid release %s before checking the network or changing files", + async (release) => { + const f = await fixture(); + const confirm = vi.fn(accept); + await expect(f.useCase.execute({ projectRoot: PROJECT, release, confirm })).rejects.toThrow( + /root release tag/ + ); + expect(f.resolvePublishedRelease).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.saveCount).toBe(0); + } + ); + + it("refuses a missing machine manifest before inspecting releases", async () => { + const f = await fixture(); + await f.repo.delete(); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm: accept }) + ).rejects.toThrow("No machine installation manifest"); + expect(f.resolvePublishedRelease).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + }); + + it("refuses when only native claims or unrelated marketplace plugins remain", async () => { + const f = await fixture(); + f.manifest.removePlugin("opencode", "aidd-context"); + f.manifest.removePlugin("kilo", "aidd-context"); + f.manifest.addPlugin( + "opencode", + InstalledPlugin.fromMetadata("third-party", "1.0.0", SOURCE, true, "user", "other") + ); + const confirm = vi.fn(accept); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm }) + ).rejects.toThrow("No flat machine-scoped AIDD plugins"); + expect(confirm).not.toHaveBeenCalled(); + expect(f.sessionEvents).toEqual(["opened", "closed"]); + expect(f.build).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")).toEqual(f.native); + }); + + it("does not update either tool when the second tool's tracked file was edited", async () => { + const f = await fixture(); + const path = join(HOME, ".config", "kilo", FILE); + await f.fs.writeFile(path, "user edit"); + f.writes.mockClear(); + const confirm = vi.fn(accept); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm }) + ).rejects.toThrow(); + expect(confirm).not.toHaveBeenCalled(); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(await f.fs.readFile(path)).toBe("user edit"); + expect(await f.fs.readFile(join(HOME, ".config", "opencode", FILE))).toBe("old"); + expect(f.repo.saveCount).toBe(0); + }); + + it("reports the completed tool if the next journal cannot be created", async () => { + const f = await fixture(); + f.store.create + .mockImplementationOnce(async () => {}) + .mockRejectedValueOnce(new Error("disk full")); + const operation = f.useCase.execute({ + projectRoot: PROJECT, + release: "v5.13.0", + confirm: accept, + }); + await expect(operation).rejects.toThrow("Completed: opencode:aidd-context"); + expect(f.repo.getCurrent()?.getPlugins("opencode")[0]?.version).toBe("2.10.0"); + expect(f.repo.getCurrent()?.getPlugins("kilo")[0]?.version).toBe("2.9.0"); + expect(await f.fs.readFile(join(HOME, ".config", "kilo", FILE))).toBe("old"); + }); + + it.each([ + { + contribution: "MCP entries", + add: (p: InstalledPlugin) => + InstalledPlugin.withMcpEntries(p, new Map([["server", "a".repeat(32)]])), + }, + { + contribution: "project hooks", + add: (p: InstalledPlugin) => + InstalledPlugin.withProjectHooks(p, { entries: [], scripts: new Map() }), + }, + { + contribution: "component paths", + add: (p: InstalledPlugin) => + InstalledPlugin.fromJSON({ ...p.toJSON(), componentPaths: { skill: "skills/example" } }), + }, + ])("refuses unsupported machine $contribution before preview", async ({ add }) => { + const f = await fixture(); + const plugin = f.manifest.getPlugins("opencode")[0]; + f.manifest.updatePlugin("opencode", add(plugin)); + const confirm = vi.fn(accept); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm }) + ).rejects.toThrow("machine ownership includes unsupported contributions"); + expect(confirm).not.toHaveBeenCalled(); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.saveCount).toBe(0); + }); + + it("updates only user-scoped flat copies and leaves a project-scoped plugin untouched", async () => { + const f = await fixture(); + const projectPlugin = InstalledPlugin.fromJSON({ + ...f.manifest.getPlugins("kilo")[0].toJSON(), + scope: "project", + }); + f.manifest.updatePlugin("kilo", projectPlugin); + f.manifest.addPlugin( + "codex", + InstalledPlugin.fromMetadata("aidd-context", "2.9.0", SOURCE, true, "user", "aidd-framework") + ); + const result = await f.useCase.execute({ + projectRoot: PROJECT, + release: "v5.13.0", + confirm: accept, + }); + expect(result.updated).toEqual(["opencode:aidd-context"]); + expect(f.repo.getCurrent()?.getPlugins("kilo")[0]?.toJSON()).toEqual(projectPlugin.toJSON()); + expect(await f.fs.readFile(join(HOME, ".config", "kilo", FILE))).toBe("old"); + expect(f.repo.getCurrent()?.getPlugins("codex")[0]?.source).toEqual(SOURCE); + }); + + it("advances a framework ref even when the plugin version itself is unchanged", async () => { + const f = await fixture({ targetVersion: "2.9.0" }); + const result = await f.useCase.execute({ + projectRoot: PROJECT, + release: "v5.13.0", + confirm: accept, + }); + expect(result.updated).toHaveLength(2); + expect(f.repo.getCurrent()?.getPlugins("opencode")[0]?.source).toEqual({ + ...SOURCE, + ref: "v5.13.0", + sha: SHA, + }); + }); + + it("accepts reapplying the current release to bring a lagging plugin version up to date", async () => { + const f = await fixture(); + const result = await f.useCase.execute({ + projectRoot: PROJECT, + release: "v5.12.0", + confirm: accept, + }); + expect(result.updated).toHaveLength(2); + expect(f.repo.getCurrent()?.getPlugins("opencode")[0]?.version).toBe("2.10.0"); + expect(f.repo.getCurrent()?.getPlugins("opencode")[0]?.source).toEqual({ ...SOURCE, sha: SHA }); + }); + + it("refuses to proceed if a machine plugin update cannot be planned", async () => { + const f = await fixture(); + vi.spyOn(f.updater, "planUpdate").mockResolvedValue(null); + const confirm = vi.fn(accept); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm }) + ).rejects.toThrow("unable to plan machine update"); + expect(confirm).not.toHaveBeenCalled(); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses a change to a shared TUI configuration before asking for confirmation", async () => { + const f = await fixture(); + const original = f.updater.planUpdate.bind(f.updater); + vi.spyOn(f.updater, "planUpdate").mockImplementation(async (...args) => { + const plan = await original(...args); + return plan === null + ? null + : { + ...plan, + next: plan.next.withTuiPlugin({ + config: "tui.jsonc", + entry: "./hooks/aidd-maintenance/kilo-tui.js", + }), + }; + }); + const confirm = vi.fn(accept); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm }) + ).rejects.toThrow("change a shared TUI configuration"); + expect(confirm).not.toHaveBeenCalled(); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.saveCount).toBe(0); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts index 8ffe7500e..379f7eb71 100644 --- a/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts +++ b/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts @@ -1,6 +1,7 @@ import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -29,6 +30,69 @@ const HOST = "real-catalog"; const REF = `test-plugin@${HOST}`; const OPTIONS = { name: ALIAS, projectRoot: "/A", autoConfirm: true, scope: "user" as const }; +describe("Kilo TUI during user marketplace removal", () => { + async function fixture(config: string) { + const fs = new InMemoryFileAdapter(); + const path = join(process.env.HOME || homedir(), ".config/kilo/tui.jsonc"); + fs.setFile(path, config); + const machine = Manifest.create(); + machine.addTool("kilo", "1.0.0", []); + machine.addPlugin( + "kilo", + InstalledPlugin.fromMetadata( + "aidd-maintenance", + "1.0.0", + { kind: "local", path: "/source" }, + false, + "user", + ALIAS + ).withTuiPlugin({ + config: "tui.jsonc", + entry: "./hooks/aidd-maintenance/kilo-tui.js", + }) + ); + const repo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-01-01", + }) + ); + return { + fs, + path, + repo, + registry, + useCase: new UserMarketplaceRemoveUseCase( + fs, + repo, + registry, + new NativeHostRegistrationGate(new Map(), new Map()) + ), + }; + } + + it("removes only its registered entry", async () => { + const f = await fixture('{"plugin":["other", "./hooks/aidd-maintenance/kilo-tui.js"]}'); + await f.useCase.execute(OPTIONS); + expect(await f.fs.readFile(f.path)).toContain("other"); + expect(await f.fs.readFile(f.path)).not.toContain("./hooks/aidd-maintenance/kilo-tui.js"); + expect(f.repo.getCurrent()?.getPlugins("kilo")).toEqual([]); + }); + + it("retains the source and claims when the registered entry is unprovable", async () => { + const f = await fixture('{"plugin":["other"]}'); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/tracked entry changed/); + expect(f.repo.getCurrent()?.getPlugins("kilo")).toHaveLength(1); + expect((await f.registry.list("/A")).some((entry) => entry.name === ALIAS)).toBe(true); + expect(f.repo.saveCount).toBe(0); + }); +}); + function proofFor(): NativeMarketplaceSource { return { kind: "effective-list", diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts index e20db6c2b..f69a000cc 100644 --- a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts @@ -1,15 +1,22 @@ import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import type { PinnedUserFramework } from "../../../../../src/contexts/framework/application/ownership/release/pinned-user-framework.js"; import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; function fixture(installedVersion = "0.0.1") { const fs = new InMemoryFileAdapter(); @@ -40,6 +47,108 @@ function fixture(installedVersion = "0.0.1") { } describe("UserPluginFileUpdater public update contract", () => { + it("writes back a recorded file that was deleted", async () => { + const f = fixture(); + const path = "sample-plugin/skills/demo/SKILL.md"; + const owned = f.plugin.withFiles(new Map([[path, f.hasher.hash(f.content).value]])); + + const result = await f.updater.update(owned, "cursor", "/A", f.logger); + + expect(await f.fs.readFile(join(f.base, path))).toBe(f.content); + expect(result?.files.has(path)).toBe(true); + expect(f.logger.warnMessages.filter((m) => m.includes("left in place"))).toEqual([]); + }); + + it("refuses to rebuild global files without the shared source, rather than write a project's layout", async () => { + const f = fixture(); + await expect(f.updater.update(f.plugin, "opencode", "/A", f.logger)).rejects.toThrow( + "run `aidd setup --global`" + ); + }); + + it("rebuilds a pinned machine plugin from its recorded commit, not the shared marketplace", async () => { + const fs = new InMemoryFileAdapter(); + const hasher = new DeterministicHasher(); + const logger = new CapturingLogger(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/project", + Marketplace.create({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework", ref: "v5.12.0" }, + scope: "user", + addedAt: "2026-01-01", + }) + ); + const content = "# pinned release"; + const file = new InstallationFile({ + relativePath: "skills/aidd-context/test/SKILL.md", + content, + hash: hasher.hash(content), + }); + const dist = new PluginDistribution({ + manifest: { name: "aidd-context", version: "2.10.0" }, + format: "claude", + files: [], + components: { commands: [], agents: [], rules: [], skills: [], hooks: [], mcp: [] }, + }); + const sha = "a".repeat(40); + let rebuilt = ""; + const pinned: PinnedUserFramework = { + async build(ref, toolId, pluginName, commit) { + rebuilt = `${ref}:${toolId}:${pluginName}:${commit}`; + return { sha, dist, files: [file] }; + }, + }; + const updater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader(new FixturePluginFetcher(), { + read: async () => { + throw new Error("must not fetch the shared plugin source"); + }, + }), + hasher, + { + ensureBuilt: { + execute: async () => { + throw new Error("must not build the shared marketplace"); + }, + }, + marketplaceRegistry: registry, + homedir: () => "/home/user", + pinnedFramework: pinned, + } + ); + const source = { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.13.0", + sha, + } as const; + const plugin = InstalledPlugin.fromMetadata( + "aidd-context", + "2.10.0", + source, + false, + "user", + "aidd-framework" + ); + const plan = await updater.planUpdate(plugin, "opencode", "/project", logger, true); + if (!plan) throw new Error("expected rebuild plan"); + const next = await updater.applyUpdate(plan, logger); + expect(rebuilt).toBe(`v5.13.0:opencode:aidd-context:${sha}`); + expect(next.source).toEqual(source); + expect(await fs.readFile("/home/user/.config/opencode/skills/aidd-context/test/SKILL.md")).toBe( + content + ); + expect((await registry.list("/project"))[0]?.source).toEqual({ + kind: "github", + repo: "ai-driven-dev/framework", + ref: "v5.12.0", + }); + }); + it.each(["1.0.0", "2.0.0"])( "leaves version %s unchanged when no upgrade exists", async (version) => { @@ -88,3 +197,101 @@ describe("UserPluginFileUpdater public update contract", () => { expect(await f.fs.readFile("/foreign/skills/demo/SKILL.md")).toBe("foreign"); }); }); + +describe("Kilo user-scope TUI registration", () => { + async function kiloFixture() { + const fs = new InMemoryFileAdapter(); + const hasher = new DeterministicHasher(); + const logger = new CapturingLogger(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/project", + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-01-01", + }) + ); + fs.setFile("/built/kilo/.kilo/hooks/aidd-maintenance/kilo-tui.js", "export default {};"); + fs.setFile("/home/user/.config/kilo/tui.jsonc", '{\n // keep\n "plugin": ["other"]\n}\n'); + const dist = new PluginDistribution({ + manifest: { name: "aidd-maintenance", version: "1.0.0" }, + format: "claude", + files: [], + components: { commands: [], agents: [], rules: [], skills: [], hooks: [], mcp: [] }, + }); + const updater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), + hasher, + { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: registry, + homedir: () => "/home/user", + } + ); + const plugin = InstalledPlugin.fromMetadata( + "aidd-maintenance", + "0.1.0", + { kind: "local", path: "/source" }, + false, + "user", + "aidd-framework" + ); + return { fs, updater, plugin, logger }; + } + + it("registers the built TUI module, preserves existing configuration and records ownership", async () => { + const f = await kiloFixture(); + const installed = await f.updater.update(f.plugin, "kilo", "/project", f.logger); + const config = await f.fs.readFile("/home/user/.config/kilo/tui.jsonc"); + expect(config).toContain("// keep"); + expect(config).toContain('"other"'); + expect(config).toContain("./hooks/aidd-maintenance/kilo-tui.js"); + expect(installed?.tuiPlugin?.config).toBe("tui.jsonc"); + expect(await f.fs.readFile("/home/user/.config/kilo/hooks/aidd-maintenance/kilo-tui.js")).toBe( + "export default {};" + ); + }); + + it("refuses an unowned TUI entry before touching plugin files", async () => { + const f = await kiloFixture(); + f.fs.setFile( + "/home/user/.config/kilo/tui.jsonc", + '{"plugin":["./hooks/aidd-maintenance/kilo-tui.js"]}' + ); + await expect(f.updater.update(f.plugin, "kilo", "/project", f.logger)).rejects.toThrow( + /without ownership/ + ); + expect( + await f.fs.fileExists("/home/user/.config/kilo/hooks/aidd-maintenance/kilo-tui.js") + ).toBe(false); + }); + + it("rechecks the TUI config after planning and refuses before writing plugin files", async () => { + const f = await kiloFixture(); + const plan = await f.updater.planUpdate(f.plugin, "kilo", "/project", f.logger); + if (plan === null) throw new Error("expected update plan"); + const changed = '{"plugin":["./hooks/aidd-maintenance/kilo-tui.js"]}'; + f.fs.setFile("/home/user/.config/kilo/tui.jsonc", changed); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow(/without ownership/); + expect(await f.fs.readFile("/home/user/.config/kilo/tui.jsonc")).toBe(changed); + expect( + await f.fs.fileExists("/home/user/.config/kilo/hooks/aidd-maintenance/kilo-tui.js") + ).toBe(false); + }); + + it("refuses when the TUI config switches files after planning", async () => { + const f = await kiloFixture(); + const plan = await f.updater.planUpdate(f.plugin, "kilo", "/project", f.logger); + if (plan === null) throw new Error("expected update plan"); + await f.fs.deleteFile("/home/user/.config/kilo/tui.jsonc"); + f.fs.setFile("/home/user/.config/kilo/tui.json", '{"plugin":["other"]}'); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow(/configuration changed/); + expect( + await f.fs.fileExists("/home/user/.config/kilo/hooks/aidd-maintenance/kilo-tui.js") + ).toBe(false); + expect(await f.fs.readFile("/home/user/.config/kilo/tui.json")).toBe('{"plugin":["other"]}'); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-rebuild.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-rebuild.integration.test.ts new file mode 100644 index 000000000..7873a3e5e --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-rebuild.integration.test.ts @@ -0,0 +1,410 @@ +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { FileAdapter } from "../../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../../src/runtime/filesystem/hasher-adapter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixture(tool: "opencode" | "kilo") { + const home = await mkdtemp(join(tmpdir(), "aidd-machine-rebuild-")); + roots.push(home); + const hasher = new HasherAdapter(); + const fs = new FileAdapter(hasher); + const content = "# installed content\n"; + const path = "skills/aidd-context/00-onboard/SKILL.md"; + const file = new InstallationFile({ relativePath: path, content, hash: hasher.hash(content) }); + const files = [file]; + const dist = new PluginDistribution({ + manifest: { name: "aidd-context", version: "2.9.0" }, + format: "claude", + files: [], + components: { commands: [], agents: [], rules: [], skills: [], hooks: [], mcp: [] }, + }); + const source = { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + sha: "a".repeat(40), + } as const; + const plugin = InstalledPlugin.fromMetadata( + "aidd-context", + "2.9.0", + source, + false, + "user" + ).withFiles(new Map([[path, file.hash.value]])); + const updater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), + hasher, + { + ensureBuilt: { + execute: async () => { + throw new Error("unexpected shared build"); + }, + }, + marketplaceRegistry: new InMemoryMarketplaceRegistry(), + homedir: () => home, + pinnedFramework: { build: async () => ({ sha: source.sha, dist, files }) }, + } + ); + const target = join(home, ".config", tool, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content); + return { + fs, + updater, + plugin, + target, + content, + home, + hasher, + files, + dist, + source, + logger: new CapturingLogger(), + }; +} + +describe("rebuilding an owned machine plugin", () => { + it("refuses a missing owned file before a prepared release update", async () => { + const f = await fixture("opencode"); + await rm(f.target); + await expect( + f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true, { + sha: f.source.sha, + dist: f.dist, + files: f.files, + }) + ).rejects.toThrow(/unsafe recorded files/); + }); + + it.each(["opencode", "kilo"] as const)("restores a deleted %s file", async (tool) => { + const f = await fixture(tool); + await rm(f.target); + const plan = await f.updater.planUpdate(f.plugin, tool, "/project", f.logger, true); + if (!plan) throw new Error("expected rebuild plan"); + const installed = await f.updater.applyUpdate(plan, f.logger); + expect(await readFile(f.target, "utf8")).toBe(f.content); + expect(installed.files).toEqual(f.plugin.files); + }); + + it("refuses an edited tracked file without changing it", async () => { + const f = await fixture("opencode"); + await writeFile(f.target, "user edit\n"); + await expect( + f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true) + ).rejects.toThrow(/edited/); + expect(await readFile(f.target, "utf8")).toBe("user edit\n"); + }); + + it("refuses a file created after the rebuild was planned", async () => { + const f = await fixture("opencode"); + await rm(f.target); + const plan = await f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true); + if (!plan) throw new Error("expected rebuild plan"); + await writeFile(f.target, "new user content\n"); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow(/edited/); + expect(await readFile(f.target, "utf8")).toBe("new user content\n"); + }); + + it.skipIf(process.platform === "win32")("does not replace a dangling symlink", async () => { + const f = await fixture("opencode"); + await rm(f.target); + await symlink("/definitely-missing-aidd-target", f.target); + await expect( + f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true) + ).rejects.toThrow(); + expect(await readlink(f.target)).toBe("/definitely-missing-aidd-target"); + }); + + it.skipIf(process.platform === "win32")( + "does not replace an existing tracked symlink when another file is missing", + async () => { + const f = await fixture("opencode"); + const relativePath = "skills/aidd-context/00-onboard/other.md"; + const file = new InstallationFile({ + relativePath, + content: f.content, + hash: f.hasher.hash(f.content), + }); + f.files.push(file); + const plugin = f.plugin.withFiles( + new Map([...f.plugin.files, [relativePath, file.hash.value]]) + ); + const sibling = join(f.home, ".config/opencode/owned-target.md"); + await writeFile(sibling, f.content); + await rm(f.target); + await symlink(sibling, f.target); + await expect( + f.updater.planUpdate(plugin, "opencode", "/project", f.logger, true) + ).rejects.toThrow(/symlink/); + expect(await readlink(f.target)).toBe(sibling); + } + ); + + it.skipIf(process.platform === "win32")( + "rechecks an existing tracked file turned into a symlink after planning", + async () => { + const f = await fixture("opencode"); + const relativePath = "skills/aidd-context/00-onboard/other.md"; + const file = new InstallationFile({ + relativePath, + content: f.content, + hash: f.hasher.hash(f.content), + }); + f.files.push(file); + const plugin = f.plugin.withFiles( + new Map([...f.plugin.files, [relativePath, file.hash.value]]) + ); + const sibling = join(f.home, ".config/opencode/owned-target.md"); + await writeFile(sibling, f.content); + const plan = await f.updater.planUpdate(plugin, "opencode", "/project", f.logger, true); + if (!plan) throw new Error("expected rebuild plan"); + await rm(f.target); + await symlink(sibling, f.target); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow(/symlink/); + expect(await readlink(f.target)).toBe(sibling); + } + ); + + it.skipIf(process.platform === "win32")( + "rechecks a dangling symlink created after planning", + async () => { + const f = await fixture("opencode"); + await rm(f.target); + const plan = await f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true); + if (!plan) throw new Error("expected rebuild plan"); + await symlink("/definitely-missing-aidd-target", f.target); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow(); + expect(await readlink(f.target)).toBe("/definitely-missing-aidd-target"); + } + ); + + it.skipIf(process.platform === "win32")( + "refuses an untracked destination symlink even when its bytes match", + async () => { + const f = await fixture("opencode"); + const relativePath = "skills/aidd-context/00-onboard/new.md"; + const newPath = join(f.home, ".config/opencode", relativePath); + const sibling = join(f.home, ".config/opencode/user-file.md"); + f.files.push( + new InstallationFile({ relativePath, content: f.content, hash: f.hasher.hash(f.content) }) + ); + await writeFile(sibling, f.content); + await rm(f.target); + await symlink(sibling, newPath); + await expect( + f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true) + ).rejects.toThrow(newPath); + expect(await readlink(newPath)).toBe(sibling); + expect(await readFile(sibling, "utf8")).toBe(f.content); + } + ); + + it.skipIf(process.platform === "win32")( + "rechecks an untracked destination symlink created after planning", + async () => { + const f = await fixture("opencode"); + const relativePath = "skills/aidd-context/00-onboard/new.md"; + const newPath = join(f.home, ".config/opencode", relativePath); + const sibling = join(f.home, ".config/opencode/user-file.md"); + f.files.push( + new InstallationFile({ relativePath, content: f.content, hash: f.hasher.hash(f.content) }) + ); + await writeFile(sibling, "user content\n"); + await rm(f.target); + const plan = await f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true); + if (!plan) throw new Error("expected rebuild plan"); + await symlink(sibling, newPath); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow(newPath); + expect(await readlink(newPath)).toBe(sibling); + expect(await readFile(sibling, "utf8")).toBe("user content\n"); + } + ); + + it.skipIf(process.platform === "win32")( + "refuses an untracked dangling destination symlink", + async () => { + const f = await fixture("opencode"); + const relativePath = "skills/aidd-context/00-onboard/new.md"; + const newPath = join(f.home, ".config/opencode", relativePath); + const destination = join(f.home, "missing-user-file"); + f.files.push( + new InstallationFile({ relativePath, content: f.content, hash: f.hasher.hash(f.content) }) + ); + await rm(f.target); + await symlink(destination, newPath); + await expect( + f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true) + ).rejects.toThrow(/symlink/); + expect(await readlink(newPath)).toBe(destination); + } + ); + + it.skipIf(process.platform === "win32")( + "refuses a new destination under a symlinked parent", + async () => { + const f = await fixture("opencode"); + const relativePath = "skills/aidd-context/linked/new.md"; + const parent = join(f.home, ".config/opencode/skills/aidd-context/linked"); + const destination = join(f.home, ".config/opencode/user-directory"); + f.files.push( + new InstallationFile({ relativePath, content: f.content, hash: f.hasher.hash(f.content) }) + ); + await mkdir(destination, { recursive: true }); + await rm(f.target); + await symlink(destination, parent); + await expect( + f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true) + ).rejects.toThrow(/symlink/); + expect(await readlink(parent)).toBe(destination); + } + ); + + it.skipIf(process.platform === "win32")( + "rechecks a destination parent replaced by a symlink after planning", + async () => { + const f = await fixture("opencode"); + const relativePath = "skills/aidd-context/linked/new.md"; + const parent = join(f.home, ".config/opencode/skills/aidd-context/linked"); + const destination = join(f.home, ".config/opencode/user-directory"); + f.files.push( + new InstallationFile({ relativePath, content: f.content, hash: f.hasher.hash(f.content) }) + ); + await mkdir(destination, { recursive: true }); + await rm(f.target); + const plan = await f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true); + if (!plan) throw new Error("expected rebuild plan"); + await symlink(destination, parent); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow(/symlink/); + expect(await readlink(parent)).toBe(destination); + } + ); +}); + +async function cursorFixture() { + const home = await mkdtemp(join(tmpdir(), "aidd-cursor-rebuild-")); + roots.push(home); + const hasher = new HasherAdapter(); + const fs = new FileAdapter(hasher); + const content = "# installed skill\n"; + const skills = ["one", "two"].map((name) => ({ + relativePath: `skills/${name}/SKILL.md`, + content, + })); + const dist = new PluginDistribution({ + manifest: { name: "aidd-context", version: "2.9.0" }, + format: "claude", + files: skills, + components: { commands: [], agents: [], rules: [], skills, hooks: [], mcp: [] }, + }); + const base = join(home, ".cursor/plugins/local"); + const paths = skills.map((skill) => join(base, "aidd-context", skill.relativePath)); + for (const path of paths) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); + } + const plugin = InstalledPlugin.fromMetadata( + "aidd-context", + "2.8.0", + { kind: "local", path: "/source" }, + false, + "user" + ).withFiles( + new Map( + skills.map((skill) => [`aidd-context/${skill.relativePath}`, hasher.hash(content).value]) + ) + ); + const updater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), + hasher, + { + ensureBuilt: { + execute: async () => { + throw new Error("unexpected build"); + }, + }, + marketplaceRegistry: new InMemoryMarketplaceRegistry(), + homedir: () => home, + } + ); + return { home, base, paths, content, plugin, updater, logger: new CapturingLogger() }; +} + +describe("rebuilding a Cursor user plugin", () => { + it("restores a deleted plugin directory and its files", async () => { + const f = await cursorFixture(); + await rm(join(f.base, "aidd-context"), { recursive: true }); + const result = await f.updater.update(f.plugin, "cursor", "/project", f.logger); + expect(result?.files.size).toBe(2); + for (const path of f.paths) expect(await readFile(path, "utf8")).toBe(f.content); + }); + + it.skipIf(process.platform === "win32")( + "preserves a tracked symlink when another file is missing", + async () => { + const f = await cursorFixture(); + const sibling = join(f.base, "other.md"); + await writeFile(sibling, f.content); + await rm(f.paths[0]); + await symlink(sibling, f.paths[0]); + await rm(f.paths[1]); + await expect(f.updater.update(f.plugin, "cursor", "/project", f.logger)).rejects.toThrow( + /symlink/ + ); + expect(await readlink(f.paths[0])).toBe(sibling); + expect(await readFile(sibling, "utf8")).toBe(f.content); + } + ); + + it.skipIf(process.platform === "win32")( + "preserves an untracked destination symlink", + async () => { + const f = await cursorFixture(); + const relative = "aidd-context/skills/one/SKILL.md"; + const files = new Map(f.plugin.files); + files.delete(relative); + const sibling = join(f.base, "other.md"); + await writeFile(sibling, f.content); + await rm(f.paths[0]); + await symlink(sibling, f.paths[0]); + await expect( + f.updater.update(f.plugin.withFiles(files), "cursor", "/project", f.logger) + ).rejects.toThrow(/symlink/); + expect(await readlink(f.paths[0])).toBe(sibling); + } + ); + + it.skipIf(process.platform === "win32")("preserves a symlinked destination parent", async () => { + const f = await cursorFixture(); + const parent = dirname(f.paths[0]); + const sibling = join(f.base, "other-directory"); + await mkdir(sibling); + await writeFile(join(sibling, "SKILL.md"), f.content); + await rm(parent, { recursive: true }); + await symlink(sibling, parent); + await expect(f.updater.update(f.plugin, "cursor", "/project", f.logger)).rejects.toThrow( + /symlink/ + ); + expect(await readlink(parent)).toBe(sibling); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-update-session.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-update-session.integration.test.ts new file mode 100644 index 000000000..493ade4e5 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-update-session.integration.test.ts @@ -0,0 +1,107 @@ +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { expect, it, vi } from "vitest"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import type { PinnedUserFramework } from "../../../../../src/contexts/framework/application/ownership/release/pinned-user-framework.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +it("uses one private session for all machine copies in sync and plugin update", async () => { + const manifest = Manifest.create(); + for (const tool of ["opencode", "kilo"] as const) { + manifest.addTool(tool, "5.0.0", []); + for (const name of ["aidd-context", "aidd-dev"]) { + const source = { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: `plugins/${name}`, + ref: "v5.13.0", + sha: "a".repeat(40), + } as const; + manifest.addPlugin( + tool, + InstalledPlugin.fromMetadata(name, "1.0.0", source, false, "user", "aidd-framework") + ); + } + } + const events: string[] = []; + const session: PinnedUserFramework = { + build: async () => { + throw new Error("planning is mocked"); + }, + }; + const pinned: PinnedUserFramework = { + build: session.build, + async withSession(action: (session: PinnedUserFramework) => Promise): Promise { + events.push("opened"); + try { + return await action(session); + } finally { + events.push("closed"); + } + }, + }; + const fs = new InMemoryFileAdapter(); + const updater = new UserPluginFileUpdater( + fs, + new PluginDistributionLoader( + new FixturePluginFetcher(), + new PluginDistributionReaderAdapter(fs) + ), + new DeterministicHasher(), + { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: new InMemoryMarketplaceRegistry(), + homedir: () => "/home/user", + pinnedFramework: pinned, + } + ); + const plan = vi.spyOn(updater, "planUpdate").mockResolvedValue(null); + const useCase = new UserPluginUpdateUseCase( + new InMemoryManifestRepository(manifest), + updater, + new CapturingLogger(), + new NativeHostRegistrationGate(new Map(), new Map()) + ); + await useCase.execute({ + toolIds: ["opencode", "kilo"], + projectRoot: "/project", + scope: "user", + rebuild: true, + }); + await useCase.execute({ toolIds: ["opencode", "kilo"], projectRoot: "/project", scope: "user" }); + expect(plan).toHaveBeenCalledTimes(8); + for (const tool of ["opencode", "kilo"]) + for (const name of ["aidd-context", "aidd-dev"]) { + expect(plan).toHaveBeenCalledWith( + expect.objectContaining({ name }), + tool, + "/project", + expect.anything(), + true, + undefined, + session + ); + expect(plan).toHaveBeenCalledWith( + expect.objectContaining({ name }), + tool, + "/project", + expect.anything(), + false, + undefined, + session + ); + } + expect(events).toEqual(["opened", "closed", "opened", "closed"]); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-release-journal.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-release-journal.integration.test.ts new file mode 100644 index 000000000..c57946a41 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-release-journal.integration.test.ts @@ -0,0 +1,366 @@ +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { UserReleaseJournal } from "../../../../../src/contexts/framework/application/ownership/release/user-release-journal.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { UserManifestRepositoryAdapter } from "../../../../../src/contexts/framework/infrastructure/user-manifest-repository-adapter.js"; +import { UserReleaseJournalStoreAdapter } from "../../../../../src/contexts/framework/infrastructure/user-release-journal-store.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { atomicWriteFile } from "../../../../../src/runtime/filesystem/atomic-write.js"; +import { FileAdapter } from "../../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../../src/runtime/filesystem/hasher-adapter.js"; + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +class InterruptingFiles extends FileAdapter { + stop = false; + afterWrite?: (path: string) => Promise; + override async writeFile(path: string, content: string): Promise { + if (this.stop && path.endsWith("b.md")) { + this.stop = false; + throw new Error("interrupted"); + } + await super.writeFile(path, content); + await this.afterWrite?.(path); + } +} + +async function fixture() { + const home = await mkdtemp(join(tmpdir(), "aidd-release-journal-")); + dirs.push(home); + const root = join(home, ".config", "opencode"); + await mkdir(join(root, "skills"), { recursive: true }); + await writeFile(join(root, "skills/a.md"), "old"); + const hasher = new HasherAdapter(); + const fs = new InterruptingFiles(hasher); + const repo = new UserManifestRepositoryAdapter(() => join(home, ".config/aidd"), atomicWriteFile); + const source = { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + } as const; + const plugin = InstalledPlugin.fromMetadata( + "aidd-context", + "2.9.0", + source, + false, + "user", + "aidd-framework" + ).withFiles(new Map([["skills/a.md", hasher.hash("old").value]])); + const old = Manifest.create(); + old.addTool("opencode", "5.0.0", []); + old.addPlugin("opencode", plugin); + await repo.save(old); + const a = new InstallationFile({ + relativePath: "skills/a.md", + content: "new", + hash: hasher.hash("new"), + }); + const b = new InstallationFile({ + relativePath: "skills/b.md", + content: "added", + hash: hasher.hash("added"), + }); + const next = Manifest.fromJSON(old.toJSON()); + next.updatePlugin( + "opencode", + plugin.withVersion("2.10.0").withFiles( + new Map([ + [a.relativePath, a.hash.value], + [b.relativePath, b.hash.value], + ]) + ) + ); + return { + home, + root, + fs, + repo, + old, + next, + files: [a, b], + journal: new UserReleaseJournal(fs, hasher, repo, { + home: () => home, + store: new UserReleaseJournalStoreAdapter(() => repo.path), + }), + }; +} + +describe("machine release journal", () => { + it("finishes an interrupted update using only recorded old or new contents", async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("new"); + expect((await f.repo.load())?.getPlugins("opencode")[0]?.version).toBe("2.9.0"); + await f.journal.recover(); + expect(await readFile(join(f.root, "skills/b.md"), "utf8")).toBe("added"); + expect((await f.repo.load())?.getPlugins("opencode")[0]?.version).toBe("2.10.0"); + expect(await f.fs.fileExists(`${f.repo.path}.release-journal`)).toBe(false); + }); + it("leaves later user edits untouched and refuses the entire recovery", async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + await writeFile(join(f.root, "skills/a.md"), "user edit"); + await expect(f.journal.recover()).rejects.toThrow("changed during release update"); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("user edit"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + }); + it("refuses to claim an existing untracked target even if its bytes match", async () => { + const f = await fixture(); + await writeFile(join(f.root, "skills/b.md"), "added"); + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("changed during release update"); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("old"); + expect(await f.fs.fileExists(`${f.repo.path}.release-journal`)).toBe(false); + }); + + it("refuses a journal replaced with a symlink", async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + const path = `${f.repo.path}.release-journal`; + const content = await readFile(path, "utf8"); + const foreign = join(f.home, "foreign"); + await writeFile(foreign, content, { mode: 0o600 }); + await rm(path); + await symlink(foreign, path); + await expect(f.journal.recover()).rejects.toThrow(); + expect(await readFile(foreign, "utf8")).toBe(content); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + }); + + it.skipIf(process.platform === "win32")( + "refuses a journal readable by another user", + async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + await chmod(`${f.repo.path}.release-journal`, 0o644); + await expect(f.journal.recover()).rejects.toThrow("not a private owned file"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + } + ); + + it("refuses a tampered relative path before writing outside the tool root", async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + const path = `${f.repo.path}.release-journal`; + const original = await readFile(path, "utf8"); + await writeFile(path, original.replace('"path":"skills/b.md"', '"path":"../outside.md"')); + await expect(f.journal.recover()).rejects.toThrow("Invalid user release journal file"); + expect(await f.fs.fileExists(join(f.home, ".config/outside.md"))).toBe(false); + }); + + it("does not discard a manifest edit made while files were being written", async () => { + const f = await fixture(); + f.fs.afterWrite = async (path) => { + if (!path.endsWith("a.md")) return; + const changed = Manifest.fromJSON(f.old.toJSON()); + changed.addTool("kilo", "5.0.0", []); + await f.repo.save(changed); + f.fs.afterWrite = undefined; + }; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("Machine manifest changed"); + expect((await f.repo.load())?.hasTool("kilo")).toBe(true); + expect(await f.fs.fileExists(`${f.repo.path}.release-journal`)).toBe(true); + }); + + it("removes obsolete owned files while retaining the target files and manifest", async () => { + const f = await fixture(); + const plugin = f.next.getPlugins("opencode")[0]; + const added = f.files[1]; + f.next.updatePlugin( + "opencode", + plugin.withFiles(new Map([[added.relativePath, added.hash.value]])) + ); + await f.journal.commit(f.old, f.next, "opencode", "aidd-context", [added]); + expect(await f.fs.fileExists(join(f.root, "skills/a.md"))).toBe(false); + expect(await readFile(join(f.root, "skills/b.md"), "utf8")).toBe("added"); + expect((await f.repo.load())?.getPlugins("opencode")[0]?.files).toEqual( + new Map([[added.relativePath, added.hash.value]]) + ); + expect(await f.fs.fileExists(`${f.repo.path}.release-journal`)).toBe(false); + }); + + it("refuses a file also claimed by another machine plugin", async () => { + const f = await fixture(); + const claimed = InstalledPlugin.fromJSON({ + ...f.old.getPlugins("opencode")[0].toJSON(), + name: "another-plugin", + }); + f.old.addPlugin("opencode", claimed); + f.next.addPlugin("opencode", claimed); + await f.repo.save(f.old); + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("ownership or digest mismatch"); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("old"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + expect(await f.fs.fileExists(`${f.repo.path}.release-journal`)).toBe(false); + }); + + it("refuses tampered target bytes before resuming an interrupted update", async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + const path = `${f.repo.path}.release-journal`; + const original = await readFile(path, "utf8"); + await writeFile(path, original.replace('"content":"added"', '"content":"tampered"')); + await expect(f.journal.recover()).rejects.toThrow("ownership or digest mismatch"); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("new"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + expect((await f.repo.load())?.getPlugins("opencode")[0]?.version).toBe("2.9.0"); + }); + + it.each([null, [], "not an object"])( + "refuses a corrupted journal root without modifying owned files: %s", + async (value) => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + await writeFile(`${f.repo.path}.release-journal`, JSON.stringify(value)); + await expect(f.journal.recover()).rejects.toThrow("Invalid user release journal"); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("new"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + expect((await f.repo.load())?.getPlugins("opencode")[0]?.version).toBe("2.9.0"); + } + ); + + it.each([ + { field: "tool", value: "unknown-tool" }, + { field: "tool", value: null }, + { field: "plugin", value: "../other" }, + { field: "plugin", value: null }, + { field: "boundary", value: null }, + { field: "files", value: null }, + ])("rejects a malformed journal $field before applying it", async ({ field, value }) => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + const path = `${f.repo.path}.release-journal`; + const journal: Record = JSON.parse(await readFile(path, "utf8")); + journal[field] = value; + await writeFile(path, JSON.stringify(journal)); + await expect(f.journal.recover()).rejects.toThrow("Invalid user release journal"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + expect((await f.repo.load())?.getPlugins("opencode")[0]?.version).toBe("2.9.0"); + }); + + it.each([ + { field: "path", value: "/outside.md" }, + { field: "path", value: "skills\\outside.md" }, + { field: "path", value: "./skills/b.md" }, + { field: "path", value: "." }, + { field: "path", value: null }, + { field: "before", value: "not-a-hash" }, + { field: "after", value: "not-a-hash" }, + { field: "content", value: 42 }, + { field: "after", value: null }, + { field: "content", value: null }, + ])("rejects a malformed journal file $field before applying it", async ({ field, value }) => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + const path = `${f.repo.path}.release-journal`; + const journal: { files: Record[] } = JSON.parse(await readFile(path, "utf8")); + journal.files[1][field] = value; + await writeFile(path, JSON.stringify(journal)); + await expect(f.journal.recover()).rejects.toThrow("Invalid user release journal file"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + expect((await f.repo.load())?.getPlugins("opencode")[0]?.version).toBe("2.9.0"); + }); + + it("does not start another release while a journal needs recovery", async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("needs recovery first"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + }); + + it("refuses a release that changes another tool's ownership", async () => { + const f = await fixture(); + f.next.addTool("kilo", "5.0.0", []); + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("changes other machine ownership"); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("old"); + expect(await f.fs.fileExists(`${f.repo.path}.release-journal`)).toBe(false); + }); + + it("refuses a changed machine directory before resuming a journal", async () => { + const f = await fixture(); + f.fs.stop = true; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", f.files) + ).rejects.toThrow("interrupted"); + const path = `${f.repo.path}.release-journal`; + const journal: { boundary: string } = JSON.parse(await readFile(path, "utf8")); + journal.boundary = join(f.home, "other-directory"); + await writeFile(path, JSON.stringify(journal)); + await expect(f.journal.recover()).rejects.toThrow("Machine directory changed"); + expect(await f.fs.fileExists(join(f.root, "skills/b.md"))).toBe(false); + }); + + it.each(["duplicate", "missing", "mismatched bytes"])( + "rejects %s target files before writing a journal", + async (mode) => { + const f = await fixture(); + const files = + mode === "duplicate" + ? [f.files[0], f.files[0], f.files[1]] + : mode === "missing" + ? [f.files[0]] + : [ + new InstallationFile({ + relativePath: f.files[0].relativePath, + content: "not the planned content", + hash: f.files[0].hash, + }), + f.files[1], + ]; + await expect( + f.journal.commit(f.old, f.next, "opencode", "aidd-context", files) + ).rejects.toThrow( + mode === "mismatched bytes" ? "Unverified target file" : "Duplicate or missing" + ); + expect(await readFile(join(f.root, "skills/a.md"), "utf8")).toBe("old"); + expect(await f.fs.fileExists(`${f.repo.path}.release-journal`)).toBe(false); + } + ); +}); diff --git a/cli/tests/contexts/framework/application/plugin/kilo-maintenance-lifecycle.integration.test.ts b/cli/tests/contexts/framework/application/plugin/kilo-maintenance-lifecycle.integration.test.ts new file mode 100644 index 000000000..9ef655217 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/kilo-maintenance-lifecycle.integration.test.ts @@ -0,0 +1,172 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { assertProjectTuiUnchanged } from "../../../../../src/contexts/framework/application/ownership/project-plugin-cleanup.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; +import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import { SetupToolsUseCase } from "../../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; +import { protectUpgradeWrites } from "../../../../../src/contexts/framework/application/upgrade-file-guard.js"; +import { UpgradeFrameworkUseCase } from "../../../../../src/contexts/framework/application/upgrade-framework-use-case.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { buildUnitDeps, initProject } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; + +const root = "/project"; +const source = "/maintenance"; +const config = join(root, ".kilo/tui.jsonc"); +const script = join(root, ".kilo/hooks/aidd-maintenance/kilo-tui.js"); + +async function fixture() { + const deps = await buildUnitDeps(root); + await initProject(deps, root); + await new SetupToolsUseCase( + deps.manifestRepo, + deps.installRuntimeConfigUseCase, + deps.installIdeConfigUseCase + ).execute({ projectRoot: root, aiTools: ["kilo"], ideTools: [], force: false, version: "test" }); + await deps.fs.writeFile(config, '{ // preserve\n "plugin":["./other.js"], "theme":"dark"\n}'); + await deps.fs.writeFile( + join(source, ".claude-plugin/plugin.json"), + '{"name":"aidd-maintenance","version":"1.0.0"}' + ); + await deps.fs.writeFile(join(source, "hooks/kilo-tui.js"), "export default {};"); + await deps.fs.writeFile(join(source, "hooks/session-start.cjs"), "console.log('test')"); + const pluginRoot = "$" + "{CLAUDE_PLUGIN_ROOT}"; + await deps.fs.writeFile( + join(source, "hooks/hooks.json"), + JSON.stringify({ + hooks: { + SessionStart: [ + { + matcher: "startup", + hooks: [{ command: `node "${pluginRoot}/hooks/session-start.cjs"` }], + }, + ], + }, + }) + ); + const loader = new PluginDistributionLoader(deps.pluginFetcher, deps.pluginDistributionReader); + const built = { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: deps.marketplaceRegistry, + homedir: () => "/home/u", + }; + const add = (fs = deps.fs) => + new PluginAddUseCase( + fs, + deps.manifestRepo, + loader, + deps.hasher, + deps.logger, + deps.marketplaceRegistry, + built.ensureBuilt, + deps.userManifestRepo + ); + const update = new PluginUpdateUseCase(deps.fs, deps.manifestRepo, loader, deps.hasher, built); + const options = { + source: { kind: "local" as const, path: source }, + toolIds: ["kilo" as const], + projectRoot: root, + interactive: false, + }; + await add().execute(options); + return { deps, add, update, options }; +} + +describe("maintenance TUI project lifecycle", () => { + it("keeps provenance and user JSONC on replacement and version update", async () => { + const { deps, add, update, options } = await fixture(); + const original = await deps.fs.readFile(config); + const before = deps.manifestRepo.getCurrent()?.getPlugins("kilo")[0]; + if (!before) throw new Error("missing plugin"); + // The upgrade guard permits only tracked files; unchanged shared JSONC needs no write. + const guarded = protectUpgradeWrites(deps.fs, root, before.files); + await add(guarded).execute({ ...options, replace: true }); + expect(await deps.fs.readFile(config)).toBe(original); + await deps.fs.writeFile( + join(source, ".claude-plugin/plugin.json"), + '{"name":"aidd-maintenance","version":"1.0.1"}' + ); + expect(await update.execute({ toolIds: ["kilo"], projectRoot: root })).toEqual([ + "aidd-maintenance", + ]); + expect(await deps.fs.readFile(config)).toBe(original); + expect(deps.manifestRepo.getCurrent()?.getPlugins("kilo")[0]?.tuiPlugin).toEqual( + before.tuiPlugin + ); + expect(await deps.fs.fileExists(script)).toBe(true); + }); + + it("refuses update before deleting files if the user removed the owned entry", async () => { + const { deps, update } = await fixture(); + await deps.fs.writeFile(config, '{ // preserve\n "plugin":["./other.js"]\n}'); + await deps.fs.writeFile( + join(source, ".claude-plugin/plugin.json"), + '{"name":"aidd-maintenance","version":"1.0.1"}' + ); + await expect(update.execute({ toolIds: ["kilo"], projectRoot: root })).rejects.toThrow( + "tracked entry was removed" + ); + expect(await deps.fs.fileExists(script)).toBe(true); + expect(deps.manifestRepo.getCurrent()?.getPlugins("kilo")[0]?.version).toBe("1.0.0"); + }); + + it("rejects a release upgrade before changing its marketplace when TUI ownership was edited", async () => { + const { deps } = await fixture(); + const manifest = deps.manifestRepo.getCurrent(); + const plugin = manifest?.getPlugins("kilo")[0]; + if (!manifest || !plugin) throw new Error("missing plugin"); + const data = plugin.toJSON(); + data.source = { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-maintenance", + ref: "v1.0.0", + }; + data.marketplace = "aidd-framework"; + manifest.updatePlugin("kilo", InstalledPlugin.fromJSON(data)); + await deps.fs.writeFile(config, '{"plugin":["./other.js"]}'); + const marketplace = Marketplace.fromJSON({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework", ref: "v1.0.0" }, + scope: "project", + addedAt: "2026-01-01", + version: "1.0.0", + }); + const saveMarketplace = vi.fn(async () => {}); + const install = vi.fn(async () => {}); + const upgrade = new UpgradeFrameworkUseCase({ + listMarketplaces: async () => [marketplace], + saveMarketplace, + loadManifest: async () => manifest, + resolvePublishedRelease: async () => ({ title: "Release title" }), + resolveTarget: async () => ({ + catalog: { + version: "1.0.1", + plugins: [ + { + name: "aidd-maintenance", + source: { kind: "local", path: "./plugins/aidd-maintenance" }, + }, + ], + }, + localPath: "/release", + }), + readTargetVersion: async () => "1.0.1", + preflight: async () => assertProjectTuiUnchanged(deps.fs, plugin, "kilo", root), + install, + sync: async () => ({ warnings: [], errors: [], binaryMissing: [] }), + }); + await expect( + upgrade.execute({ + projectRoot: root, + release: "v1.0.1", + allowShared: true, + confirm: async () => true, + }) + ).rejects.toThrow("tracked entry was removed"); + expect(saveMarketplace).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts index 646fcde9b..36f5b5019 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts @@ -133,6 +133,85 @@ describe("PluginAddUseCase", () => { }); describe("github marketplace plugin", () => { + it.each(["codex", "opencode"] as const)( + "refreshes the source before a verified %s installation", + async (toolId) => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, toolId); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GITHUB_SOURCE, PLUGIN_FIXTURE); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + const fetch = vi.spyOn(deps.pluginFetcher, "fetch"); + + await buildAddUseCase(deps, registry).execute({ + source: GITHUB_SOURCE, + toolIds: [toolId], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + requiredVersion: "1.0.0", + pluginMetadata: { name: "sample-plugin", version: "1.0.0", strict: false }, + freshSource: true, + }); + + expect(fetch).toHaveBeenCalledWith(GITHUB_SOURCE, expect.any(String), { + forceRefresh: true, + }); + expect(pluginNames(deps, toolId)).toContain("sample-plugin"); + } + ); + + it("refuses a native plugin whose version differs from the requested version", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "codex"); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + + await expect( + buildAddUseCase(deps, registry).execute({ + source: GITHUB_SOURCE, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + requiredVersion: "2.0.0", + pluginMetadata: { name: "sample-plugin", version: "1.0.0", strict: false }, + }) + ).rejects.toThrow(/version/i); + expect(pluginNames(deps, "codex")).toEqual([]); + }); + + it("checks the actual native distribution even when the catalogue claims the requested version", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "codex"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GITHUB_SOURCE, PLUGIN_FIXTURE); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + + await expect( + buildAddUseCase(deps, registry).execute({ + source: GITHUB_SOURCE, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + requiredVersion: "2.0.0", + pluginMetadata: { name: "sample-plugin", version: "2.0.0", strict: false }, + }) + ).rejects.toThrow(/version/i); + expect(pluginNames(deps, "codex")).toEqual([]); + }); it("skips fetch and registers plugin reference without materializing files", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); @@ -324,6 +403,46 @@ describe("PluginAddUseCase", () => { }); describe("opencode", () => { + it("installs project files independently when the same plugin exists globally", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + const machine = Manifest.create(); + machine.addTool("opencode", "1.0.0", []); + machine.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + GIT_SUBDIR_SOURCE, + false, + "user", + "aidd-framework" + ) + ); + await deps.userManifestRepo.save(machine); + deps.fs.setFile( + "/built/opencode/.opencode/skills/sample-plugin/demo/SKILL.md", + "# project skill" + ); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GIT_SUBDIR_SOURCE, PLUGIN_FIXTURE); + const useCase = buildAddUseCase(deps, await makeGithubRegistry(PROJECT_ROOT)); + await useCase.execute({ + source: GIT_SUBDIR_SOURCE, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: PLUGIN_METADATA, + }); + expect( + deps.fs.getFile(join(PROJECT_ROOT, ".opencode/skills/sample-plugin/demo/SKILL.md")) + ).toBe("# project skill"); + expect( + deps.manifestRepo.getCurrent()?.getPlugins("opencode")[0]?.files.size + ).toBeGreaterThan(0); + }); + it("fetches and materializes flat files", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "opencode"); @@ -513,6 +632,11 @@ describe("PluginAddUseCase", () => { it("project replace refuses legacy hooks without an install digest and preserves shared files", async () => { const deps = await cursorDeps(); await addForCursor(deps, deps.logger); + const manifest = await deps.manifestRepo.load(); + const plugin = manifest?.getPlugins("cursor")[0]; + if (!manifest || !plugin) throw new Error("missing plugin"); + manifest.updatePlugin("cursor", InstalledPlugin.withProjectHooks(plugin, undefined)); + await deps.manifestRepo.save(manifest); const hooksBefore = deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json")); const savesBefore = deps.manifestRepo.saveCount; const machineSavesBefore = deps.userManifestRepo.saveCount; diff --git a/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts index d0ac34647..fa2c28e02 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts @@ -49,4 +49,18 @@ describe("PluginDistributionLoader", () => { forceRefresh: true, }); }); + + it("fetches a global install's plugin in the given cache, outside the project", async () => { + const fetch = vi.fn().mockResolvedValue("/cache/fetched"); + const loader = new PluginDistributionLoader( + { fetch }, + { read: async () => dist }, + () => "/config/cache" + ); + const plugin = InstalledPlugin.fromMetadata("sample", "0.0.1", source, false, "user"); + + await loader.loadLatest(plugin, "/project"); + + expect(fetch).toHaveBeenCalledWith(source, "/config/cache", { forceRefresh: true }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts index 0a91d94df..866b05e41 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts @@ -93,6 +93,71 @@ function installedSamplePlugin(deps: Awaited>) } describe("PluginInstallFromMarketplaceUseCase", () => { + it("refreshes the distribution when the release source was verified", async () => { + const { useCase, deps, registry } = await buildUseCase(); + const marketplaceSource = { + kind: "github" as const, + repo: "ai-driven-dev/framework", + ref: "v5.13.0", + }; + const expectedSource = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/sample-plugin", + ref: "v5.13.0", + }; + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { name: "sample-plugin", source: "./plugins/sample-plugin", version: "1.0.0" }, + ]); + deps.pluginFetcher.register(marketplaceSource, MKT1_DIR); + deps.pluginFetcher.register(expectedSource, PLUGIN_FIXTURE); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: marketplaceSource, + scope: "project", + addedAt: "2026-05-01T00:00:00.000Z", + }) + ); + const fetch = vi.spyOn(deps.pluginFetcher, "fetch"); + + await useCase.execute({ + pluginName: "sample-plugin", + version: "1.0.0", + expectedSource, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(fetch).toHaveBeenCalledWith(expectedSource, expect.any(String), { forceRefresh: true }); + expect(installedSamplePlugin(deps)?.version).toBe("1.0.0"); + }); + + it("refuses a source changed after review before installing anything", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { name: "sample-plugin", source: { kind: "local", path: PLUGIN_FIXTURE }, version: "1.0.0" }, + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + await expect( + useCase.execute({ + pluginName: "sample-plugin", + version: "1.0.0", + expectedSource: { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/sample-plugin", + ref: "v5.13.0", + }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }) + ).rejects.toThrow(/source.*changed/i); + expect(installedSamplePlugin(deps)).toBeUndefined(); + }); it("installs a plugin found in a single marketplace and tags it", async () => { const { useCase, deps, registry } = await buildUseCase(); seedMarketplaceFile(deps.fs, MKT1_DIR, [ diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts index ec2bb8f6a..1d5a5ca45 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-selection.integration.test.ts @@ -1,6 +1,7 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -41,8 +42,60 @@ function manifest(): Manifest { return result; } +describe("Kilo user plugin TUI removal", () => { + function fixture(config: string) { + const fs = new InMemoryFileAdapter(); + const path = join(process.env.HOME || homedir(), ".config/kilo/tui.jsonc"); + fs.setFile(path, config); + const machine = Manifest.create(); + machine.addTool("kilo", "1.0.0", []); + machine.addPlugin( + "kilo", + plugin({ name: "aidd-maintenance" }).withTuiPlugin({ + config: "tui.jsonc", + entry: "./hooks/aidd-maintenance/kilo-tui.js", + }) + ); + const repo = new InMemoryManifestRepository(machine); + const useCase = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new Map(), + new Map(), + undefined, + undefined, + repo + ); + return { fs, path, repo, useCase }; + } + const options = { + pluginName: "aidd-maintenance", + toolIds: ["kilo"] as const, + projectRoot: ROOT, + scope: "user" as const, + }; + + it("removes its TUI entry while retaining the other settings", async () => { + const f = fixture('{"plugin":["other", "./hooks/aidd-maintenance/kilo-tui.js"]}'); + await f.useCase.execute({ ...options, toolIds: [...options.toolIds] }); + expect(await f.fs.readFile(f.path)).toContain("other"); + expect(await f.fs.readFile(f.path)).not.toContain("./hooks/aidd-maintenance/kilo-tui.js"); + expect(f.repo.getCurrent()?.getPlugins("kilo")).toEqual([]); + }); + + it("retains the claim when the TUI entry no longer matches", async () => { + const f = fixture('{"plugin":["other"]}'); + await expect(f.useCase.execute({ ...options, toolIds: [...options.toolIds] })).rejects.toThrow( + /tracked entry changed/ + ); + expect(f.repo.getCurrent()?.getPlugins("kilo")).toHaveLength(1); + expect(f.repo.saveCount).toBe(0); + }); +}); + describe("plugin removal selects the recorded owner, name and tool", () => { - it("detaches only the selected user plugin's project claim while preserving machine files", async () => { + it("detaches only the selected user plugin's project claim while preserving global files", async () => { const project = manifest(); const machine = manifest(); for (const toolId of ["cursor", "claude"] as const) { @@ -190,7 +243,7 @@ describe("plugin removal selects the recorded owner, name and tool", () => { expect(projectRepo.saveCount).toBe(0); }); - it("explicit user removal deletes the proven machine file without touching the project copy", async () => { + it("explicit user removal deletes the proven global file without touching the project copy", async () => { const relative = `${NAME}/commands/owned.md`; const content = "installed machine bytes"; const machine = manifest(); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index 37a7d7073..c820e1ddf 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts @@ -598,6 +598,30 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(machineBefore); }); + it("refuses an untracked identical file without changing its bytes or claiming it", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + deps.fs.setFile(join(USER_PLUGINS_DIR, newRelativePath), "# Built v2\n"); + + const before = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + const savesBefore = deps.userManifestRepo.saveCount; + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/new.md.*untracked|untracked.*new.md/); + + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, newRelativePath))).toBe("# Built v2\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(before); + }); + it("preflights a Cursor user-file collision before any selected native host update", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); diff --git a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts index 6291436ab..491fd6b0d 100644 --- a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts @@ -582,6 +582,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, + globalPlugins: [], }); }); @@ -598,6 +599,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, + globalPlugins: [], }); }); diff --git a/cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts new file mode 100644 index 000000000..e13eed49c --- /dev/null +++ b/cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import type { PluginCatalogEntry } from "../../../../../src/contexts/distribution/domain/catalog.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import type { PinnedUserFramework } from "../../../../../src/contexts/framework/application/ownership/release/pinned-user-framework.js"; +import type { PlannedUserPluginFileUpdate } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { InstallGlobalPluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-global-plugins-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { PluginInstallMode } from "../../../../../src/contexts/framework/domain/setup-flow.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; + +const FRAMEWORK = Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/fw" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", +}); + +const CATALOG: PluginCatalogEntry[] = [ + { + name: "aidd-dev", + source: { kind: "local", path: "./plugins/aidd-dev" }, + recommended: true, + strict: true, + }, + { + name: "aidd-pm", + source: { kind: "local", path: "./plugins/aidd-pm" }, + recommended: false, + strict: true, + }, +]; + +const HASH = "0".repeat(32); + +function setup(marketplaces: Marketplace[] = [FRAMEWORK], session?: PinnedUserFramework) { + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addTool("claude", "1.0.0", []); + const planUpdate = vi.fn( + async (plugin: InstalledPlugin, toolId: AiToolId): Promise => { + const relativePath = `agents/${plugin.name}-reviewer.md`; + return { + plugin, + toolId, + home: "/home", + baseDir: "/home/.config/opencode", + files: [new InstallationFile({ relativePath, content: "", hash: new FileHash(HASH) })], + oldSafe: new Map(), + next: plugin.withFiles(new Map([[relativePath, HASH]])), + }; + } + ); + const applyUpdate = vi.fn(async (plan: PlannedUserPluginFileUpdate) => plan.next); + const events: string[] = []; + const withPinnedSession = + session === undefined + ? undefined + : async (action: (pinned?: PinnedUserFramework) => Promise) => { + events.push("opened"); + try { + return await action(session); + } finally { + events.push("closed"); + } + }; + const useCase = new InstallGlobalPluginsUseCase( + { list: async () => marketplaces }, + { + execute: async () => ({ + marketplace: FRAMEWORK, + catalog: { plugins: CATALOG }, + localPath: "/fw", + }), + }, + { planUpdate, applyUpdate, withPinnedSession }, + new CapturingLogger() + ); + return { manifest, useCase, planUpdate, applyUpdate, events }; +} + +function run( + useCase: InstallGlobalPluginsUseCase, + manifest: Manifest, + mode: PluginInstallMode, + pluginNames: string[] = [] +) { + return useCase.execute( + manifest, + { + projectRoot: "/project", + toolIds: ["claude", "opencode"], + mode, + pluginNames, + }, + async () => {} + ); +} + +describe("InstallGlobalPluginsUseCase", () => { + it("keeps an explicitly updated official release pinned on setup", async () => { + const { manifest, useCase, planUpdate } = setup(); + const source = { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-dev", + ref: "v5.13.0", + sha: "a".repeat(40), + } as const; + manifest.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "aidd-dev", + "1.0.0", + source, + true, + "user", + "aidd-framework" + ).withFiles(new Map([["agents/old.md", HASH]])) + ); + await run(useCase, manifest, "recommended"); + expect(planUpdate.mock.calls[0][0].source).toEqual(source); + }); + + it("takes the source this run resolved for a plugin it already owns, and keeps its files", async () => { + const { manifest, useCase, planUpdate } = setup(); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "aidd-dev", + "1.0.0", + { kind: "local", path: "/moved-away" }, + true, + "user", + "aidd-framework" + ).withFiles(new Map([["agents/old.md", HASH]])) + ); + + await useCase.execute( + manifest, + { projectRoot: "/project", toolIds: ["opencode"], mode: "recommended", pluginNames: [] }, + async () => {} + ); + + const planned = planUpdate.mock.calls[0][0]; + expect(planned.source).not.toEqual({ kind: "local", path: "/moved-away" }); + expect([...planned.files.keys()]).toEqual(["agents/old.md"]); + }); + + it("shares one session across all machine plugins and closes it", async () => { + const session: PinnedUserFramework = { + build: async () => { + throw new Error("unused"); + }, + }; + const { manifest, useCase, planUpdate, events } = setup([FRAMEWORK], session); + manifest.addTool("kilo", "1.0.0", []); + await useCase.execute( + manifest, + { projectRoot: "/project", toolIds: ["opencode", "kilo"], mode: "all", pluginNames: [] }, + async () => {} + ); + expect(planUpdate).toHaveBeenCalledTimes(4); + for (const tool of ["opencode", "kilo"]) + for (const name of ["aidd-dev", "aidd-pm"]) + expect(planUpdate).toHaveBeenCalledWith( + expect.objectContaining({ name }), + tool, + "/project", + expect.anything(), + true, + undefined, + session + ); + expect(events).toEqual(["opened", "closed"]); + }); + + it("records each chosen plugin's machine files for a tool that copies them, and names every one", async () => { + const { manifest, useCase, planUpdate } = setup(); + + const result = await run(useCase, manifest, "all"); + + expect(result.plugins).toEqual(["aidd-dev", "aidd-pm"]); + expect( + manifest.getPlugins("opencode").map((p) => [p.name, p.scope, [...p.files.keys()]]) + ).toEqual([ + ["aidd-dev", "user", ["agents/aidd-dev-reviewer.md"]], + ["aidd-pm", "user", ["agents/aidd-pm-reviewer.md"]], + ]); + expect(manifest.getPlugins("claude")).toEqual([]); + expect(planUpdate).toHaveBeenCalledTimes(2); + }); + + it("also writes the plugins of a tool installing into a user plugins directory", async () => { + const { manifest, useCase, planUpdate } = setup(); + manifest.addTool("cursor", "1.0.0", []); + + await useCase.execute( + manifest, + { + projectRoot: "/project", + toolIds: ["cursor"], + mode: "recommended", + pluginNames: [], + }, + async () => {} + ); + + expect(manifest.getPlugins("cursor").map((p) => [p.name, p.scope])).toEqual([ + ["aidd-dev", "user"], + ]); + expect(planUpdate).toHaveBeenCalledWith( + expect.objectContaining({ name: "aidd-dev" }), + "cursor", + "/project", + expect.anything(), + true + ); + }); + + it("writes nothing when a later plugin's plan is refused", async () => { + const { manifest, useCase, planUpdate, applyUpdate } = setup(); + const accepted = planUpdate.getMockImplementation(); + if (accepted === undefined) throw new Error("fixture has no plan"); + planUpdate.mockImplementationOnce(accepted); + planUpdate.mockRejectedValueOnce(new Error("already exists")); + + await expect(run(useCase, manifest, "all")).rejects.toThrow("already exists"); + + expect(applyUpdate).not.toHaveBeenCalled(); + }); + + it("has recorded the plugins already written when a later write fails", async () => { + const { manifest, useCase, applyUpdate } = setup(); + applyUpdate.mockImplementationOnce(async (plan) => plan.next); + applyUpdate.mockRejectedValueOnce(new Error("disk full")); + const recorded: string[][] = []; + + await expect( + useCase.execute( + manifest, + { projectRoot: "/project", toolIds: ["opencode"], mode: "all", pluginNames: [] }, + async (saved) => { + recorded.push(saved.getPlugins("opencode").map((plugin) => plugin.name)); + } + ) + ).rejects.toThrow("disk full"); + + expect(recorded).toEqual([["aidd-dev"]]); + }); + + it("keeps the recommended plugins alone", async () => { + const { manifest, useCase } = setup(); + + expect((await run(useCase, manifest, "recommended")).plugins).toEqual(["aidd-dev"]); + }); + + it("refuses a named plugin the catalog does not carry", async () => { + const { manifest, useCase } = setup(); + + await expect(run(useCase, manifest, "named", ["aidd-nope"])).rejects.toThrow( + "Not in the framework catalog: aidd-nope." + ); + }); + + it("installs nothing for --plugins none", async () => { + const { manifest, useCase, planUpdate } = setup(); + + expect((await run(useCase, manifest, "none")).plugins).toEqual([]); + expect(planUpdate).not.toHaveBeenCalled(); + }); + + it("refuses when no shared framework source is registered", async () => { + const { manifest, useCase } = setup([]); + + await expect(run(useCase, manifest, "all")).rejects.toThrow("none is registered"); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts index 55f6023ce..0ba2ffc64 100644 --- a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts @@ -1,3 +1,4 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve, sep } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; @@ -20,6 +21,8 @@ import { import { BUILT_CACHE_SUBDIR, builtMarketplaceDir } from "../../../../../src/kernel/paths.js"; import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import type { VersionReader } from "../../../../../src/kernel/ports/version-reader.js"; +import { FileAdapter } from "../../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../../src/runtime/filesystem/hasher-adapter.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; @@ -108,6 +111,45 @@ describe("builtMarketplaceDir", () => { }); }); +describe("build cache containment", () => { + it.skipIf(process.platform === "win32")( + "refuses a cache symlink without touching its target", + async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-cache-boundary-")); + try { + const project = join(root, "project"); + const outside = join(root, "outside"); + await mkdir(join(project, ".aidd/cache"), { recursive: true }); + await mkdir(join(outside, "aidd-framework/opencode"), { recursive: true }); + const userFile = join(outside, "aidd-framework/opencode/user-file.txt"); + await writeFile(userFile, "user data"); + await symlink(outside, join(project, ".aidd/cache/built")); + const fs = new FileAdapter(new HasherAdapter()); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + () => { + throw new Error("must not build"); + }, + fakeVersion("5.0.0"), + () => join(root, "home") + ); + await expect( + uc.execute({ + projectRoot: project, + marketplace: makeRemoteMarketplace(), + target: "opencode", + mode: "flat", + }) + ).rejects.toThrow(/symlink/i); + expect(await readFile(userFile, "utf8")).toBe("user data"); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + ); +}); + describe("EnsureBuiltMarketplaceUseCase", () => { let fs: InMemoryFileAdapter; let builds: number; @@ -385,6 +427,111 @@ describe("EnsureBuiltMarketplaceUseCase", () => { }) ).rejects.toThrow("No framework build for target 'codex' mode 'marketplace'."); }); + + it.skipIf(process.platform === "win32")( + "refuses a cache symlink to another directory inside the project", + async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-cache-inside-")); + try { + const project = join(root, "project"); + const userDir = join(project, "user-data"); + const file = join(userDir, "aidd-framework/opencode/user-file.txt"); + await mkdir(join(project, ".aidd/cache"), { recursive: true }); + await mkdir(join(userDir, "aidd-framework/opencode"), { recursive: true }); + await writeFile(file, "user data"); + await symlink(userDir, join(project, ".aidd/cache/built")); + const fs = new FileAdapter(new HasherAdapter()); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + () => { + throw new Error("must not build"); + }, + fakeVersion("5.0.0"), + () => join(root, "home") + ); + await expect( + uc.execute({ + projectRoot: project, + marketplace: makeRemoteMarketplace(), + target: "opencode", + mode: "flat", + }) + ).rejects.toThrow(/symlink/i); + expect(await readFile(file, "utf8")).toBe("user data"); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + ); + + it.skipIf(process.platform === "win32")( + "refuses a user cache symlink without touching its target", + async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-user-cache-")); + try { + const project = join(root, "project"); + const userRoot = join(root, "user"); + const outside = join(root, "outside"); + const file = join(outside, "5.0.0/shared-mkt/opencode/user-file.txt"); + await mkdir(project, { recursive: true }); + await mkdir(join(userRoot, "cache"), { recursive: true }); + await mkdir(join(outside, "5.0.0/shared-mkt/opencode"), { recursive: true }); + await writeFile(file, "user data"); + await symlink(outside, join(userRoot, "cache/built")); + const fs = new FileAdapter(new HasherAdapter()); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + () => { + throw new Error("must not build"); + }, + fakeVersion("5.0.0"), + () => userRoot + ); + await expect( + uc.execute({ + projectRoot: project, + marketplace: makeUserMarketplace(), + target: "opencode", + mode: "flat", + }) + ).rejects.toThrow(/symlink/i); + expect(await readFile(file, "utf8")).toBe("user data"); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + ); + + it.skipIf(process.platform === "win32")("refuses a dangling cache symlink", async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-cache-dangling-")); + try { + const project = join(root, "project"); + await mkdir(join(project, ".aidd/cache"), { recursive: true }); + await symlink(join(root, "missing"), join(project, ".aidd/cache/built")); + const fs = new FileAdapter(new HasherAdapter()); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + () => { + throw new Error("must not build"); + }, + fakeVersion("5.0.0"), + () => join(root, "home") + ); + await expect( + uc.execute({ + projectRoot: project, + marketplace: makeRemoteMarketplace(), + target: "opencode", + mode: "flat", + }) + ).rejects.toThrow(/symlink/i); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); describe("EnsureBuiltMarketplaceUseCase — when a published source's sentinel can be believed", () => { @@ -434,6 +581,14 @@ describe("EnsureBuiltMarketplaceUseCase — when a published source's sentinel c expect(builds).toBe(1); }); + it("discards stale files when rebuilding a changed release", async () => { + fs.setFile(join(builtDir, ".build-version"), "5.0.0:1.0.0"); + fs.setFile(join(builtDir, "plugins/aidd-vcs/obsolete.md"), "old release"); + expect(await ensure("2.0.0")).toBe(true); + expect(await fs.fileExists(join(builtDir, "plugins/aidd-vcs/obsolete.md"))).toBe(false); + expect(fs.getFile(join(builtDir, "plugins/aidd-vcs/SKILL.md"))).toBe("built content"); + }); + it("rebuilds a catalog without a version even when the sentinel says unversioned too", async () => { fs.setFile(join(builtDir, ".build-version"), "5.0.0:unversioned"); expect(await ensure(undefined)).toBe(true); @@ -467,13 +622,20 @@ describe("force behavior at the cache-rebuild path", () => { makeIsDirectory(memFs), new CapturingLogger() ); - return new FrameworkBuildUseCase( + const build = new FrameworkBuildUseCase( memFs, validator, assetProvider, new CapturingLogger(), strategy ); + // The in-memory adapter represents directories by their children, unlike the real FS. + return { + execute: async (options) => { + await memFs.writeFile(join(outDir, ".directory"), ""); + return build.execute(options); + }, + }; }; const uc = new EnsureBuiltMarketplaceUseCase( diff --git a/cli/tests/contexts/framework/application/upgrade-file-guard.unit.test.ts b/cli/tests/contexts/framework/application/upgrade-file-guard.unit.test.ts new file mode 100644 index 000000000..8eb971335 --- /dev/null +++ b/cli/tests/contexts/framework/application/upgrade-file-guard.unit.test.ts @@ -0,0 +1,23 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { protectUpgradeWrites } from "../../../../src/contexts/framework/application/upgrade-file-guard.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; + +describe("upgrade file protection", () => { + it("refuses to overwrite an untracked file and keeps its bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "upgrade-guard-")); + try { + const path = join(root, "mine.txt"); + await writeFile(path, "user content"); + const guarded = protectUpgradeWrites(new FileAdapter(new HasherAdapter()), root, new Map()); + await expect(guarded.writeFile(path, "plugin content")).rejects.toThrow(/untracked/); + expect(await readFile(path, "utf8")).toBe("user content"); + await guarded.writeFile(join(root, "new.txt"), "new content"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/tests/contexts/framework/application/upgrade-framework-use-case.unit.test.ts b/cli/tests/contexts/framework/application/upgrade-framework-use-case.unit.test.ts new file mode 100644 index 000000000..ca697ad4d --- /dev/null +++ b/cli/tests/contexts/framework/application/upgrade-framework-use-case.unit.test.ts @@ -0,0 +1,577 @@ +import { describe, expect, it, vi } from "vitest"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { + type FrameworkUpgradePreview, + UpgradeFrameworkUseCase, +} from "../../../../src/contexts/framework/application/upgrade-framework-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { PluginSource } from "../../../../src/kernel/source.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; + +function fixture() { + const source = { kind: "github" as const, repo: "ai-driven-dev/framework", ref: "v5.12.0" }; + const original = Marketplace.fromJSON({ + name: "aidd-framework", + source, + scope: "user", + addedAt: "2026-01-01", + version: "5.12.0", + }); + const manifest = Manifest.create(); + manifest.addTool("opencode", "5.6.0", []); + manifest.addTool("kilo", "5.6.0", []); + const pluginSource = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + }; + manifest.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "aidd-context", + "2.9.0", + pluginSource, + true, + "project", + "aidd-framework" + ) + ); + manifest.addPlugin( + "kilo", + InstalledPlugin.fromMetadata( + "aidd-dev", + "2.7.0", + { ...pluginSource, path: "plugins/aidd-dev" }, + true, + "project", + "aidd-framework" + ) + ); + const list = vi.fn(async () => [original]); + const loadUserManifest = vi.fn(async (): Promise => null); + const saveMarketplace = vi.fn(async (_root: string, _marketplace: Marketplace) => {}); + const resolvePublishedRelease = vi.fn(async (_repo: string, release: string) => + ["v5.13.0", "v5.12.0"].includes(release) ? { title: "Release title" } : null + ); + const resolve = vi.fn( + async (): Promise<{ + catalog: { + version: string; + plugins: { name: string; source: PluginSource; version?: string }[]; + }; + localPath: string; + }> => ({ + catalog: { + version: "5.13.0", + plugins: [ + { name: "aidd-context", source: { kind: "local", path: "./plugins/aidd-context" } }, + { name: "aidd-dev", source: { kind: "local", path: "./plugins/aidd-dev" } }, + ], + }, + localPath: "/release", + }) + ); + const readTargetVersion = vi.fn(async (_path: string, name: string) => + name === "aidd-context" ? "2.10.0" : "2.8.0" + ); + const install = vi.fn( + async (options: { pluginName: string; toolIds: string[]; version: string }) => { + const id = options.toolIds[0] as "opencode" | "kilo"; + const previous = manifest.getPlugins(id).find((plugin) => plugin.name === options.pluginName); + if (!previous) throw new Error("Missing installed plugin"); + const data = previous.toJSON(); + data.source.ref = "v5.13.0"; + data.version = options.version; + manifest.removePlugin(id, previous.name); + manifest.addPlugin(id, InstalledPlugin.fromJSON(data)); + } + ); + const preflight = vi.fn( + async (): Promise<{ impact: []; shared: boolean; tool?: AiToolId } | undefined> => undefined + ); + const verifyImpact = vi.fn(async () => {}); + const sync = vi.fn( + async (): Promise<{ + errors: { scope: string; message: string }[]; + binaryMissing: { toolId: string; binary: string }[]; + warnings: string[]; + }> => ({ errors: [], binaryMissing: [], warnings: [] }) + ); + const uc = new UpgradeFrameworkUseCase({ + listMarketplaces: list, + saveMarketplace, + loadManifest: async () => manifest, + loadUserManifest, + resolvePublishedRelease, + resolveTarget: resolve, + readTargetVersion, + preflight, + verifyImpact, + install, + sync, + }); + return { + uc, + original, + manifest, + loadUserManifest, + list, + saveMarketplace, + resolvePublishedRelease, + resolve, + readTargetVersion, + install, + preflight, + verifyImpact, + sync, + }; +} + +const accept = async () => true; + +describe("upgrade framework", () => { + it("recognizes a global legacy plugin only from its exact official source", async () => { + const f = fixture(); + const machine = Manifest.create(); + machine.addTool("opencode", "5.6.0", []); + const source = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + }; + machine.addPlugin( + "opencode", + InstalledPlugin.fromMetadata("aidd-context", "2.9.0", source, true, "user") + ); + machine.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "aidd-dev", + "2.7.0", + { ...source, path: "plugins/not-aidd-dev" }, + true, + "user" + ) + ); + machine.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "aidd-pm", + "1.0.0", + { ...source, url: "https://github.com/other/framework.git", path: "plugins/aidd-pm" }, + true, + "user" + ) + ); + f.loadUserManifest.mockResolvedValue(machine); + const confirm = vi.fn(async (_preview: FrameworkUpgradePreview) => false); + await f.uc.execute({ projectRoot: "/project", release: "v5.13.0", allowShared: true, confirm }); + const preview = confirm.mock.calls[0]?.[0]; + expect(preview?.globalPlugins.map(({ pluginName }) => pluginName)).toEqual(["aidd-context"]); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + }); + it("distinguishes project user references missing from the machine inventory", async () => { + const f = fixture(); + const source = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-extra", + ref: "v5.12.0", + }; + f.manifest.addTool("codex", "5.6.0", []); + f.manifest.addPlugin( + "codex", + InstalledPlugin.fromMetadata("aidd-extra", "1.0.0", source, true, "user", "aidd-framework") + ); + f.manifest.addPlugin( + "kilo", + InstalledPlugin.fromMetadata("aidd-extra", "1.0.0", source, true, "user", "aidd-framework") + ); + const machine = Manifest.create(); + machine.addTool("codex", "5.6.0", []); + machine.addPlugin( + "codex", + InstalledPlugin.fromMetadata("aidd-extra", "1.0.0", source, true, "user", "aidd-framework") + ); + f.loadUserManifest.mockResolvedValue(machine); + const confirm = vi.fn(async () => false); + await f.uc.execute({ projectRoot: "/project", release: "v5.13.0", allowShared: true, confirm }); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ globalSkipped: 2, unverifiedUserRefs: 1 }) + ); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + }); + it("previews machine installations from the user manifest without changing them", async () => { + const f = fixture(); + const machine = Manifest.create(); + machine.addTool("opencode", "5.6.0", []); + machine.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + "aidd-context", + "2.9.0", + { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + }, + true, + "user", + "aidd-framework" + ) + ); + f.loadUserManifest.mockResolvedValue(machine); + const before = machine.toJSON(); + const confirm = vi.fn(async () => false); + await f.uc.execute({ projectRoot: "/project", release: "v5.13.0", allowShared: true, confirm }); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + globalPlugins: [ + { + toolId: "opencode", + pluginName: "aidd-context", + currentVersion: "2.9.0", + source: expect.objectContaining({ ref: "v5.12.0" }), + }, + ], + }) + ); + expect(machine.toJSON()).toEqual(before); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + }); + it("rechecks shared native impact before rebuilding Claude after other installations", async () => { + const f = fixture(); + f.manifest.addTool("claude", "5.6.0", []); + f.manifest.addPlugin("claude", f.manifest.getPlugins("opencode")[0]); + f.preflight.mockResolvedValueOnce({ impact: [], shared: false, tool: "claude" }); + f.verifyImpact + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Claude installations changed")); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/Upgrade incomplete.*Claude installations changed/); + expect(f.verifyImpact).toHaveBeenCalledTimes(2); + expect(f.install).toHaveBeenCalledTimes(2); + expect(f.sync).not.toHaveBeenCalled(); + }); + it("reports updated sources separately from a failed native activation", async () => { + const f = fixture(); + f.sync.mockResolvedValue({ errors: [], binaryMissing: [], warnings: ["catalog unavailable"] }); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/Upgrade incomplete.*Plugin sources updated:.*catalog unavailable/); + }); + it("shows installed targets before asking, and leaves them unchanged when declined", async () => { + const f = fixture(); + const confirm = vi.fn(async (_preview: FrameworkUpgradePreview) => false); + const result = await f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm, + }); + expect(confirm).toHaveBeenCalledWith({ + currentRef: "v5.12.0", + release: "v5.13.0", + title: "Release title", + targets: [ + { + toolId: "opencode", + pluginName: "aidd-context", + currentRef: "v5.12.0", + currentVersion: "2.9.0", + targetVersion: "2.10.0", + }, + { + toolId: "kilo", + pluginName: "aidd-dev", + currentRef: "v5.12.0", + currentVersion: "2.7.0", + targetVersion: "2.8.0", + }, + ], + globalSkipped: 0, + unverifiedUserRefs: 0, + globalPlugins: [], + shared: true, + impact: [], + }); + expect(result.cancelled).toBe(true); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + expect(f.install).not.toHaveBeenCalled(); + }); + + it("shows a pinned plugin ref even when the marketplace is already at the target release", async () => { + const f = fixture(); + const data = f.original.toJSON(); + data.source.ref = "v5.13.0"; + data.version = "5.13.0"; + f.list.mockResolvedValue([Marketplace.fromJSON(data)]); + f.resolve.mockResolvedValue({ + localPath: "/release", + catalog: { + version: "5.13.0", + plugins: [ + { + name: "aidd-context", + source: { kind: "local", path: "./plugins/aidd-context" }, + version: "2.10.0", + }, + { name: "aidd-dev", source: { kind: "local", path: "./plugins/aidd-dev" } }, + ], + }, + }); + const confirm = vi.fn(async (_preview: FrameworkUpgradePreview) => false); + await f.uc.execute({ projectRoot: "/project", release: "v5.13.0", allowShared: true, confirm }); + expect(confirm.mock.calls[0][0]).toMatchObject({ + currentRef: "v5.13.0", + release: "v5.13.0", + targets: [ + { + toolId: "opencode", + pluginName: "aidd-context", + currentRef: "v5.12.0", + currentVersion: "2.9.0", + targetVersion: "2.10.0", + }, + { + toolId: "kilo", + pluginName: "aidd-dev", + currentRef: "v5.12.0", + currentVersion: "2.7.0", + targetVersion: "2.8.0", + }, + ], + }); + }); + + it("refuses a shared marketplace change without explicit selection", async () => { + const f = fixture(); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: false, + confirm: accept, + }) + ).rejects.toThrow(/shared/i); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + expect(f.install).not.toHaveBeenCalled(); + }); + + it("upgrades only each existing project plugin/tool pair", async () => { + const f = fixture(); + const result = await f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }); + expect(f.saveMarketplace).toHaveBeenCalledOnce(); + expect(f.saveMarketplace.mock.calls[0][1].version).toBe("5.13.0"); + expect(f.preflight).toHaveBeenCalledOnce(); + expect(f.install.mock.calls.map(([options]) => [options.pluginName, options.toolIds])).toEqual([ + ["aidd-context", ["opencode"]], + ["aidd-dev", ["kilo"]], + ]); + expect(f.sync).toHaveBeenCalledOnce(); + expect(result.updated).toEqual(["opencode:aidd-context", "kilo:aidd-dev"]); + }); + + it("rejects a local source without changing its registration", async () => { + const f = fixture(); + f.list.mockResolvedValue([ + Marketplace.fromJSON({ + name: "aidd-framework", + source: { kind: "local", path: "/local/framework" }, + scope: "user", + addedAt: "2026-01-01", + }), + ]); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/local|source/i); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + }); + + it("stops before changing the marketplace if preflight reports modified files", async () => { + const f = fixture(); + f.preflight.mockRejectedValue(new Error("modified plugin files")); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/modified/); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + }); + + it("refuses an unavailable or non-root release", async () => { + const f = fixture(); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v999.0.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/release/i); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "aidd-dev-v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/release/i); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + }); + + it("refuses a target release that would downgrade an installed plugin", async () => { + const f = fixture(); + const previous = f.manifest.getPlugins("opencode")[0]; + const data = previous.toJSON(); + data.version = "3.0.0"; + f.manifest.removePlugin("opencode", previous.name); + f.manifest.addPlugin("opencode", InstalledPlugin.fromJSON(data)); + f.resolve.mockResolvedValue({ + localPath: "/release", + catalog: { + version: "5.13.0", + plugins: [ + { + name: "aidd-context", + source: { kind: "local", path: "./plugins/aidd-context" }, + version: "2.10.0", + }, + { name: "aidd-dev", source: { kind: "local", path: "./plugins/aidd-dev" } }, + ], + }, + }); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/downgrade/i); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + expect(f.install).not.toHaveBeenCalled(); + }); + + it("refuses an independently sourced plugin before changing the marketplace", async () => { + const f = fixture(); + const previous = f.manifest.getPlugins("opencode")[0]; + const data = previous.toJSON(); + data.source = { kind: "local", path: "/local/custom" }; + f.manifest.removePlugin("opencode", previous.name); + f.manifest.addPlugin("opencode", InstalledPlugin.fromJSON(data)); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/source/i); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + }); + + it("refuses a target catalogue that redirects the plugin to another path", async () => { + const f = fixture(); + f.resolve.mockResolvedValue({ + localPath: "/release", + catalog: { + version: "5.13.0", + plugins: [ + { name: "aidd-context", source: { kind: "local", path: "./plugins/other" } }, + { name: "aidd-dev", source: { kind: "local", path: "./plugins/aidd-dev" } }, + ], + }, + }); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/source/i); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + expect(f.install).not.toHaveBeenCalled(); + }); + + it("refuses a catalog and plugin version mismatch before changing the marketplace", async () => { + const f = fixture(); + f.resolve.mockResolvedValue({ + localPath: "/release", + catalog: { + version: "5.13.0", + plugins: [ + { + name: "aidd-context", + source: { kind: "local", path: "./plugins/aidd-context" }, + version: "1.0.0", + }, + { name: "aidd-dev", source: { kind: "local", path: "./plugins/aidd-dev" } }, + ], + }, + }); + const confirm = vi.fn(accept); + await expect( + f.uc.execute({ projectRoot: "/project", release: "v5.13.0", allowShared: true, confirm }) + ).rejects.toThrow(/catalog version/i); + expect(confirm).not.toHaveBeenCalled(); + expect(f.saveMarketplace).not.toHaveBeenCalled(); + expect(f.install).not.toHaveBeenCalled(); + }); + + it("does not report success when an install leaves its old source ref", async () => { + const f = fixture(); + f.install.mockImplementation(async (_options) => {}); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/incomplete|verif/i); + }); + + it("reports partial execution when a plugin cannot be upgraded", async () => { + const f = fixture(); + f.install.mockRejectedValueOnce(new Error("install failed")); + await expect( + f.uc.execute({ + projectRoot: "/project", + release: "v5.13.0", + allowShared: true, + confirm: accept, + }) + ).rejects.toThrow(/partial|incomplete/i); + expect(f.sync).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts index 44b4ca86a..bee79dbd5 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -4,6 +4,8 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { kilo } from "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { registerTool } from "../../../../src/contexts/tools/domain/registry.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { @@ -120,10 +122,24 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user" })).toThrow(UserScopeNoToolsError); }); - it("refuses an AI tool with no user-scope activation at --scope user (opencode)", () => { - expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).toThrow( - UserScopeUnsupportedAiToolsError - ); + it("refuses an AI tool with no user-scope activation at --scope user", () => { + const { plugins: _plugins, ...withoutPlugins } = kilo.capabilities; + registerTool({ ...kilo, capabilities: withoutPlugins }); + try { + expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).toThrow( + UserScopeUnsupportedAiToolsError + ); + } finally { + registerTool(kilo); + } + }); + + it("accepts an AI tool that copies global files at --scope user (kilo)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).not.toThrow(); + }); + + it("accepts an AI tool that copies global files at --scope user (opencode)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).not.toThrow(); }); it("accepts an AI tool that installs to a user-scope directory at --scope user (cursor)", () => { @@ -134,10 +150,17 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user", aiTools: ["claude"] })).not.toThrow(); }); - it("refuses --plugins at --scope user — no manifest entry exists yet to enable one against", () => { - expect(() => makeFlow({ scope: "user", aiTools: ["claude"], pluginMode: "all" })).toThrow( - UserScopePluginModeError - ); + it("refuses an interactive plugin pick at --scope user — there is no project to ask in", () => { + expect(() => + makeFlow({ scope: "user", aiTools: ["claude"], pluginMode: "interactive" }) + ).toThrow(UserScopePluginModeError); }); + + it.each(["all", "recommended", "none"] as const)( + "accepts --plugins %s at --scope user", + (pluginMode) => { + expect(() => makeFlow({ scope: "user", aiTools: ["opencode"], pluginMode })).not.toThrow(); + } + ); }); }); diff --git a/cli/tests/contexts/framework/infrastructure/pinned-user-framework-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/pinned-user-framework-adapter.integration.test.ts new file mode 100644 index 000000000..83b3f2601 --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/pinned-user-framework-adapter.integration.test.ts @@ -0,0 +1,247 @@ +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginCatalog } from "../../../../src/contexts/distribution/domain/catalog.js"; +import type { PluginCatalogRepository } from "../../../../src/contexts/distribution/domain/ports/plugin-catalog-repository.js"; +import type { PluginFetcher } from "../../../../src/contexts/distribution/domain/ports/plugin-fetcher.js"; +import type { FrameworkBuildFor } from "../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import type { PluginDistributionReader } from "../../../../src/contexts/framework/domain/ports/plugin-distribution-reader.js"; +import { PinnedUserFrameworkAdapter } from "../../../../src/contexts/framework/infrastructure/pinned-user-framework-adapter.js"; +import type { FrameworkBuildOptions } from "../../../../src/contexts/translate/domain/build-target.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "aidd-pinned-framework-")); + dirs.push(root); + const repo = join(root, "repo"); + const hooks = join(root, "hooks"); + await mkdir(hooks); + execFileSync("git", ["init", "--quiet", repo]); + execFileSync("git", [ + "-C", + repo, + "-c", + "user.name=AIDD Test", + "-c", + "user.email=test@example.test", + "-c", + `core.hooksPath=${hooks}`, + "commit", + "--allow-empty", + "--no-gpg-sign", + "-qm", + "fixture", + ]); + const sha = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const fetch = vi.fn(async () => repo); + const catalog: PluginCatalog = { + name: "aidd-framework", + version: "5.13.0", + plugins: [ + { + name: "aidd-context", + version: "2.10.0", + source: { kind: "local", path: "./plugins/aidd-context" }, + recommended: false, + strict: false, + }, + ], + }; + const load = vi.fn(async () => catalog); + const dist = new PluginDistribution({ + manifest: { name: "aidd-context", version: "2.10.0" }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }); + const read = vi.fn(async () => dist); + const execute = vi.fn(async ({ outDir }: FrameworkBuildOptions) => { + const file = join(outDir, ".opencode", "skills", "aidd-context", "onboard", "SKILL.md"); + const other = join(outDir, ".opencode", "skills", "other", "SKILL.md"); + await mkdir(dirname(file), { recursive: true }); + await mkdir(dirname(other), { recursive: true }); + await writeFile(file, "verified release content"); + await writeFile(other, "other plugin"); + return { outDir, plugins: [], totalFiles: 2 }; + }); + const buildFor = vi.fn(() => ({ execute })); + const hasher = new HasherAdapter(); + const adapter = new PinnedUserFrameworkAdapter( + new FileAdapter(hasher), + hasher, + { fetch }, + { load }, + { read }, + buildFor + ); + return { adapter, repo, sha, fetch, load, read, buildFor, execute, catalog, dist }; +} + +describe("pinned user framework source", () => { + it("builds only the selected plugin from a verified git commit and rechecks the same SHA", async () => { + const f = await fixture(); + const result = await f.adapter.build("v5.13.0", "opencode", "aidd-context"); + expect(result.sha).toBe(f.sha); + expect(result.dist.manifest).toEqual({ name: "aidd-context", version: "2.10.0" }); + expect(result.files.map((file) => [file.relativePath, file.content])).toEqual([ + ["skills/aidd-context/onboard/SKILL.md", "verified release content"], + ]); + expect(f.fetch).toHaveBeenCalledTimes(2); + expect(f.fetch.mock.calls[0][0]).toEqual({ + kind: "github", + repo: "ai-driven-dev/framework", + ref: "v5.13.0", + }); + expect(f.fetch.mock.calls[1][0]).toEqual({ + kind: "github", + repo: "ai-driven-dev/framework", + ref: "v5.13.0", + sha: f.sha, + }); + expect(f.fetch.mock.calls[1][1]).toBe(f.fetch.mock.calls[0][1]); + expect(f.load).toHaveBeenCalledWith(f.repo); + expect(f.read).toHaveBeenCalledWith(join(f.repo, "plugins", "aidd-context")); + expect(f.buildFor).toHaveBeenCalledWith("opencode", "flat", expect.any(String)); + expect(f.execute).toHaveBeenCalledWith({ + sourceDir: f.repo, + outDir: expect.any(String), + target: "opencode", + mode: "flat", + }); + const temporary = dirname(f.fetch.mock.calls[0][1]); + await expect(new FileAdapter(new HasherAdapter()).fileExists(temporary)).resolves.toBe(false); + }); + + it.each([ + { ref: "main", plugin: "aidd-context" }, + { ref: "5.13.0", plugin: "aidd-context" }, + { ref: "v5.13.0", plugin: "../outside" }, + ])( + "rejects an unsafe release or plugin name before fetching: $ref / $plugin", + async ({ ref, plugin }) => { + const f = await fixture(); + await expect(f.adapter.build(ref, "opencode", plugin)).rejects.toThrow( + "Expected a versioned official" + ); + expect(f.fetch).not.toHaveBeenCalled(); + } + ); + + it("rejects a checkout whose commit differs from the recorded SHA", async () => { + const f = await fixture(); + await expect( + f.adapter.build("v5.13.0", "opencode", "aidd-context", "0".repeat(40)) + ).rejects.toThrow("does not match the recorded release commit"); + expect(f.load).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + expect(f.fetch).toHaveBeenCalledTimes(1); + }); + + it.each([null, { version: "5.12.0", plugins: [] }, { version: "invalid", plugins: [] }])( + "rejects an absent or mismatched release catalog: %s", + async (catalog) => { + const f = await fixture(); + f.load.mockResolvedValue(catalog); + await expect(f.adapter.build("v5.13.0", "opencode", "aidd-context")).rejects.toThrow( + "invalid framework marketplace version" + ); + expect(f.read).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + } + ); + + it("rejects a release that does not contain the installed plugin", async () => { + const f = await fixture(); + f.load.mockResolvedValue({ ...f.catalog, plugins: [] }); + await expect(f.adapter.build("v5.13.0", "opencode", "aidd-context")).rejects.toThrow( + "does not contain 'aidd-context'" + ); + expect(f.read).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + }); + + it.each([ + { kind: "local" as const, path: "../outside" }, + { + kind: "git-subdir" as const, + url: "https://example.test/other.git", + path: "plugins/aidd-context", + ref: "v5.13.0", + }, + { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + }, + { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/other", + ref: "v5.13.0", + }, + ])("rejects a catalog entry that resolves to another plugin or source", async (source) => { + const f = await fixture(); + f.load.mockResolvedValue({ ...f.catalog, plugins: [{ ...f.catalog.plugins[0], source }] }); + await expect(f.adapter.build("v5.13.0", "opencode", "aidd-context")).rejects.toThrow( + "unverified source" + ); + expect(f.read).not.toHaveBeenCalled(); + expect(f.execute).not.toHaveBeenCalled(); + }); + + it.each([ + { name: "other", version: "2.10.0" }, + { name: "aidd-context", version: "invalid" }, + { name: "aidd-context", version: "2.9.0" }, + ])("rejects inconsistent installed-plugin metadata: %s", async (manifest) => { + const f = await fixture(); + f.read.mockResolvedValue( + new PluginDistribution({ + manifest, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }) + ); + await expect(f.adapter.build("v5.13.0", "opencode", "aidd-context")).rejects.toThrow( + "inconsistent metadata" + ); + expect(f.execute).not.toHaveBeenCalled(); + }); + + it("refuses non-flat hosts and missing build implementations", async () => { + const nonFlat = await fixture(); + await expect(nonFlat.adapter.build("v5.13.0", "codex", "aidd-context")).rejects.toThrow( + "supports flat machine installations only" + ); + expect(nonFlat.execute).not.toHaveBeenCalled(); + const missing = await fixture(); + missing.buildFor.mockReturnValue(undefined); + await expect(missing.adapter.build("v5.13.0", "opencode", "aidd-context")).rejects.toThrow( + "no compatible framework build" + ); + expect(missing.execute).not.toHaveBeenCalled(); + }); + + it("fails if the source cannot be verified again after the build", async () => { + const f = await fixture(); + f.fetch.mockResolvedValueOnce(f.repo).mockRejectedValueOnce(new Error("source moved")); + await expect(f.adapter.build("v5.13.0", "opencode", "aidd-context")).rejects.toThrow( + "source moved" + ); + expect(f.execute).toHaveBeenCalledOnce(); + expect(f.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/pinned-user-framework-session.integration.test.ts b/cli/tests/contexts/framework/infrastructure/pinned-user-framework-session.integration.test.ts new file mode 100644 index 000000000..83cdc73f2 --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/pinned-user-framework-session.integration.test.ts @@ -0,0 +1,188 @@ +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginCatalogRepository } from "../../../../src/contexts/distribution/domain/ports/plugin-catalog-repository.js"; +import type { PluginFetcher } from "../../../../src/contexts/distribution/domain/ports/plugin-fetcher.js"; +import type { FrameworkBuildFor } from "../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import type { PluginDistributionReader } from "../../../../src/contexts/framework/domain/ports/plugin-distribution-reader.js"; +import { PinnedUserFrameworkAdapter } from "../../../../src/contexts/framework/infrastructure/pinned-user-framework-adapter.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "aidd-pinned-session-")); + dirs.push(root); + const origin = join(root, "origin"); + execFileSync("git", ["init", "--quiet", origin]); + execFileSync("git", [ + "-C", + origin, + "-c", + "user.name=AIDD", + "-c", + "user.email=aidd@example.test", + "commit", + "--allow-empty", + "--no-gpg-sign", + "-qm", + "fixture", + ]); + const sha = execFileSync("git", ["-C", origin, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const refs = new Map(); + const clonePaths: string[] = []; + const fetch = vi.fn(async (source, cache) => { + if (source.kind !== "github" || !source.ref) throw new Error("unexpected source"); + const checkout = join(cache, "checkout"); + if (!refs.has(checkout)) { + await mkdir(cache, { recursive: true }); + execFileSync("git", ["clone", "--quiet", origin, checkout]); + refs.set(checkout, source.ref); + clonePaths.push(checkout); + } + const head = execFileSync("git", ["-C", checkout, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + if (source.sha && source.sha !== head) throw new Error("source SHA mismatch"); + return checkout; + }); + const load = vi.fn(async (checkout) => ({ + name: "aidd-framework", + version: refs.get(checkout)?.slice(1), + plugins: ["aidd-context", "aidd-dev"].map((name) => ({ + name, + version: "1.0.0", + source: { kind: "local" as const, path: `./plugins/${name}` }, + strict: false, + recommended: false, + })), + })); + const read = vi.fn( + async (dir) => + new PluginDistribution({ + manifest: { name: basename(dir), version: "1.0.0" }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }) + ); + const execute = vi.fn(async ({ outDir, target }: { outDir: string; target: string }) => { + for (const name of ["aidd-context", "aidd-dev"]) { + const file = join(outDir, `.${target}`, "skills", name, "demo", "SKILL.md"); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, `${target}:${name}`); + } + return { outDir, plugins: [], totalFiles: 2 }; + }); + const buildFor: FrameworkBuildFor = vi.fn(() => ({ execute })); + const hasher = new HasherAdapter(); + const fs = new FileAdapter(hasher); + const adapter = new PinnedUserFrameworkAdapter( + fs, + hasher, + { fetch }, + { load }, + { read }, + buildFor + ); + return { adapter, sha, clonePaths, fetch, load, read, execute, fs }; +} + +describe("one pinned framework operation", () => { + it("reuses one checkout and one build per tool for two plugins, then removes it", async () => { + const f = await fixture(); + if (!f.adapter.withSession) throw new Error("session unavailable"); + const results = await f.adapter.withSession(async (session) => { + const values = []; + for (const tool of ["opencode", "kilo"] as const) + for (const name of ["aidd-context", "aidd-dev"]) + values.push( + await session.build("v5.13.0", tool, name, values.length ? f.sha : undefined) + ); + expect(f.clonePaths).toHaveLength(1); + expect(f.execute).toHaveBeenCalledTimes(2); + expect(f.fetch.mock.calls[0][0]).not.toHaveProperty("sha"); + expect(f.fetch.mock.calls[0][2]).toEqual({ canonicalCheckout: true }); + expect(await f.fs.fileExists(f.clonePaths[0])).toBe(true); + return values; + }); + expect(results.map((v) => v.files.map((file) => file.content))).toEqual([ + ["opencode:aidd-context"], + ["opencode:aidd-dev"], + ["kilo:aidd-context"], + ["kilo:aidd-dev"], + ]); + expect(results.every((v) => v.sha === f.sha)).toBe(true); + expect(await f.fs.fileExists(f.clonePaths[0])).toBe(false); + }); + + it("does not share a checkout between operations or distinct refs", async () => { + const f = await fixture(); + if (!f.adapter.withSession) throw new Error("session unavailable"); + await f.adapter.withSession(async (s) => { + await s.build("v5.13.0", "opencode", "aidd-context", f.sha); + await s.build("v5.14.0", "opencode", "aidd-context", f.sha); + }); + await f.adapter.withSession(async (s) => { + await s.build("v5.13.0", "opencode", "aidd-context", f.sha); + }); + expect(f.clonePaths).toHaveLength(3); + expect(new Set(f.clonePaths).size).toBe(3); + expect(f.execute).toHaveBeenCalledTimes(3); + for (const checkout of f.clonePaths) expect(await f.fs.fileExists(checkout)).toBe(false); + }); + + it("refuses a different recorded commit even when the ref was already built", async () => { + const f = await fixture(); + if (!f.adapter.withSession) throw new Error("session unavailable"); + await expect( + f.adapter.withSession(async (session) => { + await session.build("v5.13.0", "opencode", "aidd-context", f.sha); + await session.build("v5.13.0", "opencode", "aidd-dev", "0".repeat(40)); + }) + ).rejects.toThrow("does not match the recorded release commit"); + expect(f.clonePaths).toHaveLength(1); + expect(f.execute).toHaveBeenCalledTimes(1); + expect(await f.fs.fileExists(f.clonePaths[0])).toBe(false); + }); + + it("cleans temporary files after an error and checks each plugin's metadata", async () => { + const f = await fixture(); + if (!f.adapter.withSession) throw new Error("session unavailable"); + f.read.mockImplementationOnce( + async () => + new PluginDistribution({ + manifest: { name: "aidd-context", version: "1.0.0" }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }) + ); + f.read.mockImplementationOnce( + async () => + new PluginDistribution({ + manifest: { name: "wrong", version: "1.0.0" }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }) + ); + await expect( + f.adapter.withSession(async (s) => { + await s.build("v5.13.0", "opencode", "aidd-context", f.sha); + await s.build("v5.13.0", "opencode", "aidd-dev", f.sha); + }) + ).rejects.toThrow("inconsistent metadata"); + expect(f.execute).toHaveBeenCalledTimes(1); + expect(await f.fs.fileExists(f.clonePaths[0])).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts index b093391cd..0a7296b4c 100644 --- a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts @@ -1,13 +1,122 @@ +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + readlink, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import { hostname, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { setTimeout as pause } from "node:timers/promises"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promisify } from "node:util"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { UserManifestRepositoryAdapter } from "../../../../src/contexts/framework/infrastructure/user-manifest-repository-adapter.js"; import { atomicWriteFile } from "../../../../src/runtime/filesystem/atomic-write.js"; +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +let runnerDir: string; +let runner: string; + +beforeAll(async () => { + runnerDir = await mkdtemp(join(tmpdir(), "aidd-lock-runner-")); + await writeFile(join(runnerDir, "package.json"), '{"type":"module"}'); + const entry = join(runnerDir, "runner.ts"); + const adapterPath = resolve( + "src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts" + ).replaceAll("\\", "/"); + await writeFile( + entry, + ` +import { open, unlink } from "node:fs/promises"; +import { setTimeout as pause } from "node:timers/promises"; +import { UserManifestRepositoryAdapter } from ${JSON.stringify(adapterPath)}; +const [dir, mode] = process.argv.slice(2); +const repo = new UserManifestRepositoryAdapter(() => dir, async () => {}); +await repo.withExclusiveAccess(async () => { + if (mode === "hold") { + process.stdout.write("locked\\n"); + await pause(60_000); + } else { + const gate = dir + "/in-critical-section"; + const file = await open(gate, "wx"); + process.stdout.write("locked\\n"); + await pause(150); + await file.close(); + await unlink(gate); + } +}); +` + ); + const tsup = join(dirname(require.resolve("tsup")), "cli-default.js"); + // Keep tsup's entry and cwd on the same volume: Windows temp and checkout may use + // different drives, and tsup otherwise turns the absolute entry into a relative path. + await execFileAsync( + process.execPath, + [tsup, "runner.ts", "--no-config", "--format", "esm", "--out-dir", ".", "--silent"], + { cwd: runnerDir } + ); + runner = join(runnerDir, "runner.mjs"); + await rename(join(runnerDir, "runner.js"), runner); +}, 30_000); + +afterAll(async () => { + await rm(runnerDir, { recursive: true, force: true }); +}); + +function runLock(dir: string, mode: "hold" | "once") { + const child = spawn(process.execPath, [runner, dir, mode], { stdio: ["ignore", "pipe", "pipe"] }); + const locked = new Promise((resolvePromise, reject) => { + child.stdout.once("data", () => resolvePromise()); + child.once("error", reject); + child.once("exit", (code) => reject(new Error(`lock runner exited before entry: ${code}`))); + }); + return { child, locked }; +} + +async function currentRealm(): Promise { + if (process.platform === "linux") { + const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(); + return `linux:${boot}:${await readlink("/proc/self/ns/pid")}`; + } + if (process.platform === "darwin") { + const { stdout } = await execFileAsync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"]); + return `darwin:${stdout.trim()}`; + } + return undefined; +} + +async function stoppedPid(): Promise { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"]); + await once(child, "spawn"); + if (child.pid === undefined) throw new Error("child has no PID"); + const pid = child.pid; + const exit = once(child, "exit"); + child.kill("SIGKILL"); + await exit; + return pid; +} + +async function expectBusy(action: () => Promise): Promise { + const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(30_000); + try { + await expect(action()).rejects.toThrow(/User manifest is busy/); + } finally { + now.mockRestore(); + } +} + describe("UserManifestRepositoryAdapter", () => { let userConfigDir: string; let adapter: UserManifestRepositoryAdapter; @@ -37,14 +146,12 @@ describe("UserManifestRepositoryAdapter", () => { await expect(adapter.load()).rejects.toThrow(manifestPath); }); - it('rejects a refused manifest version naming the real user manifest path and `aidd setup --scope user`, never .aidd/manifest.json or "in this project"', async () => { + it('rejects a refused manifest version naming the real user manifest path and `aidd setup --global`, never .aidd/manifest.json or "in this project"', async () => { const manifestPath = join(userConfigDir, "manifest.json"); await writeFile(manifestPath, '{"version": 7, "tools": {}}'); await expect(adapter.load()).rejects.toThrow( - new RegExp( - `${manifestPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*aidd setup --scope user` - ) + new RegExp(`${manifestPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*aidd setup --global`) ); await expect(adapter.load()).rejects.not.toThrow(/\.aidd\/manifest\.json/); await expect(adapter.load()).rejects.not.toThrow(/in this project/); @@ -55,7 +162,7 @@ describe("UserManifestRepositoryAdapter", () => { await writeFile(manifestPath, '{"version": 7, "tools": {}}'); await expect(adapter.load()).rejects.toThrow( - `delete ${manifestPath} for this machine, then run \`aidd setup --scope user\` to reinstall the framework.` + `delete ${manifestPath} for this machine, then run \`aidd setup --global\` to reinstall the framework.` ); }); }); @@ -79,6 +186,302 @@ describe("UserManifestRepositoryAdapter", () => { }); describe("exclusive machine mutations", () => { + it("publishes its actual owner and removes temporary claims when it releases", async () => { + const lock = `${adapter.path}.lock`; + const result = await adapter.withExclusiveAccess(async () => { + const owner = JSON.parse(await readFile(lock, "utf8")); + expect(owner).toMatchObject({ pid: process.pid, host: hostname() }); + const realm = await currentRealm(); + if (realm !== undefined) expect(owner.realm).toBe(realm); + if (process.platform !== "win32") expect((await lstat(lock)).mode & 0o077).toBe(0); + return "done"; + }); + expect(result).toBe("done"); + expect( + (await readdir(userConfigDir)).filter((name) => name.startsWith("manifest.json.lock")) + ).toEqual([]); + }); + + it.skipIf(process.platform === "win32")( + "recovers a real dead owner in-process, then acquires its own lock", + async () => { + const realm = await currentRealm(); + expect(realm).toBeDefined(); + const pid = await stoppedPid(); + const lock = `${adapter.path}.lock`; + await writeFile(lock, JSON.stringify({ pid, host: hostname(), realm })); + let entered = false; + // Allow one retry after the stale lock is removed; fail promptly if recovery is broken. + const now = vi + .spyOn(Date, "now") + .mockReturnValueOnce(0) + .mockReturnValueOnce(1) + .mockReturnValue(30_000); + try { + await adapter.withExclusiveAccess(async () => { + entered = true; + const owner = JSON.parse(await readFile(lock, "utf8")); + expect(owner).toMatchObject({ pid: process.pid, host: hostname(), realm }); + }); + } finally { + now.mockRestore(); + } + expect(entered).toBe(true); + expect(existsSync(lock)).toBe(false); + } + ); + + it.skipIf(process.platform === "win32")( + "never reaps an active PID or one whose existence cannot be checked", + async () => { + const realm = await currentRealm(); + const lock = `${adapter.path}.lock`; + const record = JSON.stringify({ pid: process.pid, host: hostname(), realm }); + await writeFile(lock, record); + let entered = false; + await expectBusy(() => + adapter.withExclusiveAccess(async () => { + entered = true; + }) + ); + expect(entered).toBe(false); + expect(await readFile(lock, "utf8")).toBe(record); + + const kill = vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("permission denied"), { code: "EPERM" }); + }); + try { + await expectBusy(() => + adapter.withExclusiveAccess(async () => { + entered = true; + }) + ); + } finally { + kill.mockRestore(); + } + expect(entered).toBe(false); + expect(await readFile(lock, "utf8")).toBe(record); + } + ); + + it.skipIf(process.platform === "win32")( + "preserves invalid, foreign and oversized owner records", + async () => { + const realm = await currentRealm(); + const pid = await stoppedPid(); + const good = { pid, host: hostname(), realm }; + const lock = `${adapter.path}.lock`; + const invalid = [ + "null", + "0", + "[]", + "{broken", + JSON.stringify({}), + JSON.stringify({ host: hostname(), realm }), + JSON.stringify({ pid, realm }), + JSON.stringify({ pid, host: hostname() }), + JSON.stringify({ ...good, pid: "1" }), + JSON.stringify({ ...good, pid: 0 }), + JSON.stringify({ ...good, pid: -1 }), + JSON.stringify({ ...good, pid: 1.5 }), + JSON.stringify({ ...good, pid: Number.MAX_SAFE_INTEGER + 1 }), + JSON.stringify({ ...good, host: "foreign-host" }), + JSON.stringify({ ...good, realm: "foreign-realm" }), + `${JSON.stringify(good)}${" ".repeat(1025)}`, + ]; + for (const record of invalid) { + await writeFile(lock, record); + let entered = false; + const kill = vi.spyOn(process, "kill"); + try { + await expectBusy(() => + adapter.withExclusiveAccess(async () => { + entered = true; + }) + ); + expect(kill).not.toHaveBeenCalled(); + } finally { + kill.mockRestore(); + } + expect(entered).toBe(false); + expect(await readFile(lock, "utf8")).toBe(record); + await rm(lock); + } + } + ); + + it.skipIf(process.platform === "win32")( + "never follows a symbolic link presented as a lock", + async () => { + const realm = await currentRealm(); + const pid = await stoppedPid(); + const lock = `${adapter.path}.lock`; + const foreign = join(userConfigDir, "foreign.json"); + const contents = JSON.stringify({ pid, host: hostname(), realm }); + await writeFile(foreign, contents); + await symlink(foreign, lock); + await expectBusy(() => adapter.withExclusiveAccess(async () => {})); + expect(await readlink(lock)).toBe(foreign); + expect(await readFile(foreign, "utf8")).toBe(contents); + } + ); + + it("does not probe or remove a dead owner when the process realm is unavailable", async () => { + const realm = await currentRealm(); + const pid = await stoppedPid(); + const lock = `${adapter.path}.lock`; + const record = JSON.stringify({ pid, host: hostname(), realm: realm ?? "unverifiable" }); + await writeFile(lock, record); + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (descriptor === undefined) throw new Error("process.platform descriptor is missing"); + const kill = vi.spyOn(process, "kill"); + try { + Object.defineProperty(process, "platform", { ...descriptor, value: "win32" }); + await expectBusy(() => adapter.withExclusiveAccess(async () => {})); + expect(kill).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, "platform", descriptor); + kill.mockRestore(); + } + expect(await readFile(lock, "utf8")).toBe(record); + }); + + it.skipIf(process.platform === "win32" || (process.getuid?.() ?? 0) === 0)( + "propagates a real filesystem permission failure without creating a claim", + async () => { + await chmod(userConfigDir, 0o500); + try { + await expect(adapter.withExclusiveAccess(async () => {})).rejects.toMatchObject({ + code: "EACCES", + }); + } finally { + await chmod(userConfigDir, 0o700); + } + expect(await readdir(userConfigDir)).toEqual([]); + } + ); + it("runs recovery while locked before the requested mutation", async () => { + const events: string[] = []; + const recovering = new UserManifestRepositoryAdapter( + () => userConfigDir, + atomicWriteFile, + async () => { + events.push("recovery"); + } + ); + await recovering.withExclusiveAccess(async () => { + events.push("action"); + }); + expect(events).toEqual(["recovery", "action"]); + const failing = new UserManifestRepositoryAdapter( + () => userConfigDir, + atomicWriteFile, + async () => { + throw new Error("recovery failed"); + } + ); + await expect( + failing.withExclusiveAccess(async () => { + events.push("unsafe"); + }) + ).rejects.toThrow("recovery failed"); + expect(events).toEqual(["recovery", "action"]); + await expect(adapter.withExclusiveAccess(async () => {})).resolves.toBeUndefined(); + }); + + it.skipIf(process.platform === "win32")( + "reclaims a killed process's lock while serializing competing processes", + async () => { + const owner = runLock(userConfigDir, "hold"); + await owner.locked; + const exit = once(owner.child, "exit"); + owner.child.kill("SIGKILL"); + await exit; + const contenders = Array.from({ length: 4 }, () => runLock(userConfigDir, "once")); + const exits = contenders.map(({ child }) => once(child, "exit")); + await Promise.all(contenders.map(({ locked }) => locked)); + expect((await Promise.all(exits)).map(([code]) => code)).toEqual([0, 0, 0, 0]); + expect(existsSync(`${adapter.path}.lock`)).toBe(false); + }, + 30_000 + ); + + it.skipIf(process.platform === "win32")( + "does not recover a live process's lock", + async () => { + const owner = runLock(userConfigDir, "hold"); + await owner.locked; + const contender = runLock(userConfigDir, "once"); + let entered = false; + void contender.locked.then(() => { + entered = true; + }); + try { + await pause(350); + expect(entered).toBe(false); + } finally { + const ownerExit = once(owner.child, "exit"); + owner.child.kill("SIGKILL"); + await ownerExit; + } + const contenderExit = once(contender.child, "exit"); + await contender.locked; + expect((await contenderExit)[0]).toBe(0); + }, + 30_000 + ); + + it.skipIf(process.platform !== "win32")( + "refuses to reap when the PID realm cannot be verified", + async () => { + const owner = runLock(userConfigDir, "hold"); + await owner.locked; + const exit = once(owner.child, "exit"); + owner.child.kill("SIGKILL"); + await exit; + const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(30_000); + try { + await expect(adapter.withExclusiveAccess(async () => {})).rejects.toThrow( + /User manifest is busy/ + ); + } finally { + now.mockRestore(); + } + expect(existsSync(`${adapter.path}.lock`)).toBe(true); + }, + 30_000 + ); + + it("keeps malformed or foreign owner records and a blocked recovery guard", async () => { + const lock = `${adapter.path}.lock`; + for (const content of [ + "{broken", + JSON.stringify({ pid: 99999999, host: "foreign-host" }), + JSON.stringify({ pid: 99999999, host: hostname(), realm: "another-pid-namespace" }), + ]) { + await writeFile(lock, content); + const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(30_000); + try { + await expect(adapter.withExclusiveAccess(async () => {})).rejects.toThrow( + /User manifest is busy/ + ); + } finally { + now.mockRestore(); + } + expect(existsSync(lock)).toBe(true); + await rm(lock); + } + await mkdir(`${lock}.guard`); + const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(30_000); + try { + await expect(adapter.withExclusiveAccess(async () => {})).rejects.toThrow( + /User manifest is busy.*If none are running, inspect and remove this stale lock path.*never remove a lock held by an active process/ + ); + } finally { + now.mockRestore(); + } + expect(existsSync(`${lock}.guard`)).toBe(true); + }); it("fails closed at the bounded deadline without stealing another process's lock", async () => { const lock = `${adapter.path}.lock`; await mkdir(lock); diff --git a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts deleted file mode 100644 index 8d9b51fc7..000000000 --- a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - COPILOT_AGENT_FRONTMATTER_KEYS, - CURSOR_AGENT_FRONTMATTER_KEYS, - stripCopilotAgentFrontmatter, - stripCursorAgentFrontmatter, - stripPathIdAgentFrontmatter, -} from "../../../../../src/contexts/tools/domain/formats/agent-frontmatter-strip.js"; - -describe("stripCopilotAgentFrontmatter", () => { - it("keeps name, description and model, and reads Claude's effort as reasoningEffort", () => { - expect( - stripCopilotAgentFrontmatter({ - name: "reviewer", - description: "Reviews code", - model: "claude-sonnet", - effort: "high", - }) - ).toEqual({ - name: "reviewer", - description: "Reviews code", - model: "claude-sonnet", - reasoningEffort: "high", - "include-custom-instructions": true, - }); - }); - - it("drops tools and every other key, so the agent keeps Copilot's default of all tools", () => { - const result = stripCopilotAgentFrontmatter({ - name: "reviewer", - tools: ["Read"], - agents: ["helper"], - "argument-hint": "[file]", - color: "red", - }); - expect(Object.keys(result)).toEqual(["name", "include-custom-instructions"]); - }); - - it("preserves an explicit repository-instruction opt-out", () => { - expect(stripCopilotAgentFrontmatter({ "include-custom-instructions": false })).toEqual({ - "include-custom-instructions": false, - }); - }); - - it("returns keys in allowlist order for deterministic serialization", () => { - const result = stripCopilotAgentFrontmatter({ - effort: "low", - model: "sonnet", - name: "reviewer", - description: "desc", - }); - expect(Object.keys(result)).toEqual(COPILOT_AGENT_FRONTMATTER_KEYS); - }); -}); - -describe("stripPathIdAgentFrontmatter", () => { - it("keeps description and model only: the file name is the ID, and no key restricts tools", () => { - expect( - stripPathIdAgentFrontmatter({ - name: "explorer", - description: "d", - model: "openai/gpt", - effort: "high", - tools: "Read", - }) - ).toEqual({ description: "d", model: "openai/gpt" }); - }); -}); - -describe("stripCursorAgentFrontmatter", () => { - it("drops tools, color, argument-hint — keeps only name/description/model", () => { - const input = { - name: "planner", - description: "Plans tasks", - model: "claude-sonnet", - tools: ["Read", "Write"], - color: "#ff0000", - "argument-hint": "[task]", - }; - const result = stripCursorAgentFrontmatter(input); - expect(result).toEqual({ - name: "planner", - description: "Plans tasks", - model: "claude-sonnet", - }); - expect(result).not.toHaveProperty("tools"); - expect(result).not.toHaveProperty("color"); - expect(result).not.toHaveProperty("argument-hint"); - }); - - it("preserves a subset: name only", () => { - const result = stripCursorAgentFrontmatter({ name: "agent" }); - expect(result).toEqual({ name: "agent" }); - }); - - it("omits model when undefined", () => { - const result = stripCursorAgentFrontmatter({ name: "agent", model: undefined }); - expect(result).not.toHaveProperty("model"); - }); - - it("returns keys in CURSOR_AGENT_FRONTMATTER_KEYS order", () => { - const input = { model: "sonnet", description: "desc", name: "agent" }; - const result = stripCursorAgentFrontmatter(input); - expect(Object.keys(result)).toEqual(CURSOR_AGENT_FRONTMATTER_KEYS.filter((k) => k in input)); - }); - - it("CURSOR_AGENT_FRONTMATTER_KEYS is exactly [name, description, model]", () => { - expect(CURSOR_AGENT_FRONTMATTER_KEYS).toEqual(["name", "description", "model"]); - }); -}); diff --git a/cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts b/cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts new file mode 100644 index 000000000..698ac1ea2 --- /dev/null +++ b/cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { hostRefInUse } from "../../../../src/contexts/tools/domain/registry.js"; + +describe("hostRefInUse — whether a ref the host lists is a plugin in use", () => { + it("takes an enabled ref for one, at every host", () => { + expect(hostRefInUse("claude", true)).toBe(true); + expect(hostRefInUse("copilot", true)).toBe(true); + }); + + it("takes a disabled ref for one where uninstall removes the ref: the person disabled it", () => { + expect(hostRefInUse("claude", false)).toBe(true); + }); + + it("takes a disabled ref for what an uninstall left where uninstall only disables it", () => { + expect(hostRefInUse("copilot", false)).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts index aa073ea4d..93628610d 100644 --- a/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/claude/plugins.unit.test.ts @@ -48,9 +48,11 @@ describe("claude plugins", () => { expect(claude.capabilities.plugins.nativeActivation).toStrictEqual({ binary: "claude", scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + userRemoveArgs: [], enableVerb: "install", disableVerb: "uninstall", upgradeVerb: "update", + releaseUpdateVerb: "update", pluginArgs: ["--yes"], marketplaceRegistry: expect.any(Function), pluginCacheDir: expect.any(Function), diff --git a/cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts new file mode 100644 index 000000000..d213657b6 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { codexGlobalFilePath } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-paths.js"; +import { globalFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const ORCHESTRATOR = { name: "aidd-orchestrator", agents: new Set() }; + +describe("codexGlobalFilePath — the agents a machine-scope install copies into ~/.codex", () => { + it("prefixes a staged agent with its plugin under agents/", () => { + expect( + codexGlobalFilePath("plugins/aidd-orchestrator/codex-agents/reviewer.toml", ORCHESTRATOR) + ).toBe("agents/aidd-orchestrator-reviewer.toml"); + }); + + it.each([ + "plugins/aidd-dev/codex-agents/reviewer.toml", + "plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md", + "plugins/aidd-orchestrator/codex-agents/nested/reviewer.toml", + "plugins/aidd-orchestrator/codex-agents/reviewer.md", + ])("leaves %s out: another plugin's, or not a staged agent", (built) => { + expect(codexGlobalFilePath(built, ORCHESTRATOR)).toBeNull(); + }); +}); + +describe("the codex profile's machine-scope directory", () => { + it("is ~/.codex under the home directory", () => { + expect(globalFilesOf("codex")?.dir("/home/me")).toBe(join("/home/me", ".codex")); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/plugins.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/plugins.unit.test.ts index 20de2462b..3fbe9a8c8 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/plugins.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/plugins.unit.test.ts @@ -10,7 +10,7 @@ describe("codex plugins", () => { // escape. `pluginCacheDir` is `expect.any(Function)`, since a function matches no literal. expect(activation).toEqual({ binary: "codex", - upgradeVerb: "upgrade", + refreshOwnedOnReleaseUpdate: true, enableVerb: "add", disableVerb: "remove", pluginCacheDir: expect.any(Function), diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts index 9d042e232..c57102254 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot/profile.unit.test.ts @@ -37,6 +37,7 @@ describe("copilot declarations the rest of the CLI reads", () => { disableVerb: "uninstall", sourceCheckVerb: "update", forceRemoveArgs: ["--force"], + uninstallLeavesRefDisabled: true, }); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts new file mode 100644 index 000000000..379e32dd0 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { kiloGlobalFilePath } from "../../../../../../src/contexts/tools/domain/profiles/kilo/kilo-paths.js"; + +const maintenance = { name: "aidd-maintenance", agents: new Set() }; +const other = { name: "aidd-dev", agents: new Set(["reviewer"]) }; + +describe("Kilo machine files", () => { + it.each([ + [".kilo/hooks/aidd-maintenance/session-start.cjs", "hooks/aidd-maintenance/session-start.cjs"], + [".kilo/hooks/aidd-maintenance/kilo-tui.js", "hooks/aidd-maintenance/kilo-tui.js"], + [".kilo/plugin/aidd-maintenance-hooks.js", "plugin/aidd-maintenance-hooks.js"], + ])("includes maintenance startup file %s only for its owner", (path, expected) => { + expect(kiloGlobalFilePath(path, maintenance)).toBe(expected); + expect(kiloGlobalFilePath(path, other)).toBeNull(); + }); + + it("keeps all other hooks project-scoped", () => { + expect(kiloGlobalFilePath(".kilo/plugin/aidd-dev-hooks.js", other)).toBeNull(); + expect(kiloGlobalFilePath(".kilo/hooks/aidd-dev/session-start.cjs", other)).toBeNull(); + expect(kiloGlobalFilePath(".kilo/hooks/aidd-maintenance/other.cjs", maintenance)).toBeNull(); + expect(kiloGlobalFilePath(".kilo/skills/aidd-dev/01-plan/SKILL.md", other)).toBe( + "skills/aidd-dev/01-plan/SKILL.md" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-session-message.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-session-message.unit.test.ts new file mode 100644 index 000000000..a4757e97d --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-session-message.unit.test.ts @@ -0,0 +1,68 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, it, vi } from "vitest"; +import { generateKiloHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.js"; + +it("shows the startup message once for each session", async () => { + const directory = await mkdtemp(join(tmpdir(), "aidd-kilo-message-")); + try { + await mkdir(join(directory, "hooks", "probe"), { recursive: true }); + await mkdir(join(directory, "plugin")); + await writeFile( + join(directory, "hooks", "probe", "start.cjs"), + 'process.stdin.resume(); process.stdin.on("end", () => console.log(JSON.stringify({ systemMessage: "Update available" })));' + ); + const root = "$" + "{CLAUDE_PLUGIN_ROOT}"; + const source = generateKiloHooksBridge( + JSON.stringify({ + hooks: { + SessionStart: [ + { matcher: "startup", hooks: [{ command: `node "${root}/hooks/start.cjs"` }] }, + ], + }, + }), + "probe" + ); + const path = join(directory, "plugin", "bridge.mjs"); + await writeFile(path, source ?? ""); + const module = await import(pathToFileURL(path).href); + const showToast = vi.fn(async () => {}); + const server = await module.default.server({ directory, client: { tui: { showToast } } }); + for (const id of ["one", "one", "two"]) { + await server.event({ event: { type: "session.created", properties: { sessionID: id } } }); + } + expect(showToast).toHaveBeenCalledTimes(2); + expect(showToast).toHaveBeenCalledWith({ + body: { message: "Update available", variant: "info" }, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("leaves the maintenance startup notice to the TUI", async () => { + const directory = await mkdtemp(join(tmpdir(), "aidd-kilo-maintenance-")); + try { + const root = "$" + "{CLAUDE_PLUGIN_ROOT}"; + const source = generateKiloHooksBridge( + JSON.stringify({ + hooks: { + SessionStart: [ + { matcher: "startup", hooks: [{ command: `node "${root}/hooks/session-start.cjs"` }] }, + ], + }, + }), + "aidd-maintenance" + ); + const path = join(directory, "bridge.mjs"); + await writeFile(path, source ?? ""); + const module = await import(pathToFileURL(path).href); + expect( + module.default.server.sessionStartCallsFor({ type: "session.created" }, directory) + ).toEqual([]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts new file mode 100644 index 000000000..7823d8d4b --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts @@ -0,0 +1,61 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { opencodeGlobalFilePath } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-paths.js"; +import { globalFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; +const MAINTENANCE = { name: "aidd-maintenance", agents: new Set() }; + +describe("opencodeGlobalFilePath — what a machine-scope install copies into OpenCode's config dir", () => { + it.each([ + [ + ".opencode/hooks/aidd-maintenance/session-start.cjs", + "hooks/aidd-maintenance/session-start.cjs", + ], + [".opencode/plugins/aidd-maintenance-tui/server.js", "plugins/aidd-maintenance-tui/server.js"], + [".opencode/plugins/aidd-maintenance-tui/tui.js", "plugins/aidd-maintenance-tui/tui.js"], + ])("installs maintenance's own startup file %s machine-wide", (built, expected) => { + expect(opencodeGlobalFilePath(built, MAINTENANCE)).toBe(expected); + expect(opencodeGlobalFilePath(built, AIDD_DEV)).toBeNull(); + }); + + it("does not claim the shared event adapter or bridge as maintenance files", () => { + expect(opencodeGlobalFilePath(".opencode/hooks/opencode-events.js", MAINTENANCE)).toBeNull(); + expect( + opencodeGlobalFilePath(".opencode/plugin/aidd-maintenance-hooks.js", MAINTENANCE) + ).toBeNull(); + }); + it.each([ + [".opencode/skills/aidd-dev/01-plan/SKILL.md", "skills/aidd-dev/01-plan/SKILL.md"], + [ + ".opencode/skills/aidd-dev/01-plan/actions/01-read.md", + "skills/aidd-dev/01-plan/actions/01-read.md", + ], + [".opencode/agents/aidd-dev-reviewer.md", "agents/aidd-dev-reviewer.md"], + ])("keeps the plugin's own %s as %s", (built, machine) => { + expect(opencodeGlobalFilePath(built, AIDD_DEV)).toBe(machine); + }); + + it.each([ + ".opencode/skills/aidd-pm/01-epic/SKILL.md", + ".opencode/agents/aidd-pm-reviewer.md", + ".opencode/commands/aidd-dev-plan.md", + ".opencode/plugin/aidd-dev-hooks.js", + ".opencode/agents/aidd-dev-reviewer.txt", + ".opencode/agents/aidd-dev-executor.md", + ".opencode/agents/aidd-dev-tools-reviewer.md", + "skills/aidd-dev/01-plan/SKILL.md", + ])( + "leaves %s out: another plugin's, an agent it does not ship, or not loaded machine-wide", + (built) => { + expect(opencodeGlobalFilePath(built, AIDD_DEV)).toBeNull(); + } + ); +}); + +describe("the opencode profile's machine-scope directory", () => { + it("is ~/.config/opencode under the home directory", () => { + expect(globalFilesOf("opencode")?.dir("/home/me")).toBe(join("/home/me", ".config/opencode")); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/hooks.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/hooks.unit.test.ts index 892732b8c..265d52e7b 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/hooks.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/hooks.unit.test.ts @@ -30,3 +30,16 @@ describe("opencode hooks", () => { }); }); }); + +describe("OpenCode TUI plugin files", () => { + it("materializes separate V2 server and TUI entries", () => { + const hooks = opencode.buildContracts?.flat?.().artifacts.hooks; + if (!hooks?.supported) throw new Error("expected hooks artifact"); + expect(hooks.path("aidd-maintenance", "hooks/opencode-server.js")).toBe( + ".opencode/plugins/aidd-maintenance-tui/server.js" + ); + expect(hooks.path("aidd-maintenance", "hooks/opencode-tui.js")).toBe( + ".opencode/plugins/aidd-maintenance-tui/tui.js" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts index 24cb95530..be16a8add 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -48,6 +48,7 @@ describe("generateOpencodeHooksBridge", () => { // the mapping this generator applies and the measurements behind it. import { setupOpencodeEvents } from "../hooks/opencode-events.js"; import { spawn } from "node:child_process"; + import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; // Never process.execPath: OpenCode ships as its own standalone binary - that path names \`opencode\` @@ -165,11 +166,10 @@ describe("generateOpencodeHooksBridge", () => { export const AiddSampleHooks = async (input) => { // Sessions whose last Stop asked the model to continue: their next Stop carries stop_hook_active. const continued = new Set(); - // SessionStart's own approximation (module doc comment above): fired once here, never - // per session. Silent on purpose: a memory refresh - // that breaks OpenCode's own startup is worse than one that never ran. + const started = new Set(); + // Existing unmatched hooks are intentionally still run at initialization. try { - for (const hook of SESSION_START) { + for (const hook of SESSION_START.filter((hook) => hook.matcher !== "startup")) { runHook( hook.script, hook.args, @@ -183,7 +183,25 @@ describe("generateOpencodeHooksBridge", () => { return { event: async ({ event }) => { try { - const directory = event?.properties?.directory ?? input.directory; + const directory = event?.properties?.info?.directory ?? event?.properties?.directory ?? input.directory; + const id = event?.properties?.sessionID ?? event?.properties?.info?.id; + if (event?.type === "session.deleted") started.delete(id); + if (!input.skipStartup && event?.type === "session.created" && typeof id === "string" && !started.has(id)) { + started.add(id); + for (const hook of SESSION_START.filter((hook) => hook.matcher === "startup")) { + const { code, stdout } = await runCapture(hook.script, hook.args, + { hook_event_name: "SessionStart", session_id: id, cwd: directory, tool: "opencode" }, directory); + let message = ""; + if (code === 0) { + try { message = String(JSON.parse(stdout)?.systemMessage ?? "").trim(); } catch { message = ""; } + } + if (message && typeof input.client?.tui?.showToast === "function") { + await input.client.tui.showToast({ body: { message, variant: "info" } }); + } else if (message) { + console.warn("[aidd] OpenCode cannot display the startup message: " + message); + } + } + } for (const call of stopCallsFor(event, directory)) { const sessionId = call.payload.session_id; const payload = { ...call.payload, stop_hook_active: continued.has(sessionId) }; @@ -221,7 +239,10 @@ describe("generateOpencodeHooksBridge", () => { export default { id: "aidd-sample-hooks", server: AiddSampleHooks, - setup: (ctx) => setupOpencodeEvents(ctx, AiddSampleHooks), + setup: (ctx) => setupOpencodeEvents(ctx, (input) => AiddSampleHooks({ + ...input, + skipStartup: existsSync(fileURLToPath(new URL("../plugins/aidd-sample-tui/tui.js", import.meta.url))), + })), }; " `); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-session-message.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-session-message.unit.test.ts new file mode 100644 index 000000000..6be3d9e09 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-session-message.unit.test.ts @@ -0,0 +1,56 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, it, vi } from "vitest"; +import { generateOpencodeHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; + +it("shows a startup message once per session", async () => { + const directory = await mkdtemp(join(tmpdir(), "aidd-opencode-message-")); + try { + await mkdir(join(directory, "hooks", "probe"), { recursive: true }); + await mkdir(join(directory, "plugins")); + await writeFile( + join(directory, "hooks", "opencode-events.js"), + "export async function setupOpencodeEvents(ctx, factory) { return factory(ctx); }\n" + ); + await writeFile(join(directory, "package.json"), '{"type":"module"}'); + await writeFile( + join(directory, "hooks", "probe", "start.cjs"), + 'process.stdin.resume(); process.stdin.on("end", () => console.log(JSON.stringify({ systemMessage: "Update available" })));' + ); + const root = "$" + "{CLAUDE_PLUGIN_ROOT}"; + const source = generateOpencodeHooksBridge( + JSON.stringify({ + hooks: { + SessionStart: [ + { matcher: "startup", hooks: [{ command: `node "${root}/hooks/start.cjs"` }] }, + ], + }, + }), + "probe" + ); + const path = join(directory, "plugins", "bridge.mjs"); + await writeFile(path, source ?? ""); + const module = await import(pathToFileURL(path).href); + const showToast = vi.fn(async () => {}); + const server = await module.default.server({ directory, client: { tui: { showToast } } }); + expect(showToast).not.toHaveBeenCalled(); + for (const id of ["one", "one", "two"]) { + await server.event({ + event: { type: "session.created", properties: { info: { id, directory } } }, + }); + } + expect(showToast).toHaveBeenCalledTimes(2); + expect(showToast).toHaveBeenCalledWith({ + body: { message: "Update available", variant: "info" }, + }); + await mkdir(join(directory, "plugins", "probe-tui")); + await writeFile(join(directory, "plugins", "probe-tui", "tui.js"), ""); + const v2 = await module.default.setup({ directory, client: { tui: { showToast } } }); + await v2.event({ event: { type: "session.created", properties: { info: { id: "three" } } } }); + expect(showToast).toHaveBeenCalledTimes(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-v2-tui.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-v2-tui.unit.test.ts new file mode 100644 index 000000000..a6f7d959a --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-v2-tui.unit.test.ts @@ -0,0 +1,122 @@ +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, it, vi } from "vitest"; + +it("shows the hook output for two sessions in the native TUI", async () => { + const directory = await mkdtemp(join(tmpdir(), "aidd-opencode-tui-")); + try { + const plugin = join(directory, ".opencode", "plugins", "aidd-maintenance-tui"); + const hooks = join(directory, ".opencode", "hooks", "aidd-maintenance"); + await mkdir(plugin, { recursive: true }); + await mkdir(hooks, { recursive: true }); + await copyFile( + resolve("../plugins/aidd-maintenance/hooks/opencode-tui.js"), + join(plugin, "tui.mjs") + ); + await writeFile( + join(hooks, "session-start.cjs"), + 'process.stdin.resume(); process.stdin.on("end", () => console.log(JSON.stringify({ systemMessage: "Update available" })));' + ); + const module = await import(pathToFileURL(join(plugin, "tui.mjs")).href); + const toast = vi.fn(); + let listener: (event: unknown) => Promise = async () => {}; + const unsubscribe = vi.fn(); + const cleanup = module.default.setup({ + data: { + on: (_: string, cb: typeof listener) => { + listener = cb; + return unsubscribe; + }, + }, + ui: { toast: { show: toast } }, + }); + for (const sessionID of ["one", "two"]) { + await listener({ data: { sessionID, location: { directory } } }); + } + expect(toast).toHaveBeenCalledTimes(2); + expect(toast).toHaveBeenCalledWith({ message: "Update available", variant: "info" }); + cleanup(); + expect(unsubscribe).toHaveBeenCalledOnce(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +it("shows a startup message that takes longer than five seconds", async () => { + const directory = await mkdtemp(join(tmpdir(), "aidd-opencode-timeout-")); + try { + const plugin = join(directory, ".opencode", "plugins", "aidd-maintenance-tui"); + const hooks = join(directory, ".opencode", "hooks", "aidd-maintenance"); + await mkdir(plugin, { recursive: true }); + await mkdir(hooks, { recursive: true }); + await copyFile( + resolve("../plugins/aidd-maintenance/hooks/opencode-tui.js"), + join(plugin, "tui.mjs") + ); + await writeFile( + join(hooks, "session-start.cjs"), + 'process.stdin.resume(); process.stdin.on("end", () => setTimeout(() => console.log(JSON.stringify({ systemMessage: "Slow network" })), 5100));' + ); + const module = await import(pathToFileURL(join(plugin, "tui.mjs")).href); + const toast = vi.fn(); + let listener: (event: unknown) => Promise = async () => {}; + module.default.setup({ + data: { + on: (_: string, cb: typeof listener) => { + listener = cb; + return () => {}; + }, + }, + ui: { toast: { show: toast } }, + }); + await listener({ data: { sessionID: "one", location: { directory } } }); + expect(toast).toHaveBeenCalledWith({ message: "Slow network", variant: "info" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}, 15000); + +it("shows the notice on opening the TUI and does not repeat it for its first session", async () => { + const directory = await mkdtemp(join(tmpdir(), "aidd-opencode-open-")); + try { + const plugin = join(directory, ".opencode", "plugins", "aidd-maintenance-tui"); + const hooks = join(directory, ".opencode", "hooks", "aidd-maintenance"); + await mkdir(plugin, { recursive: true }); + await mkdir(hooks, { recursive: true }); + await copyFile( + resolve("../plugins/aidd-maintenance/hooks/opencode-tui.js"), + join(plugin, "tui.mjs") + ); + await writeFile( + join(hooks, "session-start.cjs"), + 'process.stdin.resume(); process.stdin.on("end", () => console.log(JSON.stringify({ systemMessage: "Update available" })));' + ); + const module = await import(pathToFileURL(join(plugin, "tui.mjs")).href); + const toast = vi.fn(); + let listener: (event: unknown) => Promise = async () => {}; + const cleanup = module.default.setup({ + location: { directory }, + data: { + on: (_: string, cb: typeof listener) => { + listener = cb; + return () => {}; + }, + }, + ui: { toast: { show: toast } }, + }); + await vi.waitFor(() => expect(toast).toHaveBeenCalledOnce()); + const otherDirectory = join(directory, "another-project"); + await mkdir(otherDirectory); + await listener({ data: { sessionID: "other", location: { directory: otherDirectory } } }); + expect(toast).toHaveBeenCalledTimes(2); + await listener({ data: { sessionID: "one", location: { directory } } }); + expect(toast).toHaveBeenCalledTimes(2); + await listener({ data: { sessionID: "two", location: { directory } } }); + expect(toast).toHaveBeenCalledTimes(3); + cleanup(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts index 652cd7a41..6e9de6c2b 100644 --- a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts @@ -2,7 +2,10 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hostPluginRegistryReaders } from "../../../../src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import { + claudeInstallations, + hostPluginRegistryReaders, +} from "../../../../src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; import type { AiToolId } from "../../../../src/kernel/tool.js"; /** @@ -36,6 +39,22 @@ function readerFor(tool: AiToolId) { describe("Claude Code's own installed_plugins.json", () => { const PATH = ".claude/plugins/installed_plugins.json"; + it("allows two targeted local plugins in the same project", async () => { + const refs = ["aidd-dev@aidd-framework", "aidd-context@aidd-framework"]; + await write( + PATH, + JSON.stringify({ + plugins: Object.fromEntries( + refs.map((ref) => [ref, [{ scope: "local", projectPath: PROJECT }]]) + ), + }) + ); + expect(await claudeInstallations(join(home, ".claude"), "aidd-framework")).toEqual([ + { ref: refs[1], scope: "local", projectPath: PROJECT }, + { ref: refs[0], scope: "local", projectPath: PROJECT }, + ]); + }); + it("counts a ref installed for this project, and one installed for the machine", async () => { await write( PATH, diff --git a/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts index 2da2ae663..7cffc4f93 100644 --- a/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts @@ -247,4 +247,58 @@ describe("native marketplace source reader", () => { expect(reading.unreadable).toContain("marketplace list --json"); expect(reading.unreadable).toContain("upgrade"); }); + + it("proves Copilot 1.0.98's local sources and leaves any other source unproven", async () => { + spawn.mockReturnValue( + result( + JSON.stringify([ + { name: "aidd-framework", source: "Local: /home/.aidd/built/copilot", isDefault: false }, + { name: "awesome-copilot", source: "GitHub: github/awesome-copilot", isDefault: true }, + ]) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.get("aidd-framework")).toEqual({ + kind: "effective-list", + root: "/home/.aidd/built/copilot", + sourceType: "local", + source: "/home/.aidd/built/copilot", + }); + expect(reading.entries?.has("awesome-copilot")).toBe(true); + expect(reading.entries?.get("awesome-copilot")).toBeNull(); + }); + + it.each([ + ['{"marketplaces":[]}'], + ["[null]"], + ['[{"source":"Local: /a"}]'], + ['[{"name":"","source":"Local: /a"}]'], + ['[{"name":"x"}]'], + ['[{"name":"x","source":"Local: /a"},{"name":"x","source":"Local: /b"}]'], + ])("refuses the Copilot listing %s rather than guess a source", async (stdout) => { + spawn.mockReturnValue(result(stdout)); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toMatch( + /^copilot marketplace list could not prove a source: .*Copilot/ + ); + }); + + it("leaves a Copilot source reading just 'Local: ' unproven", async () => { + spawn.mockReturnValue(result('[{"name":"x","source":"Local: "}]')); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.get("x")).toBeNull(); + }); }); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts index da663dfb8..a95e5efad 100644 --- a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; import { describe, expect, it, vi } from "vitest"; import { NativePluginCliAdapter } from "../../../../src/contexts/tools/infrastructure/native-plugin-cli-adapter.js"; @@ -32,6 +33,19 @@ function claudeAdapter(): NativePluginCliAdapter { } describe("ClaudeCliAdapter — plugin enable/uninstall carry the requested scope", () => { + it("refreshes the local installation without accepting marketplace commands", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + new NativePluginCliAdapter("claude", { + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + releaseUpdateVerb: "update", + pluginArgs: ["--yes"], + }).refreshReleasePlugin("aidd-context@aidd-framework", "project", "/project"); + expect(mockSpawnSync).toHaveBeenLastCalledWith( + "claude", + ["plugin", "update", "aidd-context@aidd-framework", "--scope", "local"], + expect.objectContaining({ cwd: "/project" }) + ); + }); it("enables a plugin at project scope by default — --scope local, never claude's own implicit default", () => { mockSpawnSync.mockReturnValue(makeResult({})); @@ -80,3 +94,77 @@ describe("ClaudeCliAdapter — plugin enable/uninstall carry the requested scope ); }); }); + +describe("ClaudeCliAdapter — removing a user-scope marketplace deletes its registration", () => { + const adapter = () => + new NativePluginCliAdapter("claude", { + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + userRemoveArgs: [], + }); + + function listing(stdout: string, status = 0) { + mockSpawnSync.mockReset(); + mockSpawnSync.mockImplementation((_binary, args) => + (args as string[])[1] === "list" ? makeResult({ stdout, status }) : makeResult({}) + ); + } + + const removeArgs = () => mockSpawnSync.mock.calls.at(-1)?.[1]; + + it("names no scope when no project uses it, from the home directory, so the registration goes too", () => { + listing('[{"id":"aidd-dev@aidd-framework","scope":"user"},{"id":"x@other","scope":"local"}]'); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(mockSpawnSync).toHaveBeenLastCalledWith( + "claude", + ["plugin", "marketplace", "remove", "aidd-framework"], + expect.objectContaining({ cwd: homedir() }) + ); + }); + + it("stays at user scope when a project installed from it, so that project keeps its plugins", () => { + listing('[{"id":"aidd-dev@aidd-framework","scope":"local","projectPath":"/p"}]'); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(removeArgs()).toEqual([ + "plugin", + "marketplace", + "remove", + "aidd-framework", + "--scope", + "user", + ]); + }); + + it.each([ + ["fails", "", 1], + ["answers no JSON", "not json", 0], + ])("stays at user scope when the list %s, never the wider removal", (_case, stdout, status) => { + listing(stdout, status); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(removeArgs()).toEqual([ + "plugin", + "marketplace", + "remove", + "aidd-framework", + "--scope", + "user", + ]); + }); + + it("keeps the project scope's own arguments, from the current directory", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "project"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "local"], + expect.not.objectContaining({ cwd: expect.anything() }) + ); + }); +}); diff --git a/cli/tests/contexts/translate/domain/opencode-v2-content-translator.unit.test.ts b/cli/tests/contexts/translate/domain/opencode-v2-content-translator.unit.test.ts new file mode 100644 index 000000000..6b9336fa6 --- /dev/null +++ b/cli/tests/contexts/translate/domain/opencode-v2-content-translator.unit.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; + +it("installs OpenCode V2 server and TUI entries in the build's location", () => { + const hooks = [ + { relativePath: "hooks/hooks.json", content: '{"hooks":{}}' }, + { relativePath: "hooks/opencode-server.js", content: "server" }, + { relativePath: "hooks/opencode-tui.js", content: "tui" }, + ]; + const dist = new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: "claude", + files: hooks, + components: { skills: [], commands: [], agents: [], rules: [], hooks, mcp: [] }, + }); + const translator = new PluginContentTranslator({ hash: () => new FileHash("a".repeat(32)) }); + const tree = Object.fromEntries( + translator.translate(dist, opencode).map((f) => [f.relativePath, f.content]) + ); + expect(tree[".opencode/plugins/sample-plugin-tui/server.js"]).toBe("server"); + expect(tree[".opencode/plugins/sample-plugin-tui/tui.js"]).toBe("tui"); +}); diff --git a/cli/tests/e2e/command-matrix-help.e2e.test.ts b/cli/tests/e2e/command-matrix-help.e2e.test.ts index 8b0fb18a2..96d36324d 100644 --- a/cli/tests/e2e/command-matrix-help.e2e.test.ts +++ b/cli/tests/e2e/command-matrix-help.e2e.test.ts @@ -102,20 +102,21 @@ describe.concurrent("Command Matrix: Help", () => { } }); - it("aidd setup --help shows simplified flag surface (6 flags + --no-default-marketplace)", async () => { + it("aidd setup --help shows simplified flag surface, with --ai kept out of it", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("help-setup"); try { const { stdout, exitCode } = await runCli(["setup", "--help"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout).toContain("--source"); - expect(stdout).toContain("--ai"); + expect(stdout).not.toContain("--ai "); expect(stdout).toContain("--ide"); + expect(stdout).toContain("--global"); expect(stdout).toContain("--plugins"); expect(stdout).toContain("--release"); expect(stdout).toContain("--yes"); expect(stdout).not.toContain("--all-plugins"); expect(stdout).not.toContain("--recommended-plugins"); - // --all dropped (use --ai all --ide all) + // --all dropped (use --ide all) expect(stdout).not.toMatch(/--all\b(?! options)/); expect(stdout).not.toContain("--from"); expect(stdout).not.toContain("--switch-mode"); diff --git a/cli/tests/e2e/headless-run.e2e.test.ts b/cli/tests/e2e/headless-run.e2e.test.ts new file mode 100644 index 000000000..6ebe1ed13 --- /dev/null +++ b/cli/tests/e2e/headless-run.e2e.test.ts @@ -0,0 +1,388 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { delimiter, dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cliPath, createTestEnv, runCli, sandboxedEnv, writeFakeToolBinary } from "./helpers.js"; + +const hook = 'process.stdout.write(JSON.stringify({systemMessage:"MAINTENANCE NOTICE"}));'; +const hash = createHash("md5").update(hook).digest("hex"); + +async function writeHost(bin: string, tool: string, body: string) { + await mkdir(bin, { recursive: true }); + const script = join(bin, "host.cjs"); + await writeFile(script, body); + if (process.platform === "win32") { + await writeFile( + join(bin, `${tool}.cmd`), + `@echo off\r\n"${process.execPath}" "%~dp0host.cjs" %*\r\n` + ); + } else { + const binary = join(bin, tool); + await writeFile(binary, `#!/usr/bin/env node\n${body}`); + await chmod(binary, 0o755); + } +} + +async function installed(root: string, tool: "opencode" | "kilo", scope: "project" | "user") { + const relative = + scope === "project" + ? `.${tool}/hooks/aidd-maintenance/session-start.cjs` + : "hooks/aidd-maintenance/session-start.cjs"; + const base = scope === "project" ? root : join(root, ".config", tool); + const manifest = + scope === "project" + ? join(root, ".aidd", "manifest.json") + : join(root, ".config", "aidd", "manifest.json"); + await mkdir(dirname(join(base, relative)), { recursive: true }); + await mkdir(dirname(manifest), { recursive: true }); + await writeFile(join(base, relative), hook); + await writeFile( + manifest, + JSON.stringify({ + version: 8, + tools: { + [tool]: { + toolId: tool, + version: "5.6.0", + files: [], + plugins: [ + { + name: "aidd-maintenance", + version: "0.1.0", + source: { kind: "local", path: "/source" }, + strict: false, + scope, + marketplace: "aidd-framework", + files: { [relative]: hash }, + }, + ], + }, + }, + }) + ); +} + +describe("aidd run", () => { + it.each(["opencode", "kilo"] as const)( + "shows the recorded %s project hook and forwards arguments", + async (tool) => { + const env = await createTestEnv(`run-${tool}`); + try { + await installed(env.projectDir, tool, "project"); + const childDir = join(env.projectDir, "nested"); + const bin = join(env.tempDir, "bin"); + const log = join(env.tempDir, "args.txt"); + await mkdir(childDir); + await writeHost( + bin, + tool, + `require("node:fs").writeFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2)));` + ); + const result = await runCli( + ["run", tool, "--", "--format", "json", "hello world"], + childDir, + env.fakeHome, + { env: { PATH: `${bin}${delimiter}${dirname(process.execPath)}` } } + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("MAINTENANCE NOTICE"); + expect(JSON.parse(await readFile(log, "utf8"))).toEqual([ + "run", + "--format", + "json", + "hello world", + ]); + } finally { + await env.cleanup(); + } + } + ); + + it("loads an explicitly recorded global hook", async () => { + const env = await createTestEnv("run-global"); + try { + await installed(env.fakeHome, "opencode", "user"); + const bin = join(env.tempDir, "bin"); + await writeFakeToolBinary(bin, "opencode", join(env.tempDir, "args.txt")); + const result = await runCli(["run", "opencode", "--", "hi"], env.projectDir, env.fakeHome, { + env: { PATH: `${bin}${delimiter}${dirname(process.execPath)}` }, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("MAINTENANCE NOTICE"); + } finally { + await env.cleanup(); + } + }); + + it.each(["opencode", "kilo"] as const)( + "refuses competing %s project and global copies", + async (tool) => { + const env = await createTestEnv(`run-conflict-${tool}`); + try { + await installed(env.projectDir, tool, "project"); + await installed(env.fakeHome, tool, "user"); + const result = await runCli(["run", tool, "--", "hi"], env.projectDir, env.fakeHome); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/project and global copies coexist/i); + expect(result.stderr).not.toContain("MAINTENANCE NOTICE"); + } finally { + await env.cleanup(); + } + } + ); + + it.each([ + ["opencode", "OPENCODE_CONFIG_DIR"], + ["kilo", "KILO_CONFIG_DIR"], + ] as const)( + "refuses %s when an extra config directory can replace its plugin", + async (tool, key) => { + const env = await createTestEnv(`run-override-${tool}`); + try { + await installed(env.projectDir, tool, "project"); + const result = await runCli(["run", tool, "--", "hi"], env.projectDir, env.fakeHome, { + env: { [key]: join(env.tempDir, "override") }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain(key); + expect(result.stderr).not.toContain("MAINTENANCE NOTICE"); + } finally { + await env.cleanup(); + } + } + ); + + it("honors XDG_CONFIG_HOME and never falls back to an ignored home hook", async () => { + const env = await createTestEnv("run-xdg"); + try { + await installed(env.fakeHome, "opencode", "user"); + const xdg = join(env.tempDir, "custom-xdg"); + const bin = join(env.tempDir, "bin"); + await writeFakeToolBinary(bin, "opencode", join(env.tempDir, "args.txt")); + const childEnv = { + ...sandboxedEnv(env.fakeHome, { PATH: `${bin}${delimiter}${dirname(process.execPath)}` }), + XDG_CONFIG_HOME: xdg, + }; + const run = () => + spawnSync(process.execPath, [cliPath(), "run", "opencode", "--", "hi"], { + cwd: env.projectDir, + env: childEnv, + encoding: "utf8", + }); + const ignored = run(); + expect(ignored.status).not.toBe(0); + expect(ignored.stderr).not.toContain("MAINTENANCE NOTICE"); + const file = join(xdg, "opencode/hooks/aidd-maintenance/session-start.cjs"); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, hook); + const loaded = run(); + expect(loaded.status, loaded.stderr || loaded.error?.message).toBe(0); + expect(loaded.stderr).toContain("MAINTENANCE NOTICE"); + } finally { + await env.cleanup(); + } + }); + + it("refuses a hook not recorded in the manifest before starting the tool", async () => { + const env = await createTestEnv("run-untracked"); + try { + const file = join(env.projectDir, ".opencode/hooks/aidd-maintenance/session-start.cjs"); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, hook); + const result = await runCli(["run", "opencode", "--", "hi"], env.projectDir, env.fakeHome); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/maintenance.*install/i); + expect(result.stderr).not.toContain("MAINTENANCE NOTICE"); + } finally { + await env.cleanup(); + } + }); + + it("refuses a modified hook rather than executing it", async () => { + const env = await createTestEnv("run-modified"); + try { + await installed(env.projectDir, "kilo", "project"); + await writeFile( + join(env.projectDir, ".kilo/hooks/aidd-maintenance/session-start.cjs"), + 'throw Error("must not run")' + ); + const result = await runCli(["run", "kilo", "--", "hi"], env.projectDir, env.fakeHome); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/modified/i); + expect(result.stderr).not.toContain("must not run"); + } finally { + await env.cleanup(); + } + }); + + it("keeps JSON stdout and passes metacharacters literally even when an update is cached", async () => { + const env = await createTestEnv("run-json"); + try { + await installed(env.projectDir, "opencode", "project"); + const cache = join(env.fakeHome, ".config/aidd/cache/update-check.json"); + await mkdir(dirname(cache), { recursive: true }); + await writeFile(cache, JSON.stringify({ checkedAt: Date.now(), latest: "999.0.0" })); + const bin = join(env.tempDir, "bin"); + await writeHost( + bin, + "opencode", + "process.stdout.write(JSON.stringify(process.argv.slice(2)))" + ); + const value = "a & b $(nope) %PATH%"; + const result = await runCli( + ["run", "opencode", "--", "--format", "json", value], + env.projectDir, + env.fakeHome, + { + env: { + PATH: `${bin}${delimiter}${dirname(process.execPath)}`, + AIDD_SKIP_UPDATE_CHECK: "0", + }, + } + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(["run", "--format", "json", value]); + expect(result.stderr).toContain("MAINTENANCE NOTICE"); + expect(result.stderr).not.toContain("CLI update available"); + } finally { + await env.cleanup(); + } + }); + + it("forwards stdin and the tool exit status", async () => { + const env = await createTestEnv("run-stdin"); + try { + await installed(env.projectDir, "kilo", "project"); + const bin = join(env.tempDir, "bin"); + await writeHost( + bin, + "kilo", + 'process.stdin.setEncoding("utf8");let s="";process.stdin.on("data",x=>s+=x);process.stdin.on("end",()=>{process.stdout.write(s);process.exitCode=7;});' + ); + const result = await new Promise<{ code: number | null; out: string; err: string }>( + (resolvePromise, reject) => { + const child = spawn( + process.execPath, + [cliPath(), "run", "kilo", "--", "--format", "json"], + { + cwd: env.projectDir, + env: sandboxedEnv(env.fakeHome, { + PATH: `${bin}${delimiter}${dirname(process.execPath)}`, + }), + stdio: ["pipe", "pipe", "pipe"], + } + ); + let out = "", + err = ""; + child.stdout.on("data", (chunk) => { + out += chunk; + }); + child.stderr.on("data", (chunk) => { + err += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolvePromise({ code, out, err })); + child.stdin.end("stdin payload"); + } + ); + expect(result.code).toBe(7); + expect(result.out).toBe("stdin payload"); + expect(result.err).toContain("MAINTENANCE NOTICE"); + } finally { + await env.cleanup(); + } + }); + + it("reports a missing executable and refuses remote or interactive targets", async () => { + const env = await createTestEnv("run-missing"); + try { + await installed(env.projectDir, "kilo", "project"); + const missing = await runCli(["run", "kilo", "--", "hello"], env.projectDir, env.fakeHome, { + env: { PATH: env.tempDir }, + }); + expect(missing.exitCode).not.toBe(0); + expect(missing.stderr).toMatch(/unable to start kilo/i); + for (const args of [["--attach", "http://localhost:4096"], ["--interactive"], ["--pure"]]) { + const result = await runCli(["run", "kilo", "--", ...args], env.projectDir, env.fakeHome); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toContain("MAINTENANCE NOTICE"); + } + } finally { + await env.cleanup(); + } + }); + + it("uses Kilo's --dir project when locating the hook", async () => { + const env = await createTestEnv("run-dir"); + try { + await installed(env.projectDir, "kilo", "project"); + const bin = join(env.tempDir, "bin"); + await writeFakeToolBinary(bin, "kilo", join(env.tempDir, "args.txt")); + const result = await runCli( + ["run", "kilo", "--", "--dir", env.projectDir, "hi"], + env.tempDir, + env.fakeHome, + { env: { PATH: `${bin}${delimiter}${dirname(process.execPath)}` } } + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("MAINTENANCE NOTICE"); + } finally { + await env.cleanup(); + } + }); + + it.skipIf(process.platform === "win32")( + "forwards termination to the tool and preserves the signal", + async () => { + const env = await createTestEnv("run-signal"); + try { + await installed(env.projectDir, "opencode", "project"); + const bin = join(env.tempDir, "bin"); + await writeHost( + bin, + "opencode", + 'process.stdout.write("READY\\n"); setInterval(()=>{},1000);' + ); + const result = await new Promise<{ signal: NodeJS.Signals | null; out: string }>( + (resolvePromise, reject) => { + const child = spawn(process.execPath, [cliPath(), "run", "opencode", "--", "hi"], { + cwd: env.projectDir, + env: sandboxedEnv(env.fakeHome, { + PATH: `${bin}${delimiter}${dirname(process.execPath)}`, + }), + stdio: ["ignore", "pipe", "pipe"], + }); + let out = ""; + let err = ""; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("Tool did not become ready")); + }, 10000); + child.stdout.on("data", (chunk) => { + out += chunk; + if (out.includes("READY")) child.kill("SIGTERM"); + }); + child.stderr.on("data", (chunk) => { + err += chunk; + }); + child.once("error", reject); + child.once("close", (_code, signal) => { + clearTimeout(timeout); + if (!out.includes("READY")) { + reject(new Error(`Tool exited before becoming ready: ${err}`)); + return; + } + resolvePromise({ signal, out }); + }); + } + ); + expect(result.out).toContain("READY"); + expect(result.signal).toBe("SIGTERM"); + } finally { + await env.cleanup(); + } + } + ); +}); diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index a246cc633..ec0587f2a 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; @@ -25,29 +25,93 @@ async function readJson(path: string): Promise> { } describe("E2E: setup --scope user writes nothing under the project", () => { - it.each(["all", "recommended", "aidd-dev"])( - "prints a clean refusal for user-scope plugin mode %s", - async (plugins) => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-plugins"); - try { - const result = await runCli( - ["setup", "--scope", "user", "--ai", "claude", "--plugins", plugins, "--yes"], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "Error: --scope user has no manifest entry a plugin can be recorded against yet, so " + - "--plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` " + - "separately at project scope.\n" - ); - } finally { - await cleanup(); - } + it("installs OpenCode's skills and agents for the machine, refreshes them, and cleans them", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-opencode"); + try { + await gitInit(projectDir); + await execFileAsync("git", [...GIT_TEST_IDENTITY, "commit", "--allow-empty", "-m", "empty"], { + cwd: projectDir, + env: environmentWithoutGitVariables(process.env), + }); + const opencode = join(fakeHome, ".config", "opencode"); + + const setup = await runCli( + [ + "setup", + "--global", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ide", + "opencode", + "--plugins", + "aidd-dev", + "--yes", + ], + projectDir, + fakeHome + ); + expect(setup.stderr).toBe(""); + expect(setup.exitCode).toBe(0); + expect(setup.stdout).toContain("Installed for every project on this machine: aidd-dev."); + expect(await gitStatusPorcelain(projectDir)).toBe(""); + expect(await readdir(projectDir)).toEqual([".git"]); + const agent = join(opencode, "agents", "aidd-dev-reviewer.md"); + expect(await readFile(agent, "utf-8")).toMatch(/^mode: '?subagent'?$/m); + const skills = await readdir(join(opencode, "skills", "aidd-dev")); + expect(skills.length).toBeGreaterThan(0); + + const sync = await runCli(["sync", "--global"], projectDir, fakeHome); + expect(sync.exitCode).toBe(0); + expect(sync.stdout).toContain("Refreshed global files for: aidd-dev"); + expect(await readdir(projectDir)).toEqual([".git"]); + + const clean = await runCli(["clean", "--global", "--force"], projectDir, fakeHome); + expect(clean.exitCode).toBe(0); + await expect(readFile(agent, "utf-8")).rejects.toThrow(); + expect(await gitStatusPorcelain(projectDir)).toBe(""); + } finally { + await cleanup(); } - ); + }); + + it("preserves a pre-existing identical agent through refused setup and global clean", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-unowned-agent"); + try { + const args = [ + "setup", + "--global", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ide", + "opencode", + "--plugins", + "aidd-dev", + "--yes", + ]; + expect((await runCli(args, projectDir, fakeHome)).exitCode).toBe(0); + const agent = join(fakeHome, ".config", "opencode", "agents", "aidd-dev-reviewer.md"); + const content = await readFile(agent, "utf-8"); + expect((await runCli(["clean", "--global", "--force"], projectDir, fakeHome)).exitCode).toBe( + 0 + ); + await mkdir(dirname(agent), { recursive: true }); + await writeFile(agent, content); + + const refused = await runCli(args, projectDir, fakeHome); + expect(refused.exitCode).toBe(1); + expect(refused.stderr).toContain("already exists and was not installed by aidd"); + expect((await runCli(["clean", "--global", "--force"], projectDir, fakeHome)).exitCode).toBe( + 0 + ); + expect(await readFile(agent, "utf-8")).toBe(content); + } finally { + await cleanup(); + } + }); it("explains user-scope restrictions in setup help", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-help"); @@ -56,10 +120,11 @@ describe("E2E: setup --scope user writes nothing under the project", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("--ai claude,codex,copilot,cursor"); - expect(result.stdout).toContain("--plugins none"); - expect(result.stdout).toContain("no --ide"); - expect(result.stdout).toContain("host CLI on PATH"); + expect(result.stdout).toContain("--ide claude,codex,copilot,cursor,kilo,opencode"); + expect(result.stdout).toContain("--plugins all, recommended, none or names"); + expect(result.stdout).toContain("no editor"); + expect(result.stdout).toContain("must be on PATH"); + expect(result.stdout).toContain("opencode gets their files in ~/.config/opencode"); } finally { await cleanup(); } @@ -214,7 +279,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { const doctorResult = await runCli(["doctor", "--scope", "user"], projectDir, fakeHome); expect(doctorResult.exitCode).toBe(0); expect(doctorResult.stdout).toContain("Nothing registered at user scope yet"); - expect(doctorResult.stdout).toContain("aidd setup --scope user"); + expect(doctorResult.stdout).toContain("aidd setup --global"); const syncResult = await runCli(["sync", "--scope", "user"], projectDir, fakeHome); expect(syncResult.exitCode).toBe(0); diff --git a/cli/tests/e2e/xdg-machine-scope.e2e.test.ts b/cli/tests/e2e/xdg-machine-scope.e2e.test.ts new file mode 100644 index 000000000..ecbd5e8c5 --- /dev/null +++ b/cli/tests/e2e/xdg-machine-scope.e2e.test.ts @@ -0,0 +1,77 @@ +import { execFile } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { cliPath, createTestEnv, sandboxedEnv } from "./helpers.js"; + +const execFileAsync = promisify(execFile); +async function runXdg(args: string[], cwd: string, home: string, xdg: string) { + try { + const { stdout, stderr } = await execFileAsync(process.execPath, [cliPath(), ...args], { + cwd, + env: { ...sandboxedEnv(home), XDG_CONFIG_HOME: xdg }, + }); + return { stdout, stderr, exitCode: 0 }; + } catch (error) { + const result = error as { stdout?: string; stderr?: string; code?: number }; + return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", exitCode: result.code ?? 1 }; + } +} + +const fixture = resolve(process.cwd(), "tests/fixtures/framework-real"); +const setup = [ + "setup", + "--global", + "--source", + "local", + "--path", + fixture, + "--ide", + "opencode", + "--plugins", + "aidd-dev", + "--yes", +]; + +describe("machine scope and XDG_CONFIG_HOME", () => { + it.each(["opencode", "kilo"])("refuses non-standard XDG before installing %s", async (tool) => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("xdg-refusal"); + try { + const args = [...setup]; + args[args.indexOf("opencode")] = tool; + const result = await runXdg(args, projectDir, fakeHome, join(fakeHome, "other-config")); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("XDG_CONFIG_HOME"); + expect(await readdir(projectDir)).toEqual([]); + await expect(readdir(join(fakeHome, ".config", tool))).rejects.toThrow(); + } finally { + await cleanup(); + } + }); + + it("permits default XDG, refuses refresh after it changes, and allows cleanup", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("xdg-clean"); + try { + const normal = join(fakeHome, ".config"); + const other = join(fakeHome, "other-config"); + expect((await runXdg(setup, projectDir, fakeHome, normal)).exitCode).toBe(0); + const sync = await runXdg(["sync", "--global"], projectDir, fakeHome, other); + expect(sync.exitCode).not.toBe(0); + expect(sync.stderr).toContain("XDG_CONFIG_HOME"); + const update = await runXdg( + ["plugin", "update", "--scope", "user"], + projectDir, + fakeHome, + other + ); + expect(update.exitCode).not.toBe(0); + expect(update.stderr).toContain("XDG_CONFIG_HOME"); + expect( + (await runXdg(["clean", "--global", "--force"], projectDir, fakeHome, other)).exitCode + ).toBe(0); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index a921b2e68..992370823 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1748,7 +1748,7 @@ ".opencode/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".opencode/plugin/aidd-context-hooks.js": "dc1c853320be656c01684d7b2b4981347977dc84de3d9e29d2845f3cf7ee843b", + ".opencode/plugin/aidd-context-hooks.js": "6f555261c05343f8a7f0e2d3931013fdf076fb5e75c4320c5974c8a3e9939ebd", ".opencode/hooks/opencode-events.js": "e791ebbb17eddcc1814cde9c922d5d702936f696be266359b73cafd63f10215a", ".opencode/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".opencode/agents/aidd-async-dev-async-orchestrator.md": "cff54df5e690a947431982b41ea4172bb4a1306df11fb6a5953491112fcd0ea7", @@ -1940,7 +1940,7 @@ ".kilo/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".kilo/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".kilo/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".kilo/plugin/aidd-context-hooks.js": "2b5cc1472916ef7977f37efb2cc92c84454c241247c7828f530c2e0d423e66fc", + ".kilo/plugin/aidd-context-hooks.js": "e1d18ec12c856b88cdeee2bb5906a0dde1a95d118c937cf8d84d155c73d8df93", ".kilo/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".kilo/agents/aidd-async-dev-async-orchestrator.md": "cff54df5e690a947431982b41ea4172bb4a1306df11fb6a5953491112fcd0ea7", ".kilo/agents/aidd-dev-implementer.md": "ec7bd170333d20033679ffdab1976a810067c415dd98b985154f907e2bce78b3", diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index d34a66563..c6742a428 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -2,7 +2,7 @@ { "invocation": "aidd", "exitCode": 0, - "help": "Usage: aidd [options] [command]\n\nGenerate AI coding assistant configurations from the AIDD framework\n\nOptions:\n -V, --version Show version number\n --verbose Show detailed diagnostic output (default: false)\n -h, --help display help for command\n\nCommands:\n setup [options] Set up or update the project to a correct state\n — bootstraps the whole project (marketplace,\n framework, tools, plugins); see `framework\n install`, which acts on the framework alone\n framework Manage the framework's lifecycle on installed\n tools\n translate [options] Convert an arbitrary source into a target-native\n plugin tree — records nothing (see `sync` for\n the manifest-driven, tracked version)\n plugin Manage plugins for AI tools\n marketplace Manage plugin marketplaces\n auth Manage authentication\n sync [options] [files...] Rewrite owned files from what is already there —\n regenerate tracked files, driven by the manifest\n (see `translate`, which converts a source\n without recording anything)\n update|upgrade [options] Update the aidd CLI itself to the latest version\n doctor [options] Detected and equipped tools, plugins, drift, and\n problems — across all tools or one\n clean [options] Remove all AIDD-managed files from the project —\n retires every part of AIDD; see `framework\n remove`, which removes the framework only\n telemetry Measure what Claude Code and Codex sessions\n consume, locally\n help [command] display help for command" + "help": "Usage: aidd [options] [command]\n\nGenerate AI coding assistant configurations from the AIDD framework\n\nOptions:\n -V, --version Show version number\n --verbose Show detailed diagnostic output (default: false)\n -h, --help display help for command\n\nCommands:\n setup [options] Set up or update the project to a correct state\n — bootstraps the whole project (marketplace,\n framework, tools, plugins); see `framework\n install`, which acts on the framework alone\n framework Manage the framework's lifecycle on installed\n tools\n translate [options] Convert an arbitrary source into a target-native\n plugin tree — records nothing (see `sync` for\n the manifest-driven, tracked version)\n plugin Manage plugins for AI tools\n marketplace Manage plugin marketplaces\n auth Manage authentication\n sync [options] [files...] Rewrite owned files from what is already there —\n regenerate tracked files, driven by the manifest\n (see `translate`, which converts a source\n without recording anything)\n update|upgrade [options] Update the aidd CLI itself to the latest version\n doctor [options] Detected and equipped tools, plugins, drift, and\n problems — across all tools or one\n clean [options] Remove all AIDD-managed files from the project —\n retires every part of AIDD; see `framework\n remove`, which removes the framework only\n telemetry Measure what Claude Code and Codex sessions\n consume, locally\n run [args...] Run OpenCode or Kilo non-interactively with the\n installed maintenance notice\n help [command] display help for command" }, { "invocation": "aidd auth", @@ -27,17 +27,17 @@ { "invocation": "aidd clean", "exitCode": 0, - "help": "Usage: aidd clean [options]\n\nRemove all AIDD-managed files from the project — retires every part of AIDD; see\n`framework remove`, which removes the framework only\n\nOptions:\n --force Confirm file removal (skip dry-run) (default: false)\n --scope project (default) cleans this project alone; user undoes the\n machine-wide registration setup --scope user wrote and purges\n the shared source itself\n -h, --help display help for command" + "help": "Usage: aidd clean [options]\n\nRemove all AIDD-managed files from the project — retires every part of AIDD; see\n`framework remove`, which removes the framework only\n\nOptions:\n --force Confirm file removal (skip dry-run) (default: false)\n --scope project (default) cleans this project alone; user undoes the\n machine-wide registration setup --scope user wrote and purges\n the shared source itself\n --global act on the machine-wide install `setup --global` wrote, and\n purge the shared source\n -h, --help display help for command" }, { "invocation": "aidd doctor", "exitCode": 0, - "help": "Usage: aidd doctor [options]\n\nDetected and equipped tools, plugins, drift, and problems — across all tools or\none\n\nOptions:\n --tool Limit to a specific AI or IDE tool\n --plugin Limit plugin checks to a specific plugin\n --scope project (default) checks this project's own manifest; user\n checks the machine-wide manifest --scope user setup wrote\n -h, --help display help for command" + "help": "Usage: aidd doctor [options]\n\nDetected and equipped tools, plugins, drift, and problems — across all tools or\none\n\nOptions:\n --tool Limit to a specific AI or IDE tool\n --plugin Limit plugin checks to a specific plugin\n --scope project (default) checks this project's own manifest; user\n checks the machine-wide manifest --scope user setup wrote\n --global act on the machine-wide install `setup --global` wrote\n -h, --help display help for command" }, { "invocation": "aidd framework", "exitCode": 0, - "help": "Usage: aidd framework [options] [command]\n\nManage the framework's lifecycle on installed tools\n\nOptions:\n -h, --help display help for command\n\nCommands:\n install [options] Install a tool's runtime configuration from bundled assets — acts on the framework alone (see `setup`, which bootstraps the whole project)\n remove [options] Remove a tool's generated configuration files — removes the framework only (see `clean`, which removes all of AIDD)\n update [options] Re-install tool configs from bundled CLI assets, moving to a new version (all installed tools if --tool is omitted; see `marketplace refresh`, which re-fetches catalogs instead)\n rules [options] List the rules installed in this project, across every AI tool\n validate-recipes [options] [paths...] Validate recipe structure, examples, and local links without changing files\n help [command] display help for command" + "help": "Usage: aidd framework [options] [command]\n\nManage the framework's lifecycle on installed tools\n\nOptions:\n -h, --help display help for command\n\nCommands:\n install [options] Install a tool's runtime configuration from bundled assets — acts on the framework alone (see `setup`, which bootstraps the whole project)\n remove [options] Remove a tool's generated configuration files — removes the framework only (see `clean`, which removes all of AIDD)\n update [options] Update installed tool configs from the CLI, or plugins with --release\n rules [options] List the rules installed in this project, across every AI tool\n validate-recipes [options] [paths...] Validate recipe structure, examples, and local links without changing files\n help [command] display help for command" }, { "invocation": "aidd framework install", @@ -57,7 +57,7 @@ { "invocation": "aidd framework update", "exitCode": 0, - "help": "Usage: aidd framework update [options]\n\nRe-install tool configs from bundled CLI assets, moving to a new version (all\ninstalled tools if --tool is omitted; see `marketplace refresh`, which\nre-fetches catalogs instead)\n\nOptions:\n --tool Limit update to a specific AI or IDE tool\n -f, --force Overwrite modified files without prompting (default: false)\n -h, --help display help for command" + "help": "Usage: aidd framework update [options]\n\nUpdate installed tool configs from the CLI, or plugins with --release\n\nOptions:\n --tool Limit update to a specific AI or IDE tool\n -f, --force Overwrite modified files without prompting (default:\n false)\n --release Update plugins to a root release, e.g. v5.13.0\n --scope Choose project or machine plugin installations (with\n --release)\n --yes Confirm the displayed release update without prompting\n (default: false)\n --allow-shared Confirm a marketplace change shared with other\n projects (default: false)\n -h, --help display help for command" }, { "invocation": "aidd framework validate-recipes", @@ -72,7 +72,7 @@ { "invocation": "aidd marketplace add", "exitCode": 0, - "help": "Usage: aidd marketplace add [options] [name] [source]\n\nRegister a plugin marketplace\n\nOptions:\n --scope Registration scope (default: project) (default:\n \"project\")\n --yes Skip the trust + cleanup prompts\n --overwrite Replace an existing marketplace with the same name\n --token Auth token (host detected from source URL at fetch\n time)\n -h, --help display help for command" + "help": "Usage: aidd marketplace add [options] [name] [source]\n\nRegister a plugin marketplace\n\nOptions:\n --scope Registration scope (default: project) (default:\n \"project\")\n --yes Skip the trust + cleanup prompts\n --overwrite Replace an existing marketplace with the same name\n --token GitHub token for this operation\n -h, --help display help for command" }, { "invocation": "aidd marketplace check", @@ -102,7 +102,7 @@ { "invocation": "aidd plugin install", "exitCode": 0, - "help": "Usage: aidd plugin install [options] [plugin]\n\nInstall a plugin (marketplace name, local path, or interactive pick)\n\nOptions:\n --from Marketplace name (when multiple match)\n --tool Target AI tool (default: all installed)\n --token Auth token (host detected from source URL at fetch\n time)\n --scope Install scope; must match the tool's supported scope\n --yes Auto-resolve interactive prompts (CI mode)\n -h, --help display help for command" + "help": "Usage: aidd plugin install [options] [plugin]\n\nInstall a plugin (marketplace name, local path, or interactive pick)\n\nOptions:\n --from Marketplace name (when multiple match)\n --tool Target AI tool (default: all installed)\n --token GitHub token for this operation\n --scope Install scope; must match the tool's supported scope\n --yes Auto-resolve interactive prompts (CI mode)\n -h, --help display help for command" }, { "invocation": "aidd plugin list", @@ -124,15 +124,20 @@ "exitCode": 0, "help": "Usage: aidd plugin update [options] [name]\n\nUpdate one or all plugins for one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n --scope Update scope (default: project)\n -h, --help display help for command" }, + { + "invocation": "aidd run", + "exitCode": 0, + "help": "Usage: aidd run [options] [args...]\n\nRun OpenCode or Kilo non-interactively with the installed maintenance notice\n\nOptions:\n -h, --help display help for command" + }, { "invocation": "aidd setup", "exitCode": 0, - "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ai Comma-separated AI tool IDs, or 'all' (e.g.,\n claude,cursor or all)\n --ide Comma-separated IDE tool IDs, or 'all' (e.g., vscode\n or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --scope project (default) installs into this project alone;\n user registers the shared framework source and\n supported tools machine-wide, writing nothing under\n this project; see user-scope requirements below\n -h, --help display help for command\n\nUser scope:\n Pass --ai claude,codex,copilot,cursor (choose one or more), --plugins none, and no --ide.\n Native activation requires the corresponding host CLI on PATH.\n Tools without native activation are registered only; setup installs no plugins or tool configuration files." + "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ide Comma-separated tool IDs, or 'all' (e.g.,\n claude,vscode or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --global install for every project on this machine instead of\n into this project; see the requirements below\n -h, --help display help for command\n\nGlobal:\n Pass --ide claude,codex,copilot,cursor,kilo,opencode (one or more, no editor) and --plugins all, recommended, none or names.\n claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.\n codex gets their files in ~/.codex, cursor gets their files in ~/.cursor/plugins/local, kilo gets their files in ~/.config/kilo, opencode gets their files in ~/.config/opencode.\n `aidd sync --global` refreshes them from the framework source." }, { "invocation": "aidd sync", "exitCode": 0, - "help": "Usage: aidd sync [options] [files...]\n\nRewrite owned files from what is already there — regenerate tracked files,\ndriven by the manifest (see `translate`, which converts a source without\nrecording anything)\n\nArguments:\n files Limit sync to specific tracked files\n\nOptions:\n -f, --force Sync without prompting (default: false)\n --tool Limit sync to a specific tool\n --plugin Limit sync to a specific plugin\n --scope project (default) resolves this project's own manifest; user\n resolves the machine-wide manifest --scope user setup wrote,\n restoring no project files\n -h, --help display help for command" + "help": "Usage: aidd sync [options] [files...]\n\nRewrite owned files from what is already there — regenerate tracked files,\ndriven by the manifest (see `translate`, which converts a source without\nrecording anything)\n\nArguments:\n files Limit sync to specific tracked files\n\nOptions:\n -f, --force Sync without prompting (default: false)\n --tool Limit sync to a specific tool\n --plugin Limit sync to a specific plugin\n --scope project (default) resolves this project's own manifest; user\n resolves the machine-wide manifest --scope user setup wrote,\n restoring no project files\n --global act on the machine-wide install `setup --global` wrote,\n rebuilding its files from the framework source\n -h, --help display help for command" }, { "invocation": "aidd telemetry", diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index a82f58127..829b9d71e 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -25,7 +25,7 @@ { "command": "setup --source local --path --ai claude --plugins none --yes --scope user", "exitCode": 0, - "stdout": "Fetching marketplace 'aidd-framework'...\nProject initialized.\n", + "stdout": "Fetching marketplace 'aidd-framework'...\nRegistered for every project on this machine.\n", "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", "filesWritten": [], "manifest": { @@ -823,7 +823,7 @@ { "command": "[user-scope-clean] setup --source local --path --ai claude --plugins none --yes --scope user", "exitCode": 0, - "stdout": "Fetching marketplace 'aidd-framework'...\nProject initialized.\n", + "stdout": "Fetching marketplace 'aidd-framework'...\nRegistered for every project on this machine.\n", "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", "filesWritten": [], "manifest": null, @@ -853,7 +853,7 @@ { "command": "[user-scope-clean] doctor --scope user", "exitCode": 0, - "stdout": "Nothing registered at user scope yet — run `aidd setup --scope user` first.\n", + "stdout": "Nothing registered at user scope yet — run `aidd setup --global` first.\n", "stderr": "", "filesWritten": [], "manifest": null diff --git a/cli/tests/helpers/ports/fake-native-plugin-activator.ts b/cli/tests/helpers/ports/fake-native-plugin-activator.ts index fca466ea8..d2aa00a5d 100644 --- a/cli/tests/helpers/ports/fake-native-plugin-activator.ts +++ b/cli/tests/helpers/ports/fake-native-plugin-activator.ts @@ -12,6 +12,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { readonly enabledPlugins: string[] = []; readonly uninstalledPlugins: string[] = []; readonly updatedPlugins: string[] = []; + readonly releaseRefreshes: { ref: string; scope: MarketplaceScope }[] = []; /** The scope each call actually carried, in call order, never guessed from the ref. */ readonly enabledPluginScopes: MarketplaceScope[] = []; readonly uninstalledPluginScopes: MarketplaceScope[] = []; @@ -125,4 +126,9 @@ export class FakeNativePluginActivator implements NativePluginActivator { throw new NativePluginCliError(`plugin update '${pluginRef}' failed`); this.updatedPlugins.push(pluginRef); } + + refreshReleasePlugin(ref: string, scope: MarketplaceScope, _projectRoot: string): void { + if (this.failOnUpdate.has(ref)) throw new NativePluginCliError(`plugin update '${ref}' failed`); + this.releaseRefreshes.push({ ref, scope }); + } } diff --git a/cli/tests/helpers/ports/in-memory-file-adapter.ts b/cli/tests/helpers/ports/in-memory-file-adapter.ts index e41ebd7bd..820c27018 100644 --- a/cli/tests/helpers/ports/in-memory-file-adapter.ts +++ b/cli/tests/helpers/ports/in-memory-file-adapter.ts @@ -92,6 +92,10 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { return false; } + async entryExists(path: string): Promise { + return this.symlinks.has(norm(resolve(path))) || this.fileExists(path); + } + async readFileHash(path: string): Promise { const content = await this.readFile(path); return this.hasher.hash(content); diff --git a/cli/tests/helpers/sweep-stale-temp-dirs.ts b/cli/tests/helpers/sweep-stale-temp-dirs.ts deleted file mode 100644 index 264e61c46..000000000 --- a/cli/tests/helpers/sweep-stale-temp-dirs.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createRequire } from "node:module"; -import { join } from "node:path"; -import { REPOSITORY_ROOT } from "./repository-root.js"; - -/** Vitest's entry point into the repository's single sweep. The implementation is - * `scripts/sweep-stale-test-dirs.cjs`, shared with the plugin's `node:test` suites so one - * housekeeping rule is never written twice. */ -const require_ = createRequire(import.meta.url); - -interface Sweep { - sweepStaleTestDirs: (now?: number) => number; -} - -export function sweepStaleTempDirs(now?: number): number { - const { sweepStaleTestDirs } = require_( - join(REPOSITORY_ROOT, "scripts", "sweep-stale-test-dirs.cjs") - ) as Sweep; - return sweepStaleTestDirs(now); -} - -export function setup(): void { - sweepStaleTempDirs(); -} diff --git a/cli/tests/kernel/errors.unit.test.ts b/cli/tests/kernel/errors.unit.test.ts index 06cfa7b1a..0ac15bda6 100644 --- a/cli/tests/kernel/errors.unit.test.ts +++ b/cli/tests/kernel/errors.unit.test.ts @@ -482,25 +482,25 @@ describe("every error in the catalog", () => { build: () => new errors.UserScopeIdeToolsError(["vscode", "zed"]), name: "UserScopeIdeToolsError", message: - "--scope user installs no project files, so an IDE tool (vscode, zed) has nothing to install at user scope. Drop --ide, or run `aidd setup --ide ` separately at project scope.", + "--global installs no project files, so an editor (vscode, zed) has nothing to install machine-wide. Drop it from --ide, or run `aidd setup --ide ` separately in the project.", }, { build: () => new errors.UserScopeUnsupportedAiToolsError(["cursor", "opencode"]), name: "UserScopeUnsupportedAiToolsError", message: - "--scope user drives native activation machine-wide, and cursor, opencode declares no user-scope settings this CLI can point at. Drop it from --ai, or run `aidd setup --ai ` separately at project scope.", + "--global installs through an IDE's own plugin CLI or its user configuration directory, and neither exists for: cursor, opencode. Drop them from --ide, or run `aidd setup --ide ` in the project instead.", }, { build: () => new errors.UserScopeNoToolsError(), name: "UserScopeNoToolsError", message: - "--scope user with no --ai registers the shared source for no tool at all. Pass `--ai ` naming which tool to activate machine-wide.", + "--global with no --ide registers the shared source for no tool at all. Pass `--ide ` naming which tool to activate machine-wide.", }, { build: () => new errors.UserScopePluginModeError(), name: "UserScopePluginModeError", message: - "--scope user has no manifest entry a plugin can be recorded against yet, so --plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` separately at project scope.", + "--global cannot ask which plugins to install. Pass --plugins all, recommended, none, or comma-separated names.", }, { build: () => new errors.UserScopeFilterUnsupportedError("--plugin", "sync"), diff --git a/cli/tests/presentation/commands/clean-wiring.integration.test.ts b/cli/tests/presentation/commands/clean-wiring.integration.test.ts index 5fbae0276..a1ca7d0a8 100644 --- a/cli/tests/presentation/commands/clean-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/clean-wiring.integration.test.ts @@ -216,6 +216,10 @@ describe("aidd clean — the help surface", () => { "project (default) cleans this project alone; user undoes the machine-wide " + "registration setup --scope user wrote and purges the shared source itself", ], + [ + "--global", + "act on the machine-wide install `setup --global` wrote, and purge the shared source", + ], ]); }); }); diff --git a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts index d6000ff04..5f59d0ad8 100644 --- a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts @@ -402,6 +402,7 @@ describe("aidd doctor --scope user", () => { manifest: expect.anything(), projectRoot: PROJECT_ROOT, allowedIds: null, + globalInstall: true, }); expect(doctorAll).not.toHaveBeenCalled(); }); @@ -416,7 +417,7 @@ describe("aidd doctor --scope user", () => { loadUserManifest.mockResolvedValue(null); expect(await run("--scope", "user")).toEqual([ - "Nothing registered at user scope yet — run `aidd setup --scope user` first.", + "Nothing registered at user scope yet — run `aidd setup --global` first.", ]); expect(doctorRegistration).not.toHaveBeenCalled(); }); @@ -537,6 +538,7 @@ describe("aidd doctor — the help surface", () => { "project (default) checks this project's own manifest; user checks the " + "machine-wide manifest --scope user setup wrote", ], + ["--global", "act on the machine-wide install `setup --global` wrote"], ]); }); }); diff --git a/cli/tests/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts index 0bae604fb..460870a9f 100644 --- a/cli/tests/presentation/commands/framework-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/framework-wiring.integration.test.ts @@ -8,6 +8,9 @@ const uninstallAiTools = vi.fn(); const uninstallIdeTool = vi.fn(); const updateAiTools = vi.fn(); const updateIdeTools = vi.fn(); +const upgradeFramework = vi.fn(); +const upgradeUserFramework = vi.fn(); +const confirmUpgrade = vi.fn(); const listInstalledRules = vi.fn(); const validateRecipes = vi.fn(); const loadManifest = vi.fn(); @@ -21,6 +24,9 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ uninstallIdeUseCase: { execute: uninstallIdeTool }, updateAiToolsUseCase: { execute: updateAiTools }, updateIdeToolsUseCase: { execute: updateIdeTools }, + upgradeFrameworkUseCase: { execute: upgradeFramework }, + upgradeUserFrameworkUseCase: { execute: upgradeUserFramework }, + prompter: { confirm: confirmUpgrade }, listInstalledRulesUseCase: { execute: listInstalledRules }, validateRecipesUseCase: { execute: validateRecipes }, manifestRepo: { load: loadManifest }, @@ -29,6 +35,10 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ createMenuDeps: vi.fn(), })); +vi.mock("../../../src/runtime/machine-config-directory.js", () => ({ + assertMachineConfigDirectorySupported: vi.fn(), +})); + const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); const { registerFrameworkCommand } = await import( "../../../src/presentation/commands/framework.js" @@ -72,6 +82,53 @@ beforeEach(() => { uninstallIdeTool.mockResolvedValue({ toolId: "vscode", fileCount: 1 }); updateAiTools.mockResolvedValue({ updatedTools: [], errors: [] }); updateIdeTools.mockResolvedValue({ updatedTools: [], errors: [] }); + upgradeFramework.mockImplementation(async (options) => { + const confirmed = await options.confirm({ + currentRef: "v5.12.0", + release: options.release, + title: "Release title", + targets: [ + { + toolId: "opencode", + pluginName: "aidd-context", + currentRef: "v5.12.0", + currentVersion: "2.9.0", + targetVersion: "2.10.0", + }, + ], + globalSkipped: 0, + shared: true, + }); + if (confirmed && !options.allowShared) throw new Error("shared marketplace requires approval"); + return { + updated: confirmed ? ["opencode:aidd-context"] : [], + globalSkipped: 0, + cancelled: !confirmed, + }; + }); + upgradeUserFramework.mockImplementation(async (options) => { + const confirmed = await options.confirm({ + release: options.release, + title: "Release title", + nativeSkipped: 1, + targets: [ + { + toolId: "opencode", + name: "aidd-context", + currentRef: "v5.12.0", + currentVersion: "2.9.0", + targetVersion: "2.10.0", + dependents: ["/project"], + }, + ], + }); + return { + updated: confirmed ? ["opencode:aidd-context"] : [], + nativeSkipped: 1, + cancelled: !confirmed, + }; + }); + confirmUpgrade.mockResolvedValue(false); listInstalledRules.mockResolvedValue({ rules: [] }); validateRecipes.mockReturnValue({ findings: [], fileCount: 1 }); loadManifest.mockResolvedValue(null); @@ -232,6 +289,333 @@ describe("aidd framework remove", () => { }); describe("aidd framework update", () => { + it("prints a partial failure as an error without reporting a completed update", async () => { + upgradeFramework.mockRejectedValueOnce( + new Error( + "Upgrade incomplete. Plugin sources updated: claude:aidd-context. catalog unavailable" + ) + ); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect( + run("update", "--release", "v5.13.0", "--scope", "project", "--yes", "--allow-shared") + ).rejects.toThrow("exited"); + expect(errors.join("")).toContain("Plugin sources updated: claude:aidd-context"); + expect(errors.join("")).toContain("catalog unavailable"); + expect(written.join("")).not.toContain("Updated project plugin sources"); + expect(exit).toHaveBeenCalledWith(1); + }); + it.each([ + [["--scope", "user"], /--scope requires --release/], + [["--release", "v5.13.0", "--scope", "other"], /--scope must be project or user/], + [["--release", "v5.13.0", "--scope", "user", "--allow-shared"], /--allow-shared is not needed/], + ])("refuses invalid release scope options %j", async (args, message) => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", ...args)).rejects.toThrow("exited"); + expect(errors.join("")).toMatch(message); + expect(exit).toHaveBeenCalledWith(1); + expect(upgradeFramework).not.toHaveBeenCalled(); + expect(upgradeUserFramework).not.toHaveBeenCalled(); + }); + + it("routes an explicit project scope to the project updater", async () => { + const lines = await run( + "update", + "--release", + "v5.13.0", + "--scope", + "project", + "--yes", + "--allow-shared" + ); + expect(upgradeFramework).toHaveBeenCalledOnce(); + expect(upgradeUserFramework).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("Updated project plugin sources"); + }); + + it("previews machine copies and native exclusions before asking", async () => { + pretendTerminal(true); + confirmUpgrade.mockResolvedValue(false); + const lines = await run("update", "--release", "v5.13.0", "--scope", "user"); + expect(lines.join("\n")).toContain("opencode:aidd-context"); + expect(lines.join("\n")).toContain("Dependent projects: /project"); + expect(errors.join("")).toContain("native plugin claims are not updated"); + expect(confirmUpgrade).toHaveBeenCalledWith(expect.any(String), false); + expect(upgradeFramework).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("Update cancelled; no installation changes were made."); + }); + + it("shows each distinct machine source comparison and release before confirmation", async () => { + pretendTerminal(true); + upgradeUserFramework.mockImplementation(async (options) => { + const target = { + toolId: "opencode", + name: "aidd-context", + currentVersion: "2.9.0", + targetVersion: "2.10.0", + dependents: [], + }; + const confirmed = await options.confirm({ + release: options.release, + title: "Release title", + nativeSkipped: 0, + targets: [ + { ...target, currentRef: "v5.11.0" }, + { ...target, currentRef: "v5.12.0" }, + { ...target, currentRef: "v5.12.0" }, + { ...target, currentRef: "v5.13.0" }, + ], + }); + return { updated: [], nativeSkipped: 0, cancelled: !confirmed }; + }); + confirmUpgrade.mockImplementation(async () => { + const preview = written.join(""); + expect(preview).toContain("Release title: Release title"); + expect(preview).toContain("https://github.com/ai-driven-dev/framework/releases/tag/v5.13.0"); + expect(preview).toContain( + "https://github.com/ai-driven-dev/framework/compare/v5.11.0...v5.13.0" + ); + expect(preview.match(/compare\/v5\.12\.0\.\.\.v5\.13\.0/g)).toHaveLength(1); + expect(preview).not.toContain("compare/v5.13.0...v5.13.0"); + expect(preview).toContain("not limited to installed plugins"); + return false; + }); + await run("update", "--release", "v5.13.0", "--scope", "user"); + expect(confirmUpgrade).toHaveBeenCalledOnce(); + }); + + it("requires confirmation for noninteractive machine updates", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", "--release", "v5.13.0", "--scope", "user")).rejects.toThrow( + "exited" + ); + expect(errors.join("")).toMatch(/--yes/); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("passes explicit approval to the machine updater", async () => { + const lines = await run("update", "--release", "v5.13.0", "--scope", "user", "--yes"); + expect(lines.join("\n")).toContain("Updated machine plugin sources to v5.13.0"); + expect(lines.join("\n")).toContain("Restart the affected tools"); + expect(errors.join("")).toContain("1 native plugin claims are not updated"); + expect(upgradeFramework).not.toHaveBeenCalled(); + expect(confirmUpgrade).not.toHaveBeenCalled(); + }); + + it.each(["--yes", "--allow-shared"])("refuses %s without a release", async (flag) => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", flag)).rejects.toThrow("exited"); + expect(exit).toHaveBeenCalledWith(1); + expect(errors.join("")).toMatch(/--release/); + expect(updateAiTools).not.toHaveBeenCalled(); + expect(upgradeFramework).not.toHaveBeenCalled(); + }); + + it.each([["--tool", "opencode"], ["--force"]])( + "refuses tool config options on a release update: %s", + async (...flags) => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", "--release", "v5.13.0", ...flags)).rejects.toThrow("exited"); + expect(exit).toHaveBeenCalledWith(1); + expect(errors.join("")).toMatch(/--tool.*--force/); + expect(upgradeFramework).not.toHaveBeenCalled(); + } + ); + + it("does not treat --yes alone as consent to a shared marketplace change", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", "--release", "v5.13.0", "--yes")).rejects.toThrow("exited"); + expect(upgradeFramework.mock.calls[0][0].allowShared).toBe(false); + expect(confirmUpgrade).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("records interactive consent to the displayed shared impact", async () => { + pretendTerminal(true); + confirmUpgrade.mockResolvedValue(true); + await run("update", "--release", "v5.13.0"); + expect(confirmUpgrade).toHaveBeenCalledWith(expect.any(String), false); + expect(upgradeFramework.mock.calls[0][0].allowShared).toBe(true); + }); + + it("still requires shared consent for --yes in an interactive terminal", async () => { + pretendTerminal(true); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", "--release", "v5.13.0", "--yes")).rejects.toThrow("exited"); + expect(upgradeFramework.mock.calls[0][0].allowShared).toBe(false); + expect(confirmUpgrade).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("lists release targets and does not write when the user declines", async () => { + pretendTerminal(true); + const lines = await run("update", "--release", "v5.13.0"); + expect(lines.join("\n")).toContain("opencode:aidd-context"); + expect(lines.join("\n")).toContain("source v5.12.0 -> v5.13.0"); + expect(lines.join("\n")).toContain("plugin 2.9.0 -> 2.10.0"); + expect(lines.join("\n")).toContain("v5.12.0"); + expect(lines.join("\n")).toContain("v5.13.0"); + expect(lines.join("\n")).toContain(PROJECT_ROOT); + expect(lines.join("\n")).toMatch(/cancel/i); + expect(lines.join("\n")).not.toMatch(/updated project plugin sources/i); + expect(errors.join("")).toMatch(/shared/i); + expect(confirmUpgrade).toHaveBeenCalledOnce(); + expect(updateAiTools).not.toHaveBeenCalled(); + }); + + it("shows marketplace and distinct project plugin comparisons before confirmation", async () => { + pretendTerminal(true); + upgradeFramework.mockImplementation(async (options) => { + const target = { + toolId: "opencode", + pluginName: "aidd-context", + currentVersion: "2.9.0", + targetVersion: "2.10.0", + }; + const confirmed = await options.confirm({ + currentRef: "v5.11.0", + release: options.release, + title: "Release title", + targets: [ + { ...target, currentRef: "v5.12.0" }, + { ...target, currentRef: "v5.12.0" }, + ], + globalSkipped: 0, + shared: false, + }); + return { updated: [], globalSkipped: 0, cancelled: !confirmed }; + }); + confirmUpgrade.mockImplementation(async () => { + const preview = written.join(""); + expect(preview).toContain("Release title: Release title"); + expect(preview).toContain("https://github.com/ai-driven-dev/framework/releases/tag/v5.13.0"); + expect(preview).toContain( + "https://github.com/ai-driven-dev/framework/compare/v5.11.0...v5.13.0" + ); + expect(preview.match(/compare\/v5\.12\.0\.\.\.v5\.13\.0/g)).toHaveLength(1); + expect(preview).toContain("not limited to installed plugins"); + return false; + }); + await run("update", "--release", "v5.13.0"); + expect(confirmUpgrade).toHaveBeenCalledOnce(); + }); + + it("shows Claude installations affected by a shared catalogue before confirmation", async () => { + pretendTerminal(true); + upgradeFramework.mockImplementation(async (options) => { + const confirmed = await options.confirm({ + currentRef: "v5.12.0", + release: options.release, + title: "Release title", + targets: [], + globalSkipped: 0, + shared: true, + impact: [ + { ref: "aidd-dev@aidd-framework", scope: "project", projectPath: "/other-project" }, + { ref: "aidd-context@aidd-framework", scope: "user" }, + ], + }); + return { updated: [], globalSkipped: 0, cancelled: !confirmed }; + }); + confirmUpgrade.mockImplementation(async () => { + const preview = written.join(""); + expect(preview).toContain("aidd-dev@aidd-framework (project, /other-project)"); + expect(preview).toContain("aidd-context@aidd-framework (user)"); + expect(preview).toContain("Other dependents may exist"); + expect(preview).toContain("enabled states are unverified"); + return false; + }); + const lines = await run("update", "--release", "v5.13.0"); + expect(confirmUpgrade).toHaveBeenCalledOnce(); + expect(lines.join("\n")).toContain("Update cancelled"); + }); + + it("requires explicit approval outside a terminal", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", "--release", "v5.13.0")).rejects.toThrow("exited"); + expect(errors.join("")).toMatch(/--yes/); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("lets an explicit non-interactive approval pass to the release update", async () => { + const lines = await run("update", "--release", "v5.13.0", "--yes", "--allow-shared"); + expect(lines.join("\n")).toContain("opencode:aidd-context"); + expect(lines.join("\n")).toMatch( + /updated project plugin sources.*v5\.13\.0.*opencode:aidd-context/i + ); + expect(lines.join("\n")).toMatch(/restart/i); + expect(lines.join("\n")).not.toMatch(/cancel/i); + expect(upgradeFramework.mock.calls[0][0].allowShared).toBe(true); + expect(confirmUpgrade).not.toHaveBeenCalled(); + expect(updateAiTools).not.toHaveBeenCalled(); + }); + + it("lists recorded global copies separately and says native inventory is incomplete", async () => { + upgradeFramework.mockImplementation(async (options) => { + const confirmed = await options.confirm({ + currentRef: "v5.12.0", + release: options.release, + title: null, + targets: [ + { + toolId: "opencode", + pluginName: "aidd-context", + currentRef: "v5.12.0", + currentVersion: "2.9.0", + }, + ], + globalSkipped: 2, + unverifiedUserRefs: 1, + globalPlugins: [ + { + toolId: "opencode", + pluginName: "aidd-context", + currentVersion: "2.9.0", + source: { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + }, + }, + ], + shared: false, + }); + return { + updated: confirmed ? ["opencode:aidd-context"] : [], + globalSkipped: 2, + cancelled: !confirmed, + }; + }); + const lines = await run("update", "--release", "v5.13.0", "--yes"); + expect(lines.join("\n")).toContain("Release title unavailable."); + expect(lines.join("\n")).toContain( + "opencode:aidd-context (plugin 2.9.0; source ai-driven-dev/framework@v5.12.0)" + ); + expect(lines.join("\n")).toContain("Native user installations are not fully inventoried"); + expect(errors.join("")).toContain( + "Project references to user-scope plugins not verified in the machine manifest: 1" + ); + expect(lines.join("\n")).not.toMatch(/2 user-scope.*not be updated/i); + expect(lines.join("\n")).toContain("plugin 2.9.0;"); + expect(errors.join("")).not.toMatch(/shared/i); + expect(confirmUpgrade).not.toHaveBeenCalled(); + }); it("fans out across both categories when no tool was named", async () => { updateAiTools.mockResolvedValue({ updatedTools: [{ toolId: "claude", fileCount: 5 }], @@ -416,10 +800,7 @@ describe("aidd framework — the help surface", () => { "remove", "Remove a tool's generated configuration files — removes the framework only (see `clean`, which removes all of AIDD)", ], - [ - "update", - "Re-install tool configs from bundled CLI assets, moving to a new version (all installed tools if --tool is omitted; see `marketplace refresh`, which re-fetches catalogs instead)", - ], + ["update", "Update installed tool configs from the CLI, or plugins with --release"], ["rules", "List the rules installed in this project, across every AI tool"], [ "validate-recipes", @@ -438,6 +819,14 @@ describe("aidd framework — the help surface", () => { expect(optionsOf("update")).toEqual([ ["--tool ", "Limit update to a specific AI or IDE tool", false], ["-f, --force", "Overwrite modified files without prompting", false], + ["--release ", "Update plugins to a root release, e.g. v5.13.0", false], + [ + "--scope ", + "Choose project or machine plugin installations (with --release)", + false, + ], + ["--yes", "Confirm the displayed release update without prompting", false], + ["--allow-shared", "Confirm a marketplace change shared with other projects", false], ]); expect(optionsOf("rules")).toEqual([["--json", "Print the inventory as JSON", false]]); }); diff --git a/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts index 63b5de066..423b9e668 100644 --- a/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts @@ -433,7 +433,7 @@ describe("aidd marketplace — the help surface", () => { ["--scope ", "Registration scope (default: project)", "project"], ["--yes", "Skip the trust + cleanup prompts", undefined], ["--overwrite", "Replace an existing marketplace with the same name", undefined], - ["--token ", "Auth token (host detected from source URL at fetch time)", undefined], + ["--token ", "GitHub token for this operation", undefined], ]); }); diff --git a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts index 23d7deb45..4e924527c 100644 --- a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts @@ -11,6 +11,12 @@ const userPluginUpdate = vi.fn(); const activation = vi.fn(); const menuSelect = vi.fn(); const spawn = vi.fn(); +const loadMachine = vi.fn(); +const assertMachineConfig = vi.fn(); + +vi.mock("../../../src/runtime/machine-config-directory.js", () => ({ + assertMachineConfigDirectorySupported: assertMachineConfig, +})); vi.mock("../../../src/runtime/wiring/framework.js", () => ({ createDeps: vi.fn(async () => ({ @@ -20,6 +26,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ pluginSearchUseCase: { execute: pluginSearch }, pluginUpdateUseCase: { execute: pluginUpdate }, userPluginUpdateUseCase: { execute: userPluginUpdate }, + userManifestRepo: { load: loadMachine }, marketplaceSyncSettingsUseCase: { execute: activation }, })), createMenuDeps: vi.fn(() => ({ prompter: { select: menuSelect } })), @@ -60,6 +67,8 @@ beforeEach(() => { pluginSearch.mockResolvedValue({ hits: [] }); pluginUpdate.mockResolvedValue(["aidd-dev"]); userPluginUpdate.mockResolvedValue(["aidd-vcs@aidd-framework"]); + loadMachine.mockReset().mockResolvedValue(null); + assertMachineConfig.mockReset(); activation.mockResolvedValue({ binaryMissing: [], errors: [] }); menuSelect.mockResolvedValue("list"); spawn.mockResolvedValue(0); @@ -284,6 +293,69 @@ describe("aidd plugin update", () => { expect(pluginUpdate).not.toHaveBeenCalled(); }); + it("checks the selected user-scoped tool before updating it", async () => { + await run("update", "aidd-context", "--tool", "opencode", "--scope", "user"); + expect(assertMachineConfig).toHaveBeenCalledWith(["opencode"]); + expect(loadMachine).not.toHaveBeenCalled(); + expect(userPluginUpdate).toHaveBeenCalledOnce(); + }); + + it("rejects an unsupported user configuration before updating anything", async () => { + assertMachineConfig.mockImplementation(() => { + throw new Error("unsupported XDG config"); + }); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("update", "--tool", "kilo", "--scope", "user")).rejects.toThrow("exited"); + expect(errors.join("")).toContain("unsupported XDG config"); + expect(userPluginUpdate).not.toHaveBeenCalled(); + }); + + it("checks only tools owning the requested plugin when no tool is selected", async () => { + loadMachine.mockResolvedValue({ + getInstalledToolIds: () => ["opencode", "kilo", "claude"], + getPlugins: (tool: string) => + ({ + opencode: [{ name: "aidd-context", scope: "user" }], + kilo: [{ name: "aidd-context", scope: "project" }], + claude: [{ name: "other", scope: "user" }], + })[tool] ?? [], + }); + await run("update", "aidd-context", "--scope", "user"); + expect(assertMachineConfig).toHaveBeenCalledWith(["opencode"]); + expect(userPluginUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + pluginNames: ["aidd-context"], + toolIds: "all", + scope: "user", + }) + ); + }); + + it("checks every machine-owned tool when updating all user plugins", async () => { + loadMachine.mockResolvedValue({ + getInstalledToolIds: () => ["opencode", "kilo", "claude"], + getPlugins: (tool: string) => + ({ + opencode: [{ name: "aidd-context", scope: "user" }], + kilo: [{ name: "aidd-context", scope: "project" }], + claude: [{ name: "other", scope: "user" }], + })[tool] ?? [], + }); + await run("update", "--scope", "user"); + expect(assertMachineConfig).toHaveBeenCalledWith(["opencode", "claude"]); + expect(userPluginUpdate).toHaveBeenCalledWith( + expect.objectContaining({ pluginNames: undefined }) + ); + }); + + it("checks no tools if the machine manifest does not exist", async () => { + await run("update", "--scope", "user"); + expect(assertMachineConfig).toHaveBeenCalledWith([]); + expect(userPluginUpdate).toHaveBeenCalledOnce(); + }); + it("sweeps every plugin when none was named, and lists what moved", async () => { expect(await run("update")).toEqual(["Updated: aidd-dev."]); expect(pluginUpdate).toHaveBeenCalledWith({ @@ -393,7 +465,7 @@ describe("aidd plugin — the help surface", () => { expect(optionsOf("install")).toEqual([ ["--from ", "Marketplace name (when multiple match)"], ["--tool ", "Target AI tool (default: all installed)"], - ["--token ", "Auth token (host detected from source URL at fetch time)"], + ["--token ", "GitHub token for this operation"], ["--scope ", "Install scope; must match the tool's supported scope"], ["--yes", "Auto-resolve interactive prompts (CI mode)"], ]); diff --git a/cli/tests/presentation/commands/run-wiring.integration.test.ts b/cli/tests/presentation/commands/run-wiring.integration.test.ts new file mode 100644 index 000000000..b3632ef0a --- /dev/null +++ b/cli/tests/presentation/commands/run-wiring.integration.test.ts @@ -0,0 +1,534 @@ +import { ChildProcess, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, parse, relative } from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const host = vi.hoisted(() => ({ spawn: vi.fn() })); +vi.mock("cross-spawn", () => ({ default: host.spawn })); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: vi.fn(actual.spawnSync) }; +}); + +const { registerRunCommand } = await import("../../../src/presentation/commands/run/command.js"); + +let root: string; +let project: string; +let config: string; +let errors: string[]; +let child: ChildProcess; + +const notice = 'process.stdout.write(JSON.stringify({systemMessage:"MAINTENANCE NOTICE"}));'; + +function file(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function install(tool: "kilo" | "opencode", scope: "project" | "user", script = notice): string { + const scriptPath = + scope === "project" + ? join(project, `.${tool}`, "hooks/aidd-maintenance/session-start.cjs") + : join(config, tool, "hooks/aidd-maintenance/session-start.cjs"); + const manifestPath = + scope === "project" ? join(project, ".aidd/manifest.json") : join(config, "aidd/manifest.json"); + const rel = + scope === "project" + ? `.${tool}/hooks/aidd-maintenance/session-start.cjs` + : "hooks/aidd-maintenance/session-start.cjs"; + file(scriptPath, script); + file( + manifestPath, + JSON.stringify({ + version: 8, + tools: { + [tool]: { + toolId: tool, + version: "5.6.0", + files: [], + plugins: [ + { + name: "aidd-maintenance", + version: "0.1.0", + source: { kind: "local", path: root }, + strict: false, + scope, + marketplace: "aidd-framework", + files: { [rel]: createHash("md5").update(script).digest("hex") }, + }, + ], + }, + }, + }) + ); + return scriptPath; +} + +async function run(tool: string, ...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + registerRunCommand(program); + await program.parseAsync(["node", "aidd", "run", tool, "--", ...args]); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "run-wiring-")); + project = join(root, "project"); + config = join(root, "config"); + mkdirSync(project); + mkdirSync(join(project, ".git")); + vi.stubEnv("XDG_CONFIG_HOME", config); + vi.stubEnv("AIDD_USER_CONFIG_DIR", ""); + vi.stubEnv("KILO_CONFIG_DIR", ""); + vi.stubEnv("OPENCODE_CONFIG_DIR", ""); + vi.stubEnv("KILO_PURE", ""); + vi.spyOn(process, "cwd").mockReturnValue(project); + vi.spyOn(process, "kill").mockReturnValue(true); + errors = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + child = new ChildProcess(); + host.spawn.mockReset(); + host.spawn.mockImplementation(() => { + queueMicrotask(() => child.emit("close", 0, null)); + return child; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + process.exitCode = undefined; + rmSync(root, { recursive: true, force: true }); +}); + +describe("headless launcher", () => { + it.each(["kilo", "opencode"] as const)( + "runs the recorded %s hook and forwards arguments", + async (tool) => { + install(tool, "project"); + await run(tool, "--format", "json", "hello world"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledWith(tool, ["run", "--format", "json", "hello world"], { + stdio: "inherit", + }); + expect(process.exitCode).toBe(0); + } + ); + + it.each([ + "hooks/aidd-maintenance/session-start.cjs", + "plugin/aidd-maintenance-hooks.js", + "plugins/aidd-maintenance-tui/server.js", + ])("refuses an untracked project copy identified by %s", async (path) => { + install("opencode", "user"); + file(join(project, ".opencode", path), "foreign plugin"); + await run("opencode"); + expect(errors.join("")).toMatch(/project and global copies coexist/i); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("rejects an unknown tool before invoking it", async () => { + await run("unknown"); + expect(errors.join("")).toContain("Supported tools: opencode, kilo"); + expect(process.exitCode).toBe(1); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it.each([ + ["opencode", "OPENCODE_CONFIG_DIR"], + ["kilo", "KILO_CONFIG_DIR"], + ] as const)("rejects an overridden %s configuration", async (tool, key) => { + install(tool, "project"); + vi.stubEnv(key, join(root, "override")); + await run(tool); + expect(errors.join("")).toContain(key); + expect(host.spawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("reports a timeout and signal without exposing hook output or error details", async () => { + install("opencode", "project"); + vi.mocked(spawnSync).mockReturnValueOnce({ + error: Object.assign(new Error("secret path sensitive-location"), { code: "ETIMEDOUT" }), + status: null, + stdout: "secret stdout", + stderr: "secret stderr", + signal: "SIGTERM", + pid: 1, + output: [null, "secret stdout", "secret stderr"], + }); + await run("opencode"); + expect(errors.join("")).toContain("ETIMEDOUT"); + expect(errors.join("")).toContain("SIGTERM"); + expect(errors.join("")).not.toContain("secret"); + expect(errors.join("")).not.toContain("sensitive-location"); + expect(host.spawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("does not include unrecognized error codes or signals in diagnostics", async () => { + install("opencode", "project"); + vi.mocked(spawnSync).mockReturnValueOnce({ + error: Object.assign(new Error("private message"), { code: "SECRET_CODE" }), + status: 1, + stdout: "", + stderr: "private output", + signal: "SECRET_SIGNAL" as NodeJS.Signals, + pid: 1, + output: [null, "", "private output"], + }); + await run("opencode"); + expect(errors.join("")).toContain("exit 1"); + expect(errors.join("")).toContain("unknown error"); + expect(errors.join("")).not.toContain("SECRET"); + expect(errors.join("")).not.toContain("private"); + expect(host.spawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it.each(["kilo", "opencode"] as const)( + "rejects competing project and global %s hooks", + async (tool) => { + install(tool, "project"); + install(tool, "user"); + await run(tool); + expect(errors.join("")).toMatch(/project and global copies coexist/i); + expect(host.spawn).not.toHaveBeenCalled(); + } + ); + + it("uses the global hook in the selected XDG directory", async () => { + install("opencode", "user"); + await run("opencode", "hi"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledWith("opencode", ["run", "hi"], { stdio: "inherit" }); + }); + + it("does not confuse an unrelated project configuration directory with a maintenance hook", async () => { + install("opencode", "user"); + file(join(project, ".opencode", "unrelated-plugin.js"), "foreign plugin"); + await run("opencode"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledOnce(); + }); + + it("checks for conflicts across nested project directories", async () => { + install("opencode", "user"); + file(join(project, ".opencode", "plugin/aidd-maintenance-hooks.js"), "project plugin"); + const nested = join(project, "nested"); + mkdirSync(nested); + vi.mocked(process.cwd).mockReturnValue(nested); + await run("opencode"); + expect(errors.join("")).toMatch(/project and global copies coexist/i); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("does not mistake a plugin above the Git root for a conflict", async () => { + install("opencode", "user"); + file(join(root, ".opencode", "plugin/aidd-maintenance-hooks.js"), "parent plugin"); + await run("opencode"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledOnce(); + }); + + it("can find a global installation when invoked at the filesystem root", async () => { + install("opencode", "user"); + vi.mocked(process.cwd).mockReturnValue(parse(project).root); + await run("opencode"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledOnce(); + }); + + it("ignores unrelated project plugins when selecting a global installation", async () => { + install("opencode", "project"); + const manifest = join(project, ".aidd/manifest.json"); + const data = JSON.parse(readFileSync(manifest, "utf8")); + data.tools.opencode.plugins[0].name = "unrelated-plugin"; + file(manifest, JSON.stringify(data)); + rmSync(join(project, ".opencode"), { recursive: true }); + install("opencode", "user"); + await run("opencode"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledOnce(); + }); + + it.each(["project", "user"] as const)( + "refuses unrelated plugins in the %s manifest", + async (scope) => { + install("opencode", scope); + const manifest = + scope === "project" + ? join(project, ".aidd/manifest.json") + : join(config, "aidd/manifest.json"); + const data = JSON.parse(readFileSync(manifest, "utf8")); + data.tools.opencode.plugins[0].name = "unrelated-plugin"; + file(manifest, JSON.stringify(data)); + await run("opencode"); + expect(errors.join("")).toMatch(/not installed/i); + expect(host.spawn).not.toHaveBeenCalled(); + } + ); + + it("does not execute a project-scoped record from the global manifest", async () => { + install("opencode", "user"); + const manifest = join(config, "aidd/manifest.json"); + const data = JSON.parse(readFileSync(manifest, "utf8")); + data.tools.opencode.plugins[0].scope = "project"; + file(manifest, JSON.stringify(data)); + await run("opencode"); + expect(errors.join("")).toMatch(/not installed/i); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("stops at a git root rather than using the parent project's hook", async () => { + install("opencode", "project"); + const childProject = join(project, "child"); + mkdirSync(join(childProject, ".git"), { recursive: true }); + vi.mocked(process.cwd).mockReturnValue(childProject); + await run("opencode"); + expect(errors.join("")).toMatch(/not installed/i); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("discovers a project hook from its nested working directory", async () => { + install("kilo", "project"); + const nested = join(project, "nested"); + mkdirSync(nested); + vi.mocked(process.cwd).mockReturnValue(nested); + await run("kilo"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + }); + + it("refuses to execute a modified recorded script", async () => { + const script = install("kilo", "project"); + writeFileSync(script, 'throw Error("untrusted script ran")'); + await run("kilo"); + expect(errors.join("")).toMatch(/modified/i); + expect(errors.join("")).not.toContain("untrusted script ran"); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("refuses a script that is present but has no manifest", async () => { + file(join(project, ".kilo/hooks/aidd-maintenance/session-start.cjs"), notice); + await run("kilo"); + expect(errors.join("")).toMatch(/not installed/i); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("refuses a missing script even when it is recorded", async () => { + const script = install("kilo", "project"); + rmSync(script); + await run("kilo"); + expect(errors.join("")).toMatch(/not installed/i); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("refuses a script without its recorded hash", async () => { + install("kilo", "project"); + const manifest = join(project, ".aidd/manifest.json"); + const data = JSON.parse(readFileSync(manifest, "utf8")); + data.tools.kilo.plugins[0].files = {}; + file(manifest, JSON.stringify(data)); + await run("kilo"); + expect(errors.join("")).toMatch(/not installed/i); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it.each([ + ["--attach", "http://localhost"], + ["--attach=http://localhost"], + ["--server", "http://localhost"], + ["--server=http://localhost"], + ])("rejects remote argument %s", async (...args) => { + await run("kilo", ...args); + expect(errors.join("")).toContain("Remote servers are not supported"); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it.each(["--interactive", "-i", "--interactive=true"])( + "rejects interactive flag %s", + async (flag) => { + await run("kilo", flag); + expect(errors.join("")).toContain("directly for interactive sessions"); + expect(host.spawn).not.toHaveBeenCalled(); + } + ); + + it.each(["--pure", "--pure=true"])("rejects pure flag %s", async (flag) => { + await run("kilo", flag); + expect(errors.join("")).toContain("pure mode disables"); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("rejects pure mode from the environment", async () => { + vi.stubEnv("KILO_PURE", "1"); + await run("kilo"); + expect(errors.join("")).toContain("pure mode disables"); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it.each(["--dir", "--dir="])("uses Kilo's directory argument %s", async (flag) => { + install("kilo", "project"); + vi.mocked(process.cwd).mockReturnValue(root); + const rel = relative(root, project); + await run("kilo", ...(flag === "--dir" ? [flag, rel] : [`${flag}${rel}`]), "prompt"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledOnce(); + }); + + it("requires a value after --dir", async () => { + await run("kilo", "--dir"); + expect(errors.join("")).toContain("--dir requires a directory"); + expect(host.spawn).not.toHaveBeenCalled(); + }); + + it("passes literal options after the end-of-options marker to the host", async () => { + install("kilo", "project"); + await run("kilo", "--", "--attach"); + expect(errors.join("")).toBe("MAINTENANCE NOTICE\n"); + expect(host.spawn).toHaveBeenCalledWith("kilo", ["run", "--", "--attach"], { + stdio: "inherit", + }); + }); + + it("sends the hook its working directory and the requested host", async () => { + install( + "kilo", + "project", + `let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { const x = JSON.parse(input); process.stdout.write(JSON.stringify({systemMessage: [x.hook_event_name, x.cwd, x.tool].join("|")})); });` + ); + await run("kilo"); + expect(errors.join("")).toBe(`SessionStart|${project}|kilo\n`); + }); + + it("trims hook notices before showing them", async () => { + install( + "opencode", + "project", + 'process.stdout.write(JSON.stringify({systemMessage:" NOTICE "}));' + ); + await run("opencode"); + expect(errors.join("")).toBe("NOTICE\n"); + }); + + it("accepts an EPIPE when the hook exited successfully with a valid notice", async () => { + install("opencode", "project"); + vi.mocked(spawnSync).mockReturnValueOnce({ + error: Object.assign(new Error("pipe closed"), { code: "EPIPE" }), + status: 0, + stdout: JSON.stringify({ systemMessage: "NOTICE" }), + stderr: "", + signal: null, + pid: 1, + output: [null, JSON.stringify({ systemMessage: "NOTICE" }), ""], + }); + await run("opencode"); + expect(errors.join("")).toBe("NOTICE\n"); + expect(host.spawn).toHaveBeenCalledOnce(); + }); + + it.each([ + [{ code: "EPIPE", status: 1 }, "hook failed"], + [{ code: "EACCES", status: 0 }, "hook failed"], + [{ code: "EPIPE", status: 0 }, "Invalid"], + ])("still rejects failed processes and malformed output", async ({ code, status }, expected) => { + install("opencode", "project"); + vi.mocked(spawnSync).mockReturnValueOnce({ + error: Object.assign(new Error("spawn failed"), { code }), + status, + stdout: "bad json", + stderr: "", + signal: null, + pid: 1, + output: [null, "bad json", ""], + }); + await run("opencode"); + expect(errors.join("")).toContain(expected); + expect(host.spawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it.each([ + ["process.exitCode=2", "hook failed"], + ['process.stdout.write("bad json")', "Invalid"], + ['process.stdout.write(JSON.stringify({systemMessage:" "}))', "Empty"], + ["process.stdout.write(JSON.stringify({systemMessage:9}))", "Empty"], + ])("rejects failed or malformed hook output", async (script, expected) => { + install("opencode", "project", script); + await run("opencode"); + expect(errors.join("")).toContain(expected); + expect(host.spawn).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("propagates the host exit code", async () => { + install("opencode", "project"); + host.spawn.mockImplementation(() => { + queueMicrotask(() => child.emit("close", 7, null)); + return child; + }); + await run("opencode"); + expect(process.exitCode).toBe(7); + }); + + it("reports an executable that fails to start", async () => { + install("kilo", "project"); + host.spawn.mockImplementation(() => { + queueMicrotask(() => { + child.emit("error", new Error("not found")); + child.emit("close", -1, null); + }); + return child; + }); + await run("kilo"); + expect(errors.join("")).toContain("Unable to start kilo: not found"); + expect(process.exitCode).toBe(1); + }); + + it("forwards termination signals to the host and removes listeners afterwards", async () => { + install("kilo", "project"); + const listeners = new Set(process.listeners("SIGINT")); + const kill = vi.spyOn(child, "kill").mockReturnValue(true); + host.spawn.mockImplementation(() => { + queueMicrotask(() => { + for (const listener of process.listeners("SIGINT")) { + if (!listeners.has(listener)) listener("SIGINT"); + } + child.emit("close", 0, null); + }); + return child; + }); + await run("kilo"); + expect(kill).toHaveBeenCalledWith("SIGINT"); + expect(process.listeners("SIGINT")).toEqual([...listeners]); + }); + + it.skipIf(process.platform === "win32")("propagates termination by the host", async () => { + install("kilo", "project"); + host.spawn.mockImplementation(() => { + queueMicrotask(() => child.emit("close", null, "SIGTERM")); + return child; + }); + await run("kilo"); + expect(process.kill).toHaveBeenCalledWith(process.pid, "SIGTERM"); + }); + + it.skipIf(process.platform !== "win32")( + "reports host termination as failure on Windows", + async () => { + install("kilo", "project"); + host.spawn.mockImplementation(() => { + queueMicrotask(() => child.emit("close", null, "SIGTERM")); + return child; + }); + await run("kilo"); + expect(process.exitCode).toBe(1); + } + ); +}); diff --git a/cli/tests/presentation/commands/setup-options.unit.test.ts b/cli/tests/presentation/commands/setup-options.unit.test.ts index dd21fca09..65806e25d 100644 --- a/cli/tests/presentation/commands/setup-options.unit.test.ts +++ b/cli/tests/presentation/commands/setup-options.unit.test.ts @@ -97,14 +97,13 @@ describe("parseToolIds", () => { expect(output.at("error")).toHaveLength(1); }); - it("refuses an AI id given as an IDE one", () => { - const output = new CapturingOutput(false); - const exit = refuseByThrowing(); - - expect(() => parseToolIds({ ide: "claude" }, new ErrorHandler(output))).toThrow("process.exit"); + it("files every id --ide names under the list its category decides", () => { + const errorHandler = new ErrorHandler(new CapturingOutput(false)); - expect(exit).toHaveBeenCalledWith(1); - expect(output.at("error")).toHaveLength(1); + expect(parseToolIds({ ide: "claude,vscode", ai: "codex" }, errorHandler)).toStrictEqual({ + aiTools: ["codex", "claude"], + ideTools: ["vscode"], + }); }); it("accepts 'all' without checking it against the category", () => { diff --git a/cli/tests/presentation/commands/setup-wiring.integration.test.ts b/cli/tests/presentation/commands/setup-wiring.integration.test.ts index ea31fe91e..adcee6c7d 100644 --- a/cli/tests/presentation/commands/setup-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/setup-wiring.integration.test.ts @@ -38,7 +38,9 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ })); const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); -const { registerSetupCommand } = await import("../../../src/presentation/commands/setup.js"); +const { globalSetupHelp, registerSetupCommand } = await import( + "../../../src/presentation/commands/setup.js" +); const PROJECT_ROOT = process.cwd(); @@ -83,6 +85,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); process.exitCode = undefined; }); @@ -164,6 +167,28 @@ describe("aidd setup — the flow a scripted run builds", () => { expect(setupTools).not.toHaveBeenCalled(); expect(resolveSourceIfNeeded).not.toHaveBeenCalled(); }); + + it("refuses an unsupported OpenCode global config root before setup writes anything", async () => { + vi.stubEnv("HOME", "/isolated/home"); + vi.stubEnv("XDG_CONFIG_HOME", "/other/config"); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect( + run("--global", "--ide", "opencode", "--plugins", "none", "--yes") + ).rejects.toThrow("exited"); + expect(errors.join("")).toContain("opencode global installation requires XDG_CONFIG_HOME"); + expect(createDeps).not.toHaveBeenCalled(); + expect(setupMachineScope).not.toHaveBeenCalled(); + }); + + it("allows a project setup under a custom XDG root", async () => { + vi.stubEnv("HOME", "/isolated/home"); + vi.stubEnv("XDG_CONFIG_HOME", "/other/config"); + await run("--ai", "opencode", "--yes"); + expect(builtFlow().aiTools).toEqual(["opencode"]); + expect(setupTools).toHaveBeenCalledOnce(); + }); }); describe("aidd setup — a person at a terminal", () => { @@ -258,46 +283,19 @@ describe("aidd setup — every flag that makes a run a scripted one", () => { }); describe("aidd setup — what it refuses and what it reports", () => { - it.each(["all", "recommended", "aidd-dev"])( - "refuses user-scope plugin mode %s before building dependencies", - async (plugins) => { - const exit = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exited"); - }); - - await expect(run("--scope", "user", "--ai", "claude", "--plugins", plugins)).rejects.toThrow( - "exited" - ); - - expect(errors.join("")).toBe( - "Error: --scope user has no manifest entry a plugin can be recorded against yet, so " + - "--plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` " + - "separately at project scope.\n" - ); - expect(exit).toHaveBeenCalledWith(1); - expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); - expect(written).toEqual([]); - } - ); - it.each([ - { args: [], cause: "with no --ai", remedy: "Pass `--ai `" }, - { - args: ["--ai", "claude", "--ide", "vscode"], - cause: "an IDE tool (vscode)", - remedy: "Drop --ide", - }, + { args: [], cause: "with no --ide", remedy: "Pass `--ide `" }, { - args: ["--ai", "opencode"], - cause: "opencode declares no user-scope settings", - remedy: "Drop it from --ai", + args: ["--ide", "claude,vscode"], + cause: "an editor (vscode)", + remedy: "Drop it from --ide", }, - ])("renders user-scope validation for $cause before building dependencies", async (test) => { + ])("renders global validation for $cause before building dependencies", async (test) => { const exit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exited"); }); - await expect(run("--scope", "user", ...test.args)).rejects.toThrow("exited"); + await expect(run("--global", ...test.args)).rejects.toThrow("exited"); expect(errors.join("")).toContain(test.cause); expect(errors.join("")).toContain(test.remedy); @@ -389,19 +387,30 @@ describe("aidd setup — the help surface", () => { return setup; } + it("says what --global does for each tool, read from its profile", () => { + expect(globalSetupHelp().split("\n")).toEqual([ + "", + "Global:", + " Pass --ide claude,codex,copilot,cursor,opencode (one or more, no editor) and --plugins all, recommended, none or names.", + " claude, codex, copilot enable the plugins through their own CLI, which must be on PATH.", + " codex gets their files in ~/.codex, cursor gets their files in ~/.cursor/plugins/local, opencode gets their files in ~/.config/opencode.", + " `aidd sync --global` refreshes them from the framework source.", + ]); + }); + it("describes itself against the command that acts on the framework alone", () => { expect(setupCommand().description()).toBe( "Set up or update the project to a correct state — bootstraps the whole project (marketplace, framework, tools, plugins); see `framework install`, which acts on the framework alone" ); }); - it("offers a source, two tool lists, a plugin mode, a scope, and two switches", () => { - expect(setupCommand().options.map((option) => [option.flags, option.description])).toEqual([ + it("offers a source, one tool list, a plugin mode, a scope, a provider, and its switches", () => { + const visible = setupCommand().options.filter((option) => !option.hidden); + expect(visible.map((option) => [option.flags, option.description])).toEqual([ ["--source ", "Framework source: remote or local"], ["--path ", "Absolute path to local framework (required with --source local)"], ["--release ", "Marketplace release tag to fetch (e.g., v1.2.3)"], - ["--ai ", "Comma-separated AI tool IDs, or 'all' (e.g., claude,cursor or all)"], - ["--ide ", "Comma-separated IDE tool IDs, or 'all' (e.g., vscode or all)"], + ["--ide ", "Comma-separated tool IDs, or 'all' (e.g., claude,vscode or all)"], ["--plugins ", "Plugin install mode: none | all | recommended | comma-separated names"], [ "--no-default-marketplace", @@ -409,11 +418,15 @@ describe("aidd setup — the help surface", () => { ], ["--yes", "Accept defaults without prompting"], [ - "--scope ", - "project (default) installs into this project alone; user registers the shared " + - "framework source and supported tools machine-wide, writing nothing under this project; " + - "see user-scope requirements below", + "--global", + "install for every project on this machine instead of into this project; see the " + + "requirements below", ], ]); + expect( + setupCommand() + .options.filter((option) => option.hidden) + .map((option) => option.flags) + ).toEqual(["--ai ", "--scope "]); }); }); diff --git a/cli/tests/presentation/commands/sync-wiring.integration.test.ts b/cli/tests/presentation/commands/sync-wiring.integration.test.ts index e898d07d0..f17c4b141 100644 --- a/cli/tests/presentation/commands/sync-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/sync-wiring.integration.test.ts @@ -8,6 +8,7 @@ const loadManifest = vi.fn(); const getToolVersion = vi.fn(); const currentVersion = vi.fn(); const userManifestRepo = { load: vi.fn() }; +const userPluginUpdate = vi.fn(); vi.mock("../../../src/runtime/wiring/framework.js", () => ({ createDeps: vi.fn(async () => ({ @@ -16,6 +17,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ marketplaceSyncSettingsUseCase: { execute: activation }, manifestRepo: { load: loadManifest }, userManifestRepo, + userPluginUpdateUseCase: { execute: userPluginUpdate }, currentVersionProvider: { get: currentVersion }, })), createMenuDeps: vi.fn(), @@ -65,7 +67,10 @@ beforeEach(() => { getToolVersion.mockReturnValue("5.1.0"); loadManifest.mockResolvedValue({ getToolVersion }); currentVersion.mockReturnValue("5.2.2"); - userManifestRepo.load.mockResolvedValue({ getInstalledToolIds: () => ["claude"] }); + userManifestRepo.load.mockResolvedValue({ + getInstalledToolIds: () => ["claude"], + getPlugins: () => [], + }); }); afterEach(() => { @@ -231,6 +236,7 @@ describe("aidd sync --scope user", () => { toolIds: undefined, recreateFrameworkIfMissing: true, }); + expect(userPluginUpdate).not.toHaveBeenCalled(); expect(restoreAll).not.toHaveBeenCalled(); expect(restoreOne).not.toHaveBeenCalled(); }); @@ -241,6 +247,23 @@ describe("aidd sync --scope user", () => { ]); }); + it("rebuilds the global files a tool owns before driving activation, under --global", async () => { + userManifestRepo.load.mockResolvedValue({ + getInstalledToolIds: () => ["claude", "opencode"], + getPlugins: (toolId: string) => (toolId === "opencode" ? [{ scope: "user" }] : []), + }); + userPluginUpdate.mockResolvedValue(["aidd-dev", "aidd-dev"]); + + expect(await run("--global")).toEqual(["Refreshed global files for: aidd-dev"]); + expect(userPluginUpdate).toHaveBeenCalledWith({ + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + scope: "user", + rebuild: true, + }); + expect(activation).toHaveBeenCalledWith(expect.objectContaining({ scope: "user" })); + }); + it("narrows to the one tool named, still off the machine-wide manifest", async () => { await run("--scope", "user", "--tool", "claude"); @@ -350,6 +373,10 @@ describe("aidd sync — the help surface", () => { "project (default) resolves this project's own manifest; user resolves the " + "machine-wide manifest --scope user setup wrote, restoring no project files", ], + [ + "--global", + "act on the machine-wide install `setup --global` wrote, rebuilding its files from the framework source", + ], ]); }); }); diff --git a/cli/tests/presentation/display/setup-display.unit.test.ts b/cli/tests/presentation/display/setup-display.unit.test.ts index bdb68c3f5..daffbb3d0 100644 --- a/cli/tests/presentation/display/setup-display.unit.test.ts +++ b/cli/tests/presentation/display/setup-display.unit.test.ts @@ -90,6 +90,32 @@ describe("printSetupOutcome", () => { expect(output.at("success")).toEqual([]); }); + it("names the plugins a machine-wide run installed, and nothing about the project", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { kind: "initialized", install: { results: [] }, globalPlugins: ["aidd-dev", "aidd-pm"] }, + false + ); + + expect(output.lines).toEqual([ + "Installed for every project on this machine: aidd-dev, aidd-pm.", + ]); + }); + + it("says a machine-wide run with no plugin only registered the source", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { kind: "up-to-date", install: { results: [] }, globalPlugins: [] }, + false + ); + + expect(output.lines).toEqual(["Registered for every project on this machine."]); + }); + it("warns about a tool already installed rather than counting it as installed", () => { const output = new CapturingOutput(false); diff --git a/cli/tests/presentation/display/sync-display.unit.test.ts b/cli/tests/presentation/display/sync-display.unit.test.ts index 70e551aa5..2b9b760f4 100644 --- a/cli/tests/presentation/display/sync-display.unit.test.ts +++ b/cli/tests/presentation/display/sync-display.unit.test.ts @@ -164,6 +164,25 @@ describe("printActivationOutcome", () => { }); describe("printUserScopeSyncOutcome", () => { + it("names the plugins whose global files were rebuilt, and nothing else when no host was driven", () => { + const output = new CapturingOutput(false); + + printUserScopeSyncOutcome(output, [], ["aidd-dev", "aidd-pm"]); + + expect(output.at("success")).toEqual(["Refreshed global files for: aidd-dev, aidd-pm"]); + }); + + it("reports both the rebuilt files and the hosts driven", () => { + const output = new CapturingOutput(false); + + printUserScopeSyncOutcome(output, ["claude"], ["aidd-dev"]); + + expect(output.at("success")).toEqual([ + "Refreshed global files for: aidd-dev", + "Synced native activation for: claude", + ]); + }); + it("says no tool is registered at user scope when none was activated", () => { const output = new CapturingOutput(false); diff --git a/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts index 6c667e842..abdcd9877 100644 --- a/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts +++ b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts @@ -22,6 +22,29 @@ describe("FileAdapter, at the edges", () => { await rm(tempDir, { recursive: true, force: true }); }); + describe("assertWritableDirectory()", () => { + it("accepts an existing writable directory and rejects a missing one", async () => { + await expect(fs.assertWritableDirectory(tempDir)).resolves.toBeUndefined(); + await expect(fs.assertWritableDirectory(join(tempDir, "missing"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "rejects a directory without write permission", + async () => { + await chmod(tempDir, 0o555); + try { + await expect(fs.assertWritableDirectory(tempDir)).rejects.toMatchObject({ + code: "EACCES", + }); + } finally { + await chmod(tempDir, 0o700); + } + } + ); + }); + describe("listFilesRecursive()", () => { it("lists every file below the directory by absolute path", async () => { await mkdir(join(tempDir, "a", "b"), { recursive: true }); diff --git a/cli/tests/runtime/git/inject-token-hosts.unit.test.ts b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts index de802b3ca..ac985f491 100644 --- a/cli/tests/runtime/git/inject-token-hosts.unit.test.ts +++ b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts @@ -1,13 +1,5 @@ import { describe, expect, it } from "vitest"; -import { injectTokenIntoUrl, withoutCredentials } from "../../../src/runtime/git/inject-token.js"; - -describe("injectTokenIntoUrl, Azure DevOps", () => { - it("uses an empty user with the token as password", () => { - expect(injectTokenIntoUrl("https://dev.azure.com/org/repo", "tok")).toBe( - "https://:tok@dev.azure.com/org/repo" - ); - }); -}); +import { withoutCredentials } from "../../../src/runtime/git/inject-token.js"; describe("withoutCredentials", () => { it("strips a credential from an http URL as well", () => { @@ -21,4 +13,22 @@ describe("withoutCredentials", () => { it("leaves a URL carrying no credential alone", () => { expect(withoutCredentials("https://host/repo.git")).toBe("https://host/repo.git"); }); + + it("does not alter non-HTTP URLs, even when their path contains an HTTP URL", () => { + const url = "ssh://git@example.com/path/https://other.example/repo.git"; + expect(withoutCredentials(url)).toBe(url); + }); + + it.each(["u", "user:secret@part"])( + "removes userinfo from malformed HTTP URLs even when URL parsing fails", + (userinfo) => { + expect(withoutCredentials(`https://${userinfo}@ bad-host/repo`)).toBe( + "https:// bad-host/repo" + ); + } + ); + + it("also removes credentials from malformed plain HTTP URLs", () => { + expect(withoutCredentials("http://user:secret@ bad-host/repo")).toBe("http:// bad-host/repo"); + }); }); diff --git a/cli/tests/runtime/git/inject-token.unit.test.ts b/cli/tests/runtime/git/inject-token.unit.test.ts index 05e75d102..921fc7efe 100644 --- a/cli/tests/runtime/git/inject-token.unit.test.ts +++ b/cli/tests/runtime/git/inject-token.unit.test.ts @@ -1,66 +1,61 @@ import { describe, expect, it } from "vitest"; -import { injectTokenIntoUrl } from "../../../src/runtime/git/inject-token.js"; - -describe("injectTokenIntoUrl", () => { - it("returns the URL unchanged when token is undefined", () => { - const url = "https://github.com/owner/repo.git"; - expect(injectTokenIntoUrl(url, undefined)).toBe(url); - }); - - it("does not modify ssh URLs", () => { - const ssh = "git@github.com:owner/repo.git"; - expect(injectTokenIntoUrl(ssh, "tk")).toBe(ssh); - }); - - it("uses x-access-token for github", () => { - expect(injectTokenIntoUrl("https://github.com/owner/repo.git", "tk")).toBe( - "https://x-access-token:tk@github.com/owner/repo.git" - ); - }); - - it("uses oauth2 for gitlab", () => { - expect(injectTokenIntoUrl("https://gitlab.com/owner/repo.git", "tk")).toBe( - "https://oauth2:tk@gitlab.com/owner/repo.git" - ); - }); - - it("uses x-token-auth for bitbucket", () => { - expect(injectTokenIntoUrl("https://bitbucket.org/owner/repo.git", "tk")).toBe( - "https://x-token-auth:tk@bitbucket.org/owner/repo.git" - ); - }); - - it("falls back to bare-token form for unknown hosts", () => { - expect(injectTokenIntoUrl("https://example.com/owner/repo.git", "tk")).toBe( - "https://tk@example.com/owner/repo.git" - ); - }); - - it("matches a known forge on a subdomain of it", () => { - expect(injectTokenIntoUrl("https://gist.github.com/owner/repo.git", "tk")).toBe( - "https://x-access-token:tk@gist.github.com/owner/repo.git" - ); - }); - - it("does not treat a known host appearing in the path as that host", () => { - expect(injectTokenIntoUrl("https://evil.example/github.com/owner/repo.git", "tk")).toBe( - "https://tk@evil.example/github.com/owner/repo.git" - ); - }); - - it("does not treat a host merely ending in a known host's name as that host", () => { - expect(injectTokenIntoUrl("https://notgithub.com/owner/repo.git", "tk")).toBe( - "https://tk@notgithub.com/owner/repo.git" - ); - }); - - it("does not treat a known host in the query string as that host", () => { - expect(injectTokenIntoUrl("https://example.com/repo.git?from=gitlab.com", "tk")).toBe( - "https://tk@example.com/repo.git?from=gitlab.com" - ); - }); - - it("leaves a string that is not a parseable URL untouched", () => { - expect(injectTokenIntoUrl("https://", "tk")).toBe("https://"); +import { gitHttpAuthentication } from "../../../src/runtime/git/inject-token.js"; + +describe("Git HTTP authentication", () => { + it("passes a GitHub token separately from the clean URL", () => { + expect(gitHttpAuthentication("https://github.com/owner/repo.git", "tk")).toEqual({ + url: "https://github.com/owner/repo.git", + authorization: `Authorization: Basic ${Buffer.from("x-access-token:tk").toString("base64")}`, + }); + }); + + it.each([ + "https://gitlab.com/owner/repo.git", + "https://bitbucket.org/owner/repo.git", + "https://dev.azure.com/org/repo.git", + "https://gist.github.com/owner/repo.git", + "https://evil.example/github.com/owner/repo.git", + "https://notgithub.com/owner/repo.git", + "https://github.com.attacker.example/repo.git", + "https://example.com/repo.git?from=github.com", + "http://github.com/owner/repo.git", + "git@github.com:owner/repo.git", + ])("does not send a resolved token to %s", (url) => { + expect(gitHttpAuthentication(url, "tk")).toEqual({ url }); + }); + + it("removes and decodes explicit HTTP userinfo without storing it in the URL", () => { + expect( + gitHttpAuthentication("https://user:p%40ss@part@example.com/repo.git", undefined) + ).toEqual({ + url: "https://example.com/repo.git", + authorization: `Authorization: Basic ${Buffer.from("user:p@ss@part").toString("base64")}`, + }); + }); + + it("removes explicit credentials even when the HTTPS scheme is uppercase", () => { + expect(gitHttpAuthentication("HTTPS://user:secret@example.com/repo.git", undefined)).toEqual({ + url: "https://example.com/repo.git", + authorization: `Authorization: Basic ${Buffer.from("user:secret").toString("base64")}`, + }); + }); + + it("does not create authentication without a token or userinfo", () => { + expect(gitHttpAuthentication("https://github.com/owner/repo.git", undefined)).toEqual({ + url: "https://github.com/owner/repo.git", + }); + }); + + it.each(["user", ":password"])("accepts partial explicit userinfo", (userinfo) => { + const credentials = userinfo.includes(":") ? userinfo : `${userinfo}:`; + expect(gitHttpAuthentication(`https://${userinfo}@example.com/repo.git`, undefined)).toEqual({ + url: "https://example.com/repo.git", + authorization: `Authorization: Basic ${Buffer.from(credentials).toString("base64")}`, + }); + }); + + it("does not derive HTTP authentication from a non-HTTP URL containing an HTTP URL", () => { + const url = "ssh://git@example.com/path/https://other.example/repo.git"; + expect(gitHttpAuthentication(url, "tk")).toEqual({ url }); }); }); diff --git a/cli/tests/runtime/machine-config-directory.integration.test.ts b/cli/tests/runtime/machine-config-directory.integration.test.ts new file mode 100644 index 000000000..ec3405070 --- /dev/null +++ b/cli/tests/runtime/machine-config-directory.integration.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "../../src/runtime/wiring/tools.js"; +import { assertMachineConfigDirectorySupported } from "../../src/runtime/machine-config-directory.js"; + +let home: string; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "machine-config-")); + vi.stubEnv("HOME", home); + vi.stubEnv("XDG_CONFIG_HOME", ""); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); +}); + +describe("global tool configuration directory", () => { + it("allows tools when XDG_CONFIG_HOME is unset", () => { + expect(() => assertMachineConfigDirectorySupported(["kilo", "opencode"])).not.toThrow(); + }); + + it("allows tools when XDG_CONFIG_HOME is the default directory", () => { + vi.stubEnv("XDG_CONFIG_HOME", join(home, ".config")); + expect(() => assertMachineConfigDirectorySupported(["kilo", "opencode"])).not.toThrow(); + }); + + it.skipIf(process.platform === "win32")("accepts a symlink to the default directory", () => { + const expected = join(home, ".config"); + const alias = join(home, "config-alias"); + mkdirSync(expected); + symlinkSync(expected, alias); + vi.stubEnv("XDG_CONFIG_HOME", alias); + expect(() => assertMachineConfigDirectorySupported(["opencode"])).not.toThrow(); + }); + + it.each(["kilo", "opencode"])("refuses %s when its host reads another config root", (tool) => { + vi.stubEnv("XDG_CONFIG_HOME", join(home, "different")); + expect(() => assertMachineConfigDirectorySupported([tool])).toThrow( + `${tool} global installation requires XDG_CONFIG_HOME` + ); + }); + + it("reports both affected tools when checking every registered tool", () => { + vi.stubEnv("XDG_CONFIG_HOME", join(home, "different")); + expect(() => assertMachineConfigDirectorySupported()).toThrow(/kilo/); + expect(() => assertMachineConfigDirectorySupported()).toThrow(/opencode/); + }); + + it("allows unrelated, unselected, and unrecognized tools with a custom XDG root", () => { + vi.stubEnv("XDG_CONFIG_HOME", join(home, "different")); + expect(() => + assertMachineConfigDirectorySupported(["claude", "codex", "cursor", "copilot"]) + ).not.toThrow(); + expect(() => assertMachineConfigDirectorySupported([])).not.toThrow(); + expect(() => assertMachineConfigDirectorySupported(["unknown"])).not.toThrow(); + }); +}); diff --git a/cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts b/cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts index 0a2aafc15..a5296912a 100644 --- a/cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts +++ b/cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts @@ -125,6 +125,186 @@ describe("GitHubReleaseResolverAdapter", () => { }); }); + describe("resolvePublishedRelease", () => { + it("checks the selected release by tag without relying on a paginated list", async () => { + const http = makeHttp({ tag_name: "v5.13.0", draft: false }); + const adapter = new GitHubReleaseResolverAdapter(http, { resolve: async () => "token" }); + + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ title: null }); + expect(http.get).toHaveBeenCalledWith( + "https://api.github.com/repos/owner/repo/releases/tags/v5.13.0", + { token: "token" } + ); + }); + + it.each([ + { tag_name: "v5.13.0", draft: true }, + { tag_name: "v5.12.0", draft: false }, + { tag_name: "v5.13.0" }, + null, + ])("refuses an unverified or draft release: %j", async (body) => { + const adapter = new GitHubReleaseResolverAdapter(makeHttp(body)); + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toBeNull(); + }); + + it("reports a missing release without claiming it is published", async () => { + const adapter = new GitHubReleaseResolverAdapter( + makeHttpThrowing(new HttpNotFoundError("missing")) + ); + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toBeNull(); + }); + + it("surfaces rate limits instead of treating them as a missing release", async () => { + const adapter = new GitHubReleaseResolverAdapter( + makeHttpThrowing(new AuthenticationError("HTTP 403")) + ); + await expect(adapter.resolvePublishedRelease(REPO, "v5.13.0")).rejects.toThrow( + CatalogFetchAuthError + ); + }); + + it("uses the release video asset title without forwarding credentials", async () => { + const http = makeHttp({ + tag_name: "v5.13.0", + draft: false, + name: "v5.13.0", + assets: [{ name: "release-video.json" }], + }); + const fetchAsset = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ title: "Framework - Release 5.13\nNouveautés" }), { + status: 200, + }) + ); + const adapter = new GitHubReleaseResolverAdapter( + http, + { resolve: async () => "token" }, + fetchAsset + ); + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ + title: "Framework - Release 5.13 Nouveautés", + }); + expect(http.get).toHaveBeenCalledTimes(1); + expect(fetchAsset).toHaveBeenCalledWith( + "https://github.com/owner/repo/releases/download/v5.13.0/release-video.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(fetchAsset.mock.calls[0][1]?.headers).toBeUndefined(); + }); + + it("finds the video asset among other assets and ignores invalid entries", async () => { + const fetchAsset = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ title: "Video title" }), { status: 200 })); + const adapter = new GitHubReleaseResolverAdapter( + makeHttp({ + tag_name: "v5.13.0", + draft: false, + name: "Fallback", + assets: [null, { name: "notes.txt" }, { name: "release-video.json" }], + }), + undefined, + fetchAsset + ); + + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ + title: "Video title", + }); + expect(fetchAsset).toHaveBeenCalledTimes(1); + }); + + it("does not fetch a video when the release has only unrelated assets", async () => { + const fetchAsset = vi.fn(); + const adapter = new GitHubReleaseResolverAdapter( + makeHttp({ + tag_name: "v5.13.0", + draft: false, + name: "Fallback", + assets: [null, { name: "notes.txt" }], + }), + undefined, + fetchAsset + ); + + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ + title: "Fallback", + }); + expect(fetchAsset).not.toHaveBeenCalled(); + }); + + it("ignores an unsuccessful video response even when it has a title", async () => { + const adapter = new GitHubReleaseResolverAdapter( + makeHttp({ + tag_name: "v5.13.0", + draft: false, + name: "Fallback", + assets: [{ name: "release-video.json" }], + }), + undefined, + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ title: "Error page title" }), { status: 404 }) + ) + ); + + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ + title: "Fallback", + }); + }); + + it("normalizes control characters and bounds the title shown in the terminal", async () => { + const adapter = new GitHubReleaseResolverAdapter( + makeHttp({ + tag_name: "v5.13.0", + draft: false, + name: ` First\n\u001bSecond ${"x".repeat(200)} `, + }) + ); + + expect(await adapter.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ + title: `First Second ${"x".repeat(167)}`, + }); + }); + + it("uses a descriptive release name when the optional asset is absent or fails", async () => { + const absentFetch = vi.fn(); + const absent = new GitHubReleaseResolverAdapter( + makeHttp({ tag_name: "v5.13.0", draft: false, name: "Release 5.13" }), + undefined, + absentFetch + ); + expect(await absent.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ + title: "Release 5.13", + }); + expect(absentFetch).not.toHaveBeenCalled(); + const failed = new GitHubReleaseResolverAdapter( + makeHttp({ + tag_name: "v5.13.0", + draft: false, + name: "v5.13.0", + assets: [{ name: "release-video.json" }], + }), + undefined, + vi.fn().mockRejectedValue(new Error("offline")) + ); + expect(await failed.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ title: null }); + + const failedWithName = new GitHubReleaseResolverAdapter( + makeHttp({ + tag_name: "v5.13.0", + draft: false, + name: "Release 5.13", + assets: [{ name: "release-video.json" }], + }), + undefined, + vi.fn().mockRejectedValue(new Error("offline")) + ); + expect(await failedWithName.resolvePublishedRelease(REPO, "v5.13.0")).toEqual({ + title: "Release 5.13", + }); + }); + }); + describe("isRepoPublic", () => { it("returns true when the unauthenticated repo request succeeds (public)", async () => { const http = makeHttp({ full_name: REPO, private: false }); diff --git a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts new file mode 100644 index 000000000..62c4329fd --- /dev/null +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -0,0 +1,1701 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ResolveMarketplaceUseCase } from "../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceRegistryAdapter } from "../../../src/contexts/distribution/infrastructure/marketplace-registry-adapter.js"; +import { PluginFetcherAdapter } from "../../../src/contexts/distribution/infrastructure/plugin-fetcher-adapter.js"; +import { PluginInstallFromMarketplaceUseCase } from "../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { EnsureBuiltMarketplaceUseCase } from "../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { recordedProjectHookEntries } from "../../../src/contexts/framework/application/shared/remove-project-hooks.js"; +import { Manifest } from "../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { antigravityProjectHooksFormat } from "../../../src/contexts/tools/domain/profiles/antigravity/antigravity-hooks.js"; +import { FileHash, InstallationFile } from "../../../src/kernel/file.js"; +import { FileAdapter } from "../../../src/runtime/filesystem/file-adapter.js"; +import { CurrentVersionAdapter } from "../../../src/runtime/self-update/current-version-adapter.js"; +import { GitHubReleaseResolverAdapter } from "../../../src/runtime/self-update/github-release-resolver-adapter.js"; +import { createDeps } from "../../../src/runtime/wiring/framework.js"; + +const hash = (content: string) => createHash("md5").update(content).digest("hex"); + +describe("framework release update wiring", () => { + let root: string; + const confirm = vi.fn(async () => true); + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-wired-upgrade-")); + vi.stubEnv("AIDD_USER_CONFIG_DIR", join(root, "home")); + vi.stubEnv("HOME", join(root, "home")); + vi.stubEnv("CLAUDE_CONFIG_DIR", ""); + const cataloguePath = join(root, ".aidd/cache/built/aidd-framework/claude"); + await mkdir(cataloguePath, { recursive: true }); + const claudeRegistry = join(root, "home/.claude/plugins/known_marketplaces.json"); + await mkdir(dirname(claudeRegistry), { recursive: true }); + await writeFile( + claudeRegistry, + JSON.stringify({ "aidd-framework": { installLocation: cataloguePath } }) + ); + confirm.mockClear(); + const pluginManifest = join(root, "plugins/aidd-context/.claude-plugin/plugin.json"); + await mkdir(dirname(pluginManifest), { recursive: true }); + await writeFile(pluginManifest, JSON.stringify({ name: "aidd-context", version: "2.10.0" })); + vi.spyOn(GitHubReleaseResolverAdapter.prototype, "resolvePublishedRelease").mockResolvedValue({ + title: "Release title", + }); + vi.spyOn(ResolveMarketplaceUseCase.prototype, "execute").mockImplementation( + async ({ marketplace }) => ({ + marketplace, + localPath: root, + catalog: { + version: "5.13.0", + plugins: [ + { + name: "aidd-context", + source: { kind: "local", path: "plugins/aidd-context" }, + recommended: false, + strict: true, + }, + ], + }, + }) + ); + await new MarketplaceRegistryAdapter().save( + root, + Marketplace.fromJSON({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework", ref: "v5.12.0" }, + scope: "project", + addedAt: "2026-01-01", + version: "5.12.0", + }) + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + }); + + async function seed( + toolId: "antigravity" | "claude" | "cursor" | "opencode", + options: { files?: Record; mcpEntries?: Record } = {} + ) { + const deps = await createDeps(root, { verbose: false }); + const manifest = Manifest.create(); + manifest.addTool(toolId, "5.6.0", []); + const plugin = InstalledPlugin.fromMetadata( + "aidd-context", + "2.9.0", + { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + }, + true, + "project", + "aidd-framework" + ); + manifest.addPlugin(toolId, InstalledPlugin.fromJSON({ ...plugin.toJSON(), ...options })); + await deps.manifestRepo.save(manifest); + return deps; + } + + it.each(["user-only", "project-only", "other project's local scope"] as const)( + "refuses Claude %s before modifying any installation", + async (reason) => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + const ref = "aidd-context@aidd-framework"; + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry", source: join(root, "catalogue") }, + }, + ], + pluginRefs: [ref], + }); + await deps.manifestRepo.save(manifest); + const registryPath = join(root, "home/.claude/plugins/installed_plugins.json"); + await mkdir(dirname(registryPath), { recursive: true }); + const entries = + reason === "user-only" + ? [{ scope: "user" }] + : reason === "project-only" + ? [{ scope: "project", projectPath: root }] + : [{ scope: "local", projectPath: join(root, "other") }]; + const plugins = { [ref]: entries }; + await writeFile(registryPath, JSON.stringify({ plugins })); + const registry = join(root, ".aidd/marketplaces.json"); + const beforeRegistry = await readFile(registry, "utf8"); + const beforeManifest = JSON.stringify((await deps.manifestRepo.load())?.toJSON()); + const beforeMachine = JSON.stringify((await deps.userManifestRepo.load())?.toJSON()); + const untouched = join(root, ".claude/settings.local.json"); + await mkdir(dirname(untouched), { recursive: true }); + await writeFile(untouched, "user settings"); + const install = vi + .spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute") + .mockRejectedValue(new Error("installation must not start")); + + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/claude.*local installation was not found/i); + expect(confirm).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(await readFile(registry, "utf8")).toBe(beforeRegistry); + expect(JSON.stringify((await deps.manifestRepo.load())?.toJSON())).toBe(beforeManifest); + expect(JSON.stringify((await deps.userManifestRepo.load())?.toJSON())).toBe(beforeMachine); + expect(await readFile(untouched, "utf8")).toBe("user settings"); + } + ); + + it("accepts Claude's registered local scope for this project before confirmation", async () => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + const ref = "aidd-context@aidd-framework"; + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry", source: join(root, "catalogue") }, + }, + ], + pluginRefs: [ref], + }); + await deps.manifestRepo.save(manifest); + const registryPath = join(root, "home/.claude/plugins/installed_plugins.json"); + await mkdir(dirname(registryPath), { recursive: true }); + await writeFile( + registryPath, + JSON.stringify({ plugins: { [ref]: [{ scope: "local", projectPath: root }] } }) + ); + const decline = vi.fn(async () => false); + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: decline, + }); + expect(decline).toHaveBeenCalledOnce(); + expect(decline).toHaveBeenCalledWith( + expect.objectContaining({ + shared: false, + impact: [{ ref, scope: "local", projectPath: await realpath(root) }], + }) + ); + expect(result.cancelled).toBe(true); + }); + + it.each(["same plugin user", "another project local", "another plugin local"] as const)( + "requires shared approval for Claude %s and shows its exact impact", + async (reason) => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + const ref = "aidd-context@aidd-framework"; + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: [], + }); + await deps.manifestRepo.save(manifest); + const nativeRegistry = join(root, "home/.claude/plugins/installed_plugins.json"); + await mkdir(dirname(nativeRegistry), { recursive: true }); + const local = { scope: "local", projectPath: root }; + const second = + reason === "same plugin user" + ? { scope: "user" } + : { + scope: "local", + projectPath: reason === "another project local" ? join(root, "other") : root, + }; + const otherRef = reason === "another plugin local" ? "other@aidd-framework" : ref; + const plugins = + otherRef === ref ? { [ref]: [local, second] } : { [ref]: [local], [otherRef]: [second] }; + await writeFile(nativeRegistry, JSON.stringify({ plugins })); + const beforeNative = await readFile(nativeRegistry, "utf8"); + const marketplace = join(root, ".aidd/marketplaces.json"); + const beforeMarketplace = await readFile(marketplace, "utf8"); + const beforeManifest = JSON.stringify((await deps.manifestRepo.load())?.toJSON()); + const install = vi + .spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute") + .mockRejectedValue(new Error("installation must not start")); + + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/allow-shared/); + const otherImpact = + reason === "same plugin user" + ? { ref, scope: "user" } + : { + ref: otherRef, + scope: "local", + projectPath: + reason === "another project local" ? join(root, "other") : await realpath(root), + }; + expect(confirm).toHaveBeenCalledOnce(); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + shared: true, + impact: [{ ref, scope: "local", projectPath: await realpath(root) }, otherImpact], + }) + ); + expect(install).not.toHaveBeenCalled(); + expect(await readFile(nativeRegistry, "utf8")).toBe(beforeNative); + expect(await readFile(marketplace, "utf8")).toBe(beforeMarketplace); + expect(JSON.stringify((await deps.manifestRepo.load())?.toJSON())).toBe(beforeManifest); + } + ); + + it("uses the native marketplace name when it differs from the AIDD alias", async () => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + const hostName = "native-catalogue"; + const ref = `aidd-context@${hostName}`; + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { alias: "another-marketplace", hostName: "another-native-catalogue" }, + { alias: "aidd-framework", hostName }, + ], + pluginRefs: [ref], + }); + await deps.manifestRepo.save(manifest); + await writeFile( + join(root, "home/.claude/plugins/known_marketplaces.json"), + JSON.stringify({ + [hostName]: { installLocation: join(root, ".aidd/cache/built/aidd-framework/claude") }, + }) + ); + const nativeRegistry = join(root, "home/.claude/plugins/installed_plugins.json"); + await writeFile( + nativeRegistry, + JSON.stringify({ plugins: { [ref]: [{ scope: "local", projectPath: root }] } }) + ); + const decline = vi.fn(async () => false); + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: decline, + }); + expect(result.cancelled).toBe(true); + expect(decline).toHaveBeenCalledWith( + expect.objectContaining({ + shared: false, + impact: [{ ref, scope: "local", projectPath: await realpath(root) }], + }) + ); + const install = vi + .spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute") + .mockRejectedValue(new Error("installation reached")); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow("installation reached"); + expect(install).toHaveBeenCalledOnce(); + }); + + it("previews a local Claude plugin using a shared user marketplace", async () => { + const deps = await seed("claude"); + const registry = new MarketplaceRegistryAdapter(); + const projectMarketplace = (await registry.list(root)).find((m) => m.name === "aidd-framework"); + if (!projectMarketplace) throw new Error("missing project marketplace"); + await registry.delete(root, "aidd-framework", "project"); + await registry.save( + root, + Marketplace.fromJSON({ ...projectMarketplace.toJSON(), scope: "user" }) + ); + const userBuild = join( + root, + "home/cache/built", + new CurrentVersionAdapter().get(), + "aidd-framework/claude" + ); + await mkdir(userBuild, { recursive: true }); + await writeFile( + join(root, "home/.claude/plugins/known_marketplaces.json"), + JSON.stringify({ "aidd-framework": { installLocation: userBuild } }) + ); + const ref = "aidd-context@aidd-framework"; + await writeFile( + join(root, "home/.claude/plugins/installed_plugins.json"), + JSON.stringify({ plugins: { [ref]: [{ scope: "local", projectPath: root }] } }) + ); + const decline = vi.fn(async () => false); + + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: decline, + }); + expect(result.cancelled).toBe(true); + expect(decline).toHaveBeenCalledWith( + expect.objectContaining({ + shared: true, + impact: [{ ref, scope: "local", projectPath: await realpath(root) }], + }) + ); + }); + + it("refuses duplicate native marketplace identities before confirmation or changes", async () => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { alias: "aidd-framework", hostName: "first" }, + { alias: "aidd-framework", hostName: "second" }, + ], + pluginRefs: [], + }); + await deps.manifestRepo.save(manifest); + const marketplace = join(root, ".aidd/marketplaces.json"); + const beforeMarketplace = await readFile(marketplace, "utf8"); + const beforeManifest = JSON.stringify((await deps.manifestRepo.load())?.toJSON()); + const install = vi + .spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute") + .mockRejectedValue(new Error("installation must not start")); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: true, + confirm, + }) + ).rejects.toThrow(/Cannot verify Claude marketplace identity/); + expect(confirm).not.toHaveBeenCalled(); + expect(install).not.toHaveBeenCalled(); + expect(await readFile(marketplace, "utf8")).toBe(beforeMarketplace); + expect(JSON.stringify((await deps.manifestRepo.load())?.toJSON())).toBe(beforeManifest); + }); + + it("shows shared Claude installations before confirmation without requiring plugin ownership claims", async () => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + const ref = "aidd-context@aidd-framework"; + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + }, + ], + pluginRefs: [], + }); + await deps.manifestRepo.save(manifest); + const registryPath = join(root, "home/.claude/plugins/installed_plugins.json"); + await mkdir(dirname(registryPath), { recursive: true }); + await writeFile( + registryPath, + JSON.stringify({ + plugins: { + [ref]: [{ scope: "local", projectPath: root }, { scope: "user" }], + "other@aidd-framework": [{ scope: "project", projectPath: join(root, "other") }], + }, + }) + ); + const decline = vi.fn(async () => false); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: decline, + }); + expect(result.cancelled).toBe(true); + expect(decline).toHaveBeenCalledWith( + expect.objectContaining({ + shared: true, + impact: [ + { ref, scope: "local", projectPath: await realpath(root) }, + { ref, scope: "user" }, + { ref: "other@aidd-framework", scope: "project", projectPath: join(root, "other") }, + ], + }) + ); + expect(await readFile(registry, "utf8")).toBe(before); + }); + + it("refuses a shared Claude update without shared approval or when the inventory changed", async () => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + const ref = "aidd-context@aidd-framework"; + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry", source: join(root, "catalogue") }, + }, + ], + pluginRefs: [], + }); + await deps.manifestRepo.save(manifest); + const nativeRegistry = join(root, "home/.claude/plugins/installed_plugins.json"); + await mkdir(dirname(nativeRegistry), { recursive: true }); + const plugins = { [ref]: [{ scope: "local", projectPath: root }, { scope: "user" }] }; + await writeFile(nativeRegistry, JSON.stringify({ plugins })); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + const beforeManifest = JSON.stringify((await deps.manifestRepo.load())?.toJSON()); + const install = vi + .spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute") + .mockRejectedValue(new Error("installation must not start")); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/allow-shared/); + expect(confirm).toHaveBeenCalledOnce(); + expect(await readFile(registry, "utf8")).toBe(before); + expect(install).not.toHaveBeenCalled(); + const changedPlugins = { ...plugins, "new@aidd-framework": [{ scope: "user" }] }; + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: true, + confirm: async () => { + await writeFile(nativeRegistry, JSON.stringify({ plugins: changedPlugins })); + return true; + }, + }) + ).rejects.toThrow(/installations changed after the preview/); + expect(await readFile(registry, "utf8")).toBe(before); + expect(await readFile(nativeRegistry, "utf8")).toBe( + JSON.stringify({ plugins: changedPlugins }) + ); + expect(JSON.stringify((await deps.manifestRepo.load())?.toJSON())).toBe(beforeManifest); + expect(install).not.toHaveBeenCalled(); + await writeFile(nativeRegistry, JSON.stringify({ plugins })); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: true, + confirm: async () => { + await writeFile( + join(root, "home/.claude/plugins/known_marketplaces.json"), + JSON.stringify({ "aidd-framework": { installLocation: "/another/source" } }) + ); + return true; + }, + }) + ).rejects.toThrow(/source changed after the preview/); + expect(await readFile(registry, "utf8")).toBe(before); + expect(JSON.stringify((await deps.manifestRepo.load())?.toJSON())).toBe(beforeManifest); + expect(install).not.toHaveBeenCalled(); + }); + + it("refuses a different Claude marketplace source before any write or confirmation", async () => { + const deps = await seed("claude"); + await writeFile( + join(root, "home/.claude/plugins/known_marketplaces.json"), + JSON.stringify({ "aidd-framework": { installLocation: "/different/source" } }) + ); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry", source: "/different/source" }, + }, + ], + pluginRefs: [], + }); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: true, + confirm, + }) + ).rejects.toThrow(/Cannot verify Claude marketplace source/); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(registry, "utf8")).toBe(before); + }); + + it("refuses when both Claude's source and its recorded provenance change after preview", async () => { + const deps = await seed("claude"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing project manifest"); + const registration = { + binary: "claude", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry" as const, source: join(root, "catalogue") }, + }, + ], + pluginRefs: [], + }; + manifest.setNativeRegistrations("claude", registration); + await deps.manifestRepo.save(manifest); + const nativeRegistry = join(root, "home/.claude/plugins/installed_plugins.json"); + await mkdir(dirname(nativeRegistry), { recursive: true }); + await writeFile( + nativeRegistry, + JSON.stringify({ + plugins: { + "aidd-context@aidd-framework": [{ scope: "local", projectPath: root }], + }, + }) + ); + const marketplace = join(root, ".aidd/marketplaces.json"); + const before = await readFile(marketplace, "utf8"); + const install = vi + .spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute") + .mockRejectedValue(new Error("installation must not start")); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: true, + confirm: async () => { + const current = await deps.manifestRepo.load(); + if (!current) throw new Error("missing manifest"); + current.setNativeRegistrations("claude", { + ...registration, + marketplaces: [ + { + ...registration.marketplaces[0], + provenance: { kind: "registry", source: "/another/source" }, + }, + ], + }); + await deps.manifestRepo.save(current); + await writeFile( + join(root, "home/.claude/plugins/known_marketplaces.json"), + JSON.stringify({ "aidd-framework": { installLocation: "/another/source" } }) + ); + return true; + }, + }) + ).rejects.toThrow(/source changed after the preview/); + expect(await readFile(marketplace, "utf8")).toBe(before); + expect(install).not.toHaveBeenCalled(); + }); + + it("refuses a custom Claude config before modifying the marketplace", async () => { + vi.stubEnv("CLAUDE_CONFIG_DIR", join(root, "custom-claude")); + const deps = await seed("claude"); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/custom CLAUDE_CONFIG_DIR/); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(registry, "utf8")).toBe(before); + }); + + async function fixtureDistribution() { + const pluginRoot = join(root, "remote-plugin"); + const builtDir = join(root, "built"); + const rel = ".opencode/skills/aidd-context/01-demo/SKILL.md"; + const updated = "---\nname: 01-demo\ndescription: new\n---\nnew\n"; + await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true }); + await mkdir(join(pluginRoot, "skills/01-demo"), { recursive: true }); + await writeFile( + join(pluginRoot, ".claude-plugin/plugin.json"), + JSON.stringify({ name: "aidd-context", version: "2.10.0" }) + ); + await writeFile(join(pluginRoot, "skills/01-demo/SKILL.md"), updated); + await mkdir(dirname(join(builtDir, rel)), { recursive: true }); + await writeFile(join(builtDir, rel), updated); + vi.spyOn(PluginFetcherAdapter.prototype, "fetch").mockResolvedValue(pluginRoot); + vi.spyOn(EnsureBuiltMarketplaceUseCase.prototype, "execute").mockResolvedValue({ + builtDir, + version: "5.13.0", + rebuilt: true, + }); + return { rel, updated }; + } + + it("refuses a project-scoped record for a tool whose installation is user-scoped", async () => { + const deps = await seed("cursor"); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/scope differs/); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("refuses an edited tracked file before confirmation or changing the source", async () => { + const rel = ".opencode/skills/aidd-context/SKILL.md"; + const file = join(root, rel); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, "person's edit"); + const deps = await seed("opencode", { files: { [rel]: hash("original") } }); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(rel); + expect(await readFile(file, "utf8")).toBe("person's edit"); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("refuses an older plugin version from the actual target manifest before changing the source", async () => { + const deps = await seed("opencode"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + const plugin = manifest.getPlugins("opencode")[0]; + const data = plugin.toJSON(); + data.version = "3.0.0"; + manifest.removePlugin("opencode", plugin.name); + manifest.addPlugin("opencode", InstalledPlugin.fromJSON(data)); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/downgrade.*3\.0\.0.*2\.10\.0/i); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("refuses a target manifest whose plugin name does not match the installed plugin", async () => { + await writeFile( + join(root, "plugins/aidd-context/.claude-plugin/plugin.json"), + JSON.stringify({ name: "another-plugin", version: "2.10.0" }) + ); + const deps = await seed("opencode"); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/Cannot verify the target plugin 'aidd-context'/); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("refuses to reinstall a plugin removed from the manifest while awaiting confirmation", async () => { + await fixtureDistribution(); + const deps = await seed("opencode"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: async () => { + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + manifest.removePlugin("opencode", "aidd-context"); + await deps.manifestRepo.save(manifest); + return true; + }, + }) + ).rejects.toThrow(/Plugin aidd-context disappeared before installation/); + expect((await deps.manifestRepo.load())?.getPlugins("opencode")).toHaveLength(0); + }); + + it("refuses an edited shared OpenCode MCP contribution before changing the source", async () => { + const edited = JSON.stringify({ mcp: { mine: { type: "remote" } } }); + const file = join(root, "opencode.json"); + await writeFile(file, edited); + const deps = await seed("opencode", { + mcpEntries: { mine: hash(JSON.stringify({ type: "local" })) }, + }); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/MCP.*edited/); + expect(await readFile(file, "utf8")).toBe(edited); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + }); + + it.each(["tool file", "merge file"] as const)( + "refuses a target already claimed as another tool's %s", + async (kind) => { + const { rel } = await fixtureDistribution(); + const file = join(root, rel); + const existing = "other installation's content"; + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, existing); + const deps = await seed("opencode"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + const tracked = new InstallationFile({ + relativePath: rel, + content: existing, + hash: new FileHash(hash(existing)), + }); + manifest.addTool( + "kilo", + "5.6.0", + kind === "tool file" ? [tracked] : [], + kind === "merge file" ? [{ relativePath: rel, sectionKey: null, entries: {} }] : [] + ); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/claimed by another installation/i); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(file, "utf8")).toBe(existing); + expect(await readFile(registry, "utf8")).toBe(before); + } + ); + + it("refuses two incoming plugins that resolve to the same destination", async () => { + await fixtureDistribution(); + const otherName = "aidd-context-extra"; + const otherManifest = join(root, "plugins", otherName, ".claude-plugin", "plugin.json"); + await mkdir(dirname(otherManifest), { recursive: true }); + await writeFile(otherManifest, JSON.stringify({ name: otherName, version: "2.10.0" })); + const overlap = join(root, "built/.opencode/agents/aidd-context-extra-shared.md"); + await mkdir(dirname(overlap), { recursive: true }); + await writeFile(overlap, "agent for the more specific plugin"); + vi.mocked(ResolveMarketplaceUseCase.prototype.execute).mockImplementation( + async ({ marketplace }) => ({ + marketplace, + localPath: root, + catalog: { + version: "5.13.0", + plugins: ["aidd-context", otherName].map((name) => ({ + name, + source: { kind: "local" as const, path: `plugins/${name}` }, + recommended: false, + strict: true, + })), + }, + }) + ); + const deps = await seed("opencode"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromMetadata( + otherName, + "2.9.0", + { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: `plugins/${otherName}`, + ref: "v5.12.0", + }, + true, + "project", + "aidd-framework" + ) + ); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/claimed by another plugin/i); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(registry, "utf8")).toBe(before); + expect(existsSync(join(root, ".opencode/agents/aidd-context-extra-shared.md"))).toBe(false); + }); + + it("preserves a newly created target file if it appears after confirmation", async () => { + const { rel } = await fixtureDistribution(); + const file = join(root, rel); + const deps = await seed("opencode"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: async () => { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, "created while approving"); + return true; + }, + }) + ).rejects.toThrow(/untracked/i); + expect(await readFile(file, "utf8")).toBe("created while approving"); + expect((await deps.manifestRepo.load())?.getPlugins("opencode")[0].source).toMatchObject({ + ref: "v5.12.0", + }); + }); + + it("removes its preview build and leaves the installation unchanged when cancelled", async () => { + await fixtureDistribution(); + const deps = await seed("opencode"); + const registry = join(root, ".aidd/marketplaces.json"); + const manifest = join(root, ".aidd/manifest.json"); + const beforeRegistry = await readFile(registry, "utf8"); + const beforeManifest = await readFile(manifest, "utf8"); + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: async () => false, + }); + expect(result.cancelled).toBe(true); + expect(await readFile(registry, "utf8")).toBe(beforeRegistry); + expect(await readFile(manifest, "utf8")).toBe(beforeManifest); + const builds = vi.mocked(EnsureBuiltMarketplaceUseCase.prototype.execute).mock.calls; + expect(builds).toHaveLength(1); + const preview = builds[0]?.[0]; + if (!preview) throw new Error("missing preview build"); + expect(preview.marketplace.source).toMatchObject({ ref: "v5.13.0" }); + expect(preview.marketplace.scope).toBe("project"); + expect(existsSync(preview.projectRoot)).toBe(false); + }); + + it("does not report an update when an installer fails to record the requested release", async () => { + await fixtureDistribution(); + const deps = await seed("opencode"); + const [marketplace] = await new MarketplaceRegistryAdapter().list(root); + if (!marketplace) throw new Error("missing marketplace"); + vi.spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute").mockResolvedValue({ + marketplace, + entry: { + name: "aidd-context", + source: { kind: "local", path: "plugins/aidd-context" }, + recommended: false, + strict: true, + }, + }); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/Unable to verify the installed source/i); + expect((await deps.manifestRepo.load())?.getPlugins("opencode")[0].source).toMatchObject({ + ref: "v5.12.0", + }); + }); + + it("preserves obsolete files if the manifest disappears during installation", async () => { + await fixtureDistribution(); + const oldRel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const oldPath = join(root, oldRel); + await mkdir(dirname(oldPath), { recursive: true }); + await writeFile(oldPath, "still-owned content"); + const deps = await seed("opencode", { files: { [oldRel]: hash("still-owned content") } }); + const [marketplace] = await new MarketplaceRegistryAdapter().list(root); + if (!marketplace) throw new Error("missing marketplace"); + vi.spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute").mockImplementation( + async () => { + await deps.manifestRepo.delete(); + return { + marketplace, + entry: { + name: "aidd-context", + source: { kind: "local", path: "plugins/aidd-context" }, + recommended: false, + strict: true, + }, + }; + } + ); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/Unable to verify the installed source/i); + expect(await readFile(oldPath, "utf8")).toBe("still-owned content"); + }); + + it.each([ + { + field: "version", + version: "2.9.0", + source: { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.13.0", + }, + }, + { field: "source kind", version: "2.10.0", source: { kind: "local", path: "/other" } }, + { + field: "repository", + version: "2.10.0", + source: { + kind: "git-subdir", + url: "https://github.com/example/other.git", + path: "plugins/aidd-context", + ref: "v5.13.0", + }, + }, + { + field: "plugin path", + version: "2.10.0", + source: { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/other", + ref: "v5.13.0", + }, + }, + { + field: "release ref", + version: "2.10.0", + source: { + kind: "git-subdir", + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/aidd-context", + ref: "v5.12.0", + }, + }, + ])( + "refuses to report success if the installed $field is different", + async ({ version, source }) => { + await fixtureDistribution(); + const oldRel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const oldPath = join(root, oldRel); + await mkdir(dirname(oldPath), { recursive: true }); + await writeFile(oldPath, "still-owned content"); + const deps = await seed("opencode", { files: { [oldRel]: hash("still-owned content") } }); + const [marketplace] = await new MarketplaceRegistryAdapter().list(root); + if (!marketplace) throw new Error("missing marketplace"); + vi.spyOn(PluginInstallFromMarketplaceUseCase.prototype, "execute").mockImplementation( + async () => { + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + const [old] = manifest.getPlugins("opencode"); + if (!old) throw new Error("missing plugin"); + manifest.updatePlugin( + "opencode", + InstalledPlugin.fromJSON({ ...old.toJSON(), version, source, files: {} }) + ); + await deps.manifestRepo.save(manifest); + return { + marketplace, + entry: { + name: "aidd-context", + source: { kind: "local", path: "plugins/aidd-context" }, + recommended: false, + strict: true, + }, + }; + } + ); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/Unable to verify the installed source/i); + expect(await readFile(oldPath, "utf8")).toBe("still-owned content"); + } + ); + + it("updates only the recorded tool and its existing tracked file", async () => { + const { rel, updated } = await fixtureDistribution(); + const old = "---\nname: 01-demo\ndescription: old\n---\nold\n"; + const file = join(root, rel); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, old); + const deps = await seed("opencode", { files: { [rel]: hash(old) } }); + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }); + expect(result.updated).toEqual(["opencode:aidd-context"]); + expect(await readFile(file, "utf8")).toBe(updated); + const manifest = await deps.manifestRepo.load(); + expect(manifest?.getPlugins("opencode")[0].source).toMatchObject({ ref: "v5.13.0" }); + expect(manifest?.getPlugins("opencode")[0].version).toBe("2.10.0"); + expect(manifest?.getPlugins("kilo")).toHaveLength(0); + }); + + it.each(["EPERM", "EACCES", "EIO"])( + "reports %s on the project hooks directory before updating anything", + async (code) => { + const { rel } = await fixtureDistribution(); + const hooks = join(root, "plugins/aidd-context/hooks"); + await mkdir(hooks, { recursive: true }); + await writeFile(join(hooks, "hooks.json"), JSON.stringify({ hooks: {} })); + const hooksFile = join(root, ".agents/hooks.json"); + await mkdir(dirname(hooksFile), { recursive: true }); + await writeFile(hooksFile, "{}"); + const pluginFile = join(root, rel); + await mkdir(dirname(pluginFile), { recursive: true }); + await writeFile(pluginFile, "old plugin"); + const deps = await seed("antigravity"); + const manifest = await deps.manifestRepo.load(); + const plugin = manifest?.getPlugins("antigravity")[0]; + if (!manifest || !plugin) throw new Error("missing plugin"); + manifest.addTool("opencode", "5.6.0", []); + manifest.addPlugin("opencode", plugin.withFiles(new Map([[rel, hash("old plugin")]]))); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const beforeRegistry = await readFile(registry, "utf8"); + const beforeManifest = await readFile(join(root, ".aidd/manifest.json"), "utf8"); + const error = Object.assign(new Error("directory access failed"), { code }); + vi.spyOn(FileAdapter.prototype, "assertWritableDirectory").mockRejectedValue(error); + + const update = deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }); + if (code === "EIO") await expect(update).rejects.toBe(error); + else + await expect(update).rejects.toThrow( + `Cannot write project hooks directory '${dirname(hooksFile)}' (${code})` + ); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(registry, "utf8")).toBe(beforeRegistry); + expect(await readFile(join(root, ".aidd/manifest.json"), "utf8")).toBe(beforeManifest); + expect(await readFile(pluginFile, "utf8")).toBe("old plugin"); + expect(await readFile(hooksFile, "utf8")).toBe("{}"); + } + ); + + it.each(["plugin directory", "project root"] as const)( + "checks the nearest existing parent of an incoming project hook script: %s", + async (parent) => { + await fixtureDistribution(); + const hooks = join(root, "plugins/aidd-context/hooks/nested"); + await mkdir(hooks, { recursive: true }); + await writeFile(join(dirname(hooks), "hooks.json"), JSON.stringify({ hooks: {} })); + await writeFile(join(hooks, "new.cjs"), "release hook"); + const directory = parent === "project root" ? root : join(root, ".agents/hooks/aidd-context"); + await mkdir(directory, { recursive: true }); + const deps = await seed("antigravity"); + const manifest = await deps.manifestRepo.load(); + const plugin = manifest?.getPlugins("antigravity")[0]; + if (!manifest || !plugin) throw new Error("missing plugin"); + manifest.updatePlugin( + "antigravity", + InstalledPlugin.withProjectHooks(plugin, { entries: [], scripts: new Map() }) + ); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const beforeRegistry = await readFile(registry, "utf8"); + vi.spyOn(FileAdapter.prototype, "assertWritableDirectory").mockImplementation( + async (path) => { + if (path === directory) throw Object.assign(new Error("denied"), { code: "EPERM" }); + } + ); + + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(`Cannot write project hooks directory '${directory}' (EPERM)`); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(registry, "utf8")).toBe(beforeRegistry); + } + ); + + it("checks the directory of an obsolete tracked hook before updating", async () => { + await fixtureDistribution(); + const relative = ".agents/hooks/aidd-context/old.cjs"; + const oldHook = join(root, relative); + await mkdir(dirname(oldHook), { recursive: true }); + await writeFile(oldHook, "old hook"); + const deps = await seed("antigravity"); + const manifest = await deps.manifestRepo.load(); + const plugin = manifest?.getPlugins("antigravity")[0]; + if (!manifest || !plugin) throw new Error("missing plugin"); + manifest.updatePlugin( + "antigravity", + InstalledPlugin.withProjectHooks(plugin, { + entries: [], + scripts: new Map([[relative, hash("old hook")]]), + }) + ); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const beforeRegistry = await readFile(registry, "utf8"); + vi.spyOn(FileAdapter.prototype, "assertWritableDirectory").mockImplementation(async (path) => { + if (path === dirname(oldHook)) throw Object.assign(new Error("denied"), { code: "EACCES" }); + }); + + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(`Cannot write project hooks directory '${dirname(oldHook)}' (EACCES)`); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(registry, "utf8")).toBe(beforeRegistry); + expect(await readFile(oldHook, "utf8")).toBe("old hook"); + }); + + it("allows a new project hooks directory when its existing parent is writable", async () => { + await fixtureDistribution(); + const hooksJson = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "node hook.cjs" }] }] }, + }); + for (const pluginRoot of ["plugins/aidd-context", "remote-plugin"]) { + const hooks = join(root, pluginRoot, "hooks"); + await mkdir(hooks, { recursive: true }); + await writeFile(join(hooks, "hooks.json"), hooksJson); + await writeFile(join(hooks, "new.cjs"), "release hook"); + } + const deps = await seed("antigravity"); + + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }); + expect(result.updated).toEqual(["antigravity:aidd-context"]); + expect(await readFile(join(root, ".agents/hooks/aidd-context/new.cjs"), "utf8")).toBe( + "release hook" + ); + }); + + it("refuses an incoming project hook script collision before changing the marketplace", async () => { + await fixtureDistribution(); + const hooks = join(root, "plugins/aidd-context/hooks"); + await mkdir(hooks, { recursive: true }); + await writeFile(join(hooks, "hooks.json"), JSON.stringify({ hooks: {} })); + await writeFile(join(hooks, "new.cjs"), "release hook"); + const file = join(root, ".agents/hooks/aidd-context/new.cjs"); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, "user hook"); + const deps = await seed("antigravity"); + const manifest = await deps.manifestRepo.load(); + const plugin = manifest?.getPlugins("antigravity")[0]; + if (!manifest || !plugin) throw new Error("missing plugin"); + manifest.updatePlugin( + "antigravity", + InstalledPlugin.withProjectHooks(plugin, { entries: [], scripts: new Map() }) + ); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/untracked/); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(file, "utf8")).toBe("user hook"); + expect(await readFile(registry, "utf8")).toBe(before); + }); + + it("updates project hooks while preserving an existing shared hooks file", async () => { + await fixtureDistribution(); + const hooksJson = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "node hook.cjs" }] }] }, + }); + for (const pluginRoot of ["plugins/aidd-context", "remote-plugin"]) { + const hooks = join(root, pluginRoot, "hooks"); + await mkdir(hooks, { recursive: true }); + await writeFile(join(hooks, "hooks.json"), hooksJson); + await writeFile(join(hooks, "new.cjs"), "release hook"); + } + const sharedHooks = join(root, ".agents/hooks.json"); + await mkdir(dirname(sharedHooks), { recursive: true }); + const sharedContent = antigravityProjectHooksFormat.merge( + JSON.stringify({ user: { SessionStart: [{ command: "echo user" }] } }), + hooksJson, + "aidd-context" + ).content; + await writeFile(sharedHooks, sharedContent); + const existingHook = ".agents/hooks/aidd-context/new.cjs"; + const hookPath = join(root, existingHook); + await mkdir(dirname(hookPath), { recursive: true }); + await writeFile(hookPath, "old release hook"); + const deps = await seed("antigravity"); + const manifest = await deps.manifestRepo.load(); + const plugin = manifest?.getPlugins("antigravity")[0]; + if (!manifest || !plugin) throw new Error("missing plugin"); + manifest.updatePlugin( + "antigravity", + InstalledPlugin.withProjectHooks(plugin, { + entries: recordedProjectHookEntries( + antigravityProjectHooksFormat, + sharedContent, + "aidd-context" + ), + scripts: new Map([[existingHook, hash("old release hook")]]), + }) + ); + await deps.manifestRepo.save(manifest); + + const result = await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }); + expect(result.updated).toEqual(["antigravity:aidd-context"]); + expect(await readFile(hookPath, "utf8")).toBe("release hook"); + expect(JSON.parse(await readFile(sharedHooks, "utf8")).user).toEqual({ + SessionStart: [{ command: "echo user" }], + }); + }); + + it("preserves an untracked file if the target release introduces the same path", async () => { + const { rel } = await fixtureDistribution(); + const file = join(root, rel); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, "person's existing file"); + const deps = await seed("opencode"); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/untracked/); + expect(await readFile(file, "utf8")).toBe("person's existing file"); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + expect((await deps.manifestRepo.load())?.getPlugins("opencode")[0].source).toMatchObject({ + ref: "v5.12.0", + }); + }); + + it("removes files owned by the old release that do not exist in the target", async () => { + const { rel, updated } = await fixtureDistribution(); + const oldRel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const oldPath = join(root, oldRel); + const unrelated = join(root, ".opencode/skills/aidd-context/01-demo/user.md"); + await mkdir(dirname(oldPath), { recursive: true }); + await writeFile(oldPath, "old release"); + await writeFile(unrelated, "user file"); + const deps = await seed("opencode", { files: { [oldRel]: hash("old release") } }); + await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }); + await expect(readFile(oldPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(unrelated, "utf8")).toBe("user file"); + expect(await readFile(join(root, rel), "utf8")).toBe(updated); + expect((await deps.manifestRepo.load())?.getPlugins("opencode")[0].files.has(oldRel)).toBe( + false + ); + }); + + it("preserves an obsolete file edited after the user approves", async () => { + await fixtureDistribution(); + const oldRel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const oldPath = join(root, oldRel); + await mkdir(dirname(oldPath), { recursive: true }); + await writeFile(oldPath, "original content"); + const deps = await seed("opencode", { files: { [oldRel]: hash("original content") } }); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm: async () => { + await writeFile(oldPath, "user edit during approval"); + return true; + }, + }) + ).rejects.toThrow(/modified or missing/i); + expect(await readFile(oldPath, "utf8")).toBe("user edit during approval"); + }); + + it("reports failure if an obsolete file could not actually be removed", async () => { + await fixtureDistribution(); + const oldRel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const oldPath = join(root, oldRel); + await mkdir(dirname(oldPath), { recursive: true }); + await writeFile(oldPath, "original content"); + const deps = await seed("opencode", { files: { [oldRel]: hash("original content") } }); + const remove = deps.fs.deleteFile.bind(deps.fs); + vi.spyOn(deps.fs, "deleteFile").mockImplementation(async (path) => { + if (path !== oldPath) await remove(path); + }); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/Cannot remove obsolete file/i); + expect(await readFile(oldPath, "utf8")).toBe("original content"); + }); + + it.each(["tool file", "merge file"] as const)( + "preserves an obsolete file still claimed as another tool's %s", + async (kind) => { + await fixtureDistribution(); + const oldRel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const oldPath = join(root, oldRel); + const content = "shared file"; + await mkdir(dirname(oldPath), { recursive: true }); + await writeFile(oldPath, content); + const deps = await seed("opencode", { files: { [oldRel]: hash(content) } }); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + const file = new InstallationFile({ + relativePath: oldRel, + content, + hash: new FileHash(hash(content)), + }); + manifest.addTool( + "kilo", + "5.6.0", + kind === "tool file" ? [file] : [], + kind === "merge file" ? [{ relativePath: oldRel, sectionKey: null, entries: {} }] : [] + ); + await deps.manifestRepo.save(manifest); + await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }); + expect(await readFile(oldPath, "utf8")).toBe(content); + const result = await deps.manifestRepo.load(); + expect(result?.getPlugins("opencode")[0].files.has(oldRel)).toBe(false); + expect(result?.isFileTracked(oldRel)).toBe(true); + } + ); + + it("refuses a file also owned by another plugin in the same tool", async () => { + const { rel } = await fixtureDistribution(); + const file = join(root, rel); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, "shared file"); + const deps = await seed("opencode", { files: { [rel]: hash("shared file") } }); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + const other = InstalledPlugin.fromMetadata( + "other", + "1.0.0", + { kind: "local", path: "/other" }, + true, + "project" + ); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ ...other.toJSON(), files: { [rel]: hash("shared file") } }) + ); + await deps.manifestRepo.save(manifest); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/claimed by another/i); + expect(await readFile(file, "utf8")).toBe("shared file"); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("refuses to overwrite a path also claimed by another plugin", async () => { + const { rel } = await fixtureDistribution(); + const file = join(root, rel); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, "shared file"); + const deps = await seed("opencode", { files: { [rel]: hash("shared file") } }); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + manifest.addTool("kilo", "5.6.0", []); + const other = InstalledPlugin.fromMetadata( + "other", + "1.0.0", + { kind: "local", path: "/other" }, + true, + "project" + ); + manifest.addPlugin( + "kilo", + InstalledPlugin.fromJSON({ ...other.toJSON(), files: { [rel]: hash("shared file") } }) + ); + await deps.manifestRepo.save(manifest); + const before = await readFile(join(root, ".aidd/marketplaces.json"), "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/claimed by another/i); + expect(await readFile(file, "utf8")).toBe("shared file"); + expect(await readFile(join(root, ".aidd/marketplaces.json"), "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("preserves an obsolete path still owned by another plugin", async () => { + await fixtureDistribution(); + const oldRel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const oldPath = join(root, oldRel); + await mkdir(dirname(oldPath), { recursive: true }); + await writeFile(oldPath, "shared file"); + const deps = await seed("opencode", { files: { [oldRel]: hash("shared file") } }); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + manifest.addTool("kilo", "5.6.0", []); + const other = InstalledPlugin.fromMetadata( + "other", + "1.0.0", + { kind: "local", path: "/other" }, + true, + "project" + ); + manifest.addPlugin( + "kilo", + InstalledPlugin.fromJSON({ ...other.toJSON(), files: { [oldRel]: hash("shared file") } }) + ); + await deps.manifestRepo.save(manifest); + await deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }); + expect(await readFile(oldPath, "utf8")).toBe("shared file"); + expect((await deps.manifestRepo.load())?.getPlugins("kilo")[0].files.has(oldRel)).toBe(true); + }); + + it.skipIf(process.platform === "win32")( + "refuses a tracked hook script replaced by a symlink before changing the marketplace", + async () => { + const rel = ".opencode/hooks/aidd-context/start.cjs"; + const script = join(root, rel); + const userFile = join(root, "user-script.cjs"); + await mkdir(dirname(script), { recursive: true }); + await writeFile(userFile, "original hook"); + await symlink(userFile, script); + const deps = await seed("opencode"); + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new Error("missing manifest"); + const plugin = manifest.getPlugins("opencode")[0]; + manifest.removePlugin("opencode", plugin.name); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + ...plugin.toJSON(), + projectHooks: { entries: [], scripts: { [rel]: hash("original hook") } }, + }) + ); + await deps.manifestRepo.save(manifest); + const marketplace = join(root, ".aidd/marketplaces.json"); + const before = await readFile(marketplace, "utf8"); + + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/symlink/i); + expect(confirm).not.toHaveBeenCalled(); + expect(await readFile(marketplace, "utf8")).toBe(before); + expect(await readFile(userFile, "utf8")).toBe("original hook"); + } + ); + + it.skipIf(process.platform === "win32")( + "refuses a broken symlink at an incoming target path before moving the marketplace", + async () => { + const { rel } = await fixtureDistribution(); + const file = join(root, rel); + await mkdir(dirname(file), { recursive: true }); + await symlink(join(root, "missing"), file); + const deps = await seed("opencode"); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/symlink|untracked/i); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + } + ); + + it.skipIf(process.platform === "win32")( + "refuses a tracked file replaced by a matching symlink before moving the marketplace", + async () => { + const { rel } = await fixtureDistribution(); + const file = join(root, rel); + const target = join(root, "user-file.md"); + await mkdir(dirname(file), { recursive: true }); + await writeFile(target, "old release"); + await symlink(target, file); + const deps = await seed("opencode", { files: { [rel]: hash("old release") } }); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/symlink/i); + expect(await readFile(registry, "utf8")).toBe(before); + expect(await readFile(target, "utf8")).toBe("old release"); + expect(confirm).not.toHaveBeenCalled(); + } + ); + + it.skipIf(process.platform === "win32")( + "refuses an obsolete tracked file replaced by a matching symlink before moving the marketplace", + async () => { + await fixtureDistribution(); + const rel = ".opencode/skills/aidd-context/01-demo/obsolete.md"; + const file = join(root, rel); + const target = join(root, "user-file.md"); + await mkdir(dirname(file), { recursive: true }); + await writeFile(target, "old release"); + await symlink(target, file); + const deps = await seed("opencode", { files: { [rel]: hash("old release") } }); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/symlink/i); + expect(await readFile(registry, "utf8")).toBe(before); + expect(await readFile(target, "utf8")).toBe("old release"); + expect(confirm).not.toHaveBeenCalled(); + } + ); + + it.skipIf(process.platform === "win32")( + "refuses a broken symlink in an incoming target's parent before moving the marketplace", + async () => { + await fixtureDistribution(); + const dir = join(root, ".opencode"); + await mkdir(dir, { recursive: true }); + await symlink(join(root, "missing"), join(dir, "skills")); + const deps = await seed("opencode"); + const registry = join(root, ".aidd/marketplaces.json"); + const before = await readFile(registry, "utf8"); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).rejects.toThrow(/symlink/i); + expect(await readFile(registry, "utf8")).toBe(before); + expect(confirm).not.toHaveBeenCalled(); + } + ); +}); diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 18b85698c..dc4eb9dd3 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -16,10 +16,7 @@ export default defineConfig({ include: ["tests/**/*.unit.test.ts"], globals: false, environment: "node", - globalSetup: [ - "./tests/helpers/sweep-stale-temp-dirs.ts", - "./tests/helpers/throwaway-profile.ts", - ], + globalSetup: ["./tests/helpers/throwaway-profile.ts"], }, }, { @@ -43,10 +40,7 @@ export default defineConfig({ testTimeout: 60000, // A `beforeEach` that spawns git is slower than the 10s default on a loaded Windows runner. hookTimeout: 60000, - globalSetup: [ - "./tests/helpers/sweep-stale-temp-dirs.ts", - "./tests/helpers/throwaway-profile.ts", - ], + globalSetup: ["./tests/helpers/throwaway-profile.ts"], }, }, { @@ -58,7 +52,7 @@ export default defineConfig({ globals: false, environment: "node", testTimeout: 60000, - globalSetup: ["./tests/e2e/global-setup.ts", "./tests/helpers/sweep-stale-temp-dirs.ts"], + globalSetup: ["./tests/e2e/global-setup.ts"], }, }, ], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2c13f4d95..7f7d4484c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,6 +42,7 @@ Every other plugin delegates to it. | `aidd-ui` | UI and UX design | | `aidd-qa` | Acceptance QA with video evidence | | `aidd-telemetry` | Token measurement | +| `aidd-maintenance` | Framework version and update discovery | ## How a skill runs diff --git a/docs/CATALOG.md b/docs/CATALOG.md index 8add1ff7a..022910802 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -11,6 +11,7 @@ The exhaustive list of AIDD plugins, skills, and actions. Skills are invoked thr - [aidd-ui](#-aidd-ui) - UI / UX (🚧 alpha, not ready) - [aidd-telemetry](#-aidd-telemetry) - token measurement for Claude Code and Codex (🧪 beta, off the curated path) - [aidd-qa](#-aidd-qa) - acceptance QA (🔭 preview, off the curated path) +- [aidd-maintenance](#-aidd-maintenance) - framework version and updates --- @@ -174,3 +175,21 @@ Locks a scenario scope from acceptance criteria, runs it against a reviewed cand | Skill | Role | Actions | | ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- | | `01-acceptance-qa` | Lock scenarios from acceptance criteria, run them, and report a verdict per scenario | `01-load-scope`, `02-prerequisites`, `03-prepare-run`, `04-run-scenarios` | + +## 🔄 aidd-maintenance + +Reports the installed marketplace version and available updates. + +| Skill | Role | Actions | +| --- | --- | --- | +| `01-update` | Update the installed framework | `01-update` | + + + +**Hooks** + +| Event | Runs | +| --- | --- | +| `SessionStart` | `hooks/session-start.cjs` | + + diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 626a43a9f..c4bbdf74a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -28,9 +28,17 @@ It asks for your tool and your plugins, then installs them in your project. *Example for Claude Code, without the questions:* ```sh -npx @ai-driven-dev/cli@latest setup --ai claude --plugins recommended --yes +npx @ai-driven-dev/cli@latest setup --ide claude --plugins recommended --yes ``` +### For every project + +```sh +npx @ai-driven-dev/cli@latest setup --global --ide claude,codex,opencode --plugins all --yes +``` + +It installs the plugins once for the whole machine and writes nothing in your project. It takes any of `claude`, `codex`, `copilot`, `cursor`, `kilo` and `opencode`: [what each one gets](../cli/README.md#scope). + ### Check ```sh @@ -146,6 +154,7 @@ cp -R aidd-framework--flat-/. . | Installed with | Run | | --- | --- | | CLI | `npx @ai-driven-dev/cli@latest plugin update` | +| CLI, every project | `npx @ai-driven-dev/cli@latest sync --global` | | Claude Code marketplace | `claude plugin update @aidd-framework` | | Flat files | The same `translate` command, with `--force` | @@ -157,6 +166,7 @@ For another tool's marketplace, use that tool's plugin manager. | --- | --- | | CLI, one plugin | `npx @ai-driven-dev/cli@latest plugin remove ` | | CLI, everything | `npx @ai-driven-dev/cli@latest clean` previews. Add `--force` to delete | +| CLI, every project | `npx @ai-driven-dev/cli@latest clean --global --force` | | Claude Code marketplace | `claude plugin uninstall ` | | Flat files | Delete the files you copied | diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index b62d89f9c..e8c24281d 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -51,6 +51,13 @@ Only `/aidd-orchestrator:01-sdlc` names an agent, by its role (`validator`), so Install the `aidd-orchestrator` plugin: `/plugin install aidd-orchestrator@aidd-framework` in Claude Code, or see the [quick start](../README.md#quick-start) for the other tools. The agents come with it, except as a Codex plugin (see [Limits](#-limits)); open a new session so the tool loads them. +For every project on the machine at once: + +```bash +aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all +aidd sync --global # after a framework update +``` + ## 🧠 Models No model is pinned. Each host applies its own default for subagents, usually the model of the session that called them. A model you declare in an agent reaches every host in its own format. @@ -58,7 +65,7 @@ No model is pinned. Each host applies its own default for subagents, usually the ## 🚧 Limits - **Tools:** every agent inherits the calling session's tools and permissions; what it writes is an instruction, not a restriction. -- **Codex:** a Codex plugin cannot carry agents, so only the flat distribution installs them, in `.codex/agents/`. Codex loads them only in a trusted project. +- **Codex:** a Codex plugin cannot carry agents, so only the flat distribution installs them, in `.codex/agents/`, and Codex loads them only in a trusted project; `aidd setup --global` puts them in `~/.codex/agents/` instead. - **Models:** OpenCode V1 reads the same file but not the `#` variant. - **Updates:** reinstalling replaces the agents still shipped; one removed from the plugin stays where a copy put it until you delete it. diff --git a/docs/assets/plugins/plugins.json b/docs/assets/plugins/plugins.json index d3d89004e..71f3987a9 100644 --- a/docs/assets/plugins/plugins.json +++ b/docs/assets/plugins/plugins.json @@ -43,5 +43,10 @@ "status": "preview", "description": "Acceptance QA with video evidence", "emoji": "🎬" + }, + "aidd-maintenance": { + "status": "preview", + "description": "Framework version and updates", + "emoji": "🔄" } } diff --git a/plugins/aidd-maintenance/.claude-plugin/plugin.json b/plugins/aidd-maintenance/.claude-plugin/plugin.json new file mode 100644 index 000000000..b48ec706f --- /dev/null +++ b/plugins/aidd-maintenance/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "aidd-maintenance", + "version": "0.1.0", + "description": "Maintenance of an AI-Driven Development installation: release notices and updates.", + "author": { + "name": "AI-Driven Dev", + "url": "https://github.com/ai-driven-dev" + }, + "skills": ["./skills/01-update"], + "keywords": ["maintenance", "updates", "releases"], + "repository": "https://github.com/ai-driven-dev/framework", + "homepage": "https://ai-driven.dev", + "license": "MIT" +} diff --git a/plugins/aidd-maintenance/CATALOG.md b/plugins/aidd-maintenance/CATALOG.md new file mode 100644 index 000000000..bc6b32917 --- /dev/null +++ b/plugins/aidd-maintenance/CATALOG.md @@ -0,0 +1,39 @@ +# aidd-maintenance catalog + +Auto-generated index of skills, agents, references and assets shipped by the `aidd-maintenance` plugin. + +> This file is automatically updated by the `scripts/summarize-markdown.js` script. + +## Table of Contents + +- [`.claude-plugin`](#claude-plugin) +- [`hooks`](#hooks) +- [`skills`](#skills) + - [`skills/01-update`](#skills01-update) + +--- + +### `.claude-plugin` + +| File | +|------| +| [plugin.json](.claude-plugin/plugin.json) | + +### `hooks` + +| File | +|------| +| [hooks.json](hooks/hooks.json) | +| [kilo-tui.js](hooks/kilo-tui.js) | +| [opencode-server.js](hooks/opencode-server.js) | +| [opencode-tui.js](hooks/opencode-tui.js) | + +### `skills` + +#### `skills/01-update` + +| Group | File | Description | +|-------|------|---| +| `references` | [native-commands.md](skills/01-update/references/native-commands.md) | - | +| `-` | [SKILL.md](skills/01-update/SKILL.md) | `Update an installed AI-Driven Development framework and its plugins. Use when asked to upgrade AIDD, review an available release, or bring an installed framework up to date. Not for updating unrelated project dependencies.` | + diff --git a/plugins/aidd-maintenance/README.md b/plugins/aidd-maintenance/README.md new file mode 100644 index 000000000..168fbd36d --- /dev/null +++ b/plugins/aidd-maintenance/README.md @@ -0,0 +1,26 @@ +# aidd-maintenance + +Release notices and updates for an AI-Driven Development installation. + +The startup hook reports the registered marketplace version and the latest release, cached for 24 hours with its verification time. For a local source, the official release is an indicative reference: the local content may differ even when versions match. A marketplace version does not establish the versions of every installed plugin. + +Use the update skill to inspect project and global installations, choose a release, and update supported targets. + +## Compatibility + +| Tool | AIDD native marketplace | Startup evidence | +| --- | --- | --- | +| Claude Code 2.1.318 | Yes | Message shown at startup | +| Codex 0.166.0 | Yes | Message shown after the first prompt; not in `exec --json` | +| Cursor Agent | Yes | Display unverified: test account reached its usage limit | +| GitHub Copilot CLI | Yes | Message shown in TUI; the binary reports 1.0.98 and the interface 1.0.105 | +| OpenCode 2.2.5 | No | Toast at TUI launch and resume; `aidd run` also shows a headless notice | +| Kilo Code 7.8.18 | No | Toast at TUI launch and resume; `aidd run` also shows a headless notice | + +After inventory and confirmation, the skill can update CLI-managed project installations and global OpenCode/Kilo installations pinned to an official release. Cursor's CLI-managed installation is user-scoped: changing its pinned source is not yet supported. Direct native updates depend on the host's marketplace commands; a catalogue refresh alone does not verify installed plugins. + +The headless launcher writes the notice to stderr and passes the arguments and stdout to the real tool. It refuses remote servers and ambiguous project/global or overridden configurations. Direct `opencode run` and `kilo run` do not show the notice. + +Copilot CLI display uses its `COPILOT_CLI` subprocess environment variable; VS Code display is unverified. OpenCode V1 and V2 are plugin API generations, distinct from interactive TUI and non-interactive CLI modes. The current OpenCode plugin has no Desktop GUI entry; the documented [GUI API](https://opencode.ai/v2/docs/build/plugins/gui/) is experimental. The Kilo VS Code extension 7.4.1 backend was tested and does not run the startup notice; no documented GUI notification path was confirmed. Sources read 2026-11-05: [OpenCode plugins](https://opencode.ai/v2/docs/build/plugins), [Kilo plugins](https://kilo.ai/docs/automate/extending/plugins). + +Native formats: [Codex hooks](https://developers.openai.com/codex/hooks/), [Cursor plugins](https://cursor.com/docs/reference/plugins), [Copilot plugins](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference), [hook progress](https://docs.github.com/en/copilot/reference/hooks-reference#progress-messages) and [CLI changelog](https://github.com/github/copilot-cli/blob/main/changelog.md), [OpenCode V2](https://dev.opencode.ai/v2/docs/build/plugins) (read 2026-11-05). diff --git a/plugins/aidd-maintenance/hooks/hooks.json b/plugins/aidd-maintenance/hooks/hooks.json new file mode 100644 index 000000000..c097a0986 --- /dev/null +++ b/plugins/aidd-maintenance/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.cjs\"", + "statusMessage": "AIDD Maintenance: check for updates", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/plugins/aidd-maintenance/hooks/kilo-tui.js b/plugins/aidd-maintenance/hooks/kilo-tui.js new file mode 100644 index 000000000..096bafc18 --- /dev/null +++ b/plugins/aidd-maintenance/hooks/kilo-tui.js @@ -0,0 +1,30 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const script = fileURLToPath(new URL("./session-start.cjs", import.meta.url)); + +export default { + id: "aidd-maintenance-tui", + async tui(api) { + const directory = api.state.path.directory; + if (typeof directory !== "string") return; + const message = await new Promise((resolve) => { + let stdout = ""; + const child = spawn("node", [script], { + cwd: directory, + stdio: ["pipe", "pipe", "ignore"], + timeout: 10000, + }); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.on("error", () => resolve("")); + child.on("close", (code) => { + if (code !== 0) return resolve(""); + try { resolve(String(JSON.parse(stdout)?.systemMessage ?? "").trim()); } + catch { resolve(""); } + }); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify({ hook_event_name: "SessionStart", cwd: directory, tool: "kilo" })); + }); + if (message) api.ui.toast({ message, variant: "info" }); + }, +}; diff --git a/plugins/aidd-maintenance/hooks/opencode-server.js b/plugins/aidd-maintenance/hooks/opencode-server.js new file mode 100644 index 000000000..e7df39bec --- /dev/null +++ b/plugins/aidd-maintenance/hooks/opencode-server.js @@ -0,0 +1,2 @@ +// The V2 loader pairs this server entry with the adjacent TUI entry. +export default { id: "aidd-maintenance-tui-server", setup() {} }; diff --git a/plugins/aidd-maintenance/hooks/opencode-tui.js b/plugins/aidd-maintenance/hooks/opencode-tui.js new file mode 100644 index 000000000..bb9840865 --- /dev/null +++ b/plugins/aidd-maintenance/hooks/opencode-tui.js @@ -0,0 +1,63 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const script = fileURLToPath( + new URL("../../hooks/aidd-maintenance/session-start.cjs", import.meta.url) +); + +export default { + id: "aidd-maintenance-tui", + setup(ctx) { + const sessions = new Set(); + let active = true; + const show = async (directory, sessionId) => { + const message = await new Promise((resolve) => { + let stdout = ""; + const child = spawn("node", [script], { + cwd: directory, + stdio: ["pipe", "pipe", "ignore"], + timeout: 10000, + }); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.on("error", () => resolve("")); + child.on("close", (code) => { + if (code !== 0) return resolve(""); + try { + resolve(String(JSON.parse(stdout)?.systemMessage ?? "").trim()); + } catch { + resolve(""); + } + }); + child.stdin.on("error", () => {}); + child.stdin.end( + JSON.stringify({ + hook_event_name: "SessionStart", + session_id: sessionId, + cwd: directory, + tool: "opencode", + }) + ); + }); + if (message && active) ctx.ui.toast.show({ message, variant: "info" }); + return Boolean(message); + }; + const directory = ctx.location?.directory ?? ctx.data.location?.default?.()?.directory; + const opening = typeof directory === "string" ? show(directory, null) : null; + let firstSession = opening !== null; + const unsubscribe = ctx.data.on("session.created", async (event) => { + const sessionId = event?.data?.sessionID; + const sessionDirectory = event?.data?.location?.directory; + if (typeof sessionId !== "string" || typeof sessionDirectory !== "string" || sessions.has(sessionId)) + return; + sessions.add(sessionId); + if (firstSession && sessionDirectory === directory) { + firstSession = false; + if (await opening) return; + } + await show(sessionDirectory, sessionId); + }); + return () => { active = false; unsubscribe(); }; + }, +}; diff --git a/plugins/aidd-maintenance/hooks/session-start.cjs b/plugins/aidd-maintenance/hooks/session-start.cjs new file mode 100644 index 000000000..81e3c5644 --- /dev/null +++ b/plugins/aidd-maintenance/hooks/session-start.cjs @@ -0,0 +1,444 @@ +#!/usr/bin/env node +// A catalogue version is not a claim about the individual plugins the host loaded. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const OFFICIAL_REPO = 'ai-driven-dev/framework'; + +function json(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } +} + +function configDir(env, home) { + return env.AIDD_USER_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'aidd'); +} + +function projectFile(cwd, name) { + let dir = path.resolve(cwd); + while (true) { + const file = path.join(dir, '.aidd', name); + if (fs.existsSync(file)) return file; + if (fs.existsSync(path.join(dir, '.git'))) return null; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function nativeClaudeCatalogue(env, home) { + const claudePlugins = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), 'plugins'); + const resolveReal = value => { try { return fs.realpathSync(value); } catch { return path.resolve(value); } }; + const claudeCache = resolveReal(path.join(claudePlugins, 'cache', 'aidd-framework')); + const pluginRoot = env.CLAUDE_PLUGIN_ROOT && resolveReal(env.CLAUDE_PLUGIN_ROOT); + if (!pluginRoot) return null; + const native = json(path.join(claudePlugins, 'known_marketplaces.json'))?.['aidd-framework']; + if (!native || typeof native.installLocation !== 'string') return null; + const manifest = json(path.join(native.installLocation, '.claude-plugin', 'marketplace.json')); + if (!manifest || manifest.name !== 'aidd-framework') return null; + const marketplaceRoot = resolveReal(native.installLocation); + const pluginSource = Array.isArray(manifest.plugins) + ? manifest.plugins.find(plugin => plugin?.name === 'aidd-maintenance')?.source : null; + const directRoot = typeof pluginSource === 'string' && !path.isAbsolute(pluginSource) + ? resolveReal(path.join(marketplaceRoot, pluginSource)) : null; + const direct = directRoot && directRoot.startsWith(`${marketplaceRoot}${path.sep}`) && pluginRoot === directRoot; + if (!pluginRoot.startsWith(`${claudeCache}${path.sep}`) && !direct) return null; + const source = native.source?.source === 'github' + ? { kind: 'github', repo: native.source.repo, ref: native.source.ref } + : { kind: 'local' }; + return { version: manifest.version, source, scope:'native' }; +} + +function cliCatalogue(cwd, env, home) { + for (const {file, scope} of [ + {file:projectFile(cwd, 'marketplaces.json'), scope:'project'}, + {file:path.join(configDir(env, home), 'marketplaces.json'), scope:'user'}, + ]) { + if (!file || !fs.existsSync(file)) continue; + const registry = json(file); + if (!registry || !Array.isArray(registry.marketplaces)) return null; + const entry = registry.marketplaces.find(item => item.name === 'aidd-framework'); + if (entry) return { version: entry.version, source: entry.source, scope }; + } + return null; +} + +function catalogue(cwd, env, home, tool) { + // The host's installed plugin owns its native catalogue, even when an unrelated CLI + // installation registered another source in the same project or machine. + if (!tool || tool === 'claude') { + const native = nativeClaudeCatalogue(env, home); + if (native) return native; + } + return cliCatalogue(cwd, env, home); +} + +function semver(value) { + if (typeof value !== 'string') return null; + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value); + if (!match) return null; + return { numbers: match.slice(1, 4).map(Number), prerelease: match[4]?.split('.') ?? [] }; +} + +function releaseTag(value) { + return typeof value === 'string' && value.startsWith('v') ? semver(value) : null; +} + +// The CLI records the project and machine installations in separate manifests. A project can +// reference a user-scoped plugin, but only the machine manifest owns that plugin's version. +function plugins(cwd, env, home, current) { + const manifests = [ + { file: projectFile(cwd, 'manifest.json'), scope: 'project' }, + { file: path.join(configDir(env, home), 'manifest.json'), scope: 'user' }, + ]; + const found = []; let unverified = false; let globalUnverified = false; let hasManifest = false; let machineCount = 0; + const userReferences = new Set(); const machinePlugins = new Set(); + const userRegistry = json(path.join(configDir(env, home), 'marketplaces.json')); + const userEntry = Array.isArray(userRegistry?.marketplaces) + ? userRegistry.marketplaces.find(entry => entry?.name === 'aidd-framework') : null; + const userRef = releaseTag(userEntry?.source?.ref); + const userVersion = semver(userEntry?.version); + const projectRef = releaseTag(current?.source?.ref); + const projectVersion = semver(current?.version); + const projectComparable = current?.source?.kind === 'github' && REPO.test(current.source.repo || '') && + projectRef && projectVersion && compare(projectRef, projectVersion) === 0; + const userComparable = userEntry?.source?.kind === 'github' && REPO.test(userEntry.source.repo || '') && + userRef && userVersion && compare(userRef, userVersion) === 0; + for (const {file, scope} of manifests) { + if (!file || !fs.existsSync(file)) continue; + hasManifest = true; + const markUnverified = () => { if (scope === 'user') globalUnverified = true; else unverified = true; }; + const data = json(file); + if (data?.version !== 8 || !data.tools || typeof data.tools !== 'object' || Array.isArray(data.tools)) { + markUnverified(); + continue; + } + for (const [tool, entry] of Object.entries(data.tools)) { + if (!/^[a-z0-9-]+$/.test(tool) || !entry || typeof entry !== 'object' || Array.isArray(entry)) { markUnverified(); continue; } + if (entry.plugins === undefined) continue; + if (!Array.isArray(entry.plugins)) { markUnverified(); continue; } + for (const plugin of entry.plugins) { + if (!plugin || typeof plugin !== 'object' || Array.isArray(plugin)) { markUnverified(); continue; } + if (plugin.marketplace !== 'aidd-framework') { + const repo = scope === 'user' ? userEntry?.source?.repo : current?.source?.repo; + if (plugin.marketplace === undefined && repo && plugin.source?.kind === 'git-subdir' && + plugin.source.url === `https://github.com/${repo}.git` && + plugin.source.path === `plugins/${plugin.name}`) markUnverified(); + continue; + } + if (scope === 'project' && plugin.scope === 'user') { userReferences.add(`${tool}:${plugin.name}`); continue; } + if (plugin.scope !== scope || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(plugin.name || '')) { + markUnverified(); continue; + } + if (scope === 'user') { machinePlugins.add(`${tool}:${plugin.name}`); machineCount++; } + if (scope === 'user' && !userComparable) { globalUnverified = true; continue; } + if (scope === 'project' && !projectComparable) { + if (current?.source?.kind !== 'local') unverified = true; + continue; + } + const repo = scope === 'user' ? userEntry.source.repo : current.source.repo; + const source = plugin.source; + const version = semver(plugin.version); + const ref = releaseTag(source?.ref); + if (source?.kind !== 'git-subdir' || source.url !== `https://github.com/${repo}.git` || source.path !== `plugins/${plugin.name}` || !version || !ref) { + markUnverified(); + continue; + } + const channel = ref.prerelease[0] || ''; + found.push({tool, name:plugin.name, scope, version:plugin.version, ref:source.ref, repo, channel}); + } + } + } + if ([...userReferences].some(reference => !machinePlugins.has(reference))) globalUnverified = true; + return { found, unverified: unverified || !hasManifest, globalUnverified, machineCount, hasManifest }; +} + +function compare(left, right) { + for (let i = 0; i < 3; i++) { + if (left.numbers[i] !== right.numbers[i]) return left.numbers[i] - right.numbers[i]; + } + if (!left.prerelease.length) return right.prerelease.length ? 1 : 0; + if (!right.prerelease.length) return -1; + for (let i = 0; i < Math.max(left.prerelease.length, right.prerelease.length); i++) { + const a = left.prerelease[i]; const b = right.prerelease[i]; + if (a === undefined) return -1; + if (b === undefined) return 1; + if (a === b) continue; + const an = /^\d+$/.test(a); const bn = /^\d+$/.test(b); + if (an && bn) return Number(a) - Number(b); + if (an !== bn) return an ? -1 : 1; + return a < b ? -1 : 1; + } + return 0; +} + +function clean(value) { + return typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 180) : ''; +} + +async function request(fetcher, url, signal) { + const response = await fetcher(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'aidd-maintenance' }, signal }); + if (!response.ok) throw new Error('Release unavailable'); + return response.json(); +} + +async function releaseTitle(fetcher, repo, tag) { + const encodedRepo = repo.split('/').map(encodeURIComponent).join('/'); + const video = await request(fetcher, `https://github.com/${encodedRepo}/releases/download/${encodeURIComponent(tag)}/release-video.json`, AbortSignal.timeout(1500)); + const title = clean(video.title); + if (!title) throw new Error('Release title unavailable'); + return title; +} + +async function latestRelease(fetcher, repo, channel) { + const encodedRepo = repo.split('/').map(encodeURIComponent).join('/'); + const signal = AbortSignal.timeout(3000); + let selected; + // Component releases can hide the marketplace release; keep walking while a page is full. + for (let page = 1; page <= 5; page++) { + const releases = await request(fetcher, `https://api.github.com/repos/${encodedRepo}/releases?per_page=100&page=${page}`, signal); + if (!Array.isArray(releases)) throw new Error('Invalid releases'); + for (const release of releases) { + const parsed = releaseTag(release.tag_name); + if (!parsed || release.draft) continue; + const releaseChannel = parsed.prerelease[0] || ''; + if (releaseChannel !== channel || (!channel && release.prerelease)) continue; + if (!selected || compare(parsed, selected.parsed) > 0) selected = { release, parsed }; + } + if (releases.length < 100) break; + } + if (!selected) throw new Error('No matching marketplace release'); + const release = selected.release; + let title = clean(release.name) || release.tag_name; + let titlePending = false; + if (Array.isArray(release.assets) && release.assets.some(asset => asset.name === 'release-video.json')) { + try { + title = await releaseTitle(fetcher, repo, release.tag_name); + } catch { titlePending = true; } + } + return { tag: release.tag_name, title, titlePending }; +} + +// Release Please records all plugin versions in one file at the root release tag. It is useful +// only when its root version agrees with that tag; otherwise the target versions are unknown. +async function releasePluginVersions(fetcher, repo, tag) { + if (repo !== OFFICIAL_REPO) return null; + try { + const url = `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(tag)}/.release-please-manifest.json`; + const data = await request(fetcher, url, AbortSignal.timeout(1500)); + if (!data || !semver(data['.']) || compare(semver(data['.']), releaseTag(tag)) !== 0) return null; + const versions = {}; + for (const [key, value] of Object.entries(data)) { + if (/^plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(key) && semver(value)) versions[key.slice(8)] = value; + } + return versions; + } catch { return null; } +} + +function cacheFile(env, home, repo, channel) { + const key = crypto.createHash('sha256').update(`${repo}:${channel}`).digest('hex').slice(0, 16); + return path.join(configDir(env, home), 'cache', `maintenance-${key}.json`); +} + +function saveCache(file, data) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.${process.pid}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(data)); + fs.renameSync(temporary, file); + } catch { /* Cache failures never stop a conversation. */ } +} + +async function releaseState(env, home, now, fetcher, repo, channel) { + const file = cacheFile(env, home, repo, channel); + const cached = json(file); + let latest = null; + let checkedAt = now; let save = false; + if (cached && Number.isFinite(cached.checkedAt) && now >= cached.checkedAt && now - cached.checkedAt < DAY && releaseTag(cached.latest?.tag)) { + latest = cached.latest; + checkedAt = cached.checkedAt; + } else { + try { + latest = await latestRelease(fetcher, repo, channel); + save = true; + } catch { /* The release remains unknown until the next attempt. */ } + } + if (latest) { + if (latest.titlePending === undefined && repo === OFFICIAL_REPO && latest.title === latest.tag) { + latest.titlePending = true; + latest.titleCheckedAt = checkedAt; + save = true; + } + if (latest.titlePending) { + if (save) latest.titleCheckedAt = now; + else if (!Number.isFinite(latest.titleCheckedAt) || now < latest.titleCheckedAt || now - latest.titleCheckedAt >= HOUR) { + try { + latest.title = await releaseTitle(fetcher, repo, latest.tag); + latest.titlePending = false; + } catch { /* Retry the title later without hiding the known release. */ } + latest.titleCheckedAt = now; + save = true; + } + } + } + return { file, latest, checkedAt, save }; +} + +async function status({ cwd = process.cwd(), env = process.env, home = os.homedir(), now = Date.now(), fetcher = fetch, tool } = {}) { + const current = catalogue(cwd, env, home, tool); + const version = semver(current?.version); + const source = current?.source; + const result = { version: version ? current.version.replace(/^v/, '') : null, latest: null, unavailable: false }; + const local = source?.kind === 'local'; + const pinned = releaseTag(source?.ref); + const comparable = version && source?.kind === 'github' && REPO.test(source.repo || '') && pinned && compare(pinned, version) === 0; + if (!local && !comparable) { + if (source?.kind !== 'github' || !REPO.test(source.repo || '')) result.comparisonUnavailable = 'Source du marketplace inconnue : comparaison indisponible'; + else if (!version) result.comparisonUnavailable = 'Version du marketplace inconnue : comparaison indisponible'; + else if (!pinned) result.comparisonUnavailable = 'Source non épinglée à une release : comparaison indisponible'; + else result.comparisonUnavailable = 'Version du marketplace incohérente avec sa source : comparaison indisponible'; + } + const states = new Map(); + const stateFor = (repo, channel) => { + const key = `${repo}:${channel}`; + if (!states.has(key)) states.set(key, releaseState(env, home, now, fetcher, repo, channel)); + return states.get(key); + }; + const currentState = local || comparable + ? stateFor(local ? OFFICIAL_REPO : source.repo, version?.prerelease[0] || '') : null; + result.local = local; + // A native Claude catalogue and the CLI's project/global registrations can be distinct. + const cliCurrent = cliCatalogue(cwd, env, home); + const installed = plugins(cwd, env, home, cliCurrent); + if (installed.hasManifest || (!local && comparable)) result.pluginsUnverified = installed.unverified; + result.globalPluginsUnverified = installed.globalUnverified; + result.pluginUpdates = []; + result.pluginAhead = []; + const comparisons = installed.found.map(async plugin => { + const original = releaseTag(plugin.ref); + const state = await stateFor(plugin.repo, plugin.channel); + if (!state.latest) { + if (plugin.scope === 'user') result.globalPluginsUnverified = true; + else result.pluginsUnverified = true; + return; + } + const target = releaseTag(state.latest.tag); + if (compare(original, target) > 0) { + result.pluginAhead.push({...plugin, targetRef:state.latest.tag}); + return; + } + const due = !Object.hasOwn(state.latest, 'pluginVersions') || + (state.latest.pluginVersions === null && (!Number.isFinite(state.latest.pluginVersionsCheckedAt) || + now < state.latest.pluginVersionsCheckedAt || now - state.latest.pluginVersionsCheckedAt >= HOUR)); + if (due) { + state.versionsPromise ??= releasePluginVersions(fetcher, plugin.repo, state.latest.tag); + state.latest.pluginVersions = await state.versionsPromise; + state.latest.pluginVersionsCheckedAt = now; + state.save = true; + } + const versions = state.latest.pluginVersions; + const rawTargetVersion = versions?.[plugin.name]; + const targetVersion = semver(rawTargetVersion); + if (!targetVersion) { + if (plugin.scope === 'user') result.globalPluginsUnverified = true; + else result.pluginsUnverified = true; + } + if (targetVersion && compare(targetVersion, semver(plugin.version)) < 0) { + result.pluginAhead.push({...plugin, targetRef:state.latest.tag, targetVersion:rawTargetVersion}); + return; + } + if (compare(original, target) < 0 || (targetVersion && compare(targetVersion, semver(plugin.version)) > 0)) + result.pluginUpdates.push({...plugin, targetRef:state.latest.tag, targetVersion:targetVersion ? rawTargetVersion : null}); + }); + if (currentState) { + const state = await currentState; + result.latest = state.latest; + if (state.latest) result.checkedAt = state.checkedAt; + else result.unavailable = true; + } + await Promise.all(comparisons); + if (result.latest && comparable) { + result.hasUpdate = compare(releaseTag(result.latest.tag), version) > 0; + if (result.hasUpdate && current.scope === 'user' && installed.machineCount) { + result.marketplaceUpdate = result.latest.tag; + result.hasUpdate = false; + } + } + if (!result.latest && comparable) result.pluginsUnverified = true; + for (const promise of states.values()) { + const state = await promise; + if (state.save) saveCache(state.file, { checkedAt: state.checkedAt, latest: state.latest }); + } + return result; +} + +function format(result) { + let message = result.version ? `AI-Driven Development ON · marketplace v${result.version}${result.local ? ' (version enregistrée)' : ''}` : 'AI-Driven Development ON · version du marketplace inconnue'; + if (result.latest) { + const checkedAt = new Date(result.checkedAt); + const checked = Number.isFinite(checkedAt.getTime()) ? `\nVérifiée le ${checkedAt.toISOString().slice(0,16).replace('T',' ')} UTC` : ''; + const title = clean(result.latest.title); + const detail = title && title !== result.latest.tag ? ` · ${title}` : result.latest.titlePending ? ' · titre indisponible' : ''; + message += `\n\nDernière release${result.local ? ' officielle' : ''} : ${result.latest.tag}${detail}${checked}`; + } + if (result.local && result.version && result.latest && compare(semver(result.latest.tag), semver(result.version)) > 0) + message += `\nVersion enregistrée : v${result.version} → ${result.latest.tag} (comparaison indicative)`; + if (result.local) message += '\nSource locale : le contenu peut différer de la release officielle'; + if (result.marketplaceUpdate) message += `\nMarketplace partagé : v${result.version} → ${result.marketplaceUpdate} (source inchangée)`; + if (result.hasUpdate) { + message += `\n\nMise à jour disponible : v${result.version} → ${result.latest.tag}\nPour mettre à jour : skill aidd-maintenance · 01-update`; + } + const updates = result.pluginUpdates || []; + const entries = [...updates, ...(result.pluginAhead || []).map(plugin => ({...plugin, ahead:true}))]; + entries.sort((a, b) => { + const left = `${a.scope}:${a.tool}:${a.name}`; const right = `${b.scope}:${b.tool}:${b.name}`; + return left < right ? -1 : left > right ? 1 : 0; + }); + if (entries.length) message += '\n\nPlugins enregistrés par le CLI AIDD :'; + for (const plugin of entries.slice(0, 5)) { + const identity = `${plugin.tool} · ${plugin.name} · ${plugin.scope === 'user' ? 'global' : 'projet'}`; + if (plugin.ahead) { + const conflict = plugin.targetVersion + ? `version enregistrée ${plugin.version} supérieure à ${plugin.targetVersion}` + : `source ${plugin.ref} supérieure à ${plugin.targetRef}`; + message += `\n • ${identity} · ${conflict} : mise à jour non proposée`; + } else { + const targetVersion = plugin.targetVersion ? ` → ${plugin.targetVersion}` : ' → version cible inconnue'; + message += `\n • ${identity} · version enregistrée ${plugin.version}${targetVersion} · source ${plugin.ref} → ${plugin.targetRef}`; + } + } + if (entries.length > 5) message += `\nEt ${entries.length - 5} autre${entries.length === 6 ? '' : 's'} installation${entries.length === 6 ? '' : 's'} à vérifier`; + if (updates.length && !result.hasUpdate) message += '\nPour vérifier : skill aidd-maintenance · 01-update'; + if (result.comparisonUnavailable) message += `\n${result.comparisonUnavailable}`; + if (result.pluginsUnverified) message += '\nÉtat de certains plugins non vérifié'; + if (result.globalPluginsUnverified) message += '\nÉtat des plugins globaux non vérifié'; + if (result.unavailable) message += '\nDernière release indisponible'; + return message; +} + +function hookOutput(message, input) { + // Cursor and Copilot document distinct session payloads and output fields. + if (typeof input?.is_background_agent === 'boolean') return { additional_context: message }; + if (typeof input?.timestamp === 'string' || typeof input?.timestamp === 'number') return { additionalContext: message }; + // Claude and Codex both support systemMessage; Codex rejects unrelated fields. + return { systemMessage: message }; +} + +if (require.main === module) { + let input = {}; + try { input = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { /* An empty or malformed payload is still a valid startup. */ } + const write = message => { + const output = hookOutput(message, input); + if (process.env.COPILOT_CLI !== undefined && Object.hasOwn(output, 'additionalContext')) + process.stdout.write(`${JSON.stringify({type:'progress', message})}\n`); + process.stdout.write(JSON.stringify(output)); + }; + status({tool:input.tool}).then(result => write(format(result))) + .catch(() => write('AI-Driven Development ON · version du marketplace inconnue')); +} + +module.exports = { status, format }; diff --git a/plugins/aidd-maintenance/skills/01-update/SKILL.md b/plugins/aidd-maintenance/skills/01-update/SKILL.md new file mode 100644 index 000000000..7083fe31a --- /dev/null +++ b/plugins/aidd-maintenance/skills/01-update/SKILL.md @@ -0,0 +1,18 @@ +--- +name: 01-update +description: Update an installed AI-Driven Development framework and its plugins. Use when asked to upgrade AIDD, review an available release, or bring an installed framework up to date. Not for updating unrelated project dependencies. +argument-hint: release | project | global +--- + +# Update + +## Rules + +- Inspect `.aidd/manifest.json` and `.aidd/marketplaces.json` in the current project, then `manifest.json` and `marketplaces.json` in `AIDD_USER_CONFIG_DIR` or `${XDG_CONFIG_HOME:-~/.config}/aidd`. Inventory each AIDD plugin's tool, scope, source and version. Inspect native marketplaces separately when the CLI does not own them. Never print credentials. +- Compare only with the configured source. By default, propose stable for stable installs and the same channel for prereleases; offer a different channel only on explicit request. A local directory, moving branch, unknown version or unavailable network cannot establish a release update; report it and leave it unchanged. For a local Git checkout, a diff against a local release tag describes that checkout only; verify the tag against the official repository before treating it as the official release, and include untracked files. Do not claim that checkout content proves which plugin files a host loaded. +- Show installed and available versions, release title and link, affected tools and scope. For each configured official release ref, show the GitHub compare link to the selected release; it describes the whole repository, not only installed plugins. A tag comparison does not establish the installed commit if the tag moved. Ask the user to select a release before changing anything. For shared marketplaces, show known affected projects, note that others may exist, and ask before changing the shared source. Offer global installations separately and show known dependent projects. +- Preserve each plugin's tool, scope and installation method; do not overwrite modified files, force a source change or approve a host prompt for the user. +- For CLI-managed project plugins, after selection use `aidd framework update --release --yes`, with `--allow-shared` only after approval. Never use `setup --plugins all` as an update. If the CLI is missing, explain how to restore it rather than treating its installation as native. +- For CLI-managed global OpenCode and Kilo plugins pinned to an official release, after separate approval use `aidd framework update --release --scope user --yes`. The command leaves native registrations and the shared marketplace unchanged; handle skipped native installations separately. For other global plugins, including Cursor, use `aidd plugin update --scope user` only when the selected source already points to the chosen release; otherwise report the path as unsupported without changing the source manually. +- For native installations, use only the owning tool's named marketplace and exact installed AIDD plugin references. Recheck the installed help and follow the verified pinned-release paths in [native commands](references/native-commands.md), including their scope and source checks. If a host refresh updates every plugin in the marketplace, show the full impact and require approval for all of them before refreshing. For other sources or scopes, report the limitation rather than guessing how to move the release. +- Re-read the installed source and version for every target. Report updated, unchanged, unavailable or failed per tool and scope, with required restart or project refresh. After a partial update, inspect the marketplace and plugin manifests, identify remaining targets, and ask before retrying the selected release; never force an overwrite. Distinguish catalogue, CLI and plugin versions. A source or catalogue refresh alone does not verify plugin updates, skipped components, session reload or removal of files absent from the new release; report any unverified result. diff --git a/plugins/aidd-maintenance/skills/01-update/references/native-commands.md b/plugins/aidd-maintenance/skills/01-update/references/native-commands.md new file mode 100644 index 000000000..02551451f --- /dev/null +++ b/plugins/aidd-maintenance/skills/01-update/references/native-commands.md @@ -0,0 +1,27 @@ +# Native marketplace commands + +Read on 2026-11-05. Commands and support can change; recheck installed help before use. + +- Claude Code: `claude plugin marketplace update aidd-framework`, then `claude plugin update @aidd-framework` for each selected installed reference and its actual scope. Updating a plugin requires a new session. [Claude Code plugin reference](https://code.claude.com/docs/en/cli-reference) +- Copilot CLI: `copilot plugin marketplace update aidd-framework`, then `copilot plugin update @aidd-framework` for each selected installed reference. A directory-source marketplace loads its local files, not a remote release. [Copilot CLI plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference) +- Codex: `codex plugin marketplace upgrade aidd-framework` refreshes a configured Git marketplace; it does not itself establish that every installed plugin changed. [Codex plugin documentation](https://developers.openai.com/plugins/build/plugins) +- Cursor, OpenCode and Kilo: an equivalent direct native release upgrade is unverified. Use their installed tool's own documented flow or report the limitation. + +## GitHub marketplace pinned to an older release + +The update commands above refresh the configured ref; they do not move an old release tag. The following paths were exercised with Claude Code 2.1.318 and Copilot CLI 1.0.105, with an `ai-driven-dev/framework` GitHub marketplace pinned to `v5.12.0`, upgrading to `v5.13.0`. Do not generalize them to other source types. + +After the user selects a verified release and approves the affected marketplace scope, find its single effective, editable `extraKnownMarketplaces.aidd-framework.source` entry. Require `source: "github"`, `repo: "ai-driven-dev/framework"`, and an unambiguous existing release `ref`; refuse managed, conflicting or otherwise ambiguous configuration. Record the installed plugin identities, scopes and enabled states. Re-read the settings immediately before writing and stop if they changed. Preserve all other settings and JSON/JSONC comments; change only that entry's `ref` to the selected tag, or stop if it cannot be preserved. Do not remove the marketplace: Claude removal uninstalled its plugins and warned that their saved data could also be deleted, and Copilot requires force to remove one with installed plugins. + +- Claude Code: the verified marketplace scopes are `user` (`${CLAUDE_CONFIG_DIR:-~/.claude}/settings.json`) and `project` (`.claude/settings.json`). After changing the ref in the owning file, run `claude plugin marketplace add 'ai-driven-dev/framework#' --scope `, then `claude plugin update @aidd-framework --scope ` for each selected installed plugin. Plugin scopes `user`, `project` and `local` were exercised; use each plugin's existing scope. A new session is required. [Claude settings and scopes](https://code.claude.com/docs/en/settings), [CLI reference](https://code.claude.com/docs/en/cli-reference). +- Copilot CLI: the verified marketplace scope is user (`${COPILOT_HOME:-~/.copilot}/settings.json`). After changing the ref there, run `copilot plugin marketplace update aidd-framework`, then `copilot plugin update @aidd-framework` for each selected installed plugin. A disabled plugin stayed disabled in testing. [Copilot configuration](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference), [plugin reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference). + +Re-read the effective source and installed plugins after each operation. If a refresh or update fails, stop and report the changed ref and any plugins still on the old version; do not claim an atomic update. Other marketplace scopes and managed configurations are unverified. + +## Codex GitHub marketplace pinned to an older release + +Codex CLI 0.166.0 was exercised with a user marketplace added by `codex plugin marketplace add ai-driven-dev/framework --ref v5.12.0`. With `aidd-context` and `aidd-dev` installed, changing to `v5.13.0` and refreshing rebuilt both plugin caches, including a disabled plugin; its disabled state was preserved. **Before asking for approval, show every installed plugin from this marketplace, including disabled ones: the refresh updates them together.** It cannot select individual plugins. + +After approval, identify the single effective, editable `[marketplaces.aidd-framework]` entry in `${CODEX_HOME:-~/.codex}/config.toml`. The verified entry has `source_type = "git"`, `source = "https://github.com/ai-driven-dev/framework.git"` and an existing release `ref`. Refuse managed, restricted, conflicting or ambiguous sources. Re-read the configuration immediately before writing and stop if it changed. Preserve all other TOML and comments; change only `ref` to the selected verified tag, or stop if that cannot be preserved. Then run `codex plugin marketplace upgrade aidd-framework --json`. The command can fail after the ref changed: report partial state rather than assuming rollback. Re-read the effective source, installed plugins and their cached manifests; confirm each expected version and enabled state. Start a new session to load the updated plugins. + +The CLI rejected `marketplace add` with a different ref while the old source existed. A protected Codex session may refuse writes to its own configuration; report the restriction and leave it unchanged. The commands were verified for this user GitHub source, but a skill-driven refresh timed out in an isolated session: Codex 0.166.0 imposes a [30-second Git clone timeout](https://github.com/openai/codex/blob/rust-v0.166.0/codex-rs/core-plugins/src/marketplace_upgrade.rs#L30). Other source formats and managed configurations remain unverified. [Codex marketplace commands](https://developers.openai.com/plugins/build/plugins#add-a-marketplace-from-the-cli), [managed source restrictions](https://learn.chatgpt.com/docs/enterprise/managed-configuration#restrict-plugin-marketplace-sources), read 2026-11-05. diff --git a/release-please-config.json b/release-please-config.json index bd7d523ce..10c5845bb 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -116,6 +116,16 @@ } ] }, + "plugins/aidd-maintenance": { + "package-name": "aidd-maintenance", + "extra-files": [ + { + "type": "json", + "path": ".claude-plugin/plugin.json", + "jsonpath": "$.version" + } + ] + }, "cli": { "release-type": "node", "package-name": "@ai-driven-dev/cli" diff --git a/scripts/__tests__/architecture-doc-matches-the-tree.test.js b/scripts/__tests__/architecture-doc-matches-the-tree.test.js index cc7cf5025..5b72cd794 100644 --- a/scripts/__tests__/architecture-doc-matches-the-tree.test.js +++ b/scripts/__tests__/architecture-doc-matches-the-tree.test.js @@ -118,10 +118,10 @@ function hooksTableOf(plugin) { const concernsTable = () => tableWithColumns(DOC, "Plugin", "Owns"); -/** The scripts a plugin's `hooks/hooks.json` runs, relative to the plugin. */ +/** The source manifest uses Claude's plugin root before translation to other hosts. */ function declaredHookScripts(plugin) { const manifest = fs.readFileSync(path.join(ROOT, "plugins", plugin, "hooks/hooks.json"), "utf8"); - return [...new Set([...manifest.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g)].map((m) => m[1]))].sort(); + return [...new Set([...manifest.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"\\]+)/g)].map((m) => m[1]))].sort(); } test("every plugin that declares hooks lists them in its catalog section, and no other does", () => { diff --git a/scripts/__tests__/check-hosts.test.js b/scripts/__tests__/check-hosts.test.js new file mode 100644 index 000000000..1e923835a --- /dev/null +++ b/scripts/__tests__/check-hosts.test.js @@ -0,0 +1,346 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { delimiter, dirname, join, resolve } = require("node:path"); +const { test } = require("node:test"); + +const script = resolve(__dirname, "../check-hosts.cjs"); +const posixOnly = { skip: process.platform === "win32" }; +const MARKER = ""; + +function write(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +/** A framework of one plugin, one skill and one agent, with stubs standing in for the tools. */ +function fixture(stubs) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "aidd-check-hosts-test-"))); + const framework = join(dir, "framework"); + const home = join(dir, "home"); + write(join(framework, "plugins/aidd-x/.claude-plugin/plugin.json"), "{}\n"); + write(join(framework, "plugins/aidd-x/skills/01-a/SKILL.md"), "---\nname: 01-a\n---\n"); + write(join(framework, "plugins/aidd-x/agents/reviewer.md"), "---\nname: reviewer\n---\n"); + mkdirSync(join(dir, "bin"), { recursive: true }); + mkdirSync(home, { recursive: true }); + for (const [name, body] of Object.entries(stubs(home))) { + write(join(dir, "bin", name), `#!/bin/sh\n[ "$1" = --version ] && exit 0\n${body}\n`); + chmodSync(join(dir, "bin", name), 0o755); + } + return { dir, framework, home }; +} + +function check({ dir, framework, home }, root = framework) { + return spawnSync(process.execPath, [script, "--root", root], { + encoding: "utf8", + env: { HOME: home, PATH: [join(dir, "bin"), "/usr/bin", "/bin"].join(delimiter) }, + }); +} + +function scenario(name, stubs, assertions) { + test(name, posixOnly, () => { + const env = fixture(stubs); + try { + assertions(env); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +} + +const kilo = (location) => `case "$1 $2" in + 'debug paths') echo "config $HOME/.config/kilo" ;; + 'debug skill') echo '[{"location":"${location}"}]' ;; + 'debug agent') [ "$3" = aidd-x-reviewer ] && echo '{}' || exit 1 ;; +esac`; + +const opencode = (paths) => `case "$1 $2" in + 'debug paths') echo "$HOME/.config/opencode" ;; + 'api get') echo '{"data":[${paths.map((path) => `{"path":"${path}"}`).join(",")}]}' ;; + 'debug agents') echo '[{"id":"aidd-x-reviewer"}]' ;; +esac`; + +const claude = ({ skills = ["aidd-x:01-a"], plugins = [{ name: "aidd-x" }] } = {}) => { + const init = JSON.stringify({ + type: "system", + subtype: "init", + plugins, + skills, + agents: ["aidd-x:reviewer"], + }); + return `case "$1 $2" in + 'plugin list') echo '[{"id":"aidd-x@aidd-framework","scope":"user","enabled":true}]' ;; + *) echo '${init}' ;; +esac`; +}; + +scenario( + "a tool that is not installed is said so and fails nothing", + () => ({}), + (env) => { + const result = check(env); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\| Kilo \| not installed/); + } +); + +scenario( + "a checkout with no plugin to expect is refused, never a table of 0/0", + () => ({ kilo: "exit 0" }), + (env) => { + const result = check(env, join(env.dir, "nowhere")); + assert.equal(result.status, 2); + assert.match(result.stderr, /No plugin to expect/); + assert.doesNotMatch(result.stdout, /0\/0/); + } +); + +test("Kilo", posixOnly, async (t) => { + await t.test("passes when it answers with everything, proved by the tool", () => { + const env = fixture((home) => ({ kilo: kilo(join(home, ".config/kilo/skills/aidd-x/01-a/SKILL.md")) })); + try { + write(join(env.home, ".config/kilo/skills/aidd-x/01-a/SKILL.md"), "x"); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Kilo \| ✅ 1\/1 \| ➖ none documented \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails on a skill it lists from elsewhere than its config directory", () => { + const env = fixture((home) => ({ kilo: kilo(join(home, "elsewhere/aidd-x/01-a/SKILL.md")) })); + try { + write(join(env.home, "elsewhere/aidd-x/01-a/SKILL.md"), "x"); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ 0\/1, missing aidd-x\/01-a/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails on a skill it lists whose file is gone: a stale answer proves nothing", () => { + const env = fixture((home) => ({ kilo: kilo(join(home, ".config/kilo/skills/aidd-x/01-a/SKILL.md")) })); + try { + assert.equal(check(env).status, 1); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +}); + +test("Claude Code", posixOnly, async (t) => { + const withInstructions = (env) => write(join(env.home, ".claude/CLAUDE.md"), `${MARKER}\n`); + + await t.test("reads plugins, skills and agents from the session's own init event", () => { + const env = fixture(() => ({ claude: claude() })); + try { + withInstructions(env); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Claude Code \| ✅ 1\/1 \| 🟡 ~\/\.claude\/CLAUDE\.md \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("compares skills by name: as many other skills do not count", () => { + const env = fixture(() => ({ claude: claude({ skills: ["totally:other"] }) })); + try { + withInstructions(env); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ 0\/1, missing aidd-x:01-a/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails a plugin enabled in its list that the session did not load", () => { + const env = fixture(() => ({ claude: claude({ plugins: [] }) })); + try { + withInstructions(env); + assert.match(check(env).stdout, /\| Claude Code \| ❌ 0\/1, missing aidd-x/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("does not take a personal Agents heading for the managed block", () => { + const env = fixture(() => ({ claude: claude() })); + try { + write(join(env.home, ".claude/CLAUDE.md"), "## Agents\nmine\n"); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ no managed block in ~\/\.claude\/CLAUDE\.md/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +}); + +scenario( + "Codex reads plugins, instructions and skills from what it would send the model", + () => ({ + codex: `case "$1 $2" in + 'plugin list') echo '{"installed":[{"name":"aidd-x","marketplaceName":"aidd-framework","installed":true,"enabled":true}]}' ;; + 'debug prompt-input') printf '%s' '[{"content":[{"text":"${MARKER}\\n- aidd-x:01-a: does a"}]}]' ;; +esac`, + }), + (env) => { + write( + join(env.home, ".codex/agents/aidd-x-reviewer.toml"), + 'name = "aidd-x-reviewer"\ndeveloper_instructions = "x"\n' + ); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Codex \| ✅ 1\/1 \| ✅ in the prompt \| ✅ 1\/1 \| 🟡 1\/1 \|/); + } +); + +scenario( + "Codex fails an agent file that names another agent", + () => ({ + codex: `case "$1 $2" in + 'plugin list') echo '{"installed":[]}' ;; + 'debug prompt-input') echo '[]' ;; +esac`, + }), + (env) => { + write(join(env.home, ".codex/agents/aidd-x-reviewer.toml"), 'name = "other"\n'); + assert.match(check(env).stdout, /❌ 0\/1, missing aidd-x-reviewer/); + } +); + +test("Copilot", posixOnly, async (t) => { + await t.test("lists its agents through a session that sends no prompt", () => { + const env = fixture((home) => ({ + copilot: `case "$1 $2" in + 'plugin list') echo '[{"name":"aidd-x","marketplace":"aidd-framework","enabled":true}]' ;; + 'instruction list') echo '[{"sourcePath":"${home}/.copilot/copilot-instructions.md"}]' ;; + 'skill list') echo '[{"source":"plugin","enabled":true,"path":"${home}/built/plugins/aidd-x/skills/01-a"}]' ;; + --acp*) echo '{"id":2,"result":{"configOptions":[{"id":"agent","options":[{"value":"aidd-x:reviewer"}]}]}}' ;; +esac`, + })); + try { + write(join(env.home, ".copilot/copilot-instructions.md"), `${MARKER}\n`); + write(join(env.home, "built/plugins/aidd-x/skills/01-a/SKILL.md"), "x"); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Copilot \| ✅ 1\/1 \| ✅ listed \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails its agents when the session dies without answering", () => { + const env = fixture(() => ({ copilot: "exit 0" })); + try { + assert.match(check(env).stdout, /\| Copilot \| ❌ .*❌ 0\/1, missing aidd-x:reviewer \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +}); + +test("OpenCode", posixOnly, async (t) => { + const present = (env) => { + write(join(env.home, ".config/opencode/skills/aidd-x/01-a/SKILL.md"), "x"); + write(join(env.home, ".config/opencode/AGENTS.md"), `${MARKER}\n`); + }; + + await t.test("passes when its service lists every skill", () => { + const env = fixture((home) => ({ + opencode: opencode([join(home, ".config/opencode/skills/aidd-x/01-a/SKILL.md")]), + })); + try { + present(env); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| OpenCode \| ✅ 1\/1 \| 🟡 ~\/\.config\/opencode\/AGENTS\.md \| ✅ 1\/1 \| ✅ 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("reads again while its service starts cold, and gets the whole list", () => { + const env = fixture((home) => ({ + opencode: `case "$1 $2" in + 'debug paths') echo "$HOME/.config/opencode" ;; + 'api get') + if [ -f "$HOME/warm" ]; then + echo '{"data":[{"path":"${home}/.config/opencode/skills/aidd-x/01-a/SKILL.md"}]}' + else + : > "$HOME/warm"; echo '{"data":[]}' + fi ;; + 'debug agents') echo '[{"id":"aidd-x-reviewer"}]' ;; +esac`, + })); + try { + present(env); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("fails every cell when it answers no config directory, never the checkout's file", () => { + const env = fixture(() => ({ opencode: "exit 1" })); + try { + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /\| OpenCode \| ❌ no config directory answered \| ❌/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); +}); + +test("Cursor is read from its files", posixOnly, async (t) => { + const install = (env, skill) => { + const local = join(env.home, ".cursor/plugins/local/aidd-x"); + write(join(local, ".cursor-plugin/plugin.json"), "{}\n"); + write(join(local, "skills/01-a/SKILL.md"), skill); + write(join(local, "agents/reviewer.md"), "---\ndescription: reviews\n---\nBody\n"); + write(join(env.home, ".cursor/rules/aidd-agents.mdc"), `${MARKER}\n`); + }; + const failsOnSkill = (skill) => () => { + const env = fixture(() => ({ "cursor-agent": "exit 0" })); + try { + install(env, skill); + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /❌ 0\/1, missing aidd-x\/01-a/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }; + + await t.test("and passes a conforming skill", () => { + const env = fixture(() => ({ "cursor-agent": "exit 0" })); + try { + install(env, "---\nname: 01-a\ndescription: does a\n---\n"); + const result = check(env); + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /\| Cursor \| 🟡 1\/1 \| 🟡 ~\/\.cursor\/rules\/aidd-agents\.mdc \| 🟡 1\/1 \| 🟡 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } + }); + + await t.test("and fails a name apart from its folder", failsOnSkill("---\nname: a\ndescription: d\n---\n")); + await t.test("and fails a name that only starts like it", failsOnSkill("---\nname: 01-abc\ndescription: d\n---\n")); + await t.test("and fails a name found only in the body", failsOnSkill("---\ndescription: d\n---\nname: 01-a\n")); + await t.test("and fails a skill with no description", failsOnSkill("---\nname: 01-a\n---\n")); +}); + +scenario( + "a tool named agent counts as Cursor only when its help says so", + () => ({ agent: 'echo "Grok Build TUI"' }), + (env) => { + assert.match(check(env).stdout, /\| Cursor \| not installed/); + } +); diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 7d1bfb714..2156e3d77 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -171,30 +171,23 @@ function fixture(home, plugins = ["aidd-dev"]) { const BASE_PATH = process.platform === "win32" ? (process.env.PATH ?? "") : ["/usr/bin", "/bin"].join(delimiter); -function runSync(fakeBin, home, args = ["all"], overrides = {}) { +function runSync(fakeBin, home, args = ["all"], overrides = {}, cwd = home) { executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); // `bash`, resolved on PATH, never the literal `/bin/bash`: that path does not exist on // Windows, where bash is the one Git for Windows ships. `spawnSync` resolves a bare name // through the platform's own rules on both. return spawnSync("bash", [join(repo, "scripts/dev-sync.sh"), ...args], { - cwd: home, + cwd, env: { ...process.env, PATH: [fakeBin, BASE_PATH].join(delimiter), HOME: home, FW: fixture(home), - BUILD: join(home, "build"), - CODEX_CACHE: join(home, ".codex/plugins/cache"), - CLAUDE_CACHE: join(home, ".claude/plugins/cache"), - OPENCODE_SKILLS: join(home, ".config/opencode/skills"), - CODEX_AGENTS: join(home, ".codex/agents"), - OPENCODE_AGENTS: join(home, ".config/opencode/agents"), CODEX_INSTRUCTIONS: join(home, ".codex/AGENTS.md"), CLAUDE_INSTRUCTIONS: join(home, ".claude/CLAUDE.md"), OPENCODE_INSTRUCTIONS: join(home, ".config/opencode/AGENTS.md"), COPILOT_INSTRUCTIONS: join(home, ".copilot/copilot-instructions.md"), CURSOR_INSTRUCTIONS: join(home, ".cursor/rules/aidd-agents.mdc"), - CURSOR_PLUGINS: join(home, ".cursor/plugins/local"), OPENCODE_DISABLE_CLAUDE_CODE: "", OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "", ...overrides, @@ -203,37 +196,24 @@ function runSync(fakeBin, home, args = ["all"], overrides = {}) { }); } -test("reload fixtures run from their temporary home", () => { +test("the global install runs from HOME, never the checkout whose settings enable the plugins", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cwd-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - executable(join(fakeBin, "aidd-opencode-reload"), "node -e 'process.stdout.write(process.cwd())' > \"$HOME/observed-cwd\""); - const result = runSync(fakeBin, home, []); + executable(join(fakeBin, "opencode"), "exit 0"); + const source = fixture(home); + recordingCli(source, 'fs.writeFileSync(path.join(process.env.HOME, "observed-cwd"), process.cwd());'); + const result = runSync(fakeBin, home, [], {}, source); assert.equal(result.status, 0, result.stderr); - assert.equal(realpathSync(readFileSync(join(home, "observed-cwd"), "utf8").trim()), realpathSync(home)); + // `.native` expands a Windows 8.3 short name (`RUNNER~1`), which bash's `cd "$HOME"` keeps. + assert.equal( + realpathSync.native(readFileSync(join(home, "observed-cwd"), "utf8").trim()), + realpathSync.native(home) + ); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("managed OpenCode reload uses the fixed-purpose helper without a local build", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - mkdirSync(home); - executable(join(fakeBin, "aidd-opencode-reload"), 'echo "managed:ok"'); - - const result = runSync(fakeBin, home); - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /reload opencode\s+managed:ok/); - assert.equal(existsSync(join(home, "build")), false); - assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - for (const flag of ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"]) { test(`OpenCode first reload respects disabled Claude fallback via ${flag}`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-fallback-")); @@ -243,7 +223,8 @@ for (const flag of ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_COD mkdirSync(fakeBin); mkdirSync(join(home, ".claude"), { recursive: true }); writeFileSync(join(home, ".claude/CLAUDE.md"), "Excluded Claude personal rules.\n"); - executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + executable(join(fakeBin, "opencode"), "exit 0"); + recordingCli(fixture(home)); const result = runSync(fakeBin, home, [], { [flag]: "1" }); assert.equal(result.status, 0, result.stderr); const installed = readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"); @@ -270,13 +251,7 @@ test("reload delivers global instructions to all three hosts while preserving pe writeFileSync(join(home, path), "Personal rules.\n"); } for (const host of ["codex", "claude", "opencode"]) executable(join(fakeBin, host), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/.opencode/skills/aidd-pm-03-prd" -printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-pm-03-prd/SKILL.md"`); + executable(join(fakeBin, "npx"), "exit 0"); const first = runSync(fakeBin, home, []); assert.equal(first.status, 0, first.stderr); assert.match(first.stdout, /global instructions\s+ok/); @@ -296,439 +271,203 @@ printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-pm-03-prd/SKILL.md"`); } }); -for (const layout of ["flat", "nested"]) { - test(`local OpenCode reload refreshes the framework namespace using ${layout} output`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - mkdirSync(home); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev-stale"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev-stale/SKILL.md"), "stale\n"); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev/removed"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev/removed/SKILL.md"), "stale\n"); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev/01-plan"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev/01-plan/SKILL.md"), "old\n"); - const unrelated = join(home, ".config/opencode/skills/aidd-pm/03-prd/SKILL.md"); - mkdirSync(join(home, ".config/opencode/skills/aidd-pm/03-prd"), { recursive: true }); - writeFileSync(unrelated, "keep\n"); - const agentDir = join(home, ".config/opencode/agents"); - mkdirSync(agentDir, { recursive: true }); - writeFileSync(join(agentDir, "aidd-dev-checker.md"), "old agent\n"); - writeFileSync(join(agentDir, "aidd-dev-removed.md"), "stale\n"); - writeFileSync(join(agentDir, "personal.md"), "keep\n"); - writeFileSync(join(agentDir, "aidd-pm-review.md"), "keep\n"); - executable(join(fakeBin, "opencode"), "exit 0"); - const skillName = layout === "nested" ? "aidd-dev/01-plan" : "aidd-dev-01-plan"; - executable( - join(fakeBin, "npx"), - String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -skill="$out/.opencode/skills/${skillName}" -mkdir -p "$skill/actions" "$skill/assets" -printf '%s\n' '---' 'name: aidd-dev-01-plan' 'description: test' '---' > "$skill/SKILL.md" -printf '%s\n' 'updated action' > "$skill/actions/04-plan.md" -printf '%s\n' 'updated template' > "$skill/assets/plan-template.md" -mkdir -p "$out/.opencode/agents" -printf '%s\n' 'built executor' > "$out/.opencode/agents/aidd-dev-executor.md" -`, - ); - - const result = runSync(fakeBin, home, []); - assert.equal(result.status, 0, result.stderr); - const installed = join(home, ".config/opencode/skills", skillName); - assert.match(readFileSync(join(installed, "SKILL.md"), "utf8"), /name: aidd-dev-01-plan/); - assert.equal(readFileSync(join(installed, "actions/04-plan.md"), "utf8"), "updated action\n"); - assert.equal(readFileSync(join(installed, "assets/plan-template.md"), "utf8"), "updated template\n"); - assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev-stale")), false); - assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev/removed")), false); - assert.equal(readFileSync(unrelated, "utf8"), "keep\n"); - assert.match(result.stdout, /install opencode\s+skills:ok agents:ok/); - assert.equal(readFileSync(join(agentDir, "aidd-dev-executor.md"), "utf8"), "built executor\n"); - assert.equal(readFileSync(join(agentDir, "aidd-dev-checker.md"), "utf8"), "old agent\n"); - assert.equal(readFileSync(join(agentDir, "personal.md"), "utf8"), "keep\n"); - assert.equal(readFileSync(join(agentDir, "aidd-pm-review.md"), "utf8"), "keep\n"); - - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} - -test("local OpenCode reload rejects missing framework skills before removing installed ones", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-missing-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - const installed = join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev-01-plan"), { recursive: true }); - writeFileSync(installed, "keep\n"); - executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), "exit 0"); - - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built OpenCode skill for aidd-dev-01-plan/); - assert.doesNotMatch(result.stdout, /skills:ok/); - assert.equal(readFileSync(installed, "utf8"), "keep\n"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("default OpenCode reload installs every plugin's skills", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-all-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - mkdirSync(home); - fixture(home, ["aidd-dev", "aidd-pm"]); - const skills = ["aidd-dev-01-plan", "aidd-pm-03-prd"]; - const buildSkills = skills.map((name) => `mkdir -p "$out/.opencode/skills/${name}"\nprintf '%s\\n' 'skill' > "$out/.opencode/skills/${name}/SKILL.md"`).join("\n"); - executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), `out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -${buildSkills}`); - - const result = runSync(fakeBin, home, []); - assert.equal(result.status, 0, result.stderr); - for (const skill of skills) { - assert.equal(readFileSync(join(home, ".config/opencode/skills", `${skill}/SKILL.md`), "utf8"), "skill\n"); - } - assert.equal(existsSync(join(home, ".config/opencode/agents")), false); - assert.match(result.stdout, /skills:ok/); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -for (const badOutput of ["missing", "empty"]) { - test(`OpenCode rejects ${badOutput} expected skill output despite another built skill`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-partial-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - const source = fixture(home); - const second = join(source, "plugins/aidd-dev/skills/02-implement"); - mkdirSync(second); - writeFileSync(join(second, "SKILL.md"), "source skill\n"); - const installed = join(home, ".config/opencode/skills/aidd-dev-02-implement"); - mkdirSync(installed, { recursive: true }); - writeFileSync(join(installed, "SKILL.md"), "keep\n"); - executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" -printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" -${badOutput === "empty" ? 'mkdir -p "$out/.opencode/skills/aidd-dev-02-implement"; touch "$out/.opencode/skills/aidd-dev-02-implement/SKILL.md"' : ""}`); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built OpenCode skill for aidd-dev-02-implement/); - assert.equal(readFileSync(join(installed, "SKILL.md"), "utf8"), "keep\n"); - assert.doesNotMatch(result.stdout, /skills:ok|Done\./); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} - -for (const host of ["opencode", "codex", "copilot", "cursor"]) { - test(`reload fails when the installed ${host} host's build fails`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-build-failed-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - executable(join(fakeBin, host), "exit 0"); - executable(join(fakeBin, "npx"), "exit 7"); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, new RegExp(`${host} build failed`)); - assert.doesNotMatch(result.stdout, /Done\./); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} - -for (const host of ["codex", "claude", "copilot"]) { - test(`reload fails when the installed ${host} host rejects a plugin`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-install-failed-")); - try { - const fakeBin = join(root, "bin"); - const home = join(root, "home"); - mkdirSync(fakeBin); - fixture(home, ["aidd-pm"]); - executable(join(fakeBin, host), `if [ "$1 $2" = 'plugin marketplace' ] && [ "$3" = list ]; then echo '[]'; fi\nif [ "$1" = plugin ] && [ "$2" = ${host === "codex" ? "add" : "install"} ]; then exit 8; fi\nexit 0`); - executable(join(fakeBin, "npx"), host === "copilot" ? nativeBuildStub() : "exit 0"); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /plugin install failed for aidd-pm/); - assert.doesNotMatch(result.stdout, /Done\./); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -} - -function nativeBuildStub(omitSkill = false) { - return String.raw`out=""; tool="" -while [ "$#" -gt 0 ]; do - case "$1" in --out) shift; out=$1 ;; --to) shift; tool=$1 ;; esac - shift -done -for source in "$FW/plugins/"*/; do - plugin="$(basename "$source")" - target="$out/plugins/$plugin" - manifest=".$tool-plugin" - [ "$tool" != copilot ] || manifest=".plugin" - mkdir -p "$target/$manifest" "$target/skills" - printf '{"name":"%s"}\n' "$plugin" > "$target/$manifest/plugin.json" - ${omitSkill ? ":" : 'cp -R "$FW/plugins/$plugin/skills/." "$target/skills/"'} -done`; +/** A checkout CLI that records each call and answers `setup` with the given shell-free body. */ +function recordingCli(source, setupBody = "") { + const binary = join(source, "cli/dist/cli.js"); + mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, `const fs = require("node:fs"), path = require("node:path"); +const args = process.argv.slice(2); +fs.appendFileSync(path.join(process.env.HOME, "aidd-calls"), args.join(" ") + "\\n"); +if (args[0] !== "setup") process.exit(2); +${setupBody}`); } -for (const binary of ["cursor-agent", "agent", "cursor"]) { - test(`Cursor reload through ${binary} installs every built plugin and preserves personal content`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-")); +for (const localBinary of [false, true]) { + test(`managed OpenCode reload uses its helper ${localBinary ? "instead of the local binary" : "without a local binary or build"}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - fixture(home, ["aidd-dev", "aidd-pm"]); - const personal = [".cursor/agents/executor.md", ".cursor/rules/personal.mdc", ".cursor/mcp.json", ".cursor/plugins/local/personal/notes.md"]; - for (const file of personal) { - mkdirSync(dirname(join(home, file)), { recursive: true }); - writeFileSync(join(home, file), "personal bytes\n"); - } - const stale = join(home, ".cursor/plugins/local/aidd-dev/agents/removed.md"); - mkdirSync(dirname(stale), { recursive: true }); - writeFileSync(stale, "retired role\n"); - executable(join(fakeBin, binary), "echo 'Start the Cursor Agent'"); - executable(join(fakeBin, "npx"), nativeBuildStub()); + executable(join(fakeBin, "aidd-opencode-reload"), 'printf \'%s\\n\' "$*" > "$HOME/managed-call"; echo managed:ok'); + if (localBinary) executable(join(fakeBin, "opencode"), "exit 99"); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - for (const [plugin, skill] of [["aidd-dev", "01-plan"], ["aidd-pm", "03-prd"]]) { - assert.equal(readFileSync(join(home, `.cursor/plugins/local/${plugin}/skills/${skill}/SKILL.md`), "utf8"), "source skill\n"); - } - assert.equal(existsSync(join(home, ".cursor/plugins/local/aidd-dev/agents")), false); - assert.match(readFileSync(join(home, ".cursor/rules/aidd-agents.mdc"), "utf8"), /Use available agents when useful/); - assert.equal(existsSync(stale), false); - for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); - const first = readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"); - assert.equal(runSync(fakeBin, home, []).status, 0); - assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), first); + assert.equal(readFileSync(join(home, "managed-call"), "utf8"), "\n"); + assert.match(result.stdout, /reload opencode\s+managed:ok/); + assert.doesNotMatch(result.stdout, /global install|nothing to install/); + assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); } finally { rmSync(root, { recursive: true, force: true }); } }); } -test("Cursor reload rejects incomplete native output before changing installed plugins", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-incomplete-")); +test("managed OpenCode reload leaves other hosts on the global install path", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-mixed-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - const installed = join(home, ".cursor/plugins/local/aidd-dev/agents/executor.md"); - mkdirSync(dirname(installed), { recursive: true }); - writeFileSync(installed, "old role\n"); - executable(join(fakeBin, "cursor-agent"), "exit 0"); - executable(join(fakeBin, "npx"), nativeBuildStub(true)); + const source = fixture(home); + recordingCli(source); + executable(join(fakeBin, "codex"), "exit 0"); + executable(join(fakeBin, "opencode"), "exit 99"); + executable(join(fakeBin, "aidd-opencode-reload"), 'echo managed:ok'); const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built cursor skill/); - assert.equal(readFileSync(installed, "utf8"), "old role\n"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global install\s+ok \(codex\)/); + assert.match(result.stdout, /reload opencode\s+managed:ok/); + assert.match(readFileSync(join(home, "aidd-calls"), "utf8"), / --ide codex --plugins all --yes\n$/); + assert.match(readFileSync(join(home, ".codex/AGENTS.md"), "utf8"), /Use available agents when useful/); + assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("another tool named agent does not trigger Cursor installation", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-other-agent-")); +test("a failed managed OpenCode reload stops before changing other hosts or instructions", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-failed-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - executable(join(fakeBin, "agent"), "echo 'Grok Build TUI'"); - executable(join(fakeBin, "npx"), "exit 9"); + recordingCli(fixture(home)); + executable(join(fakeBin, "codex"), 'echo changed > "$HOME/host-calls"'); + executable(join(fakeBin, "aidd-opencode-reload"), 'echo "managed source refused" >&2; exit 7'); const result = runSync(fakeBin, home, []); - assert.equal(result.status, 0, result.stderr); - assert.equal(existsSync(join(home, "build/cursor")), false); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -const copilotMarketplaceList = String.raw`node -e 'process.stdout.write(JSON.stringify([{name:"aidd-framework",source:"Local: "+process.argv[1]}]))'`; -const copilotStub = String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" -case "$1 $2 $3" in - 'plugin marketplace list') - if [ -f "$HOME/copilot-source" ]; then - ${copilotMarketplaceList} "$(cat "$HOME/copilot-source")" - else echo '[]'; fi ;; - 'plugin marketplace add') - if [ -f "$HOME/copilot-source" ]; then echo 'Marketplace "aidd-framework" already registered' >&2; exit 1; fi - printf '%s' "$4" > "$HOME/copilot-source" ;; - 'plugin marketplace remove') rm "$HOME/copilot-source" ;; - 'plugin marketplace update') : ;; -esac`; - -test("Copilot fixture serializes Windows paths and quotes as JSON", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-json-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); mkdirSync(home); - const source = String.raw`D:\Work\Example "quoted"\Framework`; - writeFileSync(join(home, "copilot-source"), source); - executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); - const binary = join(fakeBin, "copilot"); - executable(binary, copilotStub); - const result = spawnSync("bash", [binary, "plugin", "marketplace", "list", "--json"], { - cwd: home, - env: { ...process.env, HOME: home, PATH: [fakeBin, BASE_PATH].join(delimiter) }, - encoding: "utf8", - }); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(JSON.parse(result.stdout), [{ name: "aidd-framework", source: `Local: ${source}` }]); + assert.equal(result.status, 7, result.stderr); + assert.match(result.stderr, /managed source refused/); + assert.doesNotMatch(result.stdout, /Done\.|global instructions|nothing to install/); + assert.equal(existsSync(join(home, "host-calls")), false); + assert.equal(existsSync(join(home, "aidd-calls")), false); + assert.equal(existsSync(join(home, ".config/opencode/AGENTS.md")), false); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("Copilot reload builds all native plugins, migrates its own source, and preserves personal files", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-")); +test("every host takes this checkout through aidd setup --global, afresh", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); mkdirSync(home); + mkdirSync(fakeBin); const source = fixture(home, ["aidd-dev", "aidd-pm"]); - writeFileSync(join(home, "copilot-source"), source); - const personal = [".copilot/agents/executor.agent.md", ".copilot/instructions/personal.instructions.md", ".copilot/settings.json"]; - for (const file of personal) { - mkdirSync(dirname(join(home, file)), { recursive: true }); - writeFileSync(join(home, file), "personal bytes\n"); + recordingCli(source); + for (const host of ["claude", "codex", "copilot", "cursor-agent", "kilo", "opencode"]) { + executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"\n[ "$*" = "plugin list --json" ] && echo '[]'; exit 0`); } - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub()); - const first = runSync(fakeBin, home, []); - assert.equal(first.status, 0, first.stderr); - assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global install\s+ok \(claude,codex,copilot,cursor,kilo,opencode\)/); + assert.equal( + readFileSync(join(home, "aidd-calls"), "utf8"), + `setup --global --source local --path ${source} --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes\n` + ); + const hostCalls = readFileSync(join(home, "host-calls"), "utf8"); for (const plugin of ["aidd-dev", "aidd-pm"]) { - assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), new RegExp(`plugin install ${plugin}@aidd-framework`)); - assert.equal(JSON.parse(readFileSync(join(home, `build/copilot/plugins/${plugin}/.plugin/plugin.json`), "utf8")).name, plugin); + assert.match(hostCalls, new RegExp(`claude plugin uninstall ${plugin}@aidd-framework --scope user`)); + assert.match(hostCalls, new RegExp(`codex plugin remove ${plugin}@aidd-framework`)); } - assert.match(readFileSync(join(home, ".copilot/copilot-instructions.md"), "utf8"), /Use available agents when useful/); - assert.equal(runSync(fakeBin, home, []).status, 0); - assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace update aidd-framework/); - for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); + assert.match(hostCalls, /^claude plugin marketplace remove aidd-framework$/m); + assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); + assert.doesNotMatch(hostCalls, /^(copilot|cursor-agent|kilo|opencode) /m); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("Copilot refuses another source registered under the same marketplace name", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-foreign-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); mkdirSync(home); - writeFileSync(join(home, "copilot-source"), "/another/framework"); - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub()); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /different Copilot marketplace source/); - assert.equal(readFileSync(join(home, "copilot-source"), "utf8"), "/another/framework"); - assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove|plugin install/); - } finally { rmSync(root, { recursive: true, force: true }); } -}); +for (const [name, listing] of [ + ["a project installed from it", `echo '[{"id":"aidd-dev@aidd-framework","scope":"local","projectPath":"/p"}]'`], + ["its list cannot be read", "exit 1"], +]) { + test(`Claude's marketplace stays at user scope when ${name}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + recordingCli(fixture(home)); + executable(join(fakeBin, "claude"), `printf '%s\\n' "$*" >> "$HOME/claude-calls"\n[ "$*" = "plugin list --json" ] && { ${listing}; }\nexit 0`); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + const calls = readFileSync(join(home, "claude-calls"), "utf8"); + assert.match(calls, /^plugin marketplace remove aidd-framework --scope user$/m); + assert.doesNotMatch(calls, /^plugin marketplace remove aidd-framework$/m); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} -test("Copilot rejects incomplete native output before registering or installing", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-incomplete-")); +test("a failing global install stops the reload with its own cause", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-failed-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub(true)); + recordingCli(fixture(home), 'console.error("fixture refusal"); process.exit(3);'); + executable(join(fakeBin, "opencode"), "exit 0"); const result = runSync(fakeBin, home, []); assert.notEqual(result.status, 0); - assert.match(result.stderr, /no built copilot skill/); - assert.equal(existsSync(join(home, "copilot-calls")), false); + assert.match(result.stderr, /fixture refusal/); + assert.match(result.stderr, /aidd setup --global failed for opencode/); + assert.doesNotMatch(result.stdout, /Done\./); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("Copilot registers an inherited own marketplace without trying to remove it", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-inherited-")); +test("a global install from the published CLI is used when the checkout has none built", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-npx-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" -case "$1 $2 $3" in - 'plugin marketplace list') ${copilotMarketplaceList} "$FW" ;; - 'plugin marketplace remove') echo 'Marketplace "aidd-framework" is not registered' >&2; exit 1 ;; - 'plugin marketplace add') printf '%s' "$4" > "$HOME/copilot-source" ;; -esac`); - executable(join(fakeBin, "npx"), nativeBuildStub()); + executable(join(fakeBin, "codex"), "exit 0"); + executable(join(fakeBin, "npx"), 'printf \'%s\\n\' "$*" > "$HOME/npx-call"'); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); - assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove/); + assert.match(readFileSync(join(home, "npx-call"), "utf8"), /^--yes @ai-driven-dev\/cli@latest setup --global .* --ide codex --plugins all --yes$/m); } finally { rmSync(root, { recursive: true, force: true }); } }); -for (const command of ["add", "update", "install"]) { - test(`Copilot ${command} failure retains the native cause`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-cause-")); +for (const binary of ["cursor-agent", "agent", "cursor"]) { + test(`Cursor found through ${binary} is installed globally with its always-on rule`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), String.raw`case "$1 $2 $3" in - 'plugin marketplace list') ${command === "update" ? `${copilotMarketplaceList} "$BUILD/copilot"` : "echo '[]'"} ;; - 'plugin marketplace ${command}'${command === "install" ? "|\'plugin install aidd-dev@aidd-framework\'" : ""}) echo 'fixture native permission denied' >&2; exit 9 ;; -esac`); - executable(join(fakeBin, "npx"), nativeBuildStub()); + recordingCli(fixture(home)); + executable(join(fakeBin, binary), "echo 'Start the Cursor Agent'"); const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /fixture native permission denied/); + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(join(home, "aidd-calls"), "utf8"), / --ide cursor /); + assert.match(readFileSync(join(home, ".cursor/rules/aidd-agents.mdc"), "utf8"), /^---\nalwaysApply: true\n---\n/); } finally { rmSync(root, { recursive: true, force: true }); } }); } -test("reload rejects plugin selection arguments before changing any host", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-filter-")); +test("another tool named agent is not taken for Cursor", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-other-agent-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - const result = runSync(fakeBin, home, ["aidd-dev"]); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /every plugin/); - assert.equal(existsSync(join(home, "build")), false); + recordingCli(fixture(home)); + executable(join(fakeBin, "agent"), "echo 'Grok Build TUI'"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /No supported host installation found/); + assert.equal(existsSync(join(home, "aidd-calls")), false); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("reload uses the checkout's built CLI instead of a published translator", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-cli-")); +test("Kilo is installed globally and gets no global instructions file", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-kilo-")); try { const fakeBin = join(root, "bin"), home = join(root, "home"); mkdirSync(fakeBin); - const source = fixture(home), binary = join(source, "cli/dist/cli.js"); - mkdirSync(dirname(binary), { recursive: true }); - writeFileSync(binary, String.raw`const fs = require("node:fs"), path = require("node:path"); -const args = process.argv.slice(2); -if (args[0] !== "translate") process.exit(2); -const out = args[args.indexOf("--out") + 1]; -const source = args[1]; -for (const plugin of fs.readdirSync(path.join(source, "plugins"))) { - const target = path.join(out, "plugins", plugin); - fs.mkdirSync(path.join(target, ".cursor-plugin"), { recursive: true }); - fs.writeFileSync(path.join(target, ".cursor-plugin/plugin.json"), JSON.stringify({ name: plugin })); - fs.cpSync(path.join(source, "plugins", plugin, "skills"), path.join(target, "skills"), { recursive: true }); - -}`); - executable(join(fakeBin, "cursor-agent"), "echo 'Start the Cursor Agent'"); - executable(join(fakeBin, "npx"), "exit 19"); + recordingCli(fixture(home)); + executable(join(fakeBin, "kilo"), "exit 0"); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), "source skill\n"); + assert.match(readFileSync(join(home, "aidd-calls"), "utf8"), / --ide kilo /); + assert.equal(existsSync(join(home, ".config/kilo/AGENTS.md")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("reload rejects plugin selection arguments before changing any host", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-filter-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "claude"), `printf '%s\\n' "$*" >> "$HOME/host-calls"`); + const result = runSync(fakeBin, home, ["aidd-dev"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /every plugin/); + assert.equal(existsSync(join(home, "host-calls")), false); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -737,8 +476,8 @@ test("Copilot instructions follow its native home with task overrides taking pre try { const fakeBin = join(root, "bin"), home = join(root, "home"), nativeHome = join(home, "native-copilot"); mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub()); + recordingCli(fixture(home)); + executable(join(fakeBin, "copilot"), "exit 0"); const result = runSync(fakeBin, home, [], { COPILOT_HOME: nativeHome, COPILOT_INSTRUCTIONS: "" }); assert.equal(result.status, 0, result.stderr); assert.match(readFileSync(join(nativeHome, "copilot-instructions.md"), "utf8"), /Use available agents when useful/); @@ -748,53 +487,6 @@ test("Copilot instructions follow its native home with task overrides taking pre } finally { rmSync(root, { recursive: true, force: true }); } }); -test("OpenCode skills follow its native home with task overrides taking precedence", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-opencode-home-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-config"); - mkdirSync(fakeBin); - executable(join(fakeBin, "opencode"), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = --out ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" -printf '%s\n' 'native skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" -`); - const result = runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config, OPENCODE_SKILLS: "", OPENCODE_INSTRUCTIONS: "" }); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(config, "opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); - assert.equal(existsSync(join(config, "opencode/agents")), false); - assert.equal(existsSync(join(home, ".config/opencode/skills")), false); - assert.equal(runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config }).status, 0); - assert.equal(readFileSync(join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -for (const customPluginRoot of [false, true]) { - test(`Claude cache follows its native ${customPluginRoot ? "plugin root" : "config home"}`, () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-claude-home-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-claude"); - const plugins = customPluginRoot ? join(home, "native-plugins") : join(config, "plugins"); - mkdirSync(fakeBin); - executable(join(fakeBin, "claude"), String.raw`if [ "$1 $2" = 'plugin install' ]; then - cache="$CLAUDE_CONFIG_DIR/plugins" - [ -z "$CLAUDE_CODE_PLUGIN_CACHE_DIR" ] || cache="$CLAUDE_CODE_PLUGIN_CACHE_DIR" - cache="$cache/cache/aidd-framework/aidd-dev/2.6.0" - mkdir -p "$cache" - printf '%s\n' 'native plugin' > "$cache/plugin-installed" -fi`); - const result = runSync(fakeBin, home, [], { CLAUDE_CONFIG_DIR: config, CLAUDE_CODE_PLUGIN_CACHE_DIR: customPluginRoot ? plugins : "", CLAUDE_CACHE: "", CLAUDE_INSTRUCTIONS: "" }); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(plugins, "cache/aidd-framework/aidd-dev/2.6.0/plugin-installed"), "utf8"), "native plugin\n"); - assert.match(readFileSync(join(config, "CLAUDE.md"), "utf8"), /Use available agents when useful/); - assert.equal(existsSync(join(home, ".claude/plugins")), false); - } finally { rmSync(root, { recursive: true, force: true }); } - }); -} - for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { test(`OpenCode preserves its actual Claude fallback despite ${override}`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-fallback-override-")); @@ -806,7 +498,8 @@ for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { const custom = join(home, "custom-claude"); mkdirSync(custom); writeFileSync(join(custom, "CLAUDE.md"), "Other Claude instructions.\n"); - executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + executable(join(fakeBin, "opencode"), "exit 0"); + recordingCli(fixture(home)); const result = runSync(fakeBin, home, [], override === "CLAUDE_CONFIG_DIR" ? { CLAUDE_CONFIG_DIR: custom, CLAUDE_INSTRUCTIONS: "" } : { CLAUDE_INSTRUCTIONS: join(custom, "CLAUDE.md") }); @@ -818,27 +511,3 @@ for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { }); } -test("Codex reload installs the plugin's built agents beside personal ones", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-codex-personal-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); - const agents = join(home, ".codex/agents"); - mkdirSync(agents, { recursive: true }); - const existing = ["aidd-dev-personal.toml", "executor.toml"]; - for (const file of existing) writeFileSync(join(agents, file), `Personal ${file}\n`); - executable(join(fakeBin, "codex"), "exit 0"); - executable(join(fakeBin, "npx"), String.raw`out="" -while [ "$#" -gt 0 ]; do - if [ "$1" = --out ]; then shift; out=$1; fi - shift -done -mkdir -p "$out/plugins/aidd-dev/codex-agents" -printf '%s\n' 'built executor' > "$out/plugins/aidd-dev/codex-agents/executor.toml" -`); - const result = runSync(fakeBin, home, []); - assert.equal(result.status, 0, result.stderr); - assert.equal(readFileSync(join(agents, "aidd-dev-executor.toml"), "utf8"), "built executor\n"); - for (const file of existing) assert.equal(readFileSync(join(agents, file), "utf8"), `Personal ${file}\n`); - } finally { rmSync(root, { recursive: true, force: true }); } -}); diff --git a/scripts/__tests__/gate-witness.test.js b/scripts/__tests__/gate-witness.test.js index 75cac1fcb..07b34ea79 100644 --- a/scripts/__tests__/gate-witness.test.js +++ b/scripts/__tests__/gate-witness.test.js @@ -100,3 +100,19 @@ test("runs what follows -- as one shell command line, on every platform", () => assert.equal(witness(r, `${count} && ${count}`).status, 0); assert.equal(runsOf(r), 2); }); + +test("does not pass a hook's Git environment to the gate", () => { + const r = repo(); + const script = `const { execFileSync } = require('node:child_process'); + const path = require('node:path'); + const target = path.join(path.dirname(process.argv[2]), 'isolated.git'); + execFileSync('git', ['init', '--bare', '--quiet', target]); + if (process.env.GIT_DIR || process.env.GIT_INDEX_FILE || process.env.GIT_SSH_COMMAND !== 'preserved') process.exit(3);`; + const result = spawnSync(process.execPath, [WITNESS, "suite", "--", commandLine(r, script)], { + cwd: r.dir, + env: { ...CLEAN_ENV, GIT_DIR: path.join(r.dir, ".git"), GIT_INDEX_FILE: path.join(r.dir, ".git", "index"), GIT_SSH_COMMAND: "preserved" }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(r.git("config", "--bool", "core.bare").toString().trim(), "false"); +}); diff --git a/scripts/__tests__/maintenance.test.js b/scripts/__tests__/maintenance.test.js new file mode 100644 index 000000000..09b6b1924 --- /dev/null +++ b/scripts/__tests__/maintenance.test.js @@ -0,0 +1,684 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { status, format } = require('../../plugins/aidd-maintenance/hooks/session-start.cjs'); + +function fixture(t, source = { kind: 'github', repo: 'ai-driven-dev/framework', ref: 'v5.12.0' }, version = '5.12.0') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aidd-maintenance-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const project = path.join(dir, 'project'); + const config = path.join(dir, 'config'); + fs.mkdirSync(path.join(project, '.aidd'), { recursive: true }); + fs.mkdirSync(config, { recursive: true }); + fs.writeFileSync(path.join(project, '.aidd', 'marketplaces.json'), JSON.stringify({version:1, marketplaces:[{name:'aidd-framework', version, source}]})); + return {cwd:project, home:dir, env:{AIDD_USER_CONFIG_DIR:config}, now:Date.parse('2026-11-04T00:00:00Z')}; +} + +function releases(calls, list) { + return async (url) => { + calls.push(url); + if (url.includes('/releases?')) return {ok:true, json:async()=>list}; + if (url.includes('/releases/download/')) return {ok:true, json:async()=>({title:'Framework - Release 5.13 : Télémétrie et Plan'})}; + throw new Error(`unexpected ${url}`); + }; +} +const available = [{tag_name:'cli-v9.0.0'}, {tag_name:'v5.13.0',name:'v5.13.0',assets:[{name:'release-video.json'}],draft:false,prerelease:false}]; + +function plugin(name, ref, version, scope='project', overrides={}) { + return {name, version, scope, marketplace:'aidd-framework', source:{kind:'git-subdir', url:'https://github.com/ai-driven-dev/framework.git', path:`plugins/${name}`, ref}, ...overrides}; +} + +function writeManifest(file, tools) { + fs.mkdirSync(path.dirname(file), {recursive:true}); + fs.writeFileSync(file, JSON.stringify({version:8, tools})); +} + +function releasesWithVersions(calls, list, versions) { + const fetchRelease = releases(calls, list); + return async url => { + if (url.startsWith('https://raw.githubusercontent.com/')) { + calls.push(url); + return {ok:true, json:async()=>versions}; + } + return fetchRelease(url); + }; +} + +test('reports a recorded project plugin behind when the catalogue already is latest', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const calls=[]; + const result=await status({...options,fetcher:releasesWithVersions(calls,available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + const text=format(result); + assert.match(text,/plugins enregistrés par le CLI AIDD/i); + assert.match(text,/codex.*aidd-context/); + assert.match(text,/2\.9\.0.*2\.10\.0/); + assert.match(text,/v5\.12\.0.*v5\.13\.0/); + assert.match(text,/01-update/); + assert.equal(calls.filter(url=>url.startsWith('https://raw.githubusercontent.com/')).length,1); +}); + +test('checks installed plugin versions even when their source reference is current', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.13.0','2.9.0')]}}); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + assert.match(format(result),/codex.*aidd-context.*2\.9\.0.*2\.10\.0/); +}); + +test('distinguishes project and user plugins and reads each authoritative manifest only once', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), { + codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}, + cursor:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}, + }); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), { + cursor:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}, + }); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.13.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'}}]})); + const calls=[]; + const result=await status({...options,fetcher:releasesWithVersions(calls,available,{'.':'5.13.0','plugins/aidd-context':'2.10.0','plugins/aidd-dev':'2.7.1'})}); + const text=format(result); + assert.match(text,/codex.*aidd-context.*projet/i); + assert.match(text,/cursor.*aidd-dev.*global/i); + assert.equal((text.match(/cursor.*aidd-dev/g)||[]).length,1); + assert.equal(calls.filter(url=>url.startsWith('https://raw.githubusercontent.com/')).length,1); +}); + +test('does not repeatedly propose the same global update when only the shared marketplace remains pinned', async t => { + const options=fixture(t); + fs.rmSync(path.join(options.cwd,'.aidd'),{recursive:true,force:true}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'), JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.12.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.12.0'}}]})); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {opencode:{plugins:[plugin('aidd-context','v5.13.0','2.10.0','user')]}}); + const text=format(await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})})); + assert.match(text,/marketplace.*v5\.12\.0/i); + assert.match(text,/marketplace partagé.*v5\.12\.0.*v5\.13\.0/i); + assert.doesNotMatch(text,/mise à jour disponible/i); + assert.doesNotMatch(text,/01-update/i); +}); + +test('does not claim global plugins are up to date when target versions cannot be verified', async t => { + const options=fixture(t); + fs.rmSync(path.join(options.cwd,'.aidd'),{recursive:true,force:true}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'), JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.12.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.12.0'}}]})); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {opencode:{plugins:[plugin('aidd-context','v5.13.0','2.10.0','user')]}}); + const text=format(await status({...options,fetcher:releases([],available)})); + assert.match(text,/marketplace partagé.*v5\.12\.0.*v5\.13\.0/i); + assert.match(text,/plugins globaux non vérifié/i); + assert.doesNotMatch(text,/certains plugins non vérifié/i); + assert.doesNotMatch(text,/à jour/i); +}); + +test('attributes malformed entries to their manifest scope', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + const project=path.join(options.cwd,'.aidd','manifest.json'); + const user=path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.13.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'}}]})); + const invalid=[ + {'BAD!':{plugins:[]}}, + {codex:null}, + {codex:[]}, + {codex:{plugins:{}}}, + {codex:{plugins:[null]}}, + {codex:{plugins:['invalid']}}, + {codex:{plugins:[plugin('aidd-dev','v5.13.0','2.7.1','project',{marketplace:undefined})]}}, + {codex:{plugins:[plugin('INVALID!','v5.13.0','2.7.1')]}}, + ]; + for (const scope of ['project','user']) { + for (const entries of invalid) { + writeManifest(project,scope==='project'?entries:{}); + writeManifest(user,scope==='user'?entries:{}); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0'})}); + assert.equal(Boolean(result.pluginsUnverified),scope==='project'); + assert.equal(Boolean(result.globalPluginsUnverified),scope==='user'); + } + } +}); + +test('does not report local, unrelated, malformed, or up-to-date plugins as updates', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[ + plugin('aidd-context','v5.13.0','2.10.0'), + plugin('aidd-local','v5.12.0','1.0.0','project',{source:{kind:'local',path:'/repo'}}), + plugin('other','v5.12.0','1.0.0','project',{marketplace:'other'}), + plugin('broken','v5.12.0','1.0.0','project',{source:{kind:'git-subdir',url:'https://example.com/other.git',path:'plugins/broken',ref:'v5.12.0'}}), + null, + ]}}); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + const text=format(result); + assert.doesNotMatch(text,/aidd-context|aidd-local|other|broken/); + assert.doesNotMatch(text,/mise à jour disponible/i); +}); + +test('does not invent a target plugin version when release metadata is missing or mismatched', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.12.0','plugins/aidd-context':'9.9.9'})}); + const text=format(result); + assert.match(text,/codex.*aidd-context.*v5\.12\.0.*v5\.13\.0/); + assert.doesNotMatch(text,/9\.9\.9/); +}); + +test('offline or malformed manifests cannot produce a fabricated plugin update', async t => { + const options=fixture(t); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.11.0','2.9.0')]}}); + const result=await status({...options,fetcher:async()=>{throw Error('offline');}}); + assert.doesNotMatch(format(result),/aidd-context|mise à jour disponible/i); + fs.writeFileSync(path.join(options.cwd,'.aidd','manifest.json'),'{bad json'); + const malformed=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + assert.doesNotMatch(format(malformed),/aidd-context/); +}); + +test('caches the verified target plugin versions with the release for one day', async t => { + const options=fixture(t); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const calls=[]; const fetcher=releasesWithVersions(calls,available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'}); + await status({...options,fetcher}); + const next=await status({...options,now:options.now+23*3600000,fetcher}); + assert.equal(calls.filter(url=>url.startsWith('https://raw.githubusercontent.com/')).length,1); + assert.match(format(next),/2\.9\.0.*2\.10\.0/); +}); + +test('does not propose a release tag the update command cannot accept', async t => { + const options=fixture(t); + const choices=[{tag_name:'9.0.0',name:'No v',assets:[],draft:false,prerelease:false},...available]; + const result=await status({...options,fetcher:releases([],choices)}); + assert.equal(result.latest.tag,'v5.13.0'); +}); + +test('invalid or unsupported manifests leave plugin state explicitly unverified', async t => { + const options=fixture(t); + const manifest=path.join(options.cwd,'.aidd','manifest.json'); + fs.writeFileSync(manifest,'{bad json'); + assert.match(format(await status({...options,fetcher:releases([],available)})),/plugins non vérifié/i); + fs.writeFileSync(manifest,JSON.stringify({version:6,tools:{codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}})); + assert.match(format(await status({...options,fetcher:releases([],available)})),/plugins non vérifié/i); +}); + +test('does not invent a version target when a release lacks plugin metadata', async t => { + const options=fixture(t); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const calls=[]; const fetcher=async url=>{ + if(url.startsWith('https://raw.githubusercontent.com/')) { calls.push(url); throw Error('offline'); } + return releases(calls,available)(url); + }; + const first=await status({...options,fetcher}); + assert.match(format(first),/aidd-context.*version cible inconnue/); + assert.match(format(first),/plugins non vérifié/i); + const next=await status({...options,now:options.now+30*60000,fetcher}); + assert.match(format(next),/version cible inconnue/); + assert.equal(calls.filter(url=>url.startsWith('https://raw.githubusercontent.com/')).length,1); +}); + +test('retries unavailable target versions after one hour without refreshing the release', async t => { + const options=fixture(t); const calls=[]; + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const failing=async url=>{ + if(url.startsWith('https://raw.githubusercontent.com/')) { calls.push(url); throw Error('offline'); } + return releases(calls,available)(url); + }; + assert.match(format(await status({...options,fetcher:failing})),/version cible inconnue/); + const next=await status({...options,now:options.now+3600000,fetcher:releasesWithVersions(calls,available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + assert.match(format(next),/2\.9\.0.*2\.10\.0/); + assert.equal(calls.filter(url=>url.includes('/releases?')).length,1); + assert.equal(calls.filter(url=>url.startsWith('https://raw.githubusercontent.com/')).length,2); +}); + +test('adds plugin versions to an existing release-only cache without refreshing the release', async t => { + const options=fixture(t); const calls=[]; + await status({...options,fetcher:releases(calls,available)}); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const next=await status({...options,now:options.now+23*3600000,fetcher:releasesWithVersions(calls,available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + assert.match(format(next),/2\.9\.0.*2\.10\.0/); + assert.equal(calls.filter(url=>url.includes('/releases?')).length,1); + assert.equal(calls.filter(url=>url.startsWith('https://raw.githubusercontent.com/')).length,1); +}); + +test('shows at most five recorded plugin installations in a stable order', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + const entries=['zulu','echo','delta','charlie','bravo','alpha'].map(name=>plugin(name,'v5.12.0','1.0.0')); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:entries}}); + const versions={'.':'5.13.0'}; for(const entry of entries) versions[`plugins/${entry.name}`]='2.0.0'; + const text=format(await status({...options,fetcher:releasesWithVersions([],available,versions)})); + assert.match(text,/codex · alpha/); + assert.doesNotMatch(text,/codex · zulu/); + assert.match(text,/1 autre installation/); +}); + +test('does not propose a release older than an installed plugin source even when the plugin version is older', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.14.0','2.9.0')]}}); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + const text=format(result); + assert.match(text,/codex.*aidd-context.*v5\.14\.0.*v5\.13\.0/); + assert.match(text,/non propos/i); + assert.doesNotMatch(text,/mise à jour disponible/i); + assert.equal(result.pluginUpdates.length,0); +}); + +test('does not propose downgrading an installed plugin even if its source reference is older', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','3.0.0')]}}); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-context':'2.10.0'})}); + const text=format(result); + assert.match(text,/codex.*aidd-context.*3\.0\.0.*2\.10\.0/); + assert.match(text,/non propos/i); + assert.doesNotMatch(text,/mise à jour disponible/i); + assert.equal(result.pluginUpdates.length,0); +}); + +test('keeps prerelease and stable source channels separate', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0-beta.1'},'5.13.0-beta.1'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const choices=[{tag_name:'v5.13.0-beta.2',name:'Beta two',assets:[],draft:false,prerelease:true}]; + const result=await status({...options,fetcher:releasesWithVersions([],choices,{'.':'5.13.0-beta.2','plugins/aidd-context':'2.10.0-beta.1'})}); + assert.equal(result.pluginUpdates.length,0); + assert.match(format(result),/plugins non vérifié/i); +}); + +test('never compares global plugins against a different local user marketplace', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {cursor:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.13.0',source:{kind:'local',path:'/other'}}]})); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-dev':'2.7.1'})}); + assert.doesNotMatch(format(result),/cursor.*aidd-dev/); + assert.match(format(result),/plugins globaux non vérifié/i); +}); + +test('compares official global plugins independently of a local project source', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'}); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {opencode:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.12.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.12.0'}}]})); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-dev':'2.7.1'})}); + assert.match(format(result),/opencode.*aidd-dev.*global.*2\.6\.0.*2\.7\.1/i); + assert.match(format(result),/source locale.*contenu.*différer/i); + assert.doesNotMatch(format(result),/plugins.*non vérifié/i); +}); + +test('compares global prereleases on their channel even with a local project', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'}); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {kilo:{plugins:[plugin('aidd-dev','v5.13.0-beta.1','2.7.0-beta.1','user')]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.13.0-beta.1',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0-beta.1'}}]})); + const choices=[...available,{tag_name:'v5.13.0-beta.2',name:'Beta two',assets:[],draft:false,prerelease:true}]; + const fetcher=async url => url.startsWith('https://raw.githubusercontent.com/') + ? {ok:true,json:async()=>({'.':'5.13.0-beta.2','plugins/aidd-dev':'2.7.0-beta.2'})} + : releases([],choices)(url); + const text=format(await status({...options,fetcher})); + assert.match(text,/kilo.*aidd-dev.*2\.7\.0-beta\.1.*2\.7\.0-beta\.2/i); + assert.match(text,/source.*v5\.13\.0-beta\.1.*v5\.13\.0-beta\.2/i); +}); + +test('compares each global plugin on its own channel when the registry is stable', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'}); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {kilo:{plugins:[plugin('aidd-dev','v5.12.0-beta.1','2.7.0-beta.1','user')]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.12.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.12.0'}}]})); + const choices=[...available,{tag_name:'v5.13.0-beta.2',name:'Beta two',assets:[],draft:false,prerelease:true}]; + const fetcher=async url => url.startsWith('https://raw.githubusercontent.com/') + ? {ok:true,json:async()=>({'.':'5.13.0-beta.2','plugins/aidd-dev':'2.7.0-beta.2'})} + : releases([],choices)(url); + const text=format(await status({...options,fetcher})); + assert.match(text,/kilo.*aidd-dev.*2\.7\.0-beta\.1.*2\.7\.0-beta\.2/i); + assert.match(text,/source.*v5\.12\.0-beta\.1.*v5\.13\.0-beta\.2/i); + assert.doesNotMatch(text,/source.*v5\.12\.0-beta\.1.*v5\.13\.0(?:\s|$)/i); +}); + +test('fetches independent project and global release channels concurrently', {timeout:2000}, async t => { + const options=fixture(t); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'),{opencode:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'),{kilo:{plugins:[plugin('aidd-dev','v5.13.0-beta.1','2.7.0-beta.1','user')]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.13.0-beta.1',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0-beta.1'}}]})); + const choices=[...available,{tag_name:'v5.13.0-beta.2',name:'Beta two',assets:[],draft:false,prerelease:true}]; + const pending=[]; + const fetcher=async url => { + if (url.includes('/releases?')) return new Promise(resolve=>{ + pending.push(resolve); + if (pending.length===2) for (const finish of pending) finish({ok:true,json:async()=>choices}); + }); + if (url.startsWith('https://raw.githubusercontent.com/')) return {ok:true,json:async()=>url.includes('beta') + ? {'.':'5.13.0-beta.2','plugins/aidd-dev':'2.7.0-beta.2'} + : {'.':'5.13.0','plugins/aidd-context':'2.10.0'}}; + return {ok:true,json:async()=>({title:'Release'})}; + }; + const result=await status({...options,fetcher}); + assert.equal(pending.length,2); + assert.match(format(result),/opencode.*aidd-context.*2\.9\.0.*2\.10\.0/i); + assert.match(format(result),/kilo.*aidd-dev.*2\.7\.0-beta\.1.*2\.7\.0-beta\.2/i); +}); + +test('does not compare a project plugin against a different registry when its own registry is corrupt', async t => { + const options=fixture(t); + fs.writeFileSync(path.join(options.cwd,'.aidd','marketplaces.json'),'{bad json'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'),{codex:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'),{opencode:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.12.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.12.0'}}]})); + const text=format(await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-context':'2.10.0','plugins/aidd-dev':'2.7.1'})})); + assert.match(text,/source du marketplace inconnue/i); + assert.doesNotMatch(text,/codex.*aidd-context/i); + assert.match(text,/opencode.*aidd-dev.*2\.6\.0.*2\.7\.1/i); +}); + +test('does not invent a target for a local project referencing a global plugin', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'}); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {cursor:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}}); + const result=await status({...options,fetcher:releases([],available)}); + assert.match(format(result),/source locale.*contenu.*différer/i); + assert.match(format(result),/plugins globaux non vérifié/i); +}); + +test('official git plugins without marketplace ownership are explicitly unverified', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + const direct=plugin('aidd-context','v5.12.0','2.9.0'); delete direct.marketplace; + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {codex:{plugins:[direct]}}); + const result=await status({...options,fetcher:releases([],available)}); + assert.doesNotMatch(format(result),/codex.*aidd-context/); + assert.match(format(result),/plugins non vérifié/i); +}); + +test('marks a missing machine-owned plugin as globally unverified', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {cursor:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}}); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {cursor:{plugins:[]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.13.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'}}]})); + const result=await status({...options,fetcher:releases([],available)}); + assert.match(format(result),/plugins globaux non vérifié/i); + assert.doesNotMatch(format(result),/cursor.*aidd-dev/); +}); + +test('a matching plugin under a different tool does not satisfy the project user reference', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'), {cursor:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}}); + writeManifest(path.join(options.env.AIDD_USER_CONFIG_DIR,'manifest.json'), {codex:{plugins:[plugin('aidd-dev','v5.12.0','2.6.0','user')]}}); + fs.writeFileSync(path.join(options.env.AIDD_USER_CONFIG_DIR,'marketplaces.json'),JSON.stringify({version:1,marketplaces:[{name:'aidd-framework',version:'5.13.0',source:{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'}}]})); + const result=await status({...options,fetcher:releasesWithVersions([],available,{'.':'5.13.0','plugins/aidd-dev':'2.7.1'})}); + assert.match(format(result),/plugins globaux non vérifié/i); + assert.match(format(result),/codex.*aidd-dev/); + assert.doesNotMatch(format(result),/cursor.*aidd-dev/); +}); + +test('matches the CLI semver treatment of build metadata', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.12.0+build.1'},'5.12.0'); + const result=await status({...options,fetcher:releases([],available)}); + assert.equal(result.latest.tag,'v5.13.0'); + assert.equal(result.hasUpdate,true); +}); + +test('reports a verified catalogue diff and editorial title', async t => { + const options = fixture(t); const calls=[]; + const text = format(await status({...options, fetcher:releases(calls, available)}), {CLAUDE_PLUGIN_ROOT:'/plugin'}); + assert.match(text,/marketplace v5\.12\.0/); + assert.match(text,/Framework - Release 5\.13 : Télémétrie et Plan/); + assert.match(text,/v5\.13\.0/); + assert.match(text,/skill aidd-maintenance · 01-update/); + assert.equal(calls.length,2); +}); + +test('shows the release tag with its title even when the marketplace is current', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0'},'5.13.0'); + const text=format(await status({...options,fetcher:releases([],available)})); + const releaseLine=text.split('\n').find(line=>line.includes('Dernière release')); + assert.match(releaseLine,/v5\.13\.0/); + assert.match(releaseLine,/Télémétrie et Plan/); +}); + +test('dates a release from the cache and refreshes it after 24 hours', async t => { + const options = fixture(t); const calls=[]; const fetcher=releases(calls, available); + const now=Date.parse('2026-11-04T23:30:00Z'); + const first=await status({...options,now,fetcher}); + assert.match(format(first),/vérifi.*2026-11-04 23:30 UTC/i); + const next = await status({...options,now:now+23*3600000,fetcher}); + assert.equal(calls.length,2); + assert.match(format(next),/vérifi.*2026-11-04 23:30 UTC/i); + assert.match(format(next, {}),/v5\.13\.0/); + const refreshed=await status({...options,now:now+86400000,fetcher}); + assert.equal(calls.length,4); + assert.match(format(refreshed),/vérifi.*2026-11-05 23:30 UTC/i); +}); + +test('network failure never presents unknown state as up to date', async t => { + const options=fixture(t); + const text=format(await status({...options,fetcher:async()=>{throw Error('offline');}}),{}); + assert.match(text,/marketplace v5\.12\.0/); + assert.doesNotMatch(text,/up.to.date|à jour/i); + assert.doesNotMatch(text,/mise à jour disponible/i); +}); + +test('reports the official release separately from the registered local version', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'}); const calls=[]; + const text=format(await status({...options,fetcher:releases(calls,available)}),{}); + assert.match(text,/marketplace.*v5\.12\.0.*enregistrée/i); + assert.match(text,/release officielle.*v5\.13\.0.*Framework - Release 5\.13 : Télémétrie et Plan/i); + assert.match(text,/version enregistrée.*v5\.12\.0.*v5\.13\.0.*indicati/i); + assert.match(text,/source locale.*contenu.*différer/i); + assert.doesNotMatch(text,/plugins.*non vérifié/i); + assert.equal(calls.length,2); + assert.doesNotMatch(text,/mise à jour disponible/i); + assert.doesNotMatch(text,/01-update/); +}); + +test('equal local and official versions do not imply identical content', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'},'5.13.0'); + const text=format(await status({...options,fetcher:releases([],available)})); + assert.match(text,/marketplace.*v5\.13\.0.*enregistrée/i); + assert.match(text,/release officielle.*v5\.13\.0/i); + assert.match(text,/source locale.*contenu.*différer/i); + assert.doesNotMatch(text,/identique|à jour|mise à jour disponible/i); +}); + +test('retries unavailable release title without waiting for the daily release refresh', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'}); const calls=[]; + const fetcher=async url => { + calls.push(url); + if (url.includes('/releases?')) return {ok:true,json:async()=>available}; + return {ok:false}; + }; + const first=await status({...options,fetcher}); + assert.match(format(first),/titre.*indisponible/i); + assert.doesNotMatch(format(first),/v5\.13\.0\s*·\s*v5\.13\.0/); + const early=[]; + await status({...options,now:options.now+3599999,fetcher:releases(early,available)}); + assert.equal(early.length,0); + const retry=[]; + const recovered=await status({...options,now:options.now+3600000,fetcher:releases(retry,available)}); + assert.equal(retry.length,1); + assert.match(format(recovered),/Télémétrie et Plan/); + assert.doesNotMatch(format(recovered),/titre.*indisponible/i); +}); + +test('caches the official release for a local source and handles offline state', async t => { + const options=fixture(t,{kind:'local',path:'/local/framework'}); const calls=[]; + const first=await status({...options,fetcher:releases(calls,available)}); + const cached=await status({...options,now:options.now+23*3600000,fetcher:async()=>{throw Error('offline');}}); + assert.equal(calls.length,2); + assert.deepEqual(cached.latest,first.latest); + assert.match(format(cached),/vérifi.*2026-11-04 00:00 UTC/i); + const offline=await status({...options,now:options.now+86400000,fetcher:async()=>{throw Error('offline');}}); + assert.match(format(offline),/release.*indisponible/i); + assert.doesNotMatch(format(offline),/indicati|mise à jour disponible/i); +}); + +test('a local source with no declared version shows the official release without a version diff', async t => { + const options=fixture(t,{kind:'local',path:'/custom/fork'},'unknown'); + const text=format(await status({...options,fetcher:releases([],available)})); + assert.match(text,/version du marketplace inconnue/i); + assert.match(text,/release officielle.*v5\.13\.0/i); + assert.doesNotMatch(text,/indicati|mise à jour disponible/i); +}); + +test('a beta installation compares releases from its own channel', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.13.0-beta.1'},'5.13.0-beta.1'); + const choices=[{tag_name:'v5.13.0',name:'stable',assets:[],draft:false,prerelease:false},{tag_name:'v5.13.0-beta.2',name:'Beta two',assets:[],draft:false,prerelease:true}]; + const text=format(await status({...options,fetcher:releases([],choices)}),{}); + assert.match(text,/v5\.13\.0-beta\.2/); + assert.match(text,/Beta two/); + assert.doesNotMatch(text,/stable/); +}); + +test('the actual hook emits JSON with a visible unknown version without network', t => { + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'aidd-maintenance-hook-')); t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); + const child=spawnSync(process.execPath,[path.resolve(__dirname,'../../plugins/aidd-maintenance/hooks/session-start.cjs')],{cwd:dir,encoding:'utf8',env:{PATH:process.env.PATH,HOME:dir,AIDD_USER_CONFIG_DIR:dir},input:'{}'}); + assert.equal(child.status,0,child.stderr); + const output=JSON.parse(child.stdout); + assert.match(output.systemMessage,/version.*unknown|version.*inconnue/i); + assert.match(output.systemMessage,/source.*inconnue.*comparaison indisponible/i); +}); + +test('Claude native catalogue is only used when running in Claude', async t => { + const options = fixture(t); + fs.rmSync(path.join(options.cwd, '.aidd'), {recursive:true, force:true}); + const native = path.join(options.home, '.claude', 'plugins'); + const installed = path.join(options.home, 'native-marketplace'); + fs.mkdirSync(native, {recursive:true}); + fs.mkdirSync(path.join(installed, '.claude-plugin'), {recursive:true}); + fs.writeFileSync(path.join(native, 'known_marketplaces.json'), JSON.stringify({'aidd-framework':{installLocation:installed, source:{source:'directory'}}})); + fs.writeFileSync(path.join(installed, '.claude-plugin', 'marketplace.json'), JSON.stringify({name:'aidd-framework',version:'5.12.0'})); + assert.equal((await status({...options,fetcher:releases([],available)})).version, null); + assert.equal((await status({...options,env:{...options.env,CLAUDE_PLUGIN_ROOT:path.join(native,'cache','aidd-framework','aidd-maintenance','1.0.0')},fetcher:releases([],available)})).version, '5.12.0'); +}); + +test('a direct Claude installation uses CLAUDE_CONFIG_DIR to find its marketplace', async t => { + const options = fixture(t); + fs.rmSync(path.join(options.cwd, '.aidd'), {recursive:true, force:true}); + const native = path.join(options.home, 'custom-claude', 'plugins'); + const installed = path.join(options.home, 'native-marketplace'); + fs.mkdirSync(native, {recursive:true}); + fs.mkdirSync(path.join(installed, '.claude-plugin'), {recursive:true}); + fs.writeFileSync(path.join(native, 'known_marketplaces.json'), JSON.stringify({'aidd-framework':{installLocation:installed, source:{source:'directory'}}})); + fs.writeFileSync(path.join(installed, '.claude-plugin', 'marketplace.json'), JSON.stringify({name:'aidd-framework',version:'5.12.0'})); + const env = {...options.env, CLAUDE_CONFIG_DIR:path.dirname(native), CLAUDE_PLUGIN_ROOT:path.join(native,'cache','aidd-framework','aidd-maintenance','1.0.0')}; + assert.equal((await status({...options,env,fetcher:releases([],available)})).version, '5.12.0'); +}); + +test('Claude uses its native marketplace when an unrelated CLI registry also exists', async t => { + const options = fixture(t); + const native = path.join(options.home, '.claude', 'plugins'); + const installed = path.join(options.home, 'native-marketplace'); + fs.mkdirSync(native, {recursive:true}); + fs.mkdirSync(path.join(installed, '.claude-plugin'), {recursive:true}); + fs.writeFileSync(path.join(native, 'known_marketplaces.json'), JSON.stringify({'aidd-framework':{installLocation:installed, source:{source:'directory'}}})); + fs.writeFileSync(path.join(installed, '.claude-plugin', 'marketplace.json'), JSON.stringify({name:'aidd-framework',version:'5.13.0'})); + const env = {...options.env,CLAUDE_PLUGIN_ROOT:path.join(native,'cache','aidd-framework','aidd-maintenance','1.0.0')}; + assert.equal((await status({...options,env,fetcher:releases([],available)})).version,'5.13.0'); + assert.equal((await status({...options,env,tool:'opencode',fetcher:releases([],available)})).version,'5.12.0'); +}); + +test('Claude resolves a symlinked config directory before matching the native plugin', async t => { + const options=fixture(t); + const config=path.join(options.home,'real-claude'); + const alias=path.join(options.home,'claude-alias'); + const native=path.join(config,'plugins'); + const pluginRoot=path.join(native,'cache','aidd-framework','aidd-maintenance','1.0.0'); + const installed=path.join(options.home,'native-marketplace'); + fs.mkdirSync(pluginRoot,{recursive:true}); + fs.mkdirSync(path.join(installed,'.claude-plugin'),{recursive:true}); + fs.symlinkSync(config,alias,process.platform === 'win32' ? 'junction' : 'dir'); + fs.writeFileSync(path.join(native,'known_marketplaces.json'),JSON.stringify({'aidd-framework':{installLocation:installed,source:{source:'directory'}}})); + fs.writeFileSync(path.join(installed,'.claude-plugin','marketplace.json'),JSON.stringify({name:'aidd-framework',version:'5.13.0'})); + const env={...options.env,CLAUDE_CONFIG_DIR:alias,CLAUDE_PLUGIN_ROOT:pluginRoot}; + assert.equal((await status({...options,env,fetcher:releases([],available)})).version,'5.13.0'); +}); + +test('Claude recognizes its direct local marketplace plugin outside the cache', async t => { + const options=fixture(t); + const native=path.join(options.home,'.claude','plugins'); + const installed=path.join(options.home,'native-marketplace'); + const pluginRoot=path.join(installed,'plugins','aidd-maintenance'); + fs.mkdirSync(native,{recursive:true}); + fs.mkdirSync(pluginRoot,{recursive:true}); + fs.mkdirSync(path.join(installed,'.claude-plugin'),{recursive:true}); + fs.writeFileSync(path.join(native,'known_marketplaces.json'),JSON.stringify({'aidd-framework':{installLocation:installed,source:{source:'directory'}}})); + fs.writeFileSync(path.join(installed,'.claude-plugin','marketplace.json'),JSON.stringify({name:'aidd-framework',version:'5.13.0',plugins:[{name:'aidd-maintenance',source:'./plugins/aidd-maintenance'}]})); + const env={...options.env,CLAUDE_PLUGIN_ROOT:pluginRoot}; + assert.equal((await status({...options,env,fetcher:releases([],available)})).version,'5.13.0'); +}); + +test('Claude reports project plugins as unverified when their CLI registry is corrupt', async t => { + const options=fixture(t); + const native=path.join(options.home,'.claude','plugins'); + const installed=path.join(options.home,'native-marketplace'); + const pluginRoot=path.join(native,'cache','aidd-framework','aidd-maintenance','1.0.0'); + fs.mkdirSync(pluginRoot,{recursive:true}); + fs.mkdirSync(path.join(installed,'.claude-plugin'),{recursive:true}); + fs.writeFileSync(path.join(native,'known_marketplaces.json'),JSON.stringify({'aidd-framework':{installLocation:installed,source:{source:'directory'}}})); + fs.writeFileSync(path.join(installed,'.claude-plugin','marketplace.json'),JSON.stringify({name:'aidd-framework',version:'5.13.0'})); + fs.writeFileSync(path.join(options.cwd,'.aidd','marketplaces.json'),'{bad json'); + writeManifest(path.join(options.cwd,'.aidd','manifest.json'),{opencode:{plugins:[plugin('aidd-context','v5.12.0','2.9.0')]}}); + const text=format(await status({...options,env:{...options.env,CLAUDE_PLUGIN_ROOT:pluginRoot},fetcher:releases([],available)})); + assert.match(text,/marketplace v5\.13\.0/i); + assert.match(text,/plugins non vérifié/i); + assert.doesNotMatch(text,/opencode.*aidd-context/i); +}); + +test('a moving branch is never compared to releases', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'next'}); const calls=[]; + const result=await status({...options,fetcher:releases(calls,available)}); + assert.equal(calls.length,0); + assert.equal(result.hasUpdate,undefined); + assert.match(format(result),/non épinglée.*comparaison indisponible/i); +}); + +test('an unpinned GitHub source is never compared to releases', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework'}); const calls=[]; + const result=await status({...options,fetcher:releases(calls,available)}); + assert.equal(calls.length,0); + assert.equal(result.hasUpdate,undefined); + assert.match(format(result),/non épinglée.*comparaison indisponible/i); +}); + +test('a catalogue version inconsistent with its source tag is not compared', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.11.0'},'5.12.0'); const calls=[]; + const result=await status({...options,fetcher:releases(calls,available)}); + assert.equal(calls.length,0); + assert.equal(result.hasUpdate,undefined); + assert.match(format(result),/incohérente.*comparaison indisponible/i); +}); + +test('an unknown catalogue version is not compared', async t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'v5.12.0'},'invalid'); const calls=[]; + const result=await status({...options,fetcher:releases(calls,available)}); + assert.equal(calls.length,0); + assert.match(format(result),/version.*inconnue.*comparaison indisponible/i); +}); + +test('a Codex plugin never uses Claude marketplace data or claims the Claude skill', async t => { + const options=fixture(t); fs.rmSync(path.join(options.cwd,'.aidd'),{recursive:true,force:true}); + const native=path.join(options.home,'.claude','plugins'); const installed=path.join(options.home,'native-marketplace'); + fs.mkdirSync(native,{recursive:true}); fs.mkdirSync(path.join(installed,'.claude-plugin'),{recursive:true}); + fs.writeFileSync(path.join(native,'known_marketplaces.json'),JSON.stringify({'aidd-framework':{installLocation:installed,source:{source:'directory'}}})); + fs.writeFileSync(path.join(installed,'.claude-plugin','marketplace.json'),JSON.stringify({name:'aidd-framework',version:'5.12.0'})); + const env={...options.env,CLAUDE_PLUGIN_ROOT:path.join(options.home,'.codex','plugins','cache','aidd-framework','aidd-maintenance','1.0.0')}; + assert.equal((await status({...options,env,fetcher:releases([],available)})).version,null); + assert.doesNotMatch(format({version:'5.12.0',latest:{tag:'v5.13.0',title:'Title'},hasUpdate:true},env),/aidd-maintenance:01-update/); +}); + +test('the hook emits the valid native shape for each host', t => { + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'aidd-maintenance-host-')); t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); + const script=path.resolve(__dirname,'../../plugins/aidd-maintenance/hooks/session-start.cjs'); + const run=input=>JSON.parse(spawnSync(process.execPath,[script],{cwd:dir,encoding:'utf8',env:{PATH:process.env.PATH,HOME:dir,AIDD_USER_CONFIG_DIR:dir},input:JSON.stringify(input)}).stdout); + assert.deepEqual(Object.keys(run({hook_event_name:'SessionStart',model:'gpt-6.1-sol'})),['systemMessage']); + assert.deepEqual(Object.keys(run({hook_event_name:'SessionStart',source:'startup'})),['systemMessage']); + assert.deepEqual(Object.keys(run({sessionId:'id',timestamp:123,cwd:dir,source:'startup'})),['additionalContext']); + assert.deepEqual(Object.keys(run({session_id:'id',is_background_agent:false})),['additional_context']); +}); + +test('Copilot CLI displays a progress message before its final hook context', t => { + const options=fixture(t,{kind:'github',repo:'ai-driven-dev/framework',ref:'next'}); + const script=path.resolve(__dirname,'../../plugins/aidd-maintenance/hooks/session-start.cjs'); + const run=(input,cli)=>{ + const child=spawnSync(process.execPath,[script],{ + cwd:options.cwd, encoding:'utf8', input:JSON.stringify(input), + env:{PATH:process.env.PATH,HOME:options.home,...options.env,...(cli?{COPILOT_CLI:'1'}:{})}, + }); + assert.equal(child.status,0,child.stderr); + return child.stdout.split('\n').map(line=>JSON.parse(line)); + }; + const payload={sessionId:'id',timestamp:123,cwd:options.cwd,source:'startup'}; + const [progress,final]=run(payload,true); + assert.equal(progress.type,'progress'); + assert.match(progress.message,/marketplace v5\.12\.0/); + assert.deepEqual(final,{additionalContext:progress.message}); + assert.deepEqual(run(payload,false),[final]); + assert.equal(run({hook_event_name:'SessionStart',source:'startup'},true).length,1); + assert.deepEqual(Object.keys(run({session_id:'id',is_background_agent:false,timestamp:123},true)[0]),['additional_context']); +}); diff --git a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js index 2b88d1147..cda8871f4 100644 --- a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js +++ b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js @@ -28,7 +28,11 @@ function aiToolIds() { function declaresUserScopeActivation(toolId) { const profile = path.join(ROOT, "cli/src/contexts/tools/domain/profiles", toolId, "profile.ts"); const text = fs.readFileSync(profile, "utf8"); - return /nativeActivation:\s*\{/u.test(text) || /installScope:\s*"user"/u.test(text); + return ( + /nativeActivation:\s*\{/u.test(text) || + /installScope:\s*"user"/u.test(text) || + /globalFiles:\s*\{/u.test(text) + ); } function scriptUserAiList() { @@ -41,13 +45,20 @@ function scriptUserAiList() { describe("smoke-real.sh drives --scope user with the tools the profiles support", () => { it("names every AI tool whose profile declares machine-wide activation, and no other", () => { const supported = aiToolIds().filter(declaresUserScopeActivation); - assert.deepEqual([...supported].sort(), ["claude", "codex", "copilot", "cursor"]); + assert.deepEqual([...supported].sort(), [ + "claude", + "codex", + "copilot", + "cursor", + "kilo", + "opencode", + ]); assert.deepEqual([...scriptUserAiList()].sort(), [...supported].sort()); }); - it("leaves out AI tools that declare neither, which setup --scope user refuses", () => { + it("leaves out AI tools that declare none, which setup --scope user refuses", () => { const unsupported = aiToolIds().filter((id) => !declaresUserScopeActivation(id)); - assert.deepEqual([...unsupported].sort(), ["antigravity", "kilo", "opencode"]); + assert.deepEqual([...unsupported].sort(), ["antigravity"]); for (const toolId of unsupported) { assert.ok( !scriptUserAiList().includes(toolId), diff --git a/scripts/check-hosts.cjs b/scripts/check-hosts.cjs new file mode 100644 index 000000000..79f0a944d --- /dev/null +++ b/scripts/check-hosts.cjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node +// What each installed AI tool really loads of this checkout's plugins, as a table: asked of the +// tool itself where it can answer, read from the files its documentation says it loads where it +// cannot. Compares names, not contents. Exits 1 when an installed tool misses something, 2 when +// the checkout has no plugin to expect. +// +// Writes no setting. Listing agents opens a session that is killed as soon as it answers: Claude's +// (no hooks, no saved transcript) may have started one model request, whose answer nobody reads; +// Copilot's sends no prompt and leaves an entry in its session state. +// +// Sources for each proof: +// https://code.claude.com/docs/en/plugins/cli-reference +// https://code.claude.com/docs/en/headless +// https://developers.openai.com/codex/cli/reference +// https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference +// https://opencode.ai/v2/docs/skills +// https://kilo.ai/docs/customize/skills +// https://cursor.com/docs/plugins#test-plugins-locally +const { spawn, spawnSync } = require("node:child_process"); +const { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } = require("node:fs"); +const { homedir, tmpdir } = require("node:os"); +const { basename, join, resolve, sep } = require("node:path"); + +const MARKETPLACE = "aidd-framework"; +const INSTRUCTIONS_MARKER = ""; +const SESSION_TIMEOUT_MS = 60000; +const READS = 15; +const READ_PAUSE_MS = 2000; + +const rootFlag = process.argv.indexOf("--root"); +const root = resolve(rootFlag === -1 ? join(__dirname, "..") : (process.argv[rootFlag + 1] ?? ".")); +const home = homedir(); + +function listNames(dir) { + return existsSync(dir) ? readdirSync(dir) : []; +} + +const plugins = listNames(join(root, "plugins")).filter((name) => + existsSync(join(root, "plugins", name, ".claude-plugin/plugin.json")) +); +const skills = plugins.flatMap((plugin) => + listNames(join(root, "plugins", plugin, "skills")) + .filter((skill) => existsSync(join(root, "plugins", plugin, "skills", skill, "SKILL.md"))) + .map((skill) => ({ plugin, skill })) +); +const agents = plugins.flatMap((plugin) => + listNames(join(root, "plugins", plugin, "agents")) + .filter((file) => file.endsWith(".md")) + .map((file) => ({ plugin, agent: basename(file, ".md") })) +); + +// Neutral on purpose: a project's own settings, skills and agents would add to the answer. +let cwd; + +function run(binary, args) { + const result = spawnSync(binary, args, { + cwd, + encoding: "utf8", + timeout: SESSION_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }); + return result.status === 0 ? result.stdout : null; +} + +function installed(binary) { + return spawnSync(binary, ["--version"], { cwd, stdio: "ignore" }).error === undefined; +} + +function json(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function pause(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function fileHolds(path, text) { + return existsSync(path) && readFileSync(path, "utf8").includes(text); +} + +/** A markdown file's frontmatter alone, so a line of its body never counts. */ +function frontmatter(path) { + if (!existsSync(path)) return null; + return readFileSync(path, "utf8").match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? null; +} + +function field(block, key) { + return block?.match(new RegExp(`^${key}:[ \\t]*["']?([^"'\\r\\n]*)`, "m"))?.[1].trim(); +} + +/** A cell: the proof it was given, unless a name is missing. */ +function count(expected, found, proof) { + const missing = expected.filter((name) => !found.has(name)); + const tally = `${expected.length - missing.length}/${expected.length}`; + if (missing.length > 0) { + const shown = missing.slice(0, 3).join(", ") + (missing.length > 3 ? ", …" : ""); + return { status: "missing", text: `${tally}, missing ${shown}` }; + } + return { status: proof, text: tally }; +} + +function instructions(path) { + const shown = path.replace(home, "~"); + return fileHolds(path, INSTRUCTIONS_MARKER) + ? { status: "files", text: shown } + : { status: "missing", text: `no managed block in ${shown}` }; +} + +/** Reads a tool's JSON lines until one is `done`, then kills it. */ +function firstLine(binary, args, done, input = []) { + return new Promise((settle) => { + const child = spawn(binary, args, { cwd, stdio: ["pipe", "pipe", "ignore"] }); + let buffer = ""; + const finish = (value) => { + clearTimeout(timer); + child.kill(); + settle(value); + }; + const timer = setTimeout(() => finish(null), SESSION_TIMEOUT_MS); + child.on("error", () => finish(null)); + child.on("close", () => finish(null)); + child.stdin.on("error", () => {}); + child.stdout.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + const parsed = json(line); + if (parsed !== null && done(parsed)) finish(parsed); + } + }); + for (const message of input) child.stdin.write(`${JSON.stringify(message)}\n`); + if (input.length === 0) child.stdin.end(); + }); +} + +const qualifiedAgent = ({ plugin, agent }) => `${plugin}:${agent}`; +const flatAgent = ({ plugin, agent }) => `${plugin}-${agent}`; +const skillId = ({ plugin, skill }) => `${plugin}/${skill}`; +const qualifiedSkill = ({ plugin, skill }) => `${plugin}:${skill}`; + +/** The `/` of a skill's path, `...//[skills/][/SKILL.md]`. */ +function skillIdOf(path) { + const parts = String(path).split(/[\\/]/).filter((part) => part !== "SKILL.md"); + const skill = parts.pop(); + const parent = parts.pop(); + return `${parent === "skills" ? parts.pop() : parent}/${skill}`; +} + +/** The skills a tool lists that are really there, under the directory it was installed in: a + * stale list or a same-named skill elsewhere proves nothing. */ +function skillsUnder(paths, dir) { + return new Set( + paths + .map(String) + .filter((path) => path.startsWith(dir + sep) && existsSync(path)) + .map(skillIdOf) + ); +} + +function pluginsOf(found) { + return new Set([...found].map((id) => id.split("/")[0])); +} + +async function claude() { + const configDir = process.env.CLAUDE_CONFIG_DIR || join(home, ".claude"); + const listed = json(run("claude", ["plugin", "list", "--json"]) ?? "") ?? []; + const enabled = listed + .filter((p) => p.enabled && p.scope === "user" && p.id?.endsWith(`@${MARKETPLACE}`)) + .map((p) => p.id.split("@")[0]); + // `plugin details` prints Agents (0) for a manifest listing its agents by file; the session's + // own init event names what it really loaded. + const init = await firstLine( + "claude", + [ + "-p", + "Reply OK", + "--model", + "haiku", + "--max-turns", + "1", + "--no-session-persistence", + "--settings", + '{"disableAllHooks":true}', + "--output-format", + "stream-json", + "--verbose", + ], + (event) => event.type === "system" && event.subtype === "init" + ); + const loaded = new Set((init?.plugins ?? []).map((plugin) => plugin.name)); + return { + plugins: count(plugins, new Set(enabled.filter((name) => loaded.has(name))), "asked"), + instructions: instructions(join(configDir, "CLAUDE.md")), + skills: count(skills.map(qualifiedSkill), new Set(init?.skills ?? []), "asked"), + agents: count(agents.map(qualifiedAgent), new Set(init?.agents ?? []), "asked"), + }; +} + +function promptText(value) { + if (Array.isArray(value)) return value.map(promptText).join("\n"); + if (value === null || typeof value !== "object") return ""; + return [typeof value.text === "string" ? value.text : "", promptText(value.content)].join("\n"); +} + +async function codex() { + const codexHome = process.env.CODEX_HOME || join(home, ".codex"); + const listed = json(run("codex", ["plugin", "list", "--json"]) ?? "")?.installed ?? []; + const enabled = listed + .filter((p) => p.marketplaceName === MARKETPLACE && p.installed && p.enabled) + .map((p) => p.name); + const prompt = promptText(json(run("codex", ["debug", "prompt-input"]) ?? "") ?? []); + const available = [...prompt.matchAll(/^- ([a-z0-9-]+):([^:\s]+)/gm)].map( + (hit) => `${hit[1]}/${hit[2]}` + ); + const agentFiles = agents.map(flatAgent).filter((name) => { + const path = join(codexHome, "agents", `${name}.toml`); + return fileHolds(path, `name = "${name}"`) && fileHolds(path, "developer_instructions"); + }); + return { + plugins: count(plugins, new Set(enabled), "asked"), + instructions: prompt.includes(INSTRUCTIONS_MARKER) + ? { status: "asked", text: "in the prompt" } + : { status: "missing", text: "not in the prompt" }, + skills: count(skills.map(skillId), new Set(available), "asked"), + agents: count(agents.map(flatAgent), new Set(agentFiles), "files"), + }; +} + +async function copilot() { + const listed = json(run("copilot", ["plugin", "list", "--json"]) ?? "") ?? []; + const enabled = listed + .filter((p) => p.marketplace === MARKETPLACE && p.enabled) + .map((p) => p.name); + const sources = json(run("copilot", ["instruction", "list", "--json"]) ?? "") ?? []; + const managed = sources.some( + (source) => + source.sourcePath?.endsWith("copilot-instructions.md") && + fileHolds(source.sourcePath, INSTRUCTIONS_MARKER) + ); + const pluginSkills = (json(run("copilot", ["skill", "list", "--json"]) ?? "") ?? []).filter( + (skill) => skill.source === "plugin" && skill.enabled + ); + const session = await firstLine("copilot", ["--acp", "--no-auto-update"], (m) => m.id === 2, [ + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1, clientCapabilities: {} }, + }, + { jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd, mcpServers: [] } }, + ]); + const agentOption = session?.result?.configOptions?.find((option) => option.id === "agent"); + return { + plugins: count(plugins, new Set(enabled), "asked"), + instructions: managed + ? { status: "asked", text: "listed" } + : { status: "missing", text: "no managed copilot-instructions.md listed" }, + skills: count( + skills.map(skillId), + new Set( + pluginSkills.filter((skill) => existsSync(String(skill.path))).map((s) => skillIdOf(s.path)) + ), + "asked" + ), + agents: count( + agents.map(qualifiedAgent), + new Set(agentOption?.options?.map((option) => option.value) ?? []), + "asked" + ), + }; +} + +async function opencode() { + const configDir = (run("opencode", ["debug", "paths", "config"]) ?? "").trim(); + if (configDir === "") { + const silent = { status: "missing", text: "no config directory answered" }; + return { plugins: silent, instructions: silent, skills: silent, agents: silent }; + } + // Not --standalone: its private server answers an empty list before it has loaded. The service + // does too while it starts cold, so the list is read again until it holds every skill or the + // reads run out: a late answer costs a pause, never a wrong cell. + const expected = skills.map(skillId); + let found = new Set(); + for (let read = 0; read < READS; read += 1) { + const listed = json(run("opencode", ["api", "get", "/api/skill"]) ?? "")?.data ?? []; + found = skillsUnder( + listed.map((skill) => skill.path), + join(configDir, "skills") + ); + if (expected.every((id) => found.has(id))) break; + if (read < READS - 1) pause(READ_PAUSE_MS); + } + const listedAgents = json(run("opencode", ["debug", "agents", "--standalone"]) ?? "") ?? []; + return { + plugins: count(plugins, pluginsOf(found), "asked"), + instructions: instructions(join(configDir, "AGENTS.md")), + skills: count(expected, found, "asked"), + agents: count(agents.map(flatAgent), new Set(listedAgents.map((agent) => agent.id)), "asked"), + }; +} + +async function kilo() { + const configDir = (run("kilo", ["debug", "paths"]) ?? "").match(/^config\s+(.+)$/m)?.[1]?.trim(); + const listed = json(run("kilo", ["debug", "skill"]) ?? "") ?? []; + const found = skillsUnder( + listed.map((skill) => skill.location), + join(configDir ?? join(home, ".config/kilo"), "skills") + ); + const loaded = agents.map(flatAgent).filter((name) => run("kilo", ["debug", "agent", name]) !== null); + return { + plugins: count(plugins, pluginsOf(found), "asked"), + // Kilo documents no global AGENTS.md, only its config's `instructions` key. + instructions: { status: "none", text: "none documented" }, + skills: count(skills.map(skillId), found, "asked"), + agents: count(agents.map(flatAgent), new Set(loaded), "asked"), + }; +} + +async function cursor() { + const local = join(home, ".cursor/plugins/local"); + const valid = plugins.filter((plugin) => { + const path = join(local, plugin, ".cursor-plugin/plugin.json"); + return existsSync(path) && json(readFileSync(path, "utf8")) !== null; + }); + const conforming = skills.filter(({ plugin, skill }) => { + const block = frontmatter(join(local, plugin, "skills", skill, "SKILL.md")); + return field(block, "name") === skill && Boolean(field(block, "description")); + }); + const present = agents.filter(({ plugin, agent }) => + Boolean(field(frontmatter(join(local, plugin, "agents", `${agent}.md`)), "description")) + ); + return { + plugins: count(plugins, new Set(valid), "files"), + instructions: instructions(join(home, ".cursor/rules/aidd-agents.mdc")), + skills: count(skills.map(skillId), new Set(conforming.map(skillId)), "files"), + agents: count(agents.map(flatAgent), new Set(present.map(flatAgent)), "files"), + }; +} + +/** `agent` is Cursor's only when its help says so: other tools take that name. */ +function cursorInstalled() { + if (installed("cursor-agent") || installed("cursor")) return true; + return (run("agent", ["--help"]) ?? "").includes("Start the Cursor Agent"); +} + +const TOOLS = [ + { name: "Claude Code", present: () => installed("claude"), check: claude }, + { name: "Codex", present: () => installed("codex"), check: codex }, + { name: "Copilot", present: () => installed("copilot"), check: copilot }, + { name: "Cursor", present: cursorInstalled, check: cursor }, + { name: "Kilo", present: () => installed("kilo"), check: kilo }, + { name: "OpenCode", present: () => installed("opencode"), check: opencode }, +]; + +const MARK = { asked: "✅", files: "🟡", missing: "❌", none: "➖" }; + +function show(cell) { + return `${MARK[cell.status]} ${cell.text}`; +} + +async function main() { + if (plugins.length === 0) { + console.error(`No plugin to expect under ${root}.`); + return 2; + } + console.log("| Tool | Plugins enabled | Global instructions | Skills | Agents |"); + console.log("| --- | --- | --- | --- | --- |"); + let failed = false; + for (const tool of TOOLS) { + if (!tool.present()) { + console.log(`| ${tool.name} | not installed | | | |`); + continue; + } + const row = await tool.check(); + const cells = [row.plugins, row.instructions, row.skills, row.agents]; + if (cells.some((cell) => cell.status === "missing")) failed = true; + console.log(`| ${tool.name} | ${cells.map(show).join(" | ")} |`); + } + console.log( + "\n✅ the tool says so · 🟡 the files its documentation says it loads · ❌ missing · ➖ does not apply" + ); + return failed ? 1 : 0; +} + +cwd = mkdtempSync(join(tmpdir(), "aidd-check-hosts-")); +main().then((code) => { + rmSync(cwd, { recursive: true, force: true }); + process.exit(code); +}); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 32aad997a..0cc0561eb 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,22 +1,22 @@ #!/usr/bin/env bash -# (Re)install every plugin into Claude, Codex, OpenCode, Copilot and Cursor from THIS checkout. -# Claude reads the raw repo, already native; the others install from a tree the aidd CLI -# builds, so what runs locally matches what ships. Codex and OpenCode load no -# agent from a plugin, so their built agents are copied to the user's agents directory. +# (Re)install every plugin into Claude, Codex, OpenCode, Kilo, Copilot and Cursor from THIS +# checkout, the way a user does: `aidd setup --global` with this checkout as the framework +# source, so the reload exercises the shipped machine-wide install. +# A managed OpenCode host delegates to its fixed-purpose reload helper instead. # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool -# whose CLI is absent is skipped, and the first run needs network. A managed OpenCode host -# exposes `aidd-opencode-reload` instead, and that helper decides which checkout may load. +# whose CLI is absent is skipped, and the first run needs network. # # Global rules: sync only the Agents section in the managed block, preserving personal -# rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. +# rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. Kilo +# documents no global AGENTS.md, only its config's `instructions` key, so it gets none. # Paths honor native home/cache overrides; explicit task paths take precedence. # Sources: # https://developers.openai.com/codex/guides/agents-md # https://code.claude.com/docs/en/memory # OpenCode fallback and disable flags: # https://opencode.ai/docs/rules/#claude-code-compatibility -# Cursor local plugins: real directories only; local imports must be allowed. +# Cursor local plugins: local imports must be allowed. # https://cursor.com/docs/plugins#test-plugins-locally # Copilot native plugin list and install: # https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference @@ -33,13 +33,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FW="${FW:-$(dirname "$SCRIPT_DIR")}" MKT="${MKT:-aidd-framework}" AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # published fallback for sources without a built CLI -BUILD="${BUILD:-$HOME/.cache/aidd-framework-dev}" # per-tool native trees the marketplaces point at -CODEX_CACHE="${CODEX_CACHE:-${CODEX_HOME:-$HOME/.codex}/plugins/cache}" -CLAUDE_CACHE="${CLAUDE_CACHE:-${CLAUDE_CODE_PLUGIN_CACHE_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins}/cache}" -CODEX_AGENTS="${CODEX_AGENTS:-${CODEX_HOME:-$HOME/.codex}/agents}" -OPENCODE_SKILLS="${OPENCODE_SKILLS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills}" -OPENCODE_AGENTS="${OPENCODE_AGENTS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/agents}" -CURSOR_PLUGINS="${CURSOR_PLUGINS:-$HOME/.cursor/plugins/local}" CODEX_INSTRUCTIONS="${CODEX_INSTRUCTIONS:-${CODEX_HOME:-$HOME/.codex}/AGENTS.md}" CLAUDE_INSTRUCTIONS="${CLAUDE_INSTRUCTIONS:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md}" OPENCODE_INSTRUCTIONS="${OPENCODE_INSTRUCTIONS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/AGENTS.md}" @@ -50,6 +43,7 @@ HAVE_CODEX=0; command -v codex >/dev/null 2>&1 && HAVE_CODEX=1 HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1 HAVE_OPENCODE=0; command -v opencode >/dev/null 2>&1 && HAVE_OPENCODE=1 HAVE_MANAGED_OPENCODE=0; command -v aidd-opencode-reload >/dev/null 2>&1 && HAVE_MANAGED_OPENCODE=1 +HAVE_KILO=0; command -v kilo >/dev/null 2>&1 && HAVE_KILO=1 HAVE_COPILOT=0; command -v copilot >/dev/null 2>&1 && HAVE_COPILOT=1 HAVE_CURSOR=0 if command -v cursor >/dev/null 2>&1 || command -v cursor-agent >/dev/null 2>&1; then @@ -58,214 +52,80 @@ elif command -v agent >/dev/null 2>&1 && agent --help 2>/dev/null | grep 'Start HAVE_CURSOR=1 fi -build_tool() { - local tool="$1" - rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" - local mode=marketplace - [ "$tool" != opencode ] || mode=flat +aidd_cli() { if [ -f "$FW/cli/dist/cli.js" ]; then - node "$FW/cli/dist/cli.js" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + node "$FW/cli/dist/cli.js" "$@" else - npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" "$@" fi } -validate_opencode_skills() { - # Check every expected nonempty skill before replacing plugin namespaces. - local name skill skill_name - for name in "$@"; do - for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do - skill_name="$(basename "$(dirname "$skill")")" - if [ ! -s "$BUILD/opencode/.opencode/skills/$name/$skill_name/SKILL.md" ] && - [ ! -s "$BUILD/opencode/.opencode/skills/$name-$skill_name/SKILL.md" ]; then - echo "no built OpenCode skill for $name-$skill_name (missing or empty SKILL.md)" >&2 - return 1 - fi - done - done +# A plugin keeps its version across edits to this checkout, so Claude and Codex would keep the +# copy they cached: each reload hands them the plugins afresh. Copilot loads a local +# marketplace live, and its uninstall leaves each plugin disabled rather than gone, which the +# CLI then reads as a ref it does not own, so it is left alone. Claude's `remove --scope user` +# drops only the settings declaration and keeps the registration, which setup --global then +# takes as still declared, so a marketplace no project uses is removed from every scope. That +# removal also uninstalls what a project installed from it, so one in use stays at user scope. +claude_marketplace_used_by_a_project() { + { (cd "$HOME" && claude plugin list --json 2>/dev/null) || true; } | node -e ' + try { + const plugins = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + process.exit(plugins.some((p) => p.id?.endsWith(`@${process.argv[1]}`) && p.scope !== "user") ? 0 : 1); + } catch { process.exit(0); } + ' "$MKT" } -validate_native_plugins() { - local tool="$1" manifest="$2" name skill +release_native_plugins() { + local name for name in "${targets[@]}"; do - if [ ! -s "$BUILD/$tool/plugins/$name/$manifest" ]; then - echo "no built $tool manifest for $name" >&2; return 1 - fi - for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do - if [ ! -s "$BUILD/$tool/plugins/$name/skills/$(basename "$(dirname "$skill")")/SKILL.md" ]; then - echo "no built $tool skill for $name-$(basename "$(dirname "$skill")")" >&2; return 1 - fi - done - done -} - -sync_opencode_skills() { - # Accept nested and legacy flat builds; preserve personal and unselected plugin content. - local name source skill destination - local -a skills - mkdir -p "$OPENCODE_SKILLS" - source="$BUILD/opencode/.opencode/skills" - for name in "$@"; do - skills=("$source/$name/"*/ "$source/$name-"*/) - rm -rf -- "$OPENCODE_SKILLS/$name" - find "$OPENCODE_SKILLS" -mindepth 1 -maxdepth 1 -type d -name "$name-*" -exec rm -rf -- {} + - for skill in "${skills[@]}"; do - case "$skill" in - "$source/$name/"*) destination="$OPENCODE_SKILLS/$name/$(basename "$skill")" ;; - *) destination="$OPENCODE_SKILLS/$(basename "$skill")" ;; - esac - mkdir -p "$(dirname "$destination")" - cp -R "$skill" "$destination" - done - done -} - -copy_agents() { - # Overwrite only the plugin's own agents; personal agents are left untouched. - local destination="$1" prefix="$2" agent - shift 2 - for agent in "$@"; do - mkdir -p "$destination" - cp -f "$agent" "$destination/$prefix$(basename "$agent")" + [ "$HAVE_CLAUDE" = 0 ] || claude plugin uninstall "$name@$MKT" --scope user >/dev/null 2>&1 || true + [ "$HAVE_CODEX" = 0 ] || codex plugin remove "$name@$MKT" >/dev/null 2>&1 || true done -} - -register_marketplace() { - case "$1" in - # Codex needs the native .codex-plugin tree. - codex) - codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true - codex plugin marketplace add "$BUILD/codex" >/dev/null 2>&1 ;; - # Claude installs the native repository. Scope every op to user: bare `marketplace remove` strips - # the declaration from EVERY scope, wiping the repo's own project-scoped config. - claude) - claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true - claude plugin marketplace add "$FW" --scope user >/dev/null 2>&1 ;; - copilot) - local registered_source - registered_source="$(copilot plugin marketplace list --json | node -e ' - const entries = JSON.parse(require("node:fs").readFileSync(0, "utf8")); - if (!Array.isArray(entries)) throw new Error("Invalid Copilot marketplace list"); - const entry = entries.find(item => item.name === process.argv[1]); - if (entry && typeof entry.source !== "string") throw new Error("Invalid Copilot marketplace source"); - process.stdout.write(entry?.source ?? ""); - ' "$MKT")" || return 1 - case "$registered_source" in - "") copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; - "Local: $BUILD/copilot") copilot plugin marketplace update "$MKT" >/dev/null ;; - "Local: $FW") - # Native list also includes inherited Claude registrations, which remove cannot change. - local add_result - if add_result="$(copilot plugin marketplace add "$BUILD/copilot" 2>&1)"; then - return 0 - fi - case "$add_result" in - *"already registered"*) - copilot plugin marketplace remove "$MKT" --force >/dev/null || return 1 - copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; - *) printf '%s\n' "$add_result" >&2; return 1 ;; - esac ;; - *) echo "Refusing a different Copilot marketplace source for $MKT: $registered_source" >&2; return 1 ;; - esac ;; - esac -} - -sync_one() { - local name="$1" - [ -f "$FW/plugins/$name/.claude-plugin/plugin.json" ] || { echo "skip $name (no plugin.json)"; return; } - printf '%-22s' "$name" - - if [ "$HAVE_CODEX" = 1 ]; then - codex plugin remove "$name" >/dev/null 2>&1 || true - rm -rf "$CODEX_CACHE/$MKT/$name" - if codex plugin add "$name@$MKT" >/dev/null 2>&1; then - printf ' codex:ok' - copy_agents "$CODEX_AGENTS" "$name-" "$BUILD/codex/plugins/$name/codex-agents/"*.toml - else - printf ' codex:FAIL' - echo "Codex plugin install failed for $name; rerun codex plugin add $name@$MKT for details." >&2 - return 1 - fi - fi if [ "$HAVE_CLAUDE" = 1 ]; then - rm -rf "$CLAUDE_CACHE/$MKT/$name" - if claude plugin install "$name@$MKT" --scope user >/dev/null 2>&1; then - printf ' claude:ok' - else - printf ' claude:FAIL' - echo "Claude plugin install failed for $name; rerun claude plugin install $name@$MKT --scope user for details." >&2 - return 1 - fi - fi - if [ "$HAVE_COPILOT" = 1 ]; then - if copilot plugin install "$name@$MKT" >/dev/null; then - printf ' copilot:ok' + if claude_marketplace_used_by_a_project; then + claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true else - echo "Copilot plugin install failed for $name" >&2; return 1 + (cd "$HOME" && claude plugin marketplace remove "$MKT") >/dev/null 2>&1 || true fi fi - echo + [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true } -if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ] && [ "$HAVE_COPILOT" = 0 ] && [ "$HAVE_CURSOR" = 0 ]; then +hosts=() +[ "$HAVE_CLAUDE" = 0 ] || hosts+=(claude) +[ "$HAVE_CODEX" = 0 ] || hosts+=(codex) +[ "$HAVE_COPILOT" = 0 ] || hosts+=(copilot) +[ "$HAVE_CURSOR" = 0 ] || hosts+=(cursor) +[ "$HAVE_KILO" = 0 ] || hosts+=(kilo) +[ "$HAVE_OPENCODE" = 0 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ] || hosts+=(opencode) +if [ "${#hosts[@]}" -eq 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then echo "No supported host installation found - nothing to install."; exit 0 fi targets=() -for d in "$FW"/plugins/*/; do targets+=("$(basename "$d")"); done - -# Codex needs a native plugin build; register against it. Claude installs from the raw -# repo (already native), so it only needs the marketplace registered - no build. -if [ "$HAVE_CODEX" = 1 ]; then - printf '%-22s' "build codex" - build_tool codex || { echo "FAIL"; echo "codex build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } - register_marketplace codex || { echo "FAIL"; echo "Codex marketplace registration failed for $BUILD/codex." >&2; exit 1; } - echo "ok" -fi -if [ "$HAVE_CLAUDE" = 1 ]; then - register_marketplace claude -fi -if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then - printf '%-22s' "build opencode" - build_tool opencode && echo "ok" || { echo "FAIL"; echo "opencode build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } -fi -if [ "$HAVE_CURSOR" = 1 ]; then - printf '%-22s' "build cursor" - build_tool cursor || { echo "FAIL"; echo "cursor build failed" >&2; exit 1; } - validate_native_plugins cursor .cursor-plugin/plugin.json || { echo "FAIL"; exit 1; } - echo "ok" -fi -if [ "$HAVE_COPILOT" = 1 ]; then - printf '%-22s' "build copilot" - build_tool copilot || { echo "FAIL"; echo "copilot build failed" >&2; exit 1; } - validate_native_plugins copilot .plugin/plugin.json || { echo "FAIL"; exit 1; } - register_marketplace copilot || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } - echo "ok" -fi - -for t in "${targets[@]}"; do sync_one "$t"; done +for d in "$FW"/plugins/*/; do + [ -f "$d.claude-plugin/plugin.json" ] && targets+=("$(basename "$d")") +done -if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then - printf '%-22s' "install opencode" - validate_opencode_skills "${targets[@]}" || { echo "FAIL"; exit 1; } - sync_opencode_skills "${targets[@]}" - for t in "${targets[@]}"; do copy_agents "$OPENCODE_AGENTS" "" "$BUILD/opencode/.opencode/agents/$t-"*.md; done - echo "skills:ok agents:ok" -fi if [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then printf '%-22s' "reload opencode" aidd-opencode-reload fi -if [ "$HAVE_CURSOR" = 1 ]; then - printf '%-22s' "install cursor" - mkdir -p "$CURSOR_PLUGINS" - for t in "${targets[@]}"; do - rm -rf -- "$CURSOR_PLUGINS/$t" - cp -R "$BUILD/cursor/plugins/$t" "$CURSOR_PLUGINS/$t" - done - echo "plugins:ok (requires local plugin imports)" + +if [ "${#hosts[@]}" -gt 0 ]; then + printf '%-22s' "global install" + release_native_plugins + # From HOME, not this checkout: its own .claude/settings.json enables the plugins at project + # scope, which the host CLIs report and setup --global would refuse as refs it does not own. + output="$(cd "$HOME" && aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ + --plugins all --yes 2>&1)" || { + echo "FAIL"; printf '%s\n' "$output" >&2 + echo "aidd setup --global failed for ${hosts[*]}; their plugins were released, so rerun make reload once fixed." >&2; exit 1 + } + echo "ok ($(IFS=,; echo "${hosts[*]}"))" + # A warning leaves setup at exit 0: what it skipped must stay visible. + printf '%s\n' "$output" | sed 's/^/ /' fi instructions=() @@ -276,13 +136,15 @@ if [ "$HAVE_OPENCODE" = 1 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then fi [ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") [ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") -printf '%-22s' "global instructions" -# OpenCode uses this fixed Claude fallback, independent of Claude config overrides. -instruction_fallback="$HOME/.claude/CLAUDE.md" -case "${OPENCODE_DISABLE_CLAUDE_CODE:-}" in 1|true) instruction_fallback="" ;; esac -case "${OPENCODE_DISABLE_CLAUDE_CODE_PROMPT:-}" in 1|true) instruction_fallback="" ;; esac -node "$SCRIPT_DIR/sync-global-instructions.cjs" "$FW/AGENTS.md" "$instruction_fallback" "${instructions[@]}" || - { echo "FAIL"; exit 1; } -echo "ok (delegation guidance only)" +if [ "${#instructions[@]}" -gt 0 ]; then + printf '%-22s' "global instructions" + # OpenCode uses this fixed Claude fallback, independent of Claude config overrides. + instruction_fallback="$HOME/.claude/CLAUDE.md" + case "${OPENCODE_DISABLE_CLAUDE_CODE:-}" in 1|true) instruction_fallback="" ;; esac + case "${OPENCODE_DISABLE_CLAUDE_CODE_PROMPT:-}" in 1|true) instruction_fallback="" ;; esac + node "$SCRIPT_DIR/sync-global-instructions.cjs" "$FW/AGENTS.md" "$instruction_fallback" "${instructions[@]}" || + { echo "FAIL"; exit 1; } + echo "ok (delegation guidance only)" +fi echo "Done. Restart hosts or reload their configuration for refreshed plugins and instructions." diff --git a/scripts/gate-witness.js b/scripts/gate-witness.js index 8ea099269..453b6587d 100644 --- a/scripts/gate-witness.js +++ b/scripts/gate-witness.js @@ -67,7 +67,14 @@ function main() { console.log(`âś“ ${name}: skipped, this exact tree already passed it.`); return 0; } - const result = spawnSync(command.join(" "), { stdio: "inherit", shell: true }); + // Git's repository-local hook variables would redirect the gate's temporary repositories. + const localGitVars = new Set( + git(root, ["rev-parse", "--local-env-vars"]).toString("utf8").trim().split(/\r?\n/) + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !localGitVars.has(key.toUpperCase())) + ); + const result = spawnSync(command.join(" "), { stdio: "inherit", shell: true, env }); if (result.status !== 0) return result.status ?? 1; if (treeKey(root, command) !== before) { console.log(`${name}: passed, but the tree changed while it ran, so it is not stamped.`); diff --git a/scripts/sweep-stale-test-dirs.cjs b/scripts/sweep-stale-test-dirs.cjs deleted file mode 100644 index f62c4d4ea..000000000 --- a/scripts/sweep-stale-test-dirs.cjs +++ /dev/null @@ -1,48 +0,0 @@ -// Removes temp directories a killed test run left behind, before the next run starts: an -// `after`/`finally` never fires on a kill, so the directory survives with whatever it held, -// and enough of those fill a disk far enough that no green run can be obtained at all. -// One implementation for both callers, the CLI's vitest globalSetup and the node:test suites. -const { readdirSync, rmSync, statSync } = require("node:fs"); -const { tmpdir } = require("node:os"); -const { join } = require("node:path"); - -// The prefixes the suites actually use, never a wildcard: `os.tmpdir()` is shared with the -// rest of the machine and nothing here is entitled to delete a stranger's files. -const PREFIXES = [ - "aidd-", - "auth-storage-test-", - "marketplace-registry-", - "mkt-list-", - "trust-store-", -]; - -// Only what a concurrent run cannot still be inside: deleting a directory another process -// is writing into would trade a disk leak for a flake, the worse of the two. -const STALE_AFTER_MS = 60 * 60 * 1000; - -function sweepStaleTestDirs(now = Date.now()) { - const root = tmpdir(); - let removed = 0; - let entries; - try { - entries = readdirSync(root, { withFileTypes: true }); - } catch { - return 0; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!PREFIXES.some((prefix) => entry.name.startsWith(prefix))) continue; - const full = join(root, entry.name); - try { - if (now - statSync(full).mtimeMs < STALE_AFTER_MS) continue; - rmSync(full, { recursive: true, force: true }); - removed += 1; - } catch { - // Another run may own it, or the OS may already have reclaimed it. Never fail a run - // over housekeeping: this improves the next run, it does not gate this one. - } - } - return removed; -} - -module.exports = { sweepStaleTestDirs, PREFIXES, STALE_AFTER_MS }; diff --git a/scripts/sync-generated-docs.mjs b/scripts/sync-generated-docs.mjs index 74e6603f9..6644bc245 100644 --- a/scripts/sync-generated-docs.mjs +++ b/scripts/sync-generated-docs.mjs @@ -20,7 +20,7 @@ const SUPPORT_RECORD = `${ROOT}cli/tests/golden/snapshots/tool-support/surface.j const PLUGIN_CONFIG = `${ROOT}docs/assets/plugins/plugins.json`; -const SCRIPT_REFERENCE = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g; +const SCRIPT_REFERENCE = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"\\]+)/g; const SURFACES = [ ["skills", "Skills"], ["agents", "Agents"],