Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 110 additions & 2 deletions src/__tests__/commands/update-cli-auto-init.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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";
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/commands/update-cli-prompt-kit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
38 changes: 34 additions & 4 deletions src/commands/update/post-update-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading