diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index dfa9e4f57..2f579bc90 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -30,7 +30,7 @@ }, "framework": { "mutate": "src/contexts/framework/**/*.ts", - "break": 69 + "break": 93 }, "presentation": { "mutate": "src/presentation/**/*.ts", diff --git a/cli/tests/contexts/framework/application/clean-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean-use-case.integration.test.ts new file mode 100644 index 000000000..0ddb127e3 --- /dev/null +++ b/cli/tests/contexts/framework/application/clean-use-case.integration.test.ts @@ -0,0 +1,66 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.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 { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; + +let projectRoot: string; + +beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "aidd-clean-aidd-dir-")); +}); + +afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); +}); + +function buildUseCase(logger: CapturingLogger): CleanUseCase { + const fs = new FileAdapter(new HasherAdapter(), logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + return new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, projectRoot), + logger, + new GitignoreUseCase(fs) + ); +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +describe("clean and the .aidd/ directory itself", () => { + it("removes .aidd/ once nothing it wrote is left inside, rather than leaving an empty shell", async () => { + const aiddDir = join(projectRoot, ".aidd"); + await mkdir(join(aiddDir, "cache", "built"), { recursive: true }); + await writeFile(join(aiddDir, "cache", "built", "x.json"), "{}"); + const logger = new CapturingLogger(); + + await buildUseCase(logger).execute({ projectRoot, force: true }); + + expect(await exists(aiddDir)).toBe(false); + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + + it("finishes cleanly when .aidd/ was already removed by hand before the run", async () => { + const logger = new CapturingLogger(); + + const result = await buildUseCase(logger).execute({ projectRoot, force: true }); + + expect(result.fileCount).toBe(0); + expect(await exists(join(projectRoot, ".aidd"))).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts index 77051b099..38d69476d 100644 --- a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts @@ -5,6 +5,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; @@ -13,6 +14,7 @@ import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugi import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import { cursorProjectHooksScriptDir } from "../../../../src/contexts/tools/domain/formats/cursor-hooks-project-merge.js"; import type { NativePluginActivator } from "../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; @@ -21,6 +23,7 @@ import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-pl 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"; +import { RecordingPrompter } from "../../../helpers/ports/recording-prompter.js"; const PROJECT_ROOT = "/test-project"; @@ -1024,6 +1027,456 @@ describe("clean", () => { expect(activator.removedMarketplaces).toEqual([HOST_NAME]); expect(activator.removedMarketplaces).not.toContain(ALIAS); }); + + describe("what the host is told, word for word", () => { + const HOME = "/fake-home"; + const CODEX_CACHE = join(HOME, ".codex", "plugins", "cache"); + + function buildUseCase(deps: { + manifest: Manifest; + fs: InMemoryFileAdapter; + logger: CapturingLogger; + activators: ReadonlyMap; + registry: InMemoryMarketplaceRegistry | undefined; + }): CleanUseCase { + return new CleanUseCase( + deps.fs, + new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT), + deps.logger, + new GitignoreUseCase(deps.fs), + deps.activators, + deps.registry, + undefined, + new Map(), + () => HOME + ); + } + + it("names the absent binary alone when no activator is wired for it", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map(), + registry: seedMarketplaceRegistry(), + }); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.manifestFound).toBe(true); + expect(logger.warnMessages).toStrictEqual([ + `codex: registration left in place, the codex CLI is not on the PATH. Its cache survives at: ${join(CODEX_CACHE, MARKETPLACE)}.`, + ]); + }); + + it("leaves a registration in place when its alias is no longer registered here, still uninstalling the plugin ref", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(CODEX_CACHE, MARKETPLACE, "leftover.json"), "{}"); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-mkt", + source: { kind: "local", path: "/other/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("leaves a registration in place when no marketplace registry is wired in at all", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry: undefined, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("names a refused marketplace removal by the host's own words", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true, throwOnRemove: true }); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `codex marketplace remove '${MARKETPLACE}' failed: marketplace remove ${MARKETPLACE} failed: '${MARKETPLACE}' is not configured or installed`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("names a refused plugin uninstall by the host's own words, after trying both scopes", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ + available: true, + failOnUninstall: [REF], + }); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPluginScopes).toStrictEqual(["project", "user"]); + expect(logger.warnMessages).toStrictEqual([ + `codex plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed`, + ]); + }); + + it("propagates an activator failure that is not the host refusing", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger: new CapturingLogger(), + activators: new Map([[BINARY, new CrashingUninstallActivator()]]), + registry: seedMarketplaceRegistry(), + }); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "activator crashed uninstalling a plugin" + ); + }); + + function seedSharedRegistry(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; + } + + function seedRegistrations(toolId: ToolId, binary: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.setNativeRegistrations(toolId, { + binary, + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [], + }); + return manifest; + } + + it("names no cache for a shared marketplace at a host whose profile declares no cache directory (copilot)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("copilot", "copilot"), + fs, + logger, + activators: new Map([["copilot", new FakeNativePluginActivator({ available: true })]]), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `copilot: '${MARKETPLACE}' is shared by every project on this machine — left registered. Its entry survives at userConfigDir()/marketplaces.json.`, + ]); + }); + + it("names no cache for a shared marketplace at a tool that drives no native CLI (cursor)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("cursor", "cursor"), + fs, + logger, + activators: new Map([["cursor", new FakeNativePluginActivator({ available: true })]]), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `cursor: '${MARKETPLACE}' is shared by every project on this machine — left registered. Its entry survives at userConfigDir()/marketplaces.json.`, + ]); + }); + + it("never treats a shared marketplace it left registered as one the host forgot, so its cache stays", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(CODEX_CACHE, MARKETPLACE, "leftover.json"), "{}"); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("codex", BINARY), + fs, + logger, + activators: new Map([[BINARY, new FakeNativePluginActivator({ available: true })]]), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(CODEX_CACHE, MARKETPLACE, "leftover.json"))).toBe(true); + expect(logger.warnMessages).toStrictEqual([ + `codex: '${MARKETPLACE}' is shared by every project on this machine — left registered. Its entry survives at userConfigDir()/marketplaces.json, and its cache at: ${join(CODEX_CACHE, MARKETPLACE)}.`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("names no surviving cache when the absent binary's profile declares no cache directory (copilot)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("copilot", "copilot"), + fs, + logger, + activators: new Map(), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "copilot: registration left in place, the copilot CLI is not on the PATH.", + ]); + }); + + it("names no surviving cache when the absent binary belongs to a tool that drives no native CLI (cursor)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("cursor", "cursor"), + fs, + logger, + activators: new Map(), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "cursor: registration left in place, the cursor CLI is not on the PATH.", + ]); + }); + + it("names no surviving cache when the absent binary registered no marketplace", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [], + pluginRefs: [REF], + }); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest, + fs, + logger, + activators: new Map(), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "codex: registration left in place, the codex CLI is not on the PATH.", + ]); + }); + + it("names every surviving cache of an absent binary, one path per marketplace", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + { alias: "mkt-a", hostName: "mkt-a" }, + { alias: "mkt-b", hostName: "mkt-b" }, + ], + pluginRefs: [], + }); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest, + fs, + logger, + activators: new Map(), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `codex: registration left in place, the codex CLI is not on the PATH. Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}.`, + ]); + }); + }); + + describe("the scope the manifest recorded for a plugin ref, at cursor, the one host admitting a user-scope record", () => { + const CURSOR_REF = "aidd-context@aidd-framework"; + + function seedCursorManifest( + marketplaces: ReadonlyArray<{ alias: string; hostName: string }>, + plugins: ReadonlyArray<{ name: string; scope: "project" | "user"; marketplace: string }> + ): Manifest { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + for (const plugin of plugins) { + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + plugin.name, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + plugin.scope, + plugin.marketplace + ) + ); + } + manifest.setNativeRegistrations("cursor", { + binary: "cursor", + marketplaces: [...marketplaces], + pluginRefs: [CURSOR_REF], + }); + return manifest; + } + + async function uninstallScopesFor(manifest: Manifest): Promise { + const fs = new InMemoryFileAdapter(); + const activator = new FakeNativePluginActivator({ + available: true, + installedAtScope: new Map([[CURSOR_REF, "user"]]), + }); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["cursor", activator]]), + seedMarketplaceRegistry() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + return activator.uninstalledPluginScopes; + } + + it("tries the user scope first when that is what the manifest recorded for the plugin behind the ref, found by its own alias among several", async () => { + const scopes = await uninstallScopesFor( + seedCursorManifest( + [ + { alias: "other", hostName: "other-host" }, + { alias: MARKETPLACE, hostName: MARKETPLACE }, + ], + [ + { name: "other-plugin", scope: "project", marketplace: MARKETPLACE }, + { name: "aidd-context", scope: "user", marketplace: MARKETPLACE }, + ] + ) + ); + + expect(scopes).toStrictEqual(["user"]); + }); + + it("tries the project scope first when the plugin's alias matches no recorded marketplace", async () => { + const scopes = await uninstallScopesFor( + seedCursorManifest( + [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + [{ name: "aidd-context", scope: "user", marketplace: "unknown-alias" }] + ) + ); + + expect(scopes).toStrictEqual(["project", "user"]); + }); + + it("tries the project scope first when the manifest records no plugin at all for the ref", async () => { + const scopes = await uninstallScopesFor( + seedCursorManifest([{ alias: MARKETPLACE, hostName: MARKETPLACE }], []) + ); + + expect(scopes).toStrictEqual(["project", "user"]); + }); + }); + + it("previews no shared-source fact when the references port is wired in but no marketplace registry is", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(seedManifestWithNativeRegistrations(), PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map(), + undefined, + undefined, + new Map(), + () => "/fake-home", + new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd") + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: { + tools: [{ toolId: "codex", fileCount: 0 }], + totalFileCount: 0, + nativeRegistrations: [ + { + toolId: "codex", + binary: BINARY, + marketplaceCount: 1, + pluginRefCount: 1, + cachePaths: [join("/fake-home", ".codex", "plugins", "cache", MARKETPLACE)], + }, + ], + sharedSourceOtherProjects: undefined, + }, + fileCount: 0, + }); + }); }); describe("machine-local files a tool's own materialization writes outside the manifest", () => { @@ -1202,8 +1655,452 @@ describe("clean", () => { expect(logger.warnMessages.some((m) => m.includes(PLUGIN_KEY))).toBe(true); }); }); + + describe("without a manifest", () => { + it("reports nothing found and nothing to preview", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(null, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: false, + preview: { tools: [], totalFileCount: 0, nativeRegistrations: [] }, + fileCount: 0, + }); + }); + }); + + describe("confirming before removing anything", () => { + function buildConfirmingUseCase( + fs: InMemoryFileAdapter, + prompter: RecordingPrompter | undefined + ) { + return new CleanUseCase( + fs, + new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map(), + undefined, + prompter + ); + } + + it("asks its one question, word for word, and removes everything on yes", async () => { + const fs = new InMemoryFileAdapter(); + const prompter = new RecordingPrompter(true); + const useCase = buildConfirmingUseCase(fs, prompter); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + + expect(prompter.confirmMessages).toStrictEqual(["Remove all AIDD files?"]); + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: true, + preview: previewOf([["claude", 1]]), + fileCount: 1, + }); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(false); + }); + + it("removes nothing on no and answers with the dry-run preview", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildConfirmingUseCase(fs, new RecordingPrompter(false)); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: previewOf([["claude", 1]]), + fileCount: 0, + }); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(true); + }); + + it("never asks outside an interactive run, even with a prompter wired in", async () => { + const fs = new InMemoryFileAdapter(); + const prompter = new RecordingPrompter(true); + const useCase = buildConfirmingUseCase(fs, prompter); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: previewOf([["claude", 1]]), + fileCount: 0, + }); + }); + + it("stays a dry-run in an interactive run with no prompter wired in", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildConfirmingUseCase(fs, undefined); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + + expect(result.dryRun).toBe(true); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(true); + }); + }); + + describe("what a dry-run previews", () => { + it("counts each tool's tracked and merged files, and totals them", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = seedTrackedFiles(fs, ["a.md", "b.md"]); + manifest.addTool( + "claude", + "1.0.0", + [fileEntry("a.md"), fileEntry("b.md")], + [{ relativePath: ".claude/settings.json", sectionKey: null, entries: { owned: hash() } }] + ); + manifest.addTool("codex", "1.0.0", [fileEntry("c.md")]); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: previewOf([ + ["claude", 3], + ["codex", 1], + ]), + fileCount: 0, + }); + }); + + it("announces no cache path for a host whose profile declares no cache directory (copilot)", async () => { + const manifest = Manifest.create(); + manifest.addTool("copilot", "1.0.0", []); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: ["aidd-context@aidd-framework"], + }); + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.preview.nativeRegistrations).toStrictEqual([ + { + toolId: "copilot", + binary: "copilot", + marketplaceCount: 1, + pluginRefCount: 1, + cachePaths: [], + }, + ]); + }); + + it("announces no cache path for a tool that drives no native CLI (cursor)", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.setNativeRegistrations("cursor", { + binary: "cursor", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: [], + }); + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.preview.nativeRegistrations).toStrictEqual([ + { + toolId: "cursor", + binary: "cursor", + marketplaceCount: 1, + pluginRefCount: 0, + cachePaths: [], + }, + ]); + }); + }); + + describe("counting what was removed", () => { + function buildCountingUseCase( + fs: InMemoryFileAdapter, + manifest: Manifest, + logger = new CapturingLogger() + ) { + return new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + logger, + new GitignoreUseCase(fs) + ); + } + + it("counts every tracked file and names the tool being removed", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildCountingUseCase(fs, seedTrackedFiles(fs, ["a.md", "b.md"]), logger); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(2); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(false); + expect(fs.has(join(PROJECT_ROOT, "b.md"))).toBe(false); + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + + it("counts the machine-local settings file a tool wrote outside the manifest", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, ".claude", "settings.local.json"), "{}"); + const useCase = buildCountingUseCase(fs, seedTrackedFiles(fs, ["a.md"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(2); + }); + + it("does not count a machine-local settings file that was never written", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedTrackedFiles(fs, ["a.md"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(1); + }); + + it("counts the project hooks file a cursor plugin was unmerged from", async () => { + const pluginName = "aidd-context"; + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + pluginName, + "1.0.0", + { kind: "local", path: "/p" }, + false, + "project" + ) + ); + const fs = new InMemoryFileAdapter(); + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + fs.setFile( + join(PROJECT_ROOT, ".cursor", "hooks.json"), + JSON.stringify({ + version: 1, + hooks: { PreToolUse: [{ command: `./${scriptMarker}run.js` }] }, + }) + ); + const useCase = buildCountingUseCase(fs, manifest); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(1); + }); + + it("deletes a project-scope plugin's files from the project and counts them", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "aidd-context", + "1.0.0", + { kind: "local", path: "/p" }, + false, + "project" + ).withFiles(new Map([["skills/x.md", HASH]])) + ); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "skills", "x.md"), "x"); + const useCase = buildCountingUseCase(fs, manifest); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(PROJECT_ROOT, "skills", "x.md"))).toBe(false); + expect(result.fileCount).toBe(1); + }); + + describe("a file merged into one the project shares", () => { + const MERGED = ".claude/settings.json"; + + function seedMerged( + fs: InMemoryFileAdapter, + content: string | null, + ownedKeys: string[] + ): Manifest { + const manifest = Manifest.create(); + const entries: Record = {}; + for (const key of ownedKeys) entries[key] = hash(); + manifest.addTool( + "claude", + "1.0.0", + [], + [{ relativePath: MERGED, sectionKey: null, entries }] + ); + if (content !== null) fs.setFile(join(PROJECT_ROOT, MERGED), content); + return manifest; + } + + it("takes back only its own keys, leaving the project's, and counts the file once", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase( + fs, + seedMerged(fs, '{"owned":1,"theirs":2}', ["owned"]) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.getFile(join(PROJECT_ROOT, MERGED))).toBe('{\n "theirs": 2\n}'); + expect(result.fileCount).toBe(1); + }); + + it("deletes the file once nothing but its own keys was in it", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedMerged(fs, '{"owned":1}', ["owned"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(PROJECT_ROOT, MERGED))).toBe(false); + expect(result.fileCount).toBe(1); + }); + + it("deletes a file it recorded whole, one with no keys of its own", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedMerged(fs, '{"theirs":2}', [])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(PROJECT_ROOT, MERGED))).toBe(false); + expect(result.fileCount).toBe(1); + }); + + it("skips a merged file already gone, without counting it", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedMerged(fs, null, ["owned"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(0); + }); + }); + }); + + describe("what stays under .aidd/", () => { + it("says it kept config.json, by its path", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, ".aidd", "config.json"), "{}"); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.infoMessages).toStrictEqual([ + "Removing claude files...", + "Kept .aidd/config.json", + ]); + }); + + it("says nothing about config.json when another file is what keeps .aidd/ alive", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, ".aidd", "auth.json"), "{}"); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + }); }); +const HASH = "abc123abc123abc123abc123abc123ab"; + +function hash(): FileHash { + return new FileHash(HASH); +} + +function fileEntry(relativePath: string): InstallationFile { + return new InstallationFile({ relativePath, content: "", hash: hash() }); +} + +function seedTrackedFiles(fs: InMemoryFileAdapter, paths: readonly string[]): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", paths.map(fileEntry)); + for (const path of paths) fs.setFile(join(PROJECT_ROOT, path), "x"); + return manifest; +} + +function previewOf(tools: ReadonlyArray) { + return { + tools: tools.map(([toolId, fileCount]) => ({ toolId, fileCount })), + totalFileCount: tools.reduce((sum, [, fileCount]) => sum + fileCount, 0), + nativeRegistrations: [], + sharedSourceOtherProjects: undefined, + }; +} + +class CrashingUninstallActivator implements NativePluginActivator { + isAvailable(): boolean { + return true; + } + addMarketplace(): void {} + enablesPlugins(): boolean { + return false; + } + removeMarketplace(): void {} + registrationState(): "live" | "dead" | "unknown" { + return "live"; + } + upgradeMarketplaces(): void {} + enablePlugin(): void {} + uninstallPlugin(): void { + throw new Error("activator crashed uninstalling a plugin"); + } +} + /** Records `uninstallPlugin`/`removeMarketplace` calls in the order they happen, so a test * can assert one came before the other. */ class OrderRecordingActivator implements NativePluginActivator { diff --git a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts index 5a5ee8a05..e1fd62d06 100644 --- a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts @@ -189,6 +189,32 @@ describe("clean guards a ref another project on this machine still needs", () => expect(activator.uninstalledPlugins).toContain("plugin-b@other-mkt"); }); + it("still disables a ref from another marketplace when that marketplace is recorded before the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "codex", + [ + { alias: "other-mkt", hostName: "other-mkt" }, + { alias: "aidd-framework", hostName: "aidd-framework" }, + ], + ["plugin-b@other-mkt", "aidd-vcs@aidd-framework"] + ), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toStrictEqual(["plugin-b@other-mkt"]); + }); + it("disables codex's ref when no claim was ever recorded for this project, and no other project references it either", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); // No references.json at all: no claim of this project's own to drop, and nothing else 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 2a483db83..78c203f75 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 @@ -1,6 +1,8 @@ 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/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { FRAMEWORK_MARKETPLACE_NAME, @@ -11,16 +13,27 @@ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest. import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; -import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-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"; +import { RecordingPrompter } from "../../../../helpers/ports/recording-prompter.js"; 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"); +const WHITELIST_PURGE_MESSAGES = [ + `user scope: cache/built purged: ${join(USER_CONFIG_DIR, "cache", "built")}`, + `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")}`, + `user scope: references.json purged: ${join(USER_CONFIG_DIR, "references.json")}`, +]; /** Records every delete in order, so an ordering constraint can be proved without reading * the use case's own private state. A shared array correlates with another recorder. */ @@ -80,31 +93,19 @@ class RecordingActivator implements NativePluginActivator { } } -/** Answers a fixed way and keeps the message, so the confirmation's exact wording and the - * "answered no" branch can both be pinned. */ -class RecordingPrompter implements Prompter { - lastConfirmMessage: string | undefined; - - constructor(private readonly answer: boolean) {} - - async confirm(message: string): Promise { - this.lastConfirmMessage = message; - return this.answer; - } - async resolveConflict(): Promise<"keep" | "overwrite"> { - return "keep"; - } - async resolveConflictBulk(): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { - return "keep"; - } - async input(): Promise { - return ""; - } - async select(): Promise { - throw new Error("not implemented"); +class StrictListingFileAdapter extends InMemoryFileAdapter { + constructor(private readonly deniedDir?: string) { + super(); } - async checkbox(): Promise { - return []; + + override async listDirectory(dirPath: string): Promise { + if (dirPath === this.deniedDir) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + if (!(await this.fileExists(dirPath))) { + throw Object.assign(new Error(`ENOENT: no such directory, ${dirPath}`), { code: "ENOENT" }); + } + return super.listDirectory(dirPath); } } @@ -537,5 +538,509 @@ describe("clean --scope user", () => { expect(manifestRepo.getCurrent()).not.toBeNull(); expect(fs.order).toEqual([]); }); + + it("never asks outside an interactive run, even with a prompter wired in", async () => { + const fs = new RecordingFileAdapter(); + const prompter = new RecordingPrompter(true); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + undefined, + prompter + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(result.dryRun).toBe(true); + expect(fs.order).toStrictEqual([]); + }); + + it("names every built version and every referencing project in its question", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "2.0.0", "aidd-framework", "x"), "1"); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "1.2.3", "aidd-framework", "x"), "1"); + fs.setFile("/project-a/marker", ""); + fs.setFile("/project-b/marker", ""); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.2.3", "/project-a"); + await userSourceReferences.addReference("2.0.0", "/project-b"); + const prompter = new RecordingPrompter(false); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences, + prompter + ); + + await useCase.execute({ projectRoot: "/wherever", force: false, interactive: true }); + + expect(prompter.confirmMessages).toStrictEqual([ + "Remove the shared 'aidd-framework' source for this machine " + + "(versions: 1.2.3, 2.0.0)? Still referenced by: /project-a, /project-b.", + ]); + }); + }); + + describe("what the run reports", () => { + function buildClaudeUseCase(fs: InMemoryFileAdapter, logger: CapturingLogger) { + return new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", new FakeNativePluginActivator({ available: true })]]), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "known_marketplaces.json", + entries: new Map(), + }), + ], + ]), + () => HOME + ); + } + + it("reports the manifest found and the tools it named, and purges the cache the host forgot", async () => { + const fs = new InMemoryFileAdapter(); + const claudeCachePath = seedClaudeCache(fs); + const useCase = buildClaudeUseCase(fs, new CapturingLogger()); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: true, + preview: { toolIds: ["claude"], builtVersions: [], referencingProjects: [] }, + }); + expect(await fs.fileExists(join(claudeCachePath, "marker.json"))).toBe(false); + }); + + it("names each purge, by its label and path, and nothing about a missing user registration", async () => { + const fs = new InMemoryFileAdapter(); + seedClaudeCache(fs); + const logger = new CapturingLogger(); + const useCase = buildClaudeUseCase(fs, logger); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for 'aidd-framework' purged: ${join(CLAUDE_CACHE, "aidd-framework")}`, + ...WHITELIST_PURGE_MESSAGES, + ]); + }); + + it("reports no tool, and says plainly that nothing was registered, when no user manifest exists", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(null), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: false, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }); + expect(logger.infoMessages).toStrictEqual([ + "No host registration was undone: nothing was registered at user scope.", + ...WHITELIST_PURGE_MESSAGES, + ]); + }); + + it("names every referencing project, comma separated, when nothing was registered here", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile("/project-a/marker", ""); + fs.setFile("/project-b/marker", ""); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.0.0", "/project-a"); + await userSourceReferences.addReference("1.0.0", "/project-b"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(null), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences + ); + + await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(logger.infoMessages).toStrictEqual([ + "No host registration was undone: nothing was registered at user scope. " + + "/project-a, /project-b still resolve the shared source through their own host; " + + "full removal is `aidd clean` in each of them, then `aidd clean --scope user`.", + ]); + }); + + it("reports no referencing project, and warns, when references.json cannot be read", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(USER_CONFIG_DIR, "references.json"), "not json"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(result.preview.referencingProjects).toStrictEqual([]); + expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + }); + }); + + describe("the versions built on this machine", () => { + function buildPreviewUseCase(fs: InMemoryFileAdapter) { + return new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + } + + it("lists them sorted, whatever order the cache holds them in", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "2.0.0", "aidd-framework", "x"), "1"); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "aidd-framework", "x"), "1"); + + const result = await buildPreviewUseCase(fs).execute({ + projectRoot: "/wherever", + force: false, + }); + + expect(result.preview.builtVersions).toStrictEqual(["1.0.0", "2.0.0"]); + }); + + it("lists none when the built cache root does not exist", async () => { + const fs = new StrictListingFileAdapter(); + + const result = await buildPreviewUseCase(fs).execute({ + projectRoot: "/wherever", + force: false, + }); + + expect(result.preview.builtVersions).toStrictEqual([]); + }); + + it("propagates a built cache root that exists but cannot be read", async () => { + const fs = new StrictListingFileAdapter(join(USER_CONFIG_DIR, "cache", "built")); + + await expect( + buildPreviewUseCase(fs).execute({ projectRoot: "/wherever", force: false }) + ).rejects.toThrow("permission denied"); + }); + }); + + describe("driving the host CLI", () => { + function buildUseCase(deps: { + fs: InMemoryFileAdapter; + logger: CapturingLogger; + manifest?: Manifest; + binary?: string; + activator: NativePluginActivator; + }) { + return new CleanUserScopeUseCase( + deps.fs, + new InMemoryManifestRepository(deps.manifest ?? manifestWithClaude()), + deps.logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([[deps.binary ?? "claude", deps.activator]]), + new Map(), + () => HOME + ); + } + + it("uninstalls every plugin ref at the user scope", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs: new InMemoryFileAdapter(), + logger: new CapturingLogger(), + activator, + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(activator.uninstalledPlugins).toStrictEqual(["aidd-context@aidd-framework"]); + expect(activator.uninstalledPluginScopes).toStrictEqual(["user"]); + }); + + it("names a refused plugin uninstall by the host's own words and still unregisters the marketplace", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + failOnUninstall: ["aidd-context@aidd-framework"], + }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ fs: new InMemoryFileAdapter(), logger, activator }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "claude plugin uninstall 'aidd-context@aidd-framework' failed: plugin `aidd-context@aidd-framework` is not installed", + ]); + expect(activator.removedMarketplaces).toStrictEqual(["aidd-framework"]); + }); + + it("unregisters every marketplace at the user scope", async () => { + const activator = new RecordingActivator([]); + const useCase = buildUseCase({ + fs: new InMemoryFileAdapter(), + logger: new CapturingLogger(), + activator, + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(activator.removedMarketplaces).toStrictEqual(["aidd-framework"]); + expect(activator.removedMarketplaceScopes).toStrictEqual(["user"]); + }); + + it("names a refused marketplace removal by the host's own words", async () => { + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs: new InMemoryFileAdapter(), + logger, + activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "claude marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", + `claude: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CLAUDE_CACHE, "aidd-framework")}`, + ]); + }); + + it("never treats a refused removal as one the host confirmed, so codex's cache stays", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: [], + }); + const fs = new InMemoryFileAdapter(); + const leftover = join(CODEX_CACHE, "aidd-framework", "leftover.json"); + fs.setFile(leftover, "{}"); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + logger, + manifest, + binary: "codex", + activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.has(leftover)).toBe(true); + expect(logger.warnMessages).toStrictEqual([ + "codex marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", + `codex: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, "aidd-framework")}`, + ]); + }); + }); + + describe("what an absent binary is said to leave standing", () => { + function seedRegistrations( + toolId: ToolId, + marketplaces: ReadonlyArray<{ alias: string; hostName: string }>, + pluginRefs: readonly string[] + ): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [...marketplaces], + pluginRefs: [...pluginRefs], + }); + return manifest; + } + + async function warningsFor(manifest: Manifest): Promise { + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + new InMemoryFileAdapter(), + new InMemoryManifestRepository(manifest), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + await useCase.execute({ projectRoot: "/wherever", force: true }); + return logger.warnMessages; + } + + it("names no cache for a host whose profile declares no cache directory (copilot)", async () => { + const warnings = await warningsFor( + seedRegistrations( + "copilot", + [{ alias: "aidd-framework", hostName: "aidd-framework" }], + ["aidd-context@aidd-framework"] + ) + ); + + expect(warnings).toStrictEqual([ + "copilot: registration left in place, the copilot CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s).", + ]); + }); + + it("names no cache for a tool that drives no native CLI (cursor)", async () => { + const warnings = await warningsFor( + seedRegistrations("cursor", [{ alias: "aidd-framework", hostName: "aidd-framework" }], []) + ); + + expect(warnings).toStrictEqual([ + "cursor: registration left in place, the cursor CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 0 plugin ref(s).", + ]); + }); + + it("names no cache when the binary registered no marketplace", async () => { + const warnings = await warningsFor( + seedRegistrations("codex", [], ["aidd-context@aidd-framework"]) + ); + + expect(warnings).toStrictEqual([ + "codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 0 marketplace(s) and 1 plugin ref(s).", + ]); + }); + + it("names every surviving cache, one path per marketplace", async () => { + const warnings = await warningsFor( + seedRegistrations( + "codex", + [ + { alias: "mkt-a", hostName: "mkt-a" }, + { alias: "mkt-b", hostName: "mkt-b" }, + ], + [] + ) + ); + + expect(warnings).toStrictEqual([ + `codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 2 marketplace(s) and 0 plugin ref(s). Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}.`, + ]); + }); + }); + + describe("a plugin's own files", () => { + it("leaves a project-scope plugin's files alone, this run owning no project", async () => { + const fs = new RecordingFileAdapter(); + const projectFile = join("/wherever", "skills", "x.md"); + fs.setFile(projectFile, "x"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "aidd-context", + "1.0.0", + { kind: "local", path: "/whatever" }, + false, + "project" + ).withFiles(new Map([["skills/x.md", "hash"]])) + ); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(manifest), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.has(projectFile)).toBe(true); + expect(fs.order).not.toContain(`deleteFile:${projectFile}`); + }); + }); + + describe("the cache/ shell around the whitelist", () => { + it("leaves cache/ and everything under it in place, naming each, once cache/ resolves outside userConfigDir()", async () => { + const fs = new RecordingFileAdapter(); + const cacheDir = join(USER_CONFIG_DIR, "cache"); + fs.setFile(join(cacheDir, "built", "1.0.0", "aidd-framework", "x"), "1"); + fs.setSymlink(cacheDir, "/elsewhere/cache"); + 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.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/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}`, + ]); + }); + + it("keeps the shell while something this whitelist never named still lives in it", async () => { + const fs = new RecordingFileAdapter(); + const other = join(USER_CONFIG_DIR, "cache", "other.json"); + fs.setFile(other, "{}"); + 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.has(other)).toBe(true); + expect(logger.infoMessages).toStrictEqual( + WHITELIST_PURGE_MESSAGES.filter((m) => !m.startsWith("user scope: cache purged")) + ); + }); }); }); diff --git a/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts b/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts index 5c68a2241..aa0a6fcf4 100644 --- a/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts +++ b/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; 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 { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; import { DoctorRegistrationUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; @@ -255,6 +256,78 @@ describe("DoctorRegistrationUseCase — marketplace source conflicts", () => { expect(await issuesFor(hostReader, { fs })).toEqual([]); }); + it("reports nothing, and does not throw, when no reader exists for the tool's marketplace registry", async () => { + const fs = new InMemoryFileAdapter({ + [`${expectedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ name: NAME, plugins: [] }), + }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const useCase = new DoctorRegistrationUseCase( + fs, + registry, + new Map(), + new Map(), + new Map(), + () => "/user-cache", + fakeVersion("1.0.0") + ); + + await expect( + useCase.execute({ manifest, projectRoot: PROJECT_ROOT, allowedIds: null }) + ).resolves.toStrictEqual([]); + }); + + it("reports nothing for a tool whose profile declares no marketplace registry, whatever a reader would answer", async () => { + const codexCatalog = ".agents/plugins/marketplace.json"; + const fs = new InMemoryFileAdapter({ + [`${resolve(builtMarketplaceDir(PROJECT_ROOT, NAME, "codex"))}/${codexCatalog}`]: + JSON.stringify({ name: NAME, plugins: [{ name: "sample-plugin" }] }), + [`/other/src/${codexCatalog}`]: JSON.stringify({ + name: NAME, + plugins: [{ name: "different-plugin" }], + }), + }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, "/other/src"]]), + }); + const useCase = new DoctorRegistrationUseCase( + fs, + registry, + new Map(), + new Map(), + new Map([["codex", hostReader]]), + () => "/user-cache", + fakeVersion("1.0.0") + ); + + const issues = await useCase.execute({ manifest, projectRoot: PROJECT_ROOT, allowedIds: null }); + + expect(issues).toStrictEqual([]); + }); + it("reads the host registry once per marketplace, the same cadence the sync-time guard uses — not once per tool, reused across every marketplace that tool has", async () => { const SECOND_NAME = "probe-mkt-2"; const hostReader = new FakeHostMarketplaceRegistryReader({ @@ -371,10 +444,13 @@ describe("DoctorRegistrationUseCase — user-scope marketplace source drift", () const issues = await driftIssuesFor(hostReader); - expect(issues).toHaveLength(1); - expect(issues[0]?.severity).toBe("warning"); - expect(issues[0]?.message).toContain(projectCache); - expect(issues[0]?.fix).toContain("aidd sync"); + expect(issues).toStrictEqual([ + { + severity: "warning", + message: `claude's marketplace registry (${REGISTRY_LOCATION}) still carries '${NAME}' from this project's own pre-migration cache (${projectCache})`, + fix: "Run `aidd sync` to move it to the shared, machine-scope source.", + }, + ]); }); it("warns naming `aidd sync` when the host still points at another project's pre-migration cache", async () => { diff --git a/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts b/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts index a8e0bc081..93e15f3a1 100644 --- a/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts @@ -140,6 +140,36 @@ describe("DoctorUseCase — plugin integrity", () => { }); }); + describe("when narrowed to a set of tools", () => { + it("ignores the plugins of a tool outside the set", async () => { + const fs = makeFs(false, EXPECTED_HASH); + const useCase = new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)); + + const issues = await useCase.execute({ + manifest: makeManifest(EXPECTED_HASH), + projectRoot: "/proj", + allowedIds: new Set(["cursor"]), + }); + + expect(issues).toStrictEqual([]); + }); + + it("still checks the plugins of a tool inside the set", async () => { + const fs = makeFs(false, EXPECTED_HASH); + const useCase = new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)); + + const issues = await useCase.execute({ + manifest: makeManifest(EXPECTED_HASH), + projectRoot: "/proj", + allowedIds: new Set(["claude"]), + }); + + expect(issues).toStrictEqual([ + { toolId: "claude", pluginName: "my-plugin", issue: "missing", filePath: PLUGIN_FILE }, + ]); + }); + }); + describe("when a user-scope plugin was never installed on this machine", () => { it("reports one not-installed-on-machine issue, not one 'missing' issue per file", async () => { const manifest = Manifest.create(); 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 d8bb2019a..43f2dcb4d 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -7,6 +7,7 @@ import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; import "../../../../src/contexts/tools/domain/profiles/claude/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/vscode/profile.js"; import type { HostPluginRegistryReader, HostPluginRegistryReading, @@ -18,17 +19,24 @@ import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-ma const PROJECT_ROOT = "/project"; const LOCAL_SETTINGS = `${PROJECT_ROOT}/.claude/settings.local.json`; +const UNDECLARED = { + severity: "warning", + message: "claude no longer declares marketplace 'aidd-framework'", + fix: "Run `aidd marketplace refresh` to write it back to .claude/settings.local.json.", +}; + +function settingsDeclaring(names: string[]): string { + const entries = Object.fromEntries(names.map((name) => [name, { source: {} }])); + return JSON.stringify({ extraKnownMarketplaces: entries }); +} async function issuesFor( - registered: string[] | null, - toolId: ToolId = "claude", - toolInstalled = true + settings: string | null, + options: { toolId?: ToolId; toolInstalled?: boolean; allowedIds?: Set | null } = {} ) { + const { toolId = "claude", toolInstalled = true, allowedIds = null } = options; const fs = new InMemoryFileAdapter(); - if (registered !== null) { - const entries = Object.fromEntries(registered.map((name) => [name, { source: {} }])); - await fs.writeFile(LOCAL_SETTINGS, JSON.stringify({ extraKnownMarketplaces: entries })); - } + if (settings !== null) await fs.writeFile(LOCAL_SETTINGS, settings); const registry = new InMemoryMarketplaceRegistry(); await registry.save( PROJECT_ROOT, @@ -55,42 +63,66 @@ async function issuesFor( ).execute({ manifest, projectRoot: PROJECT_ROOT, - allowedIds: null, + allowedIds, }); } describe("DoctorRegistrationUseCase", () => { it("says nothing when the tool still declares the marketplace", async () => { - expect(await issuesFor(["aidd-framework"])).toEqual([]); + expect(await issuesFor(settingsDeclaring(["aidd-framework"]))).toStrictEqual([]); + }); + + it("accepts the array form of the declaration", async () => { + const settings = JSON.stringify({ extraKnownMarketplaces: ["aidd-framework"] }); + expect(await issuesFor(settings)).toStrictEqual([]); }); it("reports the marketplace the file no longer declares", async () => { - const issues = await issuesFor([]); - expect(issues).toHaveLength(1); - expect(issues[0].message).toContain("aidd-framework"); - expect(issues[0].fix).toContain(".claude/settings.local.json"); + expect(await issuesFor(settingsDeclaring([]))).toStrictEqual([UNDECLARED]); }); it("reports it when the whole file is gone — nothing else would notice", async () => { - const issues = await issuesFor(null); - expect(issues).toHaveLength(1); - expect(issues[0].severity).toBe("warning"); + expect(await issuesFor(null)).toStrictEqual([UNDECLARED]); + }); + + it("reports it when the file carries no declarations key at all", async () => { + expect(await issuesFor("{}")).toStrictEqual([UNDECLARED]); + }); + + it("reports it, without throwing, when the file holds a JSON null", async () => { + await expect(issuesFor("null")).resolves.toStrictEqual([UNDECLARED]); }); // The registration is written by the tool itself, so it cannot exist while the tool does not: // reporting it missing would be reporting that something uninstalled is unconfigured. it("says nothing about a tool whose binary is out of reach", async () => { - expect(await issuesFor(null, "claude", false)).toEqual([]); + expect(await issuesFor(null, { toolInstalled: false })).toStrictEqual([]); }); it("stays silent for a tool that keeps its registrations in a tracked file", async () => { - expect(await issuesFor(null, "cursor")).toEqual([]); + expect(await issuesFor(null, { toolId: "cursor" })).toStrictEqual([]); }); // Copilot declares no place at all rather than a path, and a guard rejecting only `undefined` // lets `null` reach `join(root, null)`, which throws and takes `plugin doctor` down. it("stays silent, and does not throw, for a tool that declares no place at all", async () => { - await expect(issuesFor(null, "copilot")).resolves.toEqual([]); + await expect(issuesFor(null, { toolId: "copilot" })).resolves.toStrictEqual([]); + }); + + it("stays silent, and does not throw, for an IDE tool", async () => { + await expect(issuesFor(null, { toolId: "vscode" })).resolves.toStrictEqual([]); + }); + + describe("narrowed to a set of tools", () => { + it("ignores a tool outside the set", async () => { + expect(await issuesFor(null, { allowedIds: new Set(["cursor"]) })).toStrictEqual([]); + }); + + it("still reports a tool inside the set", async () => { + expect(await issuesFor(null, { allowedIds: new Set(["claude"]) })).toStrictEqual([ + UNDECLARED, + ]); + }); }); }); @@ -109,11 +141,11 @@ function manifestWithNativeRegistrations( return manifest; } -function manifestWithPlugin(marketplace?: string): Manifest { +function manifestWithPlugin(marketplace?: string, toolId: AiToolId = "claude"): Manifest { const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); + manifest.addTool(toolId, "test", []); manifest.addPlugin( - "claude", + toolId, InstalledPlugin.fromMetadata( marketplace === undefined ? "hand-copied" : "aidd-context", "1.0.0", @@ -190,11 +222,16 @@ describe("DoctorRegistrationUseCase — native registrations against the host's expect(issues[0].fix).toContain("aidd framework install --tool claude"); }); - it("reports an info line, never an error, when nothing here can read the registry", async () => { + it("names the registry nobody has measured, never an error, when nothing here can read it", async () => { const issues = await nativeIssuesFor(manifestWithNativeRegistrations([REF]), "unreachable"); - expect(issues).toHaveLength(1); - expect(issues[0].severity).toBe("info"); + expect(issues).toStrictEqual([ + { + severity: "info", + message: "claude keeps a plugin registry, and nothing here has established its shape", + fix: "The plugin does not load until claude's own CLI has run and answered this.", + }, + ]); }); it("reports an info line when the registry file exists but could not be read", async () => { @@ -235,13 +272,28 @@ describe("DoctorRegistrationUseCase — native registrations against the host's expect(issues[0].message).toContain(REF); }); - it("is unanswerable, not an error, for a fallback plugin recording no marketplace", async () => { + it("names the plugin whose marketplace was never recorded, as unanswerable rather than an error", async () => { const issues = await nativeIssuesFor(manifestWithPlugin(undefined), { location: REGISTRY_LOCATION, refs: new Map(), }); - expect(issues).toHaveLength(1); - expect(issues[0].severity).toBe("info"); + expect(issues).toStrictEqual([ + { + severity: "info", + message: + "AIDD records no marketplace for hand-copied (claude), so its registry cannot be asked", + fix: "claude will not load it until a marketplace is recorded for it.", + }, + ]); + }); + + it("says nothing for a tool whose plugins are enabled by a file this CLI writes", async () => { + const issues = await nativeIssuesFor(manifestWithPlugin("aidd-framework", "cursor"), { + location: REGISTRY_LOCATION, + refs: new Map(), + }); + + expect(issues).toStrictEqual([]); }); }); diff --git a/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts index 4820b39a1..61426a1e4 100644 --- a/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts @@ -4,11 +4,13 @@ import { extractAtReferences, extractMarkdownLinkTargets, } from "../../../../src/contexts/framework/domain/formats/markdown-references.js"; +import type { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildDoctorUseCase, buildUnitDeps, initAndInstall, + installTool, } from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -240,4 +242,63 @@ describe("doctor", () => { expect(mergeIssues).toHaveLength(0); }); }); + + describe("tool health", () => { + async function installedBothCategories() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest missing"); + return { deps, manifest }; + } + + function healthOf(manifest: Manifest, toolId: ToolId) { + return { + toolId, + fileCount: manifest.getToolFiles(toolId).length, + mergeFileCount: manifest.getMergeFiles(toolId).length, + }; + } + + it("reports one entry per installed tool with its file and merge-file counts", async () => { + const { deps, manifest } = await installedBothCategories(); + + const report = await buildDoctorUseCase(deps).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.toolHealth).toStrictEqual([ + healthOf(manifest, "claude"), + healthOf(manifest, "vscode"), + ]); + }); + + it("narrows the entries to the requested category", async () => { + const { deps, manifest } = await installedBothCategories(); + + const report = await buildDoctorUseCase(deps).execute({ + projectRoot: PROJECT_ROOT, + category: "ide", + }); + + expect(report.toolHealth).toStrictEqual([healthOf(manifest, "vscode")]); + }); + }); + + describe("narrowed to a category", () => { + it("does not look for orphaned directories", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await deps.fs.writeFile( + join(PROJECT_ROOT, ".cursor", "commands", "plan.md"), + "---\nname: aidd:03:plan\ndescription: Plan feature\n---\nContent here.\n" + ); + + const report = await buildDoctorUseCase(deps).execute({ + projectRoot: PROJECT_ROOT, + category: "ai", + }); + + expect(report.issues).toStrictEqual([]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-layout-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-layout-use-case.unit.test.ts new file mode 100644 index 000000000..c1ac81415 --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-layout-use-case.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { DoctorLayoutUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-layout-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeAuthReader } from "../../../../helpers/ports/fake-auth-reader.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const CLAUDE_COMMAND = ".claude/commands/plan.md"; +const SIGNAL = "---\nname: aidd:01:plan\ndescription: Plan\n---\n"; +const hasher = new DeterministicHasher(); + +function manifestTrackingClaude(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", [ + new InstallationFile({ + relativePath: CLAUDE_COMMAND, + content: SIGNAL, + hash: hasher.hash(SIGNAL), + }), + ]); + return manifest; +} + +async function issuesFor(onDisk: Record, authReader?: FakeAuthReader) { + const files = Object.fromEntries( + Object.entries(onDisk).map(([relativePath, content]) => [ + `${PROJECT_ROOT}/${relativePath}`, + content, + ]) + ); + return new DoctorLayoutUseCase(new InMemoryFileAdapter(files, hasher), authReader).execute({ + manifest: manifestTrackingClaude(), + projectRoot: PROJECT_ROOT, + }); +} + +describe("DoctorLayoutUseCase", () => { + describe("orphaned tool directories", () => { + it("warns about a tool directory holding aidd files the manifest does not track", async () => { + const issues = await issuesFor({ + [CLAUDE_COMMAND]: SIGNAL, + ".cursor/commands/plan.md": SIGNAL, + }); + + expect(issues).toStrictEqual([ + { + severity: "warning", + message: "Orphaned directory: .cursor/ (not tracked in manifest)", + fix: "Remove the directory manually, or run `aidd install ` to track it.", + }, + ]); + }); + + it("says nothing about a tracked tool directory holding aidd files", async () => { + expect(await issuesFor({ [CLAUDE_COMMAND]: SIGNAL })).toStrictEqual([]); + }); + }); + + describe("authentication", () => { + it("reports an info line when no token resolves", async () => { + const issues = await issuesFor({}, new FakeAuthReader(null)); + + expect(issues).toStrictEqual([ + { severity: "info", message: "Not authenticated", fix: "Run aidd auth login" }, + ]); + }); + + it("says nothing when a token resolves", async () => { + expect(await issuesFor({}, new FakeAuthReader("token"))).toStrictEqual([]); + }); + + it("says nothing when nothing here can read a token", async () => { + expect(await issuesFor({})).toStrictEqual([]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-merge-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-merge-files-use-case.unit.test.ts new file mode 100644 index 000000000..ced279fed --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-merge-files-use-case.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { DoctorMergeFilesUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-merge-files-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const MERGE_PATH = ".vscode/settings.json"; +const KEY = "editor.formatOnSave"; +const hasher = new DeterministicHasher(); + +const MISSING_FILE = { + severity: "error", + message: `Missing merge file: ${MERGE_PATH}`, + fix: "Run `aidd sync --force` to reinstall tracked files.", +}; +const MISSING_KEY = { + severity: "error", + message: `Missing key in ${MERGE_PATH} > ${KEY}`, + fix: "Run `aidd sync --force` to restore managed keys.", +}; +const MODIFIED_KEY = { + severity: "warning", + message: `Modified key in ${MERGE_PATH} > ${KEY}`, + fix: "Run `aidd sync --force` to restore the original value.", +}; + +function manifestMerging(toolId: ToolId): Manifest { + const manifest = Manifest.create(); + manifest.addTool( + toolId, + "1.0.0", + [], + [{ relativePath: MERGE_PATH, sectionKey: null, entries: { [KEY]: hasher.hash("true") } }] + ); + return manifest; +} + +async function issuesFor( + onDisk: string | null, + allowedIds: Set | null = null, + toolId: ToolId = "vscode" +) { + const fs = new InMemoryFileAdapter( + onDisk === null ? {} : { [`${PROJECT_ROOT}/${MERGE_PATH}`]: onDisk }, + hasher + ); + return new DoctorMergeFilesUseCase(fs, hasher).execute({ + manifest: manifestMerging(toolId), + projectRoot: PROJECT_ROOT, + allowedIds, + }); +} + +describe("DoctorMergeFilesUseCase", () => { + describe("a merge file", () => { + it("reports nothing when every managed key holds its recorded value", async () => { + expect(await issuesFor(`{ "${KEY}": true }`)).toStrictEqual([]); + }); + + it("reports one error naming the file when it is gone from disk", async () => { + expect(await issuesFor(null)).toStrictEqual([MISSING_FILE]); + }); + + it("reports one error naming the key when the file no longer holds it", async () => { + expect(await issuesFor("{}")).toStrictEqual([MISSING_KEY]); + }); + + it("reports one warning naming the key when its value changed", async () => { + expect(await issuesFor(`{ "${KEY}": false }`)).toStrictEqual([MODIFIED_KEY]); + }); + }); + + describe("narrowed to a set of tools", () => { + it("ignores a missing merge file of a tool outside the set", async () => { + expect(await issuesFor(null, new Set(["claude"]))).toStrictEqual([]); + }); + + it("still reports a missing merge file of a tool inside the set", async () => { + expect(await issuesFor(null, new Set(["vscode"]))).toStrictEqual([MISSING_FILE]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-references-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-references-use-case.unit.test.ts new file mode 100644 index 000000000..1025484ea --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-references-use-case.unit.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { DoctorReferencesUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-references-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 { ToolId } from "../../../../../src/kernel/tool.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const DOC = ".claude/rules/doc.md"; +const PLUGIN_DOC = ".claude/plugins/sample/agents/reviewer.md"; +const hasher = new DeterministicHasher(); + +function brokenIn(relativePath: string, ref: string) { + return { + severity: "warning", + message: `Broken reference in ${relativePath}: "${ref}" not found on disk`, + fix: `Restore the missing file or remove the reference in ${relativePath}`, + }; +} + +function manifestWithPluginDoc(content: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_DOC]: hasher.hash(content).value }, + scope: "project", + }) + ); + return manifest; +} + +async function issuesFor( + onDisk: Record, + options: { + trackedFiles?: { relativePath: string; toolId: ToolId | null }[]; + manifest?: Manifest; + allowedIds?: Set | null; + } = {} +) { + const files = Object.fromEntries( + Object.entries(onDisk).map(([relativePath, content]) => [ + `${PROJECT_ROOT}/${relativePath}`, + content, + ]) + ); + return new DoctorReferencesUseCase(new InMemoryFileAdapter(files, hasher)).execute({ + manifest: options.manifest ?? Manifest.create(), + projectRoot: PROJECT_ROOT, + allowedIds: options.allowedIds ?? null, + trackedFiles: options.trackedFiles ?? [], + }); +} + +const TRACKED_DOC = [{ relativePath: DOC, toolId: "claude" as const }]; + +describe("DoctorReferencesUseCase", () => { + describe("references in a tracked markdown file", () => { + it("reports a warning for an @ reference to a file that is not on disk", async () => { + const issues = await issuesFor( + { [DOC]: "See @docs/missing.md" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([brokenIn(DOC, "docs/missing.md")]); + }); + + it("reports a warning for a markdown link resolved from the file's own directory", async () => { + const issues = await issuesFor( + { [DOC]: "[sibling](gone.md)" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([brokenIn(DOC, "gone.md")]); + }); + + it("reports nothing when every reference resolves", async () => { + const issues = await issuesFor( + { + [DOC]: "See @docs/here.md and [sibling](other.md)", + "docs/here.md": "", + ".claude/rules/other.md": "", + }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([]); + }); + + it("never looks up a directory reference", async () => { + const issues = await issuesFor( + { [DOC]: "See @docs/nowhere/ and [dir](elsewhere/)" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([]); + }); + + it("ignores a link that resolves outside the project", async () => { + const issues = await issuesFor( + { [DOC]: "[up](../../../outside.md)" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([]); + }); + + it("reports nothing for a tracked file that is not on disk", async () => { + expect(await issuesFor({}, { trackedFiles: TRACKED_DOC })).toStrictEqual([]); + }); + }); + + describe("which files are scanned", () => { + it("scans only markdown files", async () => { + const settings = ".claude/settings.json"; + const issues = await issuesFor( + { [settings]: "@docs/missing.md" }, + { trackedFiles: [{ relativePath: settings, toolId: "claude" }] } + ); + + expect(issues).toStrictEqual([]); + }); + + it("skips a file whose markdown extension is not its last one", async () => { + const backup = ".claude/rules/doc.md.bak"; + const issues = await issuesFor( + { [backup]: "@docs/missing.md" }, + { trackedFiles: [{ relativePath: backup, toolId: "claude" }] } + ); + + expect(issues).toStrictEqual([]); + }); + + it("skips a file under a tasks directory", async () => { + const task = "aidd_docs/tasks/plan.md"; + const issues = await issuesFor( + { [task]: "@docs/missing.md" }, + { trackedFiles: [{ relativePath: task, toolId: null }] } + ); + + expect(issues).toStrictEqual([]); + }); + }); + + describe("files of an installed plugin", () => { + const content = "See @docs/missing.md"; + + it("are scanned like tracked files", async () => { + const issues = await issuesFor( + { [PLUGIN_DOC]: content }, + { manifest: manifestWithPluginDoc(content) } + ); + + expect(issues).toStrictEqual([brokenIn(PLUGIN_DOC, "docs/missing.md")]); + }); + + it("are ignored when their tool is outside the narrowed set", async () => { + const issues = await issuesFor( + { [PLUGIN_DOC]: content }, + { manifest: manifestWithPluginDoc(content), allowedIds: new Set(["cursor"]) } + ); + + expect(issues).toStrictEqual([]); + }); + + it("are still scanned when their tool is inside the narrowed set", async () => { + const issues = await issuesFor( + { [PLUGIN_DOC]: content }, + { manifest: manifestWithPluginDoc(content), allowedIds: new Set(["claude"]) } + ); + + expect(issues).toStrictEqual([brokenIn(PLUGIN_DOC, "docs/missing.md")]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-tracked-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-tracked-files-use-case.unit.test.ts new file mode 100644 index 000000000..f7b5db882 --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-tracked-files-use-case.unit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { DoctorTrackedFilesUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-tracked-files-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const FILE = ".claude/rules/one.md"; +const CONTENT = "one\n"; +const hasher = new DeterministicHasher(); + +const MISSING = { + severity: "error", + message: `Missing tracked file: ${FILE}`, + fix: "Restore the file or run `aidd sync` to reinstall tracked files.", +}; +const MODIFIED = { + severity: "warning", + message: `Modified tracked file: ${FILE}`, + fix: "Run `aidd sync --force` to revert to the framework version.", +}; + +function manifestTracking(toolId: ToolId): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", [ + new InstallationFile({ relativePath: FILE, content: CONTENT, hash: hasher.hash(CONTENT) }), + ]); + return manifest; +} + +async function issuesFor( + onDisk: string | null, + allowedIds: Set | null = null, + toolId: ToolId = "claude" +) { + const fs = new InMemoryFileAdapter( + onDisk === null ? {} : { [`${PROJECT_ROOT}/${FILE}`]: onDisk }, + hasher + ); + return new DoctorTrackedFilesUseCase(fs).execute({ + manifest: manifestTracking(toolId), + projectRoot: PROJECT_ROOT, + allowedIds, + }); +} + +describe("DoctorTrackedFilesUseCase", () => { + describe("a tracked file", () => { + it("reports nothing when the disk matches the manifest", async () => { + expect(await issuesFor(CONTENT)).toStrictEqual([]); + }); + + it("reports one error naming the file when it is gone from disk", async () => { + expect(await issuesFor(null)).toStrictEqual([MISSING]); + }); + + it("reports one warning naming the file when its content changed", async () => { + expect(await issuesFor("edited\n")).toStrictEqual([MODIFIED]); + }); + }); + + describe("narrowed to a set of tools", () => { + it("ignores a missing file of a tool outside the set", async () => { + expect(await issuesFor(null, new Set(["cursor"]))).toStrictEqual([]); + }); + + it("ignores a modified file of a tool outside the set", async () => { + expect(await issuesFor("edited\n", new Set(["cursor"]))).toStrictEqual([]); + }); + + it("still reports a missing file of a tool inside the set", async () => { + expect(await issuesFor(null, new Set(["claude"]))).toStrictEqual([MISSING]); + }); + + it("still reports a modified file of a tool inside the set", async () => { + expect(await issuesFor("edited\n", new Set(["claude"]))).toStrictEqual([MODIFIED]); + }); + }); + + describe("collectTrackedFiles", () => { + it("pairs every tracked file with the tool that owns it", () => { + const collected = new DoctorTrackedFilesUseCase( + new InMemoryFileAdapter() + ).collectTrackedFiles(manifestTracking("claude"), null); + + expect(collected.map((f) => [f.relativePath, f.toolId])).toStrictEqual([[FILE, "claude"]]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts index 30acf33bb..677aeabc3 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts @@ -142,3 +142,107 @@ describe("MarketplaceCheckUseCase", () => { expect(result.skipped[0]?.error).toBeDefined(); }); }); + +function fixtureMarketplace(name: string): Marketplace { + return Marketplace.create({ + name, + source: { kind: "local", path: VALID_FIXTURE }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }); +} + +function installedFrom(marketplace: string, name: string): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name, + source: { kind: "github", repo: `owner/${name}` }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + marketplace, + }); +} + +async function manifestWith( + manifestRepo: InMemoryManifestRepository, + ...plugins: readonly InstalledPlugin[] +): Promise { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + for (const plugin of plugins) manifest.addPlugin("claude", plugin); + await manifestRepo.save(manifest); +} + +describe("the staleness window", () => { + it("honours a window narrower than the default", async () => { + const { useCase, registry } = await buildUseCase(); + await registry.save(PROJECT_ROOT, fixtureMarketplace("recent")); + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + await registry.updateLastFetched(PROJECT_ROOT, "recent", "project", threeDaysAgo); + + const narrowed = await useCase.execute({ projectRoot: PROJECT_ROOT, staleMaxDays: 1 }); + const byDefault = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(narrowed.stale.map((m) => m.name)).toStrictEqual(["recent"]); + expect(byDefault.stale).toStrictEqual([]); + }); +}); + +describe("what counts as removed upstream", () => { + it("reports nothing for a plugin the catalog still lists", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith(manifestRepo, installedFrom("awesome", "dev")); + await registry.save(PROJECT_ROOT, fixtureMarketplace("awesome")); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([]); + expect(result.skipped).toStrictEqual([]); + }); + + it("reports exactly the plugin the catalog dropped", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith( + manifestRepo, + installedFrom("awesome", "dev"), + installedFrom("awesome", "ghost") + ); + await registry.save(PROJECT_ROOT, fixtureMarketplace("awesome")); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([ + { marketplace: "awesome", plugin: "ghost", toolId: "claude" }, + ]); + }); + + it("never diffs a plugin installed from another marketplace against this catalog", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith(manifestRepo, installedFrom("elsewhere", "ghost")); + await registry.save(PROJECT_ROOT, fixtureMarketplace("awesome")); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([]); + }); + + it("reports nothing about installed plugins when the marketplace has no catalog to compare against", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith(manifestRepo, installedFrom("empty", "ghost")); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "empty", + source: { kind: "local", path: "/nonexistent-marketplace-dir" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([]); + expect(result.skipped).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts index 90e66e960..44c8a7d5e 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts @@ -17,10 +17,55 @@ import { DeterministicHasher } from "../../../../helpers/ports/deterministic-has 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"; -import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; +import { KeepPrompter, ScriptedPrompter } from "../../../../helpers/ports/scripted-prompter.js"; const PROJECT_ROOT = "/test-project"; +class ConfirmRecordingPrompter extends ScriptedPrompter { + readonly confirmMessages: string[] = []; + + override async confirm(message: string, defaultValue?: boolean): Promise { + this.confirmMessages.push(message); + return super.confirm(message, defaultValue); + } +} + +class SaveCountingManifestRepository extends InMemoryManifestRepository { + saves = 0; + + override async save(manifest: Manifest): Promise { + this.saves += 1; + return super.save(manifest); + } +} + +function marketplaceNamed(name: string): Marketplace { + return Marketplace.create({ + name, + source: { kind: "github", repo: `owner/${name}` }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }); +} + +function pluginFrom(marketplace: string, name: string): InstalledPlugin { + return InstalledPlugin.fromMetadata( + name, + "1.0.0", + { kind: "github", repo: `owner/${name}` }, + false, + "project", + marketplace + ); +} + +function manifestWithPlugins(...plugins: readonly InstalledPlugin[]): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + for (const plugin of plugins) manifest.addPlugin("claude", plugin); + return manifest; +} + /** Records every path `deleteFile` is called with, so a test can prove where a plugin's * file actually got deleted from without inspecting private use-case state. */ class RecordingFileAdapter extends InMemoryFileAdapter { @@ -225,4 +270,148 @@ describe("MarketplaceRemoveUseCase", () => { expect(list).toHaveLength(1); expect(list[0]?.name).toBe(FRAMEWORK_MARKETPLACE_NAME); }); + + it("refuses the reserved name with a message naming the command that does remove it", async () => { + const { useCase } = buildUseCase(); + + await expect( + useCase.execute({ + name: FRAMEWORK_MARKETPLACE_NAME, + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }) + ).rejects.toThrow( + new InvalidMarketplaceNameError( + '"aidd-framework" is shared by every project on this machine and is not removed with `aidd marketplace remove` — it is removed with the framework itself, by `aidd clean`, once machine scope lands there.' + ) + ); + }); +}); + +describe("which marketplace a removal takes out", () => { + it("removes the named marketplace, never the first one registered", async () => { + const { useCase, registry } = buildUseCase(); + await registry.save(PROJECT_ROOT, marketplaceNamed("alpha")); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 0, orphanCount: 0 }); + expect((await registry.list(PROJECT_ROOT)).map((m) => m.name)).toStrictEqual(["alpha"]); + }); + + it("reports zero orphans when the project has no manifest at all", async () => { + const { useCase, registry } = buildUseCase(); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 0, orphanCount: 0 }); + }); +}); + +describe("which plugins a removal orphans", () => { + it("removes the marketplace's own plugins and leaves another marketplace's in place", async () => { + const { useCase, registry, manifestRepo } = buildUseCase(); + await manifestRepo.save( + manifestWithPlugins(pluginFrom("awesome", "sample"), pluginFrom("elsewhere", "other")) + ); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 1, orphanCount: 1 }); + expect((await manifestRepo.load())?.getPlugins("claude").map((p) => p.name)).toStrictEqual([ + "other", + ]); + }); + + it("keeps every orphan when the person declines the cleanup, and still drops the marketplace", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: false, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 0, orphanCount: 1 }); + expect((await manifestRepo.load())?.getPlugins("claude").map((p) => p.name)).toStrictEqual([ + "sample", + ]); + expect(await registry.list(PROJECT_ROOT)).toStrictEqual([]); + }); + + it("asks once, naming how many plugins the cleanup would remove", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await manifestRepo.save( + manifestWithPlugins(pluginFrom("awesome", "sample"), pluginFrom("awesome", "second")) + ); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: false, + }); + + expect(prompter.confirmMessages).toStrictEqual([ + "Remove 2 plugin(s) installed from this marketplace?", + ]); + expect(result.removedPluginCount).toBe(2); + }); + + it("neither asks nor rewrites the manifest when nothing was installed from the marketplace", async () => { + const { registry, fs } = buildUseCase(); + const manifestRepo = new SaveCountingManifestRepository( + manifestWithPlugins(pluginFrom("elsewhere", "other")) + ); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + await useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: false }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(manifestRepo.saves).toBe(0); + }); + + it("cleans up without asking when the caller auto-confirms", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(result.removedPluginCount).toBe(1); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts index 50b87b2d7..fa8302a70 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts @@ -42,13 +42,25 @@ interface Setup { /** Skips writing at the built dir this project just "built" to, standing in for a build that * reported success but left nothing readable where its own tool profile probes. */ readonly omitRequestedCatalog?: boolean; + readonly fs?: InMemoryFileAdapter; +} + +class RealpathRefusingFileAdapter extends InMemoryFileAdapter { + constructor(private readonly refused: string) { + super(); + } + + override async realpath(path: string): Promise { + if (path === this.refused) throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + return super.realpath(path); + } } async function sync(setup: Setup = {}) { const toolId = setup.toolId ?? "claude"; const aiddName = setup.aiddName ?? "probe-mkt"; const catalogName = setup.catalogName ?? aiddName; - const fs = new InMemoryFileAdapter(); + const fs = setup.fs ?? new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); const logger = new CapturingLogger(); @@ -230,6 +242,42 @@ describe("the sync guard against a marketplace name a host already holds", () => expect(activator.addedMarketplaces).toEqual(["/built/codex"]); expect(result.errors).toEqual([]); }); + + it("names both sources, the plugin difference, the registry file and the commands to run, in the refusal", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/other/src"]]), + }); + + const { result } = await sync({ + hostReader, + requestedPluginNames: ["sample-plugin"], + registeredCatalog: { path: "/other/src", pluginNames: ["different-plugin"] }, + }); + + expect(result.errors).toStrictEqual([ + { + scope: "claude", + message: + "Marketplace 'probe-mkt' is already registered from a different catalog: /other/src differs from the one requested, /built/claude — plugins differ (+sample-plugin, -different-plugin), per /home/.claude/plugins/known_marketplaces.json. Run `claude plugin marketplace remove probe-mkt`, then `aidd sync` again to re-register it for this project.", + }, + ]); + }); + + it("registers from the built path as given when that path cannot be resolved", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/built/claude"]]), + }); + + const { result, activator } = await sync({ + hostReader, + fs: new RealpathRefusingFileAdapter("/built/claude"), + }); + + expect(result.errors).toStrictEqual([]); + expect(activator.addedMarketplaces).toStrictEqual(["/built/claude"]); + }); }); describe("when the catalog this project just built cannot be read back", () => { diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts index 1b09fdfba..db26b9401 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts @@ -184,4 +184,49 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => expect(activator.addedMarketplaces.sort()).toEqual(["/built/market-a", "/built/market-b"]); expect(activator.enabledPlugins.sort()).toEqual(["plugin-a@market-a", "plugin-b@market-b"]); }); + + it("records exactly the named marketplace and its ref on a first narrowed run", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = build(activator); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + await useCase.execute({ projectRoot: PROJECT_ROOT, marketplaceNames: ["market-b"] }); + + expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "market-b", hostName: "market-b" }], + pluginRefs: ["plugin-b@market-b"], + }); + }); + + it("drops a stale ref of either touched marketplace when narrowed to both", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifest, manifestRepo } = build(activator); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "market-b", hostName: "market-b" }, + ], + pluginRefs: ["plugin-a@market-a", "plugin-b-old@market-b"], + }); + await manifestRepo.save(manifest); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + await useCase.execute({ + projectRoot: PROJECT_ROOT, + marketplaceNames: ["market-a", "market-b"], + }); + + expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "market-b", hostName: "market-b" }, + ], + pluginRefs: ["plugin-a@market-a", "plugin-b@market-b"], + }); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts index 585b23197..d51217e9b 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts @@ -632,3 +632,66 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo expect(recorded?.marketplaces).toContainEqual({ alias: ALIAS_Y, hostName: SHARED_HOST_NAME }); }); }); + +describe("what reclaiming a dead registration says", () => { + const CATALOG_NAME = "aidd-framework-catalog"; + const RECLAIM = + "Marketplace 'aidd-framework-catalog' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back."; + + function reclaimSync(activator: FakeNativePluginActivator) { + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: CATALOG_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const logger = new CapturingLogger(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestWithPlugin(), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + return { useCase, registry, logger }; + } + + it("names the vanished directory's own registration in the one warning it gives", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const { useCase, registry, logger } = reclaimSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.warnings).toStrictEqual([RECLAIM]); + expect(logger.warnMessages).toStrictEqual([RECLAIM]); + }); + + it("warns for each reclaim step the host's CLI refused, naming the step and the reason", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + throwOnRemove: true, + registrationState: "dead", + }); + const { useCase, registry } = reclaimSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.warnings).toStrictEqual([ + RECLAIM, + "Native plugin activation — unregister stale marketplace 'aidd-framework-catalog' skipped: marketplace remove aidd-framework-catalog failed: 'aidd-framework-catalog' is not configured or installed", + "Native plugin activation — register marketplace 'aidd-framework-catalog' skipped: marketplace is already added from a different source; remove it before adding this source", + ]); + expect(result.errors).toStrictEqual([]); + }); +}); 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 8a8b4ac8b..5287cc472 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 @@ -1,9 +1,13 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + 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 { userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.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"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; @@ -19,8 +23,8 @@ const REGISTRY_LOCATION = "/home/.claude/plugins/known_marketplaces.json"; const USER_CACHE_ROOT = "/user-cache"; const MARKETPLACE_NAME = "probe-mkt"; -function sharedPath(version: string): string { - return userBuiltMarketplaceDir(USER_CACHE_ROOT, version, MARKETPLACE_NAME, "claude"); +function sharedPath(version: string, name: string = MARKETPLACE_NAME): string { + return userBuiltMarketplaceDir(USER_CACHE_ROOT, version, name, "claude"); } async function sync(options: { @@ -28,7 +32,11 @@ async function sync(options: { registeredPath?: string; registeredVersion?: string; scope?: MarketplaceScope; + name?: string; + recreateFrameworkIfMissing?: boolean; + staleCacheFile?: string; }) { + const name = options.name ?? MARKETPLACE_NAME; const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); @@ -39,30 +47,29 @@ async function sync(options: { await registry.save( PROJECT_ROOT, Marketplace.create({ - name: MARKETPLACE_NAME, + name, source: { kind: "local", path: "/source" }, scope: options.scope ?? "user", addedAt: "2026-01-01T00:00:00Z", }) ); - const builtDir = sharedPath(options.requestedVersion); + const builtDir = sharedPath(options.requestedVersion, name); await fs.writeFile( `${builtDir}/.claude-plugin/marketplace.json`, - JSON.stringify({ name: MARKETPLACE_NAME, version: options.requestedVersion, plugins: [] }) + JSON.stringify({ name, version: options.requestedVersion, plugins: [] }) ); if (options.registeredPath !== undefined) { await fs.writeFile( `${options.registeredPath}/.claude-plugin/marketplace.json`, - JSON.stringify({ name: MARKETPLACE_NAME, version: options.registeredVersion, plugins: [] }) + JSON.stringify({ name, version: options.registeredVersion, plugins: [] }) ); } + if (options.staleCacheFile !== undefined) await fs.writeFile(options.staleCacheFile, "stale"); const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); const hostReader = new FakeHostMarketplaceRegistryReader({ location: REGISTRY_LOCATION, entries: - options.registeredPath === undefined - ? new Map() - : new Map([[MARKETPLACE_NAME, options.registeredPath]]), + options.registeredPath === undefined ? new Map() : new Map([[name, options.registeredPath]]), }); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -75,8 +82,11 @@ async function sync(options: { new Map([["claude", hostReader]]), () => USER_CACHE_ROOT ); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - return { result, activator, logger }; + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: options.recreateFrameworkIfMissing, + }); + return { result, activator, logger, manifestRepo, fs }; } describe("the sync write path refuses to roll a host back to an older aidd-framework build", () => { @@ -133,4 +143,54 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame expect(result.errors).toEqual([]); expect(result.warnings).toEqual([]); }); + + it("warns with the whole message: the registry, both versions and the command that fixes it", async () => { + const { result, logger } = await sync({ + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0"), + registeredVersion: "2.0.0", + }); + + const refusal = + "claude's marketplace registry (/home/.claude/plugins/known_marketplaces.json) already carries a newer aidd-framework build, 2.0.0, than this run's own 1.0.0 — not registering this run's build over it. Run `aidd update` to bring this project's CLI to at least the version the host already follows."; + expect(result.warnings).toStrictEqual([refusal]); + expect(logger.warnMessages).toStrictEqual([refusal]); + }); + + it("records the marketplace it left on the newer build as registered under the host's own name", async () => { + const { manifestRepo } = await sync({ + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0"), + registeredVersion: "2.0.0", + }); + + expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: MARKETPLACE_NAME, hostName: MARKETPLACE_NAME }], + pluginRefs: [], + }); + }); + + it("still purges this project's own pre-migration cache after a refused rollback: the host is on a build, not on that cache", async () => { + const staleCacheFile = join( + PROJECT_ROOT, + BUILT_CACHE_SUBDIR, + FRAMEWORK_MARKETPLACE_NAME, + "claude", + "agents", + "some-agent.md" + ); + const { result, fs } = await sync({ + name: FRAMEWORK_MARKETPLACE_NAME, + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0", FRAMEWORK_MARKETPLACE_NAME), + registeredVersion: "2.0.0", + recreateFrameworkIfMissing: true, + staleCacheFile, + }); + + expect(result.warnings).toHaveLength(1); + expect(result.errors).toStrictEqual([]); + expect(fs.has(staleCacheFile)).toBe(false); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts index 129465d40..f0885355d 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts @@ -2,13 +2,18 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { MarketplaceRegisterFrameworkUseCase } from "../../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { + type MarketplaceRegisterFramework, + type MarketplaceRegisterFrameworkOptions, + MarketplaceRegisterFrameworkUseCase, +} from "../../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; import { FRAMEWORK_MARKETPLACE_NAME, Marketplace, } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { DoctorRegistrationUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.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 type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; @@ -46,6 +51,12 @@ function catalogFixture(builtDir: string): Record { }; } +function manifestWithClaude(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + return manifest; +} + function projectScopeEntry(source: Marketplace["source"]): Marketplace { return Marketplace.create({ name: FRAMEWORK_MARKETPLACE_NAME, @@ -167,6 +178,42 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n expect(activator.addedMarketplaces).toEqual([CODEX_BUILT_DIR]); }); + it("warns with the reclaim message naming the tool that refuses the overwrite", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkAtUserScope()); + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + const activator = new FakeNativePluginActivator({ + available: true, + enablesPlugins: false, + conflictOnAdd: true, + }); + const fs = new InMemoryFileAdapter({ + [`${CODEX_BUILT_DIR}/.agents/plugins/marketplace.json`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const logger = new CapturingLogger(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + logger, + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(() => CODEX_BUILT_DIR) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const reclaim = + "Marketplace 'aidd-framework' is registered from a different source and codex refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back."; + expect(result.warnings).toStrictEqual([reclaim]); + expect(logger.warnMessages).toStrictEqual([reclaim]); + }); + it("never reclaims an arbitrary, non-reserved marketplace name this way — only the framework's own", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save( @@ -379,6 +426,94 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi expect(roots).toContain(PROJECT_ROOT); expect(roots).not.toContain("/gone-project"); }); + + function hostOnForeignCache(): { + fs: InMemoryFileAdapter; + hostReader: FakeHostMarketplaceRegistryReader; + registry: InMemoryMarketplaceRegistry; + manifest: Manifest; + activator: FakeNativePluginActivator; + } { + const foreignProjectCache = builtMarketplaceDir( + "/other-project", + FRAMEWORK_MARKETPLACE_NAME, + "claude" + ); + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const fs = new InMemoryFileAdapter({ + [`${sharedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: CURRENT_VERSION, + plugins: [], + }), + [`${foreignProjectCache}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[FRAMEWORK_MARKETPLACE_NAME, foreignProjectCache]]), + }); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + return { fs, hostReader, registry, manifest, activator }; + } + + it("still repoints the host when this run has nowhere to record references at all", async () => { + const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + }); + + it("still repoints the host, recording nothing, when the version to record under is unknown", async () => { + const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); + const added: Array<{ version: string; projectRoot: string }> = []; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT, + undefined, + noSourceReferences(added) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + expect(added).toStrictEqual([]); + }); }); describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migration cache", () => { @@ -654,6 +789,311 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr expect(activator.cacheStillPresentAtAdd).toEqual([true]); expect(fs.has(OLD_CACHE_FILE)).toBe(false); }); + + function registryWithSharedFramework(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + return registry; + } + + function staleCacheAndCatalog(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + [OLD_CACHE_FILE]: "stale content", + ...catalogFixture(CLAUDE_BUILT_DIR), + }); + } + + function recordingRegister( + calls: MarketplaceRegisterFrameworkOptions[] + ): MarketplaceRegisterFramework { + return { + execute: async (options) => { + calls.push(options); + return { registered: true, scope: "user" }; + }, + }; + } + + it("warns with the whole message when a requested tool's binary is off PATH", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ [OLD_CACHE_FILE]: "stale content" }), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", new FakeNativePluginActivator({ available: false })]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.warnMessages).toStrictEqual([ + "claude CLI not found on PATH — skipping native plugin activation.", + "This project's own pre-migration framework cache kept: a requested tool's CLI was not on PATH this run, so its own registration may still point at it — run `aidd sync` again once every tool's CLI is on PATH.", + ]); + }); + + it("warns with the whole message when a requested tool's build failed", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ [OLD_CACHE_FILE]: "stale content" }), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", new FakeNativePluginActivator({ available: true })]]), + { + execute: async () => { + throw new Error("translator refused the source"); + }, + }, + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.warnMessages).toStrictEqual([ + "Native plugin activation — build 'aidd-framework' for claude skipped: translator refused the source", + "Native plugin activation — build 'aidd-framework' for claude skipped: translator refused the source", + "This project's own pre-migration framework cache kept: a requested tool's build failed this run, so its own registration may still point at it — fix the build warning above, then run `aidd sync` again.", + ]); + }); + + it("keeps the stale built tree when one of two requested tools' builds failed", async () => { + const registry = projectWithMigratableEntry(); + const manifest = manifestWithClaude(); + manifest.addTool("codex", "test", []); + const fs = staleCacheAndCatalog(); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const codexBuildFails: EnsureBuiltMarketplace = { + execute: async (options) => { + if (options.target === "codex") throw new Error("translator refused the source"); + return { builtDir: CLAUDE_BUILT_DIR, version: "test", rebuilt: true }; + }, + }; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", activator], + ["codex", activator], + ]), + codexBuildFails, + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("names the candidate and the root it escaped when a symlink resolves it outside the project", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const fs = new InMemoryFileAdapter({ + "/etc/evil/still-here.txt": "not aidd's to delete", + ...catalogFixture(CLAUDE_BUILT_DIR), + }); + fs.setSymlink(OLD_CACHE_DIR, "/etc/evil"); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.warnMessages).toStrictEqual([ + `This project's own pre-migration framework cache does not resolve inside ${PROJECT_ROOT}; left in place: ${OLD_CACHE_DIR}`, + ]); + }); + + it("says which tree it purged", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const useCase = new MarketplaceSyncSettingsUseCase( + staleCacheAndCatalog(), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.infoMessages).toStrictEqual([ + `This project's own pre-migration framework cache purged: ${OLD_CACHE_DIR}`, + ]); + }); + + it("leaves the stale built tree alone at user scope, where no project file is this run's to touch", async () => { + const fs = staleCacheAndCatalog(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + registryWithSharedFramework(), + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + scope: "user", + }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("leaves the stale built tree alone unless the caller asked to recreate the framework entry", async () => { + const fs = staleCacheAndCatalog(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + registryWithSharedFramework(), + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("leaves the stale built tree alone while the framework entry is still at project scope", async () => { + const fs = staleCacheAndCatalog(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + projectWithMigratableEntry(), + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("never re-registers the framework when it is already shared behind another project-scope marketplace", async () => { + const registry = registryWithSharedFramework(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-plugins", + source: { kind: "local", path: "/other/plugins" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const calls: MarketplaceRegisterFrameworkOptions[] = []; + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + recordingRegister(calls) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(calls).toStrictEqual([]); + }); + + it("neither registers nor fails when other marketplaces exist and no framework entry does", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-plugins", + source: { kind: "local", path: "/other/plugins" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const calls: MarketplaceRegisterFrameworkOptions[] = []; + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + recordingRegister(calls) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(calls).toStrictEqual([]); + expect((await registry.list(PROJECT_ROOT)).map((m) => m.name)).toStrictEqual(["other-plugins"]); + }); }); describe("MarketplaceSyncSettingsUseCase + DoctorRegistrationUseCase — the full migration cycle", () => { diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts new file mode 100644 index 000000000..88266e683 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts @@ -0,0 +1,356 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/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"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import type { NativeRegistrations } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.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 PROJECT_ROOT = "/test-project"; +const MARKETPLACE = "aidd-framework"; +const PLUGIN = "aidd-telemetry"; +const SETTINGS_PATH = ".claude/settings.json"; +const SETTINGS_ABSOLUTE = resolve(PROJECT_ROOT, SETTINGS_PATH); + +class SaveCountingManifestRepository extends InMemoryManifestRepository { + saves = 0; + + override async save(manifest: Manifest): Promise { + this.saves += 1; + return super.save(manifest); + } +} + +interface Setup { + readonly toolIds?: readonly ToolId[]; + readonly marketplaceNames?: readonly string[]; + readonly failingBuilds?: readonly string[]; + readonly activator?: FakeNativePluginActivator; + readonly withPlugin?: boolean; + readonly trackedSettings?: string; + readonly settingsOnDisk?: string; + readonly otherTrackedFiles?: readonly string[]; + readonly existingRegistrations?: NativeRegistrations; +} + +function catalogPath(marketplace: string, target: string): string { + const relative = + target === "codex" ? ".agents/plugins/marketplace.json" : ".claude-plugin/marketplace.json"; + return `/built/${marketplace}/${target}/${relative}`; +} + +function buildPerMarketplace(failing: readonly string[]): EnsureBuiltMarketplace { + return { + execute: async (options) => { + if (failing.includes(options.marketplace.name)) throw new Error("no catalog at that source"); + return { + builtDir: `/built/${options.marketplace.name}/${options.target}`, + version: "test", + rebuilt: true, + }; + }, + }; +} + +async function build(setup: Setup = {}) { + const hasher = new DeterministicHasher(); + const names = setup.marketplaceNames ?? [MARKETPLACE]; + const toolIds = setup.toolIds ?? ["claude"]; + const fs = new InMemoryFileAdapter({}, hasher); + for (const name of names) { + for (const toolId of toolIds) { + fs.setFile( + catalogPath(name, toolId), + JSON.stringify({ name, version: "1.0.0", plugins: [{ name: PLUGIN }] }) + ); + } + } + if (setup.settingsOnDisk !== undefined) fs.setFile(SETTINGS_ABSOLUTE, setup.settingsOnDisk); + const manifest = Manifest.create(); + const tracked = (setup.otherTrackedFiles ?? []).map( + (relativePath) => + new InstallationFile({ relativePath, content: "# tracked", hash: hasher.hash("# tracked") }) + ); + if (setup.trackedSettings !== undefined) { + tracked.push( + new InstallationFile({ + relativePath: SETTINGS_PATH, + content: setup.trackedSettings, + hash: hasher.hash(setup.trackedSettings), + }) + ); + } + for (const toolId of toolIds) + manifest.addTool(toolId, "test", toolId === "claude" ? tracked : []); + if (setup.withPlugin === true) { + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + PLUGIN, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + MARKETPLACE + ) + ); + } + if (setup.existingRegistrations !== undefined) { + manifest.setNativeRegistrations("claude", setup.existingRegistrations); + } + const manifestRepo = new SaveCountingManifestRepository(manifest); + const registry = new InMemoryMarketplaceRegistry(); + for (const name of names) { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "github", repo: `ai-driven-dev/${name}` }, + scope: "project", + addedAt: "2026-09-02T00:00:00Z", + }) + ); + } + const activator = + setup.activator ?? new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map(toolIds.map((toolId) => [toolId, activator])), + buildPerMarketplace(setup.failingBuilds ?? []) + ); + return { useCase, manifestRepo, manifest, fs, hasher }; +} + +function recorded(manifest: Manifest): NativeRegistrations | undefined { + return manifest.getNativeRegistrations("claude"); +} + +function trackedSettingsHash(manifest: Manifest): string | undefined { + return manifest.getToolFiles("claude").find((file) => file.relativePath === SETTINGS_PATH)?.hash + .value; +} + +const FRAMEWORK_ONLY: NativeRegistrations = { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [], +}; + +describe("how many times the manifest is written back", () => { + it("zero times when a run changed nothing, writing no project file either", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const { useCase, manifestRepo, fs } = await build({ toolIds: ["claude", "codex"], activator }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(0); + expect(fs.listUnder(resolve(PROJECT_ROOT))).toStrictEqual([]); + }); + + it("once when only the settings file changed", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const { useCase, manifestRepo, fs } = await build({ withPlugin: true, activator }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + expect(JSON.parse(await fs.readFile(SETTINGS_ABSOLUTE))).toStrictEqual({ + enabledPlugins: { [`${PLUGIN}@${MARKETPLACE}`]: true }, + }); + }); + + it("once when only the host registration changed", async () => { + const { useCase, manifestRepo, manifest } = await build(); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + expect(recorded(manifest)).toStrictEqual(FRAMEWORK_ONLY); + }); + + it("once across two identical runs: the second finds nothing to record", async () => { + const { useCase, manifestRepo } = await build({ + trackedSettings: "{}", + settingsOnDisk: "{}", + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + }); + + it("never on a second run that enables the same plugin ref again", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, manifestRepo, manifest } = await build({ withPlugin: true, activator }); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + const afterFirstRun = manifestRepo.saves; + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(afterFirstRun); + expect(recorded(manifest)?.pluginRefs).toStrictEqual([`${PLUGIN}@${MARKETPLACE}`]); + }); + + it("once when only the tracked settings hash moved under the host's own write", async () => { + const onDisk = JSON.stringify({ model: "opus" }); + const { useCase, manifestRepo, manifest, hasher } = await build({ + trackedSettings: "{}", + settingsOnDisk: onDisk, + existingRegistrations: FRAMEWORK_ONLY, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + expect(trackedSettingsHash(manifest)).toBe(hasher.hash(onDisk).value); + }); + + it("once when the marketplaces key is evicted from the shared settings file", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const onDisk = JSON.stringify({ + extraKnownMarketplaces: { x: { source: "/p" } }, + model: "opus", + }); + const { useCase, manifestRepo, manifest, fs, hasher } = await build({ + trackedSettings: onDisk, + settingsOnDisk: onDisk, + activator, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const written = await fs.readFile(SETTINGS_ABSOLUTE); + expect(manifestRepo.saves).toBe(1); + expect(JSON.parse(written)).toStrictEqual({ model: "opus" }); + expect(trackedSettingsHash(manifest)).toBe(hasher.hash(written).value); + }); +}); + +describe("what the tracked settings hash follows", () => { + it("stays where it was when the tracked file is gone from disk", async () => { + const { useCase, manifest, hasher } = await build({ trackedSettings: "{}" }); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(trackedSettingsHash(manifest)).toBe(hasher.hash("{}").value); + }); + + it("never starts tracking the settings file on behalf of another tracked file", async () => { + const { useCase, manifest } = await build({ + otherTrackedFiles: ["CLAUDE.md"], + settingsOnDisk: "{}", + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifest.getToolFiles("claude").map((file) => file.relativePath)).toStrictEqual([ + "CLAUDE.md", + ]); + }); +}); + +describe("when a recorded registration is replaced", () => { + it("once its marketplaces differ, same binary and same refs", async () => { + const { useCase, manifest } = await build({ + existingRegistrations: { + binary: "claude", + marketplaces: [{ alias: "stale", hostName: "stale" }], + pluginRefs: [], + }, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual(FRAMEWORK_ONLY); + }); + + it("once a marketplace's hostName alone moved", async () => { + const { useCase, manifest } = await build({ + existingRegistrations: { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: "old-host" }], + pluginRefs: [], + }, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual(FRAMEWORK_ONLY); + }); + + it("once a plugin ref appears where none was recorded", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, manifest } = await build({ + withPlugin: true, + activator, + existingRegistrations: FRAMEWORK_ONLY, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual({ + ...FRAMEWORK_ONLY, + pluginRefs: [`${PLUGIN}@${MARKETPLACE}`], + }); + }); + + it("compares every recorded marketplace, not only the first", async () => { + const { useCase, manifest } = await build({ + marketplaceNames: ["market-a", "market-b"], + existingRegistrations: { + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "stale", hostName: "stale" }, + ], + pluginRefs: [], + }, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual({ + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "market-b", hostName: "market-b" }, + ], + pluginRefs: [], + }); + }); + + it("records a marketplace whose build failed under its own alias", async () => { + const { useCase, manifest } = await build({ + marketplaceNames: [MARKETPLACE, "broken"], + failingBuilds: ["broken"], + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual({ + binary: "claude", + marketplaces: [ + { alias: MARKETPLACE, hostName: MARKETPLACE }, + { alias: "broken", hostName: "broken" }, + ], + 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 44675f1f6..01a9879cb 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 @@ -1,5 +1,6 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; @@ -8,6 +9,8 @@ import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framewor 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 { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { NativePluginCliError } from "../../../../../src/kernel/errors.js"; +import type { PluginSource } from "../../../../../src/kernel/source.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"; @@ -43,6 +46,18 @@ interface SyncSetup { readonly crashOnAddMarketplace?: boolean; /** Refs that fail to enable — a recoverable, best-effort `NativePluginCliError`. */ readonly failOnPlugins?: readonly string[]; + readonly activator?: FakeNativePluginActivator; + readonly marketplaceSource?: (name: string) => PluginSource; +} + +class ActivatorFailingAtUpgrade extends FakeNativePluginActivator { + constructor(private readonly failure: Error) { + super({ available: true }); + } + + override upgradeMarketplaces(): void { + throw this.failure; + } } /** A real build always leaves a catalog where `fakeEnsureBuiltMarketplace()` resolves @@ -80,7 +95,7 @@ async function sync(setup: SyncSetup = {}) { PROJECT_ROOT, Marketplace.create({ name, - source: { kind: "local", path: `/source/${name}` }, + source: setup.marketplaceSource?.(name) ?? { kind: "local", path: `/source/${name}` }, scope: "project", addedAt: "2026-01-01T00:00:00Z", }) @@ -88,12 +103,14 @@ async function sync(setup: SyncSetup = {}) { } if (setup.settings !== undefined) await fs.writeFile(SHARED_SETTINGS, setup.settings); - const activator = new FakeNativePluginActivator({ - available: setup.available ?? true, - enablesPlugins: setup.enablesPlugins ?? false, - crashOnAddMarketplace: setup.crashOnAddMarketplace ?? false, - failOnPlugins: setup.failOnPlugins ?? [], - }); + const activator = + setup.activator ?? + new FakeNativePluginActivator({ + available: setup.available ?? true, + enablesPlugins: setup.enablesPlugins ?? false, + crashOnAddMarketplace: setup.crashOnAddMarketplace ?? false, + failOnPlugins: setup.failOnPlugins ?? [], + }); const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -332,4 +349,165 @@ describe("toolIds narrows which tool's CLI is driven", () => { expect(codexActivator.addedMarketplaces).toEqual([]); expect(result.activated).toEqual(["claude"]); }); + + it("leaves the other tool's CLI alone even when that tool's own tree is ready to register", async () => { + const fs = seededBuiltCatalog(); + fs.setFile( + "/built/codex/.agents/plugins/marketplace.json", + JSON.stringify({ name: "aidd-framework", version: "1.0.0", plugins: [] }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("codex", "test", []); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source/aidd-framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const claudeActivator = new FakeNativePluginActivator({ available: true }); + const codexActivator = new FakeNativePluginActivator({ available: true }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", claudeActivator], + ["codex", codexActivator], + ]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, toolIds: ["claude"] }); + + expect(claudeActivator.addedMarketplaces).toStrictEqual(["/built/claude"]); + expect(codexActivator.addedMarketplaces).toStrictEqual([]); + expect(result).toStrictEqual({ + activated: ["claude"], + binaryMissing: [], + warnings: [], + errors: [], + }); + }); +}); + +describe("what a step that could not complete leaves in warnings and errors", () => { + it("names the binary in the warning when the CLI is not on PATH", async () => { + const { logger, result } = await sync({ available: false }); + + expect(logger.warnMessages).toStrictEqual([ + "claude CLI not found on PATH — skipping native plugin activation.", + ]); + expect(result.warnings).toStrictEqual([]); + }); + + it("names the plugin ref and the CLI's own reason when enabling it fails", async () => { + const { result } = await sync({ + enablesPlugins: true, + failOnPlugins: ["aidd-context@aidd-framework"], + }); + + expect(result.warnings).toStrictEqual([ + "Native plugin activation — enable plugin 'aidd-context@aidd-framework' skipped: plugin `aidd-context@aidd-framework` was not found in marketplace", + ]); + }); + + it("warns about a refused marketplace upgrade and still enables the plugin", async () => { + const activator = new ActivatorFailingAtUpgrade(new NativePluginCliError("registry locked")); + + const { result } = await sync({ activator }); + + expect(result.warnings).toStrictEqual([ + "Native plugin activation — upgrade marketplaces skipped: registry locked", + ]); + expect(result.errors).toStrictEqual([]); + expect(activator.enabledPlugins).toStrictEqual(["aidd-context@aidd-framework"]); + }); + + it("reports an activator bug during the upgrade as an error, never as a warning", async () => { + const activator = new ActivatorFailingAtUpgrade(new Error("activator bug")); + + const { result } = await sync({ activator }); + + expect(result.errors).toStrictEqual([{ scope: "claude", message: "activator bug" }]); + expect(result.warnings).toStrictEqual([]); + }); +}); + +describe("what gets built, and how", () => { + it("asks for a marketplace-mode build of every registered tree even when the CLI is absent", async () => { + const requests: { name: string; mode: string }[] = []; + const recordingBuild: EnsureBuiltMarketplace = { + execute: async (options) => { + requests.push({ name: options.marketplace.name, mode: options.mode }); + return { builtDir: `/built/${options.target}`, version: "test", rebuilt: true }; + }, + }; + + await sync({ + marketplaceNames: ["aidd-framework", "unused"], + ensureBuilt: recordingBuild, + available: false, + }); + + expect(requests).toStrictEqual([ + { name: "aidd-framework", mode: "marketplace" }, + { name: "unused", mode: "marketplace" }, + ]); + }); +}); + +describe("a marketplace whose source the settings file cannot express", () => { + it("writes no enabled-plugins entry for it", async () => { + const { written, fs } = await sync({ + marketplaceSource: (name) => ({ kind: "url", url: `https://example.com/${name}.git` }), + }); + + expect(written).toBeUndefined(); + expect(fs.listUnder(resolve(PROJECT_ROOT))).toStrictEqual([]); + }); +}); + +describe("a project whose manifest also lists a tool with no plugin system", () => { + it("syncs the other tools and reports only the one whose CLI ran", async () => { + const fs = seededBuiltCatalog(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("vscode", "test", []); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source/aidd-framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ + activated: ["claude"], + binaryMissing: [], + warnings: [], + errors: [], + }); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts index 72c06224a..ae1b02f85 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts @@ -16,6 +16,7 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.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"; @@ -246,4 +247,69 @@ describe("the shared source's own reference, recorded by sync", () => { expect(await registry.list(PROJECT_ROOT)).toEqual([]); }); + + it("stays a silent no-op when asked to recreate with nothing wired to do it", async () => { + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({}, new DeterministicHasher()), + await manifestRepoWithClaudeInstalled(), + new InMemoryMarketplaceRegistry(), + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result).toStrictEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + }); +}); + +class UnloadableManifestRepository extends InMemoryManifestRepository { + override async load(): Promise { + throw new Error("manifest.json is not valid JSON"); + } +} + +describe("a project with no manifest to sync", () => { + it("returns the empty result and drives no tool when there is no manifest", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkMarketplace()); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({}, new DeterministicHasher()), + new InMemoryManifestRepository(), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + expect(activator.addedMarketplaces).toStrictEqual([]); + }); + + it("returns the empty result when the manifest cannot be loaded", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkMarketplace()); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({}, new DeterministicHasher()), + new UnloadableManifestRepository(), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", new FakeNativePluginActivator({ available: true })]]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + }); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts index 07f3c3a29..a2696bb00 100644 --- a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts @@ -100,3 +100,62 @@ describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { expect(result.skipped).toEqual([]); }); }); + +describe("BuiltTreeMaterializationTranslator — when no built tree applies (integration)", () => { + const COMMAND = "---\nname: aidd:01:hello\n---\n# Hello"; + + function distWithCommand(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "commands/hello.md", content: COMMAND }], + components: { + commands: [{ relativePath: "commands/hello.md", content: COMMAND }], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); + } + + async function install(marketplace: string | undefined): Promise { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/sample-plugin/skills/demo/SKILL.md`, "built skill"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + await translator.addPlugin( + distWithCommand(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + marketplace + ); + return fs; + } + + it("materializes the distribution itself, never the built tree, when no marketplace is given", async () => { + const fs = await install(undefined); + + expect(fs.listUnder(`${HOME}/.cursor/plugins/local`)).toStrictEqual([ + `${HOME}/.cursor/plugins/local/sample-plugin/commands/hello.md`, + ]); + }); + + it("materializes the distribution itself when the named marketplace is not registered", async () => { + const fs = await install("unregistered-marketplace"); + + expect(fs.listUnder(`${HOME}/.cursor/plugins/local`)).toStrictEqual([ + `${HOME}/.cursor/plugins/local/sample-plugin/commands/hello.md`, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts index dddf5d135..6b2857c0d 100644 --- a/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts @@ -133,3 +133,62 @@ describe("BuiltTreeMaterializationTranslator — opencode (integration)", () => expect(fs.has(`${PROJECT_ROOT}/.opencode/plugin/other-plugin.js`)).toBe(false); }); }); + +describe("BuiltTreeMaterializationTranslator — what the opencode flat tree never yields", () => { + async function installFrom( + fs: InMemoryFileAdapter, + distribution: PluginDistribution + ): Promise<{ skipped: readonly unknown[] }> { + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => "/home/u", + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + return translator.addPlugin( + distribution, + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + } + + it("reports no skip for a tool that keeps hooks inside its own plugin tree", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/.opencode/agents/aidd-vcs-helper.md`, "agent body"); + + const result = await installFrom(fs, dist()); + + expect(result.skipped).toStrictEqual([]); + }); + + it("ignores built files outside .opencode and entries sitting directly under it", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/other/agents/aidd-vcs-helper.md`, "not opencode"); + fs.setFile(`${BUILT}/.opencode/aidd-vcs-readme.md`, "too shallow"); + fs.setFile(`${BUILT}/.opencode/agents/aidd-vcs-helper.md`, "agent body"); + + await installFrom(fs, dist()); + + expect(fs.listUnder(PROJECT_ROOT)).toStrictEqual([ + `${PROJECT_ROOT}/.opencode/agents/aidd-vcs-helper.md`, + ]); + }); + + it("never copies the plugin's own hooks manifest, even when the built tree carries one", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/.opencode/hooks/aidd-vcs/hooks.json`, "{}"); + fs.setFile(`${BUILT}/.opencode/hooks/aidd-vcs/journal.cjs`, "// journal"); + + await installFrom(fs, distWithHooks()); + + expect(fs.listUnder(PROJECT_ROOT)).toStrictEqual([ + `${PROJECT_ROOT}/.opencode/hooks/aidd-vcs/journal.cjs`, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts index 5bee02c23..8728a4a63 100644 --- a/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts @@ -49,6 +49,23 @@ describe("ModeAMarketplaceTranslator", () => { expect(installed?.files.size).toBe(0); expect(installed?.marketplace).toBe("aidd-framework"); }); + + it("reports nothing skipped, since nothing was translated", async () => { + const { adapter } = buildAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + const result = await adapter.addPlugin( + buildDist("aidd-context"), + "claude", + { kind: "local", path: "/plugin-source" }, + "/project", + manifest, + "aidd-framework" + ); + + expect(result).toStrictEqual({ skipped: [] }); + }); }); describe("when adding a plugin without marketplace", () => { diff --git a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts index 736f6eb3a..5df09df4c 100644 --- a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts @@ -7,6 +7,10 @@ import { Manifest } from "../../../../../../src/contexts/framework/domain/manife import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { CursorProjectScopeUnsupportedError } from "../../../../../../src/kernel/errors.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/test-project"; @@ -159,4 +163,66 @@ describe("ModeBFlatMaterializationTranslator", () => { expect(plugins.find((p) => p.name === "mcp-plugin")).toBeUndefined(); }); }); + + describe("when the tool's MCP config merges the plugin's servers", () => { + function mcpDist(mcpServers: Record): PluginDistribution { + const content = JSON.stringify({ mcpServers }); + return new PluginDistribution({ + manifest: { name: "mcp-plugin", version: "1.0.0" }, + format: "claude", + files: [{ relativePath: ".mcp.json", content }], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [{ relativePath: ".mcp.json", content }], + }, + }); + } + + it("drops the servers a previous version contributed even when the new version contributes none", async () => { + const { adapter, fs } = buildAdapter(); + const configPath = join(PROJECT_ROOT, "opencode.json"); + fs.setFile(configPath, JSON.stringify({ mcp: { "old-tool": { type: "local" } } })); + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + + await adapter.addPlugin( + mcpDist({}), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + new Map([["old-tool", "digest-of-old-tool"]]) + ); + + expect(JSON.parse(fs.getFile(configPath) ?? "null")).toStrictEqual({ mcp: {} }); + }); + + it("propagates a failure to read the MCP config other than the file being absent", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("readFile", join(PROJECT_ROOT, "opencode.json"), errnoError("EACCES")); + const adapter = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => "/stub-home" + ); + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + + await expect( + adapter.addPlugin( + mcpDist({ "local-tool": { command: "node", args: ["./server.js"] } }), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined + ) + ).rejects.toThrow("EACCES: planted by the test"); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts new file mode 100644 index 000000000..6f972cc29 --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts @@ -0,0 +1,131 @@ +import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ProjectHooksMaterializer, + withoutHooks, +} from "../../../../../../src/contexts/framework/application/framework/translator/project-hooks-materializer.js"; +import { + type PluginComponentFile, + PluginDistribution, +} from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const PLUGIN_NAME = "aidd-context"; +const HOOKS_PATH = join(PROJECT_ROOT, ".cursor", "hooks.json"); +const SCRIPT = { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }; + +// biome-ignore lint/suspicious/noTemplateCurlyInString: the Claude hook placeholder the merge rewrites +const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"; + +function hooksManifest(event: string): PluginComponentFile { + return { + relativePath: "hooks/hooks.json", + content: JSON.stringify({ + hooks: { + [event]: [ + { hooks: [{ type: "command", command: `node ${PLUGIN_ROOT_VAR}/hooks/pre.js` }] }, + ], + }, + }), + }; +} + +function distWithHooks(hooks: readonly PluginComponentFile[]): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: [...hooks, { relativePath: "commands/hello.md", content: "# Hello" }], + components: { + commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], + agents: [], + rules: [], + skills: [], + hooks: [...hooks], + mcp: [], + }, + }); +} + +describe("ProjectHooksMaterializer", () => { + it("merges the plugin's hooks manifest whichever position it holds among the hook files", async () => { + const fs = new InMemoryFileAdapter(); + + const skips = await new ProjectHooksMaterializer(fs).materialize( + distWithHooks([SCRIPT, hooksManifest("PreToolUse")]), + "cursor", + PROJECT_ROOT + ); + + expect(skips).toStrictEqual([]); + expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null")).toStrictEqual({ + version: 1, + hooks: { preToolUse: [{ command: `node ./.cursor/hooks/${PLUGIN_NAME}/pre.js` }] }, + }); + }); + + it("reports an event the tool cannot map as a hooks skip for this plugin", async () => { + const fs = new InMemoryFileAdapter(); + + const skips = await new ProjectHooksMaterializer(fs).materialize( + distWithHooks([hooksManifest("Notification"), SCRIPT]), + "cursor", + PROJECT_ROOT + ); + + expect(skips).toStrictEqual([ + { + pluginName: PLUGIN_NAME, + component: "hooks", + toolId: "cursor", + reason: "cursor: unmapped event 'Notification' skipped", + }, + ]); + }); + + it("copies every hook script beside the project's hooks file, never the manifest itself", async () => { + const fs = new InMemoryFileAdapter(); + + await new ProjectHooksMaterializer(fs).materialize( + distWithHooks([hooksManifest("PreToolUse"), SCRIPT]), + "cursor", + PROJECT_ROOT + ); + + expect(fs.listUnder(join(PROJECT_ROOT, ".cursor", "hooks"))).toStrictEqual([ + `${PROJECT_ROOT}/.cursor/hooks/${PLUGIN_NAME}/pre.js`, + ]); + }); + + it("propagates a failure to read the project's hooks file other than its absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("readFile", HOOKS_PATH, errnoError("EACCES")); + + await expect( + new ProjectHooksMaterializer(fs).materialize( + distWithHooks([hooksManifest("PreToolUse"), SCRIPT]), + "cursor", + PROJECT_ROOT + ) + ).rejects.toThrow("EACCES: planted by the test"); + }); +}); + +describe("withoutHooks", () => { + it("drops every hooks file from the file list and the hooks component alike", () => { + const stripped = withoutHooks(distWithHooks([hooksManifest("PreToolUse"), SCRIPT])); + + expect(stripped.files).toStrictEqual([ + { relativePath: "commands/hello.md", content: "# Hello" }, + ]); + expect(stripped.components.hooks).toStrictEqual([]); + expect(stripped.components.commands).toStrictEqual([ + { relativePath: "commands/hello.md", content: "# Hello" }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/resolve-plugin-translator.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/resolve-plugin-translator.unit.test.ts new file mode 100644 index 000000000..4e8340bec --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/resolve-plugin-translator.unit.test.ts @@ -0,0 +1,25 @@ +import "../../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { describe, expect, it } from "vitest"; +import type { TranslatorDeps } from "../../../../../../src/contexts/framework/application/framework/translator/plugin-translator-factory.js"; +import { resolvePluginTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/resolve-plugin-translator.js"; +import { getToolConfig } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; + +function deps(): TranslatorDeps { + return { + fs: new InMemoryFileAdapter(), + hasher: new DeterministicHasher(), + homedir: () => "/stub-home", + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: new InMemoryMarketplaceRegistry(), + }; +} + +describe("resolvePluginTranslator", () => { + it("answers nothing for a tool that is not an AI tool", () => { + expect(resolvePluginTranslator(getToolConfig("vscode"), deps())).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/framework/application/gitignore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/gitignore-use-case.unit.test.ts new file mode 100644 index 000000000..510eb9bba --- /dev/null +++ b/cli/tests/contexts/framework/application/gitignore-use-case.unit.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; + +const ROOT = "/project"; +const GITIGNORE = "/project/.gitignore"; + +function build(existing?: string) { + const fs = new InMemoryFileAdapter(existing === undefined ? {} : { [GITIGNORE]: existing }); + return { fs, useCase: new GitignoreUseCase(fs) }; +} + +describe("GitignoreUseCase", () => { + describe("execute", () => { + it("creates the file from the entries, one per line, ending with a newline", async () => { + const { fs, useCase } = build(); + + await expect(useCase.execute(ROOT, ["a/", "b/"])).resolves.toBe(true); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nb/\n"); + }); + + it("starts a new line before appending to a file without a trailing newline", async () => { + const { fs, useCase } = build("keep"); + + await useCase.execute(ROOT, ["a/"]); + + expect(fs.getFile(GITIGNORE)).toBe("keep\na/\n"); + }); + + it("appends right after a trailing newline, without a blank line", async () => { + const { fs, useCase } = build("keep\n"); + + await useCase.execute(ROOT, ["a/"]); + + expect(fs.getFile(GITIGNORE)).toBe("keep\na/\n"); + }); + + it("answers false and leaves the file alone when every entry is already there", async () => { + const { fs, useCase } = build("a/\n"); + + await expect(useCase.execute(ROOT, ["a/"])).resolves.toBe(false); + + expect(fs.getFile(GITIGNORE)).toBe("a/\n"); + }); + + it("counts an entry written with surrounding whitespace as present", async () => { + const { fs, useCase } = build(" a/ \n"); + + await expect(useCase.execute(ROOT, ["a/"])).resolves.toBe(false); + + expect(fs.getFile(GITIGNORE)).toBe(" a/ \n"); + }); + + it("appends only the entries that are missing", async () => { + const { fs, useCase } = build("a/\nb/\n"); + + await expect(useCase.execute(ROOT, ["b/", "c/"])).resolves.toBe(true); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nb/\nc/\n"); + }); + }); + + describe("remove", () => { + it("does nothing when there is no file", async () => { + const { fs, useCase } = build(); + + await expect(useCase.remove(ROOT, ["a/"])).resolves.toBeUndefined(); + + expect(fs.has(GITIGNORE)).toBe(false); + }); + + it("drops the entry and keeps the other lines in order", async () => { + const { fs, useCase } = build("a/\nb/\nc/\n"); + + await useCase.remove(ROOT, ["b/"]); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nc/\n"); + }); + + it("drops an entry written with surrounding whitespace", async () => { + const { fs, useCase } = build(" b/ \nc/\n"); + + await useCase.remove(ROOT, ["b/"]); + + expect(fs.getFile(GITIGNORE)).toBe("c/\n"); + }); + + it("leaves the file byte-identical when no entry matches", async () => { + const { fs, useCase } = build("\na/\n\n"); + + await useCase.remove(ROOT, ["z/"]); + + expect(fs.getFile(GITIGNORE)).toBe("\na/\n\n"); + }); + + it("leaves an empty file in place when no entry matches", async () => { + const { fs, useCase } = build(""); + + await useCase.remove(ROOT, ["z/"]); + + expect(fs.getFile(GITIGNORE)).toBe(""); + }); + + it("collapses the blank lines around what remains", async () => { + const { fs, useCase } = build("\n\nb/\na/\nc/\n\n"); + + await useCase.remove(ROOT, ["b/"]); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nc/\n"); + }); + + it("deletes the file once its last entry is removed", async () => { + const { fs, useCase } = build("a/\n"); + + await useCase.remove(ROOT, ["a/"]); + + expect(fs.has(GITIGNORE)).toBe(false); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts index 1683ccf87..f3bf3df9f 100644 --- a/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts @@ -1,20 +1,142 @@ import { describe, expect, it } from "vitest"; import { DoctorAllUseCase } from "../../../../../src/contexts/framework/application/global/doctor-all-use-case.js"; -import { buildDoctorUseCase, buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; +import type { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { + buildDoctorUseCase, + buildUnitDeps, + initAndInstall, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; +const NO_MANIFEST = "No AIDD manifest found. Run `aidd setup` to initialize your project."; +const PLUGIN_FILE = ".claude/plugins/sample/agents/reviewer.md"; + +async function depsWithBothCategories() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest missing"); + return { deps, manifest }; +} + +function healthOf(manifest: Manifest, toolId: ToolId) { + return { + toolId, + fileCount: manifest.getToolFiles(toolId).length, + mergeFileCount: manifest.getMergeFiles(toolId).length, + }; +} + +async function deleteOneTrackedFile( + deps: Awaited>, + manifest: Manifest, + toolId: ToolId +) { + const [first] = manifest.getToolFiles(toolId); + if (first === undefined) throw new Error(`${toolId} tracks no file`); + await deps.fs.deleteFile(`${PROJECT_ROOT}/${first.relativePath}`); +} + +function verdictOf(result: Awaited>) { + return [result.healthy, result.ai?.healthy, result.ide?.healthy, result.errors]; +} describe("DoctorAllUseCase", () => { - it("is not healthy when every scope errored (no manifest found)", async () => { + it("records one error per scope, and no report, when no manifest exists", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); - const doctorUseCase = buildDoctorUseCase(deps); - const useCase = new DoctorAllUseCase(doctorUseCase); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + ai: null, + ide: null, + pluginIssues: [], + healthy: false, + errors: [ + { scope: "ai", message: NO_MANIFEST }, + { scope: "ide", message: NO_MANIFEST }, + ], + }); + }); + + it("is healthy with one report per category when every tool is in sync", async () => { + const { deps, manifest } = await depsWithBothCategories(); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + ai: { + healthy: true, + toolHealth: [healthOf(manifest, "claude")], + issues: [], + pluginIssues: [], + }, + ide: { + healthy: true, + toolHealth: [healthOf(manifest, "vscode")], + issues: [], + pluginIssues: [], + }, + pluginIssues: [], + healthy: true, + errors: [], + }); + }); + + it("is not healthy when the ai scope alone found a fault", async () => { + const { deps, manifest } = await depsWithBothCategories(); + await deleteOneTrackedFile(deps, manifest, "claude"); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(verdictOf(result)).toStrictEqual([false, false, true, []]); + }); + + it("is not healthy when the ide scope alone found a fault", async () => { + const { deps, manifest } = await depsWithBothCategories(); + await deleteOneTrackedFile(deps, manifest, "vscode"); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(verdictOf(result)).toStrictEqual([false, true, false, []]); + }); + + it("carries the ai scope's plugin issues", async () => { + const { deps, manifest } = await depsWithBothCategories(); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_FILE]: "abc123abc123abc123abc123abc123ab" }, + scope: "project", + }) + ); + await deps.manifestRepo.save(manifest); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); const result = await useCase.execute(PROJECT_ROOT); - expect(result.ai).toBeNull(); - expect(result.ide).toBeNull(); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.healthy).toBe(false); + const missing = { + toolId: "claude", + pluginName: "sample", + issue: "missing", + filePath: PLUGIN_FILE, + }; + expect([result.pluginIssues, result.ai?.pluginIssues, result.healthy]).toStrictEqual([ + [missing], + [missing], + false, + ]); }); }); diff --git a/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts b/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts index 47437e11f..cc4c91ebc 100644 --- a/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts @@ -35,6 +35,21 @@ describe("ResolveUpdateDecisionUseCase", () => { ).rejects.toThrow(InputRequiredError); }); + it("names --force as the way out", async () => { + const useCase = new ResolveUpdateDecisionUseCase(buildFakePrompter("overwrite")); + + const run = useCase.execute({ + relativePath: "some/file.md", + userForce: false, + interactive: false, + bulkState: new BulkConflictState(), + }); + + await expect(run).rejects.toThrow( + "Use --force to overwrite modified files in non-interactive mode." + ); + }); + it("never calls prompter in non-TTY mode", async () => { const prompter = buildFakePrompter("overwrite"); const useCase = new ResolveUpdateDecisionUseCase(prompter); @@ -150,6 +165,20 @@ describe("ResolveUpdateDecisionUseCase", () => { expect(prompter.resolveConflictBulk).not.toHaveBeenCalled(); }); + it("records nothing for a single-file answer", async () => { + const useCase = new ResolveUpdateDecisionUseCase(buildFakePrompter("overwrite")); + const bulkState = new BulkConflictState(); + + await useCase.execute({ + relativePath: "some/file.md", + userForce: false, + interactive: true, + bulkState, + }); + + expect(bulkState.get()).toBeNull(); + }); + it("records overwrite-all in bulkState when prompted", async () => { const prompter = buildFakePrompter("overwrite-all"); const useCase = new ResolveUpdateDecisionUseCase(prompter); diff --git a/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts index 271a8a6a5..714a00c9f 100644 --- a/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts @@ -36,6 +36,21 @@ describe("UpdateAiToolsUseCase", () => { }); }); + describe("no toolArg", () => { + it("updates the installed AI tools and leaves an installed IDE alone", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "vscode"); + + const useCase = buildUseCase(deps, buildUpdateOneToolUseCase(deps)); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, ...NO_FORCE_TTY }); + + expect(result.updatedTools.map((t) => t.toolId)).toStrictEqual(["claude"]); + expect(result.errors).toStrictEqual([]); + }); + }); + describe("single toolArg", () => { it("updates only the specified tool when toolArg is provided", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts b/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts index a1ed1a27a..7deb53fb5 100644 --- a/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts @@ -136,6 +136,35 @@ describe("UpdateOneToolUseCase integration", () => { }); }); + describe("installer skipped the tool", () => { + it("answers null with no error recorded", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + vi.spyOn(deps.installRuntimeConfigUseCase, "execute").mockResolvedValue({ + toolId: "claude", + fileCount: 0, + files: [], + skipped: true, + warnings: [], + }); + + const useCase = buildUseCase(deps, buildFakePrompter("keep")); + const errors: Parameters[4] = []; + + const result = await useCase.execute( + "claude", + await loadManifest(deps), + PROJECT_ROOT, + "test", + errors, + { userForce: false, interactive: false, bulkState: new BulkConflictState() } + ); + + expect(result).toBeNull(); + expect(errors).toStrictEqual([]); + }); + }); + describe("install failure", () => { it("reports the failure and returns null instead of throwing", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts index 8e860bafc..28f34b0c8 100644 --- a/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts @@ -148,6 +148,24 @@ describe("InstallSkillsUseCase", () => { expect(files[0].frameworkPath).toBe("skills/my-skill/SKILL.md"); }); + it("accepts the entry file however deep its skill directory nests", () => { + const { useCase } = buildUseCase(); + const contentFiles = new Map([ + [ + "skills/group/my-skill/SKILL.md", + "---\nname: my-skill\ndescription: Deep\n---\n# Skill\n", + ], + ]); + + const files = useCase.execute({ + toolConfig: claude, + section: skillsSectionWithEntry, + contentFiles, + }); + + expect(files.map((f) => f.frameworkPath)).toStrictEqual(["skills/group/my-skill/SKILL.md"]); + }); + it("filters out non-SKILL.md files from subdirectory skills", () => { const { useCase } = buildUseCase(); const contentFiles = new Map([ diff --git a/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts index 86974bedb..d9c01033f 100644 --- a/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts @@ -92,7 +92,99 @@ describe("InstallAiToolUseCase", () => { }); }); + describe("installed tools without plugins", () => { + it("runs no activation when nothing was propagated", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { useCase, syncSettingsMock } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(syncSettingsMock.execute).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + runtimeResult: result.runtimeResult, + propagatedPlugins: [], + propagationWarnings: [], + activation: undefined, + }); + }); + + it("propagates nothing when the manifest vanished right after the install", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const loaded = await deps.manifestRepo.load(); + vi.spyOn(deps.manifestRepo, "load").mockResolvedValueOnce(loaded).mockResolvedValueOnce(null); + const { useCase } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(result).toStrictEqual({ + runtimeResult: result.runtimeResult, + propagatedPlugins: [], + propagationWarnings: [], + }); + }); + }); + describe("manifest with plugins on another tool", () => { + it("propagates with the exact non-interactive replace request", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await addPlugin(deps, "claude", makeMockPlugin("my-plugin")); + const { useCase, pluginInstallMock } = buildUseCase(deps); + + await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(pluginInstallMock.execute).toHaveBeenCalledWith({ + pluginName: "my-plugin", + version: "1.0.0", + fromMarketplace: "aidd", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: true, + replace: true, + requestedVersionPolicy: "prefer-catalog", + }); + }); + + it("does not propagate the tool's own plugins back onto it on a forced reinstall", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "opencode"); + await addPlugin(deps, "opencode", makeMockPlugin("own-plugin")); + const { useCase, pluginInstallMock } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: true, + version: VERSION, + propagatePlugins: true, + }); + + expect(result.propagatedPlugins).toStrictEqual([]); + expect(pluginInstallMock.execute).not.toHaveBeenCalled(); + }); + it("propagates plugins from existing tools onto the new tool", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); @@ -233,6 +325,26 @@ describe("InstallAiToolUseCase", () => { expect(result.propagatedPlugins).toHaveLength(0); expect(pluginInstallMock.execute).not.toHaveBeenCalled(); }); + + it("answers the skipped install alone, with nothing propagated and no warning", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + const { useCase } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(result).toStrictEqual({ + runtimeResult: { toolId: "opencode", fileCount: 0, files: [], skipped: true, warnings: [] }, + propagatedPlugins: [], + propagationWarnings: [], + }); + }); }); describe("orphaned plugin (no marketplace)", () => { diff --git a/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts b/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts index c242ad637..6c8716f37 100644 --- a/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; import { InstallConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-config-use-case.js"; import { extractConfigCapabilities } from "../../../../../src/contexts/framework/domain/config-capability.js"; +import { CONFIG_MCP } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { McpCapability } from "../../../../../src/contexts/tools/domain/capabilities/mcp-capability.js"; import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; import { copilot } from "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { FrameworkDescriptor } from "../../../../../src/contexts/translate/domain/canon.js"; import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { linuxPlatform } from "../helpers.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; +import { linuxPlatform, win32Platform } from "../helpers.js"; const PROJECT_ROOT = "/test-project"; @@ -113,4 +116,162 @@ describe("InstallConfigUseCase — staticContent", () => { expect(results).toHaveLength(0); }); + + it("leaves a consumes-only capability alone even with an asset provider at hand", async () => { + const { useCase } = buildUseCase(); + const capability = new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "framework-prime", + consumes: ["someSignal"], + }); + + const results = await useCase.execute({ + capabilities: [capability], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + assetProvider: new BundledAssetProviderAdapter(), + toolId: "copilot", + }); + + expect(results).toStrictEqual([]); + }); + + describe("an asset-backed capability", () => { + const fromAsset = new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "framework-prime", + staticContentAssetFile: "vscode-settings.json", + }); + + it("produces nothing without an asset provider", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [fromAsset], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + toolId: "copilot", + }); + + expect(results).toStrictEqual([]); + }); + + it("produces nothing without a tool to ask the asset for", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [fromAsset], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + assetProvider: new BundledAssetProviderAdapter(), + }); + + expect(results).toStrictEqual([]); + }); + + it("carries an asset answered as text verbatim", async () => { + const { useCase } = buildUseCase(); + const text = '{\n // kept as written\n "t": 1\n}'; + + const results = await useCase.execute({ + capabilities: [fromAsset], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + assetProvider: new StubAssetProvider({ "copilot/vscode-settings.json": text }), + toolId: "copilot", + }); + + expect(results.map((f) => f.content)).toStrictEqual([text]); + }); + }); +}); + +describe("InstallConfigUseCase — MCP config", () => { + const MCP_REF = { name: CONFIG_MCP, path: "config/mcp.json" }; + const HOOKS_REF = { name: "hooks", path: "config/hooks.json" }; + const NPX_SERVER = JSON.stringify( + { mcpServers: { docs: { command: "npx", args: ["-y", "docs-server"] } } }, + null, + 2 + ); + + function mcpCapability(mergeStrategy?: "user-prime" | "framework-prime" | "none") { + return new McpCapability({ + outputPath: ".cursor/mcp.json", + format: "json", + consumes: [CONFIG_MCP], + ...(mergeStrategy !== undefined && { mergeStrategy }), + }); + } + + it("rewrites npx servers for Windows", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [mcpCapability()], + configRefs: [MCP_REF], + contentFiles: new Map([[MCP_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: win32Platform, + }); + + expect(results.map((f) => JSON.parse(f.content))).toStrictEqual([ + { mcpServers: { docs: { command: "cmd", args: ["/c", "npx", "-y", "docs-server"] } } }, + ]); + }); + + it("leaves a non-MCP config as written on Windows, even from a capability that also takes MCP", async () => { + const { useCase } = buildUseCase(); + const both = new SettingsCapability({ + outputPath: ".cursor/hooks.json", + mergeStrategy: "user-prime", + consumes: [CONFIG_MCP, HOOKS_REF.name], + }); + + const results = await useCase.execute({ + capabilities: [both], + configRefs: [HOOKS_REF], + contentFiles: new Map([[HOOKS_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: win32Platform, + }); + + expect(results.map((f) => f.content)).toStrictEqual([NPX_SERVER]); + }); + + it("honours the merge strategy the MCP capability declares", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [mcpCapability("framework-prime")], + configRefs: [MCP_REF], + contentFiles: new Map([[MCP_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + }); + + expect(results.map((f) => f.mergeStrategy)).toStrictEqual(["framework-prime"]); + }); + + it("lets the user's MCP entries win when the capability declares no strategy", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [mcpCapability()], + configRefs: [MCP_REF], + contentFiles: new Map([[MCP_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + }); + + expect(results.map((f) => f.mergeStrategy)).toStrictEqual(["user-prime"]); + }); }); diff --git a/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts index c86f0ae72..2db9f287c 100644 --- a/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts @@ -2,16 +2,22 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { InstallIdeConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-ide-config-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { buildUnitDeps, initProject } from "../../../../helpers/ports/build-unit-deps.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; const PROJECT_ROOT = "/test-project"; +const KEYBINDINGS = ".vscode/keybindings.json"; -function buildUseCase(deps: Awaited>) { +function buildUseCase( + deps: Awaited>, + assets: AssetProvider = deps.assetProvider +) { return new InstallIdeConfigUseCase( deps.fs, deps.hasher, deps.logger, - deps.assetProvider, + assets, deps.postInstallPipelineUseCase ); } @@ -32,12 +38,89 @@ describe("InstallIdeConfigUseCase", () => { expect(result.skipped).toBe(false); expect(result.fileCount).toBeGreaterThan(0); + expect(result.warnings).toStrictEqual([]); expect(deps.fs.has(join(PROJECT_ROOT, ".vscode/settings.json"))).toBe(true); const saved = await deps.manifestRepo.load(); expect(saved?.hasTool("vscode")).toBe(true); }); + it("answers an empty skipped result for an installed IDE", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const result = await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(result).toStrictEqual({ + toolId: "vscode", + fileCount: 0, + files: [], + skipped: true, + warnings: [], + }); + }); + + it("tracks a file the caller chose to skip under the hash it has on disk", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + const userContent = '[{"key": "ctrl+k"}]'; + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), userContent); + + await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: true, + version: "1.0.0", + onBeforeWriteRegularFile: async (path) => (path === KEYBINDINGS ? "skip" : "write"), + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, KEYBINDINGS))).toBe(userContent); + expect(manifest.getToolFiles("vscode")).toStrictEqual([ + { relativePath: KEYBINDINGS, hash: deps.hasher.hash(userContent) }, + ]); + }); + + it("writes an asset answered as text verbatim", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const text = '[\n // user comment\n {"key": "ctrl+k"}\n]'; + const assets = new StubAssetProvider({ "vscode/keybindings.json": text }, deps.assetProvider); + + await buildUseCase(deps, assets).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, KEYBINDINGS))).toBe(text); + }); + it("returns skipped without writing when already installed and no force", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts index ec7787629..20fd3af04 100644 --- a/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts @@ -1,25 +1,33 @@ import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { InstallIdeToolUseCase } from "../../../../../src/contexts/framework/application/install/install-ide-tool-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; +import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { registerTool } from "../../../../../src/contexts/tools/domain/registry.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { buildUnitDeps, initAndInstall, initProject, installTool, } from "../../../../helpers/ports/build-unit-deps.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; const PROJECT_ROOT = "/test-project"; const VERSION = "1.0.0"; -function buildUseCase(deps: Awaited>) { +function buildUseCase( + deps: Awaited>, + assetProvider: AssetProvider | null = deps.assetProvider +) { return new InstallIdeToolUseCase( deps.installIdeConfigUseCase, deps.manifestRepo, deps.fs, deps.hasher, deps.postInstallPipelineUseCase, - deps.assetProvider + assetProvider ?? undefined ); } @@ -129,4 +137,150 @@ describe("InstallIdeToolUseCase", () => { expect(content).toContain('"editor.formatOnSave"'); }); }); + + describe("selecting what an installed AI tool declares for the IDE", () => { + const forIde = new SettingsCapability({ + outputPath: ".vscode/aidd-a.json", + mergeStrategy: "framework-prime", + staticContent: '{"a": 1}', + requiresTool: "vscode", + }); + const consumesOnly = new SettingsCapability({ + outputPath: ".vscode/aidd-b.json", + mergeStrategy: "user-prime", + consumes: ["something"], + }); + const forItself = new SettingsCapability({ + outputPath: ".cursor/aidd-c.json", + mergeStrategy: "framework-prime", + staticContent: '{"c": 1}', + }); + const fromAsset = new SettingsCapability({ + outputPath: ".vscode/aidd-d.json", + mergeStrategy: "framework-prime", + staticContentAssetFile: "d.json", + requiresTool: "vscode", + }); + + function registerCursorWith(settings: SettingsCapability[]): void { + registerTool({ ...cursor, capabilities: { ...cursor.capabilities, settings } }); + } + + afterEach(() => { + registerTool(cursor); + }); + + async function withCursorRegistered( + deps: Awaited>, + mergeFiles: Parameters[3] = [] + ): Promise { + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + manifest.addTool("cursor", VERSION, [], mergeFiles); + await deps.manifestRepo.save(manifest); + return manifest; + } + + async function installVscode( + deps: Awaited>, + manifest: Manifest + ) { + return buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: VERSION, + }); + } + + function parsed(deps: Awaited>, relativePath: string) { + return JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, relativePath)) ?? ""); + } + + it("merges only the static settings that name this IDE", async () => { + registerCursorWith([forIde, consumesOnly, forItself]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = await withCursorRegistered(deps); + + await installVscode(deps, manifest); + + expect(parsed(deps, ".vscode/aidd-a.json")).toStrictEqual({ a: 1 }); + expect(deps.fs.has(join(PROJECT_ROOT, ".vscode/aidd-b.json"))).toBe(false); + expect(deps.fs.has(join(PROJECT_ROOT, ".cursor/aidd-c.json"))).toBe(false); + }); + + it("keeps the tool's other merge entries and replaces a stale one for the same file", async () => { + registerCursorWith([forIde]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const mcpEntry = { relativePath: ".cursor/mcp.json", sectionKey: null, entries: {} }; + const stale = { + relativePath: ".vscode/aidd-a.json", + sectionKey: null, + entries: { stale: deps.hasher.hash("0") }, + }; + const manifest = await withCursorRegistered(deps, [mcpEntry, stale]); + + await installVscode(deps, manifest); + + expect(manifest.getMergeFiles("cursor")).toStrictEqual([ + mcpEntry, + { + relativePath: ".vscode/aidd-a.json", + sectionKey: null, + entries: { a: deps.hasher.hash("1") }, + }, + ]); + }); + + it("leaves a hand-edited file alone when the IDE was already installed", async () => { + registerCursorWith([forIde]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = await withCursorRegistered(deps); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/aidd-a.json"), '{"user": true}'); + + const result = await installVscode(deps, manifest); + + expect(result.skipped).toBe(true); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".vscode/aidd-a.json"))).toBe('{"user": true}'); + }); + + it("writes an asset answered as text verbatim", async () => { + registerCursorWith([fromAsset]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = await withCursorRegistered(deps); + const assets = new StubAssetProvider({ "cursor/d.json": '{"d": 1}' }); + + await buildUseCase(deps, assets).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: VERSION, + }); + + expect(parsed(deps, ".vscode/aidd-d.json")).toStrictEqual({ d: 1 }); + }); + + it("merges an empty object for an asset-backed capability when no asset provider was given", async () => { + registerCursorWith([fromAsset]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = await withCursorRegistered(deps); + + await buildUseCase(deps, null).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: VERSION, + }); + + expect(parsed(deps, ".vscode/aidd-d.json")).toStrictEqual({}); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts index eae45c091..e7f661ad4 100644 --- a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts @@ -1,21 +1,30 @@ import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { InstallRuntimeConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-runtime-config-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; +import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { registerTool } from "../../../../../src/contexts/tools/domain/registry.js"; +import { extractMergeEntries } from "../../../../../src/kernel/merge.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { buildUnitDeps, initProject, installTool, } from "../../../../helpers/ports/build-unit-deps.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; const PROJECT_ROOT = "/test-project"; -function buildUseCase(deps: Awaited>) { +function buildUseCase( + deps: Awaited>, + assets: AssetProvider = deps.assetProvider +) { return new InstallRuntimeConfigUseCase( deps.fs, deps.hasher, deps.logger, - deps.assetProvider, + assets, deps.postInstallPipelineUseCase ); } @@ -64,8 +73,36 @@ describe("InstallRuntimeConfigUseCase", () => { version: "1.0.0", }); - expect(result.skipped).toBe(true); - expect(result.fileCount).toBe(0); + expect(result).toStrictEqual({ + toolId: "claude", + fileCount: 0, + files: [], + skipped: true, + warnings: [], + }); + }); + + it("tracks a file the caller chose to skip under the hash it has on disk", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "claude"); + const userContent = '{"user": true}'; + await deps.fs.writeFile(join(PROJECT_ROOT, ".claude/settings.json"), userContent); + + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + await buildUseCase(deps).execute({ + toolId: "claude", + projectRoot: PROJECT_ROOT, + manifest, + force: true, + version: "1.0.0", + onBeforeWriteRegularFile: async () => "skip", + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, ".claude/settings.json"))).toBe(userContent); + expect(manifest.getToolFiles("claude")).toStrictEqual([ + { relativePath: ".claude/settings.json", hash: deps.hasher.hash(userContent) }, + ]); }); it("overwrites existing tracked files when force is true", async () => { @@ -158,5 +195,100 @@ describe("InstallRuntimeConfigUseCase", () => { expect(parsed).toHaveProperty("github.copilot.enable"); expect(parsed).toHaveProperty("chat.plugins.enabled", true); }); + + it("records the merged settings file with a hash per top-level key", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + await buildUseCase(deps).execute({ + toolId: "copilot", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const onDisk = deps.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json")) ?? ""; + expect(manifest.getMergeFiles("copilot")).toStrictEqual([ + { + relativePath: ".vscode/settings.json", + sectionKey: null, + entries: extractMergeEntries(onDisk, null, deps.hasher), + }, + ]); + }); + }); + + describe("static settings declared by the tool", () => { + const inline = new SettingsCapability({ + outputPath: ".cursor/aidd-static.json", + mergeStrategy: "framework-prime", + staticContent: '{"static": true}', + }); + const consumesOnly = new SettingsCapability({ + outputPath: ".cursor/consumed.json", + mergeStrategy: "user-prime", + consumes: ["something"], + }); + const fromAsset = new SettingsCapability({ + outputPath: ".cursor/from-asset.json", + mergeStrategy: "framework-prime", + staticContentAssetFile: "static.json", + }); + + function registerCursorWith(settings: SettingsCapability[]): void { + registerTool({ ...cursor, capabilities: { ...cursor.capabilities, settings } }); + } + + afterEach(() => { + registerTool(cursor); + }); + + it("writes inline content and passes over a capability that only consumes", async () => { + registerCursorWith([inline, consumesOnly]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + const result = await buildUseCase(deps).execute({ + toolId: "cursor", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const written = deps.fs.getFile(join(PROJECT_ROOT, ".cursor/aidd-static.json")) ?? ""; + expect(JSON.parse(written)).toStrictEqual({ static: true }); + expect(deps.fs.has(join(PROJECT_ROOT, ".cursor/consumed.json"))).toBe(false); + expect(result.files.map((f) => f.relativePath)).toStrictEqual([ + ".cursor/settings.json", + ".cursor/aidd-static.json", + ]); + }); + + it("writes an asset answered as text verbatim", async () => { + registerCursorWith([fromAsset]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const assets = new StubAssetProvider( + { "cursor/static.json": '{"fromAsset": true}' }, + deps.assetProvider + ); + + await buildUseCase(deps, assets).execute({ + toolId: "cursor", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const written = deps.fs.getFile(join(PROJECT_ROOT, ".cursor/from-asset.json")) ?? ""; + expect(JSON.parse(written)).toStrictEqual({ fromAsset: true }); + }); }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts new file mode 100644 index 000000000..0f5b9a766 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts @@ -0,0 +1,97 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); +const PROJECT_ROOT = "/test-project"; +const MCP_PLUGIN_DIR = "/plugins/mcp-plugin"; +const OPENCODE_JSON = join(PROJECT_ROOT, "opencode.json"); + +type Deps = Awaited>; + +async function buildOpencodeProject(): Promise<{ + deps: Deps; + logger: CapturingLogger; + useCase: PluginAddUseCase; +}> { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ name: "mcp-plugin", version: "1.0.0" }) + ); + deps.fs.setFile(join(MCP_PLUGIN_DIR, "skills/demo/SKILL.md"), "# Demo skill"); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, ".mcp.json"), + JSON.stringify({ mcpServers: { "local-tool": { command: "node", args: ["./server.js"] } } }) + ); + const logger = new CapturingLogger(); + const useCase = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace() + ); + return { deps, logger, useCase }; +} + +function addLocal(useCase: PluginAddUseCase, path: string, replace?: boolean): Promise { + return useCase.execute({ + source: { kind: "local", path }, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + replace, + }); +} + +function installedMcpPlugin(deps: Deps): InstalledPlugin | undefined { + return deps.manifestRepo + .getCurrent() + ?.getPlugins("opencode") + .find((p) => p.name === "mcp-plugin"); +} + +describe("PluginAddUseCase and opencode MCP servers", () => { + it("keeps its own MCP server, without a collision, when re-added with replace", async () => { + const { deps, logger, useCase } = await buildOpencodeProject(); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await addLocal(useCase, EXTRA_PLUGIN_FIXTURE); + await addLocal(useCase, MCP_PLUGIN_DIR); + const firstEntries = [...(installedMcpPlugin(deps)?.mcpEntries ?? [])]; + + await addLocal(useCase, MCP_PLUGIN_DIR, true); + + expect(firstEntries.map(([name]) => name)).toStrictEqual(["local-tool"]); + expect([...(installedMcpPlugin(deps)?.mcpEntries ?? [])]).toStrictEqual(firstEntries); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("warns for an MCP server the user already owns and records no entry for it", async () => { + const { deps, logger, useCase } = await buildOpencodeProject(); + deps.fs.setFile( + OPENCODE_JSON, + JSON.stringify({ mcp: { "local-tool": { type: "local", command: ["mine"] } } }) + ); + + await addLocal(useCase, MCP_PLUGIN_DIR); + + expect(logger.warnMessages).toStrictEqual([ + 'Plugin "mcp-plugin": mcp skipped for opencode — local-tool: server already exists in opencode.json (user-owned); plugin entry skipped', + ]); + expect([...(installedMcpPlugin(deps)?.mcpEntries ?? [])]).toStrictEqual([]); + }); +}); 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 610d3e316..881db28e2 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 @@ -9,28 +9,81 @@ import { DuplicatePluginError, MissingPluginMetadataError, } from "../../../../../src/kernel/errors.js"; +import type { Logger } from "../../../../../src/kernel/ports/logger.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; +const GREET_PATH = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); +const GITHUB_SOURCE = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/sample-plugin", +}; -async function makeUseCase(deps: Awaited>) { - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); +type Deps = Awaited>; + +class RefusingMarketplaceRegistry extends InMemoryMarketplaceRegistry { + override async list(): Promise { + throw new Error("the registry was consulted"); + } +} + +function buildAddUseCase( + deps: Deps, + registry: InMemoryMarketplaceRegistry = deps.marketplaceRegistry, + logger: Logger = deps.logger +): PluginAddUseCase { return new PluginAddUseCase( deps.fs, deps.manifestRepo, deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs), deps.hasher, - deps.logger, - deps.marketplaceRegistry, + logger, + registry, fakeEnsureBuiltMarketplace() ); } +async function makeUseCase(deps: Deps) { + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + return buildAddUseCase(deps); +} + +async function saveMarketplace( + registry: InMemoryMarketplaceRegistry, + name: string, + source: { kind: "github"; repo: string } | { kind: "local"; path: string } +): Promise { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ name, source, scope: "project", addedAt: "2026-05-01T00:00:00.000Z" }) + ); +} + +function localAdd(toolId: "claude" | "opencode", path = PLUGIN_FIXTURE, replace?: boolean) { + return { + source: { kind: "local" as const, path }, + toolIds: [toolId], + projectRoot: PROJECT_ROOT, + interactive: false, + replace, + }; +} + +function pluginNames(deps: Deps, toolId: "claude" | "opencode" | "codex"): string[] { + return (deps.manifestRepo.getCurrent()?.getPlugins(toolId) ?? []).map((p) => p.name).sort(); +} + describe("PluginAddUseCase", () => { describe("add local plugin for claude", () => { it("writes plugin files and updates manifest", async () => { @@ -595,4 +648,248 @@ describe("PluginAddUseCase", () => { expect(plugins.find((p) => p.name === "zero-plugin")).toBeUndefined(); }); }); + + describe("marketplace resolution", () => { + it("resolves the named marketplace rather than the first registered one", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + await saveMarketplace(registry, "local-mkt", { kind: "local", path: "/mkt-source" }); + + await buildAddUseCase(deps, registry).execute({ + ...localAdd("claude"), + marketplace: "local-mkt", + }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect(installed?.marketplace).toBe("local-mkt"); + }); + + it("treats a marketplace name the registry does not list as a local marketplace", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + + await useCase.execute({ ...localAdd("claude"), marketplace: "ghost" }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect(installed?.marketplace).toBe("ghost"); + }); + + it("never consults the registry for a local add without a marketplace", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + + await buildAddUseCase(deps, new RefusingMarketplaceRegistry()).execute(localAdd("claude")); + + expect(deps.fs.has(GREET_PATH)).toBe(true); + }); + }); + + describe("github marketplace re-registration", () => { + async function registerTwice(deps: Deps, replace: boolean | undefined): Promise { + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + const useCase = buildAddUseCase(deps, registry); + const options = { + source: GITHUB_SOURCE, + toolIds: ["codex" as const], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: { name: "sample-plugin", version: "1.0.0", strict: false }, + }; + await useCase.execute(options); + await useCase.execute({ ...options, replace }); + } + + it("re-registers the plugin when replace is requested", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "codex"); + + await registerTwice(deps, true); + + expect(pluginNames(deps, "codex")).toStrictEqual(["sample-plugin"]); + }); + + it("refuses a second registration without replace", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "codex"); + + await expect(registerTwice(deps, undefined)).rejects.toThrow(DuplicatePluginError); + }); + + it("fetches the distribution once for a flat tool when the catalog omits the version", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + deps.fs.setFile("/built/opencode/.opencode/skills/sample-plugin/demo/SKILL.md", "# Demo"); + 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 fetchSpy = vi.spyOn(deps.pluginFetcher, "fetch"); + + await buildAddUseCase(deps, registry).execute({ + source: GITHUB_SOURCE, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: { name: "sample-plugin", strict: false }, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("duplicate and replace for a local add", () => { + it("re-adds an installed plugin in place when replace is requested", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await useCase.execute(localAdd("claude")); + + await useCase.execute(localAdd("claude", PLUGIN_FIXTURE, true)); + + expect(pluginNames(deps, "claude")).toStrictEqual(["sample-plugin"]); + }); + + it("leaves another installed plugin registered when replacing", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await useCase.execute(localAdd("claude")); + + await useCase.execute(localAdd("claude", EXTRA_PLUGIN_FIXTURE, true)); + + expect(pluginNames(deps, "claude")).toStrictEqual(["extra-plugin", "sample-plugin"]); + }); + + it("accepts a second plugin with a different name", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await useCase.execute(localAdd("claude")); + + await useCase.execute(localAdd("claude", EXTRA_PLUGIN_FIXTURE)); + + expect(pluginNames(deps, "claude")).toStrictEqual(["extra-plugin", "sample-plugin"]); + }); + + it("leaves the installed files untouched when refusing a duplicate", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await useCase.execute(localAdd("claude")); + const installedContent = deps.fs.getFile(GREET_PATH); + deps.fs.setFile( + "/alt/sample-plugin/.claude-plugin/plugin.json", + JSON.stringify({ name: "sample-plugin", version: "1.0.0" }) + ); + deps.fs.setFile("/alt/sample-plugin/commands/greet.md", "# Changed greeting"); + + await expect(useCase.execute(localAdd("claude", "/alt/sample-plugin"))).rejects.toThrow( + DuplicatePluginError + ); + + expect(deps.fs.getFile(GREET_PATH)).toBe(installedContent); + }); + }); + + describe("required version", () => { + it("accepts a required version equal to the plugin's own", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + + await useCase.execute({ ...localAdd("claude"), requiredVersion: "1.0.0" }); + + expect(pluginNames(deps, "claude")).toStrictEqual(["sample-plugin"]); + }); + }); + + describe("native tool from a local marketplace", () => { + it("registers a claude plugin from a local marketplace without writing its files", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "local-mkt", { kind: "local", path: "/mkt-source" }); + + await buildAddUseCase(deps, registry).execute({ + ...localAdd("claude"), + marketplace: "local-mkt", + }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect([ + installed?.marketplace, + installed?.files.size, + deps.fs.has(GREET_PATH), + ]).toStrictEqual(["local-mkt", 0, false]); + }); + + it("materializes a plugin a local marketplace catalogs from github", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GITHUB_SOURCE, PLUGIN_FIXTURE); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "local-mkt", { kind: "local", path: "/mkt-source" }); + + await buildAddUseCase(deps, registry).execute({ + source: GITHUB_SOURCE, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + marketplace: "local-mkt", + interactive: false, + }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect([ + installed?.marketplace, + installed?.files.has(".claude/plugins/sample-plugin/commands/greet.md"), + deps.fs.has(GREET_PATH), + ]).toStrictEqual(["local-mkt", true, true]); + }); + }); + + describe("install notices", () => { + it("logs no install notice for a flat add", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const logger = new CapturingLogger(); + + await buildAddUseCase(deps, deps.marketplaceRegistry, logger).execute(localAdd("opencode")); + + expect(logger.infoMessages).toStrictEqual([]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-helpers.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-helpers.unit.test.ts new file mode 100644 index 000000000..344b291b4 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-helpers.unit.test.ts @@ -0,0 +1,36 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + deletePluginFilesForTool, + loadPluginManifest, +} from "../../../../../src/contexts/framework/application/plugin/plugin-helpers.js"; +import { NoManifestError } from "../../../../../src/kernel/errors.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const PROJECT_ROOT = "/test-project"; + +describe("loadPluginManifest()", () => { + it("refuses a project that was never initialized", async () => { + await expect(loadPluginManifest(new InMemoryManifestRepository())).rejects.toThrow( + NoManifestError + ); + }); +}); + +describe("deletePluginFilesForTool()", () => { + it("returns exactly the tracked paths it deleted, in manifest order", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "a/first.md"), "1"); + fs.setFile(join(PROJECT_ROOT, "b/second.md"), "2"); + const files = new Map([ + ["a/first.md", "h1"], + ["b/second.md", "h2"], + ]); + + const deleted = await deletePluginFilesForTool(files, "project", "claude", PROJECT_ROOT, fs); + + expect(deleted).toStrictEqual(["a/first.md", "b/second.md"]); + expect(fs.listAll()).toStrictEqual([]); + }); +}); 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 f451f7be5..32371c22e 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 @@ -12,7 +12,11 @@ import { PluginNotInMarketplaceError, VersionMismatchError, } from "../../../../../src/kernel/errors.js"; +import type { Logger } from "../../../../../src/kernel/ports/logger.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { ChoosingPrompter } from "../../../../helpers/ports/choosing-prompter.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import type { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; @@ -20,6 +24,10 @@ import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; const MKT1_DIR = "/mkt1"; const MKT2_DIR = "/mkt2"; @@ -32,7 +40,7 @@ function seedMarketplaceFile( fs.writeFile(join(dir, ".claude-plugin/marketplace.json"), JSON.stringify({ plugins })); } -async function buildUseCase() { +async function buildUseCase(options: { logger?: Logger | null; prompter?: Prompter } = {}) { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); @@ -49,16 +57,40 @@ async function buildUseCase() { fakeEnsureBuiltMarketplace() ); const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); + const logger = options.logger === null ? undefined : (options.logger ?? deps.logger); const useCase = new PluginInstallFromMarketplaceUseCase( new ResolveMarketplaceUseCase(fetchMarketplaceSource, catalogRepo), registry, pluginAdd, - new KeepPrompter(), - deps.logger + options.prompter ?? new KeepPrompter(), + logger ); return { useCase, deps, registry }; } +async function saveLocalMarketplace( + registry: InMemoryMarketplaceRegistry, + name: string, + dir: string +): Promise { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "local", path: dir }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); +} + +function installedSamplePlugin(deps: Awaited>) { + return deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); +} + describe("PluginInstallFromMarketplaceUseCase", () => { it("installs a plugin found in a single marketplace and tags it", async () => { const { useCase, deps, registry } = await buildUseCase(); @@ -427,4 +459,159 @@ describe("PluginInstallFromMarketplaceUseCase", () => { expect(result.marketplace.name).toBe("mkt1"); }); + + describe("choosing among several matches", () => { + const entryIn = (version?: string) => ({ + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + ...(version === undefined ? {} : { version }), + }); + + it("lets an interactive user pick among the marketplaces that match", async () => { + const prompter = new ChoosingPrompter("mkt2 — ?"); + const { useCase, deps, registry } = await buildUseCase({ prompter }); + seedMarketplaceFile(deps.fs, MKT1_DIR, [entryIn("1.0.0")]); + seedMarketplaceFile(deps.fs, MKT2_DIR, [entryIn()]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + await saveLocalMarketplace(registry, "mkt2", MKT2_DIR); + + const result = await useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.marketplace.name).toBe("mkt2"); + expect(prompter.selectCalls).toStrictEqual([ + { + message: "Multiple matches for 'sample-plugin'. Select one:", + choiceNames: ["mkt1 — 1.0.0", "mkt2 — ?"], + }, + ]); + }); + + it("names every matching marketplace when refusing a non-interactive install", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [entryIn("1.0.0")]); + seedMarketplaceFile(deps.fs, MKT2_DIR, [entryIn("1.0.0")]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + await saveLocalMarketplace(registry, "mkt2", MKT2_DIR); + + await expect( + useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }) + ).rejects.toThrow( + "Plugin 'sample-plugin' matches multiple marketplaces: mkt1, mkt2. Use --from ." + ); + }); + + it("matches a catalog entry by name alone", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { name: "extra-plugin", source: { kind: "local", path: EXTRA_PLUGIN_FIXTURE } }, + entryIn("1.0.0"), + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + const result = await useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result.entry.name).toBe("sample-plugin"); + }); + + it("passes over a marketplace that has no catalog", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [entryIn("1.0.0")]); + await saveLocalMarketplace(registry, "no-catalog", "/no-catalog"); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + const result = await useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result.marketplace.name).toBe("mkt1"); + }); + }); + + describe("requested version against the catalog", () => { + it("accepts a requested version equal to the catalog's", 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 useCase.execute({ + pluginName: "sample-plugin", + version: "1.0.0", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(installedSamplePlugin(deps)?.version).toBe("1.0.0"); + }); + + it("logs nothing under prefer-catalog when no version was requested", async () => { + const logger = new CapturingLogger(); + const { useCase, deps, registry } = await buildUseCase({ logger }); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "2.0.0", + }, + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + await useCase.execute({ + pluginName: "sample-plugin", + requestedVersionPolicy: "prefer-catalog", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(logger.allMessages).toStrictEqual([]); + }); + + it("tolerates having no logger when the catalog version differs under prefer-catalog", async () => { + const { useCase, deps, registry } = await buildUseCase({ logger: null }); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "2.0.0", + }, + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + await useCase.execute({ + pluginName: "sample-plugin", + version: "1.0.0", + requestedVersionPolicy: "prefer-catalog", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(installedSamplePlugin(deps)?.marketplace).toBe("mkt1"); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts index cd9345d57..60107d6c4 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts @@ -6,6 +6,7 @@ import type { MarketplaceTrustStore } from "../../../../../src/contexts/distribu import type { PluginAdd } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import type { PluginInstallFromMarketplace } from "../../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; import { PluginInstallUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-install-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InteractiveOnlyError, InvalidPluginScopeError, @@ -18,6 +19,12 @@ import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory- const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; +function manifestWith(toolId: "claude" | "cursor"): InMemoryManifestRepository { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + return new InMemoryManifestRepository(manifest, PROJECT_ROOT); +} + function makeAlwaysTrustStore(): MarketplaceTrustStore { return { isTrusted: vi.fn().mockResolvedValue(true), @@ -42,6 +49,7 @@ function makeUseCases(overrides?: { marketplaceExecute?: ReturnType; trustStore?: MarketplaceTrustStore; prompter?: Prompter; + manifestRepo?: InMemoryManifestRepository; }) { const pickExecute = overrides?.pickExecute ?? vi.fn(); const addExecute = overrides?.addExecute ?? vi.fn(); @@ -51,7 +59,7 @@ function makeUseCases(overrides?: { const pluginInstallFromMarketplaceUseCase: PluginInstallFromMarketplace = { execute: marketplaceExecute, }; - const manifestRepo = new InMemoryManifestRepository(); + const manifestRepo = overrides?.manifestRepo ?? new InMemoryManifestRepository(); const trustStore = overrides?.trustStore ?? makeAlwaysTrustStore(); const prompter = overrides?.prompter ?? makeSilentPrompter(); return { @@ -278,4 +286,227 @@ describe("PluginInstallUseCase", () => { expect(trustStore.isTrusted).not.toHaveBeenCalled(); }); }); + + describe("scope validation against every targeted tool", () => { + const marketplaceExecute = () => vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + it("checks every AI tool when no manifest exists", async () => { + await expect( + makeUseCase({ marketplaceExecute: marketplaceExecute() }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + scope: "project", + }) + ).rejects.toBeInstanceOf(InvalidPluginScopeError); + }); + + it("checks only the installed tools when a manifest exists", async () => { + const result = await makeUseCase({ + marketplaceExecute: marketplaceExecute(), + manifestRepo: manifestWith("claude"), + }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + scope: "project", + }); + + expect(result.kind).toBe("marketplace"); + }); + + it("rejects a scope an installed tool refuses", async () => { + await expect( + makeUseCase({ + marketplaceExecute: marketplaceExecute(), + manifestRepo: manifestWith("claude"), + }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + scope: "user", + }) + ).rejects.toBeInstanceOf(InvalidPluginScopeError); + }); + }); + + describe("source argument shapes", () => { + it("routes a URL argument to the local-source add", async () => { + const addExecute = vi.fn().mockResolvedValue(undefined); + + const result = await makeUseCase({ addExecute }).execute({ + pluginArg: "https://github.com/x/y.git", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result).toStrictEqual({ kind: "local", installed: [] }); + }); + + it("routes a relative ./ path to the local-source add", async () => { + const addExecute = vi.fn().mockResolvedValue(undefined); + + const result = await makeUseCase({ addExecute }).execute({ + pluginArg: "./plugins/mine", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result).toStrictEqual({ kind: "local", installed: [] }); + }); + }); + + describe("what each delegate receives", () => { + it("names the action when refusing a non-interactive pick", async () => { + await expect( + makeUseCase().execute({ + pluginArg: undefined, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }) + ).rejects.toThrow("'plugin install' requires an interactive terminal."); + }); + + it("hands the pick its tools, project and an interactive flag", async () => { + const pickExecute = vi.fn().mockResolvedValue({ installed: [] }); + + await makeUseCase({ pickExecute }).execute({ + pluginArg: undefined, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(pickExecute).toHaveBeenCalledWith({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + }); + + it("hands the add the parsed source and the caller's options", async () => { + const addExecute = vi.fn().mockResolvedValue(undefined); + + const result = await makeUseCase({ addExecute }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(addExecute).toHaveBeenCalledWith({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + expect(result).toStrictEqual({ kind: "local", installed: [] }); + }); + + it("hands the marketplace install the parsed name, version and options", async () => { + const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + await makeUseCase({ marketplaceExecute }).execute({ + pluginArg: "my-plugin@1.2.3", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + fromMarketplace: "mkt", + yes: true, + }); + + expect(marketplaceExecute).toHaveBeenCalledWith({ + pluginName: "my-plugin", + version: "1.2.3", + fromMarketplace: "mkt", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: true, + }); + }); + + it("defaults autoSelect to false when --yes is absent", async () => { + const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + await makeUseCase({ marketplaceExecute }).execute({ + pluginArg: "my-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(marketplaceExecute).toHaveBeenCalledWith({ + pluginName: "my-plugin", + version: undefined, + fromMarketplace: undefined, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: false, + }); + }); + }); + + describe("trusting a direct source", () => { + function untrustedStore(): MarketplaceTrustStore { + return { + isTrusted: vi.fn().mockResolvedValue(false), + trust: vi.fn().mockResolvedValue(undefined), + }; + } + + it("neither prompts nor records trust for a source already trusted", async () => { + const trustStore = makeAlwaysTrustStore(); + const prompter = makeSilentPrompter(); + + await makeUseCase({ addExecute: vi.fn(), trustStore, prompter }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(trustStore.trust).not.toHaveBeenCalled(); + }); + + it("records trust without prompting when --yes is passed", async () => { + const trustStore = untrustedStore(); + const prompter = makeSilentPrompter(); + + await makeUseCase({ addExecute: vi.fn(), trustStore, prompter }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + yes: true, + }); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(trustStore.trust).toHaveBeenCalledWith(PROJECT_ROOT, { + kind: "local", + path: PLUGIN_FIXTURE, + }); + }); + + it("asks to trust the source by its description", async () => { + const prompter = makeSilentPrompter(); + + await makeUseCase({ addExecute: vi.fn(), trustStore: untrustedStore(), prompter }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(prompter.confirm).toHaveBeenCalledWith(`Trust plugin source '${PLUGIN_FIXTURE}'?`); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts index dddbf2bf6..905f99e37 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts @@ -6,6 +6,7 @@ import { homedir } from "node:os"; 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/copilot/profile.js"; import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; @@ -174,4 +175,85 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { expect(await fs.fileExists(witness)).toBe(true); expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); }); + + it("names the purged cache directory", async () => { + const fs = new InMemoryFileAdapter(); + await fs.writeFile( + join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"), + "{}" + ); + const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", new FakeNativePluginActivator({ available: true })]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for '${PLUGIN_NAME}' purged: ${join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME)}`, + ]); + }); + + it("leaves the cache alone, silently, when no activator drives this tool", async () => { + const fs = new InMemoryFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase(fs, manifestRepo, logger, new Map()); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + expect(logger.allMessages).toStrictEqual([]); + }); + + it("purges nothing, silently, for a host that declares no plugin cache directory", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("copilot", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "copilot", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + ALIAS + ); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + pluginRefs: [REF], + }); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["copilot", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["copilot"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(logger.allMessages).toStrictEqual([]); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts index 2468e8f25..8582973d0 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts @@ -350,4 +350,69 @@ describe("PluginRemoveUseCase undoes native activation", () => { expect(activator.uninstalledPlugins).toEqual([REF]); expect(activator.uninstalledPluginScopes).toEqual(["user"]); }); + + it("logs nothing when this tool's own native registrations name the alias", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE_NAME, hostName: "upstream" }], + pluginRefs: [], + }); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@upstream`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names the host, the ref and the host's answer when the host CLI refuses the uninstall", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + failOnUninstall: [REF], + }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(logger.warnMessages).toStrictEqual([ + `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed — an entry for it may remain in claude's own plugin registry.`, + ]); + }); + + it("propagates a failure that is not the host CLI refusing", async () => { + const activator = new FakeNativePluginActivator({ available: true, crashOnUninstall: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow("activator crashed uninstalling a plugin"); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts index 31fdf29e8..67d584575 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts @@ -176,4 +176,103 @@ describe("plugin remove guards a ref another project on this machine still needs expect(activator.uninstalledPlugins).toContain(REF); expect(logger.warnMessages).toEqual([]); }); + + describe("a ref outside the shared source", () => { + it("uninstalls a ref from a marketplace that is not the shared source, whatever other projects reference", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const registry = seedSharedMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-mkt", + source: { kind: "local", path: "/other/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const { removeUseCase } = buildUseCase( + fs, + activator, + logger, + seedManifest("other-mkt"), + registry + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@other-mkt`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("uninstalls a ref whose marketplace this project's registry no longer lists", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase } = buildUseCase(fs, activator, logger, seedManifest("gone-mkt")); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@gone-mkt`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + }); + + describe("without one of the guard's two registries", () => { + it("skips the guard when no references registry is wired, even for the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const removeUseCase = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(seedManifest(), PROJECT_ROOT), + new CapturingLogger(), + new Map([["codex", activator]]), + new Map(), + undefined, + seedSharedMarketplaceRegistry() + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + }); + + it("skips the guard when no marketplace registry is wired", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const removeUseCase = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(seedManifest(), PROJECT_ROOT), + new CapturingLogger(), + new Map([["codex", activator]]), + new Map(), + new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts index e87ef7c19..3e3dc52f1 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts @@ -1,12 +1,17 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; -import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { + InstalledPlugin, + type PluginEntryData, +} from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { PluginNotFoundError } from "../../../../../src/kernel/errors.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; @@ -14,22 +19,49 @@ import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-ad import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; -/** Records every path `deleteFile` is called with, so a test can prove where a plugin's - * file actually got deleted from without inspecting private use-case state. */ class RecordingFileAdapter extends InMemoryFileAdapter { readonly deletedPaths: string[] = []; + readonly writtenPaths: string[] = []; override async deleteFile(path: string): Promise { this.deletedPaths.push(path); return super.deleteFile(path); } + + override async writeFile(path: string, content: string): Promise { + this.writtenPaths.push(path); + return super.writeFile(path, content); + } +} + +class UnreadableFileAdapter extends InMemoryFileAdapter { + constructor(private readonly unreadablePath: string) { + super(); + } + + override async readFile(path: string): Promise { + if (path === this.unreadablePath) { + throw Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { + code: "EACCES", + }); + } + return super.readFile(path); + } } const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; +const OPENCODE_JSON = join(PROJECT_ROOT, "opencode.json"); -async function installPlugin(deps: Awaited>): Promise { - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); +async function installPlugin( + deps: Awaited>, + fixture = PLUGIN_FIXTURE +): Promise { + await seedFromDirectory(deps.fs, fixture, { useAbsolutePaths: true }); const addUseCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, @@ -41,13 +73,40 @@ async function installPlugin(deps: Awaited>): P fakeEnsureBuiltMarketplace() ); await addUseCase.execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, + source: { kind: "local", path: fixture }, toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: false, }); } +function manifestHolding( + toolId: AiToolId, + entry: Partial & { name: string } +): InMemoryManifestRepository { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.addPlugin( + toolId, + InstalledPlugin.fromJSON({ + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + ...entry, + }) + ); + return new InMemoryManifestRepository(manifest, PROJECT_ROOT); +} + +function removeUseCaseOver( + fs: InMemoryFileAdapter, + manifestRepo: InMemoryManifestRepository +): PluginRemoveUseCase { + return new PluginRemoveUseCase(fs, manifestRepo, new CapturingLogger(), new Map()); +} + describe("PluginRemoveUseCase", () => { describe("remove installed plugin", () => { it("deletes plugin files and updates manifest", async () => { @@ -135,4 +194,96 @@ describe("PluginRemoveUseCase", () => { ); }); }); + + describe("only the named plugin", () => { + it("deletes only the named plugin's files when another plugin is installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installPlugin(deps); + await installPlugin(deps, EXTRA_PLUGIN_FIXTURE); + const greet = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + const bye = join(PROJECT_ROOT, ".claude/plugins/extra-plugin/commands/bye.md"); + + await new PluginRemoveUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.nativePluginActivators + ).execute({ pluginName: "extra-plugin", toolIds: ["claude"], projectRoot: PROJECT_ROOT }); + + expect([deps.fs.has(greet), deps.fs.has(bye)]).toStrictEqual([true, false]); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .map((p) => p.name) + ).toStrictEqual(["sample-plugin"]); + }); + }); + + describe("a tool without native activation", () => { + it("removes a cursor plugin recorded with a marketplace without driving any host CLI", async () => { + const manifestRepo = manifestHolding("cursor", { name: "aidd-context", marketplace: "mkt" }); + + await removeUseCaseOver(new InMemoryFileAdapter(), manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }); + + expect(manifestRepo.getCurrent()?.getPlugins("cursor")).toStrictEqual([]); + }); + }); + + describe("MCP entries on removal", () => { + it("does not rewrite opencode's config when the plugin carries no MCP entries", async () => { + const fs = new RecordingFileAdapter(); + fs.setFile(OPENCODE_JSON, JSON.stringify({ mcp: { mine: { type: "local" } } })); + const manifestRepo = manifestHolding("opencode", { name: "aidd-context" }); + + await removeUseCaseOver(fs, manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }); + + expect(fs.writtenPaths).toStrictEqual([]); + }); + + it("leaves a native tool's own MCP file untouched when the plugin carries MCP entries", async () => { + const fs = new InMemoryFileAdapter(); + const mcpFile = join(PROJECT_ROOT, ".mcp.json"); + const content = JSON.stringify({ mcpServers: { srv: { command: "node" } } }); + fs.setFile(mcpFile, content); + const manifestRepo = manifestHolding("claude", { + name: "aidd-context", + mcpEntries: { srv: "abc123" }, + }); + + await removeUseCaseOver(fs, manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(fs.getFile(mcpFile)).toBe(content); + }); + + it("propagates a read failure of opencode's config that is not an absence", async () => { + const fs = new UnreadableFileAdapter(OPENCODE_JSON); + fs.setFile(OPENCODE_JSON, "{}"); + const manifestRepo = manifestHolding("opencode", { + name: "aidd-context", + mcpEntries: { srv: "abc123" }, + }); + + await expect( + removeUseCaseOver(fs, manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow("EACCES: permission denied"); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts index 8719269ce..73e8404d5 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts @@ -145,6 +145,80 @@ describe("PluginSearchUseCase", () => { expect(result.hits[0]?.marketplace.name).toBe("mkt2"); }); + describe("what a query matches", () => { + async function registryWith(entry: Record): Promise<{ + fs: InMemoryFileAdapter; + registry: InMemoryMarketplaceRegistry; + }> { + const fs = new InMemoryFileAdapter(); + seedMarketplace(fs, MKT1_PATH, [entry]); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "mkt1", + source: { kind: "local", path: MKT1_PATH }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + return { fs, registry }; + } + + it("matches nothing when neither name nor description contains the query", async () => { + const { fs, registry } = await registryWith({ + name: "sample-plugin", + source: { kind: "github", repo: "x/y" }, + description: "Hello world", + }); + + const result = await buildUseCase(fs, registry).execute({ + query: "zzz", + recommendedOnly: false, + projectRoot: PROJECT_ROOT, + }); + + expect(result.hits).toStrictEqual([]); + }); + + it("does not match a description an entry lacks", async () => { + const { fs, registry } = await registryWith({ + name: "sample-plugin", + source: { kind: "github", repo: "x/y" }, + }); + + const result = await buildUseCase(fs, registry).execute({ + query: "was here", + recommendedOnly: false, + projectRoot: PROJECT_ROOT, + }); + + expect(result.hits).toStrictEqual([]); + }); + }); + + it("yields no hit for a marketplace without a catalog", async () => { + const fs = new InMemoryFileAdapter(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "no-catalog", + source: { kind: "local", path: "/no-catalog" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await buildUseCase(fs, registry).execute({ + query: "", + recommendedOnly: false, + projectRoot: PROJECT_ROOT, + }); + + expect(result.hits).toStrictEqual([]); + }); + it("returns empty when no marketplaces are registered", async () => { const fs = new InMemoryFileAdapter(); const registry = new InMemoryMarketplaceRegistry(); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts index 90f2bd75b..4d684782c 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts @@ -3,14 +3,71 @@ import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { + isFrameworkPrimeFlatMcp, resolveBaseDirFromRecord, + resolvePluginToolIds, resolveScopeForInstall, } from "../../../../../src/contexts/framework/application/plugin/plugin-target-resolution.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { McpCapability } from "../../../../../src/contexts/tools/domain/capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; import { UnresolvableUserScopeError } from "../../../../../src/kernel/errors.js"; const HOME = "/home/u"; const homedir = () => HOME; +function mcp(mergeStrategy?: "user-prime" | "framework-prime"): McpCapability { + return new McpCapability({ outputPath: "mcp.json", format: "json", mergeStrategy }); +} + +const FLAT_PLUGINS = new PluginsCapability({ + mode: "flat", + flatNamespacePrefix: "x-", + acceptsHooks: false, + hooksUnsupportedReason: "none", +}); +const NATIVE_PLUGINS = new PluginsCapability({ + mode: "native", + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + acceptsHooks: false, + hooksUnsupportedReason: "none", +}); + +describe("resolvePluginToolIds()", () => { + it("resolves 'all' to the AI tools the manifest holds, in registry order", () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addTool("claude", "1.0.0", []); + + expect(resolvePluginToolIds("all", manifest)).toStrictEqual(["claude", "cursor"]); + }); +}); + +describe("isFrameworkPrimeFlatMcp()", () => { + it("fails for a tool declaring no MCP capability", () => { + expect(isFrameworkPrimeFlatMcp({ plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for an mcp declaration that is not a capability", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: "framework-prime", plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for a flat tool merging MCP user-prime", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: mcp("user-prime"), plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for a flat tool declaring no merge strategy", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: mcp(), plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for a native tool merging MCP framework-prime", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: mcp("framework-prime"), plugins: NATIVE_PLUGINS })).toBe( + false + ); + }); +}); + describe("resolveScopeForInstall()", () => { it("reads the scope a fresh install writes from the tool's own profile", () => { expect(resolveScopeForInstall("cursor")).toBe("user"); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts index 968dde656..a6750db11 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts @@ -1,16 +1,43 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import type { PluginFetchOptions } from "../../../../../src/contexts/distribution/domain/ports/plugin-fetcher.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import type { BuiltMaterializationDeps } from "../../../../../src/contexts/framework/application/shared/apply-plugin-files-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { PluginSource } from "../../../../../src/kernel/source.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; +const GREET_PATH = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); -async function setup(deps: Awaited>) { +type Deps = Awaited>; + +class RecordingFetcher extends FixturePluginFetcher { + readonly fetchOptions: Array = []; + + override fetch( + source: PluginSource, + cacheDir: string, + options?: PluginFetchOptions + ): Promise { + this.fetchOptions.push(options); + return super.fetch(source, cacheDir, options); + } +} + +async function setup( + deps: Deps, + options: { fetcher?: FixturePluginFetcher; builtDeps?: BuiltMaterializationDeps } = {} +) { await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); const reader = new PluginDistributionReaderAdapter(deps.fs); const addUseCase = new PluginAddUseCase( @@ -26,13 +53,39 @@ async function setup(deps: Awaited>) { const updateUseCase = new PluginUpdateUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, + options.fetcher ?? deps.pluginFetcher, reader, - deps.hasher + deps.hasher, + options.builtDeps ); return { addUseCase, updateUseCase }; } +async function addLocal(addUseCase: PluginAddUseCase, path = PLUGIN_FIXTURE): Promise { + await addUseCase.execute({ + source: { kind: "local", path }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); +} + +async function recordVersion(deps: Deps, name: string, version: string): Promise { + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest not found"); + const plugin = manifest.getPlugins("claude").find((p) => p.name === name); + if (plugin === undefined) throw new Error("plugin not found"); + manifest.updatePlugin("claude", plugin.withVersion(version)); + await deps.manifestRepo.save(manifest); +} + +function installed(deps: Deps, name: string) { + return deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === name); +} + describe("PluginUpdateUseCase", () => { describe("same version", () => { it("does not re-write files when version is equal", async () => { @@ -85,4 +138,97 @@ describe("PluginUpdateUseCase", () => { expect(updatedPlugin?.version).toBe("1.0.0"); }); }); + + describe("what it reports", () => { + it("reports nothing when the installed version is current", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps); + await addLocal(addUseCase); + + const updated = await updateUseCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(updated).toStrictEqual([]); + }); + + it("reports exactly the plugin it updated", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps); + await addLocal(addUseCase); + await recordVersion(deps, "sample-plugin", "0.0.1"); + + const updated = await updateUseCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(updated).toStrictEqual(["sample-plugin"]); + }); + + it("updates only the plugins named", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await addLocal(addUseCase); + await addLocal(addUseCase, EXTRA_PLUGIN_FIXTURE); + await recordVersion(deps, "sample-plugin", "0.0.1"); + await recordVersion(deps, "extra-plugin", "0.0.1"); + + const updated = await updateUseCase.execute({ + pluginNames: ["extra-plugin"], + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(updated).toStrictEqual(["extra-plugin"]); + expect([ + installed(deps, "sample-plugin")?.version, + installed(deps, "extra-plugin")?.version, + ]).toStrictEqual(["0.0.1", "1.0.0"]); + }); + }); + + describe("how it fetches", () => { + it("fetches the plugin source with a forced refresh", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const fetcher = new RecordingFetcher(); + const { addUseCase, updateUseCase } = await setup(deps, { fetcher }); + await addLocal(addUseCase); + + await updateUseCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT }); + + expect(fetcher.fetchOptions).toStrictEqual([{ forceRefresh: true }]); + }); + }); + + describe("a plugin installed from a local path", () => { + it("re-writes its files on disk even when built-tree deps are wired", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps, { + builtDeps: { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: deps.marketplaceRegistry, + homedir: () => "/home/u", + }, + }); + await addLocal(addUseCase); + await recordVersion(deps, "sample-plugin", "0.0.1"); + + await updateUseCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT }); + + const plugin = installed(deps, "sample-plugin"); + expect([ + plugin?.version, + plugin?.files.has(".claude/plugins/sample-plugin/commands/greet.md"), + deps.fs.has(GREET_PATH), + ]).toStrictEqual(["1.0.0", true, true]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts index 84d9787b8..616e8f2a8 100644 --- a/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts @@ -124,3 +124,60 @@ describe("RestoreAllPluginsUseCase — native-activation tools", () => { expect(result.nativeOnlyToolIds).toEqual([]); }); }); + +function trackedPlugin(name: string): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { "a/one.md": "00000000000000000000000000000000" }, + scope: "project", + }); +} + +describe("RestoreAllPluginsUseCase — restricting to one plugin", () => { + function claudeWithMixedPlugins(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin("claude", nativePlugin({})); + manifest.addPlugin("claude", trackedPlugin("tracked-plugin")); + return manifest; + } + + function useCase(): RestoreAllPluginsUseCase { + return new RestoreAllPluginsUseCase(noopFs, noopHasher, stubFetcher, emptyDistributionReader); + } + + it("names the tool when the one plugin asked for tracks zero files, whatever its others track", async () => { + const result = await useCase().execute({ + projectRoot: "/proj", + manifest: claudeWithMixedPlugins(), + fileFilter: null, + pluginName: "aidd-test", + }); + + expect(result.nativeOnlyToolIds).toStrictEqual(["claude"]); + }); + + it("does not name the tool when the plugin asked for tracks files", async () => { + const result = await useCase().execute({ + projectRoot: "/proj", + manifest: claudeWithMixedPlugins(), + fileFilter: null, + pluginName: "tracked-plugin", + }); + + expect(result.nativeOnlyToolIds).toStrictEqual([]); + }); + + it("does not name the tool when any of its plugins tracks files", async () => { + const result = await useCase().execute({ + projectRoot: "/proj", + manifest: claudeWithMixedPlugins(), + fileFilter: null, + }); + + expect(result.nativeOnlyToolIds).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts index 56bdbe5cf..7bdedf56c 100644 --- a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -6,11 +6,15 @@ import { RestoreUseCase } from "../../../../src/contexts/framework/application/r import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { NoManifestError } from "../../../../src/kernel/errors.js"; +import type { Prompter } from "../../../../src/kernel/ports/prompter.js"; import { buildUnitDeps, initAndInstall, + initProject, installTool, } from "../../../helpers/ports/build-unit-deps.js"; +import { CheckboxRecordingPrompter } from "../../../helpers/ports/checkbox-recording-prompter.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakePlatform } from "../../../helpers/ports/fake-platform.js"; import { OverwritePrompter, ScriptedPrompter } from "../../../helpers/ports/scripted-prompter.js"; @@ -387,3 +391,189 @@ describe("RestoreAllUseCase — native-only tools", () => { expect(result.nativeOnlyToolIds).toEqual(["claude"]); }); }); + +type RestoreOptions = Parameters[0]; +type RestoreOutcome = Awaited>; + +const NOTHING_RESTORED: RestoreOutcome = { + tools: [], + totalRestored: 0, + totalKept: 0, + totalPluginFilesRestored: 0, + restoredPluginNames: [], + unrestorable: [], + nativeOnlyToolIds: [], +}; + +function restoreAllDelegatingTo( + deps: Deps, + prompter: Prompter, + delegate: (options: RestoreOptions) => Promise +): RestoreAllUseCase { + const statusUseCase = new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ); + const restoreUseCase = new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + prompter + ); + restoreUseCase.execute = delegate; + return new RestoreAllUseCase(deps.manifestRepo, prompter, statusUseCase, restoreUseCase); +} + +function recordingDelegate(): { + asked: RestoreOptions[]; + delegate: (o: RestoreOptions) => Promise; +} { + const asked: RestoreOptions[] = []; + return { + asked, + delegate: async (options) => { + asked.push(options); + return NOTHING_RESTORED; + }, + }; +} + +describe("RestoreAllUseCase — before delegating", () => { + it("refuses a project that has no manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const useCase = restoreAllDelegatingTo( + deps, + new OverwritePrompter(), + recordingDelegate().delegate + ); + + await expect(useCase.execute(PROJECT_ROOT, false, false)).rejects.toThrow(NoManifestError); + }); + + it("hands the restore the version of the first installed tool", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, new OverwritePrompter(), delegate).execute( + PROJECT_ROOT, + false, + false + ); + + expect(asked.map((o) => o.version)).toStrictEqual(["test"]); + }); + + it("hands the restore an unknown version when no tool is installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, new OverwritePrompter(), delegate).execute( + PROJECT_ROOT, + false, + false + ); + + expect(asked.map((o) => o.version)).toStrictEqual(["unknown"]); + }); + + it("reports a failing restore as a config-restore error with nothing restored", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = restoreAllDelegatingTo(deps, new OverwritePrompter(), async () => { + throw new Error("disk on fire"); + }); + + const result = await useCase.execute(PROJECT_ROOT, false, false); + + expect(result).toStrictEqual({ + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + errors: [{ scope: "config-restore", message: "disk on fire" }], + unrestorable: [], + nativeOnlyToolIds: [], + }); + }); +}); + +describe("RestoreAllUseCase — the interactive file picker", () => { + const KEYBINDINGS = ".vscode/keybindings.json"; + const SETTINGS = ".vscode/settings.json"; + + async function vscodeProject(): Promise { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + return deps; + } + + it("offers the modified and deleted tracked entries, never a file the user added", async () => { + const deps = await vscodeProject(); + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + await deps.fs.deleteFile(join(PROJECT_ROOT, ".vscode/extensions.json")); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/user-added.json"), "{}"); + const prompter = new CheckboxRecordingPrompter(); + + await restoreAllDelegatingTo(deps, prompter, recordingDelegate().delegate).execute( + PROJECT_ROOT, + false, + true + ); + + expect(prompter.asks).toStrictEqual([ + { + message: "Select files to restore:", + offered: [ + KEYBINDINGS, + ".vscode/extensions.json > recommendations", + ".vscode/extensions.json > unwantedRecommendations", + ], + }, + ]); + }); + + it("forwards exactly the entries the user ticked", async () => { + const deps = await vscodeProject(); + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + await deps.fs.deleteFile(join(PROJECT_ROOT, SETTINGS)); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo( + deps, + new CheckboxRecordingPrompter([KEYBINDINGS]), + delegate + ).execute(PROJECT_ROOT, false, true); + + expect(asked.map((o) => o.files)).toStrictEqual([[KEYBINDINGS]]); + }); + + it("forwards an empty selection as no file at all", async () => { + const deps = await vscodeProject(); + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, new CheckboxRecordingPrompter([]), delegate).execute( + PROJECT_ROOT, + false, + true + ); + + expect(asked.map((o) => o.files)).toStrictEqual([[]]); + }); + + it("asks nothing and selects nothing when no tracked entry drifted", async () => { + const deps = await vscodeProject(); + const prompter = new CheckboxRecordingPrompter([KEYBINDINGS]); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, prompter, delegate).execute(PROJECT_ROOT, false, true); + + expect(prompter.asks).toStrictEqual([]); + expect(asked.map((o) => o.files)).toStrictEqual([[]]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts index 87742be04..2580bc7af 100644 --- a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts @@ -2,7 +2,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { RestoreUseCase } from "../../../../src/contexts/framework/application/restore/restore-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { InstallationFile } from "../../../../src/kernel/file.js"; import { buildUnitDeps, FIXTURE_DIR, @@ -12,7 +14,11 @@ import { } from "../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakePlatform } from "../../../helpers/ports/fake-platform.js"; -import { KeepPrompter, OverwritePrompter } from "../../../helpers/ports/scripted-prompter.js"; +import { + KeepPrompter, + OverwritePrompter, + ScriptedPrompter, +} from "../../../helpers/ports/scripted-prompter.js"; import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; const PROJECT_ROOT = "/test-project"; @@ -476,3 +482,253 @@ describe("restore", () => { }); }); }); + +function withoutPluginSources(deps: Awaited>): RestoreUseCase { + return new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + new OverwritePrompter() + ); +} + +function withFetcherOnly(deps: Awaited>): RestoreUseCase { + return new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + new OverwritePrompter(), + deps.pluginFetcher + ); +} + +async function loadedManifest(deps: Awaited>): Promise { + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("the project must be initialized first"); + return manifest; +} + +describe("restore — consent to overwrite", () => { + it("refuses to overwrite a modified file when neither force nor a TTY was given", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/keybindings.json"), "[]"); + + await expect( + makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow("--force"); + }); +}); + +describe("restore — plugin sources", () => { + const PLUGIN_FILE = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + + async function claudeWithCorruptedPlugin() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installPlugin(deps, "claude"); + await deps.fs.writeFile(PLUGIN_FILE, "CORRUPTED"); + return deps; + } + + it("leaves plugin files alone when no plugin source is wired", async () => { + const deps = await claudeWithCorruptedPlugin(); + + const result = await withoutPluginSources(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + }); + + expect(result.totalPluginFilesRestored).toBe(0); + expect(result.restoredPluginNames).toStrictEqual([]); + expect(result.nativeOnlyToolIds).toStrictEqual([]); + expect(deps.fs.getFile(PLUGIN_FILE)).toBe("CORRUPTED"); + }); + + it("leaves plugin files alone when only the fetcher is wired", async () => { + const deps = await claudeWithCorruptedPlugin(); + + const result = await withFetcherOnly(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + }); + + expect(result.totalPluginFilesRestored).toBe(0); + expect(deps.fs.getFile(PLUGIN_FILE)).toBe("CORRUPTED"); + }); +}); + +describe("restore — persisting the manifest", () => { + it("persists the manifest it was handed once one tool restored a file, even when another had nothing to restore", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + await installTool(deps, PROJECT_ROOT, "cursor"); + const handed = Manifest.fromJSON((await loadedManifest(deps)).toJSON()); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/keybindings.json"), "[]"); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + manifest: handed, + }); + + expect(deps.manifestRepo.getCurrent()).toBe(handed); + }); + + it("does not persist anything when no file drifted", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + const stored = await loadedManifest(deps); + const handed = Manifest.fromJSON(stored.toJSON()); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + manifest: handed, + }); + + expect(deps.manifestRepo.getCurrent()).toBe(stored); + }); + + it("persists the manifest when only a plugin file was restored", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installPlugin(deps, "claude"); + const handed = Manifest.fromJSON((await loadedManifest(deps)).toJSON()); + await deps.fs.writeFile( + join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"), + "CORRUPTED" + ); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + manifest: handed, + }); + + expect(deps.manifestRepo.getCurrent()).toBe(handed); + }); +}); + +describe("restore — totals", () => { + it("counts the files restored and kept across a tool's sections", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/keybindings.json"), "[]"); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/settings.json"), '{"editor.tabSize": 3}'); + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.conflict("overwrite"), + ScriptedPrompter.answer.conflict("keep"), + ]); + + const result = await makeRestoreUseCase(deps, prompter).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.totalRestored).toBe(1); + expect(result.totalKept).toBe(1); + expect(result.unrestorable).toStrictEqual([]); + }); +}); + +describe("restore — the framework path", () => { + it("reads only the config files the framework path actually holds", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + const keybindings = join(PROJECT_ROOT, ".vscode/keybindings.json"); + await deps.fs.writeFile("/partial-framework/config/vscode/keybindings.json", "[]"); + await deps.fs.deleteFile(keybindings); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: "/partial-framework", + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + }); + + expect(deps.fs.getFile(keybindings)).toBe("[]"); + }); +}); + +describe("restore — the file selection", () => { + const RUN = ".claude/hooks/run.js"; + const OTHER = ".claude/hooks/other.js"; + + async function claudeTrackingGhosts() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const manifest = await loadedManifest(deps); + const ghosts = [RUN, OTHER].map( + (relativePath) => + new InstallationFile({ relativePath, content: "", hash: deps.hasher.hash(relativePath) }) + ); + manifest.addTool("claude", "test", ghosts, [...manifest.getMergeFiles("claude")]); + return deps; + } + + async function unrestorableFor( + deps: Awaited>, + files: string[] + ): Promise { + const result = await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + files, + }); + return result.unrestorable; + } + + it("admits only the file named exactly", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [RUN])).toStrictEqual([RUN]); + }); + + it("admits a file when any entry of the selection names it", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, ["nothing.md", RUN])).toStrictEqual([RUN]); + }); + + it("admits every file under a directory named without a trailing slash", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [".claude/hooks"])).toStrictEqual([RUN, OTHER]); + }); + + it("admits every file under a directory named with a trailing slash", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [".claude/hooks/"])).toStrictEqual([RUN, OTHER]); + }); + + it("matches a directory only as a whole path segment", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [".claude/hook"])).toStrictEqual([]); + }); + + it("treats an empty selection as no selection at all", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [])).toStrictEqual([RUN, OTHER]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts new file mode 100644 index 000000000..e1176f1be --- /dev/null +++ b/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts @@ -0,0 +1,122 @@ +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 "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { GenerateToolDistributionUseCase } from "../../../../../src/contexts/framework/application/restore/generate-tool-distribution-use-case.js"; +import { CONFIG_VSCODE_SETTINGS } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { + getToolConfig, + isAiTool, + type ToolConfig, +} from "../../../../../src/contexts/tools/domain/registry.js"; +import { FrameworkDescriptor } from "../../../../../src/contexts/translate/domain/canon.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakePlatform } from "../../../../helpers/ports/fake-platform.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKDOWN = "---\nname: sample\ndescription: A sample.\n---\n\nBody.\n"; + +const descriptor = new FrameworkDescriptor({ + version: "test", + contentSections: ["agents", "commands", "rules", "skills", "templates"].map((name) => ({ + name, + directory: name, + entryFile: null, + })), + templateRefs: [], + configRefs: [{ name: CONFIG_VSCODE_SETTINGS, path: "config/vscode/settings.json" }], +}); + +const contentFiles = new Map([ + ["agents/reviewer.md", MARKDOWN], + ["commands/greet.md", MARKDOWN], + ["rules/naming.md", MARKDOWN], + ["skills/hello/SKILL.md", MARKDOWN], + ["templates/AGENTS.md", MARKDOWN], + ["config/vscode/settings.json", '{"editor.tabSize": 2}'], +]); + +const hasher = new DeterministicHasher(); +const assets = new BundledAssetProviderAdapter(); + +function generate( + config: ToolConfig, + assetProvider?: BundledAssetProviderAdapter +): Promise { + return new GenerateToolDistributionUseCase( + new InMemoryFileAdapter({}, hasher), + hasher, + new FakePlatform("linux"), + assetProvider + ).execute({ config, descriptor, contentFiles, projectRoot: PROJECT_ROOT }); +} + +async function pathsFor(toolId: ToolId, assetProvider?: BundledAssetProviderAdapter) { + return (await generate(getToolConfig(toolId), assetProvider)).map((f) => f.relativePath).sort(); +} + +describe("GenerateToolDistributionUseCase — content sections", () => { + it("gives an IDE tool its config files and no content section", async () => { + expect(await pathsFor("vscode")).toStrictEqual([".vscode/settings.json"]); + }); + + it("gives an AI tool one file per content section its capabilities accept", async () => { + expect(await pathsFor("claude")).toStrictEqual([ + ".claude/agents/reviewer.md", + ".claude/commands/greet.md", + ".claude/rules/naming.md", + ".claude/skills/hello/SKILL.md", + ]); + }); + + it("skips a section the tool declares no capability for", async () => { + const claude = getToolConfig("claude"); + if (!isAiTool(claude)) throw new Error("claude is an AI tool"); + const { rules: _rules, ...capabilities } = claude.capabilities as Record; + + const paths = (await generate({ ...claude, capabilities })).map((f) => f.relativePath).sort(); + + expect(paths).toStrictEqual([ + ".claude/agents/reviewer.md", + ".claude/commands/greet.md", + ".claude/skills/hello/SKILL.md", + ]); + }); +}); + +describe("GenerateToolDistributionUseCase — a tool's own config assets", () => { + it("writes a JSON asset pretty-printed at the path the tool declares", async () => { + const files = await generate(getToolConfig("claude"), assets); + const content = JSON.stringify(assets.loadConfigAsset("claude", "settings.json"), null, 2); + + expect(files.find((f) => f.relativePath === ".claude/settings.json")).toStrictEqual( + new InstallationFile({ + relativePath: ".claude/settings.json", + content, + hash: hasher.hash(content), + }) + ); + }); + + it("writes a text asset verbatim", async () => { + const asset = assets.loadConfigAsset("codex", "config.toml"); + if (typeof asset !== "string") throw new Error("the codex config asset is text"); + + const files = await generate(getToolConfig("codex"), assets); + + expect(files.find((f) => f.relativePath === ".codex/config.toml")?.content).toBe(asset); + }); + + it("adds only what a capability loads for a tool that declares no config asset path", async () => { + const withoutAssets = await pathsFor("copilot"); + + expect(await pathsFor("copilot", assets)).toStrictEqual( + [...withoutAssets, ".vscode/settings.json"].sort() + ); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/resolve-restore-decision.unit.test.ts b/cli/tests/contexts/framework/application/restore/resolve-restore-decision.unit.test.ts new file mode 100644 index 000000000..05519c562 --- /dev/null +++ b/cli/tests/contexts/framework/application/restore/resolve-restore-decision.unit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { ResolveRestoreDecisionUseCase } from "../../../../../src/contexts/framework/application/restore/resolve-restore-decision.js"; +import { InputRequiredError } from "../../../../../src/kernel/errors.js"; +import { ScriptedPrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const PATH = "a.md"; + +function neverAsked(): ScriptedPrompter { + return new ScriptedPrompter([]); +} + +describe("ResolveRestoreDecisionUseCase", () => { + it("restores a deleted file without asking, whatever the mode", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "deleted", + force: false, + interactive: false, + }); + + expect(keep).toBe(false); + }); + + it("refuses a modified file when it can neither force nor ask", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + await expect( + useCase.execute({ relativePath: PATH, reason: "modified", force: false, interactive: false }) + ).rejects.toThrow( + new InputRequiredError("Use --force to overwrite modified files in non-interactive mode.") + ); + }); + + it("asks about a modified file when it may ask but not force", async () => { + const useCase = new ResolveRestoreDecisionUseCase( + new ScriptedPrompter([ScriptedPrompter.answer.conflict("keep")]) + ); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "modified", + force: false, + interactive: true, + }); + + expect(keep).toBe(true); + }); + + it("overwrites a modified file without asking when forced, even where it could ask", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "modified", + force: true, + interactive: true, + }); + + expect(keep).toBe(false); + }); + + it("overwrites a modified file without asking when forced with no one to ask", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "modified", + force: true, + interactive: false, + }); + + expect(keep).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts index 3aa70d710..5265550cf 100644 --- a/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts @@ -372,3 +372,71 @@ describe("RestoreMergeFilesUseCase", () => { }); }); }); + +describe("RestoreMergeFilesUseCase — the entries it hands back", () => { + const distContent = JSON.stringify({ a: "framework-a" }); + + function distMapFor(deps: Awaited>) { + return new Map([ + [ + "settings.json", + new InstallationFile({ + relativePath: "settings.json", + content: distContent, + hash: deps.hasher.hash(distContent), + mergeStrategy: "framework-prime", + }), + ], + ]); + } + + it("re-reads the entries of a restored merge file from what landed on disk", async () => { + const deps = await buildDeps(); + const useCase = new RestoreMergeFilesUseCase(deps.fs, deps.hasher, new OverwritePrompter()); + + const result = await useCase.execute({ + mergeFiles: [ + { + relativePath: "settings.json", + sectionKey: null, + entries: { a: deps.hasher.hash(JSON.stringify("original-a")) }, + }, + ], + distMap: distMapFor(deps), + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: null, + }); + + expect(result?.updatedMergeFiles).toStrictEqual([ + { + relativePath: "settings.json", + sectionKey: null, + entries: { a: deps.hasher.hash(JSON.stringify("framework-a")) }, + }, + ]); + }); + + it("hands back a kept merge file's entries exactly as they were tracked", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "settings.json"), JSON.stringify({ a: "disk" })); + const tracked: MergeFileEntry = { + relativePath: "settings.json", + sectionKey: null, + entries: { a: deps.hasher.hash(JSON.stringify("original-a")) }, + }; + const useCase = new RestoreMergeFilesUseCase(deps.fs, deps.hasher, new KeepPrompter()); + + const result = await useCase.execute({ + mergeFiles: [tracked], + distMap: distMapFor(deps), + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + fileFilter: null, + }); + + expect(result?.updatedMergeFiles).toStrictEqual([tracked]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts index 81c06f43a..119075972 100644 --- a/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts @@ -307,3 +307,37 @@ describe("RestoreRegularFilesUseCase", () => { expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("disk modified content"); }); }); + +describe("RestoreRegularFilesUseCase — the files it hands back", () => { + it("hands back a restored file under its new hash and with no content", async () => { + const deps = await buildDeps(); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework content", + hash: deps.hasher.hash("framework content"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap, + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: null, + }); + + expect(result?.updatedFiles).toStrictEqual([ + new InstallationFile({ + relativePath: "a.md", + content: "", + hash: deps.hasher.hash("framework content"), + }), + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/restore-tool-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-tool-files-use-case.unit.test.ts new file mode 100644 index 000000000..075d937e8 --- /dev/null +++ b/cli/tests/contexts/framework/application/restore/restore-tool-files-use-case.unit.test.ts @@ -0,0 +1,219 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { + type RestoreToolFilesOptions, + RestoreToolFilesUseCase, +} from "../../../../../src/contexts/framework/application/restore/restore-tool-files-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { + CONFIG_VSCODE_EXTENSIONS, + CONFIG_VSCODE_KEYBINDINGS, +} from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { FrameworkDescriptor } from "../../../../../src/contexts/translate/domain/canon.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakePlatform } from "../../../../helpers/ports/fake-platform.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { KeepPrompter, OverwritePrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const PROJECT_ROOT = "/test-project"; +const KEYBINDINGS = ".vscode/keybindings.json"; +const EXTENSIONS = ".vscode/extensions.json"; +const GHOST = ".vscode/ghost.json"; +const KEYBINDINGS_CONTENT = '[{"key":"ctrl+k","command":"noop"}]'; +const EXTENSIONS_CONTENT = '{"recommendations":["a.ext"]}'; + +const descriptor = new FrameworkDescriptor({ + version: "test", + contentSections: [], + templateRefs: [], + configRefs: [ + { name: CONFIG_VSCODE_KEYBINDINGS, path: "config/vscode/keybindings.json" }, + { name: CONFIG_VSCODE_EXTENSIONS, path: "config/vscode/extensions.json" }, + ], +}); + +function contentFiles(extensions = EXTENSIONS_CONTENT): Map { + return new Map([ + ["config/vscode/keybindings.json", KEYBINDINGS_CONTENT], + ["config/vscode/extensions.json", extensions], + ]); +} + +interface Installed { + fs: InMemoryFileAdapter; + hasher: DeterministicHasher; + manifest: Manifest; +} + +function installedVscode(extraTracked: string[] = []): Installed { + const hasher = new DeterministicHasher(); + const fs = new InMemoryFileAdapter( + { + [join(PROJECT_ROOT, KEYBINDINGS)]: KEYBINDINGS_CONTENT, + [join(PROJECT_ROOT, EXTENSIONS)]: EXTENSIONS_CONTENT, + }, + hasher + ); + const manifest = Manifest.create(); + manifest.addTool( + "vscode", + "test", + [KEYBINDINGS, ...extraTracked].map( + (relativePath) => + new InstallationFile({ + relativePath, + content: "", + hash: hasher.hash(relativePath === KEYBINDINGS ? KEYBINDINGS_CONTENT : relativePath), + }) + ), + [ + { + relativePath: EXTENSIONS, + sectionKey: null, + entries: { recommendations: hasher.hash(JSON.stringify(["a.ext"])) }, + }, + ] + ); + return { fs, hasher, manifest }; +} + +function restore( + installed: Installed, + overrides: Partial = {}, + prompter: Prompter = new OverwritePrompter(), + logger = new CapturingLogger() +) { + return new RestoreToolFilesUseCase( + installed.fs, + installed.hasher, + logger, + new FakePlatform("linux"), + prompter + ).execute({ + toolId: "vscode", + manifest: installed.manifest, + descriptor, + contentFiles: contentFiles(), + projectRoot: PROJECT_ROOT, + version: "test", + force: true, + interactive: false, + fileFilter: null, + ...overrides, + }); +} + +describe("RestoreToolFilesUseCase — what it reports", () => { + it("announces the tool it is checking", async () => { + const installed = installedVscode(); + const logger = new CapturingLogger(); + + await restore(installed, {}, new OverwritePrompter(), logger); + + expect(logger.infoMessages).toStrictEqual(["Checking vscode for files to restore..."]); + }); + + it("reports nothing to restore when every tracked file and key is intact", async () => { + const result = await restore(installedVscode()); + + expect(result).toStrictEqual({ + toolId: "vscode", + nothingToRestore: true, + restored: [], + kept: [], + unrestorable: [], + }); + }); + + it("lists what it restored and what it could not, across both sections", async () => { + const installed = installedVscode([GHOST]); + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + await installed.fs.deleteFile(join(PROJECT_ROOT, EXTENSIONS)); + + const result = await restore(installed); + + expect(result).toStrictEqual({ + toolId: "vscode", + nothingToRestore: false, + restored: [KEYBINDINGS, EXTENSIONS], + kept: [], + unrestorable: [GHOST], + }); + }); + + it("lists a modified file the user chose to keep", async () => { + const installed = installedVscode(); + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + + const result = await restore( + installed, + { force: false, interactive: true }, + new KeepPrompter() + ); + + expect(result).toStrictEqual({ + toolId: "vscode", + nothingToRestore: false, + restored: [], + kept: [KEYBINDINGS], + unrestorable: [], + }); + }); +}); + +describe("RestoreToolFilesUseCase — what it records", () => { + it("keeps the version the tool was installed at, not the one the restore runs with", async () => { + const installed = installedVscode(); + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + + await restore(installed, { version: "9.9.9" }); + + expect(installed.manifest.getToolVersion("vscode")).toBe("test"); + }); + + it("keeps tracking the merge files when only a regular file was restored", async () => { + const installed = installedVscode(); + const before = [...installed.manifest.getMergeFiles("vscode")]; + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + + await restore(installed); + + expect(installed.manifest.getMergeFiles("vscode")).toStrictEqual(before); + }); + + it("keeps tracking the regular files when only a merge file was restored", async () => { + const installed = installedVscode(); + const before = [...installed.manifest.getToolFiles("vscode")]; + await installed.fs.deleteFile(join(PROJECT_ROOT, EXTENSIONS)); + + await restore(installed); + + expect(installed.manifest.getToolFiles("vscode")).toStrictEqual(before); + }); + + it("tracks every key the distribution now writes into a restored merge file", async () => { + const installed = installedVscode(); + await installed.fs.deleteFile(join(PROJECT_ROOT, EXTENSIONS)); + + await restore(installed, { + contentFiles: contentFiles( + '{"recommendations":["a.ext"],"unwantedRecommendations":["b.ext"]}' + ), + }); + + expect(installed.manifest.getMergeFiles("vscode")).toStrictEqual([ + { + relativePath: EXTENSIONS, + sectionKey: null, + entries: { + recommendations: installed.hasher.hash(JSON.stringify(["a.ext"])), + unwantedRecommendations: installed.hasher.hash(JSON.stringify(["b.ext"])), + }, + }, + ]); + }); +}); 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 cd9212b81..6291436ab 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 @@ -11,15 +11,19 @@ import { import { MarketplaceSourceMode } from "../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; import type { MarketplaceSyncSettings } from "../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; import type { PluginInstallFromMarketplace } from "../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { ProjectContextDetectorUseCase } from "../../../../src/contexts/framework/application/setup/project-context-detector-use-case.js"; import { SetupMachineScopeUseCase } from "../../../../src/contexts/framework/application/setup/setup-machine-scope-use-case.js"; import { SetupMarketplaceSourceUseCase } from "../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; import { SetupToolsUseCase } from "../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; import { SetupUseCase } from "../../../../src/contexts/framework/application/setup-use-case.js"; import { SetupMarketplaceRegistrationUseCase } from "../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../../../src/contexts/framework/domain/ports/user-source-references.js"; +import { ProjectContext } from "../../../../src/contexts/framework/domain/project-context.js"; import { SetupFlow } from "../../../../src/contexts/framework/domain/setup-flow.js"; import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { UserScopeUnavailableError } from "../../../../src/kernel/errors.js"; import type { Logger } from "../../../../src/kernel/ports/logger.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; import { AI_TOOL_IDS, IDE_TOOL_IDS } from "../../../../src/kernel/tool.js"; @@ -127,6 +131,7 @@ async function buildUseCase( marketplaceSyncSettingsUseCase?: MarketplaceSyncSettings & { execute: ReturnType; }; + detectContext?: boolean; } ) { const deps = await buildUnitDeps(PROJECT_ROOT); @@ -140,9 +145,10 @@ async function buildUseCase( deps.installRuntimeConfigUseCase, deps.installIdeConfigUseCase ); + const pluginInstallFromMarketplace = makeNoOpPluginInstallFromMarketplace(); const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( makeNoOpPluginPick(), - makeNoOpPluginInstallFromMarketplace(), + pluginInstallFromMarketplace, new InMemoryMarketplaceRegistry(), makeNoOpResolveMarketplace() ); @@ -180,7 +186,7 @@ async function buildUseCase( setupPluginsPromptUseCase, deps.currentVersionProvider, setupToolsPromptUseCase, - undefined, + options?.detectContext === true ? new ProjectContextDetectorUseCase(deps.fs) : undefined, setupMachineScopeUseCase ); return { @@ -189,6 +195,7 @@ async function buildUseCase( marketplaceRegisterFramework, marketplaceRefresh, marketplaceSyncSettingsUseCase, + pluginInstallFromMarketplace, }; } @@ -296,6 +303,58 @@ describe("setup without TTY", () => { expect(result.kind).toBe("up-to-date"); }); + it("hands the detected project context back with the result", async () => { + const { useCase, deps } = await buildUseCase(undefined, undefined, undefined, { + detectContext: true, + }); + deps.fs.setFile(join(PROJECT_ROOT, "tsconfig.json"), "{}"); + + const result = await useCase.execute(remoteFlow()); + + expect(result.context).toStrictEqual( + new ProjectContext({ stack: "typescript", isMonorepo: false, hasFramework: false }) + ); + }); + + describe("plugins named on the command line", () => { + function namedFlow(registerDefaultMarketplace: boolean): SetupFlow { + return new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools: ["claude" as ToolId], + ideTools: [], + pluginMode: "named", + pluginNames: ["aidd-context"], + interactive: false, + registerDefaultMarketplace, + }); + } + + it("installs each named plugin on every tool once the framework marketplace is registered", async () => { + const { useCase, pluginInstallFromMarketplace } = await buildUseCase(); + + await useCase.execute(namedFlow(true)); + + expect(pluginInstallFromMarketplace.execute).toHaveBeenCalledTimes(1); + expect(pluginInstallFromMarketplace.execute).toHaveBeenCalledWith({ + pluginName: "aidd-context", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: true, + replace: true, + }); + }); + + it("installs no plugin when the default marketplace is opted out", async () => { + const { useCase, pluginInstallFromMarketplace } = await buildUseCase(); + + await useCase.execute(namedFlow(false)); + + expect(pluginInstallFromMarketplace.execute).not.toHaveBeenCalled(); + }); + }); + describe("default marketplace opt-out (#197)", () => { it("registers framework marketplace by default", async () => { const { useCase, marketplaceRegisterFramework, marketplaceRefresh } = await buildUseCase(); @@ -502,6 +561,79 @@ describe("setup interactive tool selection", () => { }); describe("setup --scope user", () => { + it("refuses when no machine-scope use case was wired", async () => { + const { useCase } = await buildUseCase(); + + await expect( + useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })) + ).rejects.toThrow(UserScopeUnavailableError); + }); + + it("reports a first run as initialized, installing nothing", async () => { + const userManifestRepo = new InMemoryManifestRepository(); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + const result = await useCase.execute( + remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" }) + ); + + expect(result).toStrictEqual({ + kind: "initialized", + install: { results: [] }, + activation: { updatedTools: [] }, + context: undefined, + }); + }); + + it("reports a repeat run as up-to-date", async () => { + const userManifestRepo = new InMemoryManifestRepository(Manifest.create()); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + const result = await useCase.execute( + remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" }) + ); + + expect(result).toStrictEqual({ + kind: "up-to-date", + install: { results: [] }, + activation: { updatedTools: [] }, + context: undefined, + }); + }); + + it("keeps the tools an earlier run registered", async () => { + const seed = Manifest.create(); + seed.addTool("codex", "9.9.9", []); + const userManifestRepo = new InMemoryManifestRepository(seed); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userManifestRepo.getCurrent()?.getInstalledToolIds()).toStrictEqual(["codex", "claude"]); + }); + + it("registers a new tool with no files and saves the manifest once", async () => { + const userManifestRepo = new InMemoryManifestRepository(Manifest.create()); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userManifestRepo.getCurrent()?.getToolFiles("claude")).toStrictEqual([]); + expect(userManifestRepo.saveCount).toBe(1); + }); + + it("leaves an already-registered tool at its recorded version, saving nothing", async () => { + const seed = Manifest.create(); + seed.addTool("claude", "9.9.9", []); + const userManifestRepo = new InMemoryManifestRepository(seed); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userManifestRepo.getCurrent()?.getToolVersion("claude")).toBe("9.9.9"); + expect(userManifestRepo.saveCount).toBe(0); + }); + it("writes nothing at all under projectRoot — full directory delta, not a list", async () => { const userManifestRepo = new InMemoryManifestRepository(); const { useCase, deps } = await buildUseCase(undefined, undefined, undefined, { diff --git a/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts b/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts index 4b7adb6f0..b6d936181 100644 --- a/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts @@ -62,6 +62,22 @@ describe("ProjectContextDetectorUseCase", () => { expect(ctx.hasFramework).toBe(false); }); + it("reads a project holding only unrelated files as an unknown stack", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + await fs.writeFile(join(PROJECT_ROOT, "README.md"), "# hello"); + const detector = new ProjectContextDetectorUseCase(fs); + const ctx = await detector.execute({ projectRoot: PROJECT_ROOT }); + expect(ctx.stack).toBe("unknown"); + }); + + it("does not read a package.json without a workspaces field as a monorepo", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + await fs.writeFile(join(PROJECT_ROOT, "package.json"), JSON.stringify({ name: "solo" })); + const detector = new ProjectContextDetectorUseCase(fs); + const ctx = await detector.execute({ projectRoot: PROJECT_ROOT }); + expect(ctx.isMonorepo).toBe(false); + }); + it("describe returns hyphen-readable summary", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); await fs.writeFile(join(PROJECT_ROOT, "tsconfig.json"), "{}"); diff --git a/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts index ef210ad5e..d4ac12682 100644 --- a/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts @@ -138,6 +138,46 @@ describe("SetupMarketplaceSourceUseCase", () => { expect(result.ref).toBeUndefined(); }); + it("labels the newest release as latest and offers HEAD last", async () => { + const resolver = makeResolver(["v3.0.0", "v2.9.0"]); + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.select("remote"), + ScriptedPrompter.answer.select("v2.9.0"), + ]); + const uc = new SetupMarketplaceSourceUseCase(prompter, resolver); + + await uc.execute({ projectRoot: PROJECT_ROOT, interactive: true }); + + expect(prompter.askedSelects).toStrictEqual([ + { + message: "Select framework source:", + names: [ + "remote (fetch from GitHub marketplace — recommended)", + "local (copy from local framework directory)", + ], + }, + { + message: "Select framework release to install:", + names: ["v3.0.0 (latest)", "v2.9.0", "HEAD (main branch tip — unreleased)"], + }, + ]); + }); + + it("asks for the local path with an empty default", async () => { + const resolver = makeResolver([]); + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.select("local"), + ScriptedPrompter.answer.input("/abs/framework"), + ]); + const uc = new SetupMarketplaceSourceUseCase(prompter, resolver); + + await uc.execute({ projectRoot: PROJECT_ROOT, interactive: true }); + + expect(prompter.askedInputs).toStrictEqual([ + { message: "Path to local framework directory:", defaultValue: "" }, + ]); + }); + it("prompts for local path when user selects local", async () => { const resolver = makeResolver([]); const prompter = new ScriptedPrompter([ @@ -185,5 +225,19 @@ describe("SetupMarketplaceSourceUseCase", () => { expect(result.ref).toBe("v4.0.0"); }); + + it("pins an older release the user picks rather than the newest", async () => { + const resolver = makeResolver(["v4.0.0", "v3.0.0"]); + const prompter = new ScriptedPrompter([ScriptedPrompter.answer.select("v3.0.0")]); + const uc = new SetupMarketplaceSourceUseCase(prompter, resolver); + + const result = await uc.execute({ + projectRoot: PROJECT_ROOT, + sourceFromCli: MarketplaceSourceMode.remote(), + interactive: true, + }); + + expect(result.ref).toBe("v3.0.0"); + }); }); }); diff --git a/cli/tests/contexts/framework/application/setup/setup-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/setup-tools-use-case.unit.test.ts new file mode 100644 index 000000000..5bd705c17 --- /dev/null +++ b/cli/tests/contexts/framework/application/setup/setup-tools-use-case.unit.test.ts @@ -0,0 +1,83 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { SetupToolsUseCase } from "../../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; +import { CategoryMismatchError } from "../../../../../src/kernel/errors.js"; +import { AI_TOOL_IDS, type ToolId } from "../../../../../src/kernel/tool.js"; +import { + buildUnitDeps, + initProject, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; + +const PROJECT_ROOT = "/test-project"; +const VERSION = "1.0.0"; + +async function build() { + const deps = await buildUnitDeps(PROJECT_ROOT); + const useCase = new SetupToolsUseCase( + deps.manifestRepo, + deps.installRuntimeConfigUseCase, + deps.installIdeConfigUseCase + ); + return { deps, useCase }; +} + +function options(aiTools: ToolId[], ideTools: ToolId[]) { + return { projectRoot: PROJECT_ROOT, aiTools, ideTools, force: false, version: VERSION }; +} + +describe("SetupToolsUseCase", () => { + it("installs nothing when no tool was asked for", async () => { + const { useCase } = await build(); + + await expect(useCase.execute(options([], []))).resolves.toStrictEqual({ results: [] }); + }); + + it("installs the AI tools asked for when no IDE was", async () => { + const { useCase } = await build(); + + const { results } = await useCase.execute(options(["claude"], [])); + + expect(results.map((r) => [r.toolId, r.skipped])).toStrictEqual([["claude", false]]); + }); + + it("installs the IDE asked for when no AI tool was", async () => { + const { useCase } = await build(); + + const { results } = await useCase.execute(options([], ["vscode"])); + + expect(results.map((r) => [r.toolId, r.skipped])).toStrictEqual([["vscode", false]]); + }); + + it("installs the IDE first so an AI tool depending on it finds it installed", async () => { + const { deps, useCase } = await build(); + + const { results } = await useCase.execute(options(["copilot"], ["vscode"])); + + expect(results.map((r) => r.toolId)).toStrictEqual(["vscode", "copilot"]); + const settings = JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json")) ?? ""); + expect(settings).toHaveProperty("github.copilot.enable"); + }); + + it("carries the existing manifest forward instead of starting a fresh one", async () => { + const { deps, useCase } = await build(); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "claude"); + + await useCase.execute(options([], ["vscode"])); + + const manifest = await deps.manifestRepo.load(); + expect(manifest?.getInstalledToolIds()).toStrictEqual(["claude", "vscode"]); + }); + + it("refuses an IDE handed in as an AI tool, naming the valid ones", async () => { + const { useCase } = await build(); + + const run = useCase.execute(options(["vscode"], [])); + + await expect(run).rejects.toThrow(CategoryMismatchError); + await expect(run).rejects.toThrow( + `vscode is not an AI tool. Valid AI tools: ${AI_TOOL_IDS.join(", ")}` + ); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-use-case.unit.test.ts new file mode 100644 index 000000000..c4409ed59 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-use-case.unit.test.ts @@ -0,0 +1,70 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ApplyPluginFilesUseCase } from "../../../../../src/contexts/framework/application/shared/apply-plugin-files-use-case.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 { getToolConfig } from "../../../../../src/contexts/tools/domain/registry.js"; +import { buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; +const CACHE_DIR = join(PROJECT_ROOT, ".aidd", "plugin-cache"); +const SOURCE = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/sample-plugin", +}; + +async function makeUseCase(): Promise<{ + useCase: ApplyPluginFilesUseCase; + fs: Awaited>["fs"]; +}> { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(SOURCE, PLUGIN_FIXTURE); + const useCase = new ApplyPluginFilesUseCase( + deps.fs, + deps.hasher, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ); + return { useCase, fs: deps.fs }; +} + +function manifestWith(plugin: InstalledPlugin): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addPlugin("claude", plugin); + return manifest; +} + +describe("ApplyPluginFilesUseCase — without built-tree materialization wired in", () => { + it("restores a marketplace plugin through the source transform and tracks what it wrote", async () => { + const { useCase, fs } = await makeUseCase(); + const plugin = InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + SOURCE, + false, + "project", + "aidd-framework" + ); + const manifest = manifestWith(plugin); + + const restored = await useCase.execute({ + toolId: "claude", + plugin, + toolConfig: getToolConfig("claude"), + projectRoot: PROJECT_ROOT, + cacheDir: CACHE_DIR, + manifest, + }); + + const tracked = [...manifest.getPlugins("claude")[0].files.keys()].sort(); + expect(tracked.length).toBeGreaterThan(0); + expect(restored).toBe(tracked.length); + expect(tracked.every((relativePath) => fs.has(join(PROJECT_ROOT, relativePath)))).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/best-effort-native-call.unit.test.ts b/cli/tests/contexts/framework/application/shared/best-effort-native-call.unit.test.ts new file mode 100644 index 000000000..f87f64776 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/best-effort-native-call.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { bestEffortNativeCall } from "../../../../../src/contexts/framework/application/shared/best-effort-native-call.js"; +import { NativePluginCliError } from "../../../../../src/kernel/errors.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; + +describe("bestEffortNativeCall", () => { + it("warns and reports a call the host's own CLI refused", () => { + const logger = new CapturingLogger(); + + const completed = bestEffortNativeCall( + logger, + () => { + throw new NativePluginCliError("exit 1"); + }, + "claude marketplace remove" + ); + + expect(completed).toBe(false); + expect(logger.warnMessages).toStrictEqual(["claude marketplace remove failed: exit 1"]); + }); + + it("propagates any failure that is not the host CLI refusing", () => { + expect(() => + bestEffortNativeCall( + new CapturingLogger(), + () => { + throw new Error("activator bug"); + }, + "claude marketplace remove" + ) + ).toThrow("activator bug"); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/detect-plugin-drift-use-case.unit.test.ts b/cli/tests/contexts/framework/application/shared/detect-plugin-drift-use-case.unit.test.ts new file mode 100644 index 000000000..df860a474 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/detect-plugin-drift-use-case.unit.test.ts @@ -0,0 +1,85 @@ +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { DetectPluginDriftUseCase } from "../../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/proj"; +const USER_PLUGINS_DIR = join(homedir(), ".cursor", "plugins", "local"); +const HASHER = new DeterministicHasher(); +const HASH_OF_ONE = HASHER.hash("one").value; +const HASH_OF_TWO = HASHER.hash("two").value; + +function manifestWith(plugins: Record>): Manifest { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + for (const [name, files] of Object.entries(plugins)) { + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files, + scope: "user", + }) + ); + } + return manifest; +} + +describe("DetectPluginDriftUseCase", () => { + it("narrows the report to the plugin it was asked about", async () => { + const manifest = manifestWith({ + "aidd-one": { "aidd-one/a.md": HASH_OF_ONE }, + "aidd-two": { "aidd-two/a.md": HASH_OF_ONE }, + }); + const useCase = new DetectPluginDriftUseCase(new InMemoryFileAdapter({}, HASHER)); + + const drifts = await useCase.execute({ + manifest, + projectRoot: PROJECT_ROOT, + toolIds: ["cursor"], + pluginName: "aidd-two", + }); + + expect(drifts).toStrictEqual([ + { toolId: "cursor", pluginName: "aidd-two", files: [], notInstalledOnMachine: true }, + ]); + }); + + it("reports one missing and one changed file as drift, never as a plugin absent from this machine", async () => { + const manifest = manifestWith({ + "aidd-one": { "aidd-one/gone.md": HASH_OF_ONE, "aidd-one/changed.md": HASH_OF_ONE }, + }); + const fs = new InMemoryFileAdapter( + { [join(USER_PLUGINS_DIR, "aidd-one", "changed.md")]: "two" }, + HASHER + ); + const useCase = new DetectPluginDriftUseCase(fs); + + const drifts = await useCase.execute({ + manifest, + projectRoot: PROJECT_ROOT, + toolIds: ["cursor"], + }); + + expect(HASH_OF_TWO).not.toBe(HASH_OF_ONE); + expect(drifts).toStrictEqual([ + { + toolId: "cursor", + pluginName: "aidd-one", + files: [ + { relativePath: "aidd-one/gone.md", kind: "missing" }, + { relativePath: "aidd-one/changed.md", kind: "hash-mismatch" }, + ], + notInstalledOnMachine: false, + }, + ]); + }); +}); 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 e4cfe3d20..55f6023ce 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 @@ -289,6 +289,156 @@ describe("EnsureBuiltMarketplaceUseCase", () => { await uc.execute(opts); expect(builds).toBe(1); }); + + it("memoizes per target: a second target of the same marketplace builds again", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const codex = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + const cursor = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "cursor", + mode: "marketplace", + }); + expect(builds).toBe(2); + expect(codex.builtDir).not.toBe(cursor.builtDir); + }); + + it("reports the catalog version it built", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.version).toBe("1.0.0"); + }); + + it("reports a catalog without a version as unversioned", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", undefined), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.version).toBe("unversioned"); + }); + + it("asks the resolver for exactly the marketplace, project and refresh flag it was given", async () => { + const asked: ResolveMarketplaceOptions[] = []; + const recordingResolve: ResolveMarketplace = { + execute: async (options) => { + asked.push(options); + return { marketplace: options.marketplace, localPath: "/src/framework", catalog: null }; + }, + }; + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + recordingResolve, + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const marketplace = makeMarketplace(); + await uc.execute({ projectRoot: PROJECT, marketplace, target: "codex", mode: "marketplace" }); + expect(asked).toStrictEqual([{ marketplace, projectRoot: PROJECT, forceRefresh: undefined }]); + }); + + it("refuses a target and mode pair no build exists for", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + () => undefined, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + await expect( + uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }) + ).rejects.toThrow("No framework build for target 'codex' mode 'marketplace'."); + }); +}); + +describe("EnsureBuiltMarketplaceUseCase — when a published source's sentinel can be believed", () => { + let fs: InMemoryFileAdapter; + let builds: number; + let buildFor: FrameworkBuildFor; + const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")); + + beforeEach(() => { + fs = new InMemoryFileAdapter(); + builds = 0; + buildFor = (_target, _mode, outDir) => + ({ + execute: async () => { + builds += 1; + await fs.writeFile(join(outDir, "plugins/aidd-vcs/SKILL.md"), "built content"); + return { outDir, plugins: [], totalFiles: 1 }; + }, + }) satisfies FrameworkBuild; + }); + + async function ensure(catalogVersion: string | undefined): Promise { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", catalogVersion), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeRemoteMarketplace(), + target: "codex", + mode: "marketplace", + }); + return r.rebuilt; + } + + it("rebuilds when no sentinel was ever written", async () => { + expect(await ensure("1.0.0")).toBe(true); + expect(builds).toBe(1); + }); + + it("rebuilds when the sentinel names another CLI version", async () => { + fs.setFile(join(builtDir, ".build-version"), "4.0.0:1.0.0"); + expect(await ensure("1.0.0")).toBe(true); + expect(builds).toBe(1); + }); + + 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); + expect(builds).toBe(1); + }); }); // outDir here is always builtMarketplaceDir(), an aidd-owned disposable cache, so a collision diff --git a/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts index 561710695..aed8566b6 100644 --- a/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts +++ b/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts @@ -1,3 +1,4 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import { describe, expect, it } from "vitest"; import { hostMarketplaceSourceConflict, @@ -5,6 +6,10 @@ import { } from "../../../../../src/contexts/framework/application/shared/host-marketplace-source-conflict.js"; import { userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; const NAME = "aidd-framework"; @@ -53,4 +58,68 @@ describe("hostMarketplaceSourceConflict — resolving every path through the sam requestedVersion: "1.0.0", }); }); + + it("still decides the drift when the registered source no longer resolves on disk", async () => { + const userCacheRoot = "/home/.config/aidd"; + const requestedSource = userBuiltMarketplaceDir(userCacheRoot, "1.0.0", NAME, "claude"); + const registeredSource = userBuiltMarketplaceDir(userCacheRoot, "2.0.0", NAME, "claude"); + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", registeredSource, errnoError("ENOENT")); + const reader = new FakeHostMarketplaceRegistryReader({ + location: LOCATION, + entries: new Map([[NAME, registeredSource]]), + }); + + const check = await hostMarketplaceSourceConflict( + fs, + "claude", + reader, + requestedSource, + { name: NAME, pluginNames: [] }, + { userCacheRoot, projectRoot: "/project", marketplaceName: NAME, target: "claude" } + ); + + expect(check).toStrictEqual({ + name: NAME, + registeredSource, + requestedSource, + location: LOCATION, + drift: { kind: "version-behind", registeredVersion: "2.0.0", requestedVersion: "1.0.0" }, + }); + }); +}); + +describe("hostMarketplaceSourceConflict — without a drift context", () => { + it("reports the different catalog the host's registry points at", async () => { + const fs = new InMemoryFileAdapter({ + "/registered/.claude-plugin/marketplace.json": JSON.stringify({ + name: NAME, + plugins: [{ name: "aidd-dev" }], + }), + }); + const reader = new FakeHostMarketplaceRegistryReader({ + location: LOCATION, + entries: new Map([[NAME, "/registered"]]), + }); + + const check = await hostMarketplaceSourceConflict(fs, "claude", reader, "/requested", { + name: NAME, + pluginNames: ["aidd-dev", "aidd-vcs"], + }); + + expect(check).toStrictEqual({ + name: NAME, + registeredSource: "/registered", + requestedSource: "/requested", + registeredIdentity: { name: NAME, pluginNames: ["aidd-dev"] }, + requestedIdentity: { name: NAME, pluginNames: ["aidd-dev", "aidd-vcs"] }, + location: LOCATION, + }); + }); +}); + +describe("isDriftFound", () => { + it("is false for no check at all", () => { + expect(isDriftFound(undefined)).toBe(false); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/purge-declared-cache.unit.test.ts b/cli/tests/contexts/framework/application/shared/purge-declared-cache.unit.test.ts new file mode 100644 index 000000000..fa34e516e --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/purge-declared-cache.unit.test.ts @@ -0,0 +1,71 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + purgeCacheIfEmptyAndConfirmed, + resolveCacheCandidate, +} from "../../../../../src/contexts/framework/application/shared/purge-declared-cache.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const CACHE_ROOT = "/cache"; +const CANDIDATE = join(CACHE_ROOT, "mkt"); +const LABEL = "codex: cache for 'mkt'"; + +describe("resolveCacheCandidate", () => { + it("answers nothing, silently, when the cache root does not exist", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", CACHE_ROOT, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const candidate = await resolveCacheCandidate(fs, logger, CACHE_ROOT, "mkt", LABEL); + + expect(candidate).toBeNull(); + expect(logger.allMessages).toStrictEqual([]); + }); + + it("answers nothing, silently, when the candidate does not exist", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", CANDIDATE, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const candidate = await resolveCacheCandidate(fs, logger, CACHE_ROOT, "mkt", LABEL); + + expect(candidate).toBeNull(); + expect(logger.allMessages).toStrictEqual([]); + }); +}); + +describe("purgeCacheIfEmptyAndConfirmed", () => { + it("purges an empty, confirmed cache and says so", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + + await purgeCacheIfEmptyAndConfirmed(fs, logger, CANDIDATE, true, LABEL); + + expect(logger.infoMessages).toStrictEqual([`${LABEL} purged: ${CANDIDATE}`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("treats a cache that no longer exists as nothing to purge", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("listDirectory", CANDIDATE, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + await purgeCacheIfEmptyAndConfirmed(fs, logger, CANDIDATE, true, LABEL); + + expect(logger.allMessages).toStrictEqual([]); + }); + + it("propagates a listing failure other than absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("listDirectory", CANDIDATE, errnoError("EACCES")); + + await expect( + purgeCacheIfEmptyAndConfirmed(fs, new CapturingLogger(), CANDIDATE, true, LABEL) + ).rejects.toThrow("EACCES: planted by the test"); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts new file mode 100644 index 000000000..673bf4101 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts @@ -0,0 +1,96 @@ +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 { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + purgeAllNativeCaches, + purgeNativeMarketplaceCache, + type UndoneToolRegistrations, +} from "../../../../../src/contexts/framework/application/shared/purge-native-marketplace-cache.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const HOME = "/home/u"; +const CACHE_ROOT = "/cache"; +const HOST_NAME = "aidd-framework"; +const CANDIDATE = join(CACHE_ROOT, HOST_NAME); + +function undone(binary: string): UndoneToolRegistrations { + return { + registrations: { + binary, + marketplaces: [{ alias: HOST_NAME, hostName: HOST_NAME }], + pluginRefs: [], + }, + removedHostNames: new Set([HOST_NAME]), + }; +} + +describe("purgeAllNativeCaches", () => { + it("skips a tool whose profile declares no plugin cache", async () => { + const fs = new InMemoryFileAdapter({ [join(HOME, ".cursor", "plugins", "a.json")]: "{}" }); + const logger = new CapturingLogger(); + + await purgeAllNativeCaches( + fs, + logger, + HOME, + new Map(), + new Map([["cursor", undone("cursor")]]) + ); + + expect(logger.allMessages).toStrictEqual([]); + expect(fs.listAll()).toStrictEqual([`${HOME}/.cursor/plugins/a.json`]); + }); +}); + +describe("purgeNativeMarketplaceCache", () => { + it("names the cache path when its real location escapes the cache root", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink(CANDIDATE, "/elsewhere"); + const logger = new CapturingLogger(); + + await purgeNativeMarketplaceCache(fs, logger, undefined, CACHE_ROOT, "codex", HOST_NAME, true); + + expect(logger.warnMessages).toStrictEqual([ + `codex: cache path for '${HOST_NAME}' does not resolve inside ${CACHE_ROOT}; left in place: ${CANDIDATE}`, + ]); + }); + + it("keeps and names a cache the host never confirmed removing, for a host without a registry", async () => { + const logger = new CapturingLogger(); + + await purgeNativeMarketplaceCache( + new InMemoryFileAdapter(), + logger, + undefined, + CACHE_ROOT, + "codex", + HOST_NAME, + false + ); + + expect(logger.warnMessages).toStrictEqual([ + `codex: cache for '${HOST_NAME}' left in place, its own removal was not confirmed: ${CANDIDATE}`, + ]); + }); + + it("purges the cache and says so once the host's registry is gone", async () => { + const fs = new InMemoryFileAdapter({ [join(CANDIDATE, "plugin.json")]: "{}" }); + const logger = new CapturingLogger(); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "/home/u/.claude/plugins/known_marketplaces.json", + absent: true, + }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.listAll()).toStrictEqual([]); + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for '${HOST_NAME}' purged: ${CANDIDATE}`, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts new file mode 100644 index 000000000..458a093e3 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts @@ -0,0 +1,63 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + marketplaceCatalogProbePath, + readMarketplaceCatalogIdentity, +} from "../../../../../src/contexts/framework/application/shared/read-marketplace-catalog-identity.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const DIR = "/marketplace"; +const CATALOG_PATH = join(DIR, ".claude-plugin", "marketplace.json"); + +function fsWithCatalog(catalog: unknown): InMemoryFileAdapter { + return new InMemoryFileAdapter({ [CATALOG_PATH]: JSON.stringify(catalog) }); +} + +describe("readMarketplaceCatalogIdentity", () => { + it("reads the name and plugin names the tool's own catalog file declares", async () => { + const fs = fsWithCatalog({ name: "aidd-framework", plugins: [{ name: "a" }, { name: "b" }] }); + + const identity = await readMarketplaceCatalogIdentity(fs, "claude", DIR); + + expect(identity).toStrictEqual({ name: "aidd-framework", pluginNames: ["a", "b"] }); + }); + + it("answers no plugin names for a catalog that declares none", async () => { + const fs = fsWithCatalog({ name: "aidd-framework" }); + + const identity = await readMarketplaceCatalogIdentity(fs, "claude", DIR); + + expect(identity).toStrictEqual({ name: "aidd-framework", pluginNames: [] }); + }); + + it("skips a plugin entry that is not an object or names nothing", async () => { + const fs = fsWithCatalog({ + name: "aidd-framework", + plugins: [null, "text", { name: 3 }, { version: "1.0.0" }, { name: "a" }], + }); + + const identity = await readMarketplaceCatalogIdentity(fs, "claude", DIR); + + expect(identity).toStrictEqual({ name: "aidd-framework", pluginNames: ["a"] }); + }); + + it("answers nothing for a catalog whose name is not a string", async () => { + const fs = fsWithCatalog({ name: 5, plugins: [{ name: "a" }] }); + + expect(await readMarketplaceCatalogIdentity(fs, "claude", DIR)).toBeUndefined(); + }); + + it("answers nothing for a tool that is not an AI tool", async () => { + const fs = fsWithCatalog({ name: "aidd-framework" }); + + expect(await readMarketplaceCatalogIdentity(fs, "vscode", DIR)).toBeUndefined(); + }); +}); + +describe("marketplaceCatalogProbePath", () => { + it("names nothing for a tool that is not an AI tool", () => { + expect(marketplaceCatalogProbePath("vscode", DIR)).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts new file mode 100644 index 000000000..b19a2d229 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts @@ -0,0 +1,70 @@ +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { removeProjectHooks } from "../../../../../src/contexts/framework/application/shared/remove-project-hooks.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const PLUGIN = "aidd-context"; +const OTHER = "aidd-dev"; +const HOOKS_PATH = join(PROJECT_ROOT, ".cursor", "hooks.json"); +const SCRIPT_PATH = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN, "pre.js"); + +function entry(plugin: string): { command: string } { + return { command: `node ./.cursor/hooks/${plugin}/pre.js` }; +} + +function hooksFile(...plugins: string[]): string { + return JSON.stringify({ version: 1, hooks: { preToolUse: plugins.map(entry) } }); +} + +describe("removeProjectHooks", () => { + it("leaves a tool that keeps hooks in its plugin directory untouched and reports nothing undone", async () => { + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN) }); + + expect(await removeProjectHooks(fs, PLUGIN, "opencode", PROJECT_ROOT)).toBe(false); + expect(fs.getFile(HOOKS_PATH)).toBe(hooksFile(PLUGIN)); + }); + + it("reports nothing undone when neither the hooks file nor the script directory exists", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("deleteDirectory", `${PROJECT_ROOT}/.cursor/hooks/${PLUGIN}/`, errnoError("EPERM")); + + expect(await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).toBe(false); + }); + + it("unmerges only this plugin's entries and reports something undone when just the hooks file exists", async () => { + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN, OTHER) }); + + const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + + expect(undone).toBe(true); + expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null")).toStrictEqual({ + version: 1, + hooks: { preToolUse: [entry(OTHER)] }, + }); + }); + + it("removes the script directory and reports something undone when only the scripts exist", async () => { + const fs = new InMemoryFileAdapter({ [SCRIPT_PATH]: "module.exports = () => {};" }); + + const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + + expect(undone).toBe(true); + expect(fs.listAll()).toStrictEqual([]); + }); + + it("propagates a failure to read the hooks file other than its absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("readFile", HOOKS_PATH, errnoError("EACCES")); + + await expect(removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).rejects.toThrow( + "EACCES: planted by the test" + ); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts b/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts index 7d74cd139..5508453f5 100644 --- a/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveUninstallScopeOrder } from "../../../../../src/contexts/framework/application/shared/resolve-uninstall-scope.js"; import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; const REF = "aidd-telemetry@aidd-framework"; const PROJECT_ROOT = "/test-project"; @@ -41,4 +42,15 @@ describe("resolveUninstallScopeOrder", () => { expect(order).toEqual(["project", "user"]); }); + + it("falls back when the host's registry could not be read at all", async () => { + const reader = new FakeHostPluginRegistryReader({ + location: "/registry", + unreadable: "not valid JSON", + }); + + const order = await resolveUninstallScopeOrder(reader, REF, PROJECT_ROOT, "user"); + + expect(order).toStrictEqual(["user", "project"]); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts b/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts index abf73bd9e..c9f079fd9 100644 --- a/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts @@ -4,7 +4,11 @@ import type { MarketplaceRegisterFramework } from "../../../../../src/contexts/d import { MarketplaceSourceMode } from "../../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; import { SetupMarketplaceSourceUseCase } from "../../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; import { SetupMarketplaceRegistrationUseCase } from "../../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; import { SetupFlow } from "../../../../../src/contexts/framework/domain/setup-flow.js"; +import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { CatalogFetchAuthError } from "../../../../../src/kernel/errors.js"; +import type { TokenProvider } from "../../../../../src/runtime/auth/ports/token-provider.js"; import type { LatestReleaseResolver } from "../../../../../src/runtime/self-update/latest-release-resolver.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; @@ -15,11 +19,11 @@ import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; const PROJECT_ROOT = "/test-project"; const SKIP_SWITCH = "AIDD_SKIP_MARKETPLACE_REFRESH"; -function makeNoOpLatestResolver(): LatestReleaseResolver { +function makeNoOpLatestResolver(isRepoPublic = true): LatestReleaseResolver { return { resolveLatest: vi.fn().mockResolvedValue(null), listRootReleases: vi.fn().mockResolvedValue([]), - isRepoPublic: vi.fn().mockResolvedValue(true), + isRepoPublic: vi.fn().mockResolvedValue(isRepoPublic), }; } @@ -27,23 +31,37 @@ function makeRegisterFramework(): MarketplaceRegisterFramework { return { execute: vi.fn().mockResolvedValue({ registered: true, scope: "user" }) }; } -function makeUseCase(environment: InMemoryEnvironment): { +interface Collaborators { + tokenProvider?: TokenProvider; + releaseResolver?: LatestReleaseResolver; + userSourceReferences?: UserSourceReferences; +} + +function makeUseCase( + environment: InMemoryEnvironment, + collaborators: Collaborators = {} +): { useCase: SetupMarketplaceRegistrationUseCase; refresh: MarketplaceRefresh; + register: MarketplaceRegisterFramework; } { const refresh: MarketplaceRefresh = { execute: vi.fn().mockResolvedValue({ results: [], failedCount: 0 }), }; + const register = makeRegisterFramework(); const useCase = new SetupMarketplaceRegistrationUseCase( new InMemoryFileAdapter(), new SetupMarketplaceSourceUseCase(new KeepPrompter(), makeNoOpLatestResolver()), - makeRegisterFramework(), + register, refresh, new FakeCurrentVersion(), new CapturingLogger(), - environment + environment, + collaborators.tokenProvider, + collaborators.releaseResolver, + collaborators.userSourceReferences ); - return { useCase, refresh }; + return { useCase, refresh, register }; } async function register(environment: InMemoryEnvironment): Promise { @@ -73,4 +91,64 @@ describe("SetupMarketplaceRegistrationUseCase", () => { expect(refresh.execute).toHaveBeenCalledOnce(); }); }); + + describe("remote auth guard", () => { + it("refuses a private remote source when no token can be resolved", async () => { + const { useCase } = makeUseCase(new InMemoryEnvironment(), { + tokenProvider: { resolve: async () => null }, + releaseResolver: makeNoOpLatestResolver(false), + }); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + const attempt = useCase.registerIfPresent(flow, MarketplaceSourceMode.remote("owner/repo")); + + await expect(attempt).rejects.toThrow(CatalogFetchAuthError); + await expect(attempt).rejects.toThrow( + 'Authentication required to fetch catalog from "https://github.com/owner/repo". Run `aidd auth login` first or use `--source local --path `.' + ); + }); + }); + + describe("registration options", () => { + it("registers a local source at the project root, forcing the registration", async () => { + const { useCase, register } = makeUseCase(new InMemoryEnvironment()); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + await useCase.registerIfPresent(flow, MarketplaceSourceMode.local("/framework-source")); + + expect(register.execute).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + pluginSource: { kind: "local", path: "/framework-source" }, + force: true, + }); + }); + + it("registers a remote source by repository and ref", async () => { + const { useCase, register } = makeUseCase(new InMemoryEnvironment()); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + await useCase.registerIfPresent(flow, MarketplaceSourceMode.remote("owner/repo", "v1.0.0")); + + expect(register.execute).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + pluginSource: { kind: "github", repo: "owner/repo", ref: "v1.0.0" }, + force: true, + }); + }); + }); + + describe("shared source reference", () => { + it("records this project's claim on the shared source for a project-scope setup", async () => { + const userSourceReferences = new UserSourceReferencesAdapter( + new InMemoryFileAdapter({ [`${PROJECT_ROOT}/.aidd/manifest.json`]: "{}" }), + () => "/user-config" + ); + const { useCase } = makeUseCase(new InMemoryEnvironment(), { userSourceReferences }); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + await useCase.registerIfPresent(flow, MarketplaceSourceMode.local("/framework-source")); + + expect(await userSourceReferences.listAllReferencingProjects()).toStrictEqual([PROJECT_ROOT]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts index fab57e16d..58e5bc8d5 100644 --- a/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts @@ -2,8 +2,16 @@ import { describe, expect, it } from "vitest"; import { describeFullRemovalInstruction, describeGuardedPluginRefMessage, + frameworkSourceIsShared, refAnotherProjectStillNeeds, + resolveProjectRootForReferences, + toleratingUnreadableSourceReferences, } from "../../../../../src/contexts/framework/application/shared/shared-source-reference-support.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; const BASE = { ref: "aidd-dev@aidd-framework", @@ -34,6 +42,47 @@ describe("refAnotherProjectStillNeeds", () => { it("is false when this run never resolved the shared source's own hostName for this tool", () => { expect(refAnotherProjectStillNeeds({ ...BASE, sharedSourceHostName: undefined })).toBe(false); }); + + it("never matches a ref against the literal text 'undefined' when no hostName was resolved", () => { + expect( + refAnotherProjectStillNeeds({ + ...BASE, + ref: "aidd-dev@undefined", + sharedSourceHostName: undefined, + }) + ).toBe(false); + }); +}); + +describe("frameworkSourceIsShared", () => { + it("is false for the framework marketplace at project scope", () => { + expect(frameworkSourceIsShared("aidd-framework", "project")).toBe(false); + }); + + it("is false for another marketplace at user scope", () => { + expect(frameworkSourceIsShared("other-marketplace", "user")).toBe(false); + }); +}); + +describe("resolveProjectRootForReferences", () => { + it("propagates a resolution failure other than the project being absent", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", "/project", errnoError("EACCES")); + + await expect(resolveProjectRootForReferences(fs, "/project")).rejects.toThrow( + "EACCES: planted by the test" + ); + }); +}); + +describe("toleratingUnreadableSourceReferences", () => { + it("propagates any failure that is not an unreadable registry", async () => { + await expect( + toleratingUnreadableSourceReferences(new CapturingLogger(), "fallback", async () => { + throw new Error("a bug, not a corrupted file"); + }) + ).rejects.toThrow("a bug, not a corrupted file"); + }); }); // Nothing else in the suite pins this sentence's grammar, so a swapped singular/plural branch @@ -48,13 +97,17 @@ describe("describeGuardedPluginRefMessage", () => { expect(message).toContain("1 other project still references"); }); - it("uses plural wording for more than one other project", () => { + it("uses plural wording and lists every project for more than one other project", () => { const message = describeGuardedPluginRefMessage({ binary: "codex", ref: "aidd-vcs@aidd-framework", otherProjects: ["/other-project", "/third-project"], }); - expect(message).toContain("2 other projects still reference"); + expect(message).toBe( + "codex: 'aidd-vcs@aidd-framework' left enabled — codex enables a plugin machine-wide, and " + + "2 other projects still reference the shared source: /other-project, /third-project — " + + "which is why it stays; full removal is `aidd clean` in each of them, then `aidd clean --scope user`." + ); }); // Full removal names both commands in order — `aidd clean` in each other project before diff --git a/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts new file mode 100644 index 000000000..b032a1c86 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts @@ -0,0 +1,112 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { userScopeFilesSafeToDelete } from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const HOME = "/home/u"; +const BOUNDARY = join(HOME, ".cursor", "plugins", "local"); +const HASH = "abc123abc123abc123abc123abc123ab"; + +function plugin(files: Record): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files, + scope: "user", + }); +} + +describe("userScopeFilesSafeToDelete", () => { + it("answers nothing for a tool without a user-scope plugin directory", async () => { + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + new InMemoryFileAdapter(), + logger, + plugin({ "aidd-test/a.md": HASH }), + "claude", + HOME + ); + + expect([...safe]).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names and leaves in place a file whose path escapes the user-scope directory", async () => { + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + new InMemoryFileAdapter(), + logger, + plugin({ "../escape.md": HASH }), + "cursor", + HOME + ); + + expect([...safe]).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `cursor: 'aidd-test' file '../escape.md' does not resolve inside ${BOUNDARY}; left in place.`, + ]); + }); + + it("answers nothing, silently, when the user-scope directory itself does not exist", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", BOUNDARY, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + fs, + logger, + plugin({ "aidd-test/a.md": HASH }), + "cursor", + HOME + ); + + expect([...safe]).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names and leaves in place a file that no longer exists", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test", "gone.md"), errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + fs, + logger, + plugin({ "aidd-test/gone.md": HASH, "aidd-test/a.md": HASH }), + "cursor", + HOME + ); + + expect([...safe]).toStrictEqual([["aidd-test/a.md", HASH]]); + expect(logger.warnMessages).toStrictEqual([ + `cursor: 'aidd-test' file 'aidd-test/gone.md' does not resolve inside ${BOUNDARY}; left in place.`, + ]); + }); + + it("propagates a resolution failure other than absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test", "a.md"), errnoError("EACCES")); + + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/a.md": HASH }), + "cursor", + HOME + ) + ).rejects.toThrow("EACCES: planted by the test"); + }); +}); diff --git a/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts index 095f39d2a..f3422d668 100644 --- a/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts @@ -63,7 +63,42 @@ describe("StatusAllUseCase", () => { const result = await new StatusAllUseCase(useCase).execute(PROJECT_ROOT); - expect(result.pluginDrift).toEqual([]); - expect(result.errors.map((e) => e.scope)).toEqual(["ai"]); + expect(result).toStrictEqual({ + aiTools: { tools: [], pluginDrift: [], inSync: true }, + ideTools: report(), + pluginDrift: [], + errors: [{ scope: "ai", message: "ai scope exploded" }], + }); + }); + + it("returns each scope's own report", async () => { + const aiReport = report({ tools: [{ toolId: "claude", version: "1", drifted: [] }] }); + const ideReport = report({ tools: [{ toolId: "vscode", version: "1", drifted: [] }] }); + const { useCase } = fakeStatus((o) => (o.category === "ai" ? aiReport : ideReport)); + + const result = await new StatusAllUseCase(useCase).execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + aiTools: aiReport, + ideTools: ideReport, + pluginDrift: [], + errors: [], + }); + }); + + it("substitutes an empty in-sync report for the ide scope when it failed", async () => { + const { useCase } = fakeStatus((o) => { + if (o.category === "ide") throw new Error("ide scope exploded"); + return report(); + }); + + const result = await new StatusAllUseCase(useCase).execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + aiTools: report(), + ideTools: { tools: [], pluginDrift: [], inSync: true }, + pluginDrift: [], + errors: [{ scope: "ide", message: "ide scope exploded" }], + }); }); }); diff --git a/cli/tests/contexts/framework/application/status-use-case.unit.test.ts b/cli/tests/contexts/framework/application/status-use-case.unit.test.ts index 01ea91a11..6ad249b30 100644 --- a/cli/tests/contexts/framework/application/status-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/status-use-case.unit.test.ts @@ -8,11 +8,264 @@ import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { InitUseCase } from "../../../../src/contexts/framework/application/init-use-case.js"; import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { machineLocalFilesOf } from "../../../../src/contexts/tools/domain/registry.js"; +import { NoManifestError, ToolNotInstalledError } from "../../../../src/kernel/errors.js"; +import { InstallationFile } from "../../../../src/kernel/file.js"; import { compareSemver } from "../../../../src/kernel/semver.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; const PROJECT_ROOT = "/test-project"; +const TRACKED = ".claude/rules/one.md"; +const CONTENT = "one\n"; +const MERGE_PATH = ".vscode/settings.json"; +const KEY = "editor.formatOnSave"; +const hasher = new DeterministicHasher(); + +function tracking(manifest: Manifest, toolId: ToolId, relativePath: string, content: string) { + manifest.addTool(toolId, "test", [ + new InstallationFile({ relativePath, content, hash: hasher.hash(content) }), + ]); +} + +function merging(manifest: Manifest) { + manifest.addTool( + "vscode", + "test", + [], + [{ relativePath: MERGE_PATH, sectionKey: null, entries: { [KEY]: hasher.hash("true") } }] + ); +} + +function statusOver(onDisk: Record, manifest: Manifest | null) { + const files = Object.fromEntries( + Object.entries(onDisk).map(([relativePath, content]) => [ + `${PROJECT_ROOT}/${relativePath}`, + content, + ]) + ); + const fs = new InMemoryFileAdapter(files, hasher); + return new StatusUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + hasher, + new DetectPluginDriftUseCase(fs) + ); +} + +function inSyncTool(toolId: ToolId) { + return { toolId, version: "test", drifted: [] }; +} + +describe("StatusUseCase", () => { + describe("which tools are reported", () => { + it("fails when no manifest exists", async () => { + await expect(statusOver({}, null).execute({ projectRoot: PROJECT_ROOT })).rejects.toThrow( + NoManifestError + ); + }); + + it("refuses a filter naming a tool that is not installed", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + await expect( + statusOver({}, manifest).execute({ projectRoot: PROJECT_ROOT, filterToolId: "cursor" }) + ).rejects.toThrow(ToolNotInstalledError); + }); + + it("reports only the filtered tool", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("vscode", "test", []); + + const report = await statusOver({}, manifest).execute({ + projectRoot: PROJECT_ROOT, + filterToolId: "claude", + }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("claude")], + pluginDrift: [], + inSync: true, + }); + }); + + it("reports only the tools of the requested category", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("vscode", "test", []); + + const report = await statusOver({}, manifest).execute({ + projectRoot: PROJECT_ROOT, + category: "ide", + }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("vscode")], + pluginDrift: [], + inSync: true, + }); + }); + + it("reports a tool whose directory is absent from disk as in sync", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + const report = await statusOver({}, manifest).execute({ projectRoot: PROJECT_ROOT }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("claude")], + pluginDrift: [], + inSync: true, + }); + }); + }); + + describe("tracked files", () => { + function manifestTracking() { + const manifest = Manifest.create(); + tracking(manifest, "claude", TRACKED, CONTENT); + return manifest; + } + + it("reports an untouched tracked file as in sync", async () => { + const report = await statusOver({ [TRACKED]: CONTENT }, manifestTracking()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("claude")], + pluginDrift: [], + inSync: true, + }); + }); + + it("calls a tracked file whose content changed modified", async () => { + const report = await statusOver({ [TRACKED]: "edited\n" }, manifestTracking()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report).toStrictEqual({ + tools: [ + { + toolId: "claude", + version: "test", + drifted: [{ relativePath: TRACKED, status: "modified" }], + }, + ], + pluginDrift: [], + inSync: false, + }); + }); + + it("calls a tracked file gone from disk deleted", async () => { + const report = await statusOver({}, manifestTracking()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report.tools).toStrictEqual([ + { + toolId: "claude", + version: "test", + drifted: [{ relativePath: TRACKED, status: "deleted" }], + }, + ]); + }); + + it("calls a file in the tool directory the manifest does not track added", async () => { + const report = await statusOver( + { [TRACKED]: CONTENT, ".claude/rules/two.md": "two\n" }, + manifestTracking() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([ + { + toolId: "claude", + version: "test", + drifted: [{ relativePath: ".claude/rules/two.md", status: "added" }], + }, + ]); + }); + + it("leaves a backup file out of the additions", async () => { + const report = await statusOver( + { [TRACKED]: CONTENT, ".claude/rules/one.md.backup": "old\n" }, + manifestTracking() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([inSyncTool("claude")]); + }); + }); + + describe("merge files", () => { + const DRIFT_PATH = `${MERGE_PATH} > ${KEY}`; + + function manifestMerging() { + const manifest = Manifest.create(); + merging(manifest); + return manifest; + } + + it("reports a merge file whose managed keys match as in sync", async () => { + const report = await statusOver( + { [MERGE_PATH]: `{ "${KEY}": true }` }, + manifestMerging() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("vscode")], + pluginDrift: [], + inSync: true, + }); + }); + + it("calls every managed key of a missing merge file deleted", async () => { + const report = await statusOver({}, manifestMerging()).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([ + { + toolId: "vscode", + version: "test", + drifted: [{ relativePath: DRIFT_PATH, status: "deleted" }], + }, + ]); + }); + + it("calls a managed key missing from the merge file deleted", async () => { + const report = await statusOver({ [MERGE_PATH]: "{}" }, manifestMerging()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report.tools).toStrictEqual([ + { + toolId: "vscode", + version: "test", + drifted: [{ relativePath: DRIFT_PATH, status: "deleted" }], + }, + ]); + }); + + it("calls a managed key whose value changed modified", async () => { + const report = await statusOver( + { [MERGE_PATH]: `{ "${KEY}": false }` }, + manifestMerging() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([ + { + toolId: "vscode", + version: "test", + drifted: [{ relativePath: DRIFT_PATH, status: "modified" }], + }, + ]); + }); + }); +}); describe("status", () => { it("reports no drift when no tools are installed", async () => { diff --git a/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts index 7e5139b04..f03e81bf9 100644 --- a/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { UninstallIdeUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-ide-use-case.js"; import { UninstallToolsUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-tools-use-case.js"; +import { NoManifestError, ToolNotInstalledError } from "../../../../src/kernel/errors.js"; import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -78,3 +79,40 @@ describe("UninstallIdeUseCase — merge-file entries", () => { expect(manifest?.hasTool("vscode")).toBe(false); }); }); + +describe("UninstallIdeUseCase — refusals and report", () => { + it("refuses a project that has no manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + + await expect( + makeUseCase(deps).execute({ toolId: "vscode", projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(NoManifestError); + }); + + it("refuses a tool that is not installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + + await expect( + makeUseCase(deps).execute({ toolId: "vscode", projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(ToolNotInstalledError); + }); + + it("reports every file it removed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + + const result = await makeUseCase(deps).execute({ toolId: "vscode", projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ + toolId: "vscode", + fileCount: 3, + deletedFiles: [ + ".vscode/keybindings.json", + ".vscode/extensions.json", + ".vscode/settings.json", + ], + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts index cb98b8ce1..34f77639f 100644 --- a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts @@ -1,12 +1,18 @@ 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/codex/profile.js"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { UninstallPluginUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-plugin-use-case.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.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 { PluginNotFoundError } from "../../../../src/kernel/errors.js"; +import { NoManifestError, PluginNotFoundError } from "../../../../src/kernel/errors.js"; import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; @@ -65,3 +71,68 @@ describe("UninstallUseCase — plugin scope", () => { ).rejects.toThrow(PluginNotFoundError); }); }); + +describe("UninstallPluginUseCase — which plugin, on which tools", () => { + const HASH = "abc123abc123abc123abc123abc123ab"; + + function pluginNamed(name: string): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [`commands/${name}.md`]: HASH }, + scope: "project", + }); + } + + function uninstallOver(manifest: Manifest | null) { + return new UninstallPluginUseCase( + new InMemoryFileAdapter(), + new InMemoryManifestRepository(manifest) + ); + } + + it("refuses a project that has no manifest", async () => { + await expect( + uninstallOver(null).execute({ pluginName: "sample", toolIds: [], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(NoManifestError); + }); + + it("removes the plugin only from the tools it was asked about", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addTool("codex", "1.0.0", []); + manifest.addPlugin("claude", pluginNamed("sample")); + manifest.addPlugin("codex", pluginNamed("sample")); + + const results = await uninstallOver(manifest).execute({ + pluginName: "sample", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 1, deletedFiles: ["commands/sample.md"] }, + ]); + expect(manifest.getPlugins("codex").map((p) => p.name)).toStrictEqual(["sample"]); + }); + + it("removes only the plugin bearing the name asked for", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin("claude", pluginNamed("sample")); + manifest.addPlugin("claude", pluginNamed("other")); + + const results = await uninstallOver(manifest).execute({ + pluginName: "other", + toolIds: [], + projectRoot: PROJECT_ROOT, + }); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 1, deletedFiles: ["commands/other.md"] }, + ]); + expect(manifest.getPlugins("claude").map((p) => p.name)).toStrictEqual(["sample"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts index a97234d58..d8340a1a3 100644 --- a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts @@ -1,10 +1,18 @@ 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/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/vscode/profile.js"; import { UninstallToolsUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-tools-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 FileHash, InstallationFile } from "../../../../src/kernel/file.js"; +import type { MergeFileEntry } from "../../../../src/kernel/merge.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/test-project"; @@ -49,3 +57,221 @@ describe("UninstallToolsUseCase — cursor plugin file (user-scope)", () => { expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); }); }); + +const hasher = new DeterministicHasher(); +const SETTINGS = ".claude/settings.json"; +const AGENTS = "AGENTS.md"; +const MCP = ".mcp.json"; + +function tracked(relativePath: string, content: string): InstallationFile { + return new InstallationFile({ relativePath, content: "", hash: hasher.hash(content) }); +} + +function merging( + relativePath: string, + values: Record, + sectionKey: string | null = null +): MergeFileEntry { + const entries: Record = {}; + for (const [key, value] of Object.entries(values)) { + entries[key] = hasher.hash(JSON.stringify(value)); + } + return { relativePath, sectionKey, entries }; +} + +function project(seed: Record): { fs: InMemoryFileAdapter; manifest: Manifest } { + const absolute: Record = {}; + for (const [relativePath, content] of Object.entries(seed)) { + absolute[join(PROJECT_ROOT, relativePath)] = content; + } + return { fs: new InMemoryFileAdapter(absolute, hasher), manifest: Manifest.create() }; +} + +function remove( + target: { fs: InMemoryFileAdapter; manifest: Manifest }, + toolIds: ToolId[], + logger = new CapturingLogger() +) { + return new UninstallToolsUseCase(target.fs, logger).execute({ + toolIds, + manifest: target.manifest, + projectRoot: PROJECT_ROOT, + }); +} + +describe("UninstallToolsUseCase — regular files", () => { + it("announces each tool it removes", async () => { + const target = project({}); + target.manifest.addTool("claude", "test", []); + const logger = new CapturingLogger(); + + await remove(target, ["claude"], logger); + + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + + it("deletes the files a tool tracks and reports them, then forgets the tool", async () => { + const target = project({ [SETTINGS]: "{}", [AGENTS]: "# Agents" }); + target.manifest.addTool("claude", "test", [ + tracked(SETTINGS, "{}"), + tracked(AGENTS, "# Agents"), + ]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 2, deletedFiles: [SETTINGS, AGENTS] }, + ]); + expect(target.fs.listAll()).toStrictEqual([]); + expect(target.manifest.hasTool("claude")).toBe(false); + }); + + it("keeps a file another installed tool still tracks", async () => { + const target = project({ [SETTINGS]: "{}", [AGENTS]: "# Agents" }); + target.manifest.addTool("claude", "test", [ + tracked(SETTINGS, "{}"), + tracked(AGENTS, "# Agents"), + ]); + target.manifest.addTool("codex", "test", [tracked(AGENTS, "# Agents")]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: [SETTINGS] }]); + expect(target.fs.has(join(PROJECT_ROOT, AGENTS))).toBe(true); + }); + + it("deletes a file shared only between the tools removed together", async () => { + const target = project({ [AGENTS]: "# Agents" }); + target.manifest.addTool("claude", "test", [tracked(AGENTS, "# Agents")]); + target.manifest.addTool("codex", "test", [tracked(AGENTS, "# Agents")]); + + const results = await remove(target, ["claude", "codex"]); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 1, deletedFiles: [AGENTS] }, + { toolId: "codex", fileCount: 1, deletedFiles: [AGENTS] }, + ]); + expect(target.fs.has(join(PROJECT_ROOT, AGENTS))).toBe(false); + }); + + it("keeps a file another installed tool merges into", async () => { + const target = project({ [MCP]: '{"hub":{}}' }); + target.manifest.addTool("claude", "test", [tracked(MCP, '{"hub":{}}')]); + target.manifest.addTool("codex", "test", [], [merging(MCP, { hub: {} })]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + expect(target.fs.has(join(PROJECT_ROOT, MCP))).toBe(true); + }); +}); + +describe("UninstallToolsUseCase — merge files", () => { + const HUB = { command: "hub" }; + const PLAYWRIGHT = { command: "npx" }; + + it("deletes a merge file an AI tool alone owns, even one it cannot parse", async () => { + const target = project({ [MCP]: "not json" }); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: [MCP] }]); + expect(target.fs.has(join(PROJECT_ROOT, MCP))).toBe(false); + }); + + it("deletes a section-scoped merge file an AI tool alone owns even when it tracks no key in it", async () => { + const target = project({ [MCP]: '{"x":1}', "codex.json": "{}" }); + target.manifest.addTool("claude", "test", [], [merging(MCP, {}, "mcpServers")]); + target.manifest.addTool("codex", "test", [], [merging("codex.json", {})]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: [MCP] }]); + expect(target.fs.has(join(PROJECT_ROOT, MCP))).toBe(false); + }); + + it("strips its own keys from a merge file a remaining tool owns and does not count it", async () => { + const target = project({ [MCP]: JSON.stringify({ hub: HUB, playwright: PLAYWRIGHT }) }); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + target.manifest.addTool( + "codex", + "test", + [], + [merging("codex.json", {}), merging(MCP, { playwright: PLAYWRIGHT })] + ); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + expect(JSON.parse(target.fs.getFile(join(PROJECT_ROOT, MCP)) ?? "")).toStrictEqual({ + playwright: PLAYWRIGHT, + }); + }); + + it("keeps a merge file when any remaining tool owns it, not only when all do", async () => { + const target = project({ [MCP]: JSON.stringify({ hub: HUB, playwright: PLAYWRIGHT }) }); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + target.manifest.addTool("codex", "test", [], [merging(MCP, { playwright: PLAYWRIGHT })]); + target.manifest.addTool("copilot", "test", [], [merging("copilot.json", {})]); + + await remove(target, ["claude"]); + + expect(JSON.parse(target.fs.getFile(join(PROJECT_ROOT, MCP)) ?? "")).toStrictEqual({ + playwright: PLAYWRIGHT, + }); + }); + + it("does not count a merge file already gone from disk", async () => { + const target = project({}); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + }); + + it("leaves an IDE merge file byte-identical when it tracks no key in it", async () => { + const target = project({ ".vscode/settings.json": '{"a":1}' }); + target.manifest.addTool("vscode", "test", [], [merging(".vscode/settings.json", {})]); + + const results = await remove(target, ["vscode"]); + + expect(results).toStrictEqual([{ toolId: "vscode", fileCount: 0, deletedFiles: [] }]); + expect(target.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json"))).toBe('{"a":1}'); + }); + + it("strips an IDE tool's keys and deletes the file once nothing is left", async () => { + const target = project({ ".vscode/extensions.json": '{"recommendations":["a"]}' }); + target.manifest.addTool( + "vscode", + "test", + [], + [merging(".vscode/extensions.json", { recommendations: ["a"] })] + ); + + const results = await remove(target, ["vscode"]); + + expect(results).toStrictEqual([ + { toolId: "vscode", fileCount: 1, deletedFiles: [".vscode/extensions.json"] }, + ]); + expect(target.fs.has(join(PROJECT_ROOT, ".vscode/extensions.json"))).toBe(false); + }); + + it("strips an IDE tool's keys and keeps the user's own", async () => { + const target = project({ ".vscode/settings.json": '{"editor.tabSize":2,"user.key":7}' }); + target.manifest.addTool( + "vscode", + "test", + [], + [merging(".vscode/settings.json", { "editor.tabSize": 2 })] + ); + + const results = await remove(target, ["vscode"]); + + expect(results).toStrictEqual([{ toolId: "vscode", fileCount: 0, deletedFiles: [] }]); + expect( + JSON.parse(target.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json")) ?? "") + ).toStrictEqual({ "user.key": 7 }); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts index 334fa83c6..167230af4 100644 --- a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts @@ -7,8 +7,18 @@ import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { + InputRequiredError, + NoManifestError, + ToolNotInstalledError, +} from "../../../../src/kernel/errors.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; const PROJECT_ROOT = "/test-project"; @@ -120,3 +130,84 @@ describe("uninstall", () => { }); }); }); + +describe("uninstall — refusals", () => { + it("refuses to remove nothing, naming every tool it knows", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + + await expect( + new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: [], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }) + ).rejects.toThrow( + new InputRequiredError( + "At least one tool ID is required. Valid tools: claude, cursor, copilot, opencode, codex, vscode" + ) + ); + }); + + it("refuses a project that has no manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + + await expect( + new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }) + ).rejects.toThrow(NoManifestError); + }); + + it("refuses a tool that is not installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + + await expect( + new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }) + ).rejects.toThrow(ToolNotInstalledError); + }); +}); + +describe("uninstall — an MCP filter", () => { + it("strips the named entries and leaves the tool installed", async () => { + const hasher = new DeterministicHasher(); + const servers = { github: { command: "gh" }, playwright: { command: "npx" } }; + const fs = new InMemoryFileAdapter( + { [join(PROJECT_ROOT, ".mcp.json")]: JSON.stringify({ mcpServers: servers }) }, + hasher + ); + const manifest = Manifest.create(); + manifest.addTool( + "claude", + "test", + [], + [ + { + relativePath: ".mcp.json", + sectionKey: "mcpServers", + entries: { + github: hasher.hash(JSON.stringify(servers.github)), + playwright: hasher.hash(JSON.stringify(servers.playwright)), + }, + }, + ] + ); + const repo = new InMemoryManifestRepository(manifest); + + const results = await new UninstallUseCase(fs, repo, new CapturingLogger()).execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + mcpFilter: ["github"], + }); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: ["github"] }]); + expect(repo.getCurrent()?.hasTool("claude")).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts new file mode 100644 index 000000000..6a122cbf3 --- /dev/null +++ b/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { UninstallMcpExclusionUseCase } from "../../../../../src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { FileHash } from "../../../../../src/kernel/file.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const MCP = ".mcp.json"; +const SECTION = "mcpServers"; +const hasher = new DeterministicHasher(); +const GITHUB = { command: "gh" }; +const PLAYWRIGHT = { command: "npx" }; +const CONTENT = JSON.stringify({ [SECTION]: { github: GITHUB, playwright: PLAYWRIGHT } }); + +function hashOf(value: unknown): FileHash { + return hasher.hash(JSON.stringify(value)); +} + +function claudeMerging( + content: string, + entries: Record, + sectionKey: string | null = SECTION +): { fs: InMemoryFileAdapter; manifest: Manifest } { + const fs = new InMemoryFileAdapter({ [join(PROJECT_ROOT, MCP)]: content }, hasher); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", [], [{ relativePath: MCP, sectionKey, entries }]); + return { fs, manifest }; +} + +function twoServers() { + return claudeMerging(CONTENT, { github: hashOf(GITHUB), playwright: hashOf(PLAYWRIGHT) }); +} + +function exclude( + project: { fs: InMemoryFileAdapter; manifest: Manifest }, + mcpFilter: string[], + logger = new CapturingLogger() +) { + return new UninstallMcpExclusionUseCase(project.fs, logger).execute({ + toolId: "claude", + manifest: project.manifest, + projectRoot: PROJECT_ROOT, + mcpFilter, + }); +} + +describe("UninstallMcpExclusionUseCase", () => { + it("announces the tool it strips entries from", async () => { + const logger = new CapturingLogger(); + + await exclude(twoServers(), ["github"], logger); + + expect(logger.infoMessages).toStrictEqual(["Removing MCP entries from claude..."]); + }); + + it("removes the named entries that exist and reports exactly those", async () => { + const project = twoServers(); + + const result = await exclude(project, ["github", "absent"]); + + expect(result).toStrictEqual({ toolId: "claude", fileCount: 1, deletedFiles: ["github"] }); + expect(JSON.parse(project.fs.getFile(join(PROJECT_ROOT, MCP)) ?? "")).toStrictEqual({ + [SECTION]: { playwright: PLAYWRIGHT }, + }); + }); + + it("stops tracking the entries it removed", async () => { + const project = twoServers(); + + await exclude(project, ["github"]); + + expect(project.manifest.getMergeFiles("claude")).toStrictEqual([ + { relativePath: MCP, sectionKey: SECTION, entries: { playwright: hashOf(PLAYWRIGHT) } }, + ]); + }); + + it("records each removal so a later install leaves the entry out", async () => { + const project = twoServers(); + + await exclude(project, ["github"]); + + expect(project.manifest.getExcludedMcp("claude")).toStrictEqual([ + { configPath: MCP, entryKey: "github" }, + ]); + }); + + it("leaves a file byte-identical when none of the named entries is in it", async () => { + const project = twoServers(); + + const result = await exclude(project, ["absent"]); + + expect(result).toStrictEqual({ toolId: "claude", fileCount: 0, deletedFiles: [] }); + expect(project.fs.getFile(join(PROJECT_ROOT, MCP))).toBe(CONTENT); + }); + + it("leaves a file tracked without a section untouched even when it holds a named key", async () => { + const content = JSON.stringify({ github: GITHUB }); + const project = claudeMerging(content, { github: hashOf(GITHUB) }, null); + + const result = await exclude(project, ["github"]); + + expect(result).toStrictEqual({ toolId: "claude", fileCount: 0, deletedFiles: [] }); + expect(project.fs.getFile(join(PROJECT_ROOT, MCP))).toBe(content); + }); +}); diff --git a/cli/tests/contexts/framework/domain/config-capability.unit.test.ts b/cli/tests/contexts/framework/domain/config-capability.unit.test.ts new file mode 100644 index 000000000..6cd094144 --- /dev/null +++ b/cli/tests/contexts/framework/domain/config-capability.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { extractConfigCapabilities } from "../../../../src/contexts/framework/domain/config-capability.js"; +import { HooksCapability } from "../../../../src/contexts/tools/domain/capabilities/hooks-capability.js"; +import { McpCapability } from "../../../../src/contexts/tools/domain/capabilities/mcp-capability.js"; +import { SettingsCapability } from "../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; +import type { + HasSettings, + IdeToolConfig, +} from "../../../../src/contexts/tools/domain/contracts.js"; +import { stubAiTool } from "../../../helpers/ports/stub-ai-tool.js"; + +const mcp = new McpCapability({ outputPath: ".mcp.json", format: "json" }); +const hooks = new HooksCapability({ outputPath: ".claude/settings.json" }); +const settings = new SettingsCapability({ + outputPath: ".claude/settings.json", + mergeStrategy: "none", +}); +const extensions = new SettingsCapability({ + outputPath: ".vscode/extensions.json", + mergeStrategy: "user-prime", +}); + +function ideTool(declared: SettingsCapability | SettingsCapability[]): IdeToolConfig & HasSettings { + return { + kind: "ide", + toolId: "vscode", + directory: ".vscode/", + signalDir: null, + settings: declared, + }; +} + +describe("extractConfigCapabilities — the config files a tool declares", () => { + describe("an IDE tool", () => { + it("lists each settings file of a tool declaring several", () => { + expect(extractConfigCapabilities(ideTool([extensions, settings]))).toStrictEqual([ + extensions, + settings, + ]); + }); + + it("lists the one settings file of a tool declaring a single one", () => { + expect(extractConfigCapabilities(ideTool(settings))).toStrictEqual([settings]); + }); + }); + + describe("an AI tool", () => { + it("lists mcp, hooks and settings in that order", () => { + const tool = stubAiTool("claude", { mcp, hooks, settings }); + + expect(extractConfigCapabilities(tool)).toStrictEqual([mcp, hooks, settings]); + }); + + it("lists each settings file of a tool declaring several", () => { + const tool = stubAiTool("claude", { settings: [extensions, settings] }); + + expect(extractConfigCapabilities(tool)).toStrictEqual([extensions, settings]); + }); + + it("lists nothing for a tool declaring no config capability", () => { + expect(extractConfigCapabilities(stubAiTool("claude", { rules: {} }))).toStrictEqual([]); + }); + + it("lists nothing for a tool whose capabilities are absent altogether", () => { + expect(extractConfigCapabilities(stubAiTool("claude", null))).toStrictEqual([]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts b/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts index da212858f..c2bebf809 100644 --- a/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts +++ b/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts @@ -17,6 +17,10 @@ describe("isFileReference", () => { expect(isFileReference("docs/")).toBe(false); }); + it("treats an absolute path whose last segment has an extension as a file", () => { + expect(isFileReference("/docs/guide.md")).toBe(true); + }); + it("returns false for paths with no extension in last segment", () => { expect(isFileReference("src/utils/helper")).toBe(false); expect(isFileReference("justadirectory")).toBe(false); @@ -68,3 +72,17 @@ describe("extractMarkdownLinkTargets", () => { expect(refs).toContain("docs/local.md"); }); }); + +describe("code fences that hide references", () => { + it("ignores a reference inside a fence whose language is a single letter", () => { + const content = "```c\n// @docs/ignored.md\n```\n@docs/visible.md"; + + expect(extractAtReferences(content)).toStrictEqual(["docs/visible.md"]); + }); + + it("ignores a reference inside a fence carrying attributes after its language", () => { + const content = "```ts title=example.ts\n// @docs/ignored.md\n```\n@docs/visible.md"; + + expect(extractAtReferences(content)).toStrictEqual(["docs/visible.md"]); + }); +}); diff --git a/cli/tests/contexts/framework/domain/install-scope.unit.test.ts b/cli/tests/contexts/framework/domain/install-scope.unit.test.ts index 965395f54..096339f65 100644 --- a/cli/tests/contexts/framework/domain/install-scope.unit.test.ts +++ b/cli/tests/contexts/framework/domain/install-scope.unit.test.ts @@ -3,14 +3,18 @@ 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/opencode/profile.js"; -import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { afterEach, describe, expect, it } from "vitest"; import { assertToolSupportsScope, getToolSupportedScope, isInstallScope, parseInstallScope, } from "../../../../src/contexts/framework/domain/install-scope.js"; +import { getToolConfig, registerTool } from "../../../../src/contexts/tools/domain/registry.js"; import { InvalidPluginScopeError } from "../../../../src/kernel/errors.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { stubAiTool } from "../../../helpers/ports/stub-ai-tool.js"; describe("install-scope value object", () => { describe("isInstallScope", () => { @@ -52,6 +56,30 @@ describe("install-scope value object", () => { expect(getToolSupportedScope("copilot")).toBe("project"); expect(getToolSupportedScope("opencode")).toBe("project"); }); + + it("answers project for a tool that is not an AI tool", () => { + expect(getToolSupportedScope("vscode" as AiToolId)).toBe("project"); + }); + + describe("an AI tool declaring no plugins capability", () => { + const original = getToolConfig("opencode"); + + afterEach(() => { + registerTool(original); + }); + + it("answers project", () => { + registerTool(stubAiTool("opencode", {})); + + expect(getToolSupportedScope("opencode")).toBe("project"); + }); + + it("answers project when the capability names no install scope", () => { + registerTool(stubAiTool("opencode", { plugins: {} })); + + expect(getToolSupportedScope("opencode")).toBe("project"); + }); + }); }); describe("assertToolSupportsScope", () => { diff --git a/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts b/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts index 5ee34790a..a8186fa70 100644 --- a/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts +++ b/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts @@ -75,6 +75,17 @@ describe("toInstalledRule — one installed file, read as a rule", () => { expect(rule.paths).toEqual(["src/**", "tests/**"]); }); + it("drops the empty entries a stray or trailing comma leaves in a scope", () => { + const rule = toInstalledRule( + "cursor", + ".cursor/rules/a.mdc", + ".mdc", + '---\nglobs: "src/**,, tests/**,"\n---\n' + ); + + expect(rule.paths).toStrictEqual(["src/**", "tests/**"]); + }); + /** `SCOPE_FIELDS.flatMap` concatenates without deduplicating, so a glob carried under two * field names at once would come out twice in `InstalledRule.paths`. */ it("states a glob carried under two field names only once", () => { diff --git a/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts b/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts index 7af067da8..c6cea161e 100644 --- a/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; -import { DuplicatePluginError, PluginNotFoundError } from "../../../../src/kernel/errors.js"; +import { + DuplicatePluginError, + PluginNotFoundError, + ToolNotInManifestError, +} from "../../../../src/kernel/errors.js"; import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; @@ -101,6 +105,46 @@ describe("isFileTracked() with plugins", () => { }); }); +describe("updatePlugin()", () => { + it("replaces only the plugin of that name", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("other")); + manifest.addPlugin(CLAUDE, makePlugin("target")); + + manifest.updatePlugin(CLAUDE, makePlugin("target").withVersion("2.0.0")); + + expect(manifest.getPlugins(CLAUDE).map((p) => [p.name, p.version])).toStrictEqual([ + ["other", "1.0.0"], + ["target", "2.0.0"], + ]); + }); + + it("throws PluginNotFoundError when plugin does not exist", () => { + const manifest = makeManifest(); + expect(() => manifest.updatePlugin(CLAUDE, makePlugin("ghost"))).toThrow(PluginNotFoundError); + }); +}); + +describe("a tool that is not installed", () => { + const ABSENT = "codex" as ToolId; + + it("has no plugins", () => { + expect(makeManifest().getPlugins(ABSENT)).toStrictEqual([]); + }); + + it("refuses a plugin, naming the tool", () => { + expect(() => makeManifest().addPlugin(ABSENT, makePlugin())).toThrow(ToolNotInManifestError); + }); + + it("refuses a plugin removal, naming the tool", () => { + expect(() => makeManifest().removePlugin(ABSENT, "my-plugin")).toThrow(ToolNotInManifestError); + }); + + it("refuses a plugin update, naming the tool", () => { + expect(() => makeManifest().updatePlugin(ABSENT, makePlugin())).toThrow(ToolNotInManifestError); + }); +}); + describe("addTool() preserves existing plugins on re-add", () => { it("keeps plugins when addTool is called again", () => { const manifest = makeManifest(); diff --git a/cli/tests/contexts/framework/domain/manifest-serialization.unit.test.ts b/cli/tests/contexts/framework/domain/manifest-serialization.unit.test.ts new file mode 100644 index 000000000..79dbb73e1 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest-serialization.unit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { parseManifestTools } from "../../../../src/contexts/framework/domain/manifest-serialization.js"; +import { + InvalidManifestDataError, + InvalidManifestToolIdError, +} from "../../../../src/kernel/errors.js"; + +describe("parseManifestTools — the tools section of a manifest document", () => { + it("reads no tool from a document without a tools section", () => { + expect([...parseManifestTools({ version: 8 })]).toStrictEqual([]); + }); + + it("reads no tool from a document whose tools section is null", () => { + expect([...parseManifestTools({ version: 8, tools: null })]).toStrictEqual([]); + }); + + it("refuses a tool id this CLI does not know, naming it", () => { + const raw = { version: 8, tools: { nope: { toolId: "nope", version: "1.0.0", files: [] } } }; + + expect(() => parseManifestTools(raw)).toThrow(new InvalidManifestToolIdError("nope").message); + expect(() => parseManifestTools(raw)).toThrow(InvalidManifestToolIdError); + }); + + it("says a tool's files are missing when the entry has none", () => { + const raw = { version: 8, tools: { claude: { toolId: "claude", version: "1.0.0" } } }; + + expect(() => parseManifestTools(raw)).toThrow( + "Invalid manifest data: tools.claude.files: expected an array, got missing." + ); + expect(() => parseManifestTools(raw)).toThrow(InvalidManifestDataError); + }); + + it("names the type it found when a tool's files are not an array", () => { + const raw = { + version: 8, + tools: { claude: { toolId: "claude", version: "1.0.0", files: "nope" } }, + }; + + expect(() => parseManifestTools(raw)).toThrow( + "Invalid manifest data: tools.claude.files: expected an array, got string." + ); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest.unit.test.ts b/cli/tests/contexts/framework/domain/manifest.unit.test.ts index c9bc39c87..66283a081 100644 --- a/cli/tests/contexts/framework/domain/manifest.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest.unit.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { McpExclusion } from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; +import { InvalidManifestDataError, ToolNotInManifestError } from "../../../../src/kernel/errors.js"; import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { MergeFileEntry } from "../../../../src/kernel/merge.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; @@ -486,4 +488,182 @@ describe("Manifest", () => { expect(manifest.hasTool("claude" as ToolId)).toBe(false); }); }); + + describe("a tool that is not installed", () => { + it("has no tracked files", () => { + expect(Manifest.create().getToolFiles("claude" as ToolId)).toStrictEqual([]); + }); + + it("has no MCP exclusions", () => { + expect(Manifest.create().getExcludedMcp("claude" as ToolId)).toStrictEqual([]); + }); + + it("has no native registrations", () => { + expect(Manifest.create().getNativeRegistrations("claude" as ToolId)).toBeUndefined(); + }); + + it("refuses native registrations, naming the tool", () => { + const manifest = Manifest.create(); + + expect(() => + manifest.setNativeRegistrations("claude" as ToolId, { + binary: "claude", + marketplaces: [], + pluginRefs: [], + }) + ).toThrow(new ToolNotInManifestError("claude").message); + expect(() => + manifest.setNativeRegistrations("claude" as ToolId, { + binary: "claude", + marketplaces: [], + pluginRefs: [], + }) + ).toThrow(ToolNotInManifestError); + }); + }); + + describe("native registrations", () => { + it("survive a round trip through the document", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.setNativeRegistrations("claude" as ToolId, { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + + const restored = Manifest.fromJSON(manifest.toJSON()); + + expect(restored.getNativeRegistrations("claude" as ToolId)).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + }); + }); + + describe("getTrackedPathsInDirectory()", () => { + it("lists the tracked, merged and plugin files under the directory, across tools, and no other", () => { + const manifest = Manifest.create(); + manifest.addTool( + "claude" as ToolId, + "3.0.0", + [makeFile(".claude/rules/a.md", "aa"), makeFile("CLAUDE.md", "bb")], + [ + { relativePath: ".claude/settings.json", sectionKey: "hooks", entries: {} }, + { relativePath: ".mcp.json", sectionKey: "mcpServers", entries: {} }, + ] + ); + manifest.addTool("cursor" as ToolId, "1.0.0", [makeFile(".cursor/rules/b.mdc", "cc")]); + manifest.addPlugin( + "claude" as ToolId, + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { ".claude/skills/x.md": "d".repeat(32), "AGENTS.md": "e".repeat(32) }, + scope: "project", + }) + ); + + expect([...manifest.getTrackedPathsInDirectory(".claude/")]).toStrictEqual([ + ".claude/rules/a.md", + ".claude/settings.json", + ".claude/skills/x.md", + ]); + }); + }); + + describe("getInstalledDirectories()", () => { + it("names each top-level directory a tracked file sits under, with its trailing slash", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.addTool("cursor" as ToolId, "1.0.0", [makeFile(".cursor/rules/b.mdc", "cc")]); + + expect([...manifest.getInstalledDirectories()]).toStrictEqual([".claude/", ".cursor/"]); + }); + }); + + describe("clearExcludedMcp()", () => { + it("keeps the tool's version and files", () => { + const manifest = Manifest.create(); + manifest.addTool( + "claude" as ToolId, + "3.0.0", + claudeFiles, + [], + [{ configPath: ".mcp.json", entryKey: "playwright" }] + ); + + manifest.clearExcludedMcp("claude" as ToolId); + + expect(manifest.getToolVersion("claude" as ToolId)).toBe("3.0.0"); + expect(manifest.getToolFiles("claude" as ToolId).map((f) => f.relativePath)).toStrictEqual([ + ".claude/agents/code-reviewer.md", + ".claude/rules/naming.md", + ]); + }); + }); + + describe("updateToolMergeFiles()", () => { + const exclusion: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; + + it("replaces the exclusions when handed some", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusion]); + const replacement: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; + + manifest.updateToolMergeFiles("claude" as ToolId, [], [replacement]); + + expect(manifest.getExcludedMcp("claude" as ToolId)).toStrictEqual([replacement]); + }); + + it("keeps the exclusions when handed none", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusion]); + + manifest.updateToolMergeFiles("claude" as ToolId, []); + + expect(manifest.getExcludedMcp("claude" as ToolId)).toStrictEqual([exclusion]); + }); + }); + + describe("fromJSON() on a document that is not an object", () => { + it("refuses null, saying an object was expected", () => { + expect(() => Manifest.fromJSON(null)).toThrow(InvalidManifestDataError); + expect(() => Manifest.fromJSON(null)).toThrow("Invalid manifest data: expected an object."); + }); + + it("refuses a string, saying an object was expected", () => { + expect(() => Manifest.fromJSON("nope")).toThrow("Invalid manifest data: expected an object."); + }); + }); + + describe("version refusal wording", () => { + it("spells out the 5.2.2 remedy, then the deletion, for a version 6 document", () => { + expect(() => Manifest.fromJSON({ version: 6, tools: {} })).toThrow( + "Invalid manifest data: manifest version 6 predates version 8, the only one this CLI reads. " + + "5.2.2, a published CLI, wrote this version. Before deleting it, run " + + "`npx @ai-driven-dev/cli@5.2.2 clean --force` in this project so it unregisters " + + "what it registered and clears its own cache — once the manifest naming those " + + "is gone, nothing can drive that anymore. Then delete .aidd/manifest.json in this project, " + + "then run `aidd setup` to reinstall the framework." + ); + }); + + it("spells out the deletion alone for a version 7 document", () => { + expect(() => Manifest.fromJSON({ version: 7, tools: {} })).toThrow( + "Invalid manifest data: manifest version 7 predates version 8, the only one this CLI reads. " + + "No published CLI can write this version: delete .aidd/manifest.json in this project, " + + "then run `aidd setup` to reinstall the framework." + ); + }); + + it("reads a version that is not a number as unreadable, never as newer", () => { + expect(() => Manifest.fromJSON({ version: "99", tools: {} })).toThrow( + "manifest version 99 predates version 8" + ); + }); + }); }); diff --git a/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts new file mode 100644 index 000000000..d4f33fd24 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + addExclusions, + removeExclusions, +} from "../../../../../src/contexts/framework/domain/manifest/mcp-exclusions.js"; +import type { McpExclusion } from "../../../../../src/contexts/tools/domain/mcp-exclusion.js"; + +const playwright: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; +const github: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; +const context7: McpExclusion = { configPath: ".mcp.json", entryKey: "context7" }; + +describe("MCP exclusions recorded for a tool", () => { + it("keeps the exclusions already recorded ahead of the ones added", () => { + expect(addExclusions([playwright], [github])).toStrictEqual([playwright, github]); + }); + + it("removes exactly the exclusions named, keeping the rest", () => { + expect(removeExclusions([playwright, github, context7], [playwright, context7])).toStrictEqual([ + github, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts new file mode 100644 index 000000000..3fe84e62a --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + type NativeRegistrations, + parseNativeRegistrations, + toNativeRegistrationsData, +} from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; + +const registrations: NativeRegistrations = { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], +}; + +describe("native registrations — what a tool's own CLI was asked to register", () => { + describe("serialized", () => { + it("carries the binary, every marketplace pair and every plugin ref", () => { + expect(toNativeRegistrationsData(registrations)).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + }); + }); + + describe("parsed", () => { + it("reads back the binary, every marketplace pair and every plugin ref", () => { + expect( + parseNativeRegistrations({ + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared" }], + pluginRefs: ["a@declared", "b@declared"], + }) + ).toStrictEqual({ + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared" }], + pluginRefs: ["a@declared", "b@declared"], + }); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts new file mode 100644 index 000000000..c0fe68116 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + createToolEntry, + isFileTrackedInEntry, + parseToolEntry, + removePluginFromEntry, + serializeToolEntry, + type ToolEntry, + updatePluginInEntry, +} from "../../../../../src/contexts/framework/domain/manifest/tool-entry.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginNotFoundError } from "../../../../../src/kernel/errors.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; + +const makePlugin = (name: string, version = "1.0.0"): InstalledPlugin => + InstalledPlugin.fromJSON({ + name, + source: { kind: "github", repo: `owner/${name}` }, + version, + strict: false, + files: { [`.claude/plugins/${name}/README.md`]: "c".repeat(32) }, + scope: "project", + }); + +const makeEntry = (plugins: InstalledPlugin[]): ToolEntry => + createToolEntry({ + toolId: "claude", + version: "3.0.0", + files: [ + new InstallationFile({ + relativePath: ".claude/CLAUDE.md", + content: "content", + hash: new FileHash("a".repeat(32)), + }), + ], + mergeFiles: [{ relativePath: ".mcp.json", sectionKey: "mcpServers", entries: {} }], + excludedMcp: [], + existingPlugins: plugins, + }); + +describe("a tool's manifest entry", () => { + describe("removing a plugin", () => { + it("keeps every other plugin", () => { + const entry = makeEntry([makePlugin("keep-a"), makePlugin("drop"), makePlugin("keep-b")]); + + const updated = removePluginFromEntry(entry, "drop"); + + expect(updated.plugins.map((p) => p.name)).toStrictEqual(["keep-a", "keep-b"]); + }); + + it("refuses a plugin that is not installed even while others are", () => { + const entry = makeEntry([makePlugin("installed")]); + + expect(() => removePluginFromEntry(entry, "ghost")).toThrow(PluginNotFoundError); + }); + }); + + describe("updating a plugin", () => { + it("replaces only the plugin of that name", () => { + const entry = makeEntry([makePlugin("other"), makePlugin("target")]); + + const updated = updatePluginInEntry(entry, makePlugin("target", "2.0.0")); + + expect(updated.plugins.map((p) => [p.name, p.version])).toStrictEqual([ + ["other", "1.0.0"], + ["target", "2.0.0"], + ]); + }); + + it("refuses a plugin that is not installed", () => { + const entry = makeEntry([makePlugin("installed")]); + + expect(() => updatePluginInEntry(entry, makePlugin("ghost"))).toThrow(PluginNotFoundError); + }); + }); + + describe("tracking a file", () => { + it("does not count a path as tracked because some merge file exists", () => { + const entry = makeEntry([]); + + expect(isFileTrackedInEntry(entry, ".claude/settings.json")).toBe(false); + }); + }); + + describe("serialized", () => { + it("carries what the tool's own CLI was asked to register through a round trip", () => { + const entry: ToolEntry = { + ...makeEntry([]), + nativeRegistrations: { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }, + }; + + const data = serializeToolEntry(entry); + + expect(data.nativeRegistrations).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + expect(parseToolEntry("claude", data).nativeRegistrations).toStrictEqual( + entry.nativeRegistrations + ); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest/tracked-files.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/tracked-files.unit.test.ts new file mode 100644 index 000000000..da94efa05 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/tracked-files.unit.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + parseTrackedFiles, + type TrackedFile, + toTrackedFileData, + toTrackedFiles, + withUpdatedHash, +} from "../../../../../src/contexts/framework/domain/manifest/tracked-files.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; + +const HASH_A = new FileHash("a".repeat(32)); +const HASH_B = new FileHash("b".repeat(32)); +const HASH_NEW = new FileHash("f".repeat(32)); + +const installed = (relativePath: string, frameworkPath?: string): InstallationFile => + new InstallationFile({ relativePath, content: "content", hash: HASH_A, frameworkPath }); + +describe("tracked files — one tool's paths and hashes", () => { + describe("recorded from an installation", () => { + it("keeps where a framework-owned file came from", () => { + expect(toTrackedFiles([installed(".claude/rules/a.md", "rules/a.md")])).toStrictEqual([ + { relativePath: ".claude/rules/a.md", hash: HASH_A, frameworkPath: "rules/a.md" }, + ]); + }); + + it("records no origin for a file that has none", () => { + expect(toTrackedFiles([installed(".claude/CLAUDE.md")])).toStrictEqual([ + { relativePath: ".claude/CLAUDE.md", hash: HASH_A }, + ]); + }); + }); + + describe("serialized", () => { + it("writes the origin beside the hash for a framework-owned file", () => { + const file: TrackedFile = { + relativePath: ".claude/rules/a.md", + hash: HASH_A, + frameworkPath: "rules/a.md", + }; + + expect(toTrackedFileData([file])).toStrictEqual([ + { relativePath: ".claude/rules/a.md", hash: "a".repeat(32), frameworkPath: "rules/a.md" }, + ]); + }); + + it("writes no origin key for a file that has none", () => { + expect( + toTrackedFileData([{ relativePath: ".claude/CLAUDE.md", hash: HASH_A }]) + ).toStrictEqual([{ relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }]); + }); + }); + + describe("parsed", () => { + it("reads the origin back for a framework-owned file", () => { + expect( + parseTrackedFiles([ + { relativePath: ".claude/rules/a.md", hash: "a".repeat(32), frameworkPath: "rules/a.md" }, + ]) + ).toStrictEqual([ + { relativePath: ".claude/rules/a.md", hash: HASH_A, frameworkPath: "rules/a.md" }, + ]); + }); + + it("reads no origin key for a file that has none", () => { + expect( + parseTrackedFiles([{ relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }]) + ).toStrictEqual([{ relativePath: ".claude/CLAUDE.md", hash: HASH_A }]); + }); + }); + + describe("updating one file's hash", () => { + const files: TrackedFile[] = [ + { relativePath: ".claude/a.md", hash: HASH_A }, + { relativePath: ".claude/b.md", hash: HASH_B }, + ]; + + it("changes that file alone", () => { + expect(withUpdatedHash(files, ".claude/b.md", HASH_NEW)).toStrictEqual([ + { relativePath: ".claude/a.md", hash: HASH_A }, + { relativePath: ".claude/b.md", hash: HASH_NEW }, + ]); + }); + + it("appends a bare entry for a path not yet tracked, touching no other", () => { + expect(withUpdatedHash(files, ".claude/c.md", HASH_NEW)).toStrictEqual([ + { relativePath: ".claude/a.md", hash: HASH_A }, + { relativePath: ".claude/b.md", hash: HASH_B }, + { relativePath: ".claude/c.md", hash: HASH_NEW }, + ]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts b/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts index 4b1c342fe..f0212d472 100644 --- a/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts +++ b/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts @@ -96,6 +96,34 @@ describe("marketplaceSourceDrift — deciding purely from the path's own segment ).toBeUndefined(); }); + it("is undefined when the requested path names another marketplace than this run's", () => { + const requested = userBuiltMarketplaceDir( + DRIFT_CONTEXT.userCacheRoot, + "1.0.0", + "other-mkt", + "claude" + ); + + expect(marketplaceSourceDrift(sharedPath("2.0.0"), requested, DRIFT_CONTEXT)).toBeUndefined(); + }); + + it("is undefined when the requested path names another tool than this run's", () => { + const requested = userBuiltMarketplaceDir( + DRIFT_CONTEXT.userCacheRoot, + "1.0.0", + "aidd-framework", + "codex" + ); + + expect(marketplaceSourceDrift(sharedPath("2.0.0"), requested, DRIFT_CONTEXT)).toBeUndefined(); + }); + + it("is undefined when the requested version segment is not semver", () => { + expect( + marketplaceSourceDrift(sharedPath("2.0.0"), sharedPath("not-a-version"), DRIFT_CONTEXT) + ).toBeUndefined(); + }); + it("is undefined when the registered path names a different marketplace or tool under the shared cache root", () => { const registered = userBuiltMarketplaceDir( DRIFT_CONTEXT.userCacheRoot, diff --git a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts index 8e87fc4ce..f5899af98 100644 --- a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts @@ -4,13 +4,23 @@ import { InstalledPlugin, type McpDigestMap, type PluginEntryData, + parsePluginSpec, } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { InvalidPluginNameError, InvalidPluginVersionError, MalformedPluginScopeError, } from "../../../../../src/kernel/errors.js"; +const makeDistribution = (strict?: boolean): PluginDistribution => + new PluginDistribution({ + manifest: { name: "my-plugin", version: "1.0.0", ...(strict === undefined ? {} : { strict }) }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }); + const makePluginData = (overrides: Partial = {}): PluginEntryData => ({ name: "my-plugin", source: { kind: "github", repo: "owner/my-plugin" }, @@ -156,6 +166,60 @@ describe("InstalledPlugin", () => { }); }); + describe("fromJSON() on a name that starts well and ends badly", () => { + it("throws InvalidPluginNameError for a name whose tail is not a segment", () => { + expect(() => InstalledPlugin.fromJSON(makePluginData({ name: "my-plugin_" }))).toThrow( + InvalidPluginNameError + ); + }); + }); + + describe("toJSON() for a plugin installed from no marketplace", () => { + it("writes no marketplace key at all", () => { + const data = makePluginData(); + + expect(InstalledPlugin.fromJSON(data).toJSON()).toStrictEqual(data); + }); + }); + + describe("fromDistribution()", () => { + const source = { kind: "github", repo: "owner/my-plugin" } as const; + + it("is strict only where the distribution's manifest says so", () => { + expect( + InstalledPlugin.fromDistribution(makeDistribution(true), source, [], "project").strict + ).toBe(true); + }); + + it("is lenient where the distribution's manifest says nothing", () => { + expect( + InstalledPlugin.fromDistribution(makeDistribution(), source, [], "project").strict + ).toBe(false); + }); + + it("keeps each installed path's component path", () => { + const componentPaths = new Map([[".claude/rules/naming.md", "rules/naming.md"]]); + + const plugin = InstalledPlugin.fromDistribution( + makeDistribution(), + source, + [], + "project", + componentPaths + ); + + expect([...plugin.componentPaths]).toStrictEqual([ + [".claude/rules/naming.md", "rules/naming.md"], + ]); + }); + + it("keeps no component path when handed none", () => { + const plugin = InstalledPlugin.fromDistribution(makeDistribution(), source, [], "project"); + + expect([...plugin.componentPaths]).toStrictEqual([]); + }); + }); + describe("the three maps cannot be swapped", () => { it("fails to compile when one map's field is passed where another is expected", () => { const plugin = InstalledPlugin.fromJSON(makePluginData()); @@ -174,3 +238,27 @@ describe("InstalledPlugin", () => { }); }); }); + +describe("parsePluginSpec — a plugin argument as typed on the command line", () => { + it("reads the version after the last @", () => { + expect(parsePluginSpec("aidd-context@1.2.3")).toStrictEqual({ + name: "aidd-context", + version: "1.2.3", + }); + }); + + it("reads a bare name as the name alone, requesting no version", () => { + expect(parsePluginSpec("aidd-context")).toStrictEqual({ name: "aidd-context" }); + }); + + it("keeps a leading @ as part of a scoped name", () => { + expect(parsePluginSpec("@scope/plugin")).toStrictEqual({ name: "@scope/plugin" }); + }); + + it("splits a scoped name from its version at the last @", () => { + expect(parsePluginSpec("@scope/plugin@2.0.0")).toStrictEqual({ + name: "@scope/plugin", + version: "2.0.0", + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts index 2061a28a5..eaede3b17 100644 --- a/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts @@ -134,6 +134,32 @@ describe("resolvePluginSourceFromMarketplace", () => { }); }); + it("returns source unchanged when the absolute path is the marketplace dir itself", () => { + const entrySource: PluginSource = { kind: "local", path: MARKETPLACE_LOCAL_PATH }; + const marketplace = makeGithubMarketplace("org/repo", "main"); + + const result = resolvePluginSourceFromMarketplace( + entrySource, + marketplace, + MARKETPLACE_LOCAL_PATH + ); + + expect(result).toBe(entrySource); + }); + + it("returns source unchanged when the relative path is only ./", () => { + const entrySource: PluginSource = { kind: "local", path: "./" }; + const marketplace = makeGithubMarketplace("org/repo", "main"); + + const result = resolvePluginSourceFromMarketplace( + entrySource, + marketplace, + MARKETPLACE_LOCAL_PATH + ); + + expect(result).toBe(entrySource); + }); + it("returns source unchanged when absolute path is outside marketplace dir", () => { const entrySource: PluginSource = { kind: "local", path: "/some/other/absolute/path" }; const marketplace = makeGithubMarketplace("org/repo", "main"); diff --git a/cli/tests/contexts/framework/domain/plugins/requested-version-policy.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/requested-version-policy.unit.test.ts new file mode 100644 index 000000000..595fc550a --- /dev/null +++ b/cli/tests/contexts/framework/domain/plugins/requested-version-policy.unit.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_REQUESTED_VERSION_POLICY } from "../../../../../src/contexts/framework/domain/plugins/requested-version-policy.js"; + +describe("requested version policy", () => { + it("holds a requested version strictly unless a caller relaxes it", () => { + expect(DEFAULT_REQUESTED_VERSION_POLICY).toBe("strict"); + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts index 3a566c9e3..e7ed34057 100644 --- a/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts @@ -28,6 +28,10 @@ describe("isStrictlyWithinUserScope", () => { expect(isStrictlyWithinUserScope(BOUNDARY, BOUNDARY)).toBe(false); }); + it("rejects the boundary directory spelled with a trailing separator", () => { + expect(isStrictlyWithinUserScope(`${BOUNDARY}/`, BOUNDARY)).toBe(false); + }); + it("rejects a path that merely starts with the boundary's characters without a separator", () => { // Textually starts with BOUNDARY but is a sibling directory, not something inside it — // a naive `startsWith` would wrongly accept this. diff --git a/cli/tests/contexts/framework/domain/project-context.unit.test.ts b/cli/tests/contexts/framework/domain/project-context.unit.test.ts new file mode 100644 index 000000000..8cb9e56b3 --- /dev/null +++ b/cli/tests/contexts/framework/domain/project-context.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { ProjectContext } from "../../../../src/contexts/framework/domain/project-context.js"; + +describe("ProjectContext.describe() — one line naming what was detected", () => { + it("names every detected trait, dot-separated, in stack, monorepo, framework order", () => { + const context = new ProjectContext({ + stack: "typescript", + isMonorepo: true, + hasFramework: true, + }); + + expect(context.describe()).toBe("typescript · monorepo · AIDD present"); + }); + + it("names the stack alone for a single-package project without the framework", () => { + const context = new ProjectContext({ + stack: "typescript", + isMonorepo: false, + hasFramework: false, + }); + + expect(context.describe()).toBe("typescript"); + }); + + it("omits an unknown stack while still naming the monorepo", () => { + const context = new ProjectContext({ stack: "unknown", isMonorepo: true, hasFramework: false }); + + expect(context.describe()).toBe("monorepo"); + }); + + it("names the framework beside the stack once it is installed", () => { + const context = new ProjectContext({ stack: "python", isMonorepo: false, hasFramework: true }); + + expect(context.describe()).toBe("python · AIDD present"); + }); + + it("calls a project with nothing detected an unknown project", () => { + const context = new ProjectContext({ + stack: "unknown", + isMonorepo: false, + hasFramework: false, + }); + + expect(context.describe()).toBe("unknown project"); + }); +}); 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 c371d47db..44b4ca86a 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -46,6 +46,51 @@ describe("SetupFlow", () => { expect(flow.projectRoot).toBe(ROOT); expect(flow.aiTools).toEqual(["claude"]); }); + + it("accepts mode 'named' once a plugin is named, carrying the names through", () => { + const flow = makeFlow({ pluginMode: "named", pluginNames: ["my-plugin"] }); + + expect(flow.pluginMode).toBe("named"); + expect(flow.pluginNames).toStrictEqual(["my-plugin"]); + }); + + it("says what mode 'named' needs when no plugin is named", () => { + expect(() => makeFlow({ pluginMode: "named", pluginNames: [] })).toThrow( + 'Plugin mode "named" requires at least one plugin name.' + ); + }); + + it("names the mode that was given when names arrive under another mode", () => { + expect(() => makeFlow({ pluginMode: "all", pluginNames: ["my-plugin"] })).toThrow( + 'Plugin names provided but mode is "all" (expected "named").' + ); + }); + }); + + describe("defaults for what a caller leaves unsaid", () => { + it("installs no plugin", () => { + expect(makeFlow().pluginMode).toBe("none"); + }); + + it("names no plugin", () => { + expect(makeFlow().pluginNames).toStrictEqual([]); + }); + + it("is not interactive", () => { + expect(makeFlow().interactive).toBe(false); + }); + + it("does not force", () => { + expect(makeFlow().force).toBe(false); + }); + + it("carries a plugin mode through when one is given", () => { + expect(makeFlow({ pluginMode: "all" }).pluginMode).toBe("all"); + }); + + it("carries force through when asked", () => { + expect(makeFlow({ force: true }).force).toBe(true); + }); }); describe("scope", () => { @@ -71,6 +116,10 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user", aiTools: [] })).toThrow(UserScopeNoToolsError); }); + it("refuses --scope user when --ai is left unsaid, the same as an empty one", () => { + expect(() => makeFlow({ scope: "user" })).toThrow(UserScopeNoToolsError); + }); + it("refuses an AI tool with no user-scope activation at --scope user (opencode)", () => { expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).toThrow( UserScopeUnsupportedAiToolsError diff --git a/cli/tests/contexts/framework/domain/tool-recommendations.unit.test.ts b/cli/tests/contexts/framework/domain/tool-recommendations.unit.test.ts new file mode 100644 index 000000000..272316590 --- /dev/null +++ b/cli/tests/contexts/framework/domain/tool-recommendations.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { ProjectContext } from "../../../../src/contexts/framework/domain/project-context.js"; +import { + recommendAiTools, + recommendIdeTools, +} from "../../../../src/contexts/framework/domain/tool-recommendations.js"; + +describe("tool recommendations for a project whose stack is not recognised", () => { + it("recommends claude alone as the AI tool", () => { + const context = new ProjectContext({ + stack: "unknown", + isMonorepo: false, + hasFramework: false, + }); + + expect(recommendAiTools(context)).toStrictEqual(["claude"]); + }); +}); + +describe("tool recommendations once the framework is installed", () => { + it("recommends no IDE tool even for a typescript project", () => { + const context = new ProjectContext({ + stack: "typescript", + isMonorepo: false, + hasFramework: true, + }); + + expect(recommendIdeTools(context)).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/environment-adapter.unit.test.ts b/cli/tests/contexts/framework/infrastructure/environment-adapter.unit.test.ts new file mode 100644 index 000000000..19dbd8484 --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/environment-adapter.unit.test.ts @@ -0,0 +1,17 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EnvironmentAdapter } from "../../../../src/contexts/framework/infrastructure/environment-adapter.js"; + +const VARIABLE = "AIDD_ENVIRONMENT_ADAPTER_PROBE"; + +describe("EnvironmentAdapter", () => { + afterEach(() => { + delete process.env[VARIABLE]; + }); + + it("reads a variable the process set after the adapter was constructed", () => { + const environment = new EnvironmentAdapter(); + process.env[VARIABLE] = "later"; + + expect(environment.get(VARIABLE)).toBe("later"); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts index 0e5913bd0..61d684223 100644 --- a/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts @@ -38,6 +38,16 @@ describe("ManifestRepositoryAdapter", () => { await expect(adapter.load()).rejects.toThrow(manifestPath); }); + + it("rejects a refused manifest version naming this project as where it lives and `aidd setup` as the fix", async () => { + const manifestPath = join(tempDir, ".aidd", "manifest.json"); + await mkdir(join(tempDir, ".aidd"), { recursive: true }); + await writeFile(manifestPath, '{"version": 7, "tools": {}}'); + + await expect(adapter.load()).rejects.toThrow( + `delete ${manifestPath} in this project, then run \`aidd setup\` to reinstall the framework.` + ); + }); }); describe("save() + load() roundtrip", () => { diff --git a/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts index 3346af9ec..7aca62c86 100644 --- a/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts @@ -3,6 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { + PluginComponentFile, + PluginDistribution, +} from "../../../../src/contexts/translate/domain/plugin-distribution.js"; // Side-effect imports: the adapter reads each tool's declared manifest locations off the // registry, so an unregistered profile is a format it cannot recognise. import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; @@ -13,16 +17,41 @@ import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { InvalidPluginManifestError, InvalidPluginNameError, + InvalidPluginVersionError, } from "../../../../src/kernel/errors.js"; import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins"); +const IN_MEMORY_ROOT = "/plugins/in-memory"; +const IN_MEMORY_MANIFEST = ".claude-plugin/plugin.json"; function makeAdapter(): PluginDistributionReaderAdapter { return new PluginDistributionReaderAdapter(new FileAdapter(new HasherAdapter())); } +function readInMemory( + manifestText: string, + files: Record = {} +): Promise { + const seed: Record = { + [`${IN_MEMORY_ROOT}/${IN_MEMORY_MANIFEST}`]: manifestText, + }; + for (const [relativePath, content] of Object.entries(files)) { + seed[`${IN_MEMORY_ROOT}/${relativePath}`] = content; + } + return new PluginDistributionReaderAdapter(new InMemoryFileAdapter(seed)).read(IN_MEMORY_ROOT); +} + +function readManifest(manifest: Record): Promise { + return readInMemory(JSON.stringify(manifest)); +} + +function byPath(a: PluginComponentFile, b: PluginComponentFile): number { + return a.relativePath.localeCompare(b.relativePath); +} + describe("PluginDistributionReaderAdapter", () => { describe("claude-format fixture", () => { it("detects claude format", async () => { @@ -165,5 +194,199 @@ describe("PluginDistributionReaderAdapter", () => { InvalidPluginManifestError ); }); + + it("names the directory it searched", async () => { + const reader = new PluginDistributionReaderAdapter(new InMemoryFileAdapter()); + + await expect(reader.read("/plugins/none")).rejects.toThrow( + new InvalidPluginManifestError('no plugin.json found in "/plugins/none"') + ); + }); + }); + + describe("collecting a plugin's files", () => { + const manifestText = JSON.stringify({ name: "full", version: "1.0.0" }); + const componentFiles = { + "skills/hello/SKILL.md": "skill", + "commands/greet.md": "command", + "agents/reviewer.md": "agent", + "rules/style.md": "rule", + "hooks/hooks.json": "{}", + ".mcp.json": "{}", + }; + const otherFiles = { + "README.md": "readme", + LICENSE: "license", + "docs/guide.md": "guide", + }; + + it("keeps every component file with its content, plus the manifest, and nothing else", async () => { + const dist = await readInMemory(manifestText, { ...componentFiles, ...otherFiles }); + + expect([...dist.files].sort(byPath)).toStrictEqual([ + { relativePath: IN_MEMORY_MANIFEST, content: manifestText }, + { relativePath: ".mcp.json", content: "{}" }, + { relativePath: "agents/reviewer.md", content: "agent" }, + { relativePath: "commands/greet.md", content: "command" }, + { relativePath: "hooks/hooks.json", content: "{}" }, + { relativePath: "rules/style.md", content: "rule" }, + { relativePath: "skills/hello/SKILL.md", content: "skill" }, + ]); + }); + + it("sorts each file under its own component kind, the manifest under none", async () => { + const dist = await readInMemory(manifestText, componentFiles); + + expect(dist.components).toStrictEqual({ + skills: [{ relativePath: "skills/hello/SKILL.md", content: "skill" }], + commands: [{ relativePath: "commands/greet.md", content: "command" }], + agents: [{ relativePath: "agents/reviewer.md", content: "agent" }], + rules: [{ relativePath: "rules/style.md", content: "rule" }], + hooks: [{ relativePath: "hooks/hooks.json", content: "{}" }], + mcp: [{ relativePath: ".mcp.json", content: "{}" }], + }); + }); + + it.skipIf(process.platform === "win32")( + "reads a listing given with backslash separators as posix paths", + async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-backslash-listing-")); + try { + await mkdir(join(root, ".claude-plugin"), { recursive: true }); + await writeFile(join(root, ".claude-plugin/plugin.json"), manifestText); + await writeFile(join(root, "skills\\hello.md"), "skill"); + + const dist = await makeAdapter().read(root); + + expect(dist.components.skills).toStrictEqual([ + { relativePath: "skills/hello.md", content: "skill" }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + ); + }); + + describe("the manifest's fields", () => { + it("keeps name and version alone when nothing else is declared", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0" }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + + it("keeps a string description", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0", description: "A plugin" }); + + expect(dist.manifest).toStrictEqual({ + name: "full", + version: "1.0.0", + description: "A plugin", + }); + }); + + it("drops a description that is not a string", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0", description: 42 }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + + it("keeps the author's name and email", async () => { + const dist = await readManifest({ + name: "full", + version: "1.0.0", + author: { name: "Ann", email: "ann@example.com" }, + }); + + expect(dist.manifest).toStrictEqual({ + name: "full", + version: "1.0.0", + author: { name: "Ann", email: "ann@example.com" }, + }); + }); + + it("keeps the author's name alone when the email is not a string", async () => { + const dist = await readManifest({ + name: "full", + version: "1.0.0", + author: { name: "Ann", email: 5 }, + }); + + expect(dist.manifest).toStrictEqual({ + name: "full", + version: "1.0.0", + author: { name: "Ann" }, + }); + }); + + it("drops an author whose name is not a string", async () => { + const dist = await readManifest({ + name: "full", + version: "1.0.0", + author: { name: 5, email: "ann@example.com" }, + }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + + it("drops an author that is null", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0", author: null }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + }); + + describe("a manifest it refuses", () => { + it("names invalid JSON", async () => { + await expect(readInMemory("{ not json")).rejects.toThrow( + new InvalidPluginManifestError("plugin.json is not valid JSON") + ); + }); + + it.each([ + ["null", "null"], + ["a list", "[]"], + ["a string", '"full"'], + ])("names a top level that is %s rather than an object", async (_shape, manifestText) => { + await expect(readInMemory(manifestText)).rejects.toThrow( + new InvalidPluginManifestError("plugin.json must be a JSON object") + ); + }); + + it("names a missing name", async () => { + await expect(readManifest({ version: "1.0.0" })).rejects.toThrow( + new InvalidPluginManifestError('"name" must be a non-empty string') + ); + }); + + it("names an empty name", async () => { + await expect(readManifest({ name: "", version: "1.0.0" })).rejects.toThrow( + new InvalidPluginManifestError('"name" must be a non-empty string') + ); + }); + + it("names a name outside lowercase alphanumerics and hyphens", async () => { + await expect(readManifest({ name: "Bad Name", version: "1.0.0" })).rejects.toThrow( + new InvalidPluginNameError("Bad Name") + ); + }); + + it("names a missing version", async () => { + await expect(readManifest({ name: "full" })).rejects.toThrow( + new InvalidPluginManifestError('"version" must be a non-empty string') + ); + }); + + it("names an empty version", async () => { + await expect(readManifest({ name: "full", version: "" })).rejects.toThrow( + new InvalidPluginManifestError('"version" must be a non-empty string') + ); + }); + + it("names a version that is not semver", async () => { + await expect(readManifest({ name: "full", version: "latest" })).rejects.toThrow( + new InvalidPluginVersionError("latest") + ); + }); }); }); 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 3490b66e8..c498d09c4 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 @@ -47,6 +47,15 @@ describe("UserManifestRepositoryAdapter", () => { await expect(adapter.load()).rejects.not.toThrow(/\.aidd\/manifest\.json/); await expect(adapter.load()).rejects.not.toThrow(/in this project/); }); + + it("names this machine as where the refused manifest lives", async () => { + const manifestPath = join(userConfigDir, "manifest.json"); + 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.` + ); + }); }); describe("save() + load() roundtrip", () => { diff --git a/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts index d52fea93b..2e0b5a387 100644 --- a/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts @@ -1,10 +1,11 @@ +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import { UnreadableUserSourceReferencesError } from "../../../../src/kernel/errors.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; const USER_CONFIG_DIR = "/fake-home/.config/aidd"; -const REFERENCES_PATH = `${USER_CONFIG_DIR}/references.json`; +const REFERENCES_PATH = join(USER_CONFIG_DIR, "references.json"); function adapter(fs: InMemoryFileAdapter = new InMemoryFileAdapter()): UserSourceReferencesAdapter { return new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); @@ -90,6 +91,55 @@ describe("the shared source's own project references", () => { // Nothing here asks which version is "current", only where the project is recorded, so a CLI // self-update between the `sync` that wrote the reference and this read cannot strand it. + it("re-adding a project already recorded under its version leaves the file untouched, even a vanished neighbour", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("1.0.0", "/project-b"); + await fs.deleteFile("/project-b/marker"); + + await refs.addReference("1.0.0", "/project-a"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "1.0.0": ["/project-a", "/project-b"], + }); + }); + + it("a project recorded under two versions ends up recorded once, under the version asked", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-z"); + fs.setFile( + REFERENCES_PATH, + JSON.stringify({ "1.0.0": ["/project-a"], "2.0.0": ["/project-a", "/project-z"] }) + ); + const refs = adapter(fs); + + await refs.addReference("1.0.0", "/project-a"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "1.0.0": ["/project-a"], + "2.0.0": ["/project-z"], + }); + }); + + it("a version whose every project vanished disappears from the file at the next write", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await fs.deleteFile("/project-a/marker"); + markExisting(fs, "/project-b"); + + await refs.addReference("2.0.0", "/project-b"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "2.0.0": ["/project-b"], + }); + }); + it("adding the same project under a new version drops its claim on the old one", async () => { const fs = new InMemoryFileAdapter(); markExisting(fs, "/project-a"); @@ -131,6 +181,21 @@ describe("the shared source's own project references", () => { expect(written).toEqual({}); }); + it("drops a claim recorded under a later version, leaving the earlier version's projects", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("2.0.0", "/project-b"); + + await refs.removeReference("/project-b"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "1.0.0": ["/project-a"], + }); + }); + it("does nothing when this project never held a reference", async () => { const fs = new InMemoryFileAdapter(); markExisting(fs, "/project-a"); @@ -163,6 +228,45 @@ describe("the shared source's own project references", () => { ); }); + describe("a file it cannot read", () => { + it("names the parser's own reason for unparsable JSON", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, "not json"); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + `registry at ${REFERENCES_PATH}: Unexpected token` + ); + }); + + it.each([ + ["null", "null"], + ["a list", "[]"], + ["a string", '"/project-a"'], + ])("names a top level that is %s rather than a version-keyed object", async (_shape, raw) => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, raw); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + new UnreadableUserSourceReferencesError(REFERENCES_PATH, "it is not a version-keyed object") + ); + }); + + it("names the version whose entry mixes a non-path in", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, JSON.stringify({ "1.0.0": ["/project-a", 5] })); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + new UnreadableUserSourceReferencesError( + REFERENCES_PATH, + 'its "1.0.0" entry is not a list of project paths' + ) + ); + }); + }); + describe("listAllReferencingProjects", () => { it("lists existing projects across every version key, deduplicated", async () => { const fs = new InMemoryFileAdapter(); diff --git a/cli/tests/helpers/ports/checkbox-recording-prompter.ts b/cli/tests/helpers/ports/checkbox-recording-prompter.ts new file mode 100644 index 000000000..58b5ca493 --- /dev/null +++ b/cli/tests/helpers/ports/checkbox-recording-prompter.ts @@ -0,0 +1,53 @@ +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; + +export interface CheckboxAsk { + message: string; + offered: string[]; +} + +export class CheckboxRecordingPrompter implements Prompter { + readonly asks: CheckboxAsk[] = []; + + constructor(private readonly selection: readonly string[] = []) {} + + async resolveConflict( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + return "overwrite"; + } + + async resolveConflictBulk( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "overwrite"; + } + + async confirm(_message: string, defaultValue?: boolean): Promise { + return defaultValue ?? true; + } + + async input(_message: string, defaultValue?: string): Promise { + return defaultValue ?? ""; + } + + async select( + _message: string, + choices: Array<{ name: string; value: T; disabled?: boolean | string }> + ): Promise { + const first = choices.find((c) => !c.disabled); + if (first === undefined) throw new Error("No enabled choices available"); + return first.value; + } + + async checkbox( + message: string, + choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> + ): Promise { + this.asks.push({ message, offered: choices.map((c) => String(c.value)) }); + return choices + .filter((c) => !c.disabled && this.selection.includes(String(c.value))) + .map((c) => c.value); + } +} diff --git a/cli/tests/helpers/ports/choosing-prompter.ts b/cli/tests/helpers/ports/choosing-prompter.ts new file mode 100644 index 000000000..ceaa4eb1f --- /dev/null +++ b/cli/tests/helpers/ports/choosing-prompter.ts @@ -0,0 +1,48 @@ +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; + +export class ChoosingPrompter implements Prompter { + readonly selectCalls: Array<{ message: string; choiceNames: string[] }> = []; + + constructor(private readonly chosenName: string) {} + + async resolveConflict( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + return "overwrite"; + } + + async resolveConflictBulk( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "overwrite"; + } + + async confirm(_message: string, defaultValue?: boolean): Promise { + return defaultValue ?? true; + } + + async input(_message: string, defaultValue?: string): Promise { + return defaultValue ?? ""; + } + + async select( + message: string, + choices: Array<{ name: string; value: T; disabled?: boolean | string; description?: string }> + ): Promise { + this.selectCalls.push({ message, choiceNames: choices.map((c) => c.name) }); + const match = choices.find((c) => c.name === this.chosenName); + if (match === undefined) { + throw new Error(`ChoosingPrompter: no choice named "${this.chosenName}" for "${message}"`); + } + return match.value; + } + + async checkbox( + _message: string, + _choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> + ): Promise { + return []; + } +} diff --git a/cli/tests/helpers/ports/fake-native-plugin-activator.ts b/cli/tests/helpers/ports/fake-native-plugin-activator.ts index 07e6ac70f..93340770a 100644 --- a/cli/tests/helpers/ports/fake-native-plugin-activator.ts +++ b/cli/tests/helpers/ports/fake-native-plugin-activator.ts @@ -22,6 +22,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { private readonly state: "live" | "dead" | "unknown"; private readonly failOnUninstall: ReadonlySet; private readonly crashOnAddMarketplace: boolean; + private readonly crashOnUninstall: boolean; private readonly installedAtScope: ReadonlyMap; constructor( @@ -36,6 +37,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { registrationState?: "live" | "dead" | "unknown"; failOnUninstall?: readonly string[]; crashOnAddMarketplace?: boolean; + crashOnUninstall?: boolean; installedAtScope?: ReadonlyMap; } = {} ) { @@ -47,6 +49,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { this.state = options.registrationState ?? "unknown"; this.failOnUninstall = new Set(options.failOnUninstall ?? []); this.crashOnAddMarketplace = options.crashOnAddMarketplace ?? false; + this.crashOnUninstall = options.crashOnUninstall ?? false; this.installedAtScope = options.installedAtScope ?? new Map(); } @@ -97,6 +100,9 @@ export class FakeNativePluginActivator implements NativePluginActivator { } uninstallPlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { + if (this.crashOnUninstall) { + throw new Error("activator crashed uninstalling a plugin"); + } this.uninstalledPluginScopes.push(scope); if (this.failOnUninstall.has(pluginRef)) { throw new NativePluginCliError(`plugin \`${pluginRef}\` is not installed`); diff --git a/cli/tests/helpers/ports/faulting-file-adapter.ts b/cli/tests/helpers/ports/faulting-file-adapter.ts new file mode 100644 index 000000000..6dafc14e7 --- /dev/null +++ b/cli/tests/helpers/ports/faulting-file-adapter.ts @@ -0,0 +1,44 @@ +import { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; + +type FaultableMethod = "readFile" | "realpath" | "listDirectory" | "deleteDirectory"; + +export class FaultingFileAdapter extends InMemoryFileAdapter { + private readonly faults = new Map(); + + failOn(method: FaultableMethod, path: string, error: Error): void { + this.faults.set(faultKey(method, path), error); + } + + override async readFile(path: string): Promise { + this.throwIfFaulted("readFile", path); + return super.readFile(path); + } + + override async realpath(path: string): Promise { + this.throwIfFaulted("realpath", path); + return super.realpath(path); + } + + override async listDirectory(dirPath: string): Promise { + this.throwIfFaulted("listDirectory", dirPath); + return super.listDirectory(dirPath); + } + + override async deleteDirectory(dirPath: string): Promise { + this.throwIfFaulted("deleteDirectory", dirPath); + return super.deleteDirectory(dirPath); + } + + private throwIfFaulted(method: FaultableMethod, path: string): void { + const error = this.faults.get(faultKey(method, path)); + if (error !== undefined) throw error; + } +} + +export function errnoError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(`${code}: planted by the test`), { code }); +} + +function faultKey(method: FaultableMethod, path: string): string { + return `${method}:${path.replaceAll("\\", "/")}`; +} diff --git a/cli/tests/helpers/ports/in-memory-manifest-repository.ts b/cli/tests/helpers/ports/in-memory-manifest-repository.ts index a88009176..4b1df8bfe 100644 --- a/cli/tests/helpers/ports/in-memory-manifest-repository.ts +++ b/cli/tests/helpers/ports/in-memory-manifest-repository.ts @@ -5,6 +5,7 @@ export class InMemoryManifestRepository implements ManifestRepository { /** Derived from the root the test drives the use case with, never a fixed literal: a * double naming a fictional file lets a diagnostic go wrong with every test still green. */ readonly path: string; + saveCount = 0; private manifest: Manifest | null; constructor(seed: Manifest | null = null, projectRoot = "/test-project") { @@ -17,6 +18,7 @@ export class InMemoryManifestRepository implements ManifestRepository { } async save(manifest: Manifest): Promise { + this.saveCount += 1; this.manifest = manifest; } diff --git a/cli/tests/helpers/ports/index.ts b/cli/tests/helpers/ports/index.ts index ef48c38e8..6e8f3b351 100644 --- a/cli/tests/helpers/ports/index.ts +++ b/cli/tests/helpers/ports/index.ts @@ -1,4 +1,5 @@ export { CapturingLogger } from "./capturing-logger.js"; +export { CheckboxRecordingPrompter } from "./checkbox-recording-prompter.js"; export { DeterministicHasher } from "./deterministic-hasher.js"; export { FakeAuthReader } from "./fake-auth-reader.js"; export { FakeCurrentVersion } from "./fake-current-version.js"; @@ -11,5 +12,6 @@ export { InMemoryManifestRepository } from "./in-memory-manifest-repository.js"; export { InMemoryMarketplaceCache } from "./in-memory-marketplace-cache.js"; export { InMemoryMarketplaceRegistry } from "./in-memory-marketplace-registry.js"; export { InMemoryMarketplaceTrustStore } from "./in-memory-marketplace-trust-store.js"; +export { RecordingPrompter } from "./recording-prompter.js"; export { KeepPrompter, OverwritePrompter, ScriptedPrompter } from "./scripted-prompter.js"; export { seedFromDirectory } from "./seed-from-directory.js"; diff --git a/cli/tests/helpers/ports/recording-prompter.ts b/cli/tests/helpers/ports/recording-prompter.ts new file mode 100644 index 000000000..983b00507 --- /dev/null +++ b/cli/tests/helpers/ports/recording-prompter.ts @@ -0,0 +1,31 @@ +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; + +export class RecordingPrompter implements Prompter { + readonly confirmMessages: string[] = []; + + constructor(private readonly answer: boolean) {} + + get lastConfirmMessage(): string | undefined { + return this.confirmMessages.at(-1); + } + + async confirm(message: string): Promise { + this.confirmMessages.push(message); + return this.answer; + } + async resolveConflict(): Promise<"keep" | "overwrite"> { + return "keep"; + } + async resolveConflictBulk(): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "keep"; + } + async input(): Promise { + return ""; + } + async select(): Promise { + throw new Error("not implemented"); + } + async checkbox(): Promise { + return []; + } +} diff --git a/cli/tests/helpers/ports/scripted-prompter.ts b/cli/tests/helpers/ports/scripted-prompter.ts index 3d477e439..6483f2347 100644 --- a/cli/tests/helpers/ports/scripted-prompter.ts +++ b/cli/tests/helpers/ports/scripted-prompter.ts @@ -10,6 +10,8 @@ type PromptAnswer = /** Returns pre-defined answers in order, throwing once the queue is exhausted. */ export class ScriptedPrompter implements Prompter { + readonly askedSelects: Array<{ message: string; names: string[] }> = []; + readonly askedInputs: Array<{ message: string; defaultValue: string | undefined }> = []; private readonly queue: PromptAnswer[]; private index = 0; @@ -42,6 +44,7 @@ export class ScriptedPrompter implements Prompter { } async input(message: string, defaultValue?: string): Promise { + this.askedInputs.push({ message, defaultValue }); if (this.index >= this.queue.length) { return defaultValue ?? ""; } @@ -53,6 +56,7 @@ export class ScriptedPrompter implements Prompter { message: string, choices: Array<{ name: string; value: T; disabled?: boolean | string; description?: string }> ): Promise { + this.askedSelects.push({ message, names: choices.map((c) => c.name) }); const answer = this.next("select", message); const stringValue = answer.value as string; const match = choices.find((c) => !c.disabled && String(c.value) === stringValue); diff --git a/cli/tests/helpers/ports/stub-ai-tool.ts b/cli/tests/helpers/ports/stub-ai-tool.ts new file mode 100644 index 000000000..91f8ccfb4 --- /dev/null +++ b/cli/tests/helpers/ports/stub-ai-tool.ts @@ -0,0 +1,17 @@ +import type { AiTool } from "../../../src/contexts/tools/domain/contracts.js"; +import type { AiToolId } from "../../../src/kernel/tool.js"; + +export function stubAiTool(toolId: AiToolId, capabilities: unknown): AiTool { + return { + kind: "ai", + toolId, + directory: `.${toolId}/`, + toolSuffix: `.${toolId}.md`, + signalDir: null, + displayName: toolId, + telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, + telemetryTaskAttributable: false, + capabilities, + rewriteContent: (content: string) => content, + }; +} diff --git a/cli/tests/helpers/ports/stub-asset-provider.ts b/cli/tests/helpers/ports/stub-asset-provider.ts new file mode 100644 index 000000000..f24b74945 --- /dev/null +++ b/cli/tests/helpers/ports/stub-asset-provider.ts @@ -0,0 +1,25 @@ +import type { + AssetProvider, + ConfigAsset, + SchemaName, +} from "../../../src/kernel/ports/asset-provider.js"; +import type { ToolId } from "../../../src/kernel/tool.js"; + +export class StubAssetProvider implements AssetProvider { + constructor( + private readonly assets: Readonly>, + private readonly fallback?: AssetProvider + ) {} + + loadConfigAsset(toolId: ToolId, fileName: string): ConfigAsset { + const asset = this.assets[`${toolId}/${fileName}`]; + if (asset !== undefined) return asset; + if (this.fallback === undefined) throw new Error(`no stub asset for ${toolId}/${fileName}`); + return this.fallback.loadConfigAsset(toolId, fileName); + } + + loadSchema(name: SchemaName): object { + if (this.fallback === undefined) throw new Error(`no stub schema for ${name}`); + return this.fallback.loadSchema(name); + } +}