From 46f615ee3c86466aead164ffa30c52107eafecd7 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 09:48:18 +0800 Subject: [PATCH 01/38] feat(cli): install the framework for every project with setup --global Until now `setup --scope user` only registered the framework source: it refused --plugins, refused OpenCode, and installed nothing, so every machine-wide install lived in make reload's own copies. `aidd setup --global --ide claude,codex,opencode --plugins all|recommended|` now installs: Claude Code, Codex and Copilot enable the plugins through their own CLI at user scope; OpenCode, which has no plugin system, gets each plugin's skills and agents copied into ~/.config/opencode, and Codex, whose plugins cannot carry agents, its agents into ~/.codex/agents. A profile declares those copies as `machineFiles`; they are recorded in the machine manifest as user-scope plugins and written by the existing user-scope plugin writer, so `sync --global` rebuilds them from the framework source and `clean --global` removes them without touching files aidd did not write. `--global` is the spelling of `--scope user` on setup, sync, doctor and clean, and `--ide` names every tool, AI assistants included (`--ai` stays as a hidden former spelling). --- cli/README.md | 36 ++-- .../framework-source-is-machine-scope.md | 5 +- cli/package.json | 2 +- cli/scripts/check-bundle-size.mjs | 2 + .../marketplace-sync-settings-use-case.ts | 28 ++- .../ownership/machine-plugin-files.ts | 98 +++++++++++ .../ownership/user-plugin-file-updater.ts | 40 +++-- .../ownership/user-plugin-update-use-case.ts | 12 +- .../plugin/plugin-update-use-case.ts | 3 + .../framework/application/setup-use-case.ts | 4 + .../setup/install-machine-plugins-use-case.ts | 112 ++++++++++++ .../setup/setup-machine-scope-use-case.ts | 34 +++- .../contexts/framework/domain/setup-flow.ts | 10 +- .../domain/capabilities/plugins-capability.ts | 37 +++- .../domain/profiles/codex/codex-paths.ts | 13 ++ .../tools/domain/profiles/codex/profile.ts | 2 + .../profiles/opencode/opencode-paths.ts | 18 ++ .../tools/domain/profiles/opencode/profile.ts | 5 + cli/src/contexts/tools/domain/registry.ts | 18 +- cli/src/kernel/errors.ts | 20 +-- cli/src/presentation/commands/clean.ts | 6 +- cli/src/presentation/commands/doctor.ts | 6 +- .../presentation/commands/global-options.ts | 10 ++ cli/src/presentation/commands/setup.ts | 59 ++++--- cli/src/presentation/commands/sync.ts | 28 ++- cli/src/presentation/display/setup-display.ts | 9 + cli/src/presentation/display/sync-display.ts | 13 +- cli/src/runtime/wiring/framework.ts | 35 ++-- .../orchestrator-deps.arch.test.ts | 7 + .../machine-plugin-files.unit.test.ts | 159 ++++++++++++++++++ .../application/setup-use-case.unit.test.ts | 2 + ...tall-machine-plugins-use-case.unit.test.ts | 129 ++++++++++++++ .../framework/domain/setup-flow.unit.test.ts | 24 ++- .../profiles/codex/machine-files.unit.test.ts | 30 ++++ .../opencode/machine-files.unit.test.ts | 42 +++++ cli/tests/e2e/command-matrix-help.e2e.test.ts | 7 +- cli/tests/e2e/setup-scope-user.e2e.test.ts | 80 ++++++--- cli/tests/golden/snapshots/help/surface.json | 8 +- .../golden/snapshots/phase0/snapshot.json | 4 +- cli/tests/kernel/errors.unit.test.ts | 8 +- .../commands/clean-wiring.integration.test.ts | 4 + .../doctor-wiring.integration.test.ts | 1 + .../commands/setup-options.unit.test.ts | 13 +- .../commands/setup-wiring.integration.test.ts | 75 ++++----- .../commands/sync-wiring.integration.test.ts | 29 +++- .../display/setup-display.unit.test.ts | 26 +++ .../display/sync-display.unit.test.ts | 19 +++ docs/SUBAGENTS.md | 9 +- 48 files changed, 1130 insertions(+), 211 deletions(-) create mode 100644 cli/src/contexts/framework/application/ownership/machine-plugin-files.ts create mode 100644 cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts create mode 100644 cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts diff --git a/cli/README.md b/cli/README.md index a0d93a103..de2e31675 100644 --- a/cli/README.md +++ b/cli/README.md @@ -25,13 +25,18 @@ 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 | ## Commands @@ -101,31 +106,34 @@ 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` | Copies the plugins' skills and agents into `~/.config/opencode/` | +| `cursor` | Records the source and tool only | +| `kilo` | Unsupported: use project scope | 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 run `aidd sync --global`. ```sh -aidd setup --scope user --ai claude,codex,copilot --plugins none --yes +aidd setup --global --ide claude,codex,opencode --plugins all --yes +aidd sync --global # rebuild the copied files and refresh the hosts from the framework source +aidd clean --global # remove what --global installed and purge the shared source ``` ## Authentication 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..f8f4be92a 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 @@ -49,13 +49,14 @@ One registration of `aidd-framework` per machine, shared by every project. Read - Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse 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.machineFiles` (opencode's skills and agents, codex's agents) gets them copied into its user directory 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`, false for kilo alone). - Both refusals fire in `SetupFlow`'s constructor; `--ai all` is unusable at this scope. diff --git a/cli/package.json b/cli/package.json index 58d2105c7..ccf272438 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 722, + "bundleBudgetKB": 747, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index a9deb0584..fd2d69e9e 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -55,6 +55,8 @@ 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. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; 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..02fc810f8 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 @@ -95,6 +95,12 @@ 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[]; } interface ActivationOutcome { @@ -231,7 +237,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, toolIds, - scope + scope, + options.enablePlugins ?? [] ); let declarativeChanged = false; if (scope === "project") { @@ -403,7 +410,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest: Manifest, marketplaces: readonly Marketplace[], toolIds: readonly ToolId[], - scope: MarketplaceScope + scope: MarketplaceScope, + enablePlugins: readonly string[] ): Promise { const outcomes = new Map(); const binaryMissing: { toolId: ToolId; binary: string }[] = []; @@ -426,7 +434,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, warnings, - scope + scope, + enablePlugins ); outcomes.set(toolId, outcome); } catch (error) { @@ -529,7 +538,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest: Manifest, marketplaces: readonly Marketplace[], warnings: string[], - scope: MarketplaceScope + scope: MarketplaceScope, + enablePlugins: 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 — @@ -608,9 +618,17 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); } const hostNameByAlias = new Map(stillProven.map((m) => [m.alias, m.hostName])); + const frameworkHost = hostNameByAlias.get(FRAMEWORK_MARKETPLACE_NAME); + const requested = + frameworkHost === undefined ? [] : enablePlugins.map((name) => `${name}@${frameworkHost}`); const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables( toolId, - this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), + [ + ...new Set([ + ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), + ...requested, + ]), + ], manifest, projectRoot ); diff --git a/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts b/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts new file mode 100644 index 000000000..51903e1c2 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts @@ -0,0 +1,98 @@ +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 { machineFilesOf } 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 readMachinePluginFiles( + fs: FileReader, + hasher: Hasher, + builtDir: string, + toolId: AiToolId, + dist: PluginDistribution +): Promise { + const machine = machineFilesOf(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 unless it already holds + * the same bytes. Creates the user directory itself when the host has not yet. + */ +export async function assertMachineFilesWritable( + fs: FileReader & FileWriter, + toolId: AiToolId, + plugin: Pick | undefined, + pluginName: string, + files: readonly InstallationFile[], + homedir: string +): Promise { + const machine = machineFilesOf(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; + if ((await fs.readFileHash(target)).value === file.hash.value) 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/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index 5966418f4..260af0573 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 @@ -10,6 +10,7 @@ import type { AiToolId } from "../../../../kernel/tool.js"; import { frameworkBuildModeFor, getToolConfig, + machineFilesOf, resolvePluginsCapability, } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; @@ -28,6 +29,7 @@ import { assertUserScopeWriteBoundary, userScopeFilesSafeToDelete, } from "../shared/user-scope-plugin-files.js"; +import { assertMachineFilesWritable, readMachinePluginFiles } from "./machine-plugin-files.js"; export interface PlannedUserPluginFileUpdate { readonly plugin: InstalledPlugin; @@ -61,10 +63,11 @@ export class UserPluginFileUpdater { plugin: InstalledPlugin, toolId: AiToolId, projectRoot: string, - logger: Logger + logger: Logger, + rebuild = false ): Promise { const dist = await this.loader.loadLatest(plugin, projectRoot); - if (compareSemver(dist.manifest.version, plugin.version) <= 0) return null; + if (!rebuild && compareSemver(dist.manifest.version, plugin.version) <= 0) return null; const { files, componentPaths } = await this.materializeFiles( dist, toolId, @@ -78,9 +81,8 @@ export class UserPluginFileUpdater { `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` ); } - await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, files, home); + await this.assertWritable(plugin, toolId, files, home); const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home); - await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); return { plugin, toolId, @@ -112,14 +114,7 @@ export class UserPluginFileUpdater { `${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); await writePluginFiles([...plan.files], plan.baseDir, this.fs); const newPaths = new Set(plan.files.map((file) => file.relativePath)); await deleteOldFiles( @@ -130,6 +125,23 @@ export class UserPluginFileUpdater { return plan.next; } + /** 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 { + if (machineFilesOf(toolId) !== null) { + await assertMachineFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); + return; + } + await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home); + const baseDir = resolveBaseDirFromRecord("user", toolId, "", () => home); + await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); + } + private async assertNewFilesDoNotCollide( plugin: InstalledPlugin, toolId: AiToolId, @@ -165,6 +177,10 @@ export class UserPluginFileUpdater { target: toolId, mode: frameworkBuildModeFor(toolId), }); + if (machineFilesOf(toolId) !== null) { + const files = await readMachinePluginFiles(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" 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..15e05e30f 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 @@ -39,7 +39,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) => @@ -68,7 +70,13 @@ export class UserPluginUpdateUseCase { targets.map(async ({ toolId, plugin }) => ({ toolId, plugin, - plan: await this.fileUpdater.planUpdate(plugin, toolId, options.projectRoot, this.logger), + plan: await this.fileUpdater.planUpdate( + plugin, + toolId, + options.projectRoot, + this.logger, + options.rebuild === true + ), })) ); const nativeUpdates = await Promise.all( 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..1af12e092 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 @@ -31,6 +31,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 { diff --git a/cli/src/contexts/framework/application/setup-use-case.ts b/cli/src/contexts/framework/application/setup-use-case.ts index 1424928bc..345f8da66 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 machine-scope run: the plugins it installed for every project. */ + machinePlugins?: readonly string[]; } | { kind: "up-to-date"; install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; + /** Set by a machine-scope run: the plugins it installed for every project. */ + machinePlugins?: readonly string[]; }; export class SetupUseCase { diff --git a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts new file mode 100644 index 000000000..2f12b3f24 --- /dev/null +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -0,0 +1,112 @@ +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 { machineFilesOf } 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 { UserPluginFileUpdater } from "../ownership/user-plugin-file-updater.js"; + +export interface InstallMachinePluginsOptions { + readonly projectRoot: string; + readonly toolIds: readonly ToolId[]; + readonly mode: PluginInstallMode; + readonly pluginNames: readonly string[]; +} + +export interface InstallMachinePluginsResult { + /** 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 machine files (OpenCode's skills and agents, Codex's agents) into the tool's + * user configuration directory, 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. + */ +export class InstallMachinePluginsUseCase { + constructor( + private readonly registry: Pick, + private readonly resolveMarketplace: ResolveMarketplace, + private readonly fileUpdater: Pick, + private readonly logger: Logger + ) {} + + async execute( + manifest: Manifest, + options: InstallMachinePluginsOptions + ): Promise { + if (options.mode === "none" || options.mode === "interactive") return { plugins: [] }; + 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); + for (const toolId of options.toolIds) { + if (machineFilesOf(toolId) === null) continue; + for (const entry of entries) { + const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); + const plugin = + owned ?? + InstalledPlugin.fromMetadata( + entry.name, + entry.version ?? "0.0.0", + resolvePluginSourceFromMarketplace(entry.source, marketplace, localPath), + entry.strict, + "user", + marketplace.name + ); + await this.install(manifest, toolId as AiToolId, plugin, owned !== undefined, options); + } + } + return { plugins: entries.map((entry) => entry.name) }; + } + + private async install( + manifest: Manifest, + toolId: AiToolId, + plugin: InstalledPlugin, + owned: boolean, + options: InstallMachinePluginsOptions + ): Promise { + const plan = await this.fileUpdater.planUpdate( + plugin, + toolId, + options.projectRoot, + this.logger, + true + ); + if (plan === null || (plan.files.length === 0 && !owned)) return; + const next = await this.fileUpdater.applyUpdate(plan, this.logger); + if (owned) manifest.updatePlugin(toolId, next); + else manifest.addPlugin(toolId, next); + } +} + +function selectEntries( + catalog: readonly PluginCatalogEntry[], + options: InstallMachinePluginsOptions +): 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..714147654 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 @@ -8,12 +8,14 @@ import type { } 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 { InstallMachinePluginsUseCase } from "./install-machine-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 +27,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 installMachinePluginsUseCase?: InstallMachinePluginsUseCase ) {} async execute(flow: SetupFlow): Promise { @@ -43,16 +46,33 @@ export class SetupMachineScopeUseCase { const isNew = await this.initUserManifest(); await this.setupMarketplaceRegistration.registerIfPresent(flow, source); await this.registerUserScopeTools(flow); + const plugins = await this.installMachinePlugins(flow); const activation: MarketplaceSyncSettingsResult = await this.marketplaceSyncSettingsUseCase.execute({ projectRoot: flow.projectRoot, scope: "user", manifestRepo: this.userManifestRepo, + 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, machinePlugins: plugins }; + return isNew ? { kind: "initialized", ...outcome } : { kind: "up-to-date", ...outcome }; + } + + /** Copies the chosen plugins' machine files and returns their names, which every tool with + * native activation then enables machine-wide. */ + private async installMachinePlugins(flow: SetupFlow): Promise { + if (this.installMachinePluginsUseCase === undefined) return []; + const manifest = await this.userManifestRepo.load(); + if (manifest === null) return []; + const { plugins } = await this.installMachinePluginsUseCase.execute(manifest, { + projectRoot: flow.projectRoot, + toolIds: flow.aiTools, + mode: flow.pluginMode, + pluginNames: flow.pluginNames, + }); + await this.userManifestRepo.save(manifest); + return plugins; } private async initUserManifest(): Promise { 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/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index b488a508f..54757e2a3 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -70,6 +70,32 @@ 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 skills and agents, Codex's agents. + */ +export interface MachineFiles { + /** 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: MachinePlugin) => 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 MachinePlugin { + readonly name: string; + readonly agents: ReadonlySet; +} + +/** Shared by every mode: where a tool's machine-scope files go, when it has any. */ +interface MachineFilesParams { + machineFiles?: MachineFiles; +} + export interface NativePluginsParams extends ProjectHooksDelivery { mode: "native"; pluginsDir: string; @@ -153,10 +179,12 @@ export type HooksSupport = | { acceptsHooks: true; hooksTrustNotice?: string } | { acceptsHooks: false; hooksUnsupportedReason: string }; -type PluginsParams = +type PluginsParams = ( | (NativePluginsParams & HooksSupport) | FlatPluginsParams - | UnsupportedPluginsParams; + | UnsupportedPluginsParams +) & + MachineFilesParams; export class PluginsCapability { readonly mode: PluginsMode; @@ -190,11 +218,14 @@ export class PluginsCapability { readonly nativeActivation: NativeActivation | null; readonly translationMode: PluginTranslationMode | null; readonly installScope: "project" | "user"; + /** See {@link MachineFiles}, or `null` when a machine-scope install copies no file. */ + readonly machineFiles: MachineFiles | null; private readonly _userPluginsDir?: (homedir: string) => string; constructor(params: PluginsParams) { this.mode = params.mode; + this.machineFiles = params.machineFiles ?? null; this.translationMode = PluginsCapability.resolveTranslationMode(params); this.installScope = PluginsCapability.resolveInstallScope(params); PluginsCapability.validateUserScope(params); @@ -285,7 +316,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.machineFiles?.dir(homedir) ?? null; } pluginOutputDir(pluginName: string): string | null { diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts index e36a285aa..457d41374 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts @@ -3,6 +3,8 @@ * even where the literal values coincide: a change to either side must not collapse them. */ +import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; + /** Relative path for the Codex-native plugin manifest inside each plugin output directory. */ export const OUTPUT_CODEX_MANIFEST_RELATIVE = ".codex-plugin/plugin.json"; @@ -15,3 +17,14 @@ export const OUTPUT_CODEX_MARKETPLACE_RELATIVE = ".agents/plugins/marketplace.js /** Subdirectory name inside each plugin output for staged Codex agent TOML files. */ export const OUTPUT_CODEX_AGENTS_DIR = "codex-agents"; + +/** + * A Codex plugin cannot carry agents, and Codex loads them from `~/.codex/agents/` for every + * project, so a machine-scope install copies each staged TOML there, prefixed with its plugin. + */ +export function codexMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { + const staged = `plugins/${plugin.name}/${OUTPUT_CODEX_AGENTS_DIR}/`; + if (!builtPath.startsWith(staged) || !builtPath.endsWith(".toml")) return null; + const name = builtPath.slice(staged.length); + return name.includes("/") ? null : `agents/${plugin.name}-${name}`; +} diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index 320807e35..1d6e8e39c 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -31,6 +31,7 @@ import { stripCodexSkillFrontmatter, } from "./build.js"; import { codexAgentFrontmatter, codexAgentToml } from "./codex-agent-toml.js"; +import { codexMachineFilePath } from "./codex-paths.js"; const DIRECTORY = ".codex/"; const TOOL_SUFFIX = ".codex.md"; @@ -181,6 +182,7 @@ export const codex: AiTool< mode: "native", pluginsDir: ".codex/plugins/", pluginManifestRelativePath: "plugin.json", + machineFiles: { dir: (homedir) => join(homedir, ".codex"), map: codexMachineFilePath }, acceptsMcp: true, translationMode: "marketplace", // Codex only enables plugins from its user-global config plus its own plugin cache; a 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..ee8311f1c 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -2,8 +2,26 @@ * contract can read the same values: the profile imports the contract, so the contract cannot * import the profile back. */ +import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; + export const OPENCODE_DIRECTORY = ".opencode/"; +/** + * A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every + * project: its global config directory takes the same `skills/` and `agents/` a project's + * `.opencode/` does. Hooks, commands and MCP entries stay project-scoped. + */ +export function opencodeMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { + const relative = builtPath.startsWith(OPENCODE_DIRECTORY) + ? builtPath.slice(OPENCODE_DIRECTORY.length) + : null; + if (relative === null) return null; + 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; +} + /** * 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 diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index 6805756d9..e17b5a22e 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -33,6 +33,7 @@ import { OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, + opencodeMachineFilePath, } from "./opencode-paths.js"; const DIRECTORY = OPENCODE_DIRECTORY; @@ -106,6 +107,10 @@ export const opencode: AiTool< plugins: new PluginsCapability({ mode: "flat", flatNamespacePrefix: "aidd-", + machineFiles: { + dir: (homedir) => join(homedir, ".config", "opencode"), + map: opencodeMachineFilePath, + }, acceptsHooks: true, flatHooksDir: OPENCODE_HOOKS_DIR, flatHooksLoaderEntry: { diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index 237e60676..aad2fb652 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, + MachineFiles, NativeActivation, PluginsCapability, } from "./capabilities/plugins-capability.js"; @@ -118,13 +119,22 @@ 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 machine files into the + * user's configuration directory. `false` for a tool declaring none — kilo today. */ 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.machineFiles !== null + ); +} + +/** See {@link MachineFiles}: `null` for a tool whose machine-scope install copies no file. */ +export function machineFilesOf(toolId: ToolId): MachineFiles | null { + return resolvePluginsCapability(toolId)?.machineFiles ?? null; } /** The tool's declared build contract for one framework-build mode, or undefined when the tool 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/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..71e001b45 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>; @@ -153,6 +153,7 @@ interface DoctorCmdOptions { tool?: string; plugin?: string; scope?: string; + global?: boolean; } export function registerDoctorCommand(program: Command): void { @@ -168,12 +169,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/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/setup.ts b/cli/src/presentation/commands/setup.ts index b789ffe52..0cb4c53b8 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -1,11 +1,13 @@ 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, getAllRegisteredTools, + machineFilesOf, + nativeActivationOf, supportsUserScopeActivation, } from "../../contexts/tools/domain/registry.js"; import type { ToolId } from "../../kernel/tool.js"; @@ -32,6 +34,7 @@ interface SetupCmdOptions { yes?: boolean; defaultMarketplace?: boolean; scope?: string; + global?: boolean; } export function parseSourceFlag( @@ -58,22 +61,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 +99,24 @@ 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((id) => machineFilesOf(id) !== null); + const recorded = tools.filter((id) => !native.includes(id) && !copying.includes(id)); + const where = (id: ToolId) => machineFilesOf(id)?.dir("~").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(", ")}.`, + ` ${recorded.join(", ")} only records the framework source.`, + " `aidd sync --global` refreshes them from the framework source.", + ].join("\n"); +} + export function registerSetupCommand(program: Command): void { program .command("setup") @@ -103,8 +126,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 +138,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 +159,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; diff --git a/cli/src/presentation/commands/sync.ts b/cli/src/presentation/commands/sync.ts index fd7faf47b..8535b7986 100644 --- a/cli/src/presentation/commands/sync.ts +++ b/cli/src/presentation/commands/sync.ts @@ -4,7 +4,7 @@ import { SyncFailedError, UserScopeFilterUnsupportedError, } from "../../kernel/errors.js"; -import type { ToolId } from "../../kernel/tool.js"; +import type { AiToolId, ToolId } from "../../kernel/tool.js"; import { createDeps } from "../../runtime/wiring/framework.js"; import { printActivationOutcome, @@ -14,17 +14,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 machine 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 +44,19 @@ 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[]; + 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 +66,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 +78,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 +160,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..5b9e03ce2 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 machinePlugins?: readonly string[]; } function displayInstall( @@ -37,6 +38,14 @@ function displayInstall( } export function printSetupOutcome(output: CLIOutput, result: SetupOutcome, verbose: boolean): void { + if (result.machinePlugins !== undefined) { + output.success( + result.machinePlugins.length === 0 + ? "Registered for every project on this machine." + : `Installed for every project on this machine: ${result.machinePlugins.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..e6d423f8d 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 machine 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/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index cd97898a1..d72f7569c 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -52,6 +52,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 { InstallMachinePluginsUseCase } from "../../contexts/framework/application/setup/install-machine-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"; @@ -486,13 +487,6 @@ 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 builtMaterializationDeps = { @@ -500,6 +494,26 @@ export async function createDeps( marketplaceRegistry, homedir, }; + // 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), + hasher, + builtMaterializationDeps + ); + const setupMachineScopeUseCase = new SetupMachineScopeUseCase( + userManifestRepo, + setupMarketplaceRegistration, + marketplaceSyncSettingsUseCase, + currentVersionProvider, + new InstallMachinePluginsUseCase( + marketplaceRegistry, + resolveMarketplaceUseCase, + userPluginFileUpdater, + logger + ) + ); + const statusUseCase = new StatusUseCase(fs, manifestRepo, hasher, detectPluginDriftUseCase); const pluginUpdateUseCase = new PluginUpdateUseCase( fs, manifestRepo, @@ -510,12 +524,7 @@ export async function createDeps( ); const userPluginUpdateUseCase = new UserPluginUpdateUseCase( userManifestRepo, - new UserPluginFileUpdater( - fs, - new PluginDistributionLoader(pluginFetcher, pluginDistributionReader), - hasher, - builtMaterializationDeps - ), + userPluginFileUpdater, logger, nativeHostRegistrationGate ); diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 783d3405e..c465733b7 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' machine 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/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts new file mode 100644 index 000000000..34b51f4f2 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts @@ -0,0 +1,159 @@ +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 { + assertMachineFilesWritable, + readMachinePluginFiles, +} from "../../../../../src/contexts/framework/application/ownership/machine-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("readMachinePluginFiles", () => { + it("reads the plugin's skills and the agents its source ships, keyed under the user directory", async () => { + const files = await readMachinePluginFiles( + 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 readMachinePluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "opencode", + distribution("aidd-qa", []) + ); + + expect(files).toEqual([]); + }); + + it("reads nothing for a tool that copies no machine file, which has no directory to write either", async () => { + await expect( + assertMachineFilesWritable( + 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 machine file", async () => { + const files = await readMachinePluginFiles( + tree, + new DeterministicHasher(), + BUILT, + "claude", + distribution("aidd-dev", ["agents/reviewer.md"]) + ); + + expect(files).toEqual([]); + }); +}); + +describe("assertMachineFilesWritable", () => { + 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( + assertMachineFilesWritable(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( + assertMachineFilesWritable(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( + assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + ).rejects.toThrow(`opencode: 'aidd-dev' parent '${under("agents")}' escapes ${DIR}.`); + }); + + it("refuses to overwrite a file it does not own that holds other bytes", async () => { + const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: "mine" }, hasher); + + await expect( + assertMachineFilesWritable(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("adopts a file already holding the same bytes, and rewrites a file the plugin owns", async () => { + const fs = new InMemoryFileAdapter( + { [under("agents", "same.md")]: "new", [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( + assertMachineFilesWritable( + fs, + "opencode", + owned, + "aidd-dev", + [file("agents/same.md"), file("agents/owned.md")], + HOME + ) + ).resolves.toBe(DIR); + }); +}); 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..4ad72eea2 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, + machinePlugins: [], }); }); @@ -598,6 +599,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, + machinePlugins: [], }); }); diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts new file mode 100644 index 000000000..fd54b2fb3 --- /dev/null +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import type { PluginCatalogEntry } from "../../../../../src/contexts/distribution/domain/catalog.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import type { PlannedUserPluginFileUpdate } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { InstallMachinePluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-machine-plugins-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { 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]) { + 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 useCase = new InstallMachinePluginsUseCase( + { list: async () => marketplaces }, + { + execute: async () => ({ + marketplace: FRAMEWORK, + catalog: { plugins: CATALOG }, + localPath: "/fw", + }), + }, + { planUpdate, applyUpdate: async (plan) => plan.next }, + new CapturingLogger() + ); + return { manifest, useCase, planUpdate }; +} + +function run( + useCase: InstallMachinePluginsUseCase, + manifest: Manifest, + mode: PluginInstallMode, + pluginNames: string[] = [] +) { + return useCase.execute(manifest, { + projectRoot: "/project", + toolIds: ["claude", "opencode"], + mode, + pluginNames, + }); +} + +describe("InstallMachinePluginsUseCase", () => { + 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("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/domain/setup-flow.unit.test.ts b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts index 44b4ca86a..49d05a0a3 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,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 "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { @@ -120,12 +121,16 @@ 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( + it("refuses an AI tool with no user-scope activation at --scope user (kilo)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).toThrow( UserScopeUnsupportedAiToolsError ); }); + it("accepts an AI tool that copies machine 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)", () => { expect(() => makeFlow({ scope: "user", aiTools: ["cursor"] })).not.toThrow(); }); @@ -134,10 +139,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/tools/domain/profiles/codex/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts new file mode 100644 index 000000000..d3b4e9741 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/machine-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 { codexMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-paths.js"; +import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const ORCHESTRATOR = { name: "aidd-orchestrator", agents: new Set() }; + +describe("codexMachineFilePath — the agents a machine-scope install copies into ~/.codex", () => { + it("prefixes a staged agent with its plugin under agents/", () => { + expect( + codexMachineFilePath("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(codexMachineFilePath(built, ORCHESTRATOR)).toBeNull(); + }); +}); + +describe("the codex profile's machine-scope directory", () => { + it("is ~/.codex under the home directory", () => { + expect(machineFilesOf("codex")?.dir("/home/me")).toBe(join("/home/me", ".codex")); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts new file mode 100644 index 000000000..fb103047d --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts @@ -0,0 +1,42 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { opencodeMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-paths.js"; +import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; + +describe("opencodeMachineFilePath — what a machine-scope install copies into OpenCode's config dir", () => { + 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(opencodeMachineFilePath(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(opencodeMachineFilePath(built, AIDD_DEV)).toBeNull(); + } + ); +}); + +describe("the opencode profile's machine-scope directory", () => { + it("is ~/.config/opencode under the home directory", () => { + expect(machineFilesOf("opencode")?.dir("/home/me")).toBe(join("/home/me", ".config/opencode")); + }); +}); 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/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index a246cc633..ecd0fef9f 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; @@ -25,29 +25,54 @@ 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(""); + 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 machine files for: aidd-dev"); + + 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("explains user-scope restrictions in setup help", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-help"); @@ -56,10 +81,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,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(); } diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index d34a66563..2663670ff 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -27,12 +27,12 @@ { "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", @@ -127,12 +127,12 @@ { "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,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, opencode gets their files in ~/.config/opencode.\n cursor only records the framework source.\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..cb5e06bef 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, 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..f481d6fa2 100644 --- a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts @@ -537,6 +537,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/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..279e57499 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(); @@ -258,46 +260,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 +364,31 @@ 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, opencode gets their files in ~/.config/opencode.", + " cursor only records the framework source.", + " `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 +396,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..8b7bba755 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 machine 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 machine 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..868b5320d 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: [] }, machinePlugins: ["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: [] }, machinePlugins: [] }, + 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..7c7810857 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 machine 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 machine 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 machine 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/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index b62d89f9c..9a1df9cd6 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, Claude Code, Codex and OpenCode included: + +```bash +aidd setup --global --ide claude,codex,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. From ef31af8a14265a8a988b8673f7d3fbaff671158a Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 09:49:14 +0800 Subject: [PATCH 02/38] feat(framework): make reload installs Claude, Codex and OpenCode with setup --global The reload hands this checkout to aidd setup --global --source local --plugins all, the way a user installs machine-wide, instead of copying skills and agents by hand. Claude and Codex get their plugins afresh each run, since an edited plugin keeps its version. Copilot and Cursor are unchanged. --- scripts/__tests__/dev-sync.test.js | 293 ++++++----------------------- scripts/dev-sync.sh | 240 +++++++---------------- 2 files changed, 131 insertions(+), 402 deletions(-) diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 7d1bfb714..99a5585bf 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -208,32 +208,14 @@ test("reload fixtures run from their temporary home", () => { 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\""); + executable(join(fakeBin, "opencode"), "exit 0"); + recordingCli(fixture(home), 'fs.writeFileSync(path.join(process.env.HOME, "observed-cwd"), process.cwd());'); const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); assert.equal(realpathSync(readFileSync(join(home, "observed-cwd"), "utf8").trim()), realpathSync(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 +225,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 +253,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,156 +273,74 @@ 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"); +/** 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}`); +} - } finally { - rmSync(root, { recursive: true, force: true }); +test("Claude, Codex and OpenCode take 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); + const source = fixture(home, ["aidd-dev", "aidd-pm"]); + recordingCli(source); + for (const host of ["claude", "codex", "opencode"]) { + executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"`); } - }); -} + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /global install\s+ok \(claude,codex,opencode\)/); + assert.equal( + readFileSync(join(home, "aidd-calls"), "utf8"), + `setup --global --source local --path ${source} --ide claude,codex,opencode --plugins all --yes\n` + ); + const hostCalls = readFileSync(join(home, "host-calls"), "utf8"); + for (const plugin of ["aidd-dev", "aidd-pm"]) { + 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(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); + assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); + assert.doesNotMatch(hostCalls, /opencode/); + } 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-")); +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"); - const home = join(root, "home"); + const fakeBin = join(root, "bin"), 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"); + recordingCli(fixture(home), 'console.error("fixture refusal"); process.exit(3);'); 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 }); - } + 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("default OpenCode reload installs every plugin's skills", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-all-")); +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"); - const home = join(root, "home"); + const fakeBin = join(root, "bin"), 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}`); - + 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); - 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 }); - } + 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 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"]) { +for (const host of ["copilot", "cursor"]) { test(`reload fails when the installed ${host} host's build fails`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-build-failed-")); try { @@ -464,7 +359,7 @@ for (const host of ["opencode", "codex", "copilot", "cursor"]) { }); } -for (const host of ["codex", "claude", "copilot"]) { +for (const host of ["copilot"]) { test(`reload fails when the installed ${host} host rejects a plugin`, () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-install-failed-")); try { @@ -748,52 +643,7 @@ 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}`, () => { @@ -806,7 +656,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 +669,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/dev-sync.sh b/scripts/dev-sync.sh index 32aad997a..c5de213e2 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,12 +1,12 @@ #!/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. +# Claude, Codex and OpenCode take it the way a user does: `aidd setup --global` with this +# checkout as the framework source, so the reload exercises the shipped machine-wide install. +# Copilot and Cursor install from a tree the aidd CLI builds. # # 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. +# The agents' provider is whatever `aidd setup --global --provider` last recorded. # # 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. @@ -34,11 +34,6 @@ 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}" @@ -49,7 +44,6 @@ CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc} 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_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,33 +52,18 @@ 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 +build_tool() { + local tool="$1" + rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" + aidd_cli translate "$FW" --to "$tool" --out "$BUILD/$tool" --as marketplace --force >/dev/null 2>&1 } validate_native_plugins() { @@ -101,135 +80,68 @@ validate_native_plugins() { 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")" - 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 ;; +register_copilot_marketplace() { + 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 } -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' - else - echo "Copilot plugin install failed for $name" >&2; return 1 - fi - fi - echo +# 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, as the previous script did. +release_native_plugins() { + local name + for name in "${targets[@]}"; do + [ "$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 + [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true + [ "$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 +if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_COPILOT" = 0 ] && [ "$HAVE_CURSOR" = 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; } +for d in "$FW"/plugins/*/; do + [ -f "$d.claude-plugin/plugin.json" ] && targets+=("$(basename "$d")") +done + +hosts=() +[ "$HAVE_CLAUDE" = 0 ] || hosts+=(claude) +[ "$HAVE_CODEX" = 0 ] || hosts+=(codex) +[ "$HAVE_OPENCODE" = 0 ] || hosts+=(opencode) +if [ "${#hosts[@]}" -gt 0 ]; then + printf '%-22s' "global install" + release_native_plugins + output="$(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[*]}." >&2; exit 1 + } + echo "ok ($(IFS=,; echo "${hosts[*]}"))" fi if [ "$HAVE_CURSOR" = 1 ]; then printf '%-22s' "build cursor" @@ -241,23 +153,15 @@ 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; } + register_copilot_marketplace || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } echo "ok" + for name in "${targets[@]}"; do + printf '%-22s' "$name" + copilot plugin install "$name@$MKT" >/dev/null || { echo "Copilot plugin install failed for $name" >&2; exit 1; } + echo " copilot:ok" + done fi -for t in "${targets[@]}"; do sync_one "$t"; 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" @@ -271,9 +175,7 @@ fi instructions=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") [ "$HAVE_CLAUDE" = 0 ] || instructions+=(claude "$CLAUDE_INSTRUCTIONS") -if [ "$HAVE_OPENCODE" = 1 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then - instructions+=(opencode "$OPENCODE_INSTRUCTIONS") -fi +[ "$HAVE_OPENCODE" = 0 ] || instructions+=(opencode "$OPENCODE_INSTRUCTIONS") [ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") [ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") printf '%-22s' "global instructions" From 32fef3d50bfeefe8fe878cd09bcba119620798bf Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 10:09:06 +0800 Subject: [PATCH 03/38] refactor(cli): drop the agent frontmatter strippers no profile uses anymore Since the generic sub-agents shipped, every profile declares its agent frontmatter in agent-format.ts; agent-frontmatter-strip.ts and its test were left with no caller. --- .../domain/formats/agent-frontmatter-strip.ts | 59 ---------- .../agent-frontmatter-strip.unit.test.ts | 110 ------------------ 2 files changed, 169 deletions(-) delete mode 100644 cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts delete mode 100644 cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts 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/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"]); - }); -}); From 52a353abd44a21bc6b66a9a7cb3ce27bd1b04996 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 10:26:54 +0800 Subject: [PATCH 04/38] feat(cli): install the framework globally for Copilot, Cursor and Kilo too - Kilo copies skills and agents into ~/.config/kilo, as OpenCode does. - Cursor copies plugins into ~/.cursor/plugins/local. - Copilot 1.0.98 answers marketplace list --json: its local source is now proven, so setup --global enables the plugins through its CLI. - Plan user-scope plugin updates one at a time: plans for one tool share its built tree. --- cli/README.md | 9 ++-- .../framework-source-is-machine-scope.md | 2 +- .../ownership/user-plugin-file-updater.ts | 9 +++- .../ownership/user-plugin-update-use-case.ts | 23 ++++---- .../setup/install-machine-plugins-use-case.ts | 8 +-- .../domain/capabilities/plugins-capability.ts | 19 ++++++- .../copilot/native-marketplace-source.ts | 37 ++++++++++++- .../tools/domain/profiles/kilo/profile.ts | 7 ++- .../profiles/opencode/opencode-paths.ts | 20 ++----- cli/src/contexts/tools/domain/registry.ts | 11 +++- cli/src/presentation/commands/setup.ts | 10 ++-- ...tall-machine-plugins-use-case.unit.test.ts | 24 +++++++++ .../framework/domain/setup-flow.unit.test.ts | 21 ++++++-- .../profiles/kilo/machine-files.unit.test.ts | 30 +++++++++++ ...ketplace-source-reader.integration.test.ts | 54 +++++++++++++++++++ cli/tests/e2e/setup-scope-user.e2e.test.ts | 2 +- cli/tests/golden/snapshots/help/surface.json | 2 +- .../commands/setup-wiring.integration.test.ts | 3 +- docs/SUBAGENTS.md | 4 +- 19 files changed, 235 insertions(+), 60 deletions(-) create mode 100644 cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts diff --git a/cli/README.md b/cli/README.md index de2e31675..5bd264cb8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -122,16 +122,17 @@ Counts the tokens of Claude Code and Codex sessions, locally. Details: [FAQ](../ | --- | --- | | `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` | Copies the plugins' skills and agents into `~/.config/opencode/` | -| `cursor` | Records the source and tool only | -| `kilo` | Unsupported: use project scope | +| `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 | + +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 --global`. ```sh -aidd setup --global --ide claude,codex,opencode --plugins all --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 # remove what --global installed and purge the shared source ``` 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 f8f4be92a..6b7cb7fb7 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 @@ -49,7 +49,7 @@ One registration of `aidd-framework` per machine, shared by every project. Read - Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse 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.machineFiles` (opencode's skills and agents, codex's agents) gets them copied into its user directory 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`). +- `--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.machineFiles` (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. 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 260af0573..6ee0a551e 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 @@ -15,7 +15,7 @@ import { } 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, @@ -137,8 +137,13 @@ export class UserPluginFileUpdater { await assertMachineFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); return; } - await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, [...files], home); 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.files.size === 0 && 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.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); } 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 15e05e30f..567ea267c 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 @@ -66,19 +66,18 @@ 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, - options.rebuild === true - ), - })) - ); + toolId, + options.projectRoot, + this.logger, + options.rebuild === true + ); + 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/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts index 2f12b3f24..3e66a6c15 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -4,7 +4,7 @@ import type { ResolveMarketplace } from "../../../distribution/application/resol 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 { machineFilesOf } from "../../../tools/domain/registry.js"; +import { copiesPluginFilesForMachine } 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"; @@ -25,8 +25,8 @@ export interface InstallMachinePluginsResult { /** * `setup --global --plugins …`: picks the framework plugins from the shared source's catalog and - * copies each one's machine files (OpenCode's skills and agents, Codex's agents) into the tool's - * user configuration directory, recording them in the machine manifest as user-scope plugins so + * 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. @@ -58,7 +58,7 @@ export class InstallMachinePluginsUseCase { }); const entries = selectEntries(catalog?.plugins ?? [], options); for (const toolId of options.toolIds) { - if (machineFilesOf(toolId) === null) continue; + if (!copiesPluginFilesForMachine(toolId)) continue; for (const entry of entries) { const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); const plugin = diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 54757e2a3..43ebf7999 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -73,7 +73,7 @@ export interface ProjectHooksDelivery { /** * 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 skills and agents, Codex's agents. + * than from a plugin it enables itself: OpenCode's and Kilo's skills and agents, Codex's agents. */ export interface MachineFiles { /** The user configuration directory the files land in, given a homedir. */ @@ -91,6 +91,23 @@ export interface MachinePlugin { 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 flatMachineFilePath(directory: string): MachineFiles["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 MachineFilesParams { machineFiles?: MachineFiles; 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/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts index 708236ad2..e85dc3fab 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -1,8 +1,9 @@ +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"; import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { flatMachineFilePath, PluginsCapability } from "../../capabilities/plugins-capability.js"; import { RulesCapability } from "../../capabilities/rules-capability.js"; import { SkillsCapability } from "../../capabilities/skills-capability.js"; import type { @@ -89,6 +90,10 @@ export const kilo: AiTool join(homedir, ".config", "kilo"), + map: flatMachineFilePath(KILO_DIRECTORY), + }, 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/opencode-paths.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts index ee8311f1c..5dae58b53 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -2,25 +2,13 @@ * contract can read the same values: the profile imports the contract, so the contract cannot * import the profile back. */ -import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; +import { flatMachineFilePath } from "../../capabilities/plugins-capability.js"; export const OPENCODE_DIRECTORY = ".opencode/"; -/** - * A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every - * project: its global config directory takes the same `skills/` and `agents/` a project's - * `.opencode/` does. Hooks, commands and MCP entries stay project-scoped. - */ -export function opencodeMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { - const relative = builtPath.startsWith(OPENCODE_DIRECTORY) - ? builtPath.slice(OPENCODE_DIRECTORY.length) - : null; - if (relative === null) return null; - 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; -} +/** A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every + * project: its global config directory. */ +export const opencodeMachineFilePath = flatMachineFilePath(OPENCODE_DIRECTORY); /** * The directory OpenCode's own plugin loader scans (`{plugin,plugins}/*.{ts,js}`, one level, no diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index aad2fb652..11a466e18 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -121,7 +121,7 @@ export function pluginEnablementIsMachineGlobal(toolId: ToolId): boolean { /** 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 machine files into the - * user's configuration directory. `false` for a tool declaring none — kilo today. */ + * 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; @@ -132,6 +132,15 @@ export function supportsUserScopeActivation(toolId: ToolId): boolean { ); } +/** 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 copiesPluginFilesForMachine(toolId: ToolId): boolean { + const capability = resolvePluginsCapability(toolId); + if (capability === null) return false; + return capability.installScope === "user" || capability.machineFiles !== null; +} + /** See {@link MachineFiles}: `null` for a tool whose machine-scope install copies no file. */ export function machineFilesOf(toolId: ToolId): MachineFiles | null { return resolvePluginsCapability(toolId)?.machineFiles ?? null; diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts index 0cb4c53b8..cca55c91d 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -5,9 +5,10 @@ import { SetupUseCase } from "../../contexts/framework/application/setup-use-cas import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; import { assertToolIdsMatchCategory, + copiesPluginFilesForMachine, getAllRegisteredTools, - machineFilesOf, nativeActivationOf, + resolvePluginsCapability, supportsUserScopeActivation, } from "../../contexts/tools/domain/registry.js"; import type { ToolId } from "../../kernel/tool.js"; @@ -103,16 +104,15 @@ export function parsePluginsFlag( export function globalSetupHelp(): string { const tools = [...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort(); const native = tools.filter((id) => nativeActivationOf(id) !== undefined); - const copying = tools.filter((id) => machineFilesOf(id) !== null); - const recorded = tools.filter((id) => !native.includes(id) && !copying.includes(id)); - const where = (id: ToolId) => machineFilesOf(id)?.dir("~").replaceAll("\\", "/"); + const copying = tools.filter(copiesPluginFilesForMachine); + 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(", ")}.`, - ` ${recorded.join(", ")} only records the framework source.`, " `aidd sync --global` refreshes them from the framework source.", ].join("\n"); } diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts index fd54b2fb3..8696ac4b6 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -1,5 +1,6 @@ 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 type { PluginCatalogEntry } from "../../../../../src/contexts/distribution/domain/catalog.js"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; @@ -100,6 +101,29 @@ describe("InstallMachinePluginsUseCase", () => { 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: [], + }); + + 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("keeps the recommended plugins alone", async () => { const { manifest, useCase } = setup(); 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 49d05a0a3..d8a11be0c 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -4,7 +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 "../../../../src/contexts/tools/domain/profiles/kilo/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 { @@ -121,10 +122,20 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user" })).toThrow(UserScopeNoToolsError); }); - it("refuses an AI tool with no user-scope activation at --scope user (kilo)", () => { - expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).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 machine files at --scope user (kilo)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["kilo"] })).not.toThrow(); }); it("accepts an AI tool that copies machine files at --scope user (opencode)", () => { diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts new file mode 100644 index 000000000..2823c2de3 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/machine-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/kilo/profile.js"; +import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; + +const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; + +describe("the kilo profile's machine-scope files", () => { + const machine = machineFilesOf("kilo"); + + it("land in ~/.config/kilo under the home directory", () => { + expect(machine?.dir("/home/me")).toBe(join("/home/me", ".config/kilo")); + }); + + it.each([ + [".kilo/skills/aidd-dev/01-plan/SKILL.md", "skills/aidd-dev/01-plan/SKILL.md"], + [".kilo/agents/aidd-dev-reviewer.md", "agents/aidd-dev-reviewer.md"], + ])("keep the plugin's own %s as %s", (built, path) => { + expect(machine?.map(built, AIDD_DEV)).toBe(path); + }); + + it.each([ + ".kilo/skills/aidd-pm/01-epic/SKILL.md", + ".kilo/agents/aidd-dev-executor.md", + ".kilo/commands/aidd-dev-plan.md", + ".opencode/skills/aidd-dev/01-plan/SKILL.md", + ])("leave %s out", (built) => { + expect(machine?.map(built, AIDD_DEV)).toBeNull(); + }); +}); 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/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index ecd0fef9f..7d9acea52 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -81,7 +81,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain("--ide claude,codex,copilot,cursor,opencode"); + 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"); diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index 2663670ff..953e3cf99 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -127,7 +127,7 @@ { "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 --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,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, opencode gets their files in ~/.config/opencode.\n cursor only records the framework source.\n `aidd sync --global` refreshes them from the framework source." + "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", diff --git a/cli/tests/presentation/commands/setup-wiring.integration.test.ts b/cli/tests/presentation/commands/setup-wiring.integration.test.ts index 279e57499..84d29583b 100644 --- a/cli/tests/presentation/commands/setup-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/setup-wiring.integration.test.ts @@ -370,8 +370,7 @@ describe("aidd setup — the help surface", () => { "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, opencode gets their files in ~/.config/opencode.", - " cursor only records the framework source.", + " 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.", ]); }); diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 9a1df9cd6..e8c24281d 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -51,10 +51,10 @@ 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, Claude Code, Codex and OpenCode included: +For every project on the machine at once: ```bash -aidd setup --global --ide claude,codex,opencode --plugins all +aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all aidd sync --global # after a framework update ``` From 1d727f26bd3faafdd45ec9d5e4a0acca2ed87425 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 10:42:33 +0800 Subject: [PATCH 05/38] feat(framework): make reload installs every tool with setup --global - Copilot and Cursor leave the script's own build and copy: setup --global installs them, with Kilo now detected too. Copilot is released afresh each reload, like Claude and Codex. - Kilo documents no global AGENTS.md, so the reload writes it no instructions. - Docs: INSTALL.md shows the machine-wide install, update and removal; the CLI README, memory and smoke-real tool list follow every tool now supporting --global. --- Makefile | 2 +- aidd_docs/memory/deployment.md | 2 +- aidd_docs/memory/project-brief.md | 2 +- cli/README.md | 3 +- .../framework-source-is-machine-scope.md | 4 +- cli/scripts/smoke-real.sh | 5 +- docs/INSTALL.md | 12 +- scripts/__tests__/dev-sync.test.js | 287 ++---------------- ...e-real-user-scope-tool-list-parity.test.js | 19 +- scripts/dev-sync.sh | 146 +++------ 10 files changed, 98 insertions(+), 384 deletions(-) diff --git a/Makefile b/Makefile index 24aa053b7..1b8b55bb5 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,6 @@ 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 diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 89190d715..bdfc8ea32 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -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 5bd264cb8..92a9a5cac 100644 --- a/cli/README.md +++ b/cli/README.md @@ -37,6 +37,7 @@ Check: `aidd --version`. | 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 @@ -210,7 +211,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/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index 6b7cb7fb7..e26cf8b4c 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,7 +47,7 @@ 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.machineFiles` (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. @@ -58,5 +58,5 @@ One registration of `aidd-framework` per machine, shared by every project. Read - `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 kilo 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/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/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/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 99a5585bf..1f4a81028 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -183,18 +183,11 @@ function runSync(fakeBin, home, args = ["all"], overrides = {}) { 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, @@ -284,31 +277,33 @@ if (args[0] !== "setup") process.exit(2); ${setupBody}`); } -test("Claude, Codex and OpenCode take this checkout through aidd setup --global, afresh", () => { +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); const source = fixture(home, ["aidd-dev", "aidd-pm"]); recordingCli(source); - for (const host of ["claude", "codex", "opencode"]) { + for (const host of ["claude", "codex", "copilot", "cursor-agent", "kilo", "opencode"]) { executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"`); } const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /global install\s+ok \(claude,codex,opencode\)/); + 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,opencode --plugins all --yes\n` + `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(hostCalls, new RegExp(`claude plugin uninstall ${plugin}@aidd-framework --scope user`)); assert.match(hostCalls, new RegExp(`codex plugin remove ${plugin}@aidd-framework`)); + assert.match(hostCalls, new RegExp(`copilot plugin uninstall ${plugin}@aidd-framework`)); } assert.match(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); - assert.doesNotMatch(hostCalls, /opencode/); + assert.match(hostCalls, /copilot plugin marketplace remove aidd-framework --force/); + assert.doesNotMatch(hostCalls, /^(cursor-agent|kilo|opencode) /m); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -340,290 +335,60 @@ test("a global install from the published CLI is used when the checkout has none } finally { rmSync(root, { recursive: true, force: true }); } }); -for (const host of ["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 ["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`; -} - for (const binary of ["cursor-agent", "agent", "cursor"]) { - test(`Cursor reload through ${binary} installs every built plugin and preserves personal content`, () => { + 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); - 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"); + recordingCli(fixture(home)); executable(join(fakeBin, binary), "echo 'Start the Cursor Agent'"); - executable(join(fakeBin, "npx"), nativeBuildStub()); 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.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("Cursor reload rejects incomplete native output before changing installed plugins", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-incomplete-")); - 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 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"); - } finally { rmSync(root, { recursive: true, force: true }); } -}); - -test("another tool named agent does not trigger Cursor installation", () => { +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); + recordingCli(fixture(home)); executable(join(fakeBin, "agent"), "echo 'Grok Build TUI'"); - executable(join(fakeBin, "npx"), "exit 9"); 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}` }]); - } 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-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); mkdirSync(home); - 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"); - } - 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"))); - 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(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"); - } 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 }); } -}); - -test("Copilot rejects incomplete native output before registering or installing", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-incomplete-")); - try { - const fakeBin = join(root, "bin"), home = join(root, "home"); - mkdirSync(fakeBin); - executable(join(fakeBin, "copilot"), copilotStub); - executable(join(fakeBin, "npx"), nativeBuildStub(true)); - 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.stdout, /No supported host installation found/); + assert.equal(existsSync(join(home, "aidd-calls")), false); } 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("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); - 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()); + recordingCli(fixture(home)); + executable(join(fakeBin, "kilo"), "exit 0"); 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, "aidd-calls"), "utf8"), / --ide kilo /); + assert.equal(existsSync(join(home, ".config/kilo/AGENTS.md")), false); } 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-")); - 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()); - const result = runSync(fakeBin, home, []); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /fixture native permission denied/); - } 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, "build")), 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-")); - 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"); - 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.equal(existsSync(join(home, "host-calls")), false); } finally { rmSync(root, { recursive: true, force: true }); } }); @@ -632,8 +397,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/); @@ -643,8 +408,6 @@ test("Copilot instructions follow its native home with task overrides taking pre } 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-")); 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..34cd16836 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) || + /machineFiles:\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/dev-sync.sh b/scripts/dev-sync.sh index c5de213e2..f55560b2f 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, Codex and OpenCode take it the way a user does: `aidd setup --global` with this -# checkout as the framework source, so the reload exercises the shipped machine-wide install. -# Copilot and Cursor install from a tree the aidd CLI builds. +# (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. # # 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. # The agents' provider is whatever `aidd setup --global --provider` last recorded. # # 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,8 +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 -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}" @@ -44,6 +42,7 @@ CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc} 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_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 @@ -60,67 +59,28 @@ aidd_cli() { fi } -build_tool() { - local tool="$1" - rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" - aidd_cli translate "$FW" --to "$tool" --out "$BUILD/$tool" --as marketplace --force >/dev/null 2>&1 -} - -validate_native_plugins() { - local tool="$1" manifest="$2" name skill - 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 -} - -register_copilot_marketplace() { - 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 -} - -# 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, as the previous script did. +# A plugin keeps its version across edits to this checkout, so the native hosts would keep the +# copy they cached: each reload hands them the plugins afresh. release_native_plugins() { local name for name in "${targets[@]}"; do [ "$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 + [ "$HAVE_COPILOT" = 0 ] || copilot plugin uninstall "$name@$MKT" >/dev/null 2>&1 || true done [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true + [ "$HAVE_COPILOT" = 0 ] || copilot plugin marketplace remove "$MKT" --force >/dev/null 2>&1 || true } -if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_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 ] || hosts+=(opencode) +if [ "${#hosts[@]}" -eq 0 ]; then echo "No supported host installation found - nothing to install."; exit 0 fi @@ -129,48 +89,14 @@ for d in "$FW"/plugins/*/; do [ -f "$d.claude-plugin/plugin.json" ] && targets+=("$(basename "$d")") done -hosts=() -[ "$HAVE_CLAUDE" = 0 ] || hosts+=(claude) -[ "$HAVE_CODEX" = 0 ] || hosts+=(codex) -[ "$HAVE_OPENCODE" = 0 ] || hosts+=(opencode) -if [ "${#hosts[@]}" -gt 0 ]; then - printf '%-22s' "global install" - release_native_plugins - output="$(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[*]}." >&2; exit 1 - } - echo "ok ($(IFS=,; echo "${hosts[*]}"))" -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_copilot_marketplace || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } - echo "ok" - for name in "${targets[@]}"; do - printf '%-22s' "$name" - copilot plugin install "$name@$MKT" >/dev/null || { echo "Copilot plugin install failed for $name" >&2; exit 1; } - echo " copilot:ok" - done -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)" -fi +printf '%-22s' "global install" +release_native_plugins +output="$(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[*]}." >&2; exit 1 +} +echo "ok ($(IFS=,; echo "${hosts[*]}"))" instructions=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") @@ -178,13 +104,15 @@ instructions=() [ "$HAVE_OPENCODE" = 0 ] || instructions+=(opencode "$OPENCODE_INSTRUCTIONS") [ "$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." From 4acfdc5327d33f5cc75f4aabe807861250f4f4da Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 11:34:58 +0800 Subject: [PATCH 06/38] fix(framework): make reload installs from HOME and leaves Copilot plugins in place - setup --global runs from HOME: the checkout's own .claude/settings.json enables the plugins at project scope, which the hosts report and the CLI refuses as refs it does not own. - Copilot is no longer uninstalled each reload: its uninstall leaves every plugin disabled rather than gone, which the next setup --global refuses the same way. - doctor --global stops asking a project to declare the marketplace, which a machine-wide install never writes there, and its messages name setup --global. --- .../doctor/doctor-registration-use-case.ts | 4 +++- .../user-manifest-repository-adapter.ts | 2 +- cli/src/presentation/commands/doctor.ts | 7 ++++--- .../application/doctor-registration.unit.test.ts | 14 ++++++++++++-- ...anifest-repository-adapter.integration.test.ts | 8 +++----- cli/tests/e2e/setup-scope-user.e2e.test.ts | 2 +- cli/tests/golden/snapshots/phase0/snapshot.json | 2 +- .../commands/doctor-wiring.integration.test.ts | 3 ++- scripts/__tests__/dev-sync.test.js | 15 +++++++-------- scripts/dev-sync.sh | 12 +++++++----- 10 files changed, 41 insertions(+), 28 deletions(-) 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..d90a44d86 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 machine-wide install declares nothing in a project's settings, so it skips that check. */ + machine?: 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.machine === true ? [] : await this.checkDeclaredMarketplaces(options)), ...(await this.checkNativeRegistrations(options)), ...(await this.checkMarketplaceSources(options)), ]; 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..4ffca2844 100644 --- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts +++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts @@ -29,7 +29,7 @@ export class UserManifestRepositoryAdapter implements ManifestRepository { return readManifestFile({ path: this.path, location: "for this machine", - reinstallCommand: "aidd setup --scope user", + reinstallCommand: "aidd setup --global", }); } diff --git a/cli/src/presentation/commands/doctor.ts b/cli/src/presentation/commands/doctor.ts index 71e001b45..ebd6dcd04 100644 --- a/cli/src/presentation/commands/doctor.ts +++ b/cli/src/presentation/commands/doctor.ts @@ -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, + machine: true, }); printScopeIssues(output, "User scope", { issues }); const healthy = issues.every((i) => i.severity !== "error"); 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..97f1a4208 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, + 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/infrastructure/user-manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts index b093391cd..083d52754 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 @@ -37,14 +37,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 +53,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.` ); }); }); diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index 7d9acea52..e21a55145 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -240,7 +240,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/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index cb5e06bef..829b9d71e 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -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/presentation/commands/doctor-wiring.integration.test.ts b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts index f481d6fa2..7cc8912ed 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, + machine: 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(); }); diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 1f4a81028..08a84bb44 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -171,13 +171,13 @@ 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), @@ -196,14 +196,15 @@ 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, "opencode"), "exit 0"); - recordingCli(fixture(home), 'fs.writeFileSync(path.join(process.env.HOME, "observed-cwd"), process.cwd());'); - const result = runSync(fakeBin, home, []); + 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)); } finally { rmSync(root, { recursive: true, force: true }); } @@ -298,12 +299,10 @@ test("every host takes this checkout through aidd setup --global, afresh", () => for (const plugin of ["aidd-dev", "aidd-pm"]) { 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(hostCalls, new RegExp(`copilot plugin uninstall ${plugin}@aidd-framework`)); } assert.match(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); assert.match(hostCalls, /codex plugin marketplace remove aidd-framework/); - assert.match(hostCalls, /copilot plugin marketplace remove aidd-framework --force/); - assert.doesNotMatch(hostCalls, /^(cursor-agent|kilo|opencode) /m); + assert.doesNotMatch(hostCalls, /^(copilot|cursor-agent|kilo|opencode) /m); } finally { rmSync(root, { recursive: true, force: true }); } }); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index f55560b2f..4c7f53f5d 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -59,18 +59,18 @@ aidd_cli() { fi } -# A plugin keeps its version across edits to this checkout, so the native hosts would keep the -# copy they cached: each reload hands them the plugins afresh. +# 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. release_native_plugins() { local name for name in "${targets[@]}"; do [ "$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 - [ "$HAVE_COPILOT" = 0 ] || copilot plugin uninstall "$name@$MKT" >/dev/null 2>&1 || true done [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true - [ "$HAVE_COPILOT" = 0 ] || copilot plugin marketplace remove "$MKT" --force >/dev/null 2>&1 || true } hosts=() @@ -91,7 +91,9 @@ done printf '%-22s' "global install" release_native_plugins -output="$(aidd_cli setup --global --source local --path "$FW" --ide "$(IFS=,; echo "${hosts[*]}")" \ +# 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[*]}." >&2; exit 1 From 428236113b81b9685e5f41adf412fe16a78e6fcb Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 3 Nov 2026 15:17:42 +0800 Subject: [PATCH 07/38] fix(cli): clean --global then setup --global holds for Claude and Copilot - Claude 2.1.312's marketplace remove --scope user drops the settings declaration and keeps the registration: a user-scope remove now names no scope, from the home directory (NativeActivation.userRemoveArgs), so clean deletes it and the next setup declares it again. - Copilot's uninstall leaves each ref disabled rather than gone: a disabled ref there is no longer taken for a plugin in use (NativeActivation.uninstallLeavesRefDisabled), so setup after clean enables the plugins again. Claude keeps refusing a ref the person disabled. - make reload removes Claude's marketplace from every scope, from HOME, for the same reason. --- .../marketplace-sync-settings-use-case.ts | 8 +++-- .../domain/capabilities/plugins-capability.ts | 8 +++++ .../tools/domain/profiles/claude/profile.ts | 1 + .../tools/domain/profiles/copilot/profile.ts | 3 ++ cli/src/contexts/tools/domain/registry.ts | 7 ++++ .../abstract-native-plugin-cli-adapter.ts | 31 +++++++++++++---- .../native-plugin-cli-adapter.ts | 5 +++ .../tools/domain/host-ref-in-use.unit.test.ts | 19 +++++++++++ .../profiles/claude/plugins.unit.test.ts | 1 + .../profiles/copilot/profile.unit.test.ts | 1 + ...gin-cli-adapter.claude.integration.test.ts | 33 +++++++++++++++++++ scripts/__tests__/dev-sync.test.js | 2 +- scripts/dev-sync.sh | 7 ++-- 13 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 cli/tests/contexts/tools/domain/host-ref-in-use.unit.test.ts 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 02fc810f8..99cc83182 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, @@ -840,9 +841,10 @@ 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) + )?.[0]; return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) }; } diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 43ebf7999..5acb16071 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -22,6 +22,14 @@ 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`, run from the home directory so no project's settings are in reach. + * Measured on Claude 2.1.312: `remove --scope user` drops the settings declaration and + * keeps the registration, which only a remove naming no scope deletes. */ + 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[]; diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts index a09f3778f..7fa94ba68 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -117,6 +117,7 @@ export const claude: AiTool join(h, ".copilot", "settings.json"), diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index 11a466e18..a27d2c648 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -132,6 +132,13 @@ export function supportsUserScopeActivation(toolId: ToolId): boolean { ); } +/** 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. */ 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..cca4bcc01 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,9 +62,18 @@ 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; this.run( - ["plugin", "marketplace", "remove", name, ...this.scopeArgsFor(scope), ...force], - `marketplace remove ${name}` + [ + "plugin", + "marketplace", + "remove", + name, + ...(userArgs ?? this.scopeArgsFor(scope)), + ...force, + ], + `marketplace remove ${name}`, + userArgs === undefined ? undefined : homedir() ); } @@ -70,8 +89,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/native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts index dca4ee676..2fdcc63a5 100644 --- a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts @@ -4,6 +4,7 @@ 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; @@ -86,4 +87,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/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..4be2d754a 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,6 +48,7 @@ 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", 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/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..61a90c8e8 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"; @@ -80,3 +81,35 @@ 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: [], + }); + + it("names no scope at user scope, from the home directory, so the registration goes too", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "marketplace", "remove", "aidd-framework"], + expect.objectContaining({ cwd: homedir() }) + ); + }); + + 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/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 08a84bb44..52b4fb17a 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -300,7 +300,7 @@ test("every host takes this checkout through aidd setup --global, afresh", () => 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(hostCalls, /claude plugin marketplace remove aidd-framework --scope user/); + 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 }); } diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 4c7f53f5d..202c05502 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -62,14 +62,17 @@ aidd_cli() { # 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. +# 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: the marketplace is removed from every scope, from HOME so no +# project's settings are among them. release_native_plugins() { local name for name in "${targets[@]}"; do [ "$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 - [ "$HAVE_CLAUDE" = 0 ] || claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true + [ "$HAVE_CLAUDE" = 0 ] || (cd "$HOME" && claude plugin marketplace remove "$MKT") >/dev/null 2>&1 || true [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true } From 65882d752ade0b033931b4332fed97aad38862f4 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Wed, 4 Nov 2026 08:38:58 +0800 Subject: [PATCH 08/38] test(framework): compare the reload's working directory past Windows short names --- scripts/__tests__/dev-sync.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 52b4fb17a..b592dcd28 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -206,7 +206,11 @@ test("the global install runs from HOME, never the checkout whose settings enabl 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 }); } }); From 96ba7915f063837b06808d829d89800845f333b3 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Wed, 4 Nov 2026 14:40:42 +0800 Subject: [PATCH 09/38] chore(framework): say how to recover from a failed make reload --- scripts/dev-sync.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 202c05502..b60c17479 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -5,7 +5,6 @@ # # 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. -# The agents' provider is whatever `aidd setup --global --provider` last recorded. # # 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. Kilo @@ -99,7 +98,7 @@ release_native_plugins 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[*]}." >&2; exit 1 + 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[*]}"))" From 1138a845c8111eb80ee2c2c749c3ede1d6324df0 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 07:27:17 +0800 Subject: [PATCH 10/38] feat(framework): make check-hosts tables what each tool really loads of the plugins Plugins enabled, global instructions, skills and agents, per installed tool: asked of the tool where it can answer, read from the files its documentation names where it cannot. Exits 1 when an installed tool misses something. --- CONTRIBUTING.md | 1 + Makefile | 5 +- scripts/__tests__/check-hosts.test.js | 91 ++++++++ scripts/check-hosts.cjs | 312 ++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/check-hosts.test.js create mode 100644 scripts/check-hosts.cjs 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 1b8b55bb5..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 @@ -18,3 +18,6 @@ check: ## Run the pre-commit checks 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/scripts/__tests__/check-hosts.test.js b/scripts/__tests__/check-hosts.test.js new file mode 100644 index 000000000..bb20232bc --- /dev/null +++ b/scripts/__tests__/check-hosts.test.js @@ -0,0 +1,91 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { chmodSync, mkdirSync, mkdtempSync, 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" }; + +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 = mkdtempSync(join(tmpdir(), "aidd-check-hosts-test-")); + const framework = join(dir, "framework"); + 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"); + for (const [name, body] of Object.entries(stubs)) { + write(join(dir, "bin", name), `#!/bin/sh\n[ "$1" = --version ] && exit 0\n${body}\n`); + chmodSync(join(dir, "bin", name), 0o755); + } + mkdirSync(join(dir, "bin"), { recursive: true }); + mkdirSync(join(dir, "home"), { recursive: true }); + return { dir, framework, home: join(dir, "home") }; +} + +function check({ dir, framework, home }) { + return spawnSync(process.execPath, [script, "--root", framework], { + encoding: "utf8", + env: { HOME: home, PATH: [join(dir, "bin"), "/usr/bin", "/bin"].join(delimiter) }, + }); +} + +const KILO = (location) => `case "$1 $2" in + 'debug skill') echo '[{"location":"${location}"}]' ;; + 'debug agent') [ "$3" = aidd-x-reviewer ] && echo '{}' || exit 1 ;; +esac`; + +test("a tool that is not installed is said so and fails nothing", posixOnly, () => { + const env = fixture({}); + try { + const result = check(env); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\| Kilo \| not installed/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } +}); + +test("a tool that answers with everything passes, proved by the tool", posixOnly, () => { + const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/01-a/SKILL.md") }); + try { + 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 }); + } +}); + +test("a skill the tool does not list fails the run and is named", posixOnly, () => { + const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/99-other/SKILL.md") }); + try { + 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 }); + } +}); + +test("Cursor is read from its files, and a skill named apart from its folder is missing", posixOnly, () => { + const env = fixture({ "cursor-agent": "exit 0" }); + 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"), "---\nname: a\n---\n"); + write(join(local, "agents/reviewer.md"), "---\n---\n"); + write(join(env.home, ".cursor/rules/aidd-agents.mdc"), "## Agents\n"); + try { + const result = check(env); + assert.equal(result.status, 1); + assert.match(result.stdout, /\| Cursor \| 🟡 1\/1 \| 🟡 ~\/\.cursor\/rules\/aidd-agents\.mdc \| ❌ 0\/1, missing aidd-x\/01-a \| 🟡 1\/1 \|/); + } finally { + rmSync(env.dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/check-hosts.cjs b/scripts/check-hosts.cjs new file mode 100644 index 000000000..3935148d9 --- /dev/null +++ b/scripts/check-hosts.cjs @@ -0,0 +1,312 @@ +#!/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. Exits 1 when an installed tool misses something. Reads only; a tool's session opened to +// list its agents (Claude, Copilot) is killed before any model answers. +// +// 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 } = require("node:fs"); +const { homedir, tmpdir } = require("node:os"); +const { basename, join, resolve } = require("node:path"); + +const MARKETPLACE = "aidd-framework"; +const SESSION_TIMEOUT_MS = 60000; +const READS = 10; +const READ_PAUSE_MS = 2000; + +const rootFlag = process.argv.indexOf("--root"); +const root = rootFlag === -1 ? resolve(__dirname, "..") : resolve(process.argv[rootFlag + 1]); +// Neutral on purpose: a project's own settings, skills and agents would add to the answer. +const cwd = mkdtempSync(join(tmpdir(), "aidd-check-hosts-")); +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") })) +); + +function run(binary, args) { + const result = spawnSync(binary, args, { cwd, encoding: "utf8", timeout: SESSION_TIMEOUT_MS }); + 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 fileHolds(path, text) { + return existsSync(path) && readFileSync(path, "utf8").includes(text); +} + +/** A cell: `asked` when the tool answered, the files alone otherwise; the missing names decide. */ +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, proof = "files") { + return fileHolds(path, "## Agents") + ? { status: proof, text: path.replace(home, "~") } + : { status: "missing", text: `no Agents block in ${path.replace(home, "~")}` }; +} + +/** Reads a tool's JSON lines until one is `done`, then kills it, so no model gets to answer. */ +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.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 qualified = ({ plugin, agent }) => `${plugin}:${agent}`; +const flat = ({ plugin, agent }) => `${plugin}-${agent}`; +const skillPath = ({ plugin, skill }) => `${plugin}/${skill}`; + +/** Every `/` a list of paths names, whatever comes before and after it. */ +function skillsIn(paths) { + const found = new Set(); + for (const path of paths) { + const match = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/](?:skills[\\/])?([^\\/]+)[\\/]SKILL\.md$/); + const tail = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/]skills[\\/]([^\\/]+)(?:[\\/]SKILL\.md)?$/); + for (const hit of [match, tail]) if (hit !== null) found.add(`${hit[1]}/${hit[2]}`); + } + return found; +} + +async function claude() { + const configDir = process.env.CLAUDE_CONFIG_DIR || join(home, ".claude"); + const listed = json(run("claude", ["plugin", "list", "--json"]) ?? "") ?? []; + const enabled = new Set( + listed + .filter((p) => p.enabled && p.scope === "user" && p.id?.endsWith(`@${MARKETPLACE}`)) + .map((p) => p.id.split("@")[0]) + ); + const skillCount = plugins.reduce((sum, plugin) => { + const details = run("claude", ["plugin", "details", `${plugin}@${MARKETPLACE}`]) ?? ""; + return sum + Number(details.match(/Skills \((\d+)\)/)?.[1] ?? 0); + }, 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", "--output-format", "stream-json", "--verbose"], + (event) => event.type === "system" && event.subtype === "init" + ); + return { + plugins: count(plugins, enabled, "asked"), + instructions: instructions(join(configDir, "CLAUDE.md")), + skills: + skillCount === skills.length + ? { status: "asked", text: `${skillCount}/${skills.length}` } + : { status: "missing", text: `${skillCount}/${skills.length}` }, + agents: count(agents.map(qualified), new Set(init?.agents ?? []), "asked"), + }; +} + +function promptText(value) { + if (typeof value === "string") return ""; + 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 = new Set( + 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 = new Set( + [...prompt.matchAll(/^- ([a-z0-9-]+):([^:\s]+)/gm)].map((hit) => `${hit[1]}/${hit[2]}`) + ); + const loaded = prompt.includes("# AGENTS.md instructions") && prompt.includes("## Agents"); + return { + plugins: count(plugins, enabled, "asked"), + instructions: loaded + ? { status: "asked", text: "in the prompt" } + : { status: "missing", text: "not in the prompt" }, + skills: count(skills.map(skillPath), available, "asked"), + agents: count( + agents.map(flat), + new Set(listNames(join(codexHome, "agents")).map((file) => basename(file, ".toml"))), + "files" + ), + }; +} + +async function copilot() { + const listed = json(run("copilot", ["plugin", "list", "--json"]) ?? "") ?? []; + const enabled = new Set( + listed.filter((p) => p.marketplace === MARKETPLACE && p.enabled).map((p) => p.name) + ); + const sources = json(run("copilot", ["instruction", "list", "--json"]) ?? "") ?? []; + 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, enabled, "asked"), + instructions: sources.some((source) => source.sourcePath?.endsWith("copilot-instructions.md")) + ? { status: "asked", text: "listed" } + : { status: "missing", text: "not listed" }, + skills: count(skills.map(skillPath), skillsIn(pluginSkills.map((skill) => skill.path)), "asked"), + agents: count( + agents.map(qualified), + new Set(agentOption?.options?.map((option) => option.value) ?? []), + "asked" + ), + }; +} + +function pluginFolders(found) { + return new Set([...found].map((path) => path.split("/")[0])); +} + +async function opencode() { + const configDir = (run("opencode", ["debug", "paths", "config"]) ?? "").trim(); + // Not --standalone: its private server answers an empty list before it has loaded. The service + // does too while it starts cold, so a list counts once two reads in a row agree. + let listed = []; + for (let read = 0, previous = -1; read < READS; read += 1) { + listed = json(run("opencode", ["api", "get", "/api/skill"]) ?? "")?.data ?? []; + if (listed.length > 0 && listed.length === previous) break; + previous = listed.length; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, READ_PAUSE_MS); + } + const found = skillsIn(listed.map((skill) => skill.path)); + const listedAgents = json(run("opencode", ["debug", "agents", "--standalone"]) ?? "") ?? []; + return { + plugins: count(plugins, pluginFolders(found), "asked"), + instructions: instructions(join(configDir, "AGENTS.md")), + skills: count(skills.map(skillPath), found, "asked"), + agents: count(agents.map(flat), new Set(listedAgents.map((agent) => agent.id)), "asked"), + }; +} + +async function kilo() { + const listed = json(run("kilo", ["debug", "skill"]) ?? "") ?? []; + const found = skillsIn(listed.map((skill) => skill.location)); + const loaded = agents.map(flat).filter((name) => run("kilo", ["debug", "agent", name]) !== null); + return { + plugins: count(plugins, pluginFolders(found), "asked"), + // Kilo documents no global AGENTS.md, only its config's `instructions` key. + instructions: { status: "none", text: "none documented" }, + skills: count(skills.map(skillPath), found, "asked"), + agents: count(agents.map(flat), new Set(loaded), "asked"), + }; +} + +async function cursor() { + const local = join(home, ".cursor/plugins/local"); + const valid = plugins.filter( + (plugin) => json(existsSync(join(local, plugin, ".cursor-plugin/plugin.json")) + ? readFileSync(join(local, plugin, ".cursor-plugin/plugin.json"), "utf8") + : "") !== null + ); + const named = skills.filter(({ plugin, skill }) => + fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: ${skill}`) + || fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: '${skill}'`) + ); + const present = agents.filter(({ plugin, agent }) => + existsSync(join(local, plugin, "agents", `${agent}.md`)) + ); + return { + plugins: count(plugins, new Set(valid), "files"), + instructions: instructions(join(home, ".cursor/rules/aidd-agents.mdc")), + skills: count(skills.map(skillPath), new Set(named.map(skillPath)), "files"), + agents: count(agents.map(flat), new Set(present.map(flat)), "files"), + }; +} + +const TOOLS = [ + { name: "Claude Code", binaries: ["claude"], check: claude }, + { name: "Codex", binaries: ["codex"], check: codex }, + { name: "Copilot", binaries: ["copilot"], check: copilot }, + { name: "Cursor", binaries: ["cursor-agent", "cursor"], check: cursor }, + { name: "Kilo", binaries: ["kilo"], check: kilo }, + { name: "OpenCode", binaries: ["opencode"], check: opencode }, +]; + +const MARK = { asked: "✅", files: "🟡", missing: "❌", none: "➖" }; + +function show(cell) { + return `${MARK[cell.status]} ${cell.text}`; +} + +async function main() { + console.log("| Tool | Plugins enabled | Global instructions | Skills | Agents |"); + console.log("| --- | --- | --- | --- | --- |"); + let failed = false; + for (const tool of TOOLS) { + if (!tool.binaries.some(installed)) { + 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"); + process.exit(failed ? 1 : 0); +} + +main(); From 829634a6ba76933ba0d280d4ad70774e55058872 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 08:20:01 +0800 Subject: [PATCH 11/38] fix(framework): check-hosts proves names, and fails what it cannot prove - Claude's plugins, skills and agents come from its session's init event, compared by name; the session runs without hooks or a saved transcript. - A listed skill counts only under the tool's config directory and while its file exists. - Cursor reads names from the frontmatter alone; global instructions need the managed block. - No plugin to expect, or a tool answering no config directory, is a failure, not 0/0. - OpenCode's service is read again while it starts cold, until it lists every skill. - A test per tool, and one per false positive. --- scripts/__tests__/check-hosts.test.js | 341 ++++++++++++++++++++++---- scripts/check-hosts.cjs | 302 ++++++++++++++--------- 2 files changed, 490 insertions(+), 153 deletions(-) diff --git a/scripts/__tests__/check-hosts.test.js b/scripts/__tests__/check-hosts.test.js index bb20232bc..1e923835a 100644 --- a/scripts/__tests__/check-hosts.test.js +++ b/scripts/__tests__/check-hosts.test.js @@ -1,12 +1,13 @@ const assert = require("node:assert/strict"); const { spawnSync } = require("node:child_process"); -const { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs"); +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 }); @@ -15,77 +16,331 @@ function write(path, content) { /** A framework of one plugin, one skill and one agent, with stubs standing in for the tools. */ function fixture(stubs) { - const dir = mkdtempSync(join(tmpdir(), "aidd-check-hosts-test-")); + 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"); - for (const [name, body] of Object.entries(stubs)) { + 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); } - mkdirSync(join(dir, "bin"), { recursive: true }); - mkdirSync(join(dir, "home"), { recursive: true }); - return { dir, framework, home: join(dir, "home") }; + return { dir, framework, home }; } -function check({ dir, framework, home }) { - return spawnSync(process.execPath, [script, "--root", framework], { +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) }, }); } -const KILO = (location) => `case "$1 $2" in +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`; -test("a tool that is not installed is said so and fails nothing", posixOnly, () => { - const env = fixture({}); - try { +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/); - } finally { - rmSync(env.dir, { recursive: true, force: true }); } +); + +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("a tool that answers with everything passes, proved by the tool", posixOnly, () => { - const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/01-a/SKILL.md") }); - try { +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, /\| Kilo \| ✅ 1\/1 \| ➖ none documented \| ✅ 1\/1 \| ✅ 1\/1 \|/); - } finally { - rmSync(env.dir, { recursive: true, force: true }); + assert.match(result.stdout, /\| Codex \| ✅ 1\/1 \| ✅ in the prompt \| ✅ 1\/1 \| 🟡 1\/1 \|/); } -}); +); -test("a skill the tool does not list fails the run and is named", posixOnly, () => { - const env = fixture({ kilo: KILO("/h/.config/kilo/skills/aidd-x/99-other/SKILL.md") }); - try { - 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 }); +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("Cursor is read from its files, and a skill named apart from its folder is missing", posixOnly, () => { - const env = fixture({ "cursor-agent": "exit 0" }); - 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"), "---\nname: a\n---\n"); - write(join(local, "agents/reviewer.md"), "---\n---\n"); - write(join(env.home, ".cursor/rules/aidd-agents.mdc"), "## Agents\n"); - try { - const result = check(env); - assert.equal(result.status, 1); - assert.match(result.stdout, /\| Cursor \| 🟡 1\/1 \| 🟡 ~\/\.cursor\/rules\/aidd-agents\.mdc \| ❌ 0\/1, missing aidd-x\/01-a \| 🟡 1\/1 \|/); - } 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/check-hosts.cjs b/scripts/check-hosts.cjs index 3935148d9..79f0a944d 100644 --- a/scripts/check-hosts.cjs +++ b/scripts/check-hosts.cjs @@ -1,8 +1,12 @@ #!/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. Exits 1 when an installed tool misses something. Reads only; a tool's session opened to -// list its agents (Claude, Copilot) is killed before any model answers. +// 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 @@ -13,19 +17,18 @@ // 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 } = require("node:fs"); +const { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } = require("node:fs"); const { homedir, tmpdir } = require("node:os"); -const { basename, join, resolve } = require("node:path"); +const { basename, join, resolve, sep } = require("node:path"); const MARKETPLACE = "aidd-framework"; +const INSTRUCTIONS_MARKER = ""; const SESSION_TIMEOUT_MS = 60000; -const READS = 10; +const READS = 15; const READ_PAUSE_MS = 2000; const rootFlag = process.argv.indexOf("--root"); -const root = rootFlag === -1 ? resolve(__dirname, "..") : resolve(process.argv[rootFlag + 1]); -// Neutral on purpose: a project's own settings, skills and agents would add to the answer. -const cwd = mkdtempSync(join(tmpdir(), "aidd-check-hosts-")); +const root = resolve(rootFlag === -1 ? join(__dirname, "..") : (process.argv[rootFlag + 1] ?? ".")); const home = homedir(); function listNames(dir) { @@ -46,8 +49,16 @@ const agents = plugins.flatMap((plugin) => .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 }); + const result = spawnSync(binary, args, { + cwd, + encoding: "utf8", + timeout: SESSION_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }); return result.status === 0 ? result.stdout : null; } @@ -63,11 +74,25 @@ function json(text) { } } +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 cell: `asked` when the tool answered, the files alone otherwise; the missing names decide. */ +/** 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}`; @@ -78,13 +103,14 @@ function count(expected, found, proof) { return { status: proof, text: tally }; } -function instructions(path, proof = "files") { - return fileHolds(path, "## Agents") - ? { status: proof, text: path.replace(home, "~") } - : { status: "missing", text: `no Agents block in ${path.replace(home, "~")}` }; +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, so no model gets to answer. */ +/** 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"] }); @@ -97,6 +123,7 @@ function firstLine(binary, args, done, input = []) { 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"); @@ -111,53 +138,70 @@ function firstLine(binary, args, done, input = []) { }); } -const qualified = ({ plugin, agent }) => `${plugin}:${agent}`; -const flat = ({ plugin, agent }) => `${plugin}-${agent}`; -const skillPath = ({ plugin, skill }) => `${plugin}/${skill}`; - -/** Every `/` a list of paths names, whatever comes before and after it. */ -function skillsIn(paths) { - const found = new Set(); - for (const path of paths) { - const match = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/](?:skills[\\/])?([^\\/]+)[\\/]SKILL\.md$/); - const tail = String(path).match(/(?:^|[\\/])([^\\/]+)[\\/]skills[\\/]([^\\/]+)(?:[\\/]SKILL\.md)?$/); - for (const hit of [match, tail]) if (hit !== null) found.add(`${hit[1]}/${hit[2]}`); - } - return found; +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 = new Set( - listed - .filter((p) => p.enabled && p.scope === "user" && p.id?.endsWith(`@${MARKETPLACE}`)) - .map((p) => p.id.split("@")[0]) - ); - const skillCount = plugins.reduce((sum, plugin) => { - const details = run("claude", ["plugin", "details", `${plugin}@${MARKETPLACE}`]) ?? ""; - return sum + Number(details.match(/Skills \((\d+)\)/)?.[1] ?? 0); - }, 0); + 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", "--output-format", "stream-json", "--verbose"], + [ + "-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, enabled, "asked"), + plugins: count(plugins, new Set(enabled.filter((name) => loaded.has(name))), "asked"), instructions: instructions(join(configDir, "CLAUDE.md")), - skills: - skillCount === skills.length - ? { status: "asked", text: `${skillCount}/${skills.length}` } - : { status: "missing", text: `${skillCount}/${skills.length}` }, - agents: count(agents.map(qualified), new Set(init?.agents ?? []), "asked"), + skills: count(skills.map(qualifiedSkill), new Set(init?.skills ?? []), "asked"), + agents: count(agents.map(qualifiedAgent), new Set(init?.agents ?? []), "asked"), }; } function promptText(value) { - if (typeof value === "string") return ""; 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"); @@ -166,123 +210,151 @@ function promptText(value) { async function codex() { const codexHome = process.env.CODEX_HOME || join(home, ".codex"); const listed = json(run("codex", ["plugin", "list", "--json"]) ?? "")?.installed ?? []; - const enabled = new Set( - listed.filter((p) => p.marketplaceName === MARKETPLACE && p.installed && p.enabled).map((p) => p.name) - ); + 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 = new Set( - [...prompt.matchAll(/^- ([a-z0-9-]+):([^:\s]+)/gm)].map((hit) => `${hit[1]}/${hit[2]}`) + const available = [...prompt.matchAll(/^- ([a-z0-9-]+):([^:\s]+)/gm)].map( + (hit) => `${hit[1]}/${hit[2]}` ); - const loaded = prompt.includes("# AGENTS.md instructions") && prompt.includes("## Agents"); + 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, enabled, "asked"), - instructions: loaded + 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(skillPath), available, "asked"), - agents: count( - agents.map(flat), - new Set(listNames(join(codexHome, "agents")).map((file) => basename(file, ".toml"))), - "files" - ), + 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 = new Set( - listed.filter((p) => p.marketplace === MARKETPLACE && p.enabled).map((p) => p.name) - ); + 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: 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, enabled, "asked"), - instructions: sources.some((source) => source.sourcePath?.endsWith("copilot-instructions.md")) + plugins: count(plugins, new Set(enabled), "asked"), + instructions: managed ? { status: "asked", text: "listed" } - : { status: "missing", text: "not listed" }, - skills: count(skills.map(skillPath), skillsIn(pluginSkills.map((skill) => skill.path)), "asked"), + : { 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(qualified), + agents.map(qualifiedAgent), new Set(agentOption?.options?.map((option) => option.value) ?? []), "asked" ), }; } -function pluginFolders(found) { - return new Set([...found].map((path) => path.split("/")[0])); -} - 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 a list counts once two reads in a row agree. - let listed = []; - for (let read = 0, previous = -1; read < READS; read += 1) { - listed = json(run("opencode", ["api", "get", "/api/skill"]) ?? "")?.data ?? []; - if (listed.length > 0 && listed.length === previous) break; - previous = listed.length; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, READ_PAUSE_MS); + // 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 found = skillsIn(listed.map((skill) => skill.path)); const listedAgents = json(run("opencode", ["debug", "agents", "--standalone"]) ?? "") ?? []; return { - plugins: count(plugins, pluginFolders(found), "asked"), + plugins: count(plugins, pluginsOf(found), "asked"), instructions: instructions(join(configDir, "AGENTS.md")), - skills: count(skills.map(skillPath), found, "asked"), - agents: count(agents.map(flat), new Set(listedAgents.map((agent) => agent.id)), "asked"), + 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 = skillsIn(listed.map((skill) => skill.location)); - const loaded = agents.map(flat).filter((name) => run("kilo", ["debug", "agent", name]) !== null); + 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, pluginFolders(found), "asked"), + 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(skillPath), found, "asked"), - agents: count(agents.map(flat), new Set(loaded), "asked"), + 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) => json(existsSync(join(local, plugin, ".cursor-plugin/plugin.json")) - ? readFileSync(join(local, plugin, ".cursor-plugin/plugin.json"), "utf8") - : "") !== null - ); - const named = skills.filter(({ plugin, skill }) => - fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: ${skill}`) - || fileHolds(join(local, plugin, "skills", skill, "SKILL.md"), `name: '${skill}'`) - ); + 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 }) => - existsSync(join(local, plugin, "agents", `${agent}.md`)) + 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(skillPath), new Set(named.map(skillPath)), "files"), - agents: count(agents.map(flat), new Set(present.map(flat)), "files"), + 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", binaries: ["claude"], check: claude }, - { name: "Codex", binaries: ["codex"], check: codex }, - { name: "Copilot", binaries: ["copilot"], check: copilot }, - { name: "Cursor", binaries: ["cursor-agent", "cursor"], check: cursor }, - { name: "Kilo", binaries: ["kilo"], check: kilo }, - { name: "OpenCode", binaries: ["opencode"], check: opencode }, + { 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: "➖" }; @@ -292,11 +364,15 @@ function show(cell) { } 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.binaries.some(installed)) { + if (!tool.present()) { console.log(`| ${tool.name} | not installed | | | |`); continue; } @@ -305,8 +381,14 @@ async function main() { 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"); - process.exit(failed ? 1 : 0); + console.log( + "\n✅ the tool says so · 🟡 the files its documentation says it loads · ❌ missing · ➖ does not apply" + ); + return failed ? 1 : 0; } -main(); +cwd = mkdtempSync(join(tmpdir(), "aidd-check-hosts-")); +main().then((code) => { + rmSync(cwd, { recursive: true, force: true }); + process.exit(code); +}); From c241e53eaca5567716e3a6839d822239aa2b6114 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 09:10:27 +0800 Subject: [PATCH 12/38] fix(cli): keep a project's plugins when removing the user's marketplace Removing Claude's marketplace from every scope also uninstalls what a project installed from it, with its saved options. clean --global and make reload now do that only when no project uses the marketplace, and stay at user scope otherwise, or when the list cannot be read. The README's clean --global example names --force, without which it previews or asks. --- cli/README.md | 2 +- .../domain/capabilities/plugins-capability.ts | 7 +-- .../abstract-native-plugin-cli-adapter.ts | 24 +++++++++- ...gin-cli-adapter.claude.integration.test.ts | 48 +++++++++++++++++-- scripts/__tests__/dev-sync.test.js | 22 ++++++++- scripts/dev-sync.sh | 21 ++++++-- 6 files changed, 111 insertions(+), 13 deletions(-) diff --git a/cli/README.md b/cli/README.md index 92a9a5cac..a0c36bb99 100644 --- a/cli/README.md +++ b/cli/README.md @@ -135,7 +135,7 @@ Install it, then run `aidd sync --global`. ```sh 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 # remove what --global installed and purge the shared source +aidd clean --global --force # remove what --global installed and purge the shared source; without --force it previews or asks ``` ## Authentication diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 5acb16071..67c390327 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -23,9 +23,10 @@ export interface NativeActivation { * 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`, run from the home directory so no project's settings are in reach. - * Measured on Claude 2.1.312: `remove --scope user` drops the settings declaration and - * keeps the registration, which only a remove naming no scope deletes. */ + * `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. */ 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 cca4bcc01..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 @@ -63,20 +63,40 @@ 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, - ...(userArgs ?? this.scopeArgsFor(scope)), + ...(everywhere ? userArgs : this.scopeArgsFor(scope)), ...force, ], `marketplace remove ${name}`, - userArgs === undefined ? undefined : homedir() + 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; 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 61a90c8e8..ec5d3798c 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 @@ -89,18 +89,60 @@ describe("ClaudeCliAdapter — removing a user-scope marketplace deletes its reg userRemoveArgs: [], }); - it("names no scope at user scope, from the home directory, so the registration goes too", () => { - mockSpawnSync.mockReturnValue(makeResult({})); + 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).toHaveBeenCalledWith( + 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({})); diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index b592dcd28..d18f46ccf 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -290,7 +290,7 @@ test("every host takes this checkout through aidd setup --global, afresh", () => const source = fixture(home, ["aidd-dev", "aidd-pm"]); recordingCli(source); for (const host of ["claude", "codex", "copilot", "cursor-agent", "kilo", "opencode"]) { - executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"`); + executable(join(fakeBin, host), `printf '%s\\n' "${host} $*" >> "$HOME/host-calls"\n[ "$*" = "plugin list --json" ] && echo '[]'; exit 0`); } const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); @@ -310,6 +310,26 @@ test("every host takes this checkout through aidd setup --global, afresh", () => } 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("a failing global install stops the reload with its own cause", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-failed-")); try { diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index b60c17479..bc1bfcd95 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -63,15 +63,30 @@ aidd_cli() { # 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: the marketplace is removed from every scope, from HOME so no -# project's settings are among them. +# 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" +} + release_native_plugins() { local name for name in "${targets[@]}"; do [ "$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 - [ "$HAVE_CLAUDE" = 0 ] || (cd "$HOME" && claude plugin marketplace remove "$MKT") >/dev/null 2>&1 || true + if [ "$HAVE_CLAUDE" = 1 ]; then + if claude_marketplace_used_by_a_project; then + claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true + else + (cd "$HOME" && claude plugin marketplace remove "$MKT") >/dev/null 2>&1 || true + fi + fi [ "$HAVE_CODEX" = 0 ] || codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true } From b702522dc26ac24058baaf0467705d4645f95fa2 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 09:39:44 +0800 Subject: [PATCH 13/38] fix(cli): a stopped setup --global keeps what it wrote and blocks nothing later Every plugin's plan is checked before the first write, the manifest is saved after each plugin, and a rerun takes over identical files on Cursor as it already did on the flat tools. --- .../ownership/user-plugin-file-updater.ts | 4 +- .../setup/install-machine-plugins-use-case.ts | 48 +++++++------- .../setup/setup-machine-scope-use-case.ts | 17 +++-- .../plugin-update-built-tree.unit.test.ts | 20 ++++++ ...tall-machine-plugins-use-case.unit.test.ts | 66 +++++++++++++++---- 5 files changed, 110 insertions(+), 45 deletions(-) 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 6ee0a551e..fed4d8d4b 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 @@ -155,7 +155,9 @@ 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))) { + const target = join(baseDir, file.relativePath); + if (!(await this.fs.fileExists(target))) continue; + if ((await this.fs.readFileHash(target)).value !== file.hash.value) { throw new Error( `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` ); diff --git a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts index 3e66a6c15..69ee23212 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -9,7 +9,10 @@ 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 { UserPluginFileUpdater } from "../ownership/user-plugin-file-updater.js"; +import type { + PlannedUserPluginFileUpdate, + UserPluginFileUpdater, +} from "../ownership/user-plugin-file-updater.js"; export interface InstallMachinePluginsOptions { readonly projectRoot: string; @@ -30,6 +33,9 @@ export interface InstallMachinePluginsResult { * `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 InstallMachinePluginsUseCase { constructor( @@ -41,7 +47,8 @@ export class InstallMachinePluginsUseCase { async execute( manifest: Manifest, - options: InstallMachinePluginsOptions + options: InstallMachinePluginsOptions, + record: (manifest: Manifest) => Promise ): Promise { if (options.mode === "none" || options.mode === "interactive") return { plugins: [] }; const marketplace = (await this.registry.list(options.projectRoot)).find( @@ -57,6 +64,7 @@ export class InstallMachinePluginsUseCase { projectRoot: options.projectRoot, }); const entries = selectEntries(catalog?.plugins ?? [], options); + const plans: { readonly plan: PlannedUserPluginFileUpdate; readonly owned: boolean }[] = []; for (const toolId of options.toolIds) { if (!copiesPluginFilesForMachine(toolId)) continue; for (const entry of entries) { @@ -71,31 +79,25 @@ export class InstallMachinePluginsUseCase { "user", marketplace.name ); - await this.install(manifest, toolId as AiToolId, plugin, owned !== undefined, options); + const plan = await this.fileUpdater.planUpdate( + plugin, + toolId as AiToolId, + options.projectRoot, + this.logger, + true + ); + 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) }; } - - private async install( - manifest: Manifest, - toolId: AiToolId, - plugin: InstalledPlugin, - owned: boolean, - options: InstallMachinePluginsOptions - ): Promise { - const plan = await this.fileUpdater.planUpdate( - plugin, - toolId, - options.projectRoot, - this.logger, - true - ); - if (plan === null || (plan.files.length === 0 && !owned)) return; - const next = await this.fileUpdater.applyUpdate(plan, this.logger); - if (owned) manifest.updatePlugin(toolId, next); - else manifest.addPlugin(toolId, next); - } } function selectEntries( 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 714147654..8dae84af3 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 @@ -65,13 +65,16 @@ export class SetupMachineScopeUseCase { if (this.installMachinePluginsUseCase === undefined) return []; const manifest = await this.userManifestRepo.load(); if (manifest === null) return []; - const { plugins } = await this.installMachinePluginsUseCase.execute(manifest, { - projectRoot: flow.projectRoot, - toolIds: flow.aiTools, - mode: flow.pluginMode, - pluginNames: flow.pluginNames, - }); - await this.userManifestRepo.save(manifest); + const { plugins } = await this.installMachinePluginsUseCase.execute( + manifest, + { + projectRoot: flow.projectRoot, + toolIds: flow.aiTools, + mode: flow.pluginMode, + pluginNames: flow.pluginNames, + }, + (updated) => this.userManifestRepo.save(updated) + ); return plugins; } 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..222f835cc 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,26 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(machineBefore); }); + it("takes over a new built file an interrupted run already wrote, byte for byte", 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"); + + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }); + + expect( + deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.has(newRelativePath) + ).toBe(true); + }); + 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/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts index 8696ac4b6..7b47e1f8d 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -55,6 +55,7 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { }; } ); + const applyUpdate = vi.fn(async (plan: PlannedUserPluginFileUpdate) => plan.next); const useCase = new InstallMachinePluginsUseCase( { list: async () => marketplaces }, { @@ -64,10 +65,10 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { localPath: "/fw", }), }, - { planUpdate, applyUpdate: async (plan) => plan.next }, + { planUpdate, applyUpdate }, new CapturingLogger() ); - return { manifest, useCase, planUpdate }; + return { manifest, useCase, planUpdate, applyUpdate }; } function run( @@ -76,12 +77,16 @@ function run( mode: PluginInstallMode, pluginNames: string[] = [] ) { - return useCase.execute(manifest, { - projectRoot: "/project", - toolIds: ["claude", "opencode"], - mode, - pluginNames, - }); + return useCase.execute( + manifest, + { + projectRoot: "/project", + toolIds: ["claude", "opencode"], + mode, + pluginNames, + }, + async () => {} + ); } describe("InstallMachinePluginsUseCase", () => { @@ -105,12 +110,16 @@ describe("InstallMachinePluginsUseCase", () => { const { manifest, useCase, planUpdate } = setup(); manifest.addTool("cursor", "1.0.0", []); - await useCase.execute(manifest, { - projectRoot: "/project", - toolIds: ["cursor"], - mode: "recommended", - pluginNames: [], - }); + 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"], @@ -124,6 +133,35 @@ describe("InstallMachinePluginsUseCase", () => { ); }); + it("writes nothing when a later plugin's plan is refused", async () => { + const { manifest, useCase, planUpdate, applyUpdate } = setup(); + planUpdate.mockImplementationOnce(planUpdate.getMockImplementation()!); + 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(); From e3c2cfef052ffed02af890f4952ffd14e3f90c16 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 11:30:16 +0800 Subject: [PATCH 14/38] fix(cli): make a global install restore, stay out of the project and touch only what it names sync --global writes back a recorded file that was deleted. A remote source's clone and plugin fetches live in the user's cache, never the current directory. Only the tools a run names get the chosen plugins. A rerun takes the source it resolved. Without the shared source, global files are refused rather than rebuilt in a project's layout. make reload shows what setup skipped. --- .../marketplace-refresh-use-case.ts | 3 +- .../resolve-marketplace-use-case.ts | 11 +++++-- .../marketplace-sync-settings-use-case.ts | 6 +++- .../ownership/user-plugin-file-updater.ts | 23 +++++++++----- .../plugin/plugin-distribution-loader.ts | 7 +++-- .../setup/install-machine-plugins-use-case.ts | 20 ++++++------ .../setup/setup-machine-scope-use-case.ts | 1 + .../shared/user-scope-plugin-files.ts | 17 ++++++++++ cli/src/kernel/paths.ts | 12 +++++++ cli/src/runtime/wiring/distribution.ts | 4 ++- cli/src/runtime/wiring/framework.ts | 5 ++- .../resolve-marketplace-use-case.unit.test.ts | 21 +++++++++++++ .../doctor-registration.unit.test.ts | 2 +- .../user-plugin-file-updater.unit.test.ts | 20 ++++++++++++ .../plugin-distribution-loader.unit.test.ts | 14 +++++++++ ...tall-machine-plugins-use-case.unit.test.ts | 31 +++++++++++++++++-- scripts/dev-sync.sh | 2 ++ 17 files changed, 170 insertions(+), 29 deletions(-) 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/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index 99cc83182..a0fb48410 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 @@ -423,7 +423,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; } 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 fed4d8d4b..9588e5d23 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 @@ -28,6 +28,7 @@ import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use- import { assertUserScopeWriteBoundary, userScopeFilesSafeToDelete, + withoutMissingFiles, } from "../shared/user-scope-plugin-files.js"; import { assertMachineFilesWritable, readMachinePluginFiles } from "./machine-plugin-files.js"; @@ -75,8 +76,9 @@ export class UserPluginFileUpdater { projectRoot ); const home = (this.builtDeps?.homedir ?? nodeHomedir)(); - const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, plugin, toolId, home); - if (oldSafe.size !== plugin.files.size) { + const present = await withoutMissingFiles(this.fs, plugin, toolId, home); + const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, present, toolId, home); + if (oldSafe.size !== present.files.size) { throw new Error( `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` ); @@ -102,14 +104,15 @@ export class UserPluginFileUpdater { } async applyUpdate(plan: PlannedUserPluginFileUpdate, logger: Logger): Promise { + const present = await withoutMissingFiles(this.fs, plan.plugin, plan.toolId, plan.home); const oldSafe = await userScopeFilesSafeToDelete( this.fs, logger, - plan.plugin, + present, plan.toolId, plan.home ); - if (oldSafe.size !== plan.plugin.files.size || oldSafe.size !== plan.oldSafe.size) { + if (oldSafe.size !== present.files.size || oldSafe.size !== plan.oldSafe.size) { throw new Error( `${plan.toolId}: '${plan.plugin.name}' has unsafe recorded files since preflight; update refused before writing.` ); @@ -138,9 +141,9 @@ export class UserPluginFileUpdater { 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.files.size === 0 && PLUGIN_NAME_REGEX.test(plugin.name)) { + // A first install owns no file yet, and a restore may have lost the directory: it 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); @@ -195,6 +198,12 @@ export class UserPluginFileUpdater { : built; return { files, componentPaths: new Map() }; } + // Without the built tree, translation would write a project's layout in the user directory. + if (machineFilesOf(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/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/setup/install-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts index 69ee23212..c7528b706 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts @@ -69,16 +69,18 @@ export class InstallMachinePluginsUseCase { if (!copiesPluginFilesForMachine(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 ?? - InstalledPlugin.fromMetadata( - entry.name, - entry.version ?? "0.0.0", - resolvePluginSourceFromMarketplace(entry.source, marketplace, localPath), - entry.strict, - "user", - marketplace.name - ); + owned === undefined + ? resolved + : resolved.withFiles(owned.files).withDependents(owned.dependents); const plan = await this.fileUpdater.planUpdate( plugin, toolId as AiToolId, 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 8dae84af3..92ea9c14c 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 @@ -52,6 +52,7 @@ export class SetupMachineScopeUseCase { projectRoot: flow.projectRoot, scope: "user", manifestRepo: this.userManifestRepo, + toolIds: flow.aiTools, enablePlugins: plugins, }); const install: SetupToolsResult = { results: [] }; diff --git a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts index 8a75f4d26..4efd14ae3 100644 --- a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts +++ b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts @@ -52,6 +52,23 @@ export async function userScopeFilesSafeToDelete( return allowed; } +/** The plugin without the recorded files nothing stands at any more, which an update writes back. + * A dangling symlink counts as gone: the atomic write replaces the link, and never follows it. */ +export async function withoutMissingFiles( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + homedir: string +): Promise { + const boundary = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir); + if (boundary === null || boundary === undefined) return plugin; + const present = new Map(); + for (const [relativePath, hash] of plugin.files) { + if (await fs.fileExists(join(boundary, relativePath))) present.set(relativePath, hash); + } + return plugin.withFiles(present); +} + /** Refuse a machine update if a new path, existing parent, or plugin directory escapes its declared base. */ export async function assertUserScopeWriteBoundary( fs: FileReader, 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/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 d72f7569c..04ca99252 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import "../../contexts/tools/domain/profiles/antigravity/profile.js"; +import { join } 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"; @@ -497,7 +498,9 @@ export async function createDeps( // 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), + new PluginDistributionLoader(pluginFetcher, pluginDistributionReader, () => + join(userConfigDir(), "cache", "plugin-cache") + ), hasher, builtMaterializationDeps ); 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/framework/application/doctor-registration.unit.test.ts b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts index 97f1a4208..2859cd236 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -69,7 +69,7 @@ async function issuesFor( manifest, projectRoot: PROJECT_ROOT, allowedIds, - machine, + machine: machine, }); } 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..f26f3aab3 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,4 +1,5 @@ import "../../../../../src/contexts/tools/domain/profiles/cursor/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"; @@ -76,6 +77,25 @@ describe("UserPluginFileUpdater public update contract", () => { expect(await f.fs.fileExists(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(false); }); + 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 machine 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("rechecks planned new-file containment when a parent becomes a symlink", async () => { const f = fixture(); const plan = await f.updater.planUpdate(f.plugin, "cursor", "/A", f.logger); 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/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts index 7b47e1f8d..6dc540cc9 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts @@ -7,7 +7,7 @@ import { Marketplace } from "../../../../../src/contexts/distribution/domain/mar import type { PlannedUserPluginFileUpdate } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; import { InstallMachinePluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-machine-plugins-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; -import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.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"; @@ -133,9 +133,36 @@ describe("InstallMachinePluginsUseCase", () => { ); }); + 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("writes nothing when a later plugin's plan is refused", async () => { const { manifest, useCase, planUpdate, applyUpdate } = setup(); - planUpdate.mockImplementationOnce(planUpdate.getMockImplementation()!); + 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"); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index bc1bfcd95..e14d3b8d7 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -116,6 +116,8 @@ output="$(cd "$HOME" && aidd_cli setup --global --source local --path "$FW" --id 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/^/ /' instructions=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") From 51f046d5db87b6d5a566584f7c9fd42b4e0d3b63 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 11:31:44 +0800 Subject: [PATCH 15/38] refactor(cli): name what this change added global, not machine --global is the word people know. The names already on next stay for their own change. --- .../framework-source-is-machine-scope.md | 2 +- .../doctor/doctor-registration-use-case.ts | 6 ++-- ...plugin-files.ts => global-plugin-files.ts} | 10 +++--- .../ownership/user-plugin-file-updater.ts | 16 +++++----- .../ownership/user-plugin-ownership.ts | 2 +- .../framework/application/setup-use-case.ts | 8 ++--- ....ts => install-global-plugins-use-case.ts} | 16 +++++----- .../setup/setup-machine-scope-use-case.ts | 16 +++++----- .../domain/capabilities/plugins-capability.ts | 26 +++++++-------- .../domain/profiles/codex/codex-paths.ts | 4 +-- .../tools/domain/profiles/codex/profile.ts | 4 +-- .../tools/domain/profiles/kilo/profile.ts | 6 ++-- .../profiles/opencode/opencode-paths.ts | 4 +-- .../tools/domain/profiles/opencode/profile.ts | 6 ++-- cli/src/contexts/tools/domain/registry.ts | 16 +++++----- cli/src/presentation/commands/doctor.ts | 2 +- cli/src/presentation/commands/setup.ts | 4 +-- cli/src/presentation/commands/sync.ts | 2 +- cli/src/presentation/display/setup-display.ts | 8 ++--- cli/src/presentation/display/sync-display.ts | 2 +- cli/src/runtime/wiring/framework.ts | 4 +-- .../orchestrator-deps.arch.test.ts | 2 +- .../doctor-registration.unit.test.ts | 2 +- ...st.ts => global-plugin-files.unit.test.ts} | 32 +++++++++---------- .../user-plugin-file-updater.unit.test.ts | 2 +- ...lugin-remove-selection.integration.test.ts | 4 +-- .../application/setup-use-case.unit.test.ts | 4 +-- ...tall-global-plugins-use-case.unit.test.ts} | 10 +++--- .../framework/domain/setup-flow.unit.test.ts | 4 +-- ...unit.test.ts => global-files.unit.test.ts} | 12 +++---- ...unit.test.ts => global-files.unit.test.ts} | 4 +-- ...unit.test.ts => global-files.unit.test.ts} | 12 +++---- cli/tests/e2e/setup-scope-user.e2e.test.ts | 2 +- .../doctor-wiring.integration.test.ts | 2 +- .../commands/sync-wiring.integration.test.ts | 4 +-- .../display/setup-display.unit.test.ts | 4 +-- .../display/sync-display.unit.test.ts | 6 ++-- ...e-real-user-scope-tool-list-parity.test.js | 2 +- 38 files changed, 136 insertions(+), 136 deletions(-) rename cli/src/contexts/framework/application/ownership/{machine-plugin-files.ts => global-plugin-files.ts} (93%) rename cli/src/contexts/framework/application/setup/{install-machine-plugins-use-case.ts => install-global-plugins-use-case.ts} (92%) rename cli/tests/contexts/framework/application/ownership/{machine-plugin-files.unit.test.ts => global-plugin-files.unit.test.ts} (83%) rename cli/tests/contexts/framework/application/setup/{install-machine-plugins-use-case.unit.test.ts => install-global-plugins-use-case.unit.test.ts} (94%) rename cli/tests/contexts/tools/domain/profiles/codex/{machine-files.unit.test.ts => global-files.unit.test.ts} (60%) rename cli/tests/contexts/tools/domain/profiles/kilo/{machine-files.unit.test.ts => global-files.unit.test.ts} (88%) rename cli/tests/contexts/tools/domain/profiles/opencode/{machine-files.unit.test.ts => global-files.unit.test.ts} (69%) 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 e26cf8b4c..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 @@ -49,7 +49,7 @@ One registration of `aidd-framework` per machine, shared by every project. Read - 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.machineFiles` (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`). +- `--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. 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 d90a44d86..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,8 +35,8 @@ export interface DoctorRegistrationOptions { manifest: Manifest; projectRoot: string; allowedIds: Set | null; - /** A machine-wide install declares nothing in a project's settings, so it skips that check. */ - machine?: boolean; + /** 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 @@ -86,7 +86,7 @@ export class DoctorRegistrationUseCase { async execute(options: DoctorRegistrationOptions): Promise { return [ - ...(options.machine === true ? [] : 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/ownership/machine-plugin-files.ts b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts similarity index 93% rename from cli/src/contexts/framework/application/ownership/machine-plugin-files.ts rename to cli/src/contexts/framework/application/ownership/global-plugin-files.ts index 51903e1c2..501b50933 100644 --- a/cli/src/contexts/framework/application/ownership/machine-plugin-files.ts +++ b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts @@ -4,7 +4,7 @@ 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 { machineFilesOf } from "../../../tools/domain/registry.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"; @@ -13,14 +13,14 @@ import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-conta * 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 readMachinePluginFiles( +export async function readGlobalPluginFiles( fs: FileReader, hasher: Hasher, builtDir: string, toolId: AiToolId, dist: PluginDistribution ): Promise { - const machine = machineFilesOf(toolId); + const machine = globalFilesOf(toolId); if (machine === null) return []; const plugin = { name: dist.manifest.name, @@ -48,7 +48,7 @@ export async function readMachinePluginFiles( * file already there that this plugin does not own is never overwritten unless it already holds * the same bytes. Creates the user directory itself when the host has not yet. */ -export async function assertMachineFilesWritable( +export async function assertGlobalFilesWritable( fs: FileReader & FileWriter, toolId: AiToolId, plugin: Pick | undefined, @@ -56,7 +56,7 @@ export async function assertMachineFilesWritable( files: readonly InstallationFile[], homedir: string ): Promise { - const machine = machineFilesOf(toolId); + 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); 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 9588e5d23..e6d4df6f9 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 @@ -10,7 +10,7 @@ import type { AiToolId } from "../../../../kernel/tool.js"; import { frameworkBuildModeFor, getToolConfig, - machineFilesOf, + globalFilesOf, resolvePluginsCapability, } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; @@ -30,7 +30,7 @@ import { userScopeFilesSafeToDelete, withoutMissingFiles, } from "../shared/user-scope-plugin-files.js"; -import { assertMachineFilesWritable, readMachinePluginFiles } from "./machine-plugin-files.js"; +import { assertGlobalFilesWritable, readGlobalPluginFiles } from "./global-plugin-files.js"; export interface PlannedUserPluginFileUpdate { readonly plugin: InstalledPlugin; @@ -128,7 +128,7 @@ export class UserPluginFileUpdater { return plan.next; } - /** A tool's machine files spread across its user directory; a user-scope plugin directory + /** A tool's global 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, @@ -136,8 +136,8 @@ export class UserPluginFileUpdater { files: readonly InstallationFile[], home: string ): Promise { - if (machineFilesOf(toolId) !== null) { - await assertMachineFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); + if (globalFilesOf(toolId) !== null) { + await assertGlobalFilesWritable(this.fs, toolId, plugin, plugin.name, files, home); return; } const baseDir = resolveBaseDirFromRecord("user", toolId, "", () => home); @@ -187,8 +187,8 @@ export class UserPluginFileUpdater { target: toolId, mode: frameworkBuildModeFor(toolId), }); - if (machineFilesOf(toolId) !== null) { - const files = await readMachinePluginFiles(this.fs, this.hasher, builtDir, toolId, dist); + 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); @@ -199,7 +199,7 @@ export class UserPluginFileUpdater { return { files, componentPaths: new Map() }; } // Without the built tree, translation would write a project's layout in the user directory. - if (machineFilesOf(toolId) !== null) { + 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\`.` ); 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/setup-use-case.ts b/cli/src/contexts/framework/application/setup-use-case.ts index 345f8da66..3604b9762 100644 --- a/cli/src/contexts/framework/application/setup-use-case.ts +++ b/cli/src/contexts/framework/application/setup-use-case.ts @@ -24,16 +24,16 @@ export type SetupResult = install: SetupToolsResult; activation: MarketplaceSyncSettingsResult; context?: ProjectContext; - /** Set by a machine-scope run: the plugins it installed for every project. */ - machinePlugins?: readonly string[]; + /** 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 machine-scope run: the plugins it installed for every project. */ - machinePlugins?: readonly string[]; + /** 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-machine-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts similarity index 92% rename from cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts rename to cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts index c7528b706..d382c7d50 100644 --- a/cli/src/contexts/framework/application/setup/install-machine-plugins-use-case.ts +++ b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts @@ -4,7 +4,7 @@ import type { ResolveMarketplace } from "../../../distribution/application/resol 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 { copiesPluginFilesForMachine } from "../../../tools/domain/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"; @@ -14,14 +14,14 @@ import type { UserPluginFileUpdater, } from "../ownership/user-plugin-file-updater.js"; -export interface InstallMachinePluginsOptions { +export interface InstallGlobalPluginsOptions { readonly projectRoot: string; readonly toolIds: readonly ToolId[]; readonly mode: PluginInstallMode; readonly pluginNames: readonly string[]; } -export interface InstallMachinePluginsResult { +export interface InstallGlobalPluginsResult { /** The framework plugins this run chose, which a tool with native activation enables next. */ readonly plugins: readonly string[]; } @@ -37,7 +37,7 @@ export interface InstallMachinePluginsResult { * 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 InstallMachinePluginsUseCase { +export class InstallGlobalPluginsUseCase { constructor( private readonly registry: Pick, private readonly resolveMarketplace: ResolveMarketplace, @@ -47,9 +47,9 @@ export class InstallMachinePluginsUseCase { async execute( manifest: Manifest, - options: InstallMachinePluginsOptions, + options: InstallGlobalPluginsOptions, record: (manifest: Manifest) => Promise - ): Promise { + ): Promise { if (options.mode === "none" || options.mode === "interactive") return { plugins: [] }; const marketplace = (await this.registry.list(options.projectRoot)).find( (entry) => entry.name === FRAMEWORK_MARKETPLACE_NAME && entry.scope === "user" @@ -66,7 +66,7 @@ export class InstallMachinePluginsUseCase { const entries = selectEntries(catalog?.plugins ?? [], options); const plans: { readonly plan: PlannedUserPluginFileUpdate; readonly owned: boolean }[] = []; for (const toolId of options.toolIds) { - if (!copiesPluginFilesForMachine(toolId)) continue; + if (!copiesPluginFilesGlobally(toolId)) continue; for (const entry of entries) { const owned = manifest.getPlugins(toolId).find((plugin) => plugin.name === entry.name); const resolved = InstalledPlugin.fromMetadata( @@ -104,7 +104,7 @@ export class InstallMachinePluginsUseCase { function selectEntries( catalog: readonly PluginCatalogEntry[], - options: InstallMachinePluginsOptions + options: InstallGlobalPluginsOptions ): readonly PluginCatalogEntry[] { if (options.mode === "all") return catalog; if (options.mode === "recommended") return catalog.filter((entry) => entry.recommended); 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 92ea9c14c..5f2459bc3 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 @@ -8,7 +8,7 @@ import type { } 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 { InstallMachinePluginsUseCase } from "./install-machine-plugins-use-case.js"; +import type { InstallGlobalPluginsUseCase } from "./install-global-plugins-use-case.js"; import type { SetupToolsResult } from "./setup-tools-use-case.js"; /** @@ -28,7 +28,7 @@ export class SetupMachineScopeUseCase { private readonly setupMarketplaceRegistration: SetupMarketplaceRegistrationUseCase, private readonly marketplaceSyncSettingsUseCase: MarketplaceSyncSettings, private readonly currentVersionProvider: VersionReader, - private readonly installMachinePluginsUseCase?: InstallMachinePluginsUseCase + private readonly installGlobalPluginsUseCase?: InstallGlobalPluginsUseCase ) {} async execute(flow: SetupFlow): Promise { @@ -46,7 +46,7 @@ export class SetupMachineScopeUseCase { const isNew = await this.initUserManifest(); await this.setupMarketplaceRegistration.registerIfPresent(flow, source); await this.registerUserScopeTools(flow); - const plugins = await this.installMachinePlugins(flow); + const plugins = await this.installGlobalPlugins(flow); const activation: MarketplaceSyncSettingsResult = await this.marketplaceSyncSettingsUseCase.execute({ projectRoot: flow.projectRoot, @@ -56,17 +56,17 @@ export class SetupMachineScopeUseCase { enablePlugins: plugins, }); const install: SetupToolsResult = { results: [] }; - const outcome = { install, activation, context: undefined, machinePlugins: plugins }; + const outcome = { install, activation, context: undefined, globalPlugins: plugins }; return isNew ? { kind: "initialized", ...outcome } : { kind: "up-to-date", ...outcome }; } - /** Copies the chosen plugins' machine files and returns their names, which every tool with + /** Copies the chosen plugins' global files and returns their names, which every tool with * native activation then enables machine-wide. */ - private async installMachinePlugins(flow: SetupFlow): Promise { - if (this.installMachinePluginsUseCase === undefined) return []; + 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.installMachinePluginsUseCase.execute( + const { plugins } = await this.installGlobalPluginsUseCase.execute( manifest, { projectRoot: flow.projectRoot, diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 67c390327..42d0dfdf7 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -84,29 +84,29 @@ export interface ProjectHooksDelivery { * 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 MachineFiles { +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: MachinePlugin) => string | null; + 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 +/** The plugin a global 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 MachinePlugin { +export interface GlobalPlugin { readonly name: string; readonly agents: ReadonlySet; } /** - * The machine-file mapping of a flat tool whose user configuration directory takes the same + * The global-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 flatMachineFilePath(directory: string): MachineFiles["map"] { +export function flatGlobalFilePath(directory: string): GlobalFiles["map"] { return (builtPath, plugin) => { if (!builtPath.startsWith(directory)) return null; const relative = builtPath.slice(directory.length); @@ -118,8 +118,8 @@ export function flatMachineFilePath(directory: string): MachineFiles["map"] { } /** Shared by every mode: where a tool's machine-scope files go, when it has any. */ -interface MachineFilesParams { - machineFiles?: MachineFiles; +interface GlobalFilesParams { + globalFiles?: GlobalFiles; } export interface NativePluginsParams extends ProjectHooksDelivery { @@ -210,7 +210,7 @@ type PluginsParams = ( | FlatPluginsParams | UnsupportedPluginsParams ) & - MachineFilesParams; + GlobalFilesParams; export class PluginsCapability { readonly mode: PluginsMode; @@ -244,14 +244,14 @@ export class PluginsCapability { readonly nativeActivation: NativeActivation | null; readonly translationMode: PluginTranslationMode | null; readonly installScope: "project" | "user"; - /** See {@link MachineFiles}, or `null` when a machine-scope install copies no file. */ - readonly machineFiles: MachineFiles | null; + /** See {@link GlobalFiles}, or `null` when a machine-scope install copies no file. */ + readonly globalFiles: GlobalFiles | null; private readonly _userPluginsDir?: (homedir: string) => string; constructor(params: PluginsParams) { this.mode = params.mode; - this.machineFiles = params.machineFiles ?? null; + this.globalFiles = params.globalFiles ?? null; this.translationMode = PluginsCapability.resolveTranslationMode(params); this.installScope = PluginsCapability.resolveInstallScope(params); PluginsCapability.validateUserScope(params); @@ -342,7 +342,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) ?? this.machineFiles?.dir(homedir) ?? null; + return this._userPluginsDir?.(homedir) ?? this.globalFiles?.dir(homedir) ?? null; } pluginOutputDir(pluginName: string): string | null { diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts index 457d41374..a08b38efe 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts @@ -3,7 +3,7 @@ * even where the literal values coincide: a change to either side must not collapse them. */ -import type { MachinePlugin } from "../../capabilities/plugins-capability.js"; +import type { GlobalPlugin } from "../../capabilities/plugins-capability.js"; /** Relative path for the Codex-native plugin manifest inside each plugin output directory. */ export const OUTPUT_CODEX_MANIFEST_RELATIVE = ".codex-plugin/plugin.json"; @@ -22,7 +22,7 @@ export const OUTPUT_CODEX_AGENTS_DIR = "codex-agents"; * A Codex plugin cannot carry agents, and Codex loads them from `~/.codex/agents/` for every * project, so a machine-scope install copies each staged TOML there, prefixed with its plugin. */ -export function codexMachineFilePath(builtPath: string, plugin: MachinePlugin): string | null { +export function codexGlobalFilePath(builtPath: string, plugin: GlobalPlugin): string | null { const staged = `plugins/${plugin.name}/${OUTPUT_CODEX_AGENTS_DIR}/`; if (!builtPath.startsWith(staged) || !builtPath.endsWith(".toml")) return null; const name = builtPath.slice(staged.length); diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index 1d6e8e39c..294b9255b 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -31,7 +31,7 @@ import { stripCodexSkillFrontmatter, } from "./build.js"; import { codexAgentFrontmatter, codexAgentToml } from "./codex-agent-toml.js"; -import { codexMachineFilePath } from "./codex-paths.js"; +import { codexGlobalFilePath } from "./codex-paths.js"; const DIRECTORY = ".codex/"; const TOOL_SUFFIX = ".codex.md"; @@ -182,7 +182,7 @@ export const codex: AiTool< mode: "native", pluginsDir: ".codex/plugins/", pluginManifestRelativePath: "plugin.json", - machineFiles: { dir: (homedir) => join(homedir, ".codex"), map: codexMachineFilePath }, + globalFiles: { dir: (homedir) => join(homedir, ".codex"), map: codexGlobalFilePath }, acceptsMcp: true, translationMode: "marketplace", // Codex only enables plugins from its user-global config plus its own plugin cache; a diff --git a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts index e85dc3fab..202d3b40b 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -3,7 +3,7 @@ import { AgentsCapability } from "../../capabilities/agents-capability.js"; import { CommandsCapability } from "../../capabilities/commands-capability.js"; import { CONFIG_MCP } from "../../capabilities/config-refs.js"; import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { flatMachineFilePath, PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { flatGlobalFilePath, PluginsCapability } from "../../capabilities/plugins-capability.js"; import { RulesCapability } from "../../capabilities/rules-capability.js"; import { SkillsCapability } from "../../capabilities/skills-capability.js"; import type { @@ -90,9 +90,9 @@ export const kilo: AiTool join(homedir, ".config", "kilo"), - map: flatMachineFilePath(KILO_DIRECTORY), + map: flatGlobalFilePath(KILO_DIRECTORY), }, acceptsHooks: true, flatHooksDir: KILO_HOOKS_DIR, 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 5dae58b53..d35cb9285 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -2,13 +2,13 @@ * contract can read the same values: the profile imports the contract, so the contract cannot * import the profile back. */ -import { flatMachineFilePath } from "../../capabilities/plugins-capability.js"; +import { flatGlobalFilePath } from "../../capabilities/plugins-capability.js"; export const OPENCODE_DIRECTORY = ".opencode/"; /** A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every * project: its global config directory. */ -export const opencodeMachineFilePath = flatMachineFilePath(OPENCODE_DIRECTORY); +export const opencodeGlobalFilePath = flatGlobalFilePath(OPENCODE_DIRECTORY); /** * The directory OpenCode's own plugin loader scans (`{plugin,plugins}/*.{ts,js}`, one level, no diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index e17b5a22e..372e368e1 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -33,7 +33,7 @@ import { OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, - opencodeMachineFilePath, + opencodeGlobalFilePath, } from "./opencode-paths.js"; const DIRECTORY = OPENCODE_DIRECTORY; @@ -107,9 +107,9 @@ export const opencode: AiTool< plugins: new PluginsCapability({ mode: "flat", flatNamespacePrefix: "aidd-", - machineFiles: { + globalFiles: { dir: (homedir) => join(homedir, ".config", "opencode"), - map: opencodeMachineFilePath, + map: opencodeGlobalFilePath, }, acceptsHooks: true, flatHooksDir: OPENCODE_HOOKS_DIR, diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index a27d2c648..ef9e47d09 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -16,7 +16,7 @@ import { import type { ToolBuildContract } from "./build-contract.js"; import type { EnvironmentReader, - MachineFiles, + GlobalFiles, NativeActivation, PluginsCapability, } from "./capabilities/plugins-capability.js"; @@ -120,7 +120,7 @@ export function pluginEnablementIsMachineGlobal(toolId: ToolId): boolean { } /** 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 machine files into the + * 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); @@ -128,7 +128,7 @@ export function supportsUserScopeActivation(toolId: ToolId): boolean { return ( capability.nativeActivation !== null || capability.installScope === "user" || - capability.machineFiles !== null + capability.globalFiles !== null ); } @@ -142,15 +142,15 @@ export function hostRefInUse(toolId: ToolId, enabled: boolean): boolean { /** 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 copiesPluginFilesForMachine(toolId: ToolId): boolean { +export function copiesPluginFilesGlobally(toolId: ToolId): boolean { const capability = resolvePluginsCapability(toolId); if (capability === null) return false; - return capability.installScope === "user" || capability.machineFiles !== null; + return capability.installScope === "user" || capability.globalFiles !== null; } -/** See {@link MachineFiles}: `null` for a tool whose machine-scope install copies no file. */ -export function machineFilesOf(toolId: ToolId): MachineFiles | null { - return resolvePluginsCapability(toolId)?.machineFiles ?? 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/presentation/commands/doctor.ts b/cli/src/presentation/commands/doctor.ts index ebd6dcd04..c3c493b9f 100644 --- a/cli/src/presentation/commands/doctor.ts +++ b/cli/src/presentation/commands/doctor.ts @@ -139,7 +139,7 @@ async function runUserScopeDoctor( manifest, projectRoot, allowedIds, - machine: true, + globalInstall: true, }); printScopeIssues(output, "User scope", { issues }); const healthy = issues.every((i) => i.severity !== "error"); diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts index cca55c91d..bcd93efc1 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -5,7 +5,7 @@ import { SetupUseCase } from "../../contexts/framework/application/setup-use-cas import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; import { assertToolIdsMatchCategory, - copiesPluginFilesForMachine, + copiesPluginFilesGlobally, getAllRegisteredTools, nativeActivationOf, resolvePluginsCapability, @@ -104,7 +104,7 @@ export function parsePluginsFlag( export function globalSetupHelp(): string { const tools = [...getAllRegisteredTools().keys()].filter(supportsUserScopeActivation).sort(); const native = tools.filter((id) => nativeActivationOf(id) !== undefined); - const copying = tools.filter(copiesPluginFilesForMachine); + const copying = tools.filter(copiesPluginFilesGlobally); const where = (id: ToolId) => resolvePluginsCapability(id)?.userPluginsBaseDir("~")?.replaceAll("\\", "/"); return [ diff --git a/cli/src/presentation/commands/sync.ts b/cli/src/presentation/commands/sync.ts index 8535b7986..6dc713e22 100644 --- a/cli/src/presentation/commands/sync.ts +++ b/cli/src/presentation/commands/sync.ts @@ -25,7 +25,7 @@ interface SyncCmdOptions { } /** A user-scope install writes nothing under any project, so this skips `restoreAllUseCase`: - * it rebuilds the machine files aidd copied into each tool's user directory from the shared + * 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( diff --git a/cli/src/presentation/display/setup-display.ts b/cli/src/presentation/display/setup-display.ts index 5b9e03ce2..05ff05d5a 100644 --- a/cli/src/presentation/display/setup-display.ts +++ b/cli/src/presentation/display/setup-display.ts @@ -11,7 +11,7 @@ interface ToolInstallOutcome { interface SetupOutcome { readonly kind: "initialized" | "up-to-date"; readonly install: { readonly results: readonly ToolInstallOutcome[] }; - readonly machinePlugins?: readonly string[]; + readonly globalPlugins?: readonly string[]; } function displayInstall( @@ -38,11 +38,11 @@ function displayInstall( } export function printSetupOutcome(output: CLIOutput, result: SetupOutcome, verbose: boolean): void { - if (result.machinePlugins !== undefined) { + if (result.globalPlugins !== undefined) { output.success( - result.machinePlugins.length === 0 + result.globalPlugins.length === 0 ? "Registered for every project on this machine." - : `Installed for every project on this machine: ${result.machinePlugins.join(", ")}.` + : `Installed for every project on this machine: ${result.globalPlugins.join(", ")}.` ); return; } diff --git a/cli/src/presentation/display/sync-display.ts b/cli/src/presentation/display/sync-display.ts index e6d423f8d..5ba4453ba 100644 --- a/cli/src/presentation/display/sync-display.ts +++ b/cli/src/presentation/display/sync-display.ts @@ -72,7 +72,7 @@ export function printUserScopeSyncOutcome( activated: readonly string[], refreshed: readonly string[] = [] ): void { - if (refreshed.length > 0) output.success(`Refreshed machine files for: ${refreshed.join(", ")}`); + 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) { diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 04ca99252..0ffe39907 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -53,7 +53,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 { InstallMachinePluginsUseCase } from "../../contexts/framework/application/setup/install-machine-plugins-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"; @@ -509,7 +509,7 @@ export async function createDeps( setupMarketplaceRegistration, marketplaceSyncSettingsUseCase, currentVersionProvider, - new InstallMachinePluginsUseCase( + new InstallGlobalPluginsUseCase( marketplaceRegistry, resolveMarketplaceUseCase, userPluginFileUpdater, diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index c465733b7..5c901470e 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -21,7 +21,7 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] // 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' machine files, native activation, and the version each + // 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", 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 2859cd236..c9f6ce142 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -69,7 +69,7 @@ async function issuesFor( manifest, projectRoot: PROJECT_ROOT, allowedIds, - machine: machine, + globalInstall: machine, }); } diff --git a/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts similarity index 83% rename from cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts rename to cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts index 34b51f4f2..3aefe2ad1 100644 --- a/cli/tests/contexts/framework/application/ownership/machine-plugin-files.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts @@ -3,9 +3,9 @@ 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 { - assertMachineFilesWritable, - readMachinePluginFiles, -} from "../../../../../src/contexts/framework/application/ownership/machine-plugin-files.js"; + 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"; @@ -32,9 +32,9 @@ const tree = new InMemoryFileAdapter({ [`${BUILT}/.opencode/commands/aidd-dev-plan.md`]: "command", }); -describe("readMachinePluginFiles", () => { +describe("readGlobalPluginFiles", () => { it("reads the plugin's skills and the agents its source ships, keyed under the user directory", async () => { - const files = await readMachinePluginFiles( + const files = await readGlobalPluginFiles( tree, new DeterministicHasher(), BUILT, @@ -49,7 +49,7 @@ describe("readMachinePluginFiles", () => { }); it("reads nothing for a plugin whose source ships no agent and no skill in the tree", async () => { - const files = await readMachinePluginFiles( + const files = await readGlobalPluginFiles( tree, new DeterministicHasher(), BUILT, @@ -60,9 +60,9 @@ describe("readMachinePluginFiles", () => { expect(files).toEqual([]); }); - it("reads nothing for a tool that copies no machine file, which has no directory to write either", async () => { + it("reads nothing for a tool that copies no global file, which has no directory to write either", async () => { await expect( - assertMachineFilesWritable( + assertGlobalFilesWritable( new InMemoryFileAdapter(), "claude", undefined, @@ -73,8 +73,8 @@ describe("readMachinePluginFiles", () => { ).rejects.toThrow("claude: no machine-scope directory is declared."); }); - it("reads nothing for a tool that copies no machine file", async () => { - const files = await readMachinePluginFiles( + it("reads nothing for a tool that copies no global file", async () => { + const files = await readGlobalPluginFiles( tree, new DeterministicHasher(), BUILT, @@ -86,7 +86,7 @@ describe("readMachinePluginFiles", () => { }); }); -describe("assertMachineFilesWritable", () => { +describe("assertGlobalFilesWritable", () => { const hasher = new DeterministicHasher(); const HOME = "/home/me"; const DIR = join(HOME, ".config", "opencode"); @@ -98,7 +98,7 @@ describe("assertMachineFilesWritable", () => { const fs = new InMemoryFileAdapter({}, hasher); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) ).resolves.toBe(DIR); }); @@ -108,7 +108,7 @@ describe("assertMachineFilesWritable", () => { const fs = new InMemoryFileAdapter({}, hasher); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file(path)], HOME) + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file(path)], HOME) ).rejects.toThrow(`opencode: 'aidd-dev' path '${path}' escapes ${DIR}.`); } ); @@ -118,7 +118,7 @@ describe("assertMachineFilesWritable", () => { fs.setSymlink(under("agents"), "/elsewhere"); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + assertGlobalFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) ).rejects.toThrow(`opencode: 'aidd-dev' parent '${under("agents")}' escapes ${DIR}.`); }); @@ -126,7 +126,7 @@ describe("assertMachineFilesWritable", () => { const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: "mine" }, hasher); await expect( - assertMachineFilesWritable(fs, "opencode", undefined, "aidd-dev", [file("agents/a.md")], HOME) + 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` ); @@ -146,7 +146,7 @@ describe("assertMachineFilesWritable", () => { ).withFiles(new Map([["agents/owned.md", "0".repeat(32)]])); await expect( - assertMachineFilesWritable( + assertGlobalFilesWritable( fs, "opencode", owned, 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 f26f3aab3..b57b50c32 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 @@ -89,7 +89,7 @@ describe("UserPluginFileUpdater public update contract", () => { expect(f.logger.warnMessages.filter((m) => m.includes("left in place"))).toEqual([]); }); - it("refuses to rebuild machine files without the shared source, rather than write a project's layout", async () => { + 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`" 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..e39c5743e 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 @@ -42,7 +42,7 @@ function manifest(): Manifest { } 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 +190,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/setup-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts index 4ad72eea2..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,7 +582,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, - machinePlugins: [], + globalPlugins: [], }); }); @@ -599,7 +599,7 @@ describe("setup --scope user", () => { install: { results: [] }, activation: { updatedTools: [] }, context: undefined, - machinePlugins: [], + globalPlugins: [], }); }); diff --git a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts similarity index 94% rename from cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts index 6dc540cc9..0b90964d2 100644 --- a/cli/tests/contexts/framework/application/setup/install-machine-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/install-global-plugins-use-case.unit.test.ts @@ -5,7 +5,7 @@ import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import type { PluginCatalogEntry } from "../../../../../src/contexts/distribution/domain/catalog.js"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import type { PlannedUserPluginFileUpdate } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; -import { InstallMachinePluginsUseCase } from "../../../../../src/contexts/framework/application/setup/install-machine-plugins-use-case.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"; @@ -56,7 +56,7 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { } ); const applyUpdate = vi.fn(async (plan: PlannedUserPluginFileUpdate) => plan.next); - const useCase = new InstallMachinePluginsUseCase( + const useCase = new InstallGlobalPluginsUseCase( { list: async () => marketplaces }, { execute: async () => ({ @@ -72,7 +72,7 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { } function run( - useCase: InstallMachinePluginsUseCase, + useCase: InstallGlobalPluginsUseCase, manifest: Manifest, mode: PluginInstallMode, pluginNames: string[] = [] @@ -89,8 +89,8 @@ function run( ); } -describe("InstallMachinePluginsUseCase", () => { - it("records each chosen plugin's machine files for a tool that copies them, and names every one", async () => { +describe("InstallGlobalPluginsUseCase", () => { + it("records each chosen plugin's global files for a tool that copies them, and names every one", async () => { const { manifest, useCase, planUpdate } = setup(); const result = await run(useCase, manifest, "all"); 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 d8a11be0c..bee79dbd5 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -134,11 +134,11 @@ describe("SetupFlow", () => { } }); - it("accepts an AI tool that copies machine files at --scope user (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 machine files at --scope user (opencode)", () => { + it("accepts an AI tool that copies global files at --scope user (opencode)", () => { expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).not.toThrow(); }); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts similarity index 60% rename from cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts index d3b4e9741..d213657b6 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/machine-files.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/global-files.unit.test.ts @@ -1,15 +1,15 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import { codexMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-paths.js"; -import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.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("codexMachineFilePath — the agents a machine-scope install copies into ~/.codex", () => { +describe("codexGlobalFilePath — the agents a machine-scope install copies into ~/.codex", () => { it("prefixes a staged agent with its plugin under agents/", () => { expect( - codexMachineFilePath("plugins/aidd-orchestrator/codex-agents/reviewer.toml", ORCHESTRATOR) + codexGlobalFilePath("plugins/aidd-orchestrator/codex-agents/reviewer.toml", ORCHESTRATOR) ).toBe("agents/aidd-orchestrator-reviewer.toml"); }); @@ -19,12 +19,12 @@ describe("codexMachineFilePath — the agents a machine-scope install copies int "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(codexMachineFilePath(built, ORCHESTRATOR)).toBeNull(); + expect(codexGlobalFilePath(built, ORCHESTRATOR)).toBeNull(); }); }); describe("the codex profile's machine-scope directory", () => { it("is ~/.codex under the home directory", () => { - expect(machineFilesOf("codex")?.dir("/home/me")).toBe(join("/home/me", ".codex")); + expect(globalFilesOf("codex")?.dir("/home/me")).toBe(join("/home/me", ".codex")); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts similarity index 88% rename from cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts index 2823c2de3..928c43d7b 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/machine-files.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts @@ -1,12 +1,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { globalFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; describe("the kilo profile's machine-scope files", () => { - const machine = machineFilesOf("kilo"); + const machine = globalFilesOf("kilo"); it("land in ~/.config/kilo under the home directory", () => { expect(machine?.dir("/home/me")).toBe(join("/home/me", ".config/kilo")); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts similarity index 69% rename from cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts index fb103047d..56a63575f 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/machine-files.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/global-files.unit.test.ts @@ -1,12 +1,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import { opencodeMachineFilePath } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-paths.js"; -import { machineFilesOf } from "../../../../../../src/contexts/tools/domain/registry.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"]) }; -describe("opencodeMachineFilePath — what a machine-scope install copies into OpenCode's config dir", () => { +describe("opencodeGlobalFilePath — what a machine-scope install copies into OpenCode's config dir", () => { it.each([ [".opencode/skills/aidd-dev/01-plan/SKILL.md", "skills/aidd-dev/01-plan/SKILL.md"], [ @@ -15,7 +15,7 @@ describe("opencodeMachineFilePath — what a machine-scope install copies into O ], [".opencode/agents/aidd-dev-reviewer.md", "agents/aidd-dev-reviewer.md"], ])("keeps the plugin's own %s as %s", (built, machine) => { - expect(opencodeMachineFilePath(built, AIDD_DEV)).toBe(machine); + expect(opencodeGlobalFilePath(built, AIDD_DEV)).toBe(machine); }); it.each([ @@ -30,13 +30,13 @@ describe("opencodeMachineFilePath — what a machine-scope install copies into O ])( "leaves %s out: another plugin's, an agent it does not ship, or not loaded machine-wide", (built) => { - expect(opencodeMachineFilePath(built, AIDD_DEV)).toBeNull(); + expect(opencodeGlobalFilePath(built, AIDD_DEV)).toBeNull(); } ); }); describe("the opencode profile's machine-scope directory", () => { it("is ~/.config/opencode under the home directory", () => { - expect(machineFilesOf("opencode")?.dir("/home/me")).toBe(join("/home/me", ".config/opencode")); + expect(globalFilesOf("opencode")?.dir("/home/me")).toBe(join("/home/me", ".config/opencode")); }); }); diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index e21a55145..8e3eadd20 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -63,7 +63,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { const sync = await runCli(["sync", "--global"], projectDir, fakeHome); expect(sync.exitCode).toBe(0); - expect(sync.stdout).toContain("Refreshed machine files for: aidd-dev"); + expect(sync.stdout).toContain("Refreshed global files for: aidd-dev"); const clean = await runCli(["clean", "--global", "--force"], projectDir, fakeHome); expect(clean.exitCode).toBe(0); diff --git a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts index 7cc8912ed..5f59d0ad8 100644 --- a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts @@ -402,7 +402,7 @@ describe("aidd doctor --scope user", () => { manifest: expect.anything(), projectRoot: PROJECT_ROOT, allowedIds: null, - machine: true, + globalInstall: true, }); expect(doctorAll).not.toHaveBeenCalled(); }); diff --git a/cli/tests/presentation/commands/sync-wiring.integration.test.ts b/cli/tests/presentation/commands/sync-wiring.integration.test.ts index 8b7bba755..f17c4b141 100644 --- a/cli/tests/presentation/commands/sync-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/sync-wiring.integration.test.ts @@ -247,14 +247,14 @@ describe("aidd sync --scope user", () => { ]); }); - it("rebuilds the machine files a tool owns before driving activation, under --global", async () => { + 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 machine files for: aidd-dev"]); + expect(await run("--global")).toEqual(["Refreshed global files for: aidd-dev"]); expect(userPluginUpdate).toHaveBeenCalledWith({ toolIds: ["opencode"], projectRoot: PROJECT_ROOT, diff --git a/cli/tests/presentation/display/setup-display.unit.test.ts b/cli/tests/presentation/display/setup-display.unit.test.ts index 868b5320d..daffbb3d0 100644 --- a/cli/tests/presentation/display/setup-display.unit.test.ts +++ b/cli/tests/presentation/display/setup-display.unit.test.ts @@ -95,7 +95,7 @@ describe("printSetupOutcome", () => { printSetupOutcome( output, - { kind: "initialized", install: { results: [] }, machinePlugins: ["aidd-dev", "aidd-pm"] }, + { kind: "initialized", install: { results: [] }, globalPlugins: ["aidd-dev", "aidd-pm"] }, false ); @@ -109,7 +109,7 @@ describe("printSetupOutcome", () => { printSetupOutcome( output, - { kind: "up-to-date", install: { results: [] }, machinePlugins: [] }, + { kind: "up-to-date", install: { results: [] }, globalPlugins: [] }, false ); diff --git a/cli/tests/presentation/display/sync-display.unit.test.ts b/cli/tests/presentation/display/sync-display.unit.test.ts index 7c7810857..2b9b760f4 100644 --- a/cli/tests/presentation/display/sync-display.unit.test.ts +++ b/cli/tests/presentation/display/sync-display.unit.test.ts @@ -164,12 +164,12 @@ describe("printActivationOutcome", () => { }); describe("printUserScopeSyncOutcome", () => { - it("names the plugins whose machine files were rebuilt, and nothing else when no host was driven", () => { + 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 machine files for: 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", () => { @@ -178,7 +178,7 @@ describe("printUserScopeSyncOutcome", () => { printUserScopeSyncOutcome(output, ["claude"], ["aidd-dev"]); expect(output.at("success")).toEqual([ - "Refreshed machine files for: aidd-dev", + "Refreshed global files for: aidd-dev", "Synced native activation for: claude", ]); }); 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 34cd16836..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 @@ -31,7 +31,7 @@ function declaresUserScopeActivation(toolId) { return ( /nativeActivation:\s*\{/u.test(text) || /installScope:\s*"user"/u.test(text) || - /machineFiles:\s*\{/u.test(text) + /globalFiles:\s*\{/u.test(text) ); } From 9fbcf5601dc51db3a44f7c77f5883fb8a85fb04a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 6 Nov 2026 05:04:33 +0100 Subject: [PATCH 16/38] fix(cli): preserve global install ownership and recovery Keep successful native plugin selections recoverable, protect unowned files, and complete global cleanup without removing other marketplace sources. Restore managed OpenCode reload compatibility. Refs: #1142 --- cli/README.md | 6 +- .../framework/application/clean-use-case.ts | 6 +- .../clean/clean-user-scope-use-case.ts | 26 ++- .../marketplace-sync-settings-use-case.ts | 17 +- .../ownership/global-plugin-files.ts | 5 +- .../native-host-registration-gate.ts | 3 +- .../native-marketplace-source-proof.ts | 12 +- .../ownership/user-plugin-file-updater.ts | 8 +- ...an-user-scope-use-case.integration.test.ts | 173 +++++++++++++++++- ...ce-sync-global-plugins.integration.test.ts | 124 +++++++++++++ .../global-plugin-files.unit.test.ts | 20 +- ...tive-marketplace-source-proof.unit.test.ts | 41 ++++- .../plugin-update-built-tree.unit.test.ts | 22 ++- cli/tests/e2e/setup-scope-user.e2e.test.ts | 41 ++++- scripts/__tests__/dev-sync.test.js | 56 ++++++ scripts/dev-sync.sh | 41 +++-- 16 files changed, 526 insertions(+), 75 deletions(-) create mode 100644 cli/tests/contexts/framework/application/flows/marketplace-sync-global-plugins.integration.test.ts diff --git a/cli/README.md b/cli/README.md index a0c36bb99..f4bfb2ef9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -126,11 +126,15 @@ Counts the tokens of Claude Code and Codex sessions, locally. Details: [FAQ](../ | `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 --global`. +Install it, then rerun the original `aidd setup --global` command with its `--ide` and `--plugins` selections. ```sh aidd setup --global --ide claude,codex,copilot,cursor,kilo,opencode --plugins all --yes diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index 22e1b3826..19e1ae5d8 100644 --- a/cli/src/contexts/framework/application/clean-use-case.ts +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -272,7 +272,8 @@ export class CleanUseCase { this.hostPluginRegistries.get(toolId), registration.hostName, new Set([...registrations.pluginRefs, ...machineRefs]), - projectRoot + projectRoot, + toolId ); } } @@ -604,7 +605,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)}`); 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..320e0261e 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 @@ -327,7 +327,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) @@ -419,6 +420,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 +454,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 +474,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/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index a0fb48410..ca620c3ac 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 @@ -630,7 +630,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId, [ ...new Set([ - ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), + ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias, scope), ...requested, ]), ], @@ -782,7 +782,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[] = []; @@ -793,6 +794,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; } diff --git a/cli/src/contexts/framework/application/ownership/global-plugin-files.ts b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts index 501b50933..42665e7c6 100644 --- a/cli/src/contexts/framework/application/ownership/global-plugin-files.ts +++ b/cli/src/contexts/framework/application/ownership/global-plugin-files.ts @@ -45,8 +45,8 @@ export async function readGlobalPluginFiles( /** * 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 unless it already holds - * the same bytes. Creates the user directory itself when the host has not yet. + * 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, @@ -80,7 +80,6 @@ export async function assertGlobalFilesWritable( if (plugin?.files.has(rel) === true) continue; const target = join(boundary, rel); if (!(await fs.fileExists(target))) continue; - if ((await fs.readFileHash(target)).value === file.hash.value) continue; throw new Error( `${toolId}: '${target}' already exists and was not installed by aidd; move or delete it, then rerun.` ); 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/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index e6d4df6f9..497c6574c 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 @@ -160,11 +160,9 @@ export class UserPluginFileUpdater { if (plugin.files.has(file.relativePath)) continue; const target = join(baseDir, file.relativePath); if (!(await this.fs.fileExists(target))) continue; - if ((await this.fs.readFileHash(target)).value !== file.hash.value) { - throw new Error( - `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` - ); - } + throw new Error( + `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` + ); } } 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..44fd30054 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 @@ -33,6 +33,9 @@ const CLAUDE_CACHE = join(HOME, ".claude", "plugins", "cache"); const CODEX_CACHE = join(HOME, ".codex", "plugins", "cache"); 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 +179,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 +264,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 +298,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 +692,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 +1967,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/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/ownership/global-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/ownership/global-plugin-files.unit.test.ts index 3aefe2ad1..a4018c3b8 100644 --- 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 @@ -122,8 +122,8 @@ describe("assertGlobalFilesWritable", () => { ).rejects.toThrow(`opencode: 'aidd-dev' parent '${under("agents")}' escapes ${DIR}.`); }); - it("refuses to overwrite a file it does not own that holds other bytes", async () => { - const fs = new InMemoryFileAdapter({ [under("agents", "a.md")]: "mine" }, hasher); + 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) @@ -132,11 +132,8 @@ describe("assertGlobalFilesWritable", () => { ); }); - it("adopts a file already holding the same bytes, and rewrites a file the plugin owns", async () => { - const fs = new InMemoryFileAdapter( - { [under("agents", "same.md")]: "new", [under("agents", "owned.md")]: "old" }, - hasher - ); + 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", @@ -146,14 +143,7 @@ describe("assertGlobalFilesWritable", () => { ).withFiles(new Map([["agents/owned.md", "0".repeat(32)]])); await expect( - assertGlobalFilesWritable( - fs, - "opencode", - owned, - "aidd-dev", - [file("agents/same.md"), file("agents/owned.md")], - HOME - ) + assertGlobalFilesWritable(fs, "opencode", owned, "aidd-dev", [file("agents/owned.md")], HOME) ).resolves.toBe(DIR); }); }); 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/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index 222f835cc..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,7 +598,7 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(machineBefore); }); - it("takes over a new built file an interrupted run already wrote, byte for byte", async () => { + 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(); @@ -607,15 +607,19 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); deps.fs.setFile(join(USER_PLUGINS_DIR, newRelativePath), "# Built v2\n"); - await makeUpdateUseCase(deps, registry).execute({ - toolIds: ["cursor"], - projectRoot: PROJECT_ROOT, - scope: "user", - }); + 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.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.has(newRelativePath) - ).toBe(true); + 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 () => { diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts index 8e3eadd20..1d9044348 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 { readdir, 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"; @@ -74,6 +74,43 @@ describe("E2E: setup --scope user writes nothing under the project", () => { } }); + 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"); try { diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index d18f46ccf..2156e3d77 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -282,6 +282,62 @@ if (args[0] !== "setup") process.exit(2); ${setupBody}`); } +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); + 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); + 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("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 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.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("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); + 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, 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("every host takes this checkout through aidd setup --global, afresh", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-global-")); try { diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index e14d3b8d7..0cc0561eb 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -2,6 +2,7 @@ # (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. @@ -41,6 +42,7 @@ CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc} 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 @@ -96,8 +98,8 @@ hosts=() [ "$HAVE_COPILOT" = 0 ] || hosts+=(copilot) [ "$HAVE_CURSOR" = 0 ] || hosts+=(cursor) [ "$HAVE_KILO" = 0 ] || hosts+=(kilo) -[ "$HAVE_OPENCODE" = 0 ] || hosts+=(opencode) -if [ "${#hosts[@]}" -eq 0 ]; then +[ "$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 @@ -106,23 +108,32 @@ for d in "$FW"/plugins/*/; do [ -f "$d.claude-plugin/plugin.json" ] && targets+=("$(basename "$d")") done -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/^/ /' +if [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then + printf '%-22s' "reload opencode" + aidd-opencode-reload +fi + +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=() [ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") [ "$HAVE_CLAUDE" = 0 ] || instructions+=(claude "$CLAUDE_INSTRUCTIONS") -[ "$HAVE_OPENCODE" = 0 ] || instructions+=(opencode "$OPENCODE_INSTRUCTIONS") +if [ "$HAVE_OPENCODE" = 1 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then + instructions+=(opencode "$OPENCODE_INSTRUCTIONS") +fi [ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") [ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") if [ "${#instructions[@]}" -gt 0 ]; then From d268c7293352ba6075b74e776545a1d1b2712599 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 12:25:16 +0800 Subject: [PATCH 17/38] fix(cli): integrate global installation changes --- .claude-plugin/marketplace.json | 9 + .github/workflows/ci.yml | 1 + .release-please-manifest.json | 1 + README.md | 14 +- aidd_docs/memory/architecture.md | 6 +- aidd_docs/memory/deployment.md | 2 +- cli/README.md | 3 +- cli/aidd_docs/memory/architecture.md | 1 + cli/aidd_docs/memory/cli.md | 1 + cli/aidd_docs/memory/codebase-map.md | 5 +- cli/package.json | 5 +- cli/pnpm-lock.yaml | 59 +- cli/scripts/smoke-tools.sh | 3 +- cli/src/cli.ts | 3 + .../domain/ports/plugin-fetcher.ts | 2 + .../infrastructure/plugin-fetcher-adapter.ts | 174 +++- .../framework/application/clean-use-case.ts | 12 +- .../clean/clean-user-scope-use-case.ts | 6 + .../marketplace-sync-settings-use-case.ts | 167 +++- .../built-tree-materialization-translator.ts | 52 +- .../mode-b-flat-materialization-translator.ts | 34 +- .../framework/translator/plugin-translator.ts | 3 +- .../ownership/project-path-boundary.ts | 36 +- .../ownership/project-plugin-cleanup.ts | 46 +- .../release/pinned-user-framework.ts | 21 + .../upgrade-user-framework-use-case.ts | 174 ++++ .../ownership/release/user-release-journal.ts | 240 ++++++ .../ownership/tui/kilo-tui-config.ts | 99 +++ .../ownership/tui/user-kilo-tui-removal.ts | 33 + .../user-marketplace-remove-use-case.ts | 3 + .../ownership/user-plugin-file-updater.ts | 197 ++++- .../ownership/user-plugin-update-use-case.ts | 13 +- .../application/plugin/plugin-add-use-case.ts | 66 +- .../application/plugin/plugin-helpers.ts | 3 +- ...lugin-install-from-marketplace-use-case.ts | 13 + .../plugin/plugin-remove-use-case.ts | 14 +- .../plugin/plugin-update-use-case.ts | 15 +- .../setup/install-global-plugins-use-case.ts | 38 +- .../setup/setup-machine-scope-use-case.ts | 5 +- .../shared/apply-plugin-files-use-case.ts | 3 + .../ensure-built-marketplace-use-case.ts | 47 +- .../shared/user-scope-plugin-files.ts | 17 - .../application/upgrade-file-guard.ts | 42 + .../application/upgrade-framework-use-case.ts | 282 ++++++ .../domain/plugins/installed-plugin.ts | 20 + .../pinned-user-framework-adapter.ts | 154 ++++ .../user-manifest-repository-adapter.ts | 155 +++- .../user-release-journal-store.ts | 68 ++ .../domain/capabilities/plugins-capability.ts | 19 +- .../tools/domain/profiles/codex/profile.ts | 4 +- .../domain/profiles/kilo/headless-run.ts | 7 + .../domain/profiles/kilo/kilo-hooks-bridge.ts | 30 +- .../tools/domain/profiles/kilo/kilo-paths.ts | 14 + .../tools/domain/profiles/kilo/profile.ts | 10 +- .../tools/domain/profiles/opencode/build.ts | 19 +- .../domain/profiles/opencode/headless-run.ts | 7 + .../opencode/opencode-hooks-bridge.ts | 54 +- .../profiles/opencode/opencode-paths.ts | 33 +- .../tools/domain/profiles/opencode/profile.ts | 2 + .../translate/domain/content-translator.ts | 13 +- cli/src/kernel/ports/file-reader.ts | 2 + cli/src/presentation/commands/framework.ts | 182 +++- cli/src/presentation/commands/plugin.ts | 20 +- cli/src/presentation/commands/run/command.ts | 187 ++++ cli/src/presentation/commands/setup.ts | 5 + cli/src/presentation/commands/sync.ts | 2 + cli/src/runtime/filesystem/file-adapter.ts | 22 +- cli/src/runtime/machine-config-directory.ts | 32 + .../github-release-resolver-adapter.ts | 14 + cli/src/runtime/wiring/framework.ts | 256 +++++- cli/src/runtime/wiring/tools.ts | 5 + ...plugin-fetcher-adapter.integration.test.ts | 202 ++++- ...an-user-scope-use-case.integration.test.ts | 53 ++ ...-sync-rollback-refusal.integration.test.ts | 104 ++- .../marketplace-sync-settings.unit.test.ts | 34 +- ...sync-source-provenance.integration.test.ts | 169 +++- .../ownership/kilo-tui-config.unit.test.ts | 66 ++ ...ser-framework-use-case.integration.test.ts | 480 +++++++++++ ...ser-marketplace-remove.integration.test.ts | 64 ++ .../user-plugin-file-updater.unit.test.ts | 225 ++++- .../user-plugin-rebuild.integration.test.ts | 408 +++++++++ ...-plugin-update-session.integration.test.ts | 107 +++ .../user-release-journal.integration.test.ts | 366 ++++++++ ...-maintenance-lifecycle.integration.test.ts | 172 ++++ .../plugin/plugin-add-use-case.unit.test.ts | 119 +++ ...all-from-marketplace-use-case.unit.test.ts | 65 ++ ...lugin-remove-selection.integration.test.ts | 53 ++ ...stall-global-plugins-use-case.unit.test.ts | 124 ++- ...t-marketplace-use-case.integration.test.ts | 164 +++- .../upgrade-file-guard.unit.test.ts | 23 + .../upgrade-framework-use-case.unit.test.ts | 415 +++++++++ ...user-framework-adapter.integration.test.ts | 247 ++++++ ...user-framework-session.integration.test.ts | 188 ++++ ...est-repository-adapter.integration.test.ts | 413 ++++++++- .../profiles/codex/plugins.unit.test.ts | 2 +- .../profiles/kilo/global-files.unit.test.ts | 39 +- .../kilo/kilo-session-message.unit.test.ts | 68 ++ .../opencode/global-files.unit.test.ts | 19 + .../profiles/opencode/hooks.unit.test.ts | 13 + .../opencode-hooks-bridge.unit.test.ts | 33 +- .../opencode-session-message.unit.test.ts | 56 ++ .../opencode/opencode-v2-tui.unit.test.ts | 122 +++ ...pencode-v2-content-translator.unit.test.ts | 25 + cli/tests/e2e/headless-run.e2e.test.ts | 388 +++++++++ cli/tests/e2e/setup-scope-user.e2e.test.ts | 2 + cli/tests/e2e/xdg-machine-scope.e2e.test.ts | 77 ++ .../snapshots/framework-build/golden.json | 4 +- cli/tests/golden/snapshots/help/surface.json | 11 +- .../helpers/ports/in-memory-file-adapter.ts | 4 + .../framework-wiring.integration.test.ts | 249 +++++- .../plugin-wiring.integration.test.ts | 72 ++ .../commands/run-wiring.integration.test.ts | 453 ++++++++++ .../commands/setup-wiring.integration.test.ts | 23 + ...chine-config-directory.integration.test.ts | 61 ++ ...lease-resolver-adapter.integration.test.ts | 39 + .../framework-upgrade.integration.test.ts | 806 ++++++++++++++++++ docs/ARCHITECTURE.md | 1 + docs/CATALOG.md | 19 + docs/assets/plugins/plugins.json | 5 + .../.claude-plugin/plugin.json | 15 + plugins/aidd-maintenance/CATALOG.md | 39 + plugins/aidd-maintenance/README.md | 26 + plugins/aidd-maintenance/hooks/hooks.json | 16 + plugins/aidd-maintenance/hooks/kilo-tui.js | 30 + .../aidd-maintenance/hooks/opencode-server.js | 2 + .../aidd-maintenance/hooks/opencode-tui.js | 63 ++ .../aidd-maintenance/hooks/session-start.cjs | 443 ++++++++++ .../skills/01-update/SKILL.md | 18 + .../01-update/references/native-commands.md | 8 + release-please-config.json | 10 + .../architecture-doc-matches-the-tree.test.js | 4 +- scripts/__tests__/maintenance.test.js | 649 ++++++++++++++ scripts/sync-generated-docs.mjs | 2 +- 133 files changed, 11088 insertions(+), 395 deletions(-) create mode 100644 cli/src/contexts/framework/application/ownership/release/pinned-user-framework.ts create mode 100644 cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts create mode 100644 cli/src/contexts/framework/application/ownership/release/user-release-journal.ts create mode 100644 cli/src/contexts/framework/application/ownership/tui/kilo-tui-config.ts create mode 100644 cli/src/contexts/framework/application/ownership/tui/user-kilo-tui-removal.ts create mode 100644 cli/src/contexts/framework/application/upgrade-file-guard.ts create mode 100644 cli/src/contexts/framework/application/upgrade-framework-use-case.ts create mode 100644 cli/src/contexts/framework/infrastructure/pinned-user-framework-adapter.ts create mode 100644 cli/src/contexts/framework/infrastructure/user-release-journal-store.ts create mode 100644 cli/src/contexts/tools/domain/profiles/kilo/headless-run.ts create mode 100644 cli/src/contexts/tools/domain/profiles/opencode/headless-run.ts create mode 100644 cli/src/presentation/commands/run/command.ts create mode 100644 cli/src/runtime/machine-config-directory.ts create mode 100644 cli/tests/contexts/framework/application/ownership/kilo-tui-config.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/ownership/upgrade-user-framework-use-case.integration.test.ts create mode 100644 cli/tests/contexts/framework/application/ownership/user-plugin-rebuild.integration.test.ts create mode 100644 cli/tests/contexts/framework/application/ownership/user-plugin-update-session.integration.test.ts create mode 100644 cli/tests/contexts/framework/application/ownership/user-release-journal.integration.test.ts create mode 100644 cli/tests/contexts/framework/application/plugin/kilo-maintenance-lifecycle.integration.test.ts create mode 100644 cli/tests/contexts/framework/application/upgrade-file-guard.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/upgrade-framework-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/infrastructure/pinned-user-framework-adapter.integration.test.ts create mode 100644 cli/tests/contexts/framework/infrastructure/pinned-user-framework-session.integration.test.ts create mode 100644 cli/tests/contexts/tools/domain/profiles/kilo/kilo-session-message.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/profiles/opencode/opencode-session-message.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/profiles/opencode/opencode-v2-tui.unit.test.ts create mode 100644 cli/tests/contexts/translate/domain/opencode-v2-content-translator.unit.test.ts create mode 100644 cli/tests/e2e/headless-run.e2e.test.ts create mode 100644 cli/tests/e2e/xdg-machine-scope.e2e.test.ts create mode 100644 cli/tests/presentation/commands/run-wiring.integration.test.ts create mode 100644 cli/tests/runtime/machine-config-directory.integration.test.ts create mode 100644 cli/tests/runtime/wiring/framework-upgrade.integration.test.ts create mode 100644 plugins/aidd-maintenance/.claude-plugin/plugin.json create mode 100644 plugins/aidd-maintenance/CATALOG.md create mode 100644 plugins/aidd-maintenance/README.md create mode 100644 plugins/aidd-maintenance/hooks/hooks.json create mode 100644 plugins/aidd-maintenance/hooks/kilo-tui.js create mode 100644 plugins/aidd-maintenance/hooks/opencode-server.js create mode 100644 plugins/aidd-maintenance/hooks/opencode-tui.js create mode 100644 plugins/aidd-maintenance/hooks/session-start.cjs create mode 100644 plugins/aidd-maintenance/skills/01-update/SKILL.md create mode 100644 plugins/aidd-maintenance/skills/01-update/references/native-commands.md create mode 100644 scripts/__tests__/maintenance.test.js 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/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 bdfc8ea32..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 diff --git a/cli/README.md b/cli/README.md index f4bfb2ef9..481119be8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -52,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) | 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/package.json b/cli/package.json index ccf272438..d94243f41 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 747, + "bundleBudgetKB": 793, "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/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/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..797aef284 100644 --- a/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts +++ b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts @@ -5,12 +5,13 @@ 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"; @@ -33,15 +34,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 +60,182 @@ 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( + injectTokenIntoUrl(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( + authUrl, + 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( + authUrl, + 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 = { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + 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", + }; + for (const key of Object.keys(env)) { + if ( + /^GIT_(?:DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_PARAMETERS|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+|NAMESPACE|REPLACE_REF_BASE)$/.test( + key + ) + ) + delete env[key]; + } + 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, @@ -140,9 +264,15 @@ export class PluginFetcherAdapter implements PluginFetcher { authUrl: 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); } catch (err) { @@ -155,12 +285,14 @@ export class PluginFetcherAdapter implements PluginFetcher { 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", + ...(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"); diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index 19e1ae5d8..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 @@ -695,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 320e0261e..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, @@ -404,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, 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 ca620c3ac..cedffe40b 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 @@ -102,6 +102,8 @@ export interface MarketplaceSyncSettingsOptions { * 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 }[]; } interface ActivationOutcome { @@ -224,6 +226,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 @@ -239,13 +242,19 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { marketplaces, toolIds, scope, - options.enablePlugins ?? [] + options.enablePlugins ?? [], + options.refreshOwnedPlugins ?? [] ); let declarativeChanged = false; if (scope === "project") { for (const [toolId, outcome] of activation.outcomes) { const settings = resolvePluginsCapability(toolId)?.marketplaceSettings; - if (settings?.declarativePluginActivationRequiresNativeProof !== true) 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}`)) @@ -258,7 +267,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { manifest, marketplaces, settings, - provenRefs + provenRefs, + options.refreshOwnedPlugins ?? [] )) ) declarativeChanged = true; @@ -276,7 +286,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { const wroteRegistrations = this.recordNativeRegistrations( manifest, activation.outcomes, - options.marketplaceNames !== undefined + options.marketplaceNames !== undefined, + options.refreshOwnedPlugins ?? [] ); if (wroteHashes || wroteRegistrations) await manifestRepo.save(manifest); await this.recordMachinePluginClaims( @@ -284,7 +295,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { activation.outcomes, marketplaces, scope, - scope === "project" ? projectRoot : undefined + scope === "project" ? projectRoot : undefined, + options.refreshOwnedPlugins ?? [] ); if (options.recreateFrameworkIfMissing === true && scope === "project") { await this.purgeStaleProjectCache(projectRoot, marketplaces, activation); @@ -412,7 +424,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { marketplaces: readonly Marketplace[], toolIds: readonly ToolId[], scope: MarketplaceScope, - enablePlugins: readonly string[] + enablePlugins: readonly string[], + refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: string }[] ): Promise { const outcomes = new Map(); const binaryMissing: { toolId: ToolId; binary: string }[] = []; @@ -440,7 +453,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { marketplaces, warnings, scope, - enablePlugins + enablePlugins, + refreshOwnedPlugins ); outcomes.set(toolId, outcome); } catch (error) { @@ -458,7 +472,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) { @@ -481,8 +496,10 @@ 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 && !this.isSelectedRef(manifest, toolId, ref, selected)) ); const registrations: NativeRegistrations = { binary, @@ -544,7 +561,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { marketplaces: readonly Marketplace[], warnings: string[], scope: MarketplaceScope, - enablePlugins: readonly string[] + enablePlugins: readonly string[], + refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: 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 — @@ -556,6 +574,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) { @@ -569,16 +589,23 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); 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) { + 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); if ( proof.canonical && @@ -600,6 +627,7 @@ 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, @@ -638,7 +666,53 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { 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 + ); + 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 + ); + } + } for (const ref of refsToEnable) { + if ( + refreshOwnedPlugins.length && + (!this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) || + !(await sourceStillMatches(ref))) + ) + continue; if ( this.bestEffort( () => activator.enablePlugin(ref, scope), @@ -698,7 +772,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(); @@ -706,7 +781,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) => @@ -879,6 +958,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { outcome: "registered" | "refused" | "build-failed"; claimable: boolean; addedThisRun?: boolean; + skipNativeWrites?: boolean; + refreshable?: boolean; provenance?: NativeMarketplaceSource; }> { const builtDir = await this.buildForTool(toolId, marketplace, projectRoot); @@ -965,7 +1046,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, @@ -982,6 +1069,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { outcome: "registered", claimable: proof.sourceStatus === "owned", provenance: proof.source, + skipNativeWrites: true, }; try { activator.addMarketplace(builtDir, marketplace.scope); @@ -1011,6 +1099,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); @@ -1020,6 +1109,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { claimable: afterAdd !== undefined, addedThisRun: afterAdd !== undefined, provenance: afterAdd, + refreshable: afterAdd !== undefined, }; } @@ -1309,11 +1399,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); @@ -1327,14 +1428,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; @@ -1357,6 +1464,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/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..d0fbb8b19 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/release/upgrade-user-framework-use-case.ts @@ -0,0 +1,174 @@ +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 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; + isPublishedRelease: (repo: string, release: string) => Promise; + } + ) {} + + 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()); + if (!(await this.deps.isPublishedRelease("ai-driven-dev/framework", release))) + 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 currentChannel = + source.ref.split("+", 1)[0]?.match(/-([0-9A-Za-z-]+)/)?.[1] ?? "stable"; + const nextChannel = release.split("+", 1)[0]?.match(/-([0-9A-Za-z-]+)/)?.[1] ?? "stable"; + if (currentChannel !== nextChannel) + throw new Error(`${toolId}:${plugin.name}: refusing a change of release channel.`); + 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, + 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 497c6574c..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,11 +1,12 @@ 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, @@ -28,9 +29,13 @@ import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use- import { assertUserScopeWriteBoundary, userScopeFilesSafeToDelete, - withoutMissingFiles, } 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; @@ -50,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, @@ -65,26 +75,56 @@ export class UserPluginFileUpdater { toolId: AiToolId, projectRoot: string, logger: Logger, - rebuild = false + rebuild = false, + prepared?: PinnedUserFrameworkResult, + session?: PinnedUserFramework ): Promise { - const dist = await this.loader.loadLatest(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 } = await this.materializeFiles( - dist, - toolId, - plugin, - projectRoot - ); + const { files, componentPaths } = pinned + ? { files: [...pinned.files], componentPaths: new Map() } + : await this.materializeFiles(dist, toolId, plugin, cacheRoot); const home = (this.builtDeps?.homedir ?? nodeHomedir)(); - const present = await withoutMissingFiles(this.fs, plugin, toolId, home); - const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, present, toolId, home); - if (oldSafe.size !== present.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." ); } + 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); - const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home); return { plugin, toolId, @@ -94,30 +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 present = await withoutMissingFiles(this.fs, plan.plugin, plan.toolId, plan.home); - const oldSafe = await userScopeFilesSafeToDelete( - this.fs, - logger, - present, + 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 !== present.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 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( @@ -125,10 +178,64 @@ 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; } - /** A tool's global files spread across its user directory; a user-scope plugin directory + 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, @@ -136,20 +243,48 @@ export class UserPluginFileUpdater { files: readonly InstallationFile[], home: string ): Promise { - if (globalFilesOf(toolId) !== null) { + 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 install owns no file yet, and a restore may have lost the directory: it is created - // so the containment proof has a real path to resolve. The name is one safe path segment. + // 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, @@ -196,10 +331,10 @@ export class UserPluginFileUpdater { : built; return { files, componentPaths: new Map() }; } - // Without the built tree, translation would write a project's layout in the user directory. 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\`.` + `${toolId}: the shared framework source is not registered, so '${plugin.name}' cannot be rebuilt; run ` + + "`aidd setup --global`." ); } const toolConfig = getToolConfig(toolId); 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 567ea267c..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( @@ -74,7 +79,9 @@ export class UserPluginUpdateUseCase { toolId, options.projectRoot, this.logger, - options.rebuild === true + options.rebuild === true, + undefined, + session ); filePlans.push({ toolId, plugin, plan }); } 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..856096d0b 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; @@ -176,17 +182,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 +227,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 +238,7 @@ export class PluginAddUseCase implements PluginAdd { manifest, projectRoot ); - const { prevMcpMap, prevHooksMap } = this.prepareForInstall( + const { prevMcpMap, prevHooksMap, prevTuiMap } = this.prepareForInstall( pluginName, resolvedToolIds, manifest, @@ -224,6 +253,7 @@ export class PluginAddUseCase implements PluginAdd { marketplace, prevMcpMap, prevHooksMap, + prevTuiMap, machine ); } @@ -239,6 +269,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 +281,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 +311,7 @@ export class PluginAddUseCase implements PluginAdd { marketplace: string | undefined, prevMcpMap: Map>, prevHooksMap: Map, + prevTuiMap: Map, machine: Manifest ): Promise { const allSkipped: ReadonlySkipList[] = []; @@ -303,8 +339,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 +446,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 +462,8 @@ export class PluginAddUseCase implements PluginAdd { marketplace, previousMcpEntries, userScopeDirTaken, - previousProjectHooks + previousProjectHooks, + previousTui ); return { ...result, notices: [] }; } 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 1af12e092..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 { @@ -112,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/install-global-plugins-use-case.ts b/cli/src/contexts/framework/application/setup/install-global-plugins-use-case.ts index d382c7d50..7cb41176f 100644 --- 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 @@ -9,6 +9,7 @@ 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, @@ -41,7 +42,8 @@ export class InstallGlobalPluginsUseCase { constructor( private readonly registry: Pick, private readonly resolveMarketplace: ResolveMarketplace, - private readonly fileUpdater: Pick, + private readonly fileUpdater: Pick & + Partial>, private readonly logger: Logger ) {} @@ -51,6 +53,19 @@ export class InstallGlobalPluginsUseCase { 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" ); @@ -80,14 +95,19 @@ export class InstallGlobalPluginsUseCase { const plugin = owned === undefined ? resolved - : resolved.withFiles(owned.files).withDependents(owned.dependents); - const plan = await this.fileUpdater.planUpdate( - plugin, - toolId as AiToolId, - options.projectRoot, - this.logger, - true - ); + : 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 }); } 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 5f2459bc3..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,7 +1,8 @@ +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, @@ -43,6 +44,8 @@ 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); 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/shared/user-scope-plugin-files.ts b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts index 4efd14ae3..8a75f4d26 100644 --- a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts +++ b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts @@ -52,23 +52,6 @@ export async function userScopeFilesSafeToDelete( return allowed; } -/** The plugin without the recorded files nothing stands at any more, which an update writes back. - * A dangling symlink counts as gone: the atomic write replaces the link, and never follows it. */ -export async function withoutMissingFiles( - fs: FileReader, - plugin: InstalledPlugin, - toolId: AiToolId, - homedir: string -): Promise { - const boundary = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir); - if (boundary === null || boundary === undefined) return plugin; - const present = new Map(); - for (const [relativePath, hash] of plugin.files) { - if (await fs.fileExists(join(boundary, relativePath))) present.set(relativePath, hash); - } - return plugin.withFiles(present); -} - /** Refuse a machine update if a new path, existing parent, or plugin directory escapes its declared base. */ export async function assertUserScopeWriteBoundary( fs: FileReader, 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..ac9f5d2a0 --- /dev/null +++ b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts @@ -0,0 +1,282 @@ +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 targets: readonly FrameworkUpgradePreviewTarget[]; + readonly globalSkipped: number; + readonly shared: boolean; +} + +interface UpgradeDeps { + listMarketplaces(projectRoot: string): Promise; + saveMarketplace(projectRoot: string, marketplace: Marketplace): Promise; + loadManifest(): Promise; + isPublishedRelease(repo: string, release: string): Promise; + 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; + 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[] + ): 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; + 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++; + 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." + ); + if (!(await this.deps.isPublishedRelease(marketplace.source.repo, release))) + 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 }); + } + await this.deps.preflight(manifest, projectRoot, targets, next, localPath); + const shared = marketplace.scope === "user"; + if (!(await confirm({ currentRef, release, targets: previewTargets, globalSkipped, shared }))) { + 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." + ); + } + nextData.version = catalog.version; + await this.deps.saveMarketplace(projectRoot, Marketplace.fromJSON(nextData)); + const updated: string[] = []; + try { + for (const target of previewTargets) { + 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 + ); + 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}. Completed: ${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/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 4ffca2844..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 { @@ -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 42d0dfdf7..2b42521ff 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -44,6 +44,8 @@ export interface NativeActivation { upgradeVerb?: string; /** Exact host command for updating one installed plugin ref. Copilot declares `update`; Codex does not. */ updateVerb?: 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; @@ -92,7 +94,7 @@ export interface GlobalFiles { readonly map: (builtPath: string, plugin: GlobalPlugin) => string | null; } -/** The plugin a global file is read for: its name, and the agents its source ships, by base +/** 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 { @@ -101,7 +103,7 @@ export interface GlobalPlugin { } /** - * The global-file mapping of a flat tool whose user configuration directory takes the same + * 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. @@ -120,6 +122,7 @@ export function flatGlobalFilePath(directory: string): GlobalFiles["map"] { /** 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 { @@ -173,6 +176,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; @@ -237,6 +242,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; @@ -246,12 +252,18 @@ export class PluginsCapability { 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); @@ -273,6 +285,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; @@ -288,6 +301,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; @@ -314,6 +328,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; diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index 294b9255b..e3651cfbf 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -192,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/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 202d3b40b..81eb71373 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -3,7 +3,7 @@ import { AgentsCapability } from "../../capabilities/agents-capability.js"; import { CommandsCapability } from "../../capabilities/commands-capability.js"; import { CONFIG_MCP } from "../../capabilities/config-refs.js"; import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { flatGlobalFilePath, PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; import { RulesCapability } from "../../capabilities/rules-capability.js"; import { SkillsCapability } from "../../capabilities/skills-capability.js"; import type { @@ -30,6 +30,7 @@ import { KILO_HOOKS_DIR, KILO_PLUGIN_DIR, KILO_PLUGIN_ENTRY_BASENAME, + kiloGlobalFilePath, makeKiloHooksBridgePath, resolveKiloConfigPath, } from "./kilo-paths.js"; @@ -92,7 +93,12 @@ export const kilo: AiTool join(homedir, ".config", "kilo"), - map: flatGlobalFilePath(KILO_DIRECTORY), + map: kiloGlobalFilePath, + }, + startupTui: { + plugin: "aidd-maintenance", + directory: KILO_DIRECTORY, + module: "hooks/aidd-maintenance/kilo-tui.js", }, acceptsHooks: true, flatHooksDir: KILO_HOOKS_DIR, 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 d35cb9285..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,3 +1,5 @@ +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. */ @@ -6,9 +8,19 @@ import { flatGlobalFilePath } from "../../capabilities/plugins-capability.js"; export const OPENCODE_DIRECTORY = ".opencode/"; -/** A machine-scope install puts a plugin's skills and agents where OpenCode loads them for every - * project: its global config directory. */ -export const opencodeGlobalFilePath = flatGlobalFilePath(OPENCODE_DIRECTORY); +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 @@ -29,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 372e368e1..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,7 @@ import { OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, + opencodeFlatHooksPath, opencodeGlobalFilePath, } from "./opencode-paths.js"; @@ -113,6 +114,7 @@ export const opencode: AiTool< }, 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/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/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/framework.ts b/cli/src/presentation/commands/framework.ts index 506a4e849..723f65c2f 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,107 @@ async function runFrameworkUpdate( ); } +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})` + ); + } + if (preview.globalSkipped) + output.info(`${preview.globalSkipped} user-scope plugin entries will not be updated.`); + 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(", ")}`); + } + 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 +309,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/plugin.ts b/cli/src/presentation/commands/plugin.ts index 15e69dd72..7c62003bc 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, @@ -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..82efb9e38 --- /dev/null +++ b/cli/src/presentation/commands/run/command.ts @@ -0,0 +1,187 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } 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, + }); + if (result.error || result.status !== 0) + throw new Error(`The aidd-maintenance hook failed for ${tool}.`); + 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 bcd93efc1..bf9ed8be0 100644 --- a/cli/src/presentation/commands/setup.ts +++ b/cli/src/presentation/commands/setup.ts @@ -13,6 +13,7 @@ import { } 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, @@ -184,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 6dc713e22..d1b11f581 100644 --- a/cli/src/presentation/commands/sync.ts +++ b/cli/src/presentation/commands/sync.ts @@ -5,6 +5,7 @@ import { UserScopeFilterUnsupportedError, } from "../../kernel/errors.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, @@ -48,6 +49,7 @@ async function runUserScopeSync( 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 ? [] diff --git a/cli/src/runtime/filesystem/file-adapter.ts b/cli/src/runtime/filesystem/file-adapter.ts index 0e5ebbdd2..da907ec6b 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"; @@ -112,6 +122,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/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..1dbe290ff 100644 --- a/cli/src/runtime/self-update/github-release-resolver-adapter.ts +++ b/cli/src/runtime/self-update/github-release-resolver-adapter.ts @@ -53,6 +53,20 @@ export class GitHubReleaseResolverAdapter implements LatestReleaseResolver { } } + /** Checks the exact published tag, even when component releases fill the first page. */ + async isPublishedRelease(repo: string, tag: string): Promise { + 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 false; + const release = body as { tag_name?: unknown; draft?: unknown }; + return release.tag_name === tag && release.draft === false; + } catch (err) { + return this.handleError(err, url) ?? false; + } + } + 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. diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 0ffe39907..151b0da71 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -1,6 +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 { join } from "node:path"; +import { 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"; @@ -12,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"; @@ -24,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"; @@ -39,7 +42,14 @@ 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 { 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"; @@ -63,24 +73,34 @@ 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 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 { frameworkBuildModeFor } from "../../contexts/tools/domain/registry.js"; import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; import { HostRegistryMarketplaceSourceReaderAdapter, @@ -146,6 +166,8 @@ interface Deps { pluginRemoveUseCase: PluginRemoveUseCase; pluginListUseCase: PluginListUseCase; pluginUpdateUseCase: PluginUpdateUseCase; + upgradeFrameworkUseCase: UpgradeFrameworkUseCase; + upgradeUserFrameworkUseCase: UpgradeUserFrameworkUseCase; userPluginUpdateUseCase: UserPluginUpdateUseCase; marketplaceAddUseCase: MarketplaceAddUseCase; marketplaceListUseCase: MarketplaceListUseCase; @@ -215,7 +237,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(); @@ -264,6 +293,7 @@ export async function createDeps( // and `userConfigDir`, neither of which distribution's own wiring produces. const userSourceReferences = new UserSourceReferencesAdapter(fs, userConfigDir); const { + pluginCatalogRepository, pluginFetcher, marketplaceRegistry, marketplaceTrustStore, @@ -459,6 +489,204 @@ 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(), + isPublishedRelease: (repo, release) => releaseResolver.isPublishedRelease(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) => { + 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); + for (const file of await reader.readFlatFiles(builtDir, dist, target.toolId)) { + const path = resolve(root, file.relativePath); + const owner = `${target.toolId}:${target.pluginName}`; + const shared = manifest + .getInstalledToolIds() + .some( + (id) => + manifest + .getToolFiles(id) + .some((entry) => entry.relativePath === file.relativePath) || + manifest + .getMergeFiles(id) + .some((entry) => entry.relativePath === file.relativePath) || + manifest + .getPlugins(id) + .some( + (entry) => + (id !== target.toolId || entry.name !== target.pluginName) && + entry.scope === "project" && + entry.files.has(file.relativePath) + ) + ); + if (shared) + throw new Error( + `Cannot upgrade ${owner}: target file '${file.relativePath}' is claimed by another installation.` + ); + if (planned.has(path) && planned.get(path) !== owner) + throw new Error( + `Cannot upgrade ${owner}: target file '${file.relativePath}' is claimed by another plugin.` + ); + planned.set(path, owner); + await assertUpgradeWrite(fs, root, path, tracked); + } + } + } finally { + await fs.deleteDirectory(temporary); + } + }, + 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 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) => + marketplaceSyncSettingsUseCase.execute({ + projectRoot: root, + toolIds, + marketplaceNames: ["aidd-framework"], + refreshOwnedPlugins: targets, + }), + }); const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( prompter, releaseResolver @@ -490,10 +718,20 @@ export async function createDeps( ); // 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( @@ -531,6 +769,14 @@ export async function createDeps( logger, nativeHostRegistrationGate ); + const upgradeUserFrameworkUseCase = new UpgradeUserFrameworkUseCase({ + repo: userManifestRepo, + builder: pinnedUserFramework, + updater: userPluginFileUpdater, + journal: userReleaseJournal, + logger, + isPublishedRelease: (repo, release) => releaseResolver.isPublishedRelease(repo, release), + }); const restoreUseCase = new RestoreUseCase( fs, manifestRepo, @@ -616,7 +862,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/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts index 2715b5a66..e1bf4f58e 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,6 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -179,4 +179,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/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 44fd30054..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,6 +32,58 @@ 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)}`, 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..e77c6baee 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,18 @@ 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("does not add a global plugin to project settings during a project 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, + "aidd-context@aidd-framework": true, + }); + }); 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..c71461fae 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,7 +25,7 @@ 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; @@ -59,7 +60,7 @@ 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" }], @@ -91,7 +92,7 @@ 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() ), @@ -101,6 +102,166 @@ describe("native catalogue source provenance", () => { return { sync, fs, activator, projectRepo, machineRepo }; } + 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 +698,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/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/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..b85752013 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/upgrade-user-framework-use-case.integration.test.ts @@ -0,0 +1,480 @@ +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 isPublishedRelease = vi.fn(async (_repo: string, _release: string) => true); + const useCase = new UpgradeUserFrameworkUseCase({ + repo, + builder, + updater, + journal, + logger, + isPublishedRelease, + }); + const writes = vi.spyOn(fs, "writeFile"); + const deletes = vi.spyOn(fs, "deleteFile"); + return { + useCase, + repo, + manifest, + registry, + marketplace, + native, + fs, + store, + build, + sessionEvents, + updater, + isPublishedRelease, + 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", + 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.isPublishedRelease).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.each([ + { currentRef: "v5.12.0", target: "v5.11.0", reason: /downgrade/ }, + { currentRef: "v5.12.0", target: "v5.13.0-beta.1", reason: /channel/ }, + { currentRef: "v5.12.0-alpha.1", target: "v5.13.0-beta.1", reason: /channel/ }, + ])( + "refuses downgrade or a change of release channel ($currentRef → $target)", + async ({ currentRef, target, reason }) => { + const f = await fixture({ currentRef }); + await expect( + f.useCase.execute({ projectRoot: PROJECT, release: target, confirm: accept }) + ).rejects.toThrow(reason); + expect(f.store.create).not.toHaveBeenCalled(); + expect(f.writes).not.toHaveBeenCalled(); + expect(f.repo.saveCount).toBe(0); + } + ); + + 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.isPublishedRelease.mockResolvedValue(false); + 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.isPublishedRelease).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.isPublishedRelease).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 b57b50c32..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,16 +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(); @@ -41,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) => { @@ -77,25 +185,6 @@ describe("UserPluginFileUpdater public update contract", () => { expect(await f.fs.fileExists(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(false); }); - 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("rechecks planned new-file containment when a parent becomes a symlink", async () => { const f = fixture(); const plan = await f.updater.planUpdate(f.plugin, "cursor", "/A", f.logger); @@ -108,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..7b5e56e05 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-rebuild.integration.test.ts @@ -0,0 +1,408 @@ +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(/symlink/); + expect(await readlink(newPath)).toBe(sibling); + } + ); + + 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, f.content); + 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(/symlink/); + expect(await readlink(newPath)).toBe(sibling); + } + ); + + 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..9c826f2c9 --- /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, + isPublishedRelease: async () => true, + 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..8942dc7e0 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"); 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 e39c5743e..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,6 +42,58 @@ 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 global files", async () => { const project = manifest(); 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 index 0b90964d2..e13eed49c 100644 --- 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 @@ -2,8 +2,10 @@ 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"; @@ -37,7 +39,7 @@ const CATALOG: PluginCatalogEntry[] = [ const HASH = "0".repeat(32); -function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { +function setup(marketplaces: Marketplace[] = [FRAMEWORK], session?: PinnedUserFramework) { const manifest = Manifest.create(); manifest.addTool("opencode", "1.0.0", []); manifest.addTool("claude", "1.0.0", []); @@ -56,6 +58,18 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { } ); 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 }, { @@ -65,10 +79,10 @@ function setup(marketplaces: Marketplace[] = [FRAMEWORK]) { localPath: "/fw", }), }, - { planUpdate, applyUpdate }, + { planUpdate, applyUpdate, withPinnedSession }, new CapturingLogger() ); - return { manifest, useCase, planUpdate, applyUpdate }; + return { manifest, useCase, planUpdate, applyUpdate, events }; } function run( @@ -90,7 +104,84 @@ function run( } describe("InstallGlobalPluginsUseCase", () => { - it("records each chosen plugin's global files for a tool that copies them, and names every one", async () => { + 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"); @@ -133,31 +224,6 @@ describe("InstallGlobalPluginsUseCase", () => { ); }); - 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("writes nothing when a later plugin's plan is refused", async () => { const { manifest, useCase, planUpdate, applyUpdate } = setup(); const accepted = planUpdate.getMockImplementation(); 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..a18d5c272 --- /dev/null +++ b/cli/tests/contexts/framework/application/upgrade-framework-use-case.unit.test.ts @@ -0,0 +1,415 @@ +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"; + +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 saveMarketplace = vi.fn(async (_root: string, _marketplace: Marketplace) => {}); + const isPublishedRelease = vi.fn(async (_repo: string, release: string) => + ["v5.13.0", "v5.12.0"].includes(release) + ); + 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 () => {}); + const sync = vi.fn(async () => ({ errors: [], binaryMissing: [], warnings: [] })); + const uc = new UpgradeFrameworkUseCase({ + listMarketplaces: list, + saveMarketplace, + loadManifest: async () => manifest, + isPublishedRelease, + resolveTarget: resolve, + readTargetVersion, + preflight, + install, + sync, + }); + return { + uc, + original, + manifest, + list, + saveMarketplace, + isPublishedRelease, + resolve, + readTargetVersion, + install, + preflight, + sync, + }; +} + +const accept = async () => true; + +describe("upgrade framework", () => { + 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", + 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, + shared: true, + }); + 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/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 083d52754..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; @@ -77,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/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/kilo/global-files.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/global-files.unit.test.ts index 928c43d7b..379e32dd0 100644 --- 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 @@ -1,30 +1,25 @@ -import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import "../../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { globalFilesOf } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { kiloGlobalFilePath } from "../../../../../../src/contexts/tools/domain/profiles/kilo/kilo-paths.js"; -const AIDD_DEV = { name: "aidd-dev", agents: new Set(["reviewer"]) }; - -describe("the kilo profile's machine-scope files", () => { - const machine = globalFilesOf("kilo"); - - it("land in ~/.config/kilo under the home directory", () => { - expect(machine?.dir("/home/me")).toBe(join("/home/me", ".config/kilo")); - }); +const maintenance = { name: "aidd-maintenance", agents: new Set() }; +const other = { name: "aidd-dev", agents: new Set(["reviewer"]) }; +describe("Kilo machine files", () => { it.each([ - [".kilo/skills/aidd-dev/01-plan/SKILL.md", "skills/aidd-dev/01-plan/SKILL.md"], - [".kilo/agents/aidd-dev-reviewer.md", "agents/aidd-dev-reviewer.md"], - ])("keep the plugin's own %s as %s", (built, path) => { - expect(machine?.map(built, AIDD_DEV)).toBe(path); + [".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.each([ - ".kilo/skills/aidd-pm/01-epic/SKILL.md", - ".kilo/agents/aidd-dev-executor.md", - ".kilo/commands/aidd-dev-plan.md", - ".opencode/skills/aidd-dev/01-plan/SKILL.md", - ])("leave %s out", (built) => { - expect(machine?.map(built, AIDD_DEV)).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 index 56a63575f..7823d8d4b 100644 --- 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 @@ -5,8 +5,27 @@ import { opencodeGlobalFilePath } from "../../../../../../src/contexts/tools/dom 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"], [ 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/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/headless-run.e2e.test.ts b/cli/tests/e2e/headless-run.e2e.test.ts new file mode 100644 index 000000000..a2fd2b4bc --- /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).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).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 1d9044348..ec0587f2a 100644 --- a/cli/tests/e2e/setup-scope-user.e2e.test.ts +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -56,6 +56,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { 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")); @@ -64,6 +65,7 @@ describe("E2E: setup --scope user writes nothing under the project", () => { 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); 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 953e3cf99..d398cf7f2 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", @@ -37,7 +37,7 @@ { "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", @@ -124,6 +124,11 @@ "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, 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/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts index 0bae604fb..8fe92f28f 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,51 @@ 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, + 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, + 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 +287,187 @@ describe("aidd framework remove", () => { }); describe("aidd framework update", () => { + 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("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("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("names skipped user installs and does not warn about an unshared marketplace", async () => { + upgradeFramework.mockImplementation(async (options) => { + const confirmed = await options.confirm({ + currentRef: "v5.12.0", + release: options.release, + targets: [ + { + toolId: "opencode", + pluginName: "aidd-context", + currentRef: "v5.12.0", + currentVersion: "2.9.0", + }, + ], + globalSkipped: 2, + 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")).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 +652,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 +671,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/plugin-wiring.integration.test.ts b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts index 23d7deb45..20023ebbd 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({ 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..0bf2c8ce6 --- /dev/null +++ b/cli/tests/presentation/commands/run-wiring.integration.test.ts @@ -0,0 +1,453 @@ +import { ChildProcess } 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 })); + +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.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.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-wiring.integration.test.ts b/cli/tests/presentation/commands/setup-wiring.integration.test.ts index 84d29583b..adcee6c7d 100644 --- a/cli/tests/presentation/commands/setup-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/setup-wiring.integration.test.ts @@ -85,6 +85,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); process.exitCode = undefined; }); @@ -166,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", () => { 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..f3deb2bbf 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,45 @@ describe("GitHubReleaseResolverAdapter", () => { }); }); + describe("isPublishedRelease", () => { + 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.isPublishedRelease(REPO, "v5.13.0")).toBe(true); + 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.isPublishedRelease(REPO, "v5.13.0")).toBe(false); + }); + + it("reports a missing release without claiming it is published", async () => { + const adapter = new GitHubReleaseResolverAdapter( + makeHttpThrowing(new HttpNotFoundError("missing")) + ); + expect(await adapter.isPublishedRelease(REPO, "v5.13.0")).toBe(false); + }); + + 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.isPublishedRelease(REPO, "v5.13.0")).rejects.toThrow( + CatalogFetchAuthError + ); + }); + }); + 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..75310f568 --- /dev/null +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -0,0 +1,806 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, 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 { Manifest } from "../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { FileHash, InstallationFile } from "../../../src/kernel/file.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")); + 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, "isPublishedRelease").mockResolvedValue(true); + 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: "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; + } + + 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(/modified or missing/); + 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 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.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("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 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/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/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..3226945a3 --- /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; `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. GUI and Kilo resumed sessions remain unverified. + +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..fec90d514 --- /dev/null +++ b/plugins/aidd-maintenance/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.cjs\"", + "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..438b6610d --- /dev/null +++ b/plugins/aidd-maintenance/hooks/session-start.cjs @@ -0,0 +1,443 @@ +#!/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 data = json(file); + if (data?.version !== 8 || !data.tools || typeof data.tools !== 'object' || Array.isArray(data.tools)) { + if (scope === 'user') globalUnverified = true; else unverified = true; + continue; + } + for (const [tool, entry] of Object.entries(data.tools)) { + if (!/^[a-z0-9-]+$/.test(tool) || !entry || typeof entry !== 'object') { unverified = true; continue; } + if (entry.plugins === undefined) continue; + if (!Array.isArray(entry.plugins)) { unverified = true; continue; } + for (const plugin of entry.plugins) { + if (!plugin) 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}`) unverified = true; + 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 || '')) { + unverified = true; 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) { + if (scope === 'user') globalUnverified = true; else unverified = true; + 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; + if (!versions || typeof versions !== 'object') result.pluginsUnverified = true; + const rawTargetVersion = versions?.[plugin.name]; + const targetVersion = semver(rawTargetVersion); + if (!targetVersion) 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()) ? ` (vé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 += result.local + ? `\nDernière release officielle : ${result.latest.tag}${detail}${checked}` + : `\nDernière release : ${title || result.latest.tag}${result.latest.titlePending && title === result.latest.tag ? ' · titre indisponible' : ''}${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 += `\nMise à jour disponible : ${result.latest.tag} · 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 += '\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..31f3ad72a --- /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: stable stays stable, prereleases stay on their channel. 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. 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 [native commands](references/native-commands.md). If the host cannot select the requested pinned release, report the limitation. +- 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..af76224e0 --- /dev/null +++ b/plugins/aidd-maintenance/skills/01-update/references/native-commands.md @@ -0,0 +1,8 @@ +# Native marketplace commands + +Read on 2026-11-04. 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. 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__/maintenance.test.js b/scripts/__tests__/maintenance.test.js new file mode 100644 index 000000000..53cf724d4 --- /dev/null +++ b/scripts/__tests__/maintenance.test.js @@ -0,0 +1,649 @@ +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 non vérifié/i); + assert.doesNotMatch(text,/à jour/i); +}); + +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('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/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"], From 0a52c0757a41856005ba22c07781baf1c3933ec9 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 12:42:32 +0800 Subject: [PATCH 18/38] test(cli): cover release manifest changes during updates --- .../framework-upgrade.integration.test.ts | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index 75310f568..b5583c1bc 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -144,7 +144,7 @@ describe("framework release update wiring", () => { allowShared: false, confirm, }) - ).rejects.toThrow(/modified or missing/); + ).rejects.toThrow(rel); expect(await readFile(file, "utf8")).toBe("person's edit"); expect(await readFile(registry, "utf8")).toBe(before); expect(confirm).not.toHaveBeenCalled(); @@ -174,6 +174,46 @@ describe("framework release update wiring", () => { 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"); @@ -368,6 +408,40 @@ describe("framework release update wiring", () => { }); }); + 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", From 2343c7ad9596edca6fce141564946513265a1f72 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 13:25:01 +0800 Subject: [PATCH 19/38] fix(maintenance): compare releases and update pinned native sources --- cli/src/presentation/commands/framework.ts | 28 ++++++++ cli/tests/e2e/headless-run.e2e.test.ts | 2 +- .../framework-wiring.integration.test.ts | 72 +++++++++++++++++++ plugins/aidd-maintenance/README.md | 4 +- .../skills/01-update/SKILL.md | 4 +- .../01-update/references/native-commands.md | 13 +++- 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/cli/src/presentation/commands/framework.ts b/cli/src/presentation/commands/framework.ts index 723f65c2f..c20408a89 100644 --- a/cli/src/presentation/commands/framework.ts +++ b/cli/src/presentation/commands/framework.ts @@ -165,6 +165,25 @@ async function runFrameworkUpdate( ); } +function printReleaseLinks( + output: CLIOutput, + release: string, + currentRefs: readonly string[] +): void { + const repository = "https://github.com/ai-driven-dev/framework"; + 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, @@ -181,6 +200,10 @@ function printReleasePreview( ` ${target.toolId}:${target.pluginName} (plugin ${version}; source ${target.currentRef} -> ${preview.release})` ); } + printReleaseLinks(output, preview.release, [ + preview.currentRef, + ...preview.targets.map((target) => target.currentRef), + ]); if (preview.globalSkipped) output.info(`${preview.globalSkipped} user-scope plugin entries will not be updated.`); if (preview.shared) output.warn("This marketplace is shared; other projects may be affected."); @@ -242,6 +265,11 @@ async function runUserFrameworkReleaseUpdate( if (target.dependents.length) output.info(` Dependent projects: ${target.dependents.join(", ")}`); } + printReleaseLinks( + output, + preview.release, + preview.targets.map((target) => target.currentRef) + ); if (preview.nativeSkipped) output.warn( `${preview.nativeSkipped} native plugin claims are not updated by this command.` diff --git a/cli/tests/e2e/headless-run.e2e.test.ts b/cli/tests/e2e/headless-run.e2e.test.ts index a2fd2b4bc..e52908754 100644 --- a/cli/tests/e2e/headless-run.e2e.test.ts +++ b/cli/tests/e2e/headless-run.e2e.test.ts @@ -85,7 +85,7 @@ describe("aidd run", () => { env.fakeHome, { env: { PATH: `${bin}${delimiter}${dirname(process.execPath)}` } } ); - expect(result.exitCode).toBe(0); + 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([ diff --git a/cli/tests/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts index 8fe92f28f..3ead834bd 100644 --- a/cli/tests/presentation/commands/framework-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/framework-wiring.integration.test.ts @@ -329,6 +329,43 @@ describe("aidd framework update", () => { 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, + 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("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"); @@ -418,6 +455,41 @@ describe("aidd framework update", () => { 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, + 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("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("requires explicit approval outside a terminal", async () => { const exit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("exited"); diff --git a/plugins/aidd-maintenance/README.md b/plugins/aidd-maintenance/README.md index 3226945a3..07e23500f 100644 --- a/plugins/aidd-maintenance/README.md +++ b/plugins/aidd-maintenance/README.md @@ -15,12 +15,12 @@ Use the update skill to inspect project and global installations, choose a relea | 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; `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. GUI and Kilo resumed sessions remain unverified. +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. GUI display remains unverified. 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/skills/01-update/SKILL.md b/plugins/aidd-maintenance/skills/01-update/SKILL.md index 31f3ad72a..f2bc52e1f 100644 --- a/plugins/aidd-maintenance/skills/01-update/SKILL.md +++ b/plugins/aidd-maintenance/skills/01-update/SKILL.md @@ -10,9 +10,9 @@ argument-hint: release | project | global - 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: stable stays stable, prereleases stay on their channel. 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. 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. +- 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 [native commands](references/native-commands.md). If the host cannot select the requested pinned release, report the limitation. +- 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. 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 index af76224e0..38649d533 100644 --- a/plugins/aidd-maintenance/skills/01-update/references/native-commands.md +++ b/plugins/aidd-maintenance/skills/01-update/references/native-commands.md @@ -1,8 +1,19 @@ # Native marketplace commands -Read on 2026-11-04. Commands and support can change; recheck installed help before use. +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. From 57f630cbb913e097a72c0e6b75738cd377638c11 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 13:46:25 +0800 Subject: [PATCH 20/38] fix(maintenance): handle pinned codex marketplaces --- plugins/aidd-maintenance/skills/01-update/SKILL.md | 2 +- .../skills/01-update/references/native-commands.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/aidd-maintenance/skills/01-update/SKILL.md b/plugins/aidd-maintenance/skills/01-update/SKILL.md index f2bc52e1f..5591115b8 100644 --- a/plugins/aidd-maintenance/skills/01-update/SKILL.md +++ b/plugins/aidd-maintenance/skills/01-update/SKILL.md @@ -14,5 +14,5 @@ argument-hint: release | project | global - 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. For other sources or scopes, report the limitation rather than guessing how to move the release. +- 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 index 38649d533..4778b894e 100644 --- a/plugins/aidd-maintenance/skills/01-update/references/native-commands.md +++ b/plugins/aidd-maintenance/skills/01-update/references/native-commands.md @@ -17,3 +17,11 @@ After the user selects a verified release and approves the affected marketplace - 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; 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. From 2d56c4d309f4bee52ddfae33ad825a305f9cd1fa Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 13:46:55 +0800 Subject: [PATCH 21/38] docs(maintenance): record codex refresh timeout --- .../skills/01-update/references/native-commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/aidd-maintenance/skills/01-update/references/native-commands.md b/plugins/aidd-maintenance/skills/01-update/references/native-commands.md index 4778b894e..02551451f 100644 --- a/plugins/aidd-maintenance/skills/01-update/references/native-commands.md +++ b/plugins/aidd-maintenance/skills/01-update/references/native-commands.md @@ -24,4 +24,4 @@ Codex CLI 0.166.0 was exercised with a user marketplace added by `codex plugin m 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; 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. +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. From 61488a1f1717b51120316e7ed417af08b7a224af Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 14:12:42 +0800 Subject: [PATCH 22/38] fix(maintenance): name the startup hook --- plugins/aidd-maintenance/hooks/hooks.json | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/aidd-maintenance/hooks/hooks.json b/plugins/aidd-maintenance/hooks/hooks.json index fec90d514..c097a0986 100644 --- a/plugins/aidd-maintenance/hooks/hooks.json +++ b/plugins/aidd-maintenance/hooks/hooks.json @@ -7,6 +7,7 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.cjs\"", + "statusMessage": "AIDD Maintenance: check for updates", "timeout": 10 } ] From 563eea71add0247520026890e2eb08398ea11a8e Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 18:10:29 +0800 Subject: [PATCH 23/38] fix(maintenance): clarify the startup update notice --- plugins/aidd-maintenance/hooks/session-start.cjs | 14 ++++++-------- scripts/__tests__/maintenance.test.js | 8 ++++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/plugins/aidd-maintenance/hooks/session-start.cjs b/plugins/aidd-maintenance/hooks/session-start.cjs index 438b6610d..134bc104b 100644 --- a/plugins/aidd-maintenance/hooks/session-start.cjs +++ b/plugins/aidd-maintenance/hooks/session-start.cjs @@ -377,19 +377,17 @@ 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()) ? ` (vérifiée le ${checkedAt.toISOString().slice(0,16).replace('T',' ')} UTC)` : ''; + 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 += result.local - ? `\nDernière release officielle : ${result.latest.tag}${detail}${checked}` - : `\nDernière release : ${title || result.latest.tag}${result.latest.titlePending && title === result.latest.tag ? ' · titre indisponible' : ''}${checked}`; + 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 += `\nMise à jour disponible : ${result.latest.tag} · skill aidd-maintenance · 01-update`; + 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}))]; @@ -397,17 +395,17 @@ function format(result) { 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 += '\nPlugins enregistrés par le CLI AIDD :'; + 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`; + 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}`; + 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`; diff --git a/scripts/__tests__/maintenance.test.js b/scripts/__tests__/maintenance.test.js index 53cf724d4..71dd81b8f 100644 --- a/scripts/__tests__/maintenance.test.js +++ b/scripts/__tests__/maintenance.test.js @@ -387,6 +387,14 @@ test('reports a verified catalogue diff and editorial title', async t => { 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'); From 6b6516107618aebd98325510f5cd2763f65e45e2 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 21:01:49 +0800 Subject: [PATCH 24/38] docs(maintenance): clarify graphical host support --- plugins/aidd-maintenance/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/aidd-maintenance/README.md b/plugins/aidd-maintenance/README.md index 07e23500f..168fbd36d 100644 --- a/plugins/aidd-maintenance/README.md +++ b/plugins/aidd-maintenance/README.md @@ -21,6 +21,6 @@ After inventory and confirmation, the skill can update CLI-managed project insta 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. GUI display remains unverified. +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). From 1a50e6657a951780f97295a3fd475cb733319f56 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 21:53:59 +0800 Subject: [PATCH 25/38] fix(cli): refresh Claude plugins on framework update --- .../marketplace-sync-settings-use-case.ts | 76 +++++++++-- .../application/upgrade-framework-use-case.ts | 2 +- .../domain/capabilities/plugins-capability.ts | 2 + .../domain/ports/native-plugin-activator.ts | 1 + .../tools/domain/profiles/claude/profile.ts | 1 + .../native-plugin-cli-adapter.ts | 11 ++ .../marketplace-sync-settings.unit.test.ts | 3 +- ...sync-source-provenance.integration.test.ts | 124 +++++++++++++++++- .../upgrade-framework-use-case.unit.test.ts | 20 ++- .../profiles/claude/plugins.unit.test.ts | 1 + ...gin-cli-adapter.claude.integration.test.ts | 13 ++ .../ports/fake-native-plugin-activator.ts | 6 + .../framework-wiring.integration.test.ts | 17 +++ 13 files changed, 258 insertions(+), 19 deletions(-) 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 cedffe40b..f7b942ced 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 @@ -249,6 +249,12 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { if (scope === "project") { for (const [toolId, outcome] of activation.outcomes) { const settings = resolvePluginsCapability(toolId)?.marketplaceSettings; + // 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 && @@ -499,7 +505,9 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { const preservedRefs = (existing?.pluginRefs ?? []).filter( (ref) => [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`)) || - (selected.length > 0 && !this.isSelectedRef(manifest, toolId, ref, selected)) + (selected.length > 0 && + (nativeActivationOf(toolId)?.releaseUpdateVerb !== undefined || + !this.isSelectedRef(manifest, toolId, ref, selected))) ); const registrations: NativeRegistrations = { binary, @@ -654,18 +662,30 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { 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 } = await this.refsThisProjectEnables( toolId, - [ - ...new Set([ - ...this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias, scope), - ...requested, - ]), - ], + candidateRefs, manifest, projectRoot ); - const enabledRefs = [...alreadyOwnedRefs]; + // A machine claim can have been copied into the project without a local install. + const machine = refreshOwnedPlugins.length ? await this.userManifestRepo?.load() : undefined; + const machineClaims = machine?.getNativeRegistrations(toolId)?.pluginClaims ?? []; + const verifiedOwnedRefs = + refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb + ? alreadyOwnedRefs.filter( + (ref) => + manifest.getNativeRegistrations(toolId)?.pluginRefs.includes(ref) && + !machineClaims.some((claim) => claim.ref === ref) + ) + : alreadyOwnedRefs; + const enabledRefs = [...verifiedOwnedRefs]; const sourceStillMatches = async (ref: string): Promise => { const registration = stillProven.find((entry) => ref.endsWith(`@${entry.hostName}`)); if (!registration) return false; @@ -706,7 +726,36 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); } } + if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb) { + for (const ref of candidateRefs) { + if ( + !this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) || + verifiedOwnedRefs.includes(ref) + ) + continue; + const message = `${toolId}: existing plugin '${ref}' is not proven as AIDD-owned; release refresh skipped.`; + this.logger.warn(message); + warnings.push(message); + } + for (const ref of verifiedOwnedRefs) { + if ( + !this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) || + !(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) || @@ -962,7 +1011,7 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { 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); @@ -1296,7 +1345,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({ @@ -1307,9 +1357,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; } } diff --git a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts index ac9f5d2a0..6ca592cf1 100644 --- a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts +++ b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts @@ -274,7 +274,7 @@ export class UpgradeFrameworkUseCase { } } catch (error) { throw new Error( - `Upgrade incomplete after the shared marketplace changed to ${release}. Completed: ${updated.join(", ") || "none"}. ${error instanceof Error ? error.message : String(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/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 2b42521ff..4596ff41b 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -44,6 +44,8 @@ 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 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 7fa94ba68..36bea408d 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -121,6 +121,7 @@ export const claude: AiTool { - it("does not add a global plugin to project settings during a project release update", async () => { + it("preserves project plugin settings during a Claude release update", async () => { const { written } = await sync({ userPlugin: true, releaseUpdate: true, @@ -243,7 +243,6 @@ describe("the settings file a user also edits", () => { }); expect(written?.enabledPlugins).toEqual({ "another@outside": false, - "aidd-context@aidd-framework": true, }); }); it("writes the enabled plugin into a file that did not exist", async () => { 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 c71461fae..712a3072c 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 @@ -29,6 +29,8 @@ describe("native catalogue source provenance", () => { catalogue?: HostMarketplaceRegistryReading; marketplaceScope?: "project" | "user"; plugin?: boolean; + failUpdate?: boolean; + failBuild?: boolean; } = {} ) { const toolId = options.toolId ?? "codex"; @@ -67,7 +69,10 @@ describe("native catalogue source provenance", () => { }), }); 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 sync = new MarketplaceSyncSettingsUseCase( fs, projectRepo, @@ -75,7 +80,13 @@ describe("native catalogue source provenance", () => { new DeterministicHasher(), new CapturingLogger(), 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)]]), @@ -102,6 +113,115 @@ describe("native catalogue source provenance", () => { return { sync, fs, activator, projectRepo, machineRepo }; } + 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" }], + }); + 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" }], + }); + 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 proven as AIDD-owned"); + 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" }], + }); + 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 only an owned Codex plugin after an explicit framework release update", async () => { const refs = new Map(); const f = await hostPluginProofFixture({ location: "host", refs }); 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 index a18d5c272..7849eb300 100644 --- 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 @@ -87,7 +87,13 @@ function fixture() { } ); const preflight = vi.fn(async () => {}); - const sync = vi.fn(async () => ({ errors: [], binaryMissing: [], warnings: [] })); + 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, @@ -117,6 +123,18 @@ function fixture() { const accept = async () => true; describe("upgrade framework", () => { + 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); 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 4be2d754a..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 @@ -52,6 +52,7 @@ describe("claude plugins", () => { 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/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts index ec5d3798c..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 @@ -33,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({})); 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/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts index 3ead834bd..70aa31235 100644 --- a/cli/tests/presentation/commands/framework-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/framework-wiring.integration.test.ts @@ -287,6 +287,23 @@ 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/], From 273b78daf57de8d6913c9565e192c3e0cbb7c992 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Thu, 5 Nov 2026 22:14:39 +0800 Subject: [PATCH 26/38] fix(maintenance): guard shared Claude updates --- .../upgrade-user-framework-use-case.ts | 5 - .../host-plugin-registry-reader-adapter.ts | 54 ++++++- cli/src/runtime/wiring/framework.ts | 37 ++++- ...ser-framework-use-case.integration.test.ts | 50 ++++-- ...egistry-reader-adapter.integration.test.ts | 25 ++- .../framework-upgrade.integration.test.ts | 145 +++++++++++++++++- .../aidd-maintenance/hooks/session-start.cjs | 21 +-- .../skills/01-update/SKILL.md | 2 +- scripts/__tests__/maintenance.test.js | 29 +++- 9 files changed, 334 insertions(+), 34 deletions(-) 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 index d0fbb8b19..bf1c09733 100644 --- 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 @@ -113,11 +113,6 @@ export class UpgradeUserFrameworkUseCase { ); if (compareSemver(release, source.ref) < 0) throw new Error(`${toolId}:${plugin.name}: refusing a release downgrade.`); - const currentChannel = - source.ref.split("+", 1)[0]?.match(/-([0-9A-Za-z-]+)/)?.[1] ?? "stable"; - const nextChannel = release.split("+", 1)[0]?.match(/-([0-9A-Za-z-]+)/)?.[1] ?? "stable"; - if (currentChannel !== nextChannel) - throw new Error(`${toolId}:${plugin.name}: refusing a change of release channel.`); const prepared = await builder.build(release, toolId, plugin.name, sha); sha ??= prepared.sha; if (compareSemver(prepared.dist.manifest.version, plugin.version) < 0) 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..3c081b94c 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,55 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { } } +/** A release refresh must have a local entry and no other installation sharing its catalogue. */ +export async function claudeLocalPluginRefreshable( + configDir: string, + projectRoot: string, + ref: string, + targets: readonly string[] +): Promise { + try { + const content = await readFile(join(configDir, "plugins", "installed_plugins.json"), "utf8"); + const plugins = parseClaudePlugins(content); + const entries = plugins?.[ref]; + if (!Array.isArray(entries)) return false; + const separator = ref.lastIndexOf("@"); + if (separator < 0) return false; + const catalogue = ref.slice(separator); + const here = await resolvedPath(projectRoot); + for (const [otherRef, otherEntries] of Object.entries(plugins ?? {})) { + if (!otherRef.endsWith(catalogue)) continue; + if (!targets.includes(otherRef) || !Array.isArray(otherEntries)) return false; + for (const entry of otherEntries) { + if ( + entry?.scope !== "local" || + typeof entry.projectPath !== "string" || + (await resolvedPath(entry.projectPath)) !== here + ) + return false; + } + } + for (const entry of entries) { + if ( + entry?.scope === "local" && + typeof entry.projectPath === "string" && + (await resolvedPath(entry.projectPath)) === here + ) + return true; + } + } catch { + return false; + } + return false; +} + +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/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 151b0da71..ec1ca9e5b 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -96,12 +96,16 @@ import { RecipeFilesAdapter } from "../../contexts/framework/infrastructure/reci 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 { frameworkBuildModeFor } from "../../contexts/tools/domain/registry.js"; -import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import { + claudeLocalPluginRefreshable, + hostPluginRegistryReaders, +} from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; import { HostRegistryMarketplaceSourceReaderAdapter, NativeMarketplaceSourceReaderAdapter, @@ -112,6 +116,7 @@ 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"; @@ -288,6 +293,8 @@ 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. @@ -505,6 +512,34 @@ export async function createDeps( return distribution.manifest.version; }, preflight: async (manifest, root, targets, marketplace, localPath) => { + const claudeTargets = targets.filter((target) => target.toolId === "claude"); + 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 machine = await userManifestRepo.load(); + const machineClaims = machine?.getNativeRegistrations("claude")?.pluginClaims ?? []; + const hostName = catalogues?.length === 1 ? catalogues[0]?.hostName : undefined; + const refs = claudeTargets.map((target) => `${target.pluginName}@${hostName}`); + for (const target of claudeTargets) { + const ref = hostName === undefined ? undefined : `${target.pluginName}@${hostName}`; + if ( + ref === undefined || + !registration?.pluginRefs.includes(ref) || + machineClaims.some((claim) => claim.ref === ref) || + !(await claudeLocalPluginRefreshable(claudeDir, root, ref, refs)) + ) { + throw new Error( + `Cannot upgrade claude:${target.pluginName}: local scope or ownership is ambiguous, or another installation shares its marketplace.` + ); + } + } + } for (const target of targets) { if (getToolSupportedScope(target.toolId) !== "project") throw new Error( 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 index b85752013..294f0484e 100644 --- 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 @@ -227,20 +227,46 @@ describe("machine-scoped framework release update", () => { 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.11.0", reason: /downgrade/ }, - { currentRef: "v5.12.0", target: "v5.13.0-beta.1", reason: /channel/ }, - { currentRef: "v5.12.0-alpha.1", target: "v5.13.0-beta.1", reason: /channel/ }, + { currentRef: "v5.12.0", target: "v5.13.0-beta.1" }, + { currentRef: "v5.12.0-alpha.1", target: "v5.13.0-beta.1" }, ])( - "refuses downgrade or a change of release channel ($currentRef → $target)", - async ({ currentRef, target, reason }) => { - const f = await fixture({ currentRef }); - await expect( - f.useCase.execute({ projectRoot: PROJECT, release: target, confirm: accept }) - ).rejects.toThrow(reason); - expect(f.store.create).not.toHaveBeenCalled(); - expect(f.writes).not.toHaveBeenCalled(); - expect(f.repo.saveCount).toBe(0); + "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, + }); } ); 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..e0ae6a11b 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 { + claudeLocalPluginRefreshable, + hostPluginRegistryReaders, +} from "../../../../src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; import type { AiToolId } from "../../../../src/kernel/tool.js"; /** @@ -36,6 +39,26 @@ 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 claudeLocalPluginRefreshable( + join(home, ".claude"), + PROJECT, + "aidd-dev@aidd-framework", + refs + ) + ).toBe(true); + }); + it("counts a ref installed for this project, and one installed for the machine", async () => { await write( PATH, diff --git a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index b5583c1bc..f97e64e1c 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -25,6 +25,8 @@ describe("framework release update wiring", () => { 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", ""); confirm.mockClear(); const pluginManifest = join(root, "plugins/aidd-context/.claude-plugin/plugin.json"); await mkdir(dirname(pluginManifest), { recursive: true }); @@ -66,7 +68,7 @@ describe("framework release update wiring", () => { }); async function seed( - toolId: "cursor" | "opencode", + toolId: "claude" | "cursor" | "opencode", options: { files?: Record; mcpEntries?: Record } = {} ) { const deps = await createDeps(root, { verbose: false }); @@ -90,6 +92,147 @@ describe("framework release update wiring", () => { return deps; } + it.each([ + "missing project claim", + "machine claim", + "user-only", + "local and independent user", + "another user plugin sharing the marketplace", + "project-only", + "other project's local scope", + "another project sharing the local plugin", + "another project sharing the marketplace", + "an unselected plugin in this project", + "the selected plugin at project scope too", + ] 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" }], + pluginRefs: reason === "missing project claim" ? [] : [ref], + }); + await deps.manifestRepo.save(manifest); + if (reason === "machine claim") { + const machine = Manifest.create(); + machine.addTool("claude", "5.6.0", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [], + pluginRefs: [], + pluginClaims: [{ ref, dependents: [root] }], + }); + await deps.userManifestRepo.save(machine); + } + const registryPath = join(root, "home/.claude/plugins/installed_plugins.json"); + await mkdir(dirname(registryPath), { recursive: true }); + const local = { scope: "local", projectPath: root }; + const entries = + reason === "user-only" + ? [{ scope: "user" }] + : reason === "local and independent user" + ? [local, { scope: "user" }] + : reason === "project-only" + ? [{ scope: "project", projectPath: root }] + : reason === "other project's local scope" + ? [{ scope: "local", projectPath: join(root, "other") }] + : [local]; + const plugins = { + [ref]: + reason === "another project sharing the local plugin" + ? [local, { scope: "local", projectPath: join(root, "other") }] + : reason === "the selected plugin at project scope too" + ? [local, { scope: "project", projectPath: root }] + : entries, + ...(reason === "another user plugin sharing the marketplace" + ? { "another-plugin@aidd-framework": [{ scope: "user" }] } + : {}), + ...(reason === "another project sharing the marketplace" + ? { + "another-plugin@aidd-framework": [ + { scope: "project", projectPath: join(root, "other") }, + ], + } + : {}), + ...(reason === "an unselected plugin in this project" + ? { "another-plugin@aidd-framework": [{ scope: "local", projectPath: root }] } + : {}), + }; + 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.*(?:claim|ownership)/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" }], + 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(result.cancelled).toBe(true); + }); + + 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"); diff --git a/plugins/aidd-maintenance/hooks/session-start.cjs b/plugins/aidd-maintenance/hooks/session-start.cjs index 134bc104b..81e3c5644 100644 --- a/plugins/aidd-maintenance/hooks/session-start.cjs +++ b/plugins/aidd-maintenance/hooks/session-start.cjs @@ -111,27 +111,28 @@ function plugins(cwd, env, home, current) { 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)) { - if (scope === 'user') globalUnverified = true; else unverified = true; + markUnverified(); continue; } for (const [tool, entry] of Object.entries(data.tools)) { - if (!/^[a-z0-9-]+$/.test(tool) || !entry || typeof entry !== 'object') { unverified = true; continue; } + 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)) { unverified = true; continue; } + if (!Array.isArray(entry.plugins)) { markUnverified(); continue; } for (const plugin of entry.plugins) { - if (!plugin) continue; + 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}`) unverified = true; + 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 || '')) { - unverified = true; continue; + markUnverified(); continue; } if (scope === 'user') { machinePlugins.add(`${tool}:${plugin.name}`); machineCount++; } if (scope === 'user' && !userComparable) { globalUnverified = true; continue; } @@ -144,7 +145,7 @@ function plugins(cwd, env, home, current) { 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) { - if (scope === 'user') globalUnverified = true; else unverified = true; + markUnverified(); continue; } const channel = ref.prerelease[0] || ''; @@ -340,10 +341,12 @@ async function status({ cwd = process.cwd(), env = process.env, home = os.homedi state.save = true; } const versions = state.latest.pluginVersions; - if (!versions || typeof versions !== 'object') result.pluginsUnverified = true; const rawTargetVersion = versions?.[plugin.name]; const targetVersion = semver(rawTargetVersion); - if (!targetVersion) result.pluginsUnverified = true; + 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; diff --git a/plugins/aidd-maintenance/skills/01-update/SKILL.md b/plugins/aidd-maintenance/skills/01-update/SKILL.md index 5591115b8..7083fe31a 100644 --- a/plugins/aidd-maintenance/skills/01-update/SKILL.md +++ b/plugins/aidd-maintenance/skills/01-update/SKILL.md @@ -9,7 +9,7 @@ argument-hint: release | project | global ## 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: stable stays stable, prereleases stay on their channel. 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. +- 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. diff --git a/scripts/__tests__/maintenance.test.js b/scripts/__tests__/maintenance.test.js index 71dd81b8f..09b6b1924 100644 --- a/scripts/__tests__/maintenance.test.js +++ b/scripts/__tests__/maintenance.test.js @@ -106,10 +106,37 @@ test('does not claim global plugins are up to date when target versions cannot b 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 non vérifié/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:[ From 2cd5bcecbd81ac6b0eebbf6d01e5f389ed34380f Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 08:49:29 +0800 Subject: [PATCH 27/38] fix(maintenance): review shared native update impact --- cli/package.json | 2 +- .../marketplace-sync-settings-use-case.ts | 110 +++--- .../application/upgrade-framework-use-case.ts | 45 ++- .../host-plugin-registry-reader-adapter.ts | 61 ++- cli/src/presentation/commands/framework.ts | 10 + cli/src/runtime/wiring/framework.ts | 85 +++- ...sync-source-provenance.integration.test.ts | 40 +- .../upgrade-framework-use-case.unit.test.ts | 29 +- ...egistry-reader-adapter.integration.test.ts | 14 +- .../framework-upgrade.integration.test.ts | 367 +++++++++++++----- 10 files changed, 561 insertions(+), 202 deletions(-) diff --git a/cli/package.json b/cli/package.json index d94243f41..3115f361d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 793, + "bundleBudgetKB": 796, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", 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 f7b942ced..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 @@ -104,6 +104,8 @@ export interface MarketplaceSyncSettingsOptions { 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 { @@ -243,7 +245,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolIds, scope, options.enablePlugins ?? [], - options.refreshOwnedPlugins ?? [] + options.refreshOwnedPlugins ?? [], + options.approvedRefs ?? [] ); let declarativeChanged = false; if (scope === "project") { @@ -283,22 +286,30 @@ 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, + 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, @@ -431,7 +442,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolIds: readonly ToolId[], scope: MarketplaceScope, enablePlugins: readonly string[], - refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: string }[] + refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: string }[], + approvedRefs: readonly string[] ): Promise { const outcomes = new Map(); const binaryMissing: { toolId: ToolId; binary: string }[] = []; @@ -460,7 +472,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { warnings, scope, enablePlugins, - refreshOwnedPlugins + refreshOwnedPlugins, + nativeActivationOf(toolId)?.releaseUpdateVerb ? approvedRefs : [] ); outcomes.set(toolId, outcome); } catch (error) { @@ -570,7 +583,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { warnings: string[], scope: MarketplaceScope, enablePlugins: readonly string[], - refreshOwnedPlugins: readonly { toolId: AiToolId; pluginName: 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 — @@ -593,7 +607,8 @@ 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); @@ -614,7 +629,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { if (registration.claimable) { if (!registration.skipNativeWrites) catalogClaims.push(entry); } else if (earlier !== undefined) { - const proof = await this.nativeCatalogProof(toolId, earlier.hostName, projectRoot); + const proof = await this.nativeCatalogProof( + toolId, + earlier.hostName, + projectRoot, + undefined, + approvedRefs + ); if ( proof.canonical && proof.hostReadable && @@ -641,7 +662,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId, registration.hostName, projectRoot, - fresh + fresh, + approvedRefs ); if ( !proof.canonical || @@ -668,24 +690,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ...requested, ]), ]; - const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables( - toolId, - candidateRefs, - manifest, - projectRoot - ); - // A machine claim can have been copied into the project without a local install. - const machine = refreshOwnedPlugins.length ? await this.userManifestRepo?.load() : undefined; - const machineClaims = machine?.getNativeRegistrations(toolId)?.pluginClaims ?? []; - const verifiedOwnedRefs = - refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb - ? alreadyOwnedRefs.filter( - (ref) => - manifest.getNativeRegistrations(toolId)?.pluginRefs.includes(ref) && - !machineClaims.some((claim) => claim.ref === ref) - ) - : alreadyOwnedRefs; - const enabledRefs = [...verifiedOwnedRefs]; + 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; @@ -695,7 +703,8 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId, registration.hostName, projectRoot, - registration + registration, + approvedRefs ); if ( !current.canonical || @@ -728,21 +737,14 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { } if (refreshOwnedPlugins.length && nativeActivationOf(toolId)?.releaseUpdateVerb) { for (const ref of candidateRefs) { - if ( - !this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) || - verifiedOwnedRefs.includes(ref) - ) - continue; - const message = `${toolId}: existing plugin '${ref}' is not proven as AIDD-owned; release refresh skipped.`; - this.logger.warn(message); - warnings.push(message); - } - for (const ref of verifiedOwnedRefs) { - if ( - !this.isSelectedRef(manifest, toolId, ref, refreshOwnedPlugins) || - !(await sourceStillMatches(ref)) - ) + 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( @@ -941,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" }; @@ -988,7 +991,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ]); const foreignRef = [...reading.refs].find( ([ref, entry]) => - hostRefInUse(toolId, entry.enabled) && ref.endsWith(`@${hostName}`) && !claimed.has(ref) + hostRefInUse(toolId, entry.enabled) && + ref.endsWith(`@${hostName}`) && + !claimed.has(ref) && + !approvedRefs.includes(ref) )?.[0]; return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) }; } @@ -1001,7 +1007,8 @@ 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"; @@ -1021,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" || diff --git a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts index 6ca592cf1..82808df24 100644 --- a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts +++ b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts @@ -22,6 +22,13 @@ export interface FrameworkUpgradePreview { readonly targets: readonly FrameworkUpgradePreviewTarget[]; readonly globalSkipped: number; readonly shared: boolean; + readonly impact: readonly ClaudeInstallation[]; +} + +export interface ClaudeInstallation { + readonly ref: string; + readonly scope: "local" | "project" | "user"; + readonly projectPath?: string; } interface UpgradeDeps { @@ -46,6 +53,16 @@ interface UpgradeDeps { targets: readonly FrameworkUpgradeTarget[], marketplace: Marketplace, localPath: string + ): Promise; + verifyImpact?( + projectRoot: string, + impact: readonly ClaudeInstallation[], + source?: string ): Promise; install(options: { pluginName: string; @@ -61,7 +78,8 @@ interface UpgradeDeps { sync( projectRoot: string, toolIds: readonly AiToolId[], - targets: readonly FrameworkUpgradeTarget[] + targets: readonly FrameworkUpgradeTarget[], + approvedRefs: readonly string[] ): Promise<{ warnings: readonly string[]; errors: readonly { scope: string; message: string }[]; @@ -201,9 +219,19 @@ export class UpgradeFrameworkUseCase { ); previewTargets.push({ ...target, targetVersion }); } - await this.deps.preflight(manifest, projectRoot, targets, next, localPath); - const shared = marketplace.scope === "user"; - if (!(await confirm({ currentRef, release, targets: previewTargets, globalSkipped, shared }))) { + const preflight = await this.deps.preflight(manifest, projectRoot, targets, next, localPath); + const impact = preflight?.impact ?? []; + const shared = marketplace.scope === "user" || preflight?.shared === true; + if ( + !(await confirm({ + currentRef, + release, + targets: previewTargets, + globalSkipped, + shared, + impact, + })) + ) { return { updated: [], globalSkipped, cancelled: true }; } if (shared && !allowShared) { @@ -211,11 +239,17 @@ export class UpgradeFrameworkUseCase { "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({ @@ -256,7 +290,8 @@ export class UpgradeFrameworkUseCase { const activation = await this.deps.sync( projectRoot, [...new Set(targets.map((target) => target.toolId))], - targets + targets, + [...new Set(impact.map((entry) => entry.ref))] ); if ( activation.errors.length || 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 3c081b94c..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 @@ -77,46 +77,37 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { } } -/** A release refresh must have a local entry and no other installation sharing its catalogue. */ -export async function claudeLocalPluginRefreshable( +/** Claude's recorded installations sharing a marketplace. Presence does not prove enablement. */ +export async function claudeInstallations( configDir: string, - projectRoot: string, - ref: string, - targets: readonly string[] -): Promise { - try { - const content = await readFile(join(configDir, "plugins", "installed_plugins.json"), "utf8"); - const plugins = parseClaudePlugins(content); - const entries = plugins?.[ref]; - if (!Array.isArray(entries)) return false; - const separator = ref.lastIndexOf("@"); - if (separator < 0) return false; - const catalogue = ref.slice(separator); - const here = await resolvedPath(projectRoot); - for (const [otherRef, otherEntries] of Object.entries(plugins ?? {})) { - if (!otherRef.endsWith(catalogue)) continue; - if (!targets.includes(otherRef) || !Array.isArray(otherEntries)) return false; - for (const entry of otherEntries) { - if ( - entry?.scope !== "local" || - typeof entry.projectPath !== "string" || - (await resolvedPath(entry.projectPath)) !== here - ) - return false; - } - } + 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 === "local" && - typeof entry.projectPath === "string" && - (await resolvedPath(entry.projectPath)) === here + if (entry?.scope === "user") installations.push({ ref, scope: "user" }); + else if ( + (entry?.scope === "local" || entry?.scope === "project") && + typeof entry.projectPath === "string" ) - return true; + installations.push({ + ref, + scope: entry.scope, + projectPath: await resolvedPath(entry.projectPath), + }); + else throw new Error(`Cannot inspect Claude installation '${ref}'.`); } - } catch { - return false; } - return false; + return installations; } function parseClaudePlugins(content: string): Record | undefined { diff --git a/cli/src/presentation/commands/framework.ts b/cli/src/presentation/commands/framework.ts index c20408a89..860913677 100644 --- a/cli/src/presentation/commands/framework.ts +++ b/cli/src/presentation/commands/framework.ts @@ -206,6 +206,16 @@ function printReleasePreview( ]); if (preview.globalSkipped) output.info(`${preview.globalSkipped} user-scope plugin entries will not be updated.`); + 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."); } diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index ec1ca9e5b..77eabd715 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -42,6 +42,7 @@ 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 { inspectNativeMarketplaceSource } from "../../contexts/framework/application/ownership/native-marketplace-source-proof.js"; import { assertNoSymlinksWithinRoot } from "../../contexts/framework/application/ownership/project-path-boundary.js"; import { assertProjectMcpEntriesRemovable, @@ -103,13 +104,14 @@ import { codexMarketplaceSourceListContract } from "../../contexts/tools/domain/ import { copilotMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/copilot/native-marketplace-source.js"; import { frameworkBuildModeFor } from "../../contexts/tools/domain/registry.js"; import { - claudeLocalPluginRefreshable, + 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"; @@ -513,6 +515,9 @@ export async function createDeps( }, 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( @@ -522,23 +527,51 @@ export async function createDeps( const catalogues = registration?.marketplaces.filter( (entry) => entry.alias === marketplace.name ); - const machine = await userManifestRepo.load(); - const machineClaims = machine?.getNativeRegistrations("claude")?.pluginClaims ?? []; - const hostName = catalogues?.length === 1 ? catalogues[0]?.hostName : undefined; + 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 = hostName === undefined ? undefined : `${target.pluginName}@${hostName}`; + const ref = `${target.pluginName}@${hostName}`; if ( - ref === undefined || - !registration?.pluginRefs.includes(ref) || - machineClaims.some((claim) => claim.ref === ref) || - !(await claudeLocalPluginRefreshable(claudeDir, root, ref, refs)) + !impact.some( + (entry) => entry.ref === ref && entry.scope === "local" && entry.projectPath === here + ) ) { throw new Error( - `Cannot upgrade claude:${target.pluginName}: local scope or ownership is ambiguous, or another installation shares its marketplace.` + `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") @@ -659,6 +692,35 @@ export async function createDeps( } 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(); @@ -714,12 +776,13 @@ export async function createDeps( throw new Error(`Cannot remove obsolete file '${relativePath}'.`); } }, - sync: (root, toolIds, targets) => + sync: (root, toolIds, targets, approvedRefs) => marketplaceSyncSettingsUseCase.execute({ projectRoot: root, toolIds, marketplaceNames: ["aidd-framework"], refreshOwnedPlugins: targets, + approvedRefs, }), }); const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( 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 712a3072c..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 @@ -73,12 +73,13 @@ describe("native catalogue source provenance", () => { 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]]), options.failBuild ? { @@ -110,7 +111,7 @@ describe("native catalogue source provenance", () => { ], ]) ); - 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 () => { @@ -121,6 +122,7 @@ describe("native catalogue source provenance", () => { 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([]); @@ -137,6 +139,7 @@ describe("native catalogue source provenance", () => { 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([ @@ -186,7 +189,7 @@ describe("native catalogue source provenance", () => { refreshOwnedPlugins: [{ toolId: "claude", pluginName: "test-plugin" }], }); expect(f.activator.releaseRefreshes).toEqual([]); - expect(result.warnings.join(" ")).toContain("not proven as AIDD-owned"); + expect(result.warnings.join(" ")).toContain("not in the approved preview"); expect(project.getNativeRegistrations("claude")?.pluginRefs).toContain("test-plugin@same-name"); }); @@ -201,6 +204,7 @@ describe("native catalogue source provenance", () => { 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( @@ -222,6 +226,36 @@ describe("native catalogue source provenance", () => { 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 }); 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 index 7849eb300..8a709027d 100644 --- 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 @@ -7,6 +7,7 @@ import { 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" }; @@ -86,7 +87,10 @@ function fixture() { manifest.addPlugin(id, InstalledPlugin.fromJSON(data)); } ); - const preflight = vi.fn(async () => {}); + 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 }[]; @@ -102,6 +106,7 @@ function fixture() { resolveTarget: resolve, readTargetVersion, preflight, + verifyImpact, install, sync, }); @@ -116,6 +121,7 @@ function fixture() { readTargetVersion, install, preflight, + verifyImpact, sync, }; } @@ -123,6 +129,26 @@ function fixture() { const accept = async () => true; describe("upgrade framework", () => { + 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"] }); @@ -165,6 +191,7 @@ describe("upgrade framework", () => { ], globalSkipped: 0, shared: true, + impact: [], }); expect(result.cancelled).toBe(true); expect(f.saveMarketplace).not.toHaveBeenCalled(); 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 e0ae6a11b..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 @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - claudeLocalPluginRefreshable, + claudeInstallations, hostPluginRegistryReaders, } from "../../../../src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; import type { AiToolId } from "../../../../src/kernel/tool.js"; @@ -49,14 +49,10 @@ describe("Claude Code's own installed_plugins.json", () => { ), }) ); - expect( - await claudeLocalPluginRefreshable( - join(home, ".claude"), - PROJECT, - "aidd-dev@aidd-framework", - refs - ) - ).toBe(true); + 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 () => { diff --git a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index f97e64e1c..ff33c5d87 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +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"; @@ -27,6 +27,14 @@ describe("framework release update wiring", () => { 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 }); @@ -92,128 +100,307 @@ describe("framework release update wiring", () => { return deps; } - it.each([ - "missing project claim", - "machine claim", - "user-only", - "local and independent user", - "another user plugin sharing the marketplace", - "project-only", - "other project's local scope", - "another project sharing the local plugin", - "another project sharing the marketplace", - "an unselected plugin in this project", - "the selected plugin at project scope too", - ] as const)("refuses Claude %s before modifying any installation", async (reason) => { + 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" }], - pluginRefs: reason === "missing project claim" ? [] : [ref], + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry", source: join(root, "catalogue") }, + }, + ], + pluginRefs: [ref], }); await deps.manifestRepo.save(manifest); - if (reason === "machine claim") { - const machine = Manifest.create(); - machine.addTool("claude", "5.6.0", []); - machine.setNativeRegistrations("claude", { - binary: "claude", - marketplaces: [], - pluginRefs: [], - pluginClaims: [{ ref, dependents: [root] }], - }); - await deps.userManifestRepo.save(machine); - } const registryPath = join(root, "home/.claude/plugins/installed_plugins.json"); await mkdir(dirname(registryPath), { recursive: true }); - const local = { scope: "local", projectPath: root }; - const entries = - reason === "user-only" - ? [{ scope: "user" }] - : reason === "local and independent user" - ? [local, { scope: "user" }] - : reason === "project-only" - ? [{ scope: "project", projectPath: root }] - : reason === "other project's local scope" - ? [{ scope: "local", projectPath: join(root, "other") }] - : [local]; - const plugins = { - [ref]: - reason === "another project sharing the local plugin" - ? [local, { scope: "local", projectPath: join(root, "other") }] - : reason === "the selected plugin at project scope too" - ? [local, { scope: "project", projectPath: root }] - : entries, - ...(reason === "another user plugin sharing the marketplace" - ? { "another-plugin@aidd-framework": [{ scope: "user" }] } - : {}), - ...(reason === "another project sharing the marketplace" - ? { - "another-plugin@aidd-framework": [ - { scope: "project", projectPath: join(root, "other") }, - ], - } - : {}), - ...(reason === "an unselected plugin in this project" - ? { "another-plugin@aidd-framework": [{ scope: "local", projectPath: root }] } - : {}), - }; - 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.*(?:claim|ownership)/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"); + 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(result.cancelled).toBe(true); }); - it("accepts Claude's registered local scope for this project before confirmation", async () => { + 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: [ref], + 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 }] } }) + 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(decline).toHaveBeenCalledOnce(); 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"); + 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); + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: true, + confirm: async () => { + await writeFile( + nativeRegistry, + JSON.stringify({ plugins: { ...plugins, "new@aidd-framework": [{ scope: "user" }] } }) + ); + return true; + }, + }) + ).rejects.toThrow(/installations changed after the preview/); + expect(await readFile(registry, "utf8")).toBe(before); + 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); + }); + + 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 () => { From d944dfa9cbbfead45873c66aa43807dac36af92e Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 09:15:39 +0800 Subject: [PATCH 28/38] feat(maintenance): show release and global inventory --- cli/package.json | 2 +- .../upgrade-user-framework-use-case.ts | 13 +- .../application/upgrade-framework-use-case.ts | 49 ++++++- cli/src/presentation/commands/framework.ts | 39 ++++-- .../github-release-resolver-adapter.ts | 55 +++++++- cli/src/runtime/wiring/framework.ts | 7 +- ...ser-framework-use-case.integration.test.ts | 19 ++- ...-maintenance-lifecycle.integration.test.ts | 2 +- .../upgrade-framework-use-case.unit.test.ts | 125 +++++++++++++++++- .../framework-wiring.integration.test.ts | 33 ++++- ...lease-resolver-adapter.integration.test.ts | 76 ++++++++++- .../framework-upgrade.integration.test.ts | 4 +- 12 files changed, 383 insertions(+), 41 deletions(-) diff --git a/cli/package.json b/cli/package.json index 3115f361d..f809aee1d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 796, + "bundleBudgetKB": 798, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", 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 index bf1c09733..d58f95082 100644 --- 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 @@ -12,6 +12,7 @@ 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; @@ -32,7 +33,10 @@ export class UpgradeUserFrameworkUseCase { updater: UserPluginFileUpdater; journal: UserReleaseJournal; logger: Logger; - isPublishedRelease: (repo: string, release: string) => Promise; + resolvePublishedRelease: ( + repo: string, + release: string + ) => Promise<{ title: string | null } | null>; } ) {} @@ -64,7 +68,11 @@ export class UpgradeUserFrameworkUseCase { const loaded = await this.deps.repo.load(); if (!loaded) throw new Error("No machine installation manifest exists."); let manifest = Manifest.fromJSON(loaded.toJSON()); - if (!(await this.deps.isPublishedRelease("ai-driven-dev/framework", release))) + 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() @@ -138,6 +146,7 @@ export class UpgradeUserFrameworkUseCase { } const preview: UserReleasePreview = { release, + title: publishedRelease.title, nativeSkipped, targets: plans.map(({ toolId, plugin, plan, currentRef }) => ({ toolId, diff --git a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts index 82808df24..e4a0839f4 100644 --- a/cli/src/contexts/framework/application/upgrade-framework-use-case.ts +++ b/cli/src/contexts/framework/application/upgrade-framework-use-case.ts @@ -19,8 +19,16 @@ interface FrameworkUpgradePreviewTarget extends FrameworkUpgradeTarget { 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[]; } @@ -35,7 +43,8 @@ interface UpgradeDeps { listMarketplaces(projectRoot: string): Promise; saveMarketplace(projectRoot: string, marketplace: Marketplace): Promise; loadManifest(): Promise; - isPublishedRelease(repo: string, release: string): Promise; + loadUserManifest?(): Promise; + resolvePublishedRelease(repo: string, release: string): Promise<{ title: string | null } | null>; resolveTarget( marketplace: Marketplace, projectRoot: string @@ -129,12 +138,14 @@ export class UpgradeFrameworkUseCase { 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; @@ -164,7 +175,11 @@ export class UpgradeFrameworkUseCase { throw new Error( "No project-scoped AIDD plugins found; global installations must be updated separately." ); - if (!(await this.deps.isPublishedRelease(marketplace.source.repo, release))) + 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 }; @@ -220,14 +235,44 @@ export class UpgradeFrameworkUseCase { 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, })) diff --git a/cli/src/presentation/commands/framework.ts b/cli/src/presentation/commands/framework.ts index 860913677..778dc4b7f 100644 --- a/cli/src/presentation/commands/framework.ts +++ b/cli/src/presentation/commands/framework.ts @@ -168,9 +168,11 @@ async function runFrameworkUpdate( function printReleaseLinks( output: CLIOutput, release: string, - currentRefs: readonly 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; @@ -200,12 +202,32 @@ function printReleasePreview( ` ${target.toolId}:${target.pluginName} (plugin ${version}; source ${target.currentRef} -> ${preview.release})` ); } - printReleaseLinks(output, preview.release, [ - preview.currentRef, - ...preview.targets.map((target) => target.currentRef), - ]); - if (preview.globalSkipped) - output.info(`${preview.globalSkipped} user-scope plugin entries will not be updated.`); + 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) @@ -278,7 +300,8 @@ async function runUserFrameworkReleaseUpdate( printReleaseLinks( output, preview.release, - preview.targets.map((target) => target.currentRef) + preview.targets.map((target) => target.currentRef), + preview.title ); if (preview.nativeSkipped) output.warn( 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 1dbe290ff..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 { @@ -54,16 +55,47 @@ export class GitHubReleaseResolverAdapter implements LatestReleaseResolver { } /** Checks the exact published tag, even when component releases fill the first page. */ - async isPublishedRelease(repo: string, tag: string): Promise { + 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 false; - const release = body as { tag_name?: unknown; draft?: unknown }; - return release.tag_name === tag && release.draft === false; + 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) ?? false; + return this.handleError(err, url); } } @@ -86,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/framework.ts b/cli/src/runtime/wiring/framework.ts index 77eabd715..6b285b4a3 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -502,7 +502,9 @@ export async function createDeps( listMarketplaces: (root) => marketplaceRegistry.list(root), saveMarketplace: (root, marketplace) => marketplaceRegistry.save(root, marketplace), loadManifest: () => manifestRepo.load(), - isPublishedRelease: (repo, release) => releaseResolver.isPublishedRelease(repo, release), + loadUserManifest: () => userManifestRepo.load(), + resolvePublishedRelease: (repo, release) => + releaseResolver.resolvePublishedRelease(repo, release), resolveTarget: (marketplace, root) => resolveMarketplaceUseCase.execute({ marketplace, projectRoot: root, forceRefresh: true }), readTargetVersion: async (localPath, pluginName) => { @@ -873,7 +875,8 @@ export async function createDeps( updater: userPluginFileUpdater, journal: userReleaseJournal, logger, - isPublishedRelease: (repo, release) => releaseResolver.isPublishedRelease(repo, release), + resolvePublishedRelease: (repo, release) => + releaseResolver.resolvePublishedRelease(repo, release), }); const restoreUseCase = new RestoreUseCase( fs, 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 index 294f0484e..b9d4be423 100644 --- 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 @@ -122,14 +122,18 @@ async function fixture(options: { currentRef?: string; targetVersion?: string } }), }; const journal = new UserReleaseJournal(fs, hasher, repo, { home: () => HOME, store }); - const isPublishedRelease = vi.fn(async (_repo: string, _release: string) => true); + 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, - isPublishedRelease, + resolvePublishedRelease, }); const writes = vi.spyOn(fs, "writeFile"); const deletes = vi.spyOn(fs, "deleteFile"); @@ -145,7 +149,7 @@ async function fixture(options: { currentRef?: string; targetVersion?: string } build, sessionEvents, updater, - isPublishedRelease, + resolvePublishedRelease, writes, deletes, }; @@ -162,6 +166,7 @@ describe("machine-scoped framework release update", () => { expect(f.sessionEvents).toEqual(["opened", "closed"]); expect(confirm).toHaveBeenCalledWith({ release: "v5.13.0", + title: "Release title", nativeSkipped: 1, targets: ["opencode", "kilo"].map((toolId) => ({ toolId, @@ -195,7 +200,7 @@ describe("machine-scoped framework release update", () => { }); 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.isPublishedRelease).toHaveBeenCalledWith("ai-driven-dev/framework", "v5.13.0"); + 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 }); @@ -302,7 +307,7 @@ describe("machine-scoped framework release update", () => { ).rejects.toThrow(/plugin downgrade/); expect(old.store.create).not.toHaveBeenCalled(); const unpublished = await fixture(); - unpublished.isPublishedRelease.mockResolvedValue(false); + unpublished.resolvePublishedRelease.mockResolvedValue(null); await expect( unpublished.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm: accept }) ).rejects.toThrow(/not available/); @@ -318,7 +323,7 @@ describe("machine-scoped framework release update", () => { await expect(f.useCase.execute({ projectRoot: PROJECT, release, confirm })).rejects.toThrow( /root release tag/ ); - expect(f.isPublishedRelease).not.toHaveBeenCalled(); + expect(f.resolvePublishedRelease).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); expect(f.writes).not.toHaveBeenCalled(); expect(f.repo.saveCount).toBe(0); @@ -331,7 +336,7 @@ describe("machine-scoped framework release update", () => { await expect( f.useCase.execute({ projectRoot: PROJECT, release: "v5.13.0", confirm: accept }) ).rejects.toThrow("No machine installation manifest"); - expect(f.isPublishedRelease).not.toHaveBeenCalled(); + expect(f.resolvePublishedRelease).not.toHaveBeenCalled(); expect(f.writes).not.toHaveBeenCalled(); }); 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 index 9c826f2c9..9ef655217 100644 --- 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 @@ -140,7 +140,7 @@ describe("maintenance TUI project lifecycle", () => { listMarketplaces: async () => [marketplace], saveMarketplace, loadManifest: async () => manifest, - isPublishedRelease: async () => true, + resolvePublishedRelease: async () => ({ title: "Release title" }), resolveTarget: async () => ({ catalog: { version: "1.0.1", 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 index 8a709027d..ca697ad4d 100644 --- 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 @@ -50,9 +50,10 @@ function fixture() { ) ); const list = vi.fn(async () => [original]); + const loadUserManifest = vi.fn(async (): Promise => null); const saveMarketplace = vi.fn(async (_root: string, _marketplace: Marketplace) => {}); - const isPublishedRelease = vi.fn(async (_repo: string, release: string) => - ["v5.13.0", "v5.12.0"].includes(release) + 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<{ @@ -102,7 +103,8 @@ function fixture() { listMarketplaces: list, saveMarketplace, loadManifest: async () => manifest, - isPublishedRelease, + loadUserManifest, + resolvePublishedRelease, resolveTarget: resolve, readTargetVersion, preflight, @@ -114,9 +116,10 @@ function fixture() { uc, original, manifest, + loadUserManifest, list, saveMarketplace, - isPublishedRelease, + resolvePublishedRelease, resolve, readTargetVersion, install, @@ -129,6 +132,117 @@ function fixture() { 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", []); @@ -173,6 +287,7 @@ describe("upgrade framework", () => { expect(confirm).toHaveBeenCalledWith({ currentRef: "v5.12.0", release: "v5.13.0", + title: "Release title", targets: [ { toolId: "opencode", @@ -190,6 +305,8 @@ describe("upgrade framework", () => { }, ], globalSkipped: 0, + unverifiedUserRefs: 0, + globalPlugins: [], shared: true, impact: [], }); diff --git a/cli/tests/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts index 70aa31235..42fdcac8e 100644 --- a/cli/tests/presentation/commands/framework-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/framework-wiring.integration.test.ts @@ -86,6 +86,7 @@ beforeEach(() => { const confirmed = await options.confirm({ currentRef: "v5.12.0", release: options.release, + title: "Release title", targets: [ { toolId: "opencode", @@ -108,6 +109,7 @@ beforeEach(() => { upgradeUserFramework.mockImplementation(async (options) => { const confirmed = await options.confirm({ release: options.release, + title: "Release title", nativeSkipped: 1, targets: [ { @@ -358,6 +360,7 @@ describe("aidd framework update", () => { }; const confirmed = await options.confirm({ release: options.release, + title: "Release title", nativeSkipped: 0, targets: [ { ...target, currentRef: "v5.11.0" }, @@ -370,6 +373,7 @@ describe("aidd framework update", () => { }); 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" @@ -484,6 +488,7 @@ describe("aidd framework update", () => { 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" }, @@ -495,6 +500,7 @@ describe("aidd framework update", () => { }); 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" @@ -529,11 +535,12 @@ describe("aidd framework update", () => { expect(updateAiTools).not.toHaveBeenCalled(); }); - it("names skipped user installs and does not warn about an unshared marketplace", async () => { + 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", @@ -543,6 +550,20 @@ describe("aidd framework update", () => { }, ], 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 { @@ -552,7 +573,15 @@ describe("aidd framework update", () => { }; }); const lines = await run("update", "--release", "v5.13.0", "--yes"); - expect(lines.join("\n")).toMatch(/2 user-scope.*not be updated/i); + 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(); 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 f3deb2bbf..e0e1c4924 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,12 +125,12 @@ describe("GitHubReleaseResolverAdapter", () => { }); }); - describe("isPublishedRelease", () => { + 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.isPublishedRelease(REPO, "v5.13.0")).toBe(true); + 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" } @@ -144,24 +144,90 @@ describe("GitHubReleaseResolverAdapter", () => { null, ])("refuses an unverified or draft release: %j", async (body) => { const adapter = new GitHubReleaseResolverAdapter(makeHttp(body)); - expect(await adapter.isPublishedRelease(REPO, "v5.13.0")).toBe(false); + 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.isPublishedRelease(REPO, "v5.13.0")).toBe(false); + 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.isPublishedRelease(REPO, "v5.13.0")).rejects.toThrow( + 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("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", () => { diff --git a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index ff33c5d87..ab739c71d 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -39,7 +39,9 @@ describe("framework release update wiring", () => { 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, "isPublishedRelease").mockResolvedValue(true); + vi.spyOn(GitHubReleaseResolverAdapter.prototype, "resolvePublishedRelease").mockResolvedValue({ + title: "Release title", + }); vi.spyOn(ResolveMarketplaceUseCase.prototype, "execute").mockImplementation( async ({ marketplace }) => ({ marketplace, From 077d10dcad6b71ac04edfc13918903c5acca62eb Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 09:31:07 +0800 Subject: [PATCH 29/38] test(maintenance): cover shared update preview --- .../framework-wiring.integration.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cli/tests/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts index 42fdcac8e..460870a9f 100644 --- a/cli/tests/presentation/commands/framework-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/framework-wiring.integration.test.ts @@ -513,6 +513,36 @@ describe("aidd framework update", () => { 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"); From 0119edd6bd4c24de5a4fcab8891e667bfea2d1f1 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 11:15:27 +0800 Subject: [PATCH 30/38] fix(cli): protect credentials and temporary files --- cli/README.md | 4 +- cli/aidd_docs/memory/testing.md | 2 +- cli/package.json | 2 +- .../infrastructure/plugin-fetcher-adapter.ts | 155 +++++++++--- cli/src/presentation/commands/marketplace.ts | 2 +- cli/src/presentation/commands/plugin.ts | 2 +- cli/src/runtime/git/inject-token.ts | 65 +++-- ...vitest-startup-preserves-temp.arch.test.ts | 62 +++++ ...plugin-fetcher-adapter.integration.test.ts | 231 +++++++++++++++++- ...plugin-fetcher-cache-and-auth.unit.test.ts | 137 +++++++++-- cli/tests/golden/snapshots/help/surface.json | 4 +- cli/tests/helpers/sweep-stale-temp-dirs.ts | 23 -- .../marketplace-wiring.integration.test.ts | 2 +- .../plugin-wiring.integration.test.ts | 2 +- .../git/inject-token-hosts.unit.test.ts | 10 +- .../runtime/git/inject-token.unit.test.ts | 108 ++++---- cli/vitest.config.ts | 12 +- scripts/sweep-stale-test-dirs.cjs | 48 ---- 18 files changed, 624 insertions(+), 247 deletions(-) create mode 100644 cli/tests/architecture/vitest-startup-preserves-temp.arch.test.ts delete mode 100644 cli/tests/helpers/sweep-stale-temp-dirs.ts delete mode 100644 scripts/sweep-stale-test-dirs.cjs diff --git a/cli/README.md b/cli/README.md index 481119be8..1c4de4420 100644 --- a/cli/README.md +++ b/cli/README.md @@ -152,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` @@ -190,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 | 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 f809aee1d..aed13cffe 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 798, + "bundleBudgetKB": 801, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts index 797aef284..2d7ccafd3 100644 --- a/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts +++ b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts @@ -14,11 +14,37 @@ import { 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; @@ -70,7 +96,7 @@ export class PluginFetcherAdapter implements PluginFetcher { if (!(await this.fs.fileExists(targetDir))) { const token = (await this.tokenProvider?.resolve()) ?? undefined; await this.cloneShallow( - injectTokenIntoUrl(baseUrl, token), + gitHttpAuthentication(baseUrl, token), baseUrl, targetDir, source.ref, @@ -92,11 +118,8 @@ export class PluginFetcherAdapter implements PluginFetcher { 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, + gitHttpAuthentication(source.url, token), source.url, targetDir, source.ref, @@ -119,9 +142,8 @@ export class PluginFetcherAdapter implements PluginFetcher { 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, + gitHttpAuthentication(url, token), url, targetDir, subpath, @@ -146,21 +168,12 @@ export class PluginFetcherAdapter implements PluginFetcher { let index: string; try { const env: NodeJS.ProcessEnv = { - ...process.env, - GIT_TERMINAL_PROMPT: "0", + ...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", }; - for (const key of Object.keys(env)) { - if ( - /^GIT_(?:DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_PARAMETERS|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+|NAMESPACE|REPLACE_REF_BASE)$/.test( - key - ) - ) - delete env[key]; - } const run = (args: string[]) => execFile( "git", @@ -257,11 +270,38 @@ 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, @@ -274,14 +314,28 @@ export class PluginFetcherAdapter implements PluginFetcher { ...(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, @@ -289,19 +343,39 @@ export class PluginFetcherAdapter implements PluginFetcher { canonicalCheckout = false ): Promise { try { - await this.gitWithNoPrompt().clone(authUrl, 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"); + 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); @@ -310,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/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 7c62003bc..02483079b 100644 --- a/cli/src/presentation/commands/plugin.ts +++ b/cli/src/presentation/commands/plugin.ts @@ -83,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( 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/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/infrastructure/plugin-fetcher-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts index e1bf4f58e..230d60603 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 { appendFile, mkdir, mkdtemp, readFile, 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"; @@ -56,6 +57,232 @@ 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-")); + 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(); + }); + await new Promise((resolve) => destination.listen(0, "127.0.0.1", 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(); + }); + try { + 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 { + 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 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"], { + 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", + }, + }); + 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", () => res.end()); + backend.on("error", () => res.destroy()); + }); + try { + await mkdir(workPath, { recursive: true }); + await setupBareRepoWithPlugin(repoPath, workPath); + await execFileAsync( + "git", + ["--git-dir", repoPath, "config", "uploadpack.allowFilter", "true"], + { env: gitEnv() } + ); + await execFileAsync( + "git", + ["--git-dir", repoPath, "config", "uploadpack.allowAnySHA1InWant", "true"], + { env: gitEnv() } + ); + process.env.HOME = sandbox; + await execFileAsync( + "git", + [ + "config", + "--global", + "credential.helper", + `!f() { echo username=tester; echo password=${password}; }; f`, + ], + { env: gitEnv() } + ); + 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; + for (const [index, key] of gitConfigKeys.entries()) { + const previous = previousGitConfig[index]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + 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(); 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/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index d398cf7f2..c6742a428 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -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", 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/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 20023ebbd..4e924527c 100644 --- a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts @@ -465,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/runtime/git/inject-token-hosts.unit.test.ts b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts index de802b3ca..9e67ec4ce 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", () => { diff --git a/cli/tests/runtime/git/inject-token.unit.test.ts b/cli/tests/runtime/git/inject-token.unit.test.ts index 05e75d102..4050a93f8 100644 --- a/cli/tests/runtime/git/inject-token.unit.test.ts +++ b/cli/tests/runtime/git/inject-token.unit.test.ts @@ -1,66 +1,48 @@ 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", + }); }); }); 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/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 }; From aa3efc616731adf5ad448b4c9bb34aacac59a345 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 11:32:50 +0800 Subject: [PATCH 31/38] test(cli): isolate git authentication tests --- ...plugin-fetcher-adapter.integration.test.ts | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) 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 230d60603..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 @@ -34,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"), @@ -46,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 { @@ -91,6 +92,8 @@ describe("PluginFetcherAdapter", () => { }); 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) => { @@ -98,7 +101,8 @@ describe("PluginFetcherAdapter", () => { seen = req.headers.authorization; res.writeHead(401, { "WWW-Authenticate": 'Basic realm="test"' }).end(); }); - await new Promise((resolve) => destination.listen(0, "127.0.0.1", resolve)); + 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"); @@ -109,7 +113,13 @@ describe("PluginFetcherAdapter", () => { }) .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"); @@ -120,6 +130,12 @@ describe("PluginFetcherAdapter", () => { 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 }); @@ -143,6 +159,7 @@ describe("PluginFetcherAdapter", () => { ] 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) { @@ -152,6 +169,7 @@ describe("PluginFetcherAdapter", () => { authenticatedRequests++; const url = new URL(req.url ?? "/", "http://localhost"); const backend = spawn("git", ["http-backend"], { + timeout: 15000, env: { ...gitEnv(), GIT_PROJECT_ROOT: sandbox, @@ -164,6 +182,8 @@ describe("PluginFetcherAdapter", () => { REMOTE_USER: "tester", }, }); + backend.stderr.resume(); + backend.stdin.on("error", () => res.destroy()); req.pipe(backend.stdin); let pending = Buffer.alloc(0); let started = false; @@ -189,32 +209,43 @@ describe("PluginFetcherAdapter", () => { res.write(pending.subarray(boundary + 4)); started = true; }); - backend.stdout.on("end", () => res.end()); + 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() } + { env: gitEnv(), timeout: 15000 } ); await execFileAsync( "git", ["--git-dir", repoPath, "config", "uploadpack.allowAnySHA1InWant", "true"], - { env: gitEnv() } + { 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() } + { env: gitEnv(), timeout: 15000 } ); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); @@ -274,11 +305,14 @@ describe("PluginFetcherAdapter", () => { } 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 }); } From 6c8d391937a6a1028904bc520790f03b24cd5d10 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 11:39:23 +0800 Subject: [PATCH 32/38] fix: isolate git hook test environments --- scripts/__tests__/gate-witness.test.js | 16 ++++++++++++++++ scripts/gate-witness.js | 9 ++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) 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/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.`); From f35ed2ec402b14510ab525432ffee4924e900bd5 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 12:25:43 +0800 Subject: [PATCH 33/38] test(cli): cover release updates and credentials --- .../git/inject-token-hosts.unit.test.ts | 18 ++ .../runtime/git/inject-token.unit.test.ts | 13 + ...lease-resolver-adapter.integration.test.ts | 75 +++++ .../framework-upgrade.integration.test.ts | 260 +++++++++++++++++- 4 files changed, 362 insertions(+), 4 deletions(-) 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 9e67ec4ce..ac985f491 100644 --- a/cli/tests/runtime/git/inject-token-hosts.unit.test.ts +++ b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts @@ -13,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 4050a93f8..921fc7efe 100644 --- a/cli/tests/runtime/git/inject-token.unit.test.ts +++ b/cli/tests/runtime/git/inject-token.unit.test.ts @@ -45,4 +45,17 @@ describe("Git HTTP authentication", () => { 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/self-update/github-release-resolver-adapter.integration.test.ts b/cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts index e0e1c4924..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 @@ -191,6 +191,81 @@ describe("GitHubReleaseResolverAdapter", () => { 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( diff --git a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index ab739c71d..ce2354875 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -13,6 +13,7 @@ import { EnsureBuiltMarketplaceUseCase } from "../../../src/contexts/framework/a import { Manifest } from "../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { FileHash, InstallationFile } from "../../../src/kernel/file.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"; @@ -190,7 +191,209 @@ describe("framework release update wiring", () => { 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 () => { @@ -266,6 +469,10 @@ describe("framework release update wiring", () => { 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, @@ -276,21 +483,25 @@ describe("framework release update wiring", () => { ).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: { ...plugins, "new@aidd-framework": [{ scope: "user" }] } }) - ); + 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({ @@ -307,6 +518,8 @@ describe("framework release update wiring", () => { }) ).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 () => { @@ -1111,6 +1324,45 @@ describe("framework release update wiring", () => { 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 () => { From e78f2646e29bd298a565d3cab506da3bd9143ec4 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 15:51:04 +0800 Subject: [PATCH 34/38] test(cli): preserve global symlink protections --- .../ownership/user-plugin-rebuild.integration.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index 7b5e56e05..7873a3e5e 100644 --- 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 @@ -213,8 +213,9 @@ describe("rebuilding an owned machine plugin", () => { await symlink(sibling, newPath); await expect( f.updater.planUpdate(f.plugin, "opencode", "/project", f.logger, true) - ).rejects.toThrow(/symlink/); + ).rejects.toThrow(newPath); expect(await readlink(newPath)).toBe(sibling); + expect(await readFile(sibling, "utf8")).toBe(f.content); } ); @@ -228,13 +229,14 @@ describe("rebuilding an owned machine plugin", () => { f.files.push( new InstallationFile({ relativePath, content: f.content, hash: f.hasher.hash(f.content) }) ); - await writeFile(sibling, 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(/symlink/); + 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"); } ); From 9458178470518e011eceece0f3eb2111da8f9a68 Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Fri, 6 Nov 2026 16:17:26 +0800 Subject: [PATCH 35/38] fix(maintenance): preserve project hook ownership on upgrade --- cli/package.json | 2 +- cli/scripts/check-bundle-size.mjs | 10 ++ .../application/plugin/plugin-add-use-case.ts | 6 +- cli/src/runtime/wiring/framework.ts | 44 ++++++--- .../plugin/plugin-add-use-case.unit.test.ts | 5 + .../framework-upgrade.integration.test.ts | 91 ++++++++++++++++++- 6 files changed, 142 insertions(+), 16 deletions(-) diff --git a/cli/package.json b/cli/package.json index aed13cffe..98cc2a640 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.16.1", - "bundleBudgetKB": 801, + "bundleBudgetKB": 826, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index fd2d69e9e..24e455a4b 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -57,6 +57,16 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // (+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. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; 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 856096d0b..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 @@ -127,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, @@ -463,6 +464,7 @@ export class PluginAddUseCase implements PluginAdd { previousMcpEntries, userScopeDirTaken, previousProjectHooks, + undefined, previousTui ); return { ...result, notices: [] }; diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 6b285b4a3..811a9bd4b 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -102,7 +102,10 @@ import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.j 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 { frameworkBuildModeFor } from "../../contexts/tools/domain/registry.js"; +import { + frameworkBuildModeFor, + projectHooksDeliveryOf, +} from "../../contexts/tools/domain/registry.js"; import { claudeInstallations, hostPluginRegistryReaders, @@ -657,35 +660,45 @@ export async function createDeps( ); for (const [path, hash] of plugin.projectHooks?.scripts ?? []) tracked.set(resolve(root, path), hash); - for (const file of await reader.readFlatFiles(builtDir, dist, target.toolId)) { - const path = resolve(root, file.relativePath); + const paths = (await reader.readFlatFiles(builtDir, dist, target.toolId)).map( + (file) => file.relativePath + ); + const delivery = projectHooksDeliveryOf(target.toolId); + if ( + delivery && + dist.components.hooks.some((file) => file.relativePath === "hooks/hooks.json") + ) { + 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 === file.relativePath) || - manifest - .getMergeFiles(id) - .some((entry) => entry.relativePath === file.relativePath) || + 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(file.relativePath) + (entry.files.has(relativePath) || + entry.projectHooks?.scripts.has(relativePath) === true) ) ); if (shared) throw new Error( - `Cannot upgrade ${owner}: target file '${file.relativePath}' is claimed by another installation.` + `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 '${file.relativePath}' is claimed by another plugin.` + `Cannot upgrade ${owner}: target file '${relativePath}' is claimed by another plugin.` ); planned.set(path, owner); await assertUpgradeWrite(fs, root, path, tracked); @@ -732,6 +745,13 @@ export async function createDeps( 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, 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 8942dc7e0..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 @@ -632,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/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index ce2354875..807defc10 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -10,8 +10,10 @@ import { MarketplaceRegistryAdapter } from "../../../src/contexts/distribution/i 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 { CurrentVersionAdapter } from "../../../src/runtime/self-update/current-version-adapter.js"; import { GitHubReleaseResolverAdapter } from "../../../src/runtime/self-update/github-release-resolver-adapter.js"; @@ -79,7 +81,7 @@ describe("framework release update wiring", () => { }); async function seed( - toolId: "claude" | "cursor" | "opencode", + toolId: "antigravity" | "claude" | "cursor" | "opencode", options: { files?: Record; mcpEntries?: Record } = {} ) { const deps = await createDeps(root, { verbose: false }); @@ -1095,6 +1097,93 @@ describe("framework release update wiring", () => { expect(manifest?.getPlugins("kilo")).toHaveLength(0); }); + 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); From 507a16141783a4f3c5875c305eedc08a5f560872 Mon Sep 17 00:00:00 2001 From: Alex <8973343+alexsoyes@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:25:38 +0100 Subject: [PATCH 36/38] fix(maintenance): check hook directory access before update --- cli/scripts/check-bundle-size.mjs | 1 + cli/src/runtime/filesystem/file-adapter.ts | 4 + cli/src/runtime/wiring/framework.ts | 36 +++++- .../framework-upgrade.integration.test.ts | 104 ++++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index 24e455a4b..e83b51ffb 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -67,6 +67,7 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 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. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/runtime/filesystem/file-adapter.ts b/cli/src/runtime/filesystem/file-adapter.ts index da907ec6b..3e8be0b10 100644 --- a/cli/src/runtime/filesystem/file-adapter.ts +++ b/cli/src/runtime/filesystem/file-adapter.ts @@ -28,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); diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 811a9bd4b..a75b1067d 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -1,7 +1,7 @@ import { mkdtemp } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import "../../contexts/tools/domain/profiles/antigravity/profile.js"; -import { join, resolve } from "node:path"; +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"; @@ -664,10 +664,36 @@ export async function createDeps( (file) => file.relativePath ); const delivery = projectHooksDeliveryOf(target.toolId); - if ( - delivery && - dist.components.hooks.some((file) => file.relativePath === "hooks/hooks.json") - ) { + 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)); diff --git a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index 807defc10..6e97ba62a 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -15,6 +15,7 @@ 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"; @@ -1097,6 +1098,109 @@ describe("framework release update wiring", () => { expect(manifest?.getPlugins("kilo")).toHaveLength(0); }); + it.each(["EPERM", "EACCES"])( + "refuses %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"); + vi.spyOn(FileAdapter.prototype, "assertWritableDirectory").mockRejectedValue( + Object.assign(new Error("denied"), { code }) + ); + + await expect( + deps.upgradeFrameworkUseCase.execute({ + projectRoot: root, + release: "v5.13.0", + allowShared: false, + confirm, + }) + ).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("checks the nearest existing parent of an incoming project hook script", async () => { + 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 = 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("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"); From 29b58a5b2e54acc34fcafffc2da1e44edcd84bce Mon Sep 17 00:00:00 2001 From: Alex <8973343+alexsoyes@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:25:59 +0100 Subject: [PATCH 37/38] fix(cli): handle closed hook input reliably --- cli/scripts/check-bundle-size.mjs | 2 + cli/src/presentation/commands/run/command.ts | 18 +++- cli/tests/e2e/headless-run.e2e.test.ts | 2 +- .../commands/run-wiring.integration.test.ts | 83 ++++++++++++++++++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index e83b51ffb..33bce2da5 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -68,6 +68,8 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 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/src/presentation/commands/run/command.ts b/cli/src/presentation/commands/run/command.ts index 82efb9e38..55f56b82d 100644 --- a/cli/src/presentation/commands/run/command.ts +++ b/cli/src/presentation/commands/run/command.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; +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"; @@ -121,8 +121,20 @@ function printNotice(tool: Tool, cwd: string): void { encoding: "utf8", timeout: 10000, }); - if (result.error || result.status !== 0) - throw new Error(`The aidd-maintenance hook failed for ${tool}.`); + 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; diff --git a/cli/tests/e2e/headless-run.e2e.test.ts b/cli/tests/e2e/headless-run.e2e.test.ts index e52908754..6ebe1ed13 100644 --- a/cli/tests/e2e/headless-run.e2e.test.ts +++ b/cli/tests/e2e/headless-run.e2e.test.ts @@ -178,7 +178,7 @@ describe("aidd run", () => { await mkdir(dirname(file), { recursive: true }); await writeFile(file, hook); const loaded = run(); - expect(loaded.status).toBe(0); + expect(loaded.status, loaded.stderr || loaded.error?.message).toBe(0); expect(loaded.stderr).toContain("MAINTENANCE NOTICE"); } finally { await env.cleanup(); diff --git a/cli/tests/presentation/commands/run-wiring.integration.test.ts b/cli/tests/presentation/commands/run-wiring.integration.test.ts index 0bf2c8ce6..b3632ef0a 100644 --- a/cli/tests/presentation/commands/run-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/run-wiring.integration.test.ts @@ -1,4 +1,4 @@ -import { ChildProcess } from "node:child_process"; +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"; @@ -8,6 +8,10 @@ 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"); @@ -148,6 +152,46 @@ describe("headless launcher", () => { 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) => { @@ -373,6 +417,43 @@ describe("headless launcher", () => { 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"], From f71d7a7fc1d49c3b0d3521e221ee2e83b5bf14a4 Mon Sep 17 00:00:00 2001 From: Alex <8973343+alexsoyes@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:53:26 +0100 Subject: [PATCH 38/38] test(cli): cover hook directory permissions --- .../file-adapter-edges.integration.test.ts | 23 ++++++ .../framework-upgrade.integration.test.ts | 80 ++++++++++++++----- 2 files changed, 85 insertions(+), 18 deletions(-) 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/wiring/framework-upgrade.integration.test.ts b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts index 6e97ba62a..62c4329fd 100644 --- a/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-upgrade.integration.test.ts @@ -1098,8 +1098,8 @@ describe("framework release update wiring", () => { expect(manifest?.getPlugins("kilo")).toHaveLength(0); }); - it.each(["EPERM", "EACCES"])( - "refuses %s on the project hooks directory before updating anything", + 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"); @@ -1121,8 +1121,53 @@ describe("framework release update wiring", () => { const registry = join(root, ".aidd/marketplaces.json"); const beforeRegistry = await readFile(registry, "utf8"); const beforeManifest = await readFile(join(root, ".aidd/manifest.json"), "utf8"); - vi.spyOn(FileAdapter.prototype, "assertWritableDirectory").mockRejectedValue( - Object.assign(new Error("denied"), { code }) + 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( @@ -1132,36 +1177,34 @@ describe("framework release update wiring", () => { allowShared: false, confirm, }) - ).rejects.toThrow(`Cannot write project hooks directory '${dirname(hooksFile)}' (${code})`); + ).rejects.toThrow(`Cannot write project hooks directory '${directory}' (EPERM)`); 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("checks the nearest existing parent of an incoming project hook script", async () => { + it("checks the directory of an obsolete tracked hook before updating", async () => { 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 = join(root, ".agents/hooks/aidd-context"); - await mkdir(directory, { recursive: true }); + 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() }) + 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 === directory) throw Object.assign(new Error("denied"), { code: "EPERM" }); + if (path === dirname(oldHook)) throw Object.assign(new Error("denied"), { code: "EACCES" }); }); await expect( @@ -1171,9 +1214,10 @@ describe("framework release update wiring", () => { allowShared: false, confirm, }) - ).rejects.toThrow(`Cannot write project hooks directory '${directory}' (EPERM)`); + ).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 () => {