diff --git a/docs/cli.md b/docs/cli.md index 562f75097..15b14b465 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -144,6 +144,10 @@ require installation. Repair only affected targets within the authorized scope; Hack-owned integrations; add `--install-missing` to create missing ones (see [integration setup](integrations.md#refresh-only-hack-setup-sync)). Read updated guidance after repair; restart only if the client cannot reload changed hooks or skills. +To move from standalone skill and rule files to the `hack` plugin, preview with +`hack setup migrate`, then run `hack setup migrate --apply` after the plugin loads; apply writes a +backup manifest that `hack setup migrate --rollback-manifest ` restores (see +[migrate standalone files](integrations.md#migrate-standalone-files-to-the-plugin)). Ordinary commands, `hack update`, and `hack doctor --fix` never render, repair, remove, or otherwise mutate integration files. CLI upgrades do not trigger integration updates. diff --git a/docs/integrations.md b/docs/integrations.md index e2d8b35b6..e05ecac61 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -142,9 +142,71 @@ all, and why a repository `.mcp.json` counts as that client's MCP path. Installing a plugin does not migrate or remove existing rules, skills, hooks, or MCP configuration. If switching an existing installation, inspect the chosen client's project and user scopes, verify the plugin loads in a fresh session, then explicitly -remove only duplicate Hack-owned entries in that scope. Preserve customized content. -Do not use a broad sync or removal command as a migration step. If validation fails, -disable the plugin and keep using the standalone integration. +remove only duplicate Hack-owned entries in that scope with `hack setup migrate` +(below). Preserve customized content. Do not use a broad sync or removal command as +a migration step. If validation fails, disable the plugin and keep using the +standalone integration. + +### Migrate standalone files to the plugin + +`hack setup migrate` moves one scope at a time from standalone Hack skill and rule +files to the `hack` plugin. It covers the files the plugin replaces: the Codex +`hack-cli` and `hack-init` skills, the Claude Code `hack-init` skill, the Cursor +`hack.mdc` rule, and the shared `~/.ai/skills/hack-cli` skill. It never touches +Claude primer hooks, MCP entries, or `AGENTS.md`/`CLAUDE.md` sections; remove those +with their own `hack setup` command if you no longer want them. + +```bash +hack setup migrate # preview this project; changes nothing +hack setup migrate --global --codex # preview user-scope Codex files only +hack setup migrate --apply # back up and remove the previewed files +hack setup migrate --rollback-manifest ~/.hack/agent-migrations/.json +``` + +The preview (the default) is read-only. For each file it reports one action: + +| Action | Meaning | +| --- | --- | +| `remove` | Hack-owned (the current or an unedited earlier render), and the replacement plugin is verified or its check was skipped | +| `blocked` | Hack-owned, but the replacement plugin is not verified for this scope; `--apply` refuses | +| `preserve` | Customized, unrecognized, or reached through a symbolic link; never removed | +| `absent` | Not installed | + +Replacement checks read client configuration for the scope. Claude Code must enable +`hack@hack-dance` in the nearest settings file (project `settings.local.json`, then +project `settings.json`, then `~/.claude/settings.json`), and +`~/.claude/plugins/installed_plugins.json` must list an installed copy that carries +the `hack-cli` and `hack-init` skills. Codex must set +`[plugins."hack@hack-dance"] enabled = true` in the project or user `config.toml`. +For the user scope (`--global`), only user-level configuration counts. Hack cannot +inspect Cursor plugin installs, so Cursor files stay `blocked` until you pass +`--skip-plugin-check cursor`. A verified configuration is not proof that the plugin +loads: check that in a fresh session before applying, and use +`--skip-plugin-check ` (comma-separated `claude`, `codex`, `cursor`, or +`all`) only after you have. + +`--apply` is all or nothing. If any selected file is `blocked`, or a file cannot be +read for backup, it changes nothing and exits 1. Otherwise it first writes a backup +manifest under `$HACK_HOME/agent-migrations/` (default `~/.hack/agent-migrations/`) +with the exact bytes and mode of every file it will remove, then removes each file +only if its bytes still match the backup. A skill directory is removed only when it +is empty afterwards. A file edited in between is reported as `changed`, left in +place, and makes the command exit 1. With nothing Hack-owned to remove, apply writes +no manifest. + +`--rollback-manifest ` restores every recorded file whose path is now empty, with +its original bytes and mode. It never overwrites an existing file: identical bytes +are reported as `already-present`, and different content as `conflict` (exit 1), +left unchanged. Rollback can be repeated, and it also recovers an interrupted +apply. A manifest that names a path other than a known Hack target, or whose bytes +do not match their digest, is refused without restoring anything. + +Limit clients with `--claude`, `--codex`, or `--cursor` (default: all three). The +shared skill follows Claude Code or Codex. `--json` prints +`{ok, data: {action, dryRun, ...}}`: the preview carries `counts`, `plugins` +(per-scope check status) and `targets`; apply adds `status`, `reason` when refused, +`manifestPath`, and per-file `results`; rollback carries `status` and `results`. +Messages are fixed text and never include file contents. ### Upgrading from a plugin that bundled MCP diff --git a/docs/reference/cli.md b/docs/reference/cli.md index dd1f96c8c..f27f6501d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1802,6 +1802,7 @@ hack setup [options] | `hack setup codex` | Install Codex skill for hack CLI usage | | `hack setup agents` | Install AGENTS.md / CLAUDE.md snippets for hack CLI usage | | `hack setup sync` | Refresh installed Hack-owned agent integrations | +| `hack setup migrate` | Preview, apply or roll back moving standalone agent files to the hack plugin | | `hack setup mcp` | Install MCP configs for hack CLI usage (no-shell only) | ### Options @@ -1947,6 +1948,34 @@ hack setup sync [options] | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack setup migrate` + +Preview, apply or roll back moving standalone agent files to the hack plugin + +### Usage + +```bash +hack setup migrate [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--path, -p ` | Run a project command against a repo path (overrides cwd search) | +| `--global` | Migrate user-scope files under HOME instead of the project | +| `--all-scopes` | Migrate both project and user-scope files | +| `--claude` | Limit to Claude Code files (default: all clients) | +| `--codex` | Limit to Codex files (default: all clients) | +| `--cursor` | Limit to Cursor files (default: all clients) | +| `--skip-plugin-check ` | Treat the plugin as verified for these comma-separated clients (claude, codex, cursor, or all) after checking it loads yourself | +| `--apply` | Remove the previewed Hack-owned standalone files after backing them up (default: read-only preview) | +| `--rollback-manifest ` | Restore the exact files recorded in a migration backup manifest | +| `--json` | Output JSON (machine-readable) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + ## `hack setup mcp` Install MCP configs for hack CLI usage (no-shell only) diff --git a/src/agents/plugin-migration.ts b/src/agents/plugin-migration.ts new file mode 100644 index 000000000..81441bde6 --- /dev/null +++ b/src/agents/plugin-migration.ts @@ -0,0 +1,790 @@ +import { createHash, randomBytes } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + open, + readFile, + rename, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +import { readOptionalTextFile } from "../lib/fs.ts"; +import { isRecord } from "../lib/guards.ts"; +import { + classifyIntegrationContent, + classifyIntegrationFile, + describePreservedReason, + type IntegrationPreservedReason, + removeOwnedIntegrationFile, +} from "./integration-ownership.ts"; +import { + checkPluginReplacement, + type MigrationClient, + type PluginCheckStatus, +} from "./plugin-replacement.ts"; +import { + listOwnedFileTargets, + type OwnedFileTarget, + type SetupSyncScope, + type SetupSyncTarget, +} from "./setup-sync.ts"; + +/** + * Scoped migration from standalone Hack agent files to the skills-only `hack` + * plugin (HACK-1132). + * + * Contract: + * - `planPluginMigration` is read-only. It lists every standalone Hack file + * target in the selected scopes and clients with one action: `remove` + * (Hack-owned and the replacement plugin is verified or its check was + * explicitly skipped), `blocked` (Hack-owned but the replacement is not + * verified), `preserve` (customized, unknown, or reached through a symbolic + * link) or `absent`. Ownership comes from the W11 classifier + * (`integration-ownership.ts`); this module never widens it. + * - `applyPluginMigration` re-plans, refuses without writing anything when a + * target is `blocked` or a scope root is unresolved, and otherwise writes a + * backup manifest holding the exact bytes and mode of every file it will + * remove before removing any of them. The manifest write is the commit + * point: a file is removed only after its bytes are durable in the + * manifest, and only when its bytes still match the backup. + * - `rollbackPluginMigration` restores each manifest entry whose path is + * absent, byte-for-byte with its recorded mode. It never overwrites an + * existing path: identical bytes are `already-present`, anything else is a + * `conflict` left unchanged. Rollback is idempotent and also recovers an + * interrupted apply. + * + * Only standalone skill and rule files are in scope. Claude primer hooks, MCP + * entries and `AGENTS.md`/`CLAUDE.md` sections are never touched here. + * + * Replacement verification reads client configuration, not a live session: + * Claude Code must enable `hack@hack-dance` in a settings file that applies to + * the scope and list an installed copy that carries the replacement skills; + * Codex must enable it in a `config.toml` that applies to the scope. Hack + * cannot inspect Cursor plugin installs, so Cursor targets need an explicit + * skip. A verified configuration is not proof that a fresh session loads the + * plugin. + * + * Every path derives from explicit `root`, `home` and `backupDir` inputs. + * Diagnostics are closed strings with no file content or caller input. + * Planning, backup and removal are not atomic with respect to concurrent + * editors; each removal rechecks bytes and ownership immediately before it + * unlinks, which leaves the same one read-then-unlink window as W11. + */ + +/** + * Which plugin replaces a target. `shared` (`~/.ai/skills/hack-cli`) is a + * cross-client root, replaced when the Claude Code or Codex plugin is. + */ +export type ReplacementClient = MigrationClient | "shared"; + +export type PluginCheck = { + readonly scope: SetupSyncScope; + readonly client: MigrationClient; + readonly status: PluginCheckStatus; + readonly message: string; +}; + +/** Why migration leaves a file in place. Closed and value-free. */ +export type MigrationPreservedReason = + | IntegrationPreservedReason + | "symlinked-path"; + +export type MigrationPlanAction = + | "remove" + | "blocked" + | "preserve" + | "absent" + | "error"; + +export type MigrationPlanEntry = { + readonly scope: SetupSyncScope; + readonly target: SetupSyncTarget; + readonly replacement: ReplacementClient; + readonly path: string; + readonly action: MigrationPlanAction; + readonly reason?: MigrationPreservedReason; + readonly message?: string; +}; + +export type MigrationScopeRoot = { + readonly scope: SetupSyncScope; + /** Project root for `project`, home directory for `global`. */ + readonly root: string; +}; + +export type PluginMigrationInput = { + readonly scopes: readonly MigrationScopeRoot[]; + /** Home directory whose user-level client configuration applies. */ + readonly home: string; + readonly clients: readonly MigrationClient[]; + /** Clients whose replacement check the operator explicitly skipped. */ + readonly skipPluginCheck: readonly MigrationClient[]; +}; + +export type PluginMigrationPlan = { + readonly checks: readonly PluginCheck[]; + readonly entries: readonly MigrationPlanEntry[]; +}; + +const TARGET_REPLACEMENT: Readonly< + Partial> +> = { + "cursor-rules": "cursor", + "claude-hack-init-skill": "claude", + "codex-hack-cli-skill": "codex", + "codex-hack-init-skill": "codex", + "shared-hack-cli-skill": "shared", +}; + +const PLUGIN_CHECK_MESSAGES: Readonly> = { + verified: "The client configuration enables the hack plugin for this scope.", + skipped: "The replacement check was skipped explicitly.", + "not-installed": + "The client configuration does not enable the hack plugin for this scope. Install it, verify it loads in a fresh session, then rerun.", + disabled: + "The client configuration disables the hack plugin for this scope. Enable it, verify it loads in a fresh session, then rerun.", + incomplete: + "The installed hack plugin copy is missing a replacement skill. Update or reinstall the plugin, then rerun.", + unreadable: + "The client configuration could not be read or parsed; the replacement is not verified.", + "not-inspectable": + "Hack cannot inspect this client's plugin installs. Verify the plugin loads in a fresh session, then pass --skip-plugin-check for this client.", +}; + +const BLOCKED_MESSAGE = + "Hack-owned, but the replacement plugin is not verified; apply would refuse."; + +const SYMLINK_MESSAGE = + "Hack left this path unchanged because it is reached through a symbolic link, which rollback could not recreate. Remove it yourself if the plugin replaces it."; + +/** Fixed operator guidance for a migration-preserved file. */ +export function describeMigrationPreservedReason( + reason: MigrationPreservedReason +): string { + if (reason === "symlinked-path") { + return SYMLINK_MESSAGE; + } + return describePreservedReason({ reason, action: "remove" }); +} + +/** Targets in one scope whose replacement belongs to a selected client. */ +function selectTargets(opts: { + readonly scope: SetupSyncScope; + readonly clients: readonly MigrationClient[]; +}): readonly (OwnedFileTarget & { readonly replacement: ReplacementClient })[] { + const selected = new Set(opts.clients); + if (selected.has("claude") || selected.has("codex")) { + selected.add("shared"); + } + const out: (OwnedFileTarget & { readonly replacement: ReplacementClient })[] = + []; + for (const file of listOwnedFileTargets(opts.scope)) { + const replacement = TARGET_REPLACEMENT[file.target]; + if (replacement && selected.has(replacement)) { + out.push({ ...file, replacement }); + } + } + return out; +} + +/** Read-only migration preview for the selected scopes and clients. */ +export async function planPluginMigration( + input: PluginMigrationInput +): Promise { + const checks: PluginCheck[] = []; + const entries: MigrationPlanEntry[] = []; + const home = input.home.trim(); + for (const { scope, root: rawRoot } of input.scopes) { + const root = rawRoot.trim(); + const targets = selectTargets({ scope, clients: input.clients }); + if (!root || (scope === "project" && !home)) { + for (const target of targets) { + entries.push({ + scope, + target: target.target, + replacement: target.replacement, + path: "", + action: "error", + message: + scope === "global" || !home + ? "HOME is not set; cannot resolve integration paths." + : "Project root could not be resolved.", + }); + } + continue; + } + + const scopeChecks = new Map(); + for (const client of input.clients) { + const status = input.skipPluginCheck.includes(client) + ? "skipped" + : await checkPluginReplacement({ client, scope, root, home }); + const check: PluginCheck = { + scope, + client, + status, + message: PLUGIN_CHECK_MESSAGES[status], + }; + scopeChecks.set(client, check); + checks.push(check); + } + + for (const target of targets) { + const path = resolve(root, target.relativePath); + entries.push({ + scope, + target: target.target, + replacement: target.replacement, + path, + ...(await planTarget({ + target, + path, + replaced: isReplaced({ + replacement: target.replacement, + checks: scopeChecks, + }), + })), + }); + } + } + return { checks, entries }; +} + +function isSatisfied(check: PluginCheck | undefined): boolean { + return check?.status === "verified" || check?.status === "skipped"; +} + +function isReplaced(opts: { + readonly replacement: ReplacementClient; + readonly checks: ReadonlyMap; +}): boolean { + if (opts.replacement === "shared") { + return ( + isSatisfied(opts.checks.get("claude")) || + isSatisfied(opts.checks.get("codex")) + ); + } + return isSatisfied(opts.checks.get(opts.replacement)); +} + +async function planTarget(opts: { + readonly target: OwnedFileTarget; + readonly path: string; + readonly replaced: boolean; +}): Promise> { + const ownership = await classifyIntegrationFile({ + surface: opts.target.surface, + path: opts.path, + currentRender: opts.target.render(), + }); + if (ownership.kind === "absent") { + return { action: "absent" }; + } + if (ownership.kind === "customized" || ownership.kind === "unknown") { + return { + action: "preserve", + reason: ownership.reason, + message: describeMigrationPreservedReason(ownership.reason), + }; + } + if (await isSymlinkedPath({ path: opts.path })) { + return { + action: "preserve", + reason: "symlinked-path", + message: SYMLINK_MESSAGE, + }; + } + if (!opts.replaced) { + return { action: "blocked", message: BLOCKED_MESSAGE }; + } + return { action: "remove" }; +} + +/** True when the file or its containing directory is a symbolic link. */ +async function isSymlinkedPath(opts: { + readonly path: string; +}): Promise { + for (const candidate of [opts.path, dirname(opts.path)]) { + try { + if ((await lstat(candidate)).isSymbolicLink()) { + return true; + } + } catch { + // Absent components are judged by the classifier, not here. + } + } + return false; +} + +// --------------------------------------------------------------------------- +// Apply +// --------------------------------------------------------------------------- + +export const MIGRATION_MANIFEST_VERSION = 1; + +/** Per-file outcome of an apply. */ +export type MigrationApplyStatus = + | "removed" + | "preserved" + | "changed" + | "missing" + | "error"; + +export type MigrationApplyEntry = { + readonly scope: SetupSyncScope; + readonly target: SetupSyncTarget; + readonly path: string; + readonly status: MigrationApplyStatus; + readonly reason?: MigrationPreservedReason; + readonly message?: string; +}; + +export type PluginMigrationApplyResult = + | { + /** + * Nothing was written: a target is blocked, a scope root is unresolved, + * or a removable file could not be read for backup. + */ + readonly status: "refused"; + readonly reason: "blocked" | "scope-error" | "backup-failed"; + readonly plan: PluginMigrationPlan; + } + | { + /** Nothing Hack-owned to remove; no manifest was written. */ + readonly status: "nothing-to-migrate"; + readonly plan: PluginMigrationPlan; + } + | { + readonly status: "applied"; + readonly plan: PluginMigrationPlan; + readonly manifestPath: string; + readonly results: readonly MigrationApplyEntry[]; + }; + +type ManifestEntry = { + readonly scope: SetupSyncScope; + readonly root: string; + readonly target: SetupSyncTarget; + readonly relativePath: string; + readonly path: string; + readonly sha256: string; + readonly mode: number; + readonly contentBase64: string; + readonly outcome?: MigrationApplyStatus; +}; + +type MigrationManifest = { + readonly version: typeof MIGRATION_MANIFEST_VERSION; + readonly kind: "hack-plugin-migration"; + readonly id: string; + readonly createdAt: string; + readonly state: "applying" | "applied"; + readonly entries: readonly ManifestEntry[]; +}; + +/** Seams for tests; production uses the real clock, id and unlink. */ +export type PluginMigrationApplyHooks = { + readonly now?: () => Date; + readonly id?: string; + /** Called after the manifest commit and before each removal. */ + readonly beforeRemove?: (entry: { + readonly path: string; + readonly index: number; + }) => Promise; +}; + +/** + * Apply the migration: refuse on any blocked target, back up every removable + * file to a manifest under `backupDir`, then remove each one. + */ +export async function applyPluginMigration( + input: PluginMigrationInput & { + readonly backupDir: string; + readonly hooks?: PluginMigrationApplyHooks; + } +): Promise { + const plan = await planPluginMigration(input); + if (plan.entries.some((entry) => entry.action === "error")) { + return { status: "refused", reason: "scope-error", plan }; + } + if (plan.entries.some((entry) => entry.action === "blocked")) { + return { status: "refused", reason: "blocked", plan }; + } + const removable = plan.entries.filter((entry) => entry.action === "remove"); + if (removable.length === 0) { + return { status: "nothing-to-migrate", plan }; + } + + const roots = new Map(input.scopes.map((s) => [s.scope, s.root.trim()])); + const backups: ManifestEntry[] = []; + for (const entry of removable) { + const target = listOwnedFileTargets(entry.scope).find( + (file) => file.target === entry.target + ); + const root = roots.get(entry.scope) ?? ""; + const snapshot = await snapshotFile({ path: entry.path }); + if (!(target && snapshot)) { + // All or nothing: never remove a file set whose backup is incomplete. + return { status: "refused", reason: "backup-failed", plan }; + } + backups.push({ + scope: entry.scope, + root, + target: entry.target, + relativePath: target.relativePath, + path: entry.path, + sha256: snapshot.sha256, + mode: snapshot.mode, + contentBase64: snapshot.bytes.toString("base64"), + }); + } + + const now = input.hooks?.now?.() ?? new Date(); + const id = input.hooks?.id ?? manifestId(now); + const manifestPath = join(input.backupDir, `${id}.json`); + const manifest: MigrationManifest = { + version: MIGRATION_MANIFEST_VERSION, + kind: "hack-plugin-migration", + id, + createdAt: now.toISOString(), + state: "applying", + entries: backups, + }; + // Commit point: nothing is removed before this write succeeds. + await writeManifest({ path: manifestPath, manifest }); + + const results: MigrationApplyEntry[] = []; + const recorded: ManifestEntry[] = []; + for (const [index, backup] of backups.entries()) { + await input.hooks?.beforeRemove?.({ path: backup.path, index }); + const status = await removeBackedUpFile({ backup }); + results.push({ + scope: backup.scope, + target: backup.target, + path: backup.path, + ...status, + }); + recorded.push({ ...backup, outcome: status.status }); + } + await writeManifest({ + path: manifestPath, + manifest: { ...manifest, state: "applied", entries: recorded }, + }); + return { status: "applied", plan, manifestPath, results }; +} + +const ISO_SEPARATORS = /[-:]/g; +const ISO_FRACTION = /\.\d+Z$/; + +function manifestId(now: Date): string { + const stamp = now + .toISOString() + .replace(ISO_SEPARATORS, "") + .replace(ISO_FRACTION, "Z"); + return `plugin-migration-${stamp}-${randomBytes(4).toString("hex")}`; +} + +async function snapshotFile(opts: { readonly path: string }): Promise<{ + readonly bytes: Buffer; + readonly sha256: string; + readonly mode: number; +} | null> { + try { + const info = await lstat(opts.path); + if (!info.isFile()) { + return null; + } + const bytes = await readFile(opts.path); + return { bytes, sha256: sha256(bytes), mode: info.mode & 0o7777 }; + } catch { + return null; + } +} + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function writeManifest(opts: { + readonly path: string; + readonly manifest: MigrationManifest; +}): Promise { + await mkdir(dirname(opts.path), { recursive: true, mode: 0o700 }); + const temporary = `${opts.path}.${process.pid}.tmp`; + const handle = await open(temporary, "w", 0o600); + try { + await handle.writeFile(`${JSON.stringify(opts.manifest, null, 2)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, opts.path); +} + +async function removeBackedUpFile(opts: { + readonly backup: ManifestEntry; +}): Promise> { + const { backup } = opts; + const target = listOwnedFileTargets(backup.scope).find( + (file) => file.target === backup.target + ); + if (!target) { + return { status: "error", message: "Unknown migration target." }; + } + try { + if (await isSymlinkedPath({ path: backup.path })) { + return { + status: "preserved", + reason: "symlinked-path", + message: SYMLINK_MESSAGE, + }; + } + const current = await snapshotFile({ path: backup.path }); + if (!current) { + return { status: "missing" }; + } + if (current.sha256 !== backup.sha256) { + return { + status: "changed", + message: + "The file changed after it was backed up; left unchanged. Rerun the preview.", + }; + } + const outcome = await removeOwnedIntegrationFile({ + surface: target.surface, + path: backup.path, + currentRender: target.render(), + removeEmptyDirectory: target.removeEmptyDirectory, + }); + if (outcome.status === "preserved") { + return { + status: "preserved", + reason: outcome.reason, + message: describeMigrationPreservedReason(outcome.reason), + }; + } + return { status: outcome.status }; + } catch { + return { + status: "error", + message: + "The file could not be removed; it is recorded in the backup manifest.", + }; + } +} + +// --------------------------------------------------------------------------- +// Rollback +// --------------------------------------------------------------------------- + +export type MigrationRollbackStatus = + | "restored" + | "already-present" + | "conflict" + | "error"; + +export type MigrationRollbackEntry = { + readonly scope: SetupSyncScope; + readonly target: SetupSyncTarget; + readonly path: string; + readonly status: MigrationRollbackStatus; + readonly message?: string; +}; + +export type PluginMigrationRollbackResult = + | { + readonly status: "invalid-manifest"; + readonly message: string; + } + | { + readonly status: "rolled-back"; + readonly results: readonly MigrationRollbackEntry[]; + }; + +const CONFLICT_MESSAGE = + "A different file now exists at this path; left unchanged. Move it aside and rerun the rollback to restore the backup."; + +/** Restore the exact bytes recorded in a migration manifest. */ +export async function rollbackPluginMigration(opts: { + readonly manifestPath: string; +}): Promise { + const parsed = await readManifest({ path: opts.manifestPath }); + if (!parsed.ok) { + return { status: "invalid-manifest", message: parsed.message }; + } + const results: MigrationRollbackEntry[] = []; + for (const entry of parsed.entries) { + results.push({ + scope: entry.scope, + target: entry.target, + path: entry.path, + ...(await restoreEntry({ entry })), + }); + } + return { status: "rolled-back", results }; +} + +type ValidEntry = { + readonly scope: SetupSyncScope; + readonly target: SetupSyncTarget; + readonly path: string; + readonly bytes: Buffer; + readonly sha256: string; + readonly mode: number; +}; + +const INVALID_MANIFEST = + "The migration manifest is missing, unreadable, or not a valid Hack plugin migration backup; nothing was restored."; + +/** + * Parse and validate a manifest. Every entry must name a known target whose + * recorded path is exactly that target's path under the recorded root, its + * bytes must match the recorded digest, and those bytes must be a current or + * known earlier Hack render for the target; otherwise nothing is restored. + */ +async function readManifest(opts: { + readonly path: string; +}): Promise< + | { readonly ok: true; readonly entries: readonly ValidEntry[] } + | { readonly ok: false; readonly message: string } +> { + const invalid = { ok: false, message: INVALID_MANIFEST } as const; + const file = await readOptionalTextFile(opts.path); + if (file.kind !== "text") { + return invalid; + } + let parsed: unknown; + try { + parsed = JSON.parse(file.text); + } catch { + return invalid; + } + if ( + !isRecord(parsed) || + parsed.version !== MIGRATION_MANIFEST_VERSION || + parsed.kind !== "hack-plugin-migration" || + !Array.isArray(parsed.entries) + ) { + return invalid; + } + const entries: ValidEntry[] = []; + for (const raw of parsed.entries) { + const entry = validateManifestEntry(raw); + if (!entry) { + return invalid; + } + entries.push(entry); + } + return { ok: true, entries }; +} + +function validateManifestEntry(raw: unknown): ValidEntry | null { + if (!isRecord(raw)) { + return null; + } + const { + scope, + root, + target, + path, + sha256: digest, + mode, + contentBase64, + } = raw; + if ( + (scope !== "project" && scope !== "global") || + typeof root !== "string" || + !isAbsolute(root) || + typeof target !== "string" || + typeof path !== "string" || + typeof digest !== "string" || + typeof mode !== "number" || + !Number.isInteger(mode) || + mode < 0 || + mode > 0o7777 || + typeof contentBase64 !== "string" + ) { + return null; + } + const owned = listOwnedFileTargets(scope).find( + (file) => file.target === target + ); + if (!owned || resolve(root, owned.relativePath) !== path) { + return null; + } + const bytes = Buffer.from(contentBase64, "base64"); + if (sha256(bytes) !== digest) { + return null; + } + // Apply backs up only Hack-owned renders, so rollback restores only those. + const ownership = classifyIntegrationContent({ + surface: owned.surface, + content: bytes.toString("utf8"), + currentRender: owned.render(), + }); + if ( + ownership.kind !== "hack-owned-current" && + ownership.kind !== "hack-owned-prior" + ) { + return null; + } + return { scope, target: owned.target, path, bytes, sha256: digest, mode }; +} + +async function restoreEntry(opts: { + readonly entry: ValidEntry; +}): Promise> { + const { entry } = opts; + try { + const existing = await inspectRestorePath({ entry }); + if (existing === "identical") { + return { status: "already-present" }; + } + if (existing === "other") { + return { status: "conflict", message: CONFLICT_MESSAGE }; + } + await mkdir(dirname(entry.path), { recursive: true }); + // Exclusive create: never replace a file that appeared since the check. + await writeFile(entry.path, entry.bytes, { flag: "wx", mode: entry.mode }); + await chmod(entry.path, entry.mode); + return { status: "restored" }; + } catch (error: unknown) { + if (errorCode(error) === "EEXIST") { + return { status: "conflict", message: CONFLICT_MESSAGE }; + } + return { + status: "error", + message: "The backup could not be restored at this path.", + }; + } +} + +/** What occupies a restore path. Symlinks and directories are `other`. */ +async function inspectRestorePath(opts: { + readonly entry: ValidEntry; +}): Promise<"absent" | "identical" | "other"> { + let isFile: boolean; + try { + isFile = (await lstat(opts.entry.path)).isFile(); + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return "absent"; + } + throw error; + } + if (!isFile) { + return "other"; + } + const bytes = await readFile(opts.entry.path); + return sha256(bytes) === opts.entry.sha256 ? "identical" : "other"; +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return; + } + const { code } = error; + return typeof code === "string" ? code : undefined; +} diff --git a/src/agents/plugin-replacement.ts b/src/agents/plugin-replacement.ts new file mode 100644 index 000000000..59d060cfd --- /dev/null +++ b/src/agents/plugin-replacement.ts @@ -0,0 +1,256 @@ +import { isAbsolute, join } from "node:path"; + +import { readOptionalTextFile } from "../lib/fs.ts"; +import { isRecord } from "../lib/guards.ts"; +import type { SetupSyncScope } from "./setup-sync.ts"; + +/** + * Replacement verification for `hack setup migrate` (HACK-1132): whether a + * client's configuration enables the `hack` plugin for a scope. + * + * This reads configuration only; it never starts a client. Claude Code must + * enable `hack@hack-dance` in the nearest settings file that applies to the + * scope (project `settings.local.json`, project `settings.json`, then user + * `settings.json`) and list an installed copy carrying the replacement + * skills. Codex must set `enabled = true` for the plugin in the nearest + * `config.toml` (project, then user). A global-scope check reads user + * configuration only. Cursor installs are not inspectable. Results are closed + * statuses; file content and parser output are never surfaced. + */ + +/** Clients whose standalone files the plugin replaces. */ +export const MIGRATION_CLIENTS = ["claude", "codex", "cursor"] as const; +export type MigrationClient = (typeof MIGRATION_CLIENTS)[number]; + +/** Plugin identifier the clients record when the `hack` plugin is installed. */ +export const HACK_PLUGIN_ID = "hack@hack-dance"; + +/** Skills an installed Claude Code copy must carry to replace the targets. */ +const REQUIRED_PLUGIN_SKILLS: readonly string[] = ["hack-cli", "hack-init"]; + +export type PluginCheckStatus = + | "verified" + | "skipped" + | "not-installed" + | "disabled" + | "incomplete" + | "unreadable" + | "not-inspectable"; + +/** Check one client's replacement plugin for one scope. */ +export async function checkPluginReplacement(opts: { + readonly client: MigrationClient; + readonly scope: SetupSyncScope; + readonly root: string; + readonly home: string; +}): Promise { + switch (opts.client) { + case "claude": + return await checkClaudePlugin(opts); + case "codex": + return await checkCodexPlugin(opts); + default: + return "not-inspectable"; + } +} + +type EnabledLookup = + | { readonly kind: "unset" } + | { readonly kind: "unreadable" } + | { readonly kind: "set"; readonly enabled: boolean }; + +/** + * Claude Code: settings precedence is project local, project shared, user. + * The nearest file that sets the plugin decides. An enabled plugin must also + * appear in the user's plugin registry with an install that carries every + * replacement skill. + */ +async function checkClaudePlugin(opts: { + readonly scope: SetupSyncScope; + readonly root: string; + readonly home: string; +}): Promise { + const settingsFiles = [ + ...(opts.scope === "project" + ? [ + join(opts.root, ".claude", "settings.local.json"), + join(opts.root, ".claude", "settings.json"), + ] + : []), + join(opts.home, ".claude", "settings.json"), + ]; + const enabled = await firstSetting({ + paths: settingsFiles, + read: readClaudeEnabled, + }); + if (enabled !== "verified") { + return enabled; + } + return await checkClaudeInstalledCopy({ home: opts.home }); +} + +async function firstSetting(opts: { + readonly paths: readonly string[]; + readonly read: (path: string) => Promise; +}): Promise { + for (const path of opts.paths) { + const lookup = await opts.read(path); + if (lookup.kind === "unreadable") { + return "unreadable"; + } + if (lookup.kind === "set") { + return lookup.enabled ? "verified" : "disabled"; + } + } + return "not-installed"; +} + +async function readJsonRecord( + path: string +): Promise< + | { readonly kind: "absent" } + | { readonly kind: "unreadable" } + | { readonly kind: "ok"; readonly value: Record } +> { + const file = await readOptionalTextFile(path); + if (file.kind === "absent") { + return { kind: "absent" }; + } + if (file.kind === "unreadable") { + return { kind: "unreadable" }; + } + try { + const parsed: unknown = JSON.parse(file.text); + if (isRecord(parsed)) { + return { kind: "ok", value: parsed }; + } + } catch { + // Closed diagnostic below; parser output is never surfaced. + } + return { kind: "unreadable" }; +} + +async function readClaudeEnabled(path: string): Promise { + const file = await readJsonRecord(path); + if (file.kind === "absent") { + return { kind: "unset" }; + } + if (file.kind === "unreadable") { + return { kind: "unreadable" }; + } + const plugins = file.value.enabledPlugins; + if (plugins === undefined) { + return { kind: "unset" }; + } + if (!isRecord(plugins)) { + return { kind: "unreadable" }; + } + const value = plugins[HACK_PLUGIN_ID]; + if (value === undefined) { + return { kind: "unset" }; + } + return { kind: "set", enabled: value === true }; +} + +async function checkClaudeInstalledCopy(opts: { + readonly home: string; +}): Promise { + const registry = await readJsonRecord( + join(opts.home, ".claude", "plugins", "installed_plugins.json") + ); + if (registry.kind === "absent") { + return "not-installed"; + } + if (registry.kind === "unreadable") { + return "unreadable"; + } + const plugins = registry.value.plugins; + if (!isRecord(plugins)) { + return "unreadable"; + } + const raw = plugins[HACK_PLUGIN_ID]; + if (raw === undefined) { + return "not-installed"; + } + const installs = Array.isArray(raw) ? raw : [raw]; + const paths = installs + .map((install) => (isRecord(install) ? install.installPath : undefined)) + .filter( + (path): path is string => typeof path === "string" && isAbsolute(path) + ); + if (paths.length === 0) { + return "unreadable"; + } + for (const installPath of paths) { + if (await carriesReplacementSkills({ installPath })) { + return "verified"; + } + } + return "incomplete"; +} + +async function carriesReplacementSkills(opts: { + readonly installPath: string; +}): Promise { + for (const skill of REQUIRED_PLUGIN_SKILLS) { + const file = await readOptionalTextFile( + join(opts.installPath, "skills", skill, "SKILL.md") + ); + if (file.kind !== "text" || !file.text.includes(`name: ${skill}`)) { + return false; + } + } + return true; +} + +/** + * Codex: a project `.codex/config.toml` overrides the user one. The plugin + * counts only with `[plugins."hack@hack-dance"] enabled = true`. + */ +async function checkCodexPlugin(opts: { + readonly scope: SetupSyncScope; + readonly root: string; + readonly home: string; +}): Promise { + const configs = [ + ...(opts.scope === "project" + ? [join(opts.root, ".codex", "config.toml")] + : []), + join(opts.home, ".codex", "config.toml"), + ]; + return await firstSetting({ paths: configs, read: readCodexEnabled }); +} + +async function readCodexEnabled(path: string): Promise { + const file = await readOptionalTextFile(path); + if (file.kind === "absent") { + return { kind: "unset" }; + } + if (file.kind === "unreadable") { + return { kind: "unreadable" }; + } + let parsed: unknown; + try { + parsed = Bun.TOML.parse(file.text); + } catch { + return { kind: "unreadable" }; + } + if (!isRecord(parsed)) { + return { kind: "unreadable" }; + } + const plugins = parsed.plugins; + if (plugins === undefined) { + return { kind: "unset" }; + } + if (!isRecord(plugins)) { + return { kind: "unreadable" }; + } + const entry = plugins[HACK_PLUGIN_ID]; + if (entry === undefined) { + return { kind: "unset" }; + } + if (!isRecord(entry)) { + return { kind: "unreadable" }; + } + return { kind: "set", enabled: entry.enabled === true }; +} diff --git a/src/agents/setup-sync.ts b/src/agents/setup-sync.ts index 5de2430bb..3eb988541 100644 --- a/src/agents/setup-sync.ts +++ b/src/agents/setup-sync.ts @@ -108,7 +108,8 @@ export type SetupSyncTargetResult = { type Outcome = Omit; -type OwnedFileTarget = { +/** A standalone Hack-owned file target and the render that owns it. */ +export type OwnedFileTarget = { readonly target: SetupSyncTarget; readonly surface: IntegrationSurface; readonly relativePath: string; @@ -158,6 +159,17 @@ const GLOBAL_OWNED_FILES: readonly OwnedFileTarget[] = [ }, ]; +/** + * Standalone Hack-owned file targets for one scope, relative to the scope + * root (project root or home). Shared with the plugin migration engine so + * both act on the same paths and ownership surfaces. + */ +export function listOwnedFileTargets( + scope: SetupSyncScope +): readonly OwnedFileTarget[] { + return scope === "global" ? GLOBAL_OWNED_FILES : PROJECT_OWNED_FILES; +} + const CLAUDE_SETTINGS_PATHS: Readonly> = { project: ".claude/settings.local.json", global: ".claude/settings.json", @@ -214,8 +226,7 @@ export async function runSetupSyncScope(opts: { installMissing: opts.action === "sync" ? opts.installMissing : false, planInstall: opts.action === "check" && opts.installMissing, }; - const ownedFiles = - opts.scope === "global" ? GLOBAL_OWNED_FILES : PROJECT_OWNED_FILES; + const ownedFiles = listOwnedFileTargets(opts.scope); const results: SetupSyncTargetResult[] = []; for (const file of ownedFiles) { diff --git a/src/commands/setup-migrate.ts b/src/commands/setup-migrate.ts new file mode 100644 index 000000000..8a9c29724 --- /dev/null +++ b/src/commands/setup-migrate.ts @@ -0,0 +1,524 @@ +import { join, resolve } from "node:path"; + +import { + applyPluginMigration, + type MigrationApplyEntry, + type MigrationPlanEntry, + type MigrationRollbackEntry, + type MigrationScopeRoot, + type PluginCheck, + type PluginMigrationApplyResult, + type PluginMigrationPlan, + type PluginMigrationRollbackResult, + planPluginMigration, + rollbackPluginMigration, +} from "../agents/plugin-migration.ts"; +import { + MIGRATION_CLIENTS, + type MigrationClient, +} from "../agents/plugin-replacement.ts"; +import type { SetupSyncTarget } from "../agents/setup-sync.ts"; +import type { CliContext, CommandArgs } from "../cli/command.ts"; +import { CliUsageError, defineCommand, defineOption } from "../cli/command.ts"; +import { optJson, optPath } from "../cli/options.ts"; +import { emitCliResult, okResult } from "../lib/cli-result.ts"; +import { resolveGlobalHackDir } from "../lib/config-paths.ts"; +import { findRepoRootForInit } from "../lib/project.ts"; +import { type DisplayStatusItem, display } from "../ui/display.ts"; +import { logger } from "../ui/logger.ts"; + +/** + * `hack setup migrate`: preview (default, read-only), `--apply`, or + * `--rollback-manifest ` for moving from standalone Hack skill and rule + * files to the skills-only `hack` plugin. See `plugin-migration.ts` for the + * ownership, verification and backup contract. + */ + +const optApply = defineOption({ + name: "apply", + type: "boolean", + long: "--apply", + description: + "Remove the previewed Hack-owned standalone files after backing them up (default: read-only preview)", +} as const); + +const optRollbackManifest = defineOption({ + name: "rollbackManifest", + type: "string", + long: "--rollback-manifest", + valueHint: "", + description: + "Restore the exact files recorded in a migration backup manifest", +} as const); + +const optSkipPluginCheck = defineOption({ + name: "skipPluginCheck", + type: "string", + long: "--skip-plugin-check", + valueHint: "", + description: + "Treat the plugin as verified for these comma-separated clients (claude, codex, cursor, or all) after checking it loads yourself", +} as const); + +const optMigrateGlobal = defineOption({ + name: "global", + type: "boolean", + long: "--global", + description: "Migrate user-scope files under HOME instead of the project", +} as const); + +const optMigrateAllScopes = defineOption({ + name: "allScopes", + type: "boolean", + long: "--all-scopes", + description: "Migrate both project and user-scope files", +} as const); + +const optMigrateClaude = defineOption({ + name: "claude", + type: "boolean", + long: "--claude", + description: "Limit to Claude Code files (default: all clients)", +} as const); + +const optMigrateCodex = defineOption({ + name: "codex", + type: "boolean", + long: "--codex", + description: "Limit to Codex files (default: all clients)", +} as const); + +const optMigrateCursor = defineOption({ + name: "cursor", + type: "boolean", + long: "--cursor", + description: "Limit to Cursor files (default: all clients)", +} as const); + +const setupMigrateOptions = [ + optPath, + optMigrateGlobal, + optMigrateAllScopes, + optMigrateClaude, + optMigrateCodex, + optMigrateCursor, + optSkipPluginCheck, + optApply, + optRollbackManifest, + optJson, +] as const; + +type SetupMigrateArgs = CommandArgs; + +export const setupMigrateSpec = defineCommand({ + name: "migrate", + summary: + "Preview, apply or roll back moving standalone agent files to the hack plugin", + group: "Agents", + options: setupMigrateOptions, + positionals: [], + subcommands: [], +} as const); + +const TARGET_LABELS: Readonly>> = { + "cursor-rules": "Cursor rules", + "claude-hack-init-skill": "Claude hack-init skill", + "codex-hack-cli-skill": "Codex hack-cli skill", + "codex-hack-init-skill": "Codex hack-init skill", + "shared-hack-cli-skill": "Shared Hack skill", +}; + +const CLIENT_LABELS: Readonly> = { + claude: "Claude Code", + codex: "Codex", + cursor: "Cursor", +}; + +/** Directory that holds migration backup manifests. */ +export function resolveMigrationBackupDir(): string { + return join(resolveGlobalHackDir(), "agent-migrations"); +} + +/** Parse `--skip-plugin-check`; unknown client names are a usage error. */ +export function parseSkipPluginCheck( + value: string | undefined +): readonly MigrationClient[] { + if (value === undefined) { + return []; + } + const names = value + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + if (names.length === 0) { + throw new CliUsageError( + "--skip-plugin-check needs claude, codex, cursor or all." + ); + } + const out = new Set(); + for (const name of names) { + if (name === "all") { + for (const client of MIGRATION_CLIENTS) { + out.add(client); + } + continue; + } + const client = MIGRATION_CLIENTS.find((candidate) => candidate === name); + if (!client) { + throw new CliUsageError( + "--skip-plugin-check accepts only claude, codex, cursor or all." + ); + } + out.add(client); + } + return [...out]; +} + +function resolveClients(opts: { + readonly claude: boolean; + readonly codex: boolean; + readonly cursor: boolean; +}): readonly MigrationClient[] { + const selected = MIGRATION_CLIENTS.filter((client) => opts[client]); + return selected.length > 0 ? selected : MIGRATION_CLIENTS; +} + +export async function handleSetupMigrate({ + ctx, + args, +}: { + readonly ctx: CliContext; + readonly args: SetupMigrateArgs; +}): Promise { + const options = args.options; + const rollback = options.rollbackManifest?.trim(); + if (options.rollbackManifest !== undefined) { + if (!rollback) { + throw new CliUsageError("--rollback-manifest needs a manifest path."); + } + if ( + options.apply || + options.global || + options.allScopes || + options.claude || + options.codex || + options.cursor || + options.skipPluginCheck !== undefined || + options.path !== undefined + ) { + throw new CliUsageError( + "--rollback-manifest takes only the manifest path (and --json); the manifest records the scopes and files." + ); + } + const result = await rollbackPluginMigration({ + manifestPath: resolve(ctx.cwd, rollback), + }); + return await reportRollback({ result, json: options.json === true }); + } + + const home = (process.env.HOME ?? "").trim(); + const scopes: MigrationScopeRoot[] = []; + if (!options.global || options.allScopes) { + const startDir = resolve(ctx.cwd, options.path ?? "."); + scopes.push({ + scope: "project", + root: await findRepoRootForInit(startDir), + }); + } + if (options.global || options.allScopes) { + scopes.push({ scope: "global", root: home }); + } + const input = { + scopes, + home, + clients: resolveClients({ + claude: options.claude === true, + codex: options.codex === true, + cursor: options.cursor === true, + }), + skipPluginCheck: parseSkipPluginCheck(options.skipPluginCheck), + }; + + if (!options.apply) { + const plan = await planPluginMigration(input); + return await reportPreview({ plan, json: options.json === true }); + } + const result = await applyPluginMigration({ + ...input, + backupDir: resolveMigrationBackupDir(), + }); + return await reportApply({ result, json: options.json === true }); +} + +// --------------------------------------------------------------------------- +// Exit codes and JSON payloads +// --------------------------------------------------------------------------- + +/** Preview is read-only; it fails only when a scope could not be resolved. */ +export function migratePreviewExitCode(plan: PluginMigrationPlan): number { + return plan.entries.some((entry) => entry.action === "error") ? 1 : 0; +} + +/** Apply fails when refused or when any file could not be handled. */ +export function migrateApplyExitCode( + result: PluginMigrationApplyResult +): number { + if (result.status === "refused") { + return 1; + } + if (result.status === "nothing-to-migrate") { + return 0; + } + return result.results.some( + (entry) => entry.status === "error" || entry.status === "changed" + ) + ? 1 + : 0; +} + +/** Rollback fails on an invalid manifest, a conflict or an error. */ +export function migrateRollbackExitCode( + result: PluginMigrationRollbackResult +): number { + if (result.status === "invalid-manifest") { + return 1; + } + return result.results.some( + (entry) => entry.status === "conflict" || entry.status === "error" + ) + ? 1 + : 0; +} + +function countBy( + values: readonly T[] +): Partial> { + const counts: Partial> = {}; + for (const value of values) { + counts[value] = (counts[value] ?? 0) + 1; + } + return counts; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +function label(target: SetupSyncTarget): string { + return TARGET_LABELS[target] ?? target; +} + +function scopeLabel(scope: "project" | "global"): string { + return scope === "project" ? "Project" : "Global"; +} + +function describePlanEntry(entry: MigrationPlanEntry): string { + const at = entry.path ? ` at ${entry.path}` : ""; + switch (entry.action) { + case "remove": + return `${label(entry.target)}: would remove${at} (replaced by the hack plugin)`; + case "blocked": + return `${label(entry.target)}: blocked${at}. ${entry.message ?? ""}`.trim(); + case "preserve": + return `${label(entry.target)}: preserved${at}. ${entry.message ?? ""}`.trim(); + case "error": + return `${label(entry.target)}: ${entry.message ?? "error"}`; + default: + return `${label(entry.target)}: absent${at}`; + } +} + +function describeCheck(check: PluginCheck): string { + return `${CLIENT_LABELS[check.client]} plugin: ${check.status}. ${check.message}`; +} + +function planItems(plan: PluginMigrationPlan): DisplayStatusItem[] { + const scopes = [...new Set(plan.entries.map((entry) => entry.scope))]; + return scopes.map((scope) => { + const entries = plan.entries.filter((entry) => entry.scope === scope); + const present = entries.filter((entry) => entry.action !== "absent"); + // Human output names only the checks that decide a present file. + const relevant = new Set( + present.flatMap((entry) => + entry.replacement === "shared" + ? ["claude", "codex"] + : [entry.replacement] + ) + ); + const checks = plan.checks.filter( + (check) => check.scope === scope && relevant.has(check.client) + ); + const counts = countBy(entries.map((entry) => entry.action)); + let status: DisplayStatusItem["status"] = "ok"; + if ((counts.error ?? 0) > 0) { + status = "error"; + } else if ((counts.blocked ?? 0) > 0 || (counts.preserve ?? 0) > 0) { + status = "warn"; + } + const lines = [ + ...checks.map(describeCheck), + ...present.map(describePlanEntry), + ]; + return { + label: scopeLabel(scope), + status, + meta: `${counts.remove ?? 0} to remove, ${counts.blocked ?? 0} blocked, ${counts.preserve ?? 0} preserved`, + detail: lines.length > 0 ? lines.join("\n") : undefined, + }; + }); +} + +async function reportPreview(opts: { + readonly plan: PluginMigrationPlan; + readonly json: boolean; +}): Promise { + const exitCode = migratePreviewExitCode(opts.plan); + if (opts.json) { + emitCliResult({ + result: okResult({ + data: { + action: "preview", + dryRun: true, + counts: countBy(opts.plan.entries.map((entry) => entry.action)), + plugins: opts.plan.checks, + targets: opts.plan.entries, + }, + }), + }); + return exitCode; + } + const blocked = opts.plan.entries.some((entry) => entry.action === "blocked"); + await display.statusList({ + title: "Plugin migration preview (nothing changed)", + items: planItems(opts.plan), + }); + logger.info({ + message: blocked + ? "Install the hack plugin for the blocked clients and verify it loads in a fresh session (or pass --skip-plugin-check after checking it yourself), then rerun with --apply." + : "Rerun with --apply to back up and remove the files marked 'would remove'.", + }); + return exitCode; +} + +function describeApplyEntry(entry: MigrationApplyEntry): string { + const message = entry.message ? ` ${entry.message}` : ""; + return `${scopeLabel(entry.scope)} ${label(entry.target)}: ${entry.status} at ${entry.path}.${message}`; +} + +const REFUSAL_MESSAGES: Readonly< + Record< + Extract["reason"], + string + > +> = { + blocked: + "Migration refused; nothing was changed. The replacement plugin is not verified for at least one client with Hack-owned files.", + "scope-error": + "Migration refused; nothing was changed. A scope root could not be resolved.", + "backup-failed": + "Migration refused; nothing was changed. A file could not be read for backup.", +}; + +async function reportApply(opts: { + readonly result: PluginMigrationApplyResult; + readonly json: boolean; +}): Promise { + const { result } = opts; + const exitCode = migrateApplyExitCode(result); + if (opts.json) { + emitCliResult({ + result: okResult({ + data: { + action: "apply", + dryRun: false, + status: result.status, + ...(result.status === "refused" ? { reason: result.reason } : {}), + manifestPath: + result.status === "applied" ? result.manifestPath : null, + plugins: result.plan.checks, + targets: result.plan.entries, + results: result.status === "applied" ? result.results : [], + }, + }), + }); + return exitCode; + } + if (result.status === "refused") { + await display.statusList({ + title: "Plugin migration refused", + items: planItems(result.plan), + }); + logger.error({ message: REFUSAL_MESSAGES[result.reason] }); + return exitCode; + } + if (result.status === "nothing-to-migrate") { + await display.statusList({ + title: "Plugin migration: nothing to remove", + items: planItems(result.plan), + }); + return exitCode; + } + const counts = countBy(result.results.map((entry) => entry.status)); + await display.statusList({ + title: "Plugin migration applied", + items: [ + { + label: "Removed", + status: exitCode === 0 ? "ok" : "error", + meta: `${counts.removed ?? 0} removed`, + detail: result.results.map(describeApplyEntry).join("\n"), + }, + ], + }); + logger.info({ message: `Backup manifest: ${result.manifestPath}` }); + logger.info({ + message: `Undo with: hack setup migrate --rollback-manifest ${result.manifestPath}`, + }); + return exitCode; +} + +function describeRollbackEntry(entry: MigrationRollbackEntry): string { + const message = entry.message ? ` ${entry.message}` : ""; + return `${scopeLabel(entry.scope)} ${label(entry.target)}: ${entry.status} at ${entry.path}.${message}`; +} + +async function reportRollback(opts: { + readonly result: PluginMigrationRollbackResult; + readonly json: boolean; +}): Promise { + const { result } = opts; + const exitCode = migrateRollbackExitCode(result); + if (opts.json) { + emitCliResult({ + result: okResult({ + data: { + action: "rollback", + dryRun: false, + status: result.status, + ...(result.status === "invalid-manifest" + ? { message: result.message } + : {}), + results: result.status === "rolled-back" ? result.results : [], + }, + }), + }); + return exitCode; + } + if (result.status === "invalid-manifest") { + logger.error({ message: result.message }); + return exitCode; + } + const counts = countBy(result.results.map((entry) => entry.status)); + await display.statusList({ + title: "Plugin migration rolled back", + items: [ + { + label: "Restored", + status: exitCode === 0 ? "ok" : "error", + meta: `${counts.restored ?? 0} restored, ${counts["already-present"] ?? 0} already present, ${counts.conflict ?? 0} conflicts`, + detail: result.results.map(describeRollbackEntry).join("\n"), + }, + ], + }); + return exitCode; +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 2711f94f5..62a67b92e 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -57,6 +57,7 @@ import { } from "../mcp/install.ts"; import { type DisplayStatusItem, display } from "../ui/display.ts"; import { logger } from "../ui/logger.ts"; +import { handleSetupMigrate, setupMigrateSpec } from "./setup-migrate.ts"; const optCheck = defineOption({ name: "check", @@ -253,6 +254,7 @@ export const setupCommand = defineCommand({ withHandler(codexSpec, handleSetupCodex), withHandler(agentsSpec, handleSetupAgents), withHandler(syncSpec, handleSetupSync), + withHandler(setupMigrateSpec, handleSetupMigrate), withHandler(mcpSpec, handleSetupMcp), ], } as const); diff --git a/tests/cli-command.test.ts b/tests/cli-command.test.ts index e63a10ae4..d3eb7c4c4 100644 --- a/tests/cli-command.test.ts +++ b/tests/cli-command.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { + type AnyCommandSpec, + builtinOptions, collectAllowedOptionNames, + type OptionSpec, + parseCliArgv, parseOptionsForCommand, parsePositionalsForCommand, resolveCommand, @@ -86,6 +90,71 @@ test("resolveCommand exposes host exec path", () => { expect(resolved.remainingPositionals).toEqual(["bun", "test"]); }); +/** + * argv is parsed once against the union of every command's options, keyed by + * long name, so one long name declared as a boolean in one command and as a + * value in another changes how every command parses it. A value-typed + * `setup migrate --rollback` once made `config adopt --rollback --json` consume + * `--json` as its value and fail with a usage error. + */ +test("each long option keeps one value type across the command tree", () => { + const declarations = new Map>(); + const record = (opts: { + readonly owner: string; + readonly options: readonly OptionSpec[]; + }) => { + for (const option of opts.options) { + const kind = option.type === "boolean" ? "boolean" : "value"; + const owners = declarations.get(option.long) ?? new Set(); + owners.add(`${kind} (${opts.owner})`); + declarations.set(option.long, owners); + } + }; + record({ owner: "builtin", options: builtinOptions() }); + record({ owner: "global", options: CLI_SPEC.globalOptions }); + const walk = ( + commands: readonly AnyCommandSpec[], + parent: readonly string[] + ) => { + for (const command of commands) { + const path = [...parent, command.name]; + record({ owner: path.join(" "), options: command.options }); + walk(command.subcommands, path); + } + }; + walk(CLI_SPEC.commands, []); + + const conflicts = [...declarations] + .filter( + ([, owners]) => + new Set([...owners].map((owner) => owner.split(" ")[0])).size > 1 + ) + .map(([long, owners]) => `${long}: ${[...owners].sort().join(", ")}`); + expect(conflicts).toEqual([]); +}); + +test("boolean config adopt --rollback does not consume the next flag", () => { + const adopt = parseCliArgv(CLI_SPEC, [ + "config", + "adopt", + "--rollback", + "--json", + ]); + expect(adopt.values.rollback).toBe(true); + expect(adopt.values.json).toBe(true); + expect(adopt.positionals).toEqual(["config", "adopt"]); + + const migrate = parseCliArgv(CLI_SPEC, [ + "setup", + "migrate", + "--rollback-manifest", + "manifest.json", + "--json", + ]); + expect(migrate.values["rollback-manifest"]).toBe("manifest.json"); + expect(migrate.values.json).toBe(true); +}); + test("dispatch rejects retired GitHub PR automation flags as unknown", async () => { const result = await runCliWithCapturedOutput([ "dispatch", diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 6ae45edea..8d5360a55 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -494,9 +494,12 @@ sentinel client config (`~/.claude/settings.json`, `~/.claude.json`, `.claude/settings.local.json` and `.mcp.json` into two fixtures. It then runs `init --auto`, `init --auto --with codex`, `agent prime`, `agent prime --check`, `agent onboard`, `agent patterns`, `doctor --json`, -`doctor --fix --no-interactive`, `setup sync --check`, and refresh-only -`setup sync --all-scopes` twice each (`setup sync --check` exits 1 because the -customized files are reported as preserved). After +`doctor --fix --no-interactive`, `setup sync --check`, refresh-only +`setup sync --all-scopes`, the read-only `setup migrate --all-scopes` preview, +and `setup migrate --all-scopes --apply --skip-plugin-check all` twice each +(`setup sync --check` exits 1 because the customized files are reported as +preserved; the migration apply exits 0 and removes nothing because no seeded +file is Hack-owned). After every run it requires byte-identical hashes for those trees, no new entries under them, and `git status` changes only under `.hack/`. Each command must also exit with its expected code, so a usage error cannot pass as "no write". diff --git a/tests/e2e/scenarios/agent-surface-freeze.ts b/tests/e2e/scenarios/agent-surface-freeze.ts index 06057ed51..179cffaef 100644 --- a/tests/e2e/scenarios/agent-surface-freeze.ts +++ b/tests/e2e/scenarios/agent-surface-freeze.ts @@ -45,7 +45,9 @@ type FreezeCommand = { * because the customized guidance is reported as preserved; it must not * repair it. Refresh-only `setup sync --all-scopes` (HACK-1132) exits 0: every * seeded file is customized or lacks a Hack section, so nothing is refreshed - * and nothing missing is created. + * and nothing missing is created. The plugin migration preview is read-only, + * and an explicit apply with every replacement check skipped still removes + * nothing, because no seeded file is Hack-owned (HACK-1132). */ const FREEZE_COMMANDS: readonly FreezeCommand[] = [ { @@ -114,6 +116,25 @@ const FREEZE_COMMANDS: readonly FreezeCommand[] = [ args: ["setup", "sync", "--all-scopes"], codes: [[0], [0]], }, + { + label: "setup migrate --all-scopes", + fixture: "project", + args: ["setup", "migrate", "--all-scopes"], + codes: [[0], [0]], + }, + { + label: "setup migrate --all-scopes --apply --skip-plugin-check all", + fixture: "project", + args: [ + "setup", + "migrate", + "--all-scopes", + "--apply", + "--skip-plugin-check", + "all", + ], + codes: [[0], [0]], + }, ]; type FreezeState = { diff --git a/tests/plugin-migration.test.ts b/tests/plugin-migration.test.ts new file mode 100644 index 000000000..bfd487423 --- /dev/null +++ b/tests/plugin-migration.test.ts @@ -0,0 +1,557 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { renderCodexSkill } from "../src/agents/codex-skill.ts"; +import { renderCursorRules } from "../src/agents/cursor.ts"; +import { renderHackInitSkill } from "../src/agents/hack-init-skill.ts"; +import { + applyPluginMigration, + type PluginMigrationInput, + planPluginMigration, + rollbackPluginMigration, +} from "../src/agents/plugin-migration.ts"; +import { + HACK_PLUGIN_ID, + type MigrationClient, +} from "../src/agents/plugin-replacement.ts"; + +/** + * Plugin migration engine (HACK-1132). Every path is under a per-test temp + * root: a fixture project and a fixture HOME passed explicitly. The process + * HOME is never read or written. + */ + +let root = ""; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-plugin-migration-")); + await mkdir(join(root, "home"), { recursive: true }); + await mkdir(join(root, "repo"), { recursive: true }); +}); + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }); + root = ""; + } +}); + +const repo = (): string => join(root, "repo"); +const home = (): string => join(root, "home"); +const backupDir = (): string => join(root, "hack-home", "agent-migrations"); + +const CUSTOM_SKILL = + "---\nname: hack-init\n---\nTeam-edited hack-init skill. Keep me.\n"; +const UNKNOWN_RULE = "Our own cursor rule without any Hack marker.\n"; + +async function put(path: string, content: string, mode = 0o644) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); + await chmod(path, mode); +} + +/** Hack-owned current renders plus customized and unknown neighbours. */ +async function seedProject(): Promise { + await put( + join(repo(), ".codex/skills/hack-cli/SKILL.md"), + renderCodexSkill(), + 0o640 + ); + await put(join(repo(), ".codex/skills/hack-init/SKILL.md"), CUSTOM_SKILL); + await put( + join(repo(), ".claude/skills/hack-init/SKILL.md"), + renderHackInitSkill() + ); + await put(join(repo(), ".cursor/rules/hack.mdc"), UNKNOWN_RULE); +} + +async function enableClaude(opts?: { + readonly skills?: readonly string[]; + readonly settingsRoot?: string; + readonly settingsFile?: string; + readonly enabled?: boolean; +}): Promise { + const installPath = join(root, "claude-plugin-copy"); + for (const skill of opts?.skills ?? ["hack-cli", "hack-init"]) { + await put( + join(installPath, "skills", skill, "SKILL.md"), + `---\nname: ${skill}\n---\n` + ); + } + await put( + join(home(), ".claude/plugins/installed_plugins.json"), + JSON.stringify({ + version: 2, + plugins: { [HACK_PLUGIN_ID]: [{ scope: "user", installPath }] }, + }) + ); + await put( + join( + opts?.settingsRoot ?? home(), + ".claude", + opts?.settingsFile ?? "settings.json" + ), + JSON.stringify({ + enabledPlugins: { [HACK_PLUGIN_ID]: opts?.enabled ?? true }, + }) + ); +} + +async function enableCodex(opts?: { + readonly configRoot?: string; + readonly body?: string; +}): Promise { + await put( + join(opts?.configRoot ?? home(), ".codex/config.toml"), + opts?.body ?? + `model = "x"\n\n[plugins."${HACK_PLUGIN_ID}"]\nenabled = true\n` + ); +} + +function input( + overrides: Partial = {} +): PluginMigrationInput { + return { + scopes: [{ scope: "project", root: repo() }], + home: home(), + clients: ["claude", "codex", "cursor"], + skipPluginCheck: [], + ...overrides, + }; +} + +/** Path to sha/mode for every file and symlink under the fixture root. */ +async function snapshotTree(): Promise> { + const out = new Map(); + async function visit(path: string): Promise { + const info = await lstat(path); + if (info.isDirectory()) { + for (const name of await readdir(path)) { + await visit(join(path, name)); + } + return; + } + const body = info.isSymbolicLink() + ? "symlink" + : new Bun.CryptoHasher("sha256") + .update(await readFile(path)) + .digest("hex"); + out.set(path, `${body}:${(info.mode & 0o777).toString(8)}`); + } + await visit(root); + return out; +} + +function actions( + plan: Awaited> +): Record { + return Object.fromEntries( + plan.entries.map((entry) => [ + `${entry.scope}:${entry.target}`, + entry.reason ? `${entry.action}:${entry.reason}` : entry.action, + ]) + ); +} + +function checks( + plan: Awaited> +): Record { + return Object.fromEntries( + plan.checks.map((check) => [`${check.scope}:${check.client}`, check.status]) + ); +} + +test("preview without a plugin blocks owned files, preserves custom ones and writes nothing", async () => { + await seedProject(); + const before = await snapshotTree(); + + const plan = await planPluginMigration(input()); + + expect(checks(plan)).toEqual({ + "project:claude": "not-installed", + "project:codex": "not-installed", + "project:cursor": "not-inspectable", + }); + expect(actions(plan)).toEqual({ + "project:cursor-rules": "preserve:missing-ownership-marker", + "project:claude-hack-init-skill": "blocked", + "project:codex-hack-cli-skill": "blocked", + "project:codex-hack-init-skill": "preserve:customized-content", + }); + for (const entry of plan.entries) { + expect(entry.message ?? "").not.toContain("Team-edited"); + } + expect(await snapshotTree()).toEqual(before); +}); + +test("apply refuses without writing when any owned file lacks a verified replacement", async () => { + await seedProject(); + await enableClaude(); + const before = await snapshotTree(); + + const result = await applyPluginMigration({ + ...input(), + backupDir: backupDir(), + }); + + expect(result.status).toBe("refused"); + expect(result.status === "refused" && result.reason).toBe("blocked"); + expect(actions(result.plan)["project:codex-hack-cli-skill"]).toBe("blocked"); + expect(actions(result.plan)["project:claude-hack-init-skill"]).toBe("remove"); + expect(await snapshotTree()).toEqual(before); +}); + +test("verified apply backs up and removes only owned files; rollback restores exact bytes and mode", async () => { + await seedProject(); + await enableClaude(); + await enableCodex(); + const before = await snapshotTree(); + const codexSkill = join(repo(), ".codex/skills/hack-cli/SKILL.md"); + const claudeSkill = join(repo(), ".claude/skills/hack-init/SKILL.md"); + + const result = await applyPluginMigration({ + ...input(), + backupDir: backupDir(), + hooks: { id: "fixture" }, + }); + + expect(result.status).toBe("applied"); + if (result.status !== "applied") { + return; + } + expect( + result.results.map((entry) => `${entry.target}:${entry.status}`).sort() + ).toEqual(["claude-hack-init-skill:removed", "codex-hack-cli-skill:removed"]); + // The removed skill directories were left empty, so they are gone too. + await expect(stat(dirname(codexSkill))).rejects.toThrow(); + await expect(stat(dirname(claudeSkill))).rejects.toThrow(); + // Customized and unknown neighbours are byte-identical. + for (const kept of [ + join(repo(), ".codex/skills/hack-init/SKILL.md"), + join(repo(), ".cursor/rules/hack.mdc"), + ]) { + expect((await snapshotTree()).get(kept)).toBe(before.get(kept)); + } + expect(result.manifestPath).toBe(join(backupDir(), "fixture.json")); + expect((await stat(result.manifestPath)).mode & 0o777).toBe(0o600); + const manifest = JSON.parse(await readFile(result.manifestPath, "utf8")); + expect(manifest.state).toBe("applied"); + expect(manifest.entries).toHaveLength(2); + + const rollback = await rollbackPluginMigration({ + manifestPath: result.manifestPath, + }); + expect(rollback.status).toBe("rolled-back"); + if (rollback.status !== "rolled-back") { + return; + } + expect(rollback.results.map((entry) => entry.status)).toEqual([ + "restored", + "restored", + ]); + const after = await snapshotTree(); + after.delete(result.manifestPath); + expect(after).toEqual(before); + + const again = await rollbackPluginMigration({ + manifestPath: result.manifestPath, + }); + expect( + again.status === "rolled-back" && + again.results.every((entry) => entry.status === "already-present") + ).toBe(true); +}); + +test("an installed Claude copy without the replacement skills is not a verified replacement", async () => { + await seedProject(); + await enableClaude({ skills: ["hack-cli"] }); + + const plan = await planPluginMigration(input({ clients: ["claude"] })); + + expect(checks(plan)["project:claude"]).toBe("incomplete"); + expect(actions(plan)["project:claude-hack-init-skill"]).toBe("blocked"); +}); + +test("nearest Claude settings decide: a project-local disable overrides a user enable", async () => { + await seedProject(); + await enableClaude(); + await put( + join(repo(), ".claude/settings.local.json"), + JSON.stringify({ enabledPlugins: { [HACK_PLUGIN_ID]: false } }) + ); + + const plan = await planPluginMigration(input({ clients: ["claude"] })); + + expect(checks(plan)["project:claude"]).toBe("disabled"); + expect(actions(plan)["project:claude-hack-init-skill"]).toBe("blocked"); +}); + +test("Codex must enable the plugin explicitly; disabled and malformed configs block", async () => { + await seedProject(); + const codex = { clients: ["codex"] as MigrationClient[] }; + + await enableCodex({ + body: `[plugins."${HACK_PLUGIN_ID}"]\nenabled = false\n`, + }); + expect(checks(await planPluginMigration(input(codex)))["project:codex"]).toBe( + "disabled" + ); + + await enableCodex({ body: "[plugins\nnot toml" }); + const malformed = await planPluginMigration(input(codex)); + expect(checks(malformed)["project:codex"]).toBe("unreadable"); + expect(actions(malformed)["project:codex-hack-cli-skill"]).toBe("blocked"); + + await enableCodex({ body: '[plugins."other@market"]\nenabled = true\n' }); + expect(checks(await planPluginMigration(input(codex)))["project:codex"]).toBe( + "not-installed" + ); +}); + +test("project-scope plugin config does not verify user-scope duplicates", async () => { + await seedProject(); + await put( + join(home(), ".codex/skills/hack-cli/SKILL.md"), + renderCodexSkill() + ); + await put(join(home(), ".ai/skills/hack-cli/SKILL.md"), renderCodexSkill()); + await enableCodex({ configRoot: repo() }); + const scopes = [ + { scope: "project", root: repo() }, + { scope: "global", root: home() }, + ] as const; + + const plan = await planPluginMigration(input({ scopes, clients: ["codex"] })); + + expect(checks(plan)).toEqual({ + "project:codex": "verified", + "global:codex": "not-installed", + }); + expect(actions(plan)["project:codex-hack-cli-skill"]).toBe("remove"); + expect(actions(plan)["global:codex-hack-cli-skill"]).toBe("blocked"); + expect(actions(plan)["global:shared-hack-cli-skill"]).toBe("blocked"); + + const before = await snapshotTree(); + const result = await applyPluginMigration({ + ...input({ scopes, clients: ["codex"] }), + backupDir: backupDir(), + }); + expect(result.status).toBe("refused"); + expect(await snapshotTree()).toEqual(before); +}); + +test("an explicit skip satisfies Cursor, which Hack cannot inspect", async () => { + await put(join(repo(), ".cursor/rules/hack.mdc"), renderCursorRules()); + + const unskipped = await planPluginMigration(input({ clients: ["cursor"] })); + expect(actions(unskipped)).toEqual({ "project:cursor-rules": "blocked" }); + + const skipped = await planPluginMigration( + input({ clients: ["cursor"], skipPluginCheck: ["cursor"] }) + ); + expect(checks(skipped)).toEqual({ "project:cursor": "skipped" }); + expect(actions(skipped)).toEqual({ "project:cursor-rules": "remove" }); +}); + +test("client selection limits targets; shared skill follows Claude or Codex", async () => { + const plan = await planPluginMigration( + input({ scopes: [{ scope: "global", root: home() }], clients: ["cursor"] }) + ); + expect(Object.keys(actions(plan))).toEqual(["global:cursor-rules"]); + + const codexOnly = await planPluginMigration( + input({ scopes: [{ scope: "global", root: home() }], clients: ["codex"] }) + ); + expect(Object.keys(actions(codexOnly)).sort()).toEqual([ + "global:codex-hack-cli-skill", + "global:codex-hack-init-skill", + "global:shared-hack-cli-skill", + ]); +}); + +test("an owned file reached through a symlinked directory is preserved", async () => { + const real = join(root, "elsewhere", "hack-cli"); + await put(join(real, "SKILL.md"), renderCodexSkill()); + await mkdir(join(repo(), ".codex/skills"), { recursive: true }); + await symlink(real, join(repo(), ".codex/skills/hack-cli")); + await enableCodex(); + const before = await snapshotTree(); + + const result = await applyPluginMigration({ + ...input({ clients: ["codex"] }), + backupDir: backupDir(), + }); + + expect(actions(result.plan)["project:codex-hack-cli-skill"]).toBe( + "preserve:symlinked-path" + ); + expect(result.status).toBe("nothing-to-migrate"); + expect(await snapshotTree()).toEqual(before); +}); + +test("an interrupted apply leaves a manifest that rollback recovers", async () => { + await seedProject(); + await enableClaude(); + await enableCodex(); + const before = await snapshotTree(); + + await expect( + applyPluginMigration({ + ...input(), + backupDir: backupDir(), + hooks: { + id: "interrupted", + beforeRemove: ({ index }) => { + if (index === 1) { + return Promise.reject(new Error("simulated interruption")); + } + return Promise.resolve(); + }, + }, + }) + ).rejects.toThrow("simulated interruption"); + + const manifestPath = join(backupDir(), "interrupted.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + expect(manifest.state).toBe("applying"); + expect(manifest.entries).toHaveLength(2); + + const rollback = await rollbackPluginMigration({ manifestPath }); + expect( + rollback.status === "rolled-back" && + rollback.results.map((entry) => entry.status).sort() + ).toEqual(["already-present", "restored"]); + const after = await snapshotTree(); + after.delete(manifestPath); + expect(after).toEqual(before); +}); + +test("a file edited after backup is left in place and reported as changed", async () => { + await seedProject(); + await enableCodex(); + const target = join(repo(), ".codex/skills/hack-cli/SKILL.md"); + + const result = await applyPluginMigration({ + ...input({ clients: ["codex"] }), + backupDir: backupDir(), + hooks: { + beforeRemove: async () => { + await writeFile(target, `${renderCodexSkill()}\nlocal edit\n`); + }, + }, + }); + + expect(result.status === "applied" && result.results).toEqual([ + expect.objectContaining({ + target: "codex-hack-cli-skill", + status: "changed", + }), + ]); + expect(await readFile(target, "utf8")).toContain("local edit"); +}); + +test("rollback never overwrites a file that reappeared at a restore path", async () => { + await seedProject(); + await enableClaude(); + await enableCodex(); + const result = await applyPluginMigration({ + ...input(), + backupDir: backupDir(), + }); + if (result.status !== "applied") { + throw new Error("apply did not run"); + } + const target = join(repo(), ".codex/skills/hack-cli/SKILL.md"); + await put(target, "user replacement\n"); + + const rollback = await rollbackPluginMigration({ + manifestPath: result.manifestPath, + }); + + expect( + rollback.status === "rolled-back" && + Object.fromEntries( + rollback.results.map((entry) => [entry.target, entry.status]) + ) + ).toEqual({ + "claude-hack-init-skill": "restored", + "codex-hack-cli-skill": "conflict", + }); + expect(await readFile(target, "utf8")).toBe("user replacement\n"); +}); + +test("rollback refuses a tampered manifest and restores nothing", async () => { + await seedProject(); + await enableClaude(); + await enableCodex(); + const result = await applyPluginMigration({ + ...input(), + backupDir: backupDir(), + }); + if (result.status !== "applied") { + throw new Error("apply did not run"); + } + const original = JSON.parse(await readFile(result.manifestPath, "utf8")); + let count = 0; + const tamper = async (mutate: (entry: Record) => void) => { + const copy = structuredClone(original); + mutate(copy.entries[0]); + count += 1; + const path = join(root, `tampered-${count}.json`); + await writeFile(path, JSON.stringify(copy)); + return path; + }; + const cases = [ + await tamper((entry) => { + entry.path = join(root, "outside", "SKILL.md"); + }), + await tamper((entry) => { + entry.contentBase64 = Buffer.from("other bytes").toString("base64"); + }), + await tamper((entry) => { + entry.target = "agents-md"; + }), + // Hack-owned bytes whose recorded digest does not match. + await tamper((entry) => { + entry.sha256 = "0".repeat(64); + }), + // Consistent digest, but the bytes are not a Hack render. + await tamper((entry) => { + const bytes = Buffer.from("---\nname: hack-cli\n---\nnot Hack's\n"); + entry.contentBase64 = bytes.toString("base64"); + entry.sha256 = new Bun.CryptoHasher("sha256").update(bytes).digest("hex"); + }), + ]; + const before = await snapshotTree(); + + for (const manifestPath of cases) { + const rollback = await rollbackPluginMigration({ manifestPath }); + expect(rollback.status).toBe("invalid-manifest"); + } + expect( + (await rollbackPluginMigration({ manifestPath: join(root, "absent.json") })) + .status + ).toBe("invalid-manifest"); + expect(await snapshotTree()).toEqual(before); +}); + +test("an unresolved scope root refuses apply", async () => { + const result = await applyPluginMigration({ + ...input({ scopes: [{ scope: "global", root: " " }] }), + backupDir: backupDir(), + }); + expect(result.status === "refused" && result.reason).toBe("scope-error"); +}); diff --git a/tests/setup-migrate-command.test.ts b/tests/setup-migrate-command.test.ts new file mode 100644 index 000000000..500b8bd03 --- /dev/null +++ b/tests/setup-migrate-command.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { renderCodexSkill } from "../src/agents/codex-skill.ts"; +import { renderCursorRules } from "../src/agents/cursor.ts"; +import { CliUsageError } from "../src/cli/command.ts"; +import { parseSkipPluginCheck } from "../src/commands/setup-migrate.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * `hack setup migrate` through the source CLI. HOME and HACK_HOME are private + * fixture directories; the contributor's HOME is never read or written. + */ + +const CLI = resolve(import.meta.dir, "..", "index.ts"); +/** Each test spawns the source CLI; a cold start under suite load exceeds 5 s. */ +const CLI_TEST_TIMEOUT_MS = 60_000; + +let root = ""; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-setup-migrate-")); + await mkdir(join(root, "repo", ".git"), { recursive: true }); +}); + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }); + root = ""; + } +}); + +const repo = (): string => join(root, "repo"); +const hackHome = (): string => join(root, "hack-home"); + +async function put(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); +} + +async function runCli(args: readonly string[]): Promise<{ + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}> { + const child = Bun.spawn( + [process.execPath, CLI, "setup", "migrate", ...args], + { + cwd: repo(), + env: privateCliEnv({ + root, + env: { HACK_HOME: hackHome(), NO_COLOR: "1" }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stdout, stderr }; +} + +async function snapshotRepo(): Promise> { + const out = new Map(); + async function visit(path: string): Promise { + const info = await lstat(path); + if (info.isDirectory()) { + for (const name of await readdir(path)) { + await visit(join(path, name)); + } + return; + } + out.set(path, await readFile(path, "base64")); + } + await visit(repo()); + return out; +} + +async function seed(): Promise<{ + readonly owned: string; + readonly rule: string; +}> { + const owned = join(repo(), ".codex/skills/hack-cli/SKILL.md"); + const rule = join(repo(), ".cursor/rules/hack.mdc"); + await put(owned, renderCodexSkill()); + await put(rule, `${renderCursorRules()}\nteam addition\n`); + return { owned, rule }; +} + +function data(stdout: string): Record { + const parsed: unknown = JSON.parse(stdout); + if ( + typeof parsed !== "object" || + parsed === null || + !("ok" in parsed) || + parsed.ok !== true || + !("data" in parsed) || + typeof parsed.data !== "object" || + parsed.data === null + ) { + throw new Error("unexpected envelope"); + } + return parsed.data as Record; +} + +test( + "preview is read-only, apply refuses unverified replacements, and rollback restores", + async () => { + const { owned, rule } = await seed(); + const before = await snapshotRepo(); + + const preview = await runCli(["--json"]); + expect(preview.exitCode).toBe(0); + const plan = data(preview.stdout); + expect(plan.action).toBe("preview"); + expect(plan.dryRun).toBe(true); + expect(plan.counts).toEqual({ preserve: 1, blocked: 1, absent: 2 }); + expect(await snapshotRepo()).toEqual(before); + + const refused = await runCli(["--apply"]); + expect(refused.exitCode).toBe(1); + expect(`${refused.stdout}${refused.stderr}`).toContain( + "Migration refused; nothing was changed." + ); + expect(await snapshotRepo()).toEqual(before); + await expect(lstat(join(hackHome(), "agent-migrations"))).rejects.toThrow(); + + const applied = await runCli([ + "--apply", + "--skip-plugin-check", + "codex,cursor", + "--json", + ]); + expect(applied.exitCode).toBe(0); + const result = data(applied.stdout); + expect(result.status).toBe("applied"); + const manifestPath = String(result.manifestPath); + expect(dirname(manifestPath)).toBe(join(hackHome(), "agent-migrations")); + await expect(lstat(owned)).rejects.toThrow(); + expect(await readFile(rule, "utf8")).toContain("team addition"); + + const rollback = await runCli(["--rollback-manifest", manifestPath]); + expect(rollback.exitCode).toBe(0); + expect(rollback.stdout).toContain("1 restored"); + expect(await snapshotRepo()).toEqual(before); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "a nonexistent manifest is refused without output of file content", + async () => { + const result = await runCli([ + "--rollback-manifest", + join(root, "missing.json"), + ]); + expect(result.exitCode).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain( + "nothing was restored" + ); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "rollback rejects scope, client and apply flags", + async () => { + for (const extra of [["--apply"], ["--global"], ["--codex"]]) { + const result = await runCli(["--rollback-manifest", "m.json", ...extra]); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + "--rollback-manifest takes only the manifest path" + ); + } + }, + CLI_TEST_TIMEOUT_MS +); + +test("--skip-plugin-check accepts only known clients", () => { + expect(parseSkipPluginCheck(undefined)).toEqual([]); + expect(parseSkipPluginCheck("all")).toEqual(["claude", "codex", "cursor"]); + expect(parseSkipPluginCheck(" codex , cursor ")).toEqual(["codex", "cursor"]); + expect(() => parseSkipPluginCheck("vscode")).toThrow(CliUsageError); + expect(() => parseSkipPluginCheck(",")).toThrow(CliUsageError); +});