From 1051c6d8d4cb9f061cf9fa41ddae1d8fa7b9ff93 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 28 Aug 2026 20:16:50 -0400 Subject: [PATCH] fix(#3799): scope legacy cleanup to the install's resolved config dir and add --no-legacy-cleanup (#4013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#3795): read the interrupted agent id before clearing the stale marker (#4006) * test(#3795): the interrupted-agent read must precede the stale-id clear * fix(#3795): read the interrupted agent id before clearing the stale marker execute-plan's init_agent_tracking step ran `rm -f .planning/current-agent-id.txt` BEFORE the existence check that read it, so the interrupted-agent branch and the Task resume prompt it exists to offer were unreachable (#3795) — a kill -9 mid-executor left the file, and the next run deleted it before looking. The read now precedes the clear; fresh-run semantics (no stale id leaking into the new spawn) are preserved. A structural guard pins the order. Emitted-Drift-Ack-Growth: execute-plan.md — #3795: +bytes from reordering the interrupted-agent read before the rm plus the explaining comment * chore(#3795): changeset fragment (pr number backfilled after PR creation) * chore(#3795): backfill changeset PR number (4006) --------- Co-authored-by: sim * fix(#3799): scope legacy cleanup to the install's resolved config dir and add --no-legacy-cleanup * chore(#3799): changeset fragment (pr number backfilled after PR creation) * chore(#3799): backfill changeset PR number (4013) * test(#3799): mark the legacy-name fixtures with gsd-allow-legacy-name --------- Co-authored-by: sim --- .changeset/daring-newts-chatter.md | 5 + bin/install.js | 86 +++++++++++---- gsd-core/bin/lib/legacy-cleanup.cjs | 10 +- tests/legacy-cleanup-config-dir.test.cjs | 132 +++++++++++++++++++++++ 4 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 .changeset/daring-newts-chatter.md create mode 100644 tests/legacy-cleanup-config-dir.test.cjs diff --git a/.changeset/daring-newts-chatter.md b/.changeset/daring-newts-chatter.md new file mode 100644 index 000000000..9426b6d08 --- /dev/null +++ b/.changeset/daring-newts-chatter.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4013 +--- +**`--config-dir` installs no longer plan removals of the default home's live legacy install** — the legacy get-shit-done-cc cleanup is scoped to the resolved config dir when `--config-dir` redirects the install (scan, shared cache, and per-package cache alike), the `--dry-run` preview shows the same scoped plan the real install would apply, and `--no-legacy-cleanup` skips the scan entirely. (#3799) diff --git a/bin/install.js b/bin/install.js index f83d232a5..62fa3da5a 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1178,6 +1178,14 @@ function parseConfigDirFromArgs(argsArray) { return null; } +// Parse --no-legacy-cleanup (#3799) — skip the legacy get-shit-done-cc scan +// entirely. Some users run gsd-core alongside a live legacy install on +// purpose; the scan is best-effbelt cleanup, never load-bearing for the +// install itself. +function parseNoLegacyCleanupArg(args = process.argv) { + return args.includes('--no-legacy-cleanup'); +} + // Parse --config-dir argument function parseConfigDirArg() { const result = parseConfigDirFromArgs(args); @@ -1208,7 +1216,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--kimi-code${reset} Install for Kimi Code only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n and resolve the node runner at hook-fire time via\n hooks/gsd-node-runner.sh (WSL/Docker bind-mount\n setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--reclaim-kimi-legacy${reset} With --kimi-code: also remove the GSD hooks a\n pre-1.10.0 --kimi-code install orphaned in ~/.kimi.\n Opt-in — those artifacts are indistinguishable from\n Kimi CLI's own, so skip it if you use Kimi CLI too.\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi Code globally (its own ~/.kimi-code root)${reset}\n npx ${pkg.name} --kimi-code --global\n\n ${dim}# Kimi Code, also reclaiming hooks a pre-1.10.0 install left in ~/.kimi${reset}\n npx ${pkg.name} --kimi-code --global --reclaim-kimi-legacy\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Kimi CLI and Kimi Code are separate products with separate hook roots: use ${cyan}--kimi${reset} (${cyan}~/.kimi${reset}, ${cyan}KIMI_SHARE_DIR${reset}) or ${cyan}--kimi-code${reset} (${cyan}~/.kimi-code${reset}, ${cyan}KIMI_CODE_HOME${reset}).\n`); + console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--kimi-code${reset} Install for Kimi Code only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--pi${reset} Install for Pi only\n ${cyan}--gemini${reset} Install for Gemini CLI only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}--no-legacy-cleanup${reset} Skip the legacy get-shit-done-cc artifact scan\n (an explicit --config-dir already scopes the scan to it)\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n and resolve the node runner at hook-fire time via\n hooks/gsd-node-runner.sh (WSL/Docker bind-mount\n setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--reclaim-kimi-legacy${reset} With --kimi-code: also remove the GSD hooks a\n pre-1.10.0 --kimi-code install orphaned in ~/.kimi.\n Opt-in — those artifacts are indistinguishable from\n Kimi CLI's own, so skip it if you use Kimi CLI too.\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi Code globally (its own ~/.kimi-code root)${reset}\n npx ${pkg.name} --kimi-code --global\n\n ${dim}# Kimi Code, also reclaiming hooks a pre-1.10.0 install left in ~/.kimi${reset}\n npx ${pkg.name} --kimi-code --global --reclaim-kimi-legacy\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Kimi CLI and Kimi Code are separate products with separate hook roots: use ${cyan}--kimi${reset} (${cyan}~/.kimi${reset}, ${cyan}KIMI_SHARE_DIR${reset}) or ${cyan}--kimi-code${reset} (${cyan}~/.kimi-code${reset}, ${cyan}KIMI_CODE_HOME${reset}).\n`); process.exit(0); } @@ -11720,10 +11728,21 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) { // abort a successful install — log a warning and continue. // install() is never reached in --dry-run mode (the early-exit at the CLI // dispatch handles preview), so cleanup here always applies for real. - try { - cleanupLegacyGsdCc({ dryRun: false }); - } catch (cleanupErr) { - console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`); + // + // #3799: when --config-dir redirected the install, the scan is SCOPED to + // that destination ([targetDir]) — the default home's live install must + // never be planned for removal from a sandboxed install. --no-legacy-cleanup + // skips the scan entirely. + const skipNoLegacyCleanup = parseNoLegacyCleanupArg(); + const legacyCleanupScope = (explicitConfigDir !== null && isGlobal) + ? [targetDir] + : undefined; + if (!skipNoLegacyCleanup) { + try { + cleanupLegacyGsdCc({ dryRun: false, ...(legacyCleanupScope ? { configDirs: legacyCleanupScope } : {}) }); + } catch (cleanupErr) { + console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`); + } } if (failures.length > 0) { @@ -13624,31 +13643,49 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [ * @param {object} [opts.logger=console] - injectable logger * @returns {{ plan: {path:string,reason:string}[], result: object }} */ -function cleanupLegacyGsdCc({ homeDir = os.homedir(), dryRun = false, logger = console } = {}) { +function cleanupLegacyGsdCc({ homeDir = os.homedir(), configDirs = null, dryRun = false, logger = console } = {}) { // Build de-duplicated list of candidate config dirs to scan. // Only scan under homeDir — never cwd — to prevent accidental deletion of // the user's active-project hooks when the installer is invoked from a // project directory that has .claude/hooks or similar subdirs. + // #3799: an explicit configDirs override (install() passes [targetDir] + // whenever --config-dir redirected the destination) scopes the WHOLE scan + // to that dir — the default-home scan must never plan removals of a live + // install that lives outside the destination the user chose. const seen = new Set(); - const configDirs = []; - for (const name of _LEGACY_SCAN_SUBDIR_NAMES) { - const candidate = path.join(homeDir, name); - if (!seen.has(candidate) && fs.existsSync(candidate)) { - seen.add(candidate); - configDirs.push(candidate); + const scanDirs = []; + if (Array.isArray(configDirs) && configDirs.length > 0) { + for (const candidate of configDirs) { + if (!seen.has(candidate) && fs.existsSync(candidate)) { + seen.add(candidate); + scanDirs.push(candidate); + } + } + } else { + for (const name of _LEGACY_SCAN_SUBDIR_NAMES) { + const candidate = path.join(homeDir, name); + if (!seen.has(candidate) && fs.existsSync(candidate)) { + seen.add(candidate); + scanDirs.push(candidate); + } } } // planLegacyCleanup scans each configDir and already includes the legacy // shared cache (gsd-update-check.json) as a plan entry. - const plan = planLegacyCleanup(configDirs, { homeDir }); + const plan = planLegacyCleanup(scanDirs, { homeDir, ...(Array.isArray(configDirs) && configDirs.length > 0 ? { configDirs } : {}) }); // Apply the plan (dryRun honors the flag). const result = applyLegacyCleanup(plan, { dryRun, logger }); // Also clear / preview the per-package cache so next session re-evaluates // hook versions (replaces the former inline unlinkSync on line ~9104). - const perPkgCacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName); + // #3799: under a configDirs override the cache is read/cleared under the + // SCOPE root, never the default home — same invariant as the scan itself. + const perPkgCacheRoot = (Array.isArray(configDirs) && configDirs.length > 0) + ? configDirs[0] + : homeDir; + const perPkgCacheFile = path.join(perPkgCacheRoot, '.cache', 'gsd', updateCacheFileName); if (dryRun) { logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)'); } else { @@ -13944,10 +13981,23 @@ if (require.main === module && !process.env.GSD_TEST_MODE) { console.log('Dry run — no files will be modified.\n'); // cleanupLegacyGsdCc with dryRun:true is the single source of truth for // both the legacy artifacts and the per-package cache path — no duplicate - // printing here. - const { plan } = cleanupLegacyGsdCc({ dryRun: true }); - if (plan.length === 0) { - console.log(' (no legacy get-shit-done-cc artifacts found)'); + // printing here. #3799: the preview honors the SAME scope and skip the + // real install would apply (--config-dir scopes; --no-legacy-cleanup + // skips) — a preview that listed default-home paths a real install would + // never touch misrepresents the run. + if (parseNoLegacyCleanupArg()) { + console.log(' (--no-legacy-cleanup — legacy scan skipped)'); + } else { + const previewScope = (explicitConfigDir !== null) + ? [getGlobalConfigDir(DEFAULT_RUNTIME, explicitConfigDir)] + : undefined; + const { plan } = cleanupLegacyGsdCc({ + dryRun: true, + ...(previewScope ? { configDirs: previewScope } : {}), + }); + if (plan.length === 0) { + console.log(' (no legacy get-shit-done-cc artifacts found)'); + } } process.exit(0); } else if (hasSkillsRoot) { diff --git a/gsd-core/bin/lib/legacy-cleanup.cjs b/gsd-core/bin/lib/legacy-cleanup.cjs index 7d51ebf9b..95f05d9da 100644 --- a/gsd-core/bin/lib/legacy-cleanup.cjs +++ b/gsd-core/bin/lib/legacy-cleanup.cjs @@ -262,8 +262,14 @@ function planLegacyCleanup(configDirs, opts = {}) { } } - // Legacy shared cache (fixed name from the old package) - const legacyCachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); + // Legacy shared cache (fixed name from the old package). #3799: under an + // explicit configDirs override the cache lives under the SCOPE root(s), not + // the default home — the override means "clean only what this redirected + // install owns", and the default home's cache belongs to the live install. + const cacheRoot = (Array.isArray(opts.configDirs) && opts.configDirs.length > 0) + ? opts.configDirs[0] + : homeDir; + const legacyCachePath = path.join(cacheRoot, '.cache', 'gsd', 'gsd-update-check.json'); try { const stat = fsMod.statSync(legacyCachePath); if (stat.isFile()) { diff --git a/tests/legacy-cleanup-config-dir.test.cjs b/tests/legacy-cleanup-config-dir.test.cjs new file mode 100644 index 000000000..798a49d54 --- /dev/null +++ b/tests/legacy-cleanup-config-dir.test.cjs @@ -0,0 +1,132 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// #3799 — legacy cleanup must honor the install's resolved destination. +// +// `install()` called `cleanupLegacyGsdCc({ dryRun: false })` with no +// arguments, so the scan always ran against `os.homedir()` + +// `_LEGACY_SCAN_SUBDIR_NAMES` — an install redirected with `--config-dir +// ~/.sandbox` planned removals of a LIVE legacy install under the DEFAULT +// home (the reporter's dry-run listed 60 default-home paths; a real install +// would have deleted 59 files). The cleanup now accepts an explicit +// `configDirs` scope (install() passes `[targetDir]` when --config-dir +// redirected the destination), and `--no-legacy-cleanup` skips the scan. +// ───────────────────────────────────────────────────────────────────────────── + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +process.env.GSD_TEST_MODE = '1'; + +const REPO_ROOT = path.join(__dirname, '..'); +const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js'); +const { cleanupLegacyGsdCc } = require(INSTALL_BIN); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const LEGACY_PKG_SIGNAL = 'get-shit-done-cc'; + +function seedLegacySkill(configDir) { + // configDir is a CONFIG DIR root (like ~/.claude): artifacts live at + // /skills/gsd-*/SKILL.md. The scan's content signal is a stale + // '/get-shit-done/' PATH reference (legacy-cleanup.cjs // gsd-allow-legacy-name + // LEGACY_SKILL_PATH_SIGNAL), not the bare package name. + const skillDir = path.join(configDir, 'skills', 'gsd-add-tests'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `\nSee ~/.claude/get-shit-done/skills/gsd-add-tests/SKILL.md\n`, // gsd-allow-legacy-name + ); + return path.join(skillDir, 'SKILL.md'); +} + +describe('#3799: cleanupLegacyGsdCc honors an explicit configDirs scope', () => { + let tmpRoot; + let defaultHome; + let sandboxDir; + + beforeEach(() => { + tmpRoot = createTempDir('gsd-3799-cleanup-'); + defaultHome = path.join(tmpRoot, 'home'); + sandboxDir = path.join(tmpRoot, 'pilot-sandbox'); + fs.mkdirSync(defaultHome, { recursive: true }); + fs.mkdirSync(sandboxDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('#3799: an explicit configDirs scope never plans removals outside it', () => { + // A LIVE legacy install under the default home (at /.claude, one + // of _LEGACY_SCAN_SUBDIR_NAMES)… + const liveArtifact = seedLegacySkill(path.join(defaultHome, '.claude')); + // …and one stale artifact inside the redirected sandbox. + const sandboxArtifact = seedLegacySkill(sandboxDir); + + const { plan } = cleanupLegacyGsdCc({ + homeDir: defaultHome, + configDirs: [sandboxDir], + dryRun: true, + logger: { log: () => {} }, + }); + + const paths = plan.map((p) => p.path); + assert.ok( + paths.every((p) => p.startsWith(sandboxDir)), + `#3799: every plan entry must live inside the explicit scope; got ${JSON.stringify(paths)}`, + ); + assert.ok(paths.includes(sandboxArtifact), 'the sandbox artifact is in scope and planned'); + assert.ok( + !paths.includes(liveArtifact), + '#3799: the default home\'s live legacy install must not be planned for removal', + ); + assert.ok( + fs.existsSync(liveArtifact), + 'dry-run leaves everything in place', + ); + }); + + test('#3799 control: without the override, the home scan is unchanged', () => { + const liveArtifact = seedLegacySkill(path.join(defaultHome, '.claude')); + const { plan } = cleanupLegacyGsdCc({ + homeDir: defaultHome, + dryRun: true, + logger: { log: () => {} }, + }); + assert.ok( + plan.map((p) => p.path).includes(liveArtifact), + 'default behavior (no scope) still scans homeDir subdirs as before', + ); + }); +}); + +describe('#3799: --no-legacy-cleanup and --config-dir CLI flags', () => { + const HELP_TEXT = fs.readFileSync(INSTALL_BIN, 'utf-8'); + + test('the --no-legacy-cleanup flag exists and is documented in --help', () => { + assert.ok( + HELP_TEXT.includes('--no-legacy-cleanup'), + 'the escape hatch must be documented in the installer help text', + ); + }); + + test('the install() call site threads the config-dir scope and the skip flag', () => { + const src = HELP_TEXT; // same file read — the shipped installer source + // Slice the install() body up to its cleanup call so the conditional + // spread's braces cannot defeat a single-regex match. + const fnStart = src.indexOf('function install(isGlobal, runtime = DEFAULT_RUNTIME'); + const callIdx = src.indexOf('cleanupLegacyGsdCc({ dryRun: false', fnStart); + assert.ok(fnStart > 0 && callIdx > fnStart, 'install() still calls cleanupLegacyGsdCc'); + const callSite = src.slice(callIdx, callIdx + 240); + assert.ok( + /configDirs/.test(callSite), + '#3799: the call site must pass a configDirs scope', + ); + assert.ok( + /skipNoLegacyCleanup/.test(src.slice(fnStart, callIdx + 400)), + '#3799: the call site must honor the --no-legacy-cleanup skip', + ); + }); +});