diff --git a/src/__tests__/commands/update-cli-auto-init.test.ts b/src/__tests__/commands/update-cli-auto-init.test.ts index 4b010fa4..bdab9d76 100644 --- a/src/__tests__/commands/update-cli-auto-init.test.ts +++ b/src/__tests__/commands/update-cli-auto-init.test.ts @@ -1,11 +1,15 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { PromptKitUpdateDeps } from "@/commands/update-cli.js"; import { promptKitUpdate } from "@/commands/update-cli.js"; import { countMissingCkHookRegistrations } from "@/commands/update/post-update-handler.js"; -import type { InstallModeReport } from "@/domains/installation/plugin/install-mode-detector.js"; +import { + type InstallModeReport, + detectInstallMode, + hasTrackedPluginSuppliedLegacyFiles, +} from "@/domains/installation/plugin/install-mode-detector.js"; const confirmMock = mock(async (_options: { message: string }) => true); const isCancelMock = mock((value: unknown) => value === "cancelled"); @@ -42,6 +46,21 @@ async function writeMetadata( ); } +async function writeOfficialPluginCacheFile( + dir: string, + version: string, + relativePath: string, + content: string, +) { + const versionRoot = join(dir, "plugins", "cache", "claudekit", "ck", version); + const manifestPath = join(versionRoot, ".claude-plugin", "plugin.json"); + const cacheFile = join(versionRoot, relativePath); + await mkdir(dirname(manifestPath), { recursive: true }); + await mkdir(dirname(cacheFile), { recursive: true }); + await writeFile(manifestPath, JSON.stringify({ name: "ck", version })); + await writeFile(cacheFile, content); +} + function makeInstallModeReport( claudeDir: string, mode: InstallModeReport["mode"], @@ -576,6 +595,95 @@ describe("promptKitUpdate auto-init behavior", () => { expect(capturedSpawnArgs()).toContain("--restore-ck-hooks"); }); + test("routes metadata-free historical cache matches through plugin repair", async () => { + await writeMetadata(tempDir, "1.0.0", "plugin"); + await writeGlobalHookState(tempDir, { includeSessionState: true }); + await writeFile( + join(tempDir, "settings.json"), + JSON.stringify({ + enabledPlugins: { "ck@claudekit": true }, + hooks: { + UserPromptSubmit: [ + { + hooks: ["simplify-gate", "session-state"].map((name) => ({ + type: "command", + command: `node "$HOME/.claude/hooks/${name}.cjs"`, + })), + }, + ], + }, + }), + ); + await mkdir(join(tempDir, "skills", "retired"), { recursive: true }); + await writeOfficialPluginCacheFile(tempDir, "0.9.0", "skills/retired/SKILL.md", "retired\n"); + await writeFile(join(tempDir, "skills", "retired", "SKILL.md"), "retired\n"); + + const { deps, execCount, spawnCount, capturedSpawnArgs } = makeDeps(); + deps.getLatestReleaseTagFn = async () => "v1.0.0"; + deps.detectInstallModeFn = detectInstallMode; + deps.hasTrackedPluginSuppliedLegacyFilesFn = hasTrackedPluginSuppliedLegacyFiles; + + await promptKitUpdate(false, true, deps); + + expect(spawnCount()).toBe(1); + expect(execCount()).toBe(0); + expect(capturedSpawnArgs()).toContain("--install-mode"); + expect(capturedSpawnArgs()).toContain("plugin"); + expect(capturedSpawnArgs()).toContain("--restore-ck-hooks"); + }); + + test.each([ + ["absent", null, "legacy"], + ["malformed", null, "legacy"], + ["empty", {}, "legacy"], + [ + "partial plugin-consent", + { kits: { engineer: { installModePreference: "plugin" } } }, + "plugin", + ], + ] as const)( + "recovers cache-proven global Engineer update with %s metadata", + async (metadataState, setupMetadata, expectedMode) => { + const metadataPath = join(tempDir, "metadata.json"); + if (metadataState === "absent") { + await rm(metadataPath, { force: true }); + } else if (metadataState === "malformed") { + await writeFile(metadataPath, "{"); + } else { + await writeFile(metadataPath, JSON.stringify(setupMetadata)); + } + await mkdir(join(tempDir, "skills", "retired"), { recursive: true }); + await writeOfficialPluginCacheFile(tempDir, "0.9.0", "skills/retired/SKILL.md", "retired\n"); + await writeFile(join(tempDir, "skills", "retired", "SKILL.md"), "retired\n"); + + const { deps, execCount, spawnCount, capturedSpawnArgs } = makeDeps(); + deps.getSetupFn = async () => ({ + global: { + path: tempDir, + metadata: setupMetadata as never, + components: { commands: 0, hooks: 0, skills: 1, workflows: 0, settings: 0 }, + }, + project: { + path: "", + metadata: null, + components: { commands: 0, hooks: 0, skills: 0, workflows: 0, settings: 0 }, + }, + }); + deps.detectInstallModeFn = detectInstallMode; + deps.hasTrackedPluginSuppliedLegacyFilesFn = hasTrackedPluginSuppliedLegacyFiles; + + await promptKitUpdate(false, true, deps); + + expect(spawnCount()).toBe(1); + expect(execCount()).toBe(0); + expect(capturedSpawnArgs()).toContain("--kit"); + expect(capturedSpawnArgs()).toContain("engineer"); + expect(capturedSpawnArgs()).toContain("--install-mode"); + expect(capturedSpawnArgs()).toContain(expectedMode); + expect(capturedSpawnArgs()).toContain("--restore-ck-hooks"); + }, + ); + test("normal preference cleans an active mixed install after tracked legacy files are gone", async () => { const { deps, execCount, spawnCount, capturedSpawnArgs } = makeDeps(); deps.getLatestReleaseTagFn = async () => "v1.0.0"; diff --git a/src/__tests__/commands/update-cli-prompt-kit.test.ts b/src/__tests__/commands/update-cli-prompt-kit.test.ts index b9705bc0..ac905f04 100644 --- a/src/__tests__/commands/update-cli-prompt-kit.test.ts +++ b/src/__tests__/commands/update-cli-prompt-kit.test.ts @@ -89,6 +89,19 @@ describe("promptKitUpdate version display", () => { hasTrackedPluginSuppliedLegacyFilesFn: () => opts?.hasTrackedPluginSuppliedLegacyFiles ?? false, shouldRefreshCodexPluginFn: async () => false, + detectCodexPluginStateFn: async () => ({ + status: "missing", + pluginId: "ck@claudekit", + enabled: false, + installed: false, + installedVersion: null, + expectedVersion: null, + marketplace: null, + expectedMarketplace: "claudekit", + source: null, + expectedSource: null, + shouldRefresh: true, + }), }; return { deps, stopCalls, spawnArgs, wasSpawnCalled: () => spawnCalled }; } diff --git a/src/commands/update/post-update-handler.ts b/src/commands/update/post-update-handler.ts index 84b088d4..36445b8b 100644 --- a/src/commands/update/post-update-handler.ts +++ b/src/commands/update/post-update-handler.ts @@ -33,7 +33,11 @@ import { detectInstallMode, hasTrackedPluginSuppliedLegacyFiles, } from "@/domains/installation/plugin/install-mode-detector.js"; -import { resolveInstallModePreferenceForUpdate } from "@/domains/installation/plugin/install-mode-preference.js"; +import { + readInstallModePreferenceFromClaudeDir, + resolveEffectiveInstallModePreference, + resolveInstallModePreferenceForUpdate, +} from "@/domains/installation/plugin/install-mode-preference.js"; import { getInstalledKits } from "@/domains/migration/metadata-migration.js"; import { versionsMatch } from "@/domains/versioning/checking/version-utils.js"; import { getClaudeKitSetup } from "@/services/file-operations/claudekit-scanner.js"; @@ -526,9 +530,30 @@ export async function promptKitUpdate( const globalMetadata = hasGlobal ? await readMetadataFile(setup.global.path) : null; const localKits = localMetadata ? getInstalledKits(localMetadata) : []; - const globalKits = globalMetadata ? getInstalledKits(globalMetadata) : []; + let globalKits = globalMetadata ? getInstalledKits(globalMetadata) : []; + let recoveredGlobalInstallMode: InstallModeReport | null = null; + if (setup.global.path && globalKits.length === 0) { + try { + const candidate = detectInstallModeFn(setup.global.path); + if (candidate.legacy.installed) { + recoveredGlobalInstallMode = candidate; + globalKits = ["engineer"]; + } + } catch (error) { + logger.verbose( + `Global Engineer recovery check skipped: ${ + error instanceof Error ? error.message : "unknown" + }`, + ); + } + } - let selection = selectKitForUpdate({ hasLocal, hasGlobal, localKits, globalKits }); + let selection = selectKitForUpdate({ + hasLocal, + hasGlobal: hasGlobal || recoveredGlobalInstallMode !== null, + localKits, + globalKits, + }); if (!selection) { logger.verbose("No ClaudeKit installations detected, skipping kit update prompt"); @@ -589,6 +614,11 @@ export async function promptKitUpdate( let installModePreference: InstallModePreference | undefined = selection.isGlobal ? resolveInstallModePreferenceForUpdate(globalMetadata, selection.kit) : undefined; + if (selection.isGlobal && selection.kit === "engineer" && !globalMetadata) { + installModePreference = resolveEffectiveInstallModePreference( + readInstallModePreferenceFromClaudeDir(setup.global.path, "engineer"), + ); + } const selectedClaudeDir = selection.isGlobal ? setup.global.path : setup.project.path; if (selectedClaudeDir) { try { @@ -633,7 +663,7 @@ export async function promptKitUpdate( try { if (selection.isGlobal && selection.kit === "engineer") { - const installMode = detectInstallModeFn(selectedClaudeDir); + const installMode = recoveredGlobalInstallMode ?? detectInstallModeFn(selectedClaudeDir); if (installModePreference === "legacy") { const codexPluginState = await detectCodexPluginStateFn(); if ( diff --git a/src/domains/health-checks/__tests__/plugin-install-mode-checker.test.ts b/src/domains/health-checks/__tests__/plugin-install-mode-checker.test.ts index 70a7ebc9..c38b1426 100644 --- a/src/domains/health-checks/__tests__/plugin-install-mode-checker.test.ts +++ b/src/domains/health-checks/__tests__/plugin-install-mode-checker.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { PluginInstallModeChecker } from "@/domains/health-checks/plugin-install-mode-checker.js"; import type { CodexPluginState } from "@/domains/installation/plugin/codex-plugin-installer.js"; @@ -35,6 +35,15 @@ describe("PluginInstallModeChecker", () => { writeFile(join(claudeDir, "settings.json"), JSON.stringify({ enabledPlugins }), "utf-8"); const writeMetadata = (obj: unknown) => writeFile(join(claudeDir, "metadata.json"), JSON.stringify(obj), "utf-8"); + const writePluginCacheFile = async (version: string, relativePath: string, content: string) => { + const versionRoot = join(claudeDir, "plugins", "cache", "claudekit", "ck", version); + const manifestPath = join(versionRoot, ".claude-plugin", "plugin.json"); + const cacheFile = join(versionRoot, relativePath); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, JSON.stringify({ name: "ck", version }), "utf-8"); + await mkdir(dirname(cacheFile), { recursive: true }); + await writeFile(cacheFile, content, "utf-8"); + }; async function single(codexState: CodexPluginState = codexUnavailable) { const results = await new PluginInstallModeChecker(claudeDir, { @@ -101,6 +110,50 @@ describe("PluginInstallModeChecker", () => { expect(r.message).toContain("enabled"); }); + test("warns when plugin mode has metadata-free legacy files proven by historical cache", async () => { + await writeMetadata({ + kits: { + engineer: { + version: "2.20.1-beta.7", + installedAt: "x", + installModePreference: "plugin", + }, + }, + }); + await writeSettings({ "ck@claudekit": true }); + await writePluginCacheFile("2.20.1-beta.5", "skills/gemini-research/SKILL.md", "retired\n"); + await mkdir(join(claudeDir, "skills", "gemini-research"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "gemini-research", "SKILL.md"), "retired\n", "utf-8"); + + const r = await single(); + + expect(r.status).toBe("warn"); + expect(r.message).toContain("Install mode: mixed"); + expect(r.message).toContain("--install-mode plugin"); + }); + + test.each([ + ["absent", null], + ["malformed", "{"], + ["empty", {}], + ] as const)("warns on cache-proven mixed state with %s metadata", async (_label, metadata) => { + if (metadata === "{") { + await writeFile(join(claudeDir, "metadata.json"), metadata, "utf-8"); + } else if (metadata !== null) { + await writeMetadata(metadata); + } + await writeSettings({ "ck@claudekit": true }); + await writePluginCacheFile("2.20.1-beta.5", "skills/retired/SKILL.md", "retired\n"); + await mkdir(join(claudeDir, "skills", "retired"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "retired", "SKILL.md"), "retired\n", "utf-8"); + + const r = await single(); + + expect(r.status).toBe("warn"); + expect(r.message).toContain("Install mode: mixed"); + expect(r.message).toContain("--install-mode legacy"); + }); + test("persisted plugin consent with disabled plugin -> warn with enable hint", async () => { await writeMetadata({ kits: { diff --git a/src/domains/installation/plugin/__tests__/install-mode-detector.test.ts b/src/domains/installation/plugin/__tests__/install-mode-detector.test.ts index ccde7a03..9a351460 100644 --- a/src/domains/installation/plugin/__tests__/install-mode-detector.test.ts +++ b/src/domains/installation/plugin/__tests__/install-mode-detector.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, rm, utimes, writeFile } from "node:fs/promises"; +import { mkdir, rm, symlink, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { classifyInstallMode, detectInstallMode, @@ -43,6 +43,50 @@ describe("install-mode-detector", () => { }); } + async function writePluginCacheFile( + version: string, + relativePath: string, + content: string, + ): Promise { + const versionRoot = join(claudeDir, "plugins", "cache", "claudekit", "ck", version); + await writePluginCacheManifest(version); + const cacheFile = join(versionRoot, relativePath); + await mkdir(dirname(cacheFile), { recursive: true }); + await writeFile(cacheFile, content, "utf-8"); + } + + async function writePluginCacheManifest( + version: string, + manifest: unknown = { name: "ck", version }, + ): Promise { + const manifestPath = join( + claudeDir, + "plugins", + "cache", + "claudekit", + "ck", + version, + ".claude-plugin", + "plugin.json", + ); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile( + manifestPath, + typeof manifest === "string" ? manifest : JSON.stringify(manifest), + "utf-8", + ); + } + + async function writePluginCachePayloadWithoutManifest( + version: string, + relativePath: string, + content: string, + ): Promise { + const cacheFile = join(claudeDir, "plugins", "cache", "claudekit", "ck", version, relativePath); + await mkdir(dirname(cacheFile), { recursive: true }); + await writeFile(cacheFile, content, "utf-8"); + } + test("fresh: no settings, no metadata, no cache", () => { const report = detectInstallMode(claudeDir); expect(report.mode).toBe("fresh"); @@ -263,6 +307,175 @@ describe("install-mode-detector", () => { expect(report.legacy.installed).toBe(true); }); + test("mixed: metadata-free legacy files match a historical CK plugin cache payload", async () => { + await writeSettings({ "ck@claudekit": true }); + await writeMetadata({ + kits: { + engineer: { + version: "2.20.1-beta.7", + installedAt: "x", + installModePreference: "plugin", + }, + }, + }); + await writePluginCacheFile("2.20.1-beta.5", "skills/gemini-research/SKILL.md", "retired\n"); + await writePluginCacheFile("2.20.1-beta.7", "skills/cook/SKILL.md", "current\n"); + await mkdir(join(claudeDir, "skills", "gemini-research"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "gemini-research", "SKILL.md"), "retired\n", "utf-8"); + + const report = detectInstallMode(claudeDir); + + expect(report.mode).toBe("mixed"); + expect(report.legacy).toEqual({ installed: true, version: "2.20.1-beta.7" }); + expect(hasTrackedPluginSuppliedLegacyFiles(claudeDir)).toBe(true); + }); + + test.each([ + ["absent", null], + ["malformed", "{"], + ["empty", {}], + ["unrecognized", { unrelated: true }], + ] as const)( + "mixed: %s metadata retains official cache ownership proof", + async (_label, metadata) => { + await writeSettings({ "ck@claudekit": true }); + if (metadata === "{") { + await writeFile(join(claudeDir, "metadata.json"), metadata, "utf-8"); + } else if (metadata !== null) { + await writeMetadata(metadata); + } + await writePluginCacheFile("2.20.1-beta.5", "skills/retired/SKILL.md", "retired\n"); + await mkdir(join(claudeDir, "skills", "retired"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "retired", "SKILL.md"), "retired\n", "utf-8"); + + expect(detectInstallMode(claudeDir).mode).toBe("mixed"); + expect(detectLegacyState(claudeDir)).toEqual({ installed: true, version: null }); + }, + ); + + test.each([ + ["missing", null], + ["forged name", { name: "other", version: "2.20.1-beta.5" }], + ["version mismatch", { name: "ck", version: "2.20.1-beta.4" }], + ["malformed", "{"], + ] as const)( + "plugin: %s cache manifest cannot prove orphan ownership", + async (_label, manifest) => { + const version = "2.20.1-beta.5"; + await writeSettings({ "ck@claudekit": true }); + await writeMetadata({ + kits: { + engineer: { + version: "2.20.1-beta.7", + installedAt: "x", + installModePreference: "plugin", + }, + }, + }); + await writePluginCachePayloadWithoutManifest(version, "skills/retired/SKILL.md", "retired\n"); + if (manifest !== null) await writePluginCacheManifest(version, manifest); + await mkdir(join(claudeDir, "skills", "retired"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "retired", "SKILL.md"), "retired\n", "utf-8"); + + expect(detectInstallMode(claudeDir).mode).toBe("plugin"); + expect(hasTrackedPluginSuppliedLegacyFiles(claudeDir)).toBe(false); + }, + ); + + test("mixed: cache manifest and directory versions may differ only by leading v", async () => { + await writeSettings({ "ck@claudekit": true }); + await writePluginCachePayloadWithoutManifest( + "v2.20.1-beta.5", + "skills/retired/SKILL.md", + "retired\n", + ); + await writePluginCacheManifest("v2.20.1-beta.5", { + name: "ck", + version: "2.20.1-beta.5", + }); + await mkdir(join(claudeDir, "skills", "retired"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "retired", "SKILL.md"), "retired\n", "utf-8"); + + expect(detectInstallMode(claudeDir).mode).toBe("mixed"); + }); + + test.skipIf(process.platform === "win32")( + "plugin: symlinked CK manifest cannot prove orphan ownership", + async () => { + const version = "2.20.1-beta.5"; + const outsideManifest = join(claudeDir, "outside-plugin.json"); + const manifestPath = join( + claudeDir, + "plugins", + "cache", + "claudekit", + "ck", + version, + ".claude-plugin", + "plugin.json", + ); + await writeSettings({ "ck@claudekit": true }); + await writePluginCachePayloadWithoutManifest(version, "skills/retired/SKILL.md", "retired\n"); + await mkdir(dirname(manifestPath), { recursive: true }); + await writeFile(outsideManifest, JSON.stringify({ name: "ck", version }), "utf-8"); + await symlink(outsideManifest, manifestPath, "file"); + await mkdir(join(claudeDir, "skills", "retired"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "retired", "SKILL.md"), "retired\n", "utf-8"); + + expect(detectInstallMode(claudeDir).mode).toBe("plugin"); + expect(hasTrackedPluginSuppliedLegacyFiles(claudeDir)).toBe(false); + }, + ); + + test("plugin: modified, custom, and symlinked legacy files are not cache-owned", async () => { + const outsideDir = join( + tmpdir(), + `ck-mode-outside-${Date.now()}-${Math.round(performance.now())}`, + ); + await writeSettings({ "ck@claudekit": true }); + await writeMetadata({ + kits: { + engineer: { + version: "2.20.1-beta.7", + installedAt: "x", + installModePreference: "plugin", + }, + }, + }); + await writePluginCacheFile("2.20.1-beta.5", "skills/cook/SKILL.md", "original\n"); + await writePluginCacheFile("2.20.1-beta.5", "skills/linked/SKILL.md", "outside\n"); + await mkdir(join(claudeDir, "skills", "cook"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "cook", "SKILL.md"), "edited\n", "utf-8"); + await mkdir(join(claudeDir, "skills", "custom"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "custom", "SKILL.md"), "custom\n", "utf-8"); + await mkdir(outsideDir, { recursive: true }); + await writeFile(join(outsideDir, "SKILL.md"), "outside\n", "utf-8"); + await symlink(outsideDir, join(claudeDir, "skills", "linked"), "dir"); + await mkdir(join(claudeDir, "skills", "cache-linked"), { recursive: true }); + await writeFile(join(claudeDir, "skills", "cache-linked", "SKILL.md"), "outside\n", "utf-8"); + await symlink( + outsideDir, + join( + claudeDir, + "plugins", + "cache", + "claudekit", + "ck", + "2.20.1-beta.5", + "skills", + "cache-linked", + ), + "dir", + ); + + try { + expect(detectInstallMode(claudeDir).mode).toBe("plugin"); + expect(hasTrackedPluginSuppliedLegacyFiles(claudeDir)).toBe(false); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); + test("plugin migration receipt metadata without legacy payload does not stay mixed forever", async () => { await writeMetadata({ kits: { diff --git a/src/domains/installation/plugin/__tests__/migrate-legacy-to-plugin.test.ts b/src/domains/installation/plugin/__tests__/migrate-legacy-to-plugin.test.ts index f823db57..b8938df7 100644 --- a/src/domains/installation/plugin/__tests__/migrate-legacy-to-plugin.test.ts +++ b/src/domains/installation/plugin/__tests__/migrate-legacy-to-plugin.test.ts @@ -644,6 +644,113 @@ describe("migrateLegacyToPlugin (orchestration)", () => { expect(detectInstallMode(claudeDir).legacy.installed).toBe(false); }); + test("metadata-free historical cache matches are backed up and removed while edits survive", async () => { + const pluginSourceDir = join(claudeDir, "staged-source"); + const cacheRoot = join(claudeDir, "plugins", "cache", "claudekit", "ck", "2.20.1-beta.5"); + const matchedPath = "skills/gemini-research/SKILL.md"; + const editedPath = "skills/cook/SKILL.md"; + await mkdir(join(cacheRoot, "skills", "gemini-research"), { recursive: true }); + await mkdir(join(cacheRoot, "skills", "cook"), { recursive: true }); + await mkdir(join(cacheRoot, ".claude-plugin"), { recursive: true }); + await mkdir(join(claudeDir, "skills", "gemini-research"), { recursive: true }); + await mkdir(join(claudeDir, "skills", "cook"), { recursive: true }); + await mkdir(join(pluginSourceDir, ".claude", ".claude-plugin"), { recursive: true }); + await writeFile(join(cacheRoot, matchedPath), "retired\n", "utf-8"); + await writeFile(join(cacheRoot, editedPath), "original\n", "utf-8"); + await writeFile( + join(cacheRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "ck", version: "2.20.1-beta.5" }), + "utf-8", + ); + await writeFile(join(claudeDir, matchedPath), "retired\n", "utf-8"); + await writeFile(join(claudeDir, editedPath), "edited\n", "utf-8"); + await writeFile( + join(pluginSourceDir, ".claude", ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "ck", version: "2.20.1-beta.7" }), + "utf-8", + ); + await writeMetadata({ + kits: { + engineer: { + version: "2.20.1-beta.7", + installedAt: "x", + installModePreference: "plugin", + }, + }, + }); + await writeSettings({ "ck@claudekit": true }); + await writeMarketplace(pluginSourceDir); + const { installer } = fakeInstaller(); + + const result = await migrateLegacyToPlugin({ + pluginSourceDir, + claudeDir, + installer, + now: TS, + }); + + expect(result.action).toBe("migrated-from-legacy"); + expect(result.modeBefore).toBe("mixed"); + expect(result.removedPaths).toEqual([matchedPath]); + expect(existsSync(join(claudeDir, matchedPath))).toBe(false); + expect(readFileSync(join(result.backupDir as string, matchedPath), "utf-8")).toBe("retired\n"); + expect(readFileSync(join(claudeDir, editedPath), "utf-8")).toBe("edited\n"); + expect(detectInstallMode(claudeDir).mode).toBe("plugin"); + }); + + test.each(["absent", "malformed", "empty"] as const)( + "cache-proven ghosts migrate safely with %s metadata", + async (metadataState) => { + const version = "2.20.1-beta.5"; + const pluginSourceDir = join(claudeDir, "staged-source"); + const cacheRoot = join(claudeDir, "plugins", "cache", "claudekit", "ck", version); + const relativePath = "skills/retired/SKILL.md"; + const metadataPath = join(claudeDir, "metadata.json"); + await mkdir(join(cacheRoot, "skills", "retired"), { recursive: true }); + await mkdir(join(cacheRoot, ".claude-plugin"), { recursive: true }); + await mkdir(join(claudeDir, "skills", "retired"), { recursive: true }); + await mkdir(join(pluginSourceDir, ".claude", ".claude-plugin"), { recursive: true }); + await writeFile(join(cacheRoot, relativePath), "retired\n", "utf-8"); + await writeFile(join(claudeDir, relativePath), "retired\n", "utf-8"); + await writeFile( + join(cacheRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "ck", version }), + "utf-8", + ); + await writeFile( + join(pluginSourceDir, ".claude", ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "ck", version: "2.20.1-beta.7" }), + "utf-8", + ); + if (metadataState === "malformed") { + await writeFile(metadataPath, "{", "utf-8"); + } else if (metadataState === "empty") { + await writeFile(metadataPath, "{}", "utf-8"); + } + const metadataBefore = existsSync(metadataPath) ? readFileSync(metadataPath) : null; + await writeSettings({ "ck@claudekit": true }); + await writeMarketplace(pluginSourceDir); + const { installer } = fakeInstaller(); + + const result = await migrateLegacyToPlugin({ + pluginSourceDir, + claudeDir, + installer, + now: TS, + }); + + expect(result.action).toBe("migrated-from-legacy"); + expect(result.modeBefore).toBe("mixed"); + expect(result.removedPaths).toEqual([relativePath]); + expect(existsSync(join(claudeDir, relativePath))).toBe(false); + expect(readFileSync(join(result.backupDir as string, relativePath), "utf-8")).toBe( + "retired\n", + ); + expect(existsSync(metadataPath) ? readFileSync(metadataPath) : null).toEqual(metadataBefore); + expect(detectInstallMode(claudeDir).mode).toBe("plugin"); + }, + ); + test("mismatched deprecated installedFiles content stays mixed and actionable", async () => { const legacyFile = join(claudeDir, "skills", "cook", "SKILL.md"); const pluginSourceDir = join(claudeDir, "staged-source"); diff --git a/src/domains/installation/plugin/__tests__/orphaned-plugin-legacy-files.test.ts b/src/domains/installation/plugin/__tests__/orphaned-plugin-legacy-files.test.ts new file mode 100644 index 00000000..c3643162 --- /dev/null +++ b/src/domains/installation/plugin/__tests__/orphaned-plugin-legacy-files.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { defaultLegacyRemover } from "@/domains/installation/plugin/migrate-legacy-to-plugin.js"; +import { + collectOrphanedPluginLegacyFileProofs, + orphanedPluginLegacyFileProofMatches, +} from "@/domains/installation/plugin/orphaned-plugin-legacy-files.js"; + +describe("orphaned plugin legacy file proofs", () => { + const version = "2.20.1-beta.5"; + const relativePath = "skills/retired/SKILL.md"; + let root: string; + let claudeDir: string; + let cachePath: string; + let manifestPath: string; + let targetPath: string; + let backupDir: string; + let metadataPath: string; + let metadataBytes: Buffer; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "ck-orphan-proof-")); + claudeDir = join(root, "claude"); + const versionRoot = join(claudeDir, "plugins", "cache", "claudekit", "ck", version); + cachePath = join(versionRoot, relativePath); + manifestPath = join(versionRoot, ".claude-plugin", "plugin.json"); + targetPath = join(claudeDir, relativePath); + backupDir = join(claudeDir, "backups", "proof-test"); + metadataPath = join(claudeDir, "metadata.json"); + await mkdir(dirname(cachePath), { recursive: true }); + await mkdir(dirname(manifestPath), { recursive: true }); + await mkdir(dirname(targetPath), { recursive: true }); + await mkdir(backupDir, { recursive: true }); + await writeFile(cachePath, "official\n"); + await writeFile(targetPath, "official\n"); + await writeFile(manifestPath, JSON.stringify({ name: "ck", version })); + metadataBytes = Buffer.from('{"marker":"preserve"}\n'); + await writeFile(metadataPath, metadataBytes); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + function collectSingleProof() { + const proofs = collectOrphanedPluginLegacyFileProofs(claudeDir); + expect(proofs).toHaveLength(1); + return proofs[0]; + } + + function expectRemovalPreservedTarget(): void { + expect(defaultLegacyRemover(claudeDir, backupDir)).toEqual([]); + expect(existsSync(targetPath)).toBe(true); + expect(existsSync(join(backupDir, relativePath))).toBe(false); + expect(Buffer.compare(readFileSync(metadataPath), metadataBytes)).toBe(0); + } + + test("target mutation invalidates a collected proof and prevents cleanup", async () => { + const proof = collectSingleProof(); + + await writeFile(targetPath, "user edit\n"); + + expect(orphanedPluginLegacyFileProofMatches(claudeDir, proof)).toBe(false); + expectRemovalPreservedTarget(); + expect(readFileSync(targetPath, "utf-8")).toBe("user edit\n"); + }); + + test("cache mutation invalidates a collected proof and prevents cleanup", async () => { + const proof = collectSingleProof(); + + await writeFile(cachePath, "cache changed\n"); + + expect(orphanedPluginLegacyFileProofMatches(claudeDir, proof)).toBe(false); + expectRemovalPreservedTarget(); + expect(readFileSync(targetPath, "utf-8")).toBe("official\n"); + }); + + test("manifest mutation invalidates a collected proof and prevents cleanup", async () => { + const proof = collectSingleProof(); + + await writeFile(manifestPath, JSON.stringify({ name: "forged", version })); + + expect(orphanedPluginLegacyFileProofMatches(claudeDir, proof)).toBe(false); + expectRemovalPreservedTarget(); + expect(readFileSync(targetPath, "utf-8")).toBe("official\n"); + }); + + test.skipIf(process.platform === "win32")( + "target symlink replacement invalidates proof without touching its referent", + async () => { + const proof = collectSingleProof(); + const outsideTarget = join(root, "outside-target.md"); + await writeFile(outsideTarget, "official\n"); + await rm(targetPath); + await symlink(outsideTarget, targetPath, "file"); + + expect(orphanedPluginLegacyFileProofMatches(claudeDir, proof)).toBe(false); + expectRemovalPreservedTarget(); + expect(lstatSync(targetPath).isSymbolicLink()).toBe(true); + expect(readFileSync(outsideTarget, "utf-8")).toBe("official\n"); + }, + ); + + test.skipIf(process.platform === "win32")( + "cache symlink replacement invalidates proof without removing the legacy target", + async () => { + const proof = collectSingleProof(); + const outsideCache = join(root, "outside-cache.md"); + await writeFile(outsideCache, "official\n"); + await rm(cachePath); + await symlink(outsideCache, cachePath, "file"); + + expect(orphanedPluginLegacyFileProofMatches(claudeDir, proof)).toBe(false); + expectRemovalPreservedTarget(); + expect(lstatSync(cachePath).isSymbolicLink()).toBe(true); + expect(readFileSync(targetPath, "utf-8")).toBe("official\n"); + }, + ); +}); diff --git a/src/domains/installation/plugin/install-mode-detector.ts b/src/domains/installation/plugin/install-mode-detector.ts index abb78d78..34c0e6cc 100644 --- a/src/domains/installation/plugin/install-mode-detector.ts +++ b/src/domains/installation/plugin/install-mode-detector.ts @@ -4,6 +4,7 @@ import { join, relative, resolve } from "node:path"; import { PathResolver } from "@/shared/path-resolver.js"; import { compareVersions } from "compare-versions"; import { collectEngineerHistoricalFiles } from "./historical-metadata-files.js"; +import { collectOrphanedPluginLegacyFileProofs } from "./orphaned-plugin-legacy-files.js"; /** * Install-mode detection for the ClaudeKit Engineer kit. @@ -39,9 +40,9 @@ export interface PluginState { } export interface LegacyState { - /** A legacy copy of the engineer kit is tracked in metadata.json. */ + /** A CK-owned legacy Engineer payload is still present. */ installed: boolean; - /** Version recorded for the legacy copy, or null. */ + /** Version recorded in metadata.json, or null when cache proof is the only signal. */ version: string | null; } @@ -138,14 +139,13 @@ function normalizeVersion(version: string): string { /** * Detect a legacy (copied-into-~/.claude) install of the engineer kit. * - * Authoritative signal is metadata.json: the CLI records the kit under - * `kits.engineer` (multi-kit format) or at the root (legacy single-kit format) - * when it copies the payload into ~/.claude. + * Metadata tracks normal copied installs. For files orphaned by pruned metadata, + * exact same-path bytes in an official CK plugin cache are accepted as proof. */ export function detectLegacyState(claudeDir: string): LegacyState { const metadata = readJsonSafe(join(claudeDir, "metadata.json")); - if (!isRecord(metadata)) return { installed: false, version: null }; - const hasLegacyPayload = hasTrackedPluginSuppliedLegacyFiles(claudeDir); + const hasLegacyPayload = hasPluginSuppliedLegacyFiles(claudeDir, metadata); + if (!isRecord(metadata)) return { installed: hasLegacyPayload, version: null }; // Multi-kit format: kits.engineer if (isRecord(metadata.kits) && isRecord(metadata.kits[ENGINEER_KIT_KEY])) { @@ -166,7 +166,7 @@ export function detectLegacyState(claudeDir: string): LegacyState { return { installed: true, version: metadata.version }; } - return { installed: false, version: null }; + return { installed: hasLegacyPayload, version: null }; } export function classifyInstallMode(plugin: PluginState, legacy: LegacyState): InstallMode { @@ -232,23 +232,33 @@ const PLUGIN_SUPPLIED_LEGACY_PREFIXES = ["agents/", "skills/"]; /** * True when the legacy flat-copy install still has CK-owned files that are now - * supplied by the plugin. This avoids forcing plugin migration forever for - * mixed installs that intentionally retain runtime hook/rule surfaces. + * supplied by the plugin. Metadata remains authoritative for tracked files; + * otherwise exact same-path bytes in an official CK plugin cache prove an + * orphaned legacy file. This avoids trusting arbitrary/custom files. */ export function hasTrackedPluginSuppliedLegacyFiles( claudeDir: string = PathResolver.getGlobalKitDir(), ): boolean { const metadata = readJsonSafe(join(claudeDir, "metadata.json")); - if (!isRecord(metadata)) return false; + return hasPluginSuppliedLegacyFiles(claudeDir, metadata); +} + +function hasPluginSuppliedLegacyFiles(claudeDir: string, metadata: unknown): boolean { + const historicalFiles = collectEngineerHistoricalFiles(metadata); - for (const file of collectEngineerHistoricalFiles(metadata)) { + for (const file of historicalFiles) { const resolvedPath = resolveSafePluginSuppliedLegacyPath(claudeDir, file.path); if (!resolvedPath || !existsSync(resolvedPath)) continue; if (file.ownership === "user" && !checksumMatches(resolvedPath, file.checksum)) continue; return true; } - return false; + return ( + collectOrphanedPluginLegacyFileProofs( + claudeDir, + historicalFiles.map((file) => file.path), + ).length > 0 + ); } function resolveSafePluginSuppliedLegacyPath(claudeDir: string, pathValue: string): string | null { diff --git a/src/domains/installation/plugin/migrate-legacy-to-plugin.ts b/src/domains/installation/plugin/migrate-legacy-to-plugin.ts index b1b86382..2a0afcfe 100644 --- a/src/domains/installation/plugin/migrate-legacy-to-plugin.ts +++ b/src/domains/installation/plugin/migrate-legacy-to-plugin.ts @@ -22,6 +22,10 @@ import { detectInstallMode, detectPluginState, } from "@/domains/installation/plugin/install-mode-detector.js"; +import { + collectOrphanedPluginLegacyFileProofs, + orphanedPluginLegacyFileProofMatches, +} from "@/domains/installation/plugin/orphaned-plugin-legacy-files.js"; import { PluginInstaller } from "@/domains/installation/plugin/plugin-installer.js"; import { PathResolver } from "@/shared/path-resolver.js"; @@ -251,6 +255,18 @@ export function defaultLegacyRemover( continue; removed.push(legacyPath.relativePath); } + for (const proof of collectOrphanedPluginLegacyFileProofs( + claudeDir, + files.map((file) => file.path), + )) { + if (!orphanedPluginLegacyFileProofMatches(claudeDir, proof)) continue; + const legacyPath = resolveSafePluginSuppliedLegacyPath(claudeDir, proof.relativePath); + if (!legacyPath || !existsSync(legacyPath.absolutePath)) continue; + if (!backupAndRemove(claudeDir, backupDir, legacyPath.relativePath, legacyPath.absolutePath)) { + continue; + } + removed.push(legacyPath.relativePath); + } removed.push(...removeOrphanLegacySentinels(claudeDir, backupDir, removed)); return removed; } diff --git a/src/domains/installation/plugin/orphaned-plugin-legacy-files.ts b/src/domains/installation/plugin/orphaned-plugin-legacy-files.ts new file mode 100644 index 00000000..1df77d04 --- /dev/null +++ b/src/domains/installation/plugin/orphaned-plugin-legacy-files.ts @@ -0,0 +1,237 @@ +import { type Dirent, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +const LEGACY_ROOTS = ["agents", "skills"] as const; +const CK_PLUGIN_MANIFEST_PATH = [".claude-plugin", "plugin.json"] as const; + +export interface OrphanedPluginLegacyFileProof { + relativePath: string; + cachePath: string; +} + +/** + * Find untracked flat files whose bytes are proven by an official ClaudeKit + * plugin cache version at the same agents/ or skills/ path. + */ +export function collectOrphanedPluginLegacyFileProofs( + claudeDir: string, + excludedRelativePaths: Iterable = [], +): OrphanedPluginLegacyFileProof[] { + const cacheRoot = join(claudeDir, "plugins", "cache", "claudekit", "ck"); + if (!isSafeDirectoryWithin(claudeDir, cacheRoot)) return []; + + const excluded = new Set( + [...excludedRelativePaths].map(normalizeRelativePath).filter(isPluginLegacyPath), + ); + const proofs = new Map(); + const versions = safeReadDir(cacheRoot) + .filter((entry) => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const version of versions) { + const versionRoot = join(cacheRoot, version.name); + if (!isSafeDirectoryWithin(cacheRoot, versionRoot)) continue; + if (!hasExpectedCkPluginManifest(cacheRoot, versionRoot, version.name)) continue; + for (const payloadRoot of [versionRoot, join(versionRoot, ".claude")]) { + for (const legacyRoot of LEGACY_ROOTS) { + const sourceRoot = join(payloadRoot, legacyRoot); + if (!isSafeDirectoryWithin(cacheRoot, sourceRoot)) continue; + collectPayloadProofs(claudeDir, cacheRoot, payloadRoot, sourceRoot, excluded, proofs); + } + } + } + + return [...proofs.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); +} + +/** Revalidate a cache ownership proof immediately before destructive cleanup. */ +export function orphanedPluginLegacyFileProofMatches( + claudeDir: string, + proof: OrphanedPluginLegacyFileProof, +): boolean { + const cacheRoot = join(claudeDir, "plugins", "cache", "claudekit", "ck"); + const relativePath = normalizeRelativePath(proof.relativePath); + const versionRoot = resolveCacheVersionRootForLegacyPath( + cacheRoot, + proof.cachePath, + relativePath, + ); + if (!versionRoot) return false; + const versionName = normalizeRelativePath(relative(resolve(cacheRoot), resolve(versionRoot))); + if (!hasExpectedCkPluginManifest(cacheRoot, versionRoot, versionName)) return false; + return legacyPayloadBytesMatch(claudeDir, cacheRoot, proof.cachePath, relativePath); +} + +function collectPayloadProofs( + claudeDir: string, + cacheRoot: string, + payloadRoot: string, + directory: string, + excluded: Set, + proofs: Map, +): void { + for (const entry of safeReadDir(directory).sort((a, b) => a.name.localeCompare(b.name))) { + const sourcePath = join(directory, entry.name); + if (entry.isDirectory()) { + if (isSafeDirectoryWithin(cacheRoot, sourcePath)) { + collectPayloadProofs(claudeDir, cacheRoot, payloadRoot, sourcePath, excluded, proofs); + } + continue; + } + if (!entry.isFile() || !isSafeRegularFileWithin(cacheRoot, sourcePath)) continue; + + const relativePath = normalizeRelativePath(relative(payloadRoot, sourcePath)); + if (excluded.has(relativePath) || proofs.has(relativePath)) continue; + const proof = { relativePath, cachePath: sourcePath }; + if (legacyPayloadBytesMatch(claudeDir, cacheRoot, sourcePath, relativePath)) { + proofs.set(relativePath, proof); + } + } +} + +function resolveCacheVersionRootForLegacyPath( + cacheRoot: string, + cachePath: string, + relativePath: string, +): string | null { + const cacheRelative = normalizeRelativePath(relative(resolve(cacheRoot), resolve(cachePath))); + const suffix = `/${relativePath}`; + if (!cacheRelative.endsWith(suffix)) return null; + const prefix = cacheRelative.slice(0, -suffix.length).split("/"); + if (prefix.length !== 1 && !(prefix.length === 2 && prefix[1] === ".claude")) return null; + const versionName = prefix[0]; + if (!versionName || versionName === ".." || versionName.includes("/")) return null; + return join(cacheRoot, versionName); +} + +function hasExpectedCkPluginManifest( + cacheRoot: string, + versionRoot: string, + versionName: string, +): boolean { + if (!isSafeDirectoryWithin(cacheRoot, versionRoot)) return false; + const manifestPath = join(versionRoot, ...CK_PLUGIN_MANIFEST_PATH); + if (!isSafeRegularFileWithin(cacheRoot, manifestPath)) return false; + + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as unknown; + if (!isRecord(manifest) || manifest.name !== "ck" || typeof manifest.version !== "string") { + return false; + } + const expectedVersion = normalizePluginVersion(versionName); + const manifestVersion = normalizePluginVersion(manifest.version); + return Boolean(expectedVersion && manifestVersion && expectedVersion === manifestVersion); + } catch { + return false; + } +} + +function legacyPayloadBytesMatch( + claudeDir: string, + cacheRoot: string, + cachePath: string, + relativePath: string, +): boolean { + const targetPath = resolveSafeLegacyPath(claudeDir, relativePath); + if ( + !targetPath || + !isSafeRegularFileWithin(cacheRoot, cachePath) || + !isSafeRegularFileWithin(claudeDir, targetPath) + ) { + return false; + } + + try { + if (statSync(cachePath).size !== statSync(targetPath).size) return false; + return readFileSync(cachePath).equals(readFileSync(targetPath)); + } catch { + return false; + } +} + +function normalizePluginVersion(version: string): string { + return version.trim().replace(/^v/, ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function resolveSafeLegacyPath(claudeDir: string, pathValue: string): string | null { + const normalized = normalizeRelativePath(pathValue); + if (!isPluginLegacyPath(normalized) || hasPathTraversal(normalized)) return null; + const resolvedBase = resolve(claudeDir); + const resolvedTarget = resolve(resolvedBase, normalized); + const targetRelative = normalizeRelativePath(relative(resolvedBase, resolvedTarget)); + return targetRelative === normalized ? resolvedTarget : null; +} + +function isPluginLegacyPath(pathValue: string): boolean { + return LEGACY_ROOTS.some((root) => pathValue.startsWith(`${root}/`)); +} + +function normalizeRelativePath(pathValue: string): string { + return pathValue + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/^\.claude\//, ""); +} + +function hasPathTraversal(pathValue: string): boolean { + return pathValue.split("/").some((segment) => segment === ".."); +} + +function safeReadDir(directory: string): Dirent[] { + try { + return readdirSync(directory, { withFileTypes: true }); + } catch { + return []; + } +} + +function isSafeDirectoryWithin(baseDir: string, directoryPath: string): boolean { + if (!isPathWithin(baseDir, directoryPath) || pathHasSymlinkComponent(baseDir, directoryPath)) { + return false; + } + try { + return lstatSync(directoryPath).isDirectory() && realPathIsWithin(baseDir, directoryPath); + } catch { + return false; + } +} + +function isSafeRegularFileWithin(baseDir: string, filePath: string): boolean { + if (!isPathWithin(baseDir, filePath) || pathHasSymlinkComponent(baseDir, filePath)) return false; + try { + return lstatSync(filePath).isFile() && realPathIsWithin(baseDir, filePath); + } catch { + return false; + } +} + +function isPathWithin(baseDir: string, targetPath: string): boolean { + const relativePath = normalizeRelativePath(relative(resolve(baseDir), resolve(targetPath))); + return Boolean(relativePath && relativePath !== ".." && !relativePath.startsWith("../")); +} + +function realPathIsWithin(baseDir: string, targetPath: string): boolean { + const relativePath = normalizeRelativePath( + relative(realpathSync(baseDir), realpathSync(targetPath)), + ); + return Boolean(relativePath && relativePath !== ".." && !relativePath.startsWith("../")); +} + +function pathHasSymlinkComponent(baseDir: string, targetPath: string): boolean { + const relativePath = normalizeRelativePath(relative(resolve(baseDir), resolve(targetPath))); + if (!relativePath || relativePath === ".." || relativePath.startsWith("../")) return true; + let current = resolve(baseDir); + for (const segment of relativePath.split("/")) { + current = join(current, segment); + try { + if (lstatSync(current).isSymbolicLink()) return true; + } catch { + return true; + } + } + return false; +}