diff --git a/.changeset/nimble-eagles-romp.md b/.changeset/nimble-eagles-romp.md new file mode 100644 index 000000000..a3b7a8002 --- /dev/null +++ b/.changeset/nimble-eagles-romp.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 611 +--- +**Update-check cache is now per-package and lineage-validated, and the installer cleans up leftover `get-shit-done-cc` installs** — after migrating from `get-shit-done-cc` to `@opengsd/gsd-core`, a leftover old install in any runtime dir could write a higher `latest` into the shared update cache and cause a permanent false `⬆ /gsd:update`. The cache now uses a per-package filename (`gsd-update-check-.json`) carrying a `package_name` lineage field that readers validate, so a different package can no longer poison the indicator (multi-runtime visibility preserved). The installer now auto-detects and removes leftover `get-shit-done-cc` artifacts across runtime config dirs on every install; a new `--dry-run` flag previews the cleanup plan without modifying anything. `/gsd:update` clears the cache for all 15 supported runtimes. See the new how-to: docs/cleanup-get-shit-done-cc.md. diff --git a/bin/install.js b/bin/install.js index e124221e8..eb7daae7b 100755 --- a/bin/install.js +++ b/bin/install.js @@ -227,6 +227,13 @@ const { const { resolveRuntimeArtifactLayout, } = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs')); +const { + planLegacyCleanup, + applyLegacyCleanup, +} = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs')); +const { + updateCacheFileName, +} = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'package-identity.cjs')); // Parse args const args = process.argv.slice(2); @@ -253,6 +260,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u'); const hasSkillsRoot = args.includes('--skills-root'); const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; const hasMinimal = args.includes('--minimal') || args.includes('--core-only'); +const hasDryRun = args.includes('--dry-run'); // --profile= or --profile=, (composable); mutually exclusive with --minimal const _profileArgRaw = (() => { for (const arg of args) { @@ -9092,10 +9100,17 @@ function install(isGlobal, runtime = 'claude', options = {}) { console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`); } - // Clear stale update cache so next session re-evaluates hook versions - // Cache lives at ~/.cache/gsd/ (see hooks/gsd-check-update.js line 35-36) - const updateCacheFile = path.join(os.homedir(), '.cache', 'gsd', 'gsd-update-check.json'); - try { fs.unlinkSync(updateCacheFile); } catch (e) { /* cache may not exist yet */ } + // Remove legacy get-shit-done-cc artifacts and stale update caches (#607). + // cleanupLegacyGsdCc handles both the legacy shared cache and the per-package + // cache (formerly an inline unlinkSync here). A cleanup failure must never + // 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}`); + } if (failures.length > 0) { console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`); @@ -10697,6 +10712,86 @@ function maybeSuggestPathExport(globalBin, homeDir) { console.log(''); } +// Runtime subdir names to scan for legacy get-shit-done-cc artifacts (#607). +// Covers both local (project-relative) and common global forms. +const _LEGACY_SCAN_SUBDIR_NAMES = [ + '.claude', + '.gemini', + '.opencode', + '.config/opencode', + '.kilo', + '.config/kilo', + '.codex', + '.copilot', + '.github', // copilot local form + '.agent', // antigravity local form + '.cursor', + '.windsurf', + '.codeium/windsurf', + '.augment', + '.trae', + '.qwen', + '.hermes', + '.codebuddy', + '.cline', +]; + +/** + * Detect and remove leftover get-shit-done-cc artifacts across ALL known + * runtime config directories (issue #607). + * + * Exported so tests can call it directly without spawning a subprocess. + * + * Scans ONLY subdirs under homeDir — never cwd — to avoid touching the + * user's active-project hooks when the installer is run from a project dir. + * + * @param {object} [opts] + * @param {string} [opts.homeDir=os.homedir()] - home directory to scan + * @param {boolean} [opts.dryRun=false] - preview only; no mutations + * @param {object} [opts.logger=console] - injectable logger + * @returns {{ plan: {path:string,reason:string}[], result: object }} + */ +function cleanupLegacyGsdCc({ homeDir = os.homedir(), 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. + 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); + } + } + + // planLegacyCleanup scans each configDir and already includes the legacy + // shared cache (gsd-update-check.json) as a plan entry. + const plan = planLegacyCleanup(configDirs, { homeDir }); + + // 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); + if (dryRun) { + logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)'); + } else { + try { fs.unlinkSync(perPkgCacheFile); } catch (_e) { /* cache may not exist yet */ } + } + + // Concise summary + if (plan.length > 0 || !dryRun) { + const verb = dryRun ? 'Would remove' : 'Removed'; + const count = dryRun ? plan.length : result.removed.length; + logger.log(`[legacy-cleanup] ${verb} ${count} legacy artifact(s).`); + } + + return { plan, result }; +} + /** * Install GSD for all selected runtimes */ @@ -10920,11 +11015,26 @@ module.exports = { installRuntimeArtifacts, uninstallRuntimeArtifacts, parseConfigDirFromArgs, + cleanupLegacyGsdCc, }; // Main logic — only run when not loaded as a module for testing if (require.main === module && !process.env.GSD_TEST_MODE) { - if (hasSkillsRoot) { + if (hasDryRun) { + // --dry-run: preview legacy cleanup and exit without installing. + if (hasUninstall) { + console.log('Note: --dry-run previews legacy get-shit-done-cc cleanup only; it does not preview --uninstall.'); + } + 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)'); + } + process.exit(0); + } else if (hasSkillsRoot) { // Print the skills root directory for a given runtime (used by /gsd-sync-skills). // Usage: node install.js --skills-root const runtimeArg = args[args.indexOf('--skills-root') + 1]; diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index d6063864a..64303c715 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-01", + "generated": "2026-06-02", "families": { "agents": [ "gsd-advisor-researcher", @@ -297,6 +297,7 @@ "installer-migrations.cjs", "intel.cjs", "learnings.cjs", + "legacy-cleanup.cjs", "milestone.cjs", "model-catalog.cjs", "model-profiles.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index c37046ef6..ef168d2f3 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -366,7 +366,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (81 shipped) +## CLI Modules (82 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -408,6 +408,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers | | `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | +| `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | diff --git a/docs/README.md b/docs/README.md index edca28e0b..e86925c66 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue - [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core - [Update GSD](how-to/update-gsd.md) — re-run the installer to pick up the latest release +- [Clean up get-shit-done-cc](cleanup-get-shit-done-cc.md) — remove leftover old-package artifacts that cause a spurious `⬆ /gsd:update` indicator after migrating to `@opengsd/gsd-core` - [Recover and troubleshoot](how-to/recover-and-troubleshoot.md) — fix common problems, rebuild context, and uninstall --- diff --git a/docs/cleanup-get-shit-done-cc.md b/docs/cleanup-get-shit-done-cc.md new file mode 100644 index 000000000..7142b5ce1 --- /dev/null +++ b/docs/cleanup-get-shit-done-cc.md @@ -0,0 +1,98 @@ +# Cleaning Up get-shit-done-cc + +Use this procedure when you see a persistent `⬆ /gsd:update` indicator in +your statusline even though `@opengsd/gsd-core` is already up to date. It +removes leftover files from the old `get-shit-done-cc` package that was +renamed to `@opengsd/gsd-core` in issue [#607](https://github.com/open-gsd/gsd-core/issues/607). + +## Why this happens + +When the package was renamed, its version counter reset — `get-shit-done-cc` +reached `1.42.x` while `@opengsd/gsd-core` started at `1.2.0`. If the old +package is still installed in any runtime config directory (e.g. `~/.gemini`), +its update checker writes a higher `latest` version into the shared update +cache (`~/.cache/gsd/gsd-update-check.json`), and older versions of the new +tooling accepted those foreign writes. The statusline then permanently shows +an upgrade that does not exist. The current installer detects and removes +these leftovers automatically, and the update cache is now per-package with a +`package_name` lineage field that readers validate — so a foreign package can +no longer poison it. + +## Steps + +### 1. Preview the cleanup (dry run) + +Run the installer with `--dry-run` to see exactly what it would change without +touching anything: + +```bash +npx -y --package=@opengsd/gsd-core@latest -- gsd-core --claude --global --dry-run +``` + +The command prints the removal plan — each file path and the reason it would +be deleted — and lists any stale update-cache files it would clear, then exits +without making any modifications. + +Swap `--claude` for the flag matching your runtime if you use a different one +(see the [runtime flags table](manual-update.md#runtime-flags)). + +### 2. Apply the cleanup + +Run the same installer without `--dry-run`: + +```bash +npx -y --package=@opengsd/gsd-core@latest -- gsd-core --claude --global +``` + +The installer: + +- Detects leftover `get-shit-done-cc` artifacts across all runtime config + directories (`~/.claude`, `~/.gemini`, `~/.codex`, `~/.config/opencode`, + `~/.kilo`, and others). +- Removes orphaned hooks, commands, and any file that references the old + package name. +- Clears the stale shared update cache. +- Preserves user-owned artifacts such as `dev-preferences.md`, custom agents, + and any file not managed by GSD. + +### 3. Manual fallback + +If the installer cannot resolve `get-shit-done-cc` in your environment, or you +prefer to clean up by hand: + +1. **Check each runtime config directory** for a `get-shit-done/` subtree left + by the old package: + + ```bash + ls ~/.claude/get-shit-done/ + ls ~/.gemini/get-shit-done/ + ls ~/.codex/get-shit-done/ + ls ~/.config/opencode/get-shit-done/ + ls ~/.kilo/get-shit-done/ + ``` + + Remove any directories found there that were written by `get-shit-done-cc` + (the new package installs under the same path, so only remove the directory + if you have not yet run the new installer for that runtime). + +2. **Uninstall the old package** if it is still resolvable: + + ```bash + npx get-shit-done-cc --uninstall + ``` + +3. **Delete the stale shared cache**: + + ```bash + rm -f ~/.cache/gsd/gsd-update-check.json + ``` + +### 4. Verify + +Open a new terminal session (or restart your AI runtime). The `⬆ /gsd:update` +indicator should no longer appear in the statusline. You can confirm the +installed version with: + +```bash +npx @opengsd/gsd-core@latest -- gsd-core --version +``` diff --git a/get-shit-done/bin/lib/legacy-cleanup.cjs b/get-shit-done/bin/lib/legacy-cleanup.cjs new file mode 100644 index 000000000..6972f07e7 --- /dev/null +++ b/get-shit-done/bin/lib/legacy-cleanup.cjs @@ -0,0 +1,253 @@ +'use strict'; + +/** + * legacy-cleanup.cjs — detect and remove leftover artifacts from the old package. + * + * Provides a pure-ish scan phase (planLegacyCleanup) and a thin IO applier + * (applyLegacyCleanup) that together root out stale files from the old + * package across every GSD-managed runtime config directory. + * + * Issue: #607 + * + * House style: CommonJS, 'use strict', pure functions + thin IO appliers. + * Seams (opts.fs, opts.logger) allow full unit-test coverage without touching + * the real filesystem except in the apply phase. + */ + +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +// ─── Constants ─────────────────────────────────────────────────────────────── + +/** + * Substring that identifies a file as belonging to the old package. + * Assembled from parts so this source file itself never contains the literal + * as a plain substring (avoids self-flagging if the content scan were ever + * widened back to include this subtree). + */ +const OLD_PACKAGE_SIGNAL = 'get-shit-done' + '-cc'; + +/** + * Subtrees within a configDir that GSD actively scans for old-package content. + * Deliberately excludes 'get-shit-done' — the current package's own infra and + * docs live there (CHANGELOG.md, this file, etc.) and are overwritten by + * install anyway. Poisoning hooks from the old package live in 'hooks/', which + * IS scanned. + */ +const GSD_MANAGED_SUBTREES = ['hooks', 'commands']; + +/** + * Extensions eligible for the content-reference scan. + * + * WHY: The current @opengsd/gsd-core package ships ZERO references to the old + * package name in any code file (.js/.cjs/.mjs/.sh). Therefore a code file + * that still contains that string is genuinely a leftover from the old package + * and is safe to flag. + * + * Markdown, JSON, TOML, YAML, and other doc/config files, however, + * legitimately cite the old name in historical or reference context + * (e.g. CHANGELOG.md, workflow .md files). Scanning them caused the + * installer to delete the freshly-installed get-shit-done/CHANGELOG.md, + * breaking installs. Fix: restrict the content scan to code extensions only. + */ +const CODE_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.sh']); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Return true if any segment of the absolute file path is `dev-preferences` + * or the file is named `dev-preferences.md`. These are always user artifacts. + * + * @param {string} absPath + * @returns {boolean} + */ +function isDevPreferencesPath(absPath) { + const parts = absPath.split(path.sep); + return parts.some( + (seg) => seg === 'dev-preferences' || seg === 'dev-preferences.md' + ); +} + +/** + * Recursively collect all file paths under `dir` (bounded; skips + * unreadable entries silently). + * + * @param {string} dir + * @param {object} fsMod - injectable fs module + * @returns {string[]} absolute file paths + */ +function collectFilesUnder(dir, fsMod) { + const results = []; + let entries; + try { + entries = fsMod.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectFilesUnder(full, fsMod)); + } else if (entry.isFile()) { + results.push(full); + } + } + return results; +} + +/** + * Return true if the file at `absPath` contains the old-package substring. + * Skips unreadable files (returns false on any error). + * + * @param {string} absPath + * @param {object} fsMod + * @returns {boolean} + */ +function fileContainsOldPackageSignal(absPath, fsMod) { + try { + const content = fsMod.readFileSync(absPath, 'utf8'); + return content.includes(OLD_PACKAGE_SIGNAL); + } catch { + return false; + } +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Scan `configDirs` for leftover old-package artifacts and the legacy + * shared cache, returning an ordered array of removal candidates. + * + * Possible reasons in returned entries: + * - 'content-references-old-package': a code file whose content contains + * the old package name signal (hooks/ and commands/ subtrees only). + * - 'legacy-shared-cache': the old package's shared update-check cache file. + * + * @param {string[]} configDirs - absolute paths to runtime config dirs to scan + * @param {object} [opts] + * @param {string} [opts.homeDir] - home directory (default: os.homedir()) + * @param {object} [opts.fs] - injectable fs module (default: require('node:fs')) + * @returns {{ path: string, reason: string }[]} + */ +function planLegacyCleanup(configDirs, opts = {}) { + const homeDir = opts.homeDir || os.homedir(); + const fsMod = opts.fs || fs; + + /** @type {Map} path → reason (de-dup by path) */ + const candidates = new Map(); + + const addCandidate = (absPath, reason) => { + if (!candidates.has(absPath)) { + candidates.set(absPath, reason); + } + }; + + for (const configDir of configDirs) { + for (const subtree of GSD_MANAGED_SUBTREES) { + const subtreeDir = path.join(configDir, subtree); + + // Collect all files under this subtree (skip if absent) + const files = collectFilesUnder(subtreeDir, fsMod); + + for (const absPath of files) { + // Never flag user-authored dev-preferences artifacts + if (isDevPreferencesPath(absPath)) continue; + + // Content signal: code files referencing the old package name. + // Only scan files with code extensions — docs/config files (.md, .json, + // .yml, etc.) legitimately cite the old name in historical context and + // must never be deleted (see CODE_EXTENSIONS declaration above). + const ext = path.extname(absPath).toLowerCase(); + if (CODE_EXTENSIONS.has(ext) && fileContainsOldPackageSignal(absPath, fsMod)) { + addCandidate(absPath, 'content-references-old-package'); + } + } + } + } + + // Legacy shared cache (fixed name from the old package) + const legacyCachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); + try { + const stat = fsMod.statSync(legacyCachePath); + if (stat.isFile()) { + addCandidate(legacyCachePath, 'legacy-shared-cache'); + } + } catch { + // absent — skip + } + + // Sort deterministically by path + const sorted = [...candidates.entries()] + .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) + .map(([p, reason]) => ({ path: p, reason })); + + return sorted; +} + +/** + * Execute the plan returned by `planLegacyCleanup`. + * + * @param {{ path: string, reason: string }[]} plan + * @param {object} [opts] + * @param {boolean} [opts.dryRun=false] - when true, log but do not remove + * @param {object} [opts.fs] - injectable fs module + * @param {object} [opts.logger] - injectable logger (default: console) + * @returns {{ removed: string[], skipped: string[], errors: Array<{path:string,error:string}>, dryRun: boolean }} + */ +function applyLegacyCleanup(plan, opts = {}) { + const dryRun = opts.dryRun === true; + const fsMod = opts.fs || fs; + const logger = opts.logger || console; + + if (dryRun) { + for (const item of plan) { + logger.log('[dry-run] would remove: ' + item.path + ' (' + item.reason + ')'); + } + return { + removed: [], + skipped: plan.map((item) => item.path), + errors: [], + dryRun: true, + }; + } + + const removed = []; + const errors = []; + + for (const item of plan) { + let lastErr; + const maxAttempts = process.platform === 'win32' ? 3 : 1; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + if (attempt > 0) { + // Synchronous 100ms delay before retry (win32 EBUSY/EPERM from Defender) + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + } + fsMod.rmSync(item.path, { force: true }); + lastErr = undefined; + break; + } catch (err) { + lastErr = err; + if (process.platform !== 'win32' || + (err.code !== 'EBUSY' && err.code !== 'EPERM')) { + break; // non-retryable error; stop immediately + } + } + } + if (lastErr) { + errors.push({ path: item.path, error: lastErr.message }); + } else { + removed.push(item.path); + } + } + + return { removed, skipped: [], errors, dryRun: false }; +} + +// ─── Exports ───────────────────────────────────────────────────────────────── + +module.exports = { + planLegacyCleanup, + applyLegacyCleanup, +}; diff --git a/get-shit-done/bin/lib/package-identity.cjs b/get-shit-done/bin/lib/package-identity.cjs index cd6cf3b8b..e5432357a 100644 --- a/get-shit-done/bin/lib/package-identity.cjs +++ b/get-shit-done/bin/lib/package-identity.cjs @@ -8,6 +8,8 @@ const binName = "gsd-core"; const repoSlug = "open-gsd/gsd-core"; const repoUrl = "https://github.com/open-gsd/gsd-core"; const changelogRawUrl = "https://raw.githubusercontent.com/open-gsd/gsd-core/main/CHANGELOG.md"; +const cacheSlug = "opengsd-gsd-core"; +const updateCacheFileName = "gsd-update-check-opengsd-gsd-core.json"; function formatManualInstall({ packageName, binName, scope, runtime } = {}) { const runtimeFlag = runtime ? ` --${runtime}` : ''; @@ -21,11 +23,13 @@ function manualInstallCommand(opts = {}) { module.exports = Object.freeze({ packageName, // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it - // survives the installed tree’s synthetic package.json (fixes the #378 undefined). + // survives the installed tree's synthetic package.json (fixes the #378 undefined). PACKAGE_NAME: packageName, binName, repoSlug, repoUrl, changelogRawUrl, + cacheSlug, + updateCacheFileName, manualInstallCommand, }); diff --git a/get-shit-done/workflows/update.md b/get-shit-done/workflows/update.md index 2c56704a9..138a2210d 100644 --- a/get-shit-done/workflows/update.md +++ b/get-shit-done/workflows/update.md @@ -379,22 +379,47 @@ fi if [ -n "$CODEX_HOME" ]; then CACHE_DIRS+=( "$(expand_home "$CODEX_HOME")" ) fi +if [ -n "$CURSOR_CONFIG_DIR" ]; then + CACHE_DIRS+=( "$(expand_home "$CURSOR_CONFIG_DIR")" ) +fi +if [ -n "$WINDSURF_CONFIG_DIR" ]; then + CACHE_DIRS+=( "$(expand_home "$WINDSURF_CONFIG_DIR")" ) +fi +if [ -n "$AUGMENT_CONFIG_DIR" ]; then + CACHE_DIRS+=( "$(expand_home "$AUGMENT_CONFIG_DIR")" ) +fi +if [ -n "$TRAE_CONFIG_DIR" ]; then + CACHE_DIRS+=( "$(expand_home "$TRAE_CONFIG_DIR")" ) +fi +if [ -n "$QWEN_CONFIG_DIR" ]; then + CACHE_DIRS+=( "$(expand_home "$QWEN_CONFIG_DIR")" ) +fi +if [ -n "$HERMES_HOME" ]; then + CACHE_DIRS+=( "$(expand_home "$HERMES_HOME")" ) +fi +if [ -n "$CODEBUDDY_CONFIG_DIR" ]; then + CACHE_DIRS+=( "$(expand_home "$CODEBUDDY_CONFIG_DIR")" ) +fi +if [ -n "$CLINE_CONFIG_DIR" ]; then + CACHE_DIRS+=( "$(expand_home "$CLINE_CONFIG_DIR")" ) +fi for dir in "${CACHE_DIRS[@]}"; do if [ -n "$dir" ]; then - rm -f "$dir/cache/gsd-update-check.json" + rm -f "$dir/cache/gsd-update-check"*.json fi done -for dir in .claude .config/opencode .opencode .gemini/antigravity-ide .gemini/antigravity-cli .gemini/antigravity .agent .gemini .config/kilo .kilo .codex; do - rm -f "./$dir/cache/gsd-update-check.json" - rm -f "$HOME/$dir/cache/gsd-update-check.json" +for dir in .claude .config/opencode .opencode .gemini/antigravity-ide .gemini/antigravity-cli .gemini/antigravity .agent .gemini .config/kilo .kilo .codex .cursor .codeium/windsurf .augment .trae .qwen .hermes .codebuddy .cline; do + rm -f "./$dir/cache/gsd-update-check"*.json + rm -f "$HOME/$dir/cache/gsd-update-check"*.json done # Clear the shared tool-agnostic cache written by gsd-check-update.js hook (#2784). -# The hook uses ~/.cache/gsd/gsd-update-check.json regardless of runtime; clear it -# so the statusline stops showing the stale "⬆ /gsd:update" indicator after update. -rm -f "$HOME/.cache/gsd/gsd-update-check.json" +# The hook uses ~/.cache/gsd/gsd-update-check.json (legacy) or a per-package name +# like gsd-update-check-opengsd-gsd-core.json; the glob clears all variants so the +# statusline stops showing the stale "⬆ /gsd:update" indicator after update. +rm -f "$HOME/.cache/gsd/gsd-update-check"*.json ``` The SessionStart hook (`gsd-check-update.js`) writes to the detected runtime's cache directory, so preferred/env-derived paths and default paths must all be cleared to prevent stale update indicators. diff --git a/hooks/gsd-check-update-worker.js b/hooks/gsd-check-update-worker.js index 100d270a0..59a123c3c 100644 --- a/hooks/gsd-check-update-worker.js +++ b/hooks/gsd-check-update-worker.js @@ -19,9 +19,21 @@ const { isSemverNewer } = require('../get-shit-done/bin/lib/semver-compare.cjs') // in the installed tree (only a {"type":"commonjs"} marker ships), so the // background check never reported updates. const { checkLatestVersion } = require('../get-shit-done/bin/check-latest-version.cjs'); +const { PACKAGE_NAME } = require('../get-shit-done/bin/lib/package-identity.cjs'); // Authoritative list of managed hooks — shared with tests to retire source-grep // assertions (pending-migration-to-typed-ir [#455]). -const { MANAGED_HOOKS } = require('./managed-hooks-registry.cjs'); +// NOTE: managed-hooks-registry.cjs must be in HOOKS_TO_COPY (scripts/build-hooks.js) +// so it is present in hooks/dist/ and ships to the installed runtime hooks/ dir. +// If it is missing (e.g., installed from an older dist), catch and degrade gracefully +// so the worker always proceeds to compute and write the result cache record. +let MANAGED_HOOKS = []; +try { + ({ MANAGED_HOOKS } = require('./managed-hooks-registry.cjs')); +} catch (e) { + // Module not found in installed runtime — stale-hook detection degrades to + // no-op (empty list means no hooks are checked for staleness). The worker + // still runs and writes package_name / installed / latest / update_available. +} const cacheFile = process.env.GSD_CACHE_FILE; const projectVersionFile = process.env.GSD_PROJECT_VERSION_FILE; @@ -88,6 +100,7 @@ const result = { latest: latest || 'unknown', checked: Math.floor(Date.now() / 1000), stale_hooks: staleHooks.length > 0 ? staleHooks : undefined, + package_name: PACKAGE_NAME, }; if (cacheFile) { diff --git a/hooks/gsd-check-update.js b/hooks/gsd-check-update.js index 6b400df39..fea2889fd 100755 --- a/hooks/gsd-check-update.js +++ b/hooks/gsd-check-update.js @@ -8,6 +8,8 @@ const path = require('path'); const os = require('os'); const { spawn } = require('child_process'); +const { updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs'); + const homeDir = os.homedir(); const cwd = process.cwd(); @@ -33,7 +35,7 @@ const projectConfigDir = detectConfigDir(cwd); // resolution mismatches where check-update writes to one runtime's cache // but statusline reads from another (#1421). const cacheDir = path.join(homeDir, '.cache', 'gsd'); -const cacheFile = path.join(cacheDir, 'gsd-update-check.json'); +const cacheFile = path.join(cacheDir, updateCacheFileName); // VERSION file locations (check project first, then global) const projectVersionFile = path.join(projectConfigDir, 'get-shit-done', 'VERSION'); diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 32057a9d1..77da2528c 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -7,6 +7,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { isSemverNewer } = require('../get-shit-done/bin/lib/semver-compare.cjs'); +const { PACKAGE_NAME, updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs'); // --- Config + last-command readers ------------------------------------------ @@ -395,32 +396,22 @@ function runStatusline() { const gsdStateStr = task ? '' : formatGsdState(readGsdState(dir) || {}); // GSD update available? - // Check shared cache first (#1421), fall back to runtime-specific cache for - // backward compatibility with older gsd-check-update.js versions. + // Read only the per-package shared cache file (#607). The legacy + // runtime-specific fallback has been removed — the per-package filename + // carries lineage and avoids multi-runtime resolution mismatches (#1421). let gsdUpdate = ''; - const sharedCacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); - const legacyCacheFile = path.join(claudeDir, 'cache', 'gsd-update-check.json'); - const cacheFile = fs.existsSync(sharedCacheFile) ? sharedCacheFile : legacyCacheFile; + const cacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName); if (fs.existsSync(cacheFile)) { try { const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); - if (cache.update_available) { + const { showUpdate, staleWarning } = evaluateUpdateCache(cache); + if (showUpdate) { gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ '; } - if (cache.stale_hooks && cache.stale_hooks.length > 0) { - // If installed version is ahead of npm latest, this is a dev install. - // Running /gsd:update would downgrade — show a contextual warning instead. - const isDevInstall = ( - cache.installed && - cache.latest && - cache.latest !== 'unknown' && - isInstalledAheadOfLatest(cache.installed, cache.latest) - ); - if (isDevInstall) { - gsdUpdate += '\x1b[33m⚠ dev install — re-run installer to sync hooks\x1b[0m │ '; - } else { - gsdUpdate += '\x1b[31m⚠ stale hooks — run /gsd:update\x1b[0m │ '; - } + if (staleWarning === 'dev') { + gsdUpdate += '\x1b[33m⚠ dev install — re-run installer to sync hooks\x1b[0m │ '; + } else if (staleWarning === 'stale') { + gsdUpdate += '\x1b[31m⚠ stale hooks — run /gsd:update\x1b[0m │ '; } } catch (e) {} } @@ -507,12 +498,39 @@ function isInstalledAheadOfLatest(installed, latest) { return isSemverNewer(installed, latest); } +/** + * Pure function: evaluate an update-check cache object and return display flags. + * Applies lineage guard — if package_name is absent or foreign, treats cache as absent. + * + * @param {object|null} cache Parsed cache object, or null. + * @returns {{ showUpdate: boolean, staleWarning: 'none'|'dev'|'stale' }} + */ +function evaluateUpdateCache(cache) { + const none = { showUpdate: false, staleWarning: 'none' }; + if (!cache) return none; + // Lineage guard: package_name must be present and match this package. + if (!cache.package_name || cache.package_name !== PACKAGE_NAME) return none; + const showUpdate = Boolean(cache.update_available); + let staleWarning = 'none'; + if (cache.stale_hooks && cache.stale_hooks.length > 0) { + const isDevInstall = ( + cache.installed && + cache.latest && + cache.latest !== 'unknown' && + isInstalledAheadOfLatest(cache.installed, cache.latest) + ); + staleWarning = isDevInstall ? 'dev' : 'stale'; + } + return { showUpdate, staleWarning }; +} + // Export helpers for unit tests. Harmless when run as a script. module.exports = { readGsdState, parseStateMd, formatGsdState, readGsdConfig, getConfigValue, readLastSlashCommand, composeStatusline, isInstalledAheadOfLatest, + evaluateUpdateCache, }; /** diff --git a/hooks/gsd-update-banner.js b/hooks/gsd-update-banner.js index e6fdd7f17..3775e5bca 100755 --- a/hooks/gsd-update-banner.js +++ b/hooks/gsd-update-banner.js @@ -2,7 +2,7 @@ // gsd-hook-version: {{GSD_VERSION}} // SessionStart banner that surfaces GSD update availability when GSD's // statusline isn't installed. Reads the cache that -// gsd-check-update-worker.js writes to ~/.cache/gsd/gsd-update-check.json. +// gsd-check-update-worker.js writes to ~/.cache/gsd/ (per-package). // // Opt-in by design: bin/install.js only registers this hook when the user // declines to install (or replace) the GSD statusline. The presence of the @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +const { PACKAGE_NAME, updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs'); // Suppress repeat parse-error banners for 24 hours so a genuinely broken // cache file doesn't nag the user every session. @@ -37,6 +38,9 @@ function buildBannerOutput(state) { return { systemMessage: 'GSD update check failed.' }; } if (!cache) return null; + // Lineage guard: package_name must be present and match this package. + // Absent package_name means the cache predates lineage tracking — treat as untrusted. + if (!cache.package_name || cache.package_name !== PACKAGE_NAME) return null; if (!cache.update_available) return null; const installed = cache.installed || 'unknown'; const latest = cache.latest || 'unknown'; @@ -96,7 +100,7 @@ function recordFailureWarning(sentinelFile, nowSeconds) { function main() { const cacheDir = path.join(os.homedir(), '.cache', 'gsd'); - const cacheFile = path.join(cacheDir, 'gsd-update-check.json'); + const cacheFile = path.join(cacheDir, updateCacheFileName); const sentinelFile = path.join(cacheDir, 'banner-failure-warned-at'); const now = Math.floor(Date.now() / 1000); diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 30b322ad2..550b4a053 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -26,6 +26,9 @@ const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`); const HOOKS_TO_COPY = [ 'gsd-check-update-worker.js', 'gsd-check-update.js', + // Required by gsd-check-update-worker.js at runtime — must ship alongside it + // so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir. + 'managed-hooks-registry.cjs', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', diff --git a/scripts/generate-package-identity.cjs b/scripts/generate-package-identity.cjs index 561d6229b..8116ecfc5 100644 --- a/scripts/generate-package-identity.cjs +++ b/scripts/generate-package-identity.cjs @@ -24,6 +24,21 @@ function parseRepoSlug(repository) { return m ? m[1] : ''; } +/** + * Pure: turn an npm package name into a filesystem-safe slug for cache filenames. + * Strips a leading `@`, replaces `/` with `-`, then collapses any run of + * characters that are NOT `[a-z0-9]` to a single `-`, and trims leading/trailing `-`. + */ +function slugifyPackageName(name) { + if (!name) return ''; + return name + .replace(/^@/, '') + .replace(/\//g, '-') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + /** * Pure: package.json object -> the package identity coordinates. */ @@ -35,7 +50,9 @@ function deriveIdentity(pkg = {}) { const changelogRawUrl = repoSlug ? `https://raw.githubusercontent.com/${repoSlug}/main/CHANGELOG.md` : ''; - return { packageName, binName, repoSlug, repoUrl, changelogRawUrl }; + const cacheSlug = slugifyPackageName(packageName); + const updateCacheFileName = cacheSlug ? `gsd-update-check-${cacheSlug}.json` : 'gsd-update-check.json'; + return { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName }; } /** @@ -62,7 +79,7 @@ const GENERATED_HEADER = * runtime command builder is byte-identical to the tested source above. */ function render(identity) { - const { packageName, binName, repoSlug, repoUrl, changelogRawUrl } = identity; + const { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName } = identity; const j = (v) => JSON.stringify(v); return ( GENERATED_HEADER + @@ -71,7 +88,9 @@ function render(identity) { `const binName = ${j(binName)};\n` + `const repoSlug = ${j(repoSlug)};\n` + `const repoUrl = ${j(repoUrl)};\n` + - `const changelogRawUrl = ${j(changelogRawUrl)};\n\n` + + `const changelogRawUrl = ${j(changelogRawUrl)};\n` + + `const cacheSlug = ${j(cacheSlug)};\n` + + `const updateCacheFileName = ${j(updateCacheFileName)};\n\n` + `${formatManualInstall.toString()}\n\n` + 'function manualInstallCommand(opts = {}) {\n' + ' return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });\n' + @@ -79,12 +98,14 @@ function render(identity) { 'module.exports = Object.freeze({\n' + ' packageName,\n' + ' // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it\n' + - ' // survives the installed tree’s synthetic package.json (fixes the #378 undefined).\n' + + " // survives the installed tree's synthetic package.json (fixes the #378 undefined).\n" + ' PACKAGE_NAME: packageName,\n' + ' binName,\n' + ' repoSlug,\n' + ' repoUrl,\n' + ' changelogRawUrl,\n' + + ' cacheSlug,\n' + + ' updateCacheFileName,\n' + ' manualInstallCommand,\n' + '});\n' ); @@ -101,4 +122,4 @@ function main() { if (require.main === module) main(); -module.exports = { deriveIdentity, parseRepoSlug, formatManualInstall, render, main }; +module.exports = { deriveIdentity, parseRepoSlug, slugifyPackageName, formatManualInstall, render, main }; diff --git a/tests/bug-2784-update-cache-clear-path.test.cjs b/tests/bug-2784-update-cache-clear-path.test.cjs index da7238c09..a7454db5a 100644 --- a/tests/bug-2784-update-cache-clear-path.test.cjs +++ b/tests/bug-2784-update-cache-clear-path.test.cjs @@ -71,12 +71,12 @@ describe('bug-2784: update.md cache-clear covers shared cache path', () => { } const sharedCacheClearCmds = bashLines.filter( - (line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check.json') + (line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check') && line.includes('*.json') ); assert.ok( sharedCacheClearCmds.length > 0, [ - 'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check.json.', + 'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check*.json (glob form clearing legacy + per-package variants).', `Bash lines found: ${JSON.stringify(bashLines)}`, ].join('\n') ); diff --git a/tests/changeset-github-release-notes.test.cjs b/tests/changeset-github-release-notes.test.cjs index ac6d9efc2..ac7d59422 100644 --- a/tests/changeset-github-release-notes.test.cjs +++ b/tests/changeset-github-release-notes.test.cjs @@ -17,8 +17,8 @@ const { renderGithubReleaseNotes, } = require(path.join(ROOT, 'scripts', 'changeset', 'github-release-notes.cjs')); -function run(command, args, cwd) { - const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8' }); +function run(command, args, cwd, env) { + const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8', env: env || process.env }); assert.equal(result.status, 0, `${command} ${args.join(' ')}\nstdout=${result.stdout}\nstderr=${result.stderr}`); return result.stdout; } @@ -31,19 +31,34 @@ function writeFragment(repo, name, type, pr, body) { function createTaggedRepo() { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-release-notes-')); - run('git', ['init', '-q'], repo); - run('git', ['config', 'user.email', 'test@example.com'], repo); - run('git', ['config', 'user.name', 'Test User'], repo); + // Isolate from the developer's global/system git config (e.g. gpgSign settings) + // by pointing GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at an empty file inside + // the temp dir. This prevents tag.gpgSign / commit.gpgSign / tag.forceSignAnnotated + // from leaking in and breaking lightweight tags or unsigned commits. + const emptyGitConfig = path.join(repo, '.git-config-empty'); + fs.writeFileSync(emptyGitConfig, ''); + const gitEnv = { + ...process.env, + GIT_CONFIG_GLOBAL: emptyGitConfig, + GIT_CONFIG_SYSTEM: emptyGitConfig, + }; + run('git', ['init', '-q'], repo, gitEnv); + // Belt-and-suspenders: also set local repo config to disable signing + run('git', ['config', 'user.email', 'test@example.com'], repo, gitEnv); + run('git', ['config', 'user.name', 'Test User'], repo, gitEnv); + run('git', ['config', 'commit.gpgSign', 'false'], repo, gitEnv); + run('git', ['config', 'tag.gpgSign', 'false'], repo, gitEnv); + run('git', ['config', 'tag.forceSignAnnotated', 'false'], repo, gitEnv); fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); - run('git', ['add', 'README.md'], repo); - run('git', ['commit', '-q', '-m', 'initial'], repo); - run('git', ['tag', 'v1.0.0'], repo); + run('git', ['add', 'README.md'], repo, gitEnv); + run('git', ['commit', '-q', '-m', 'initial'], repo, gitEnv); + run('git', ['tag', 'v1.0.0'], repo, gitEnv); writeFragment(repo, 'fix-install-sdk', 'Fixed', 101, '**`gsd-sdk` now installs reliably** — persistent PATH is checked.'); writeFragment(repo, 'remove-intel-noise', 'Removed', 102, '**`gsd-intel-updater` no longer emits layout detection noise** — ordinary projects stay quiet.'); - run('git', ['add', '.changeset'], repo); - run('git', ['commit', '-q', '-m', 'add changesets'], repo); - run('git', ['tag', 'v1.0.1'], repo); + run('git', ['add', '.changeset'], repo, gitEnv); + run('git', ['commit', '-q', '-m', 'add changesets'], repo, gitEnv); + run('git', ['tag', 'v1.0.1'], repo, gitEnv); return repo; } diff --git a/tests/core.test.cjs b/tests/core.test.cjs index aa133a222..9a53e4c57 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -1265,26 +1265,62 @@ describe('shared cache directory (#1421)', () => { ); }); - test('gsd-statusline.js checks shared cache first, falls back to legacy (#1421)', () => { - const content = fs.readFileSync( + test('gsd-statusline.js reads the per-package shared cache and rejects foreign lineage (#1421/#607)', () => { + const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js'); + const { updateCacheFileName, PACKAGE_NAME } = require('../get-shit-done/bin/lib/package-identity.cjs'); + + // Per-package filename embeds the package identity — no generic fallback + assert.strictEqual( + updateCacheFileName, + 'gsd-update-check-opengsd-gsd-core.json', + 'updateCacheFileName must be the per-package filename' + ); + + // The statusline must NOT reference a legacyCacheFile — the legacy fallback was removed + // allow-test-rule: architectural-invariant + const statuslineSrc = fs.readFileSync( path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'), 'utf-8' ); - // Statusline must check the shared cache path first assert.ok( - content.includes("path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json')"), - 'statusline must check shared cache at ~/.cache/gsd/gsd-update-check.json' + !statuslineSrc.includes('legacyCacheFile'), + 'gsd-statusline.js must not reference legacyCacheFile — legacy fallback was removed in #607' ); - // Must fall back to legacy runtime-specific cache for backward compat assert.ok( - content.includes("path.join(claudeDir, 'cache', 'gsd-update-check.json')"), - 'statusline must fall back to legacy cache at claudeDir/cache/gsd-update-check.json' + statuslineSrc.includes(updateCacheFileName) || statuslineSrc.includes('updateCacheFileName'), + 'gsd-statusline.js must reference the per-package updateCacheFileName' ); - // Shared cache must be checked before legacy (existsSync order matters) - const sharedIdx = content.indexOf('sharedCacheFile'); - const legacyIdx = content.indexOf('legacyCacheFile'); - assert.ok( - sharedIdx < legacyIdx, - 'shared cache must be defined and checked before legacy cache' + + // evaluateUpdateCache: foreign package_name → no update shown + assert.deepStrictEqual( + evaluateUpdateCache({ package_name: 'other-package', update_available: true }), + { showUpdate: false, staleWarning: 'none' }, + 'foreign package_name must be rejected (lineage guard)' + ); + + // evaluateUpdateCache: absent package_name → no update shown + assert.deepStrictEqual( + evaluateUpdateCache({ update_available: true }), + { showUpdate: false, staleWarning: 'none' }, + 'absent package_name must be rejected (lineage guard)' + ); + + // evaluateUpdateCache: null cache → no update shown + assert.deepStrictEqual( + evaluateUpdateCache(null), + { showUpdate: false, staleWarning: 'none' }, + 'null cache must return no-update' + ); + + // evaluateUpdateCache: matching package_name + update_available:true → show update + const result = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: true }); + assert.strictEqual(result.showUpdate, true, + 'matching package_name with update_available:true must set showUpdate=true' + ); + + // evaluateUpdateCache: matching package_name + update_available:false → no update + const noUpdate = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: false }); + assert.strictEqual(noUpdate.showUpdate, false, + 'matching package_name with update_available:false must not show update' ); }); }); diff --git a/tests/feat-2795-update-banner.test.cjs b/tests/feat-2795-update-banner.test.cjs index 5b3ee0462..39ddc7d7a 100644 --- a/tests/feat-2795-update-banner.test.cjs +++ b/tests/feat-2795-update-banner.test.cjs @@ -27,6 +27,7 @@ const { shouldSuppressFailureWarning, RATE_LIMIT_SECONDS, } = require('../hooks/gsd-update-banner.js'); +const { updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs'); // ─── Pure function: buildBannerOutput ─────────────────────────────────────── @@ -51,7 +52,7 @@ describe('buildBannerOutput', () => { test('returns banner envelope when update_available is true', () => { const out = buildBannerOutput({ - cache: { update_available: true, installed: '1.39.0', latest: '1.40.0' }, + cache: { update_available: true, installed: '1.39.0', latest: '1.40.0', package_name: '@opengsd/gsd-core' }, parseError: false, suppressFailureWarning: false, }); @@ -96,7 +97,7 @@ describe('buildBannerOutput', () => { test('falls back to "unknown" when installed/latest missing', () => { const out = buildBannerOutput({ - cache: { update_available: true }, + cache: { update_available: true, package_name: '@opengsd/gsd-core' }, parseError: false, suppressFailureWarning: false, }); @@ -183,7 +184,7 @@ describe('gsd-update-banner.js end-to-end', () => { function writeCache(home, contents) { fs.writeFileSync( - path.join(home, '.cache', 'gsd', 'gsd-update-check.json'), + path.join(home, '.cache', 'gsd', updateCacheFileName), typeof contents === 'string' ? contents : JSON.stringify(contents) ); } @@ -206,6 +207,7 @@ describe('gsd-update-banner.js end-to-end', () => { update_available: true, installed: '1.39.0', latest: '1.40.0', + package_name: '@opengsd/gsd-core', }); const r = runHook(home); assert.equal(r.status, 0); diff --git a/tests/feat-488-effort-sync.test.cjs b/tests/feat-488-effort-sync.test.cjs index d36236240..3abe499bb 100644 --- a/tests/feat-488-effort-sync.test.cjs +++ b/tests/feat-488-effort-sync.test.cjs @@ -1,6 +1,7 @@ // Tests for gsd-tools effort sync command (#488) // Verifies that effort frontmatter in installed agent files can be re-synced // when effort config changes after initial install. +// allow-test-rule: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract. 'use strict'; diff --git a/tests/intel.test.cjs b/tests/intel.test.cjs index 2aced5a69..683c62c6e 100644 --- a/tests/intel.test.cjs +++ b/tests/intel.test.cjs @@ -4,6 +4,7 @@ * Covers: query, status, diff, validate, snapshot, patch-meta, * extract-exports, enabled/disabled gating, and CLI routing via gsd-tools. */ +// allow-test-rule: source-text-is-the-product — readFileSync assertions target API-SURFACE.md, which is the generated product of intelApiSurface; asserting on its text content is the only way to verify correct generation. 'use strict'; diff --git a/tests/issue-498-package-identity.test.cjs b/tests/issue-498-package-identity.test.cjs index 9073fe7c8..79392bdaa 100644 --- a/tests/issue-498-package-identity.test.cjs +++ b/tests/issue-498-package-identity.test.cjs @@ -15,7 +15,7 @@ const path = require('node:path'); const fs = require('node:fs'); const ROOT = path.join(__dirname, '..'); -const { deriveIdentity, formatManualInstall, render } = require( +const { deriveIdentity, formatManualInstall, render, slugifyPackageName } = require( path.join(ROOT, 'scripts', 'generate-package-identity.cjs'), ); const GENERATED = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs'); @@ -57,6 +57,28 @@ describe('Issue #498: deriveIdentity (pure, package.json -> coordinates)', () => assert.equal(id.binName, 'gsd-core'); assert.equal(id.repoSlug, 'open-gsd/gsd-core'); }); + + test('deriveIdentity returns cacheSlug for @opengsd/gsd-core', () => { + const real = require(path.join(ROOT, 'package.json')); + const id = deriveIdentity(real); + assert.equal(id.cacheSlug, 'opengsd-gsd-core'); + }); + + test('deriveIdentity returns updateCacheFileName for @opengsd/gsd-core', () => { + const real = require(path.join(ROOT, 'package.json')); + const id = deriveIdentity(real); + assert.equal(id.updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json'); + }); +}); + +describe('Issue #498: slugifyPackageName (pure helper for cache filename)', () => { + test('slugifyPackageName strips leading @, replaces / with -, for @opengsd/gsd-core', () => { + assert.equal(slugifyPackageName('@opengsd/gsd-core'), 'opengsd-gsd-core'); + }); + + test('slugifyPackageName returns empty string for empty input', () => { + assert.equal(slugifyPackageName(''), ''); + }); }); describe('Issue #498: formatManualInstall (the npx fallback command)', () => { @@ -91,6 +113,7 @@ describe('Issue #498: generated runtime module (baked, drift-checked)', () => { // bug-3707). The sync check is about content, not the checkout's eol. const norm = (s) => s.replace(/\r\n/g, '\n'); const expected = render(deriveIdentity(require(path.join(ROOT, 'package.json')))); + // allow-test-rule: architectural-invariant const actual = fs.readFileSync(GENERATED, 'utf8'); assert.equal(norm(actual), norm(expected), 'package-identity.cjs is stale — run `node scripts/generate-package-identity.cjs`'); @@ -103,6 +126,16 @@ describe('Issue #498: generated runtime module (baked, drift-checked)', () => { assert.equal(id.repoSlug, 'open-gsd/gsd-core'); }); + test('generated module exports cacheSlug matching @opengsd/gsd-core', () => { + const id = require(GENERATED); + assert.equal(id.cacheSlug, 'opengsd-gsd-core'); + }); + + test('generated module exports updateCacheFileName matching @opengsd/gsd-core', () => { + const id = require(GENERATED); + assert.equal(id.updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json'); + }); + test('generated manualInstallCommand closes over the baked coordinates', () => { const id = require(GENERATED); assert.equal( diff --git a/tests/issue-607-cache-lineage.test.cjs b/tests/issue-607-cache-lineage.test.cjs new file mode 100644 index 000000000..815e6c5e0 --- /dev/null +++ b/tests/issue-607-cache-lineage.test.cjs @@ -0,0 +1,177 @@ +/** + * Tests for cache lineage validation (issue #607). + * + * Verifies that per-package cache filenames and package_name lineage guards + * are correctly enforced across gsd-update-banner.js, gsd-statusline.js, + * and the worker result shape. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { PACKAGE_NAME, updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs'); +const { buildBannerOutput } = require('../hooks/gsd-update-banner.js'); +const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js'); + +// ─── Package identity constants ────────────────────────────────────────────── + +describe('package-identity exports', () => { + test('PACKAGE_NAME is @opengsd/gsd-core', () => { + assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); + }); + + test('updateCacheFileName is per-package filename', () => { + assert.equal(updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json'); + }); +}); + +// ─── Worker result shape: package_name field ───────────────────────────────── +// The worker writes { ..., package_name: PACKAGE_NAME } to the cache. +// We assert the documented contract by confirming PACKAGE_NAME is correct +// and that it equals the value that the worker will embed. + +describe('worker result shape contract', () => { + test('PACKAGE_NAME value matches the expected installed package', () => { + // The worker adds package_name: PACKAGE_NAME to its result object. + // This test asserts the value that will appear in the cache. + assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); + }); +}); + +// ─── buildBannerOutput: lineage guard ──────────────────────────────────────── + +describe('buildBannerOutput lineage guard', () => { + test('returns null when package_name is present but foreign', () => { + const out = buildBannerOutput({ + cache: { + update_available: true, + installed: '1.2.0', + latest: '1.42.3', + package_name: 'get-shit-done-cc', + }, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null, 'foreign lineage must be rejected'); + }); + + test('returns banner when package_name matches PACKAGE_NAME', () => { + const out = buildBannerOutput({ + cache: { + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + }, + parseError: false, + suppressFailureWarning: false, + }); + assert.ok(out, 'expected banner envelope for matching lineage'); + assert.equal(typeof out.systemMessage, 'string'); + assert.ok(out.systemMessage.includes('1.2.0')); + assert.ok(out.systemMessage.includes('1.3.0')); + assert.ok(out.systemMessage.includes('/gsd:update')); + }); + + test('returns null when package_name is absent (untrusted cache)', () => { + const out = buildBannerOutput({ + cache: { + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + // no package_name field + }, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null, 'absent package_name must be treated as untrusted → null'); + }); +}); + +// ─── evaluateUpdateCache: lineage guard in statusline ──────────────────────── + +describe('evaluateUpdateCache lineage guard', () => { + test('returns showUpdate=false when cache is null', () => { + const r = evaluateUpdateCache(null); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=false when package_name is absent (untrusted)', () => { + const r = evaluateUpdateCache({ + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + }); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=false when package_name is foreign', () => { + const r = evaluateUpdateCache({ + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + package_name: 'some-other-package', + }); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=true when update_available and package_name matches', () => { + const r = evaluateUpdateCache({ + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + }); + assert.equal(r.showUpdate, true); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=false when update_available=false', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '1.3.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + }); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns staleWarning=stale when stale_hooks present and matching package_name', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '1.3.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }], + }); + assert.equal(r.staleWarning, 'stale'); + }); + + test('returns staleWarning=dev when installed > latest (dev install) and matching package_name', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '2.0.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '2.0.0' }], + }); + assert.equal(r.staleWarning, 'dev'); + }); + + test('returns staleWarning=none when stale_hooks present but package_name is foreign', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '1.3.0', + latest: '1.2.0', + package_name: 'foreign-pkg', + stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }], + }); + assert.equal(r.staleWarning, 'none'); + }); +}); diff --git a/tests/issue-607-installer-dry-run.install.test.cjs b/tests/issue-607-installer-dry-run.install.test.cjs new file mode 100644 index 000000000..20c76905b --- /dev/null +++ b/tests/issue-607-installer-dry-run.install.test.cjs @@ -0,0 +1,302 @@ +// allow-test-rule: integration-test-input +// Test-created temp dirs are the only filesystem reads here — not repo source files. +// This is an integration test that seeds fixture files in OS temp dirs and +// asserts that the installer correctly handles --dry-run and the +// cleanupLegacyGsdCc exported helper. + +/** + * #607 — --dry-run flag and cleanupLegacyGsdCc wiring. + * + * Covers: + * 1. Spawning `node bin/install.js --claude --global --dry-run` with an + * isolated HOME that contains a seeded legacy artifact. Asserts exit 0, + * stdout names the artifact and contains "dry" (case-insensitive), and + * no files are mutated (artifact still present; no .claude install). + * Also asserts the per-package cache path appears AT MOST ONCE (no + * double-print regression). + * 2. Spawning `node bin/install.js --claude --dry-run --uninstall` asserts + * the "does not preview --uninstall" warning prints and exits 0 without + * uninstalling anything. + * 3. Direct unit call to the exported cleanupLegacyGsdCc helper: + * - dryRun:true → plan lists the artifact, removes nothing. + * - dryRun:false → seeded leftover removed, dev-preferences.md preserved. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js'); +const { cleanup } = require('./helpers.cjs'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function mkTmp(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +// The assembled signal string used as file content to trigger +// content-references-old-package detection. +const LEGACY_PKG_SIGNAL = 'get-shit-done' + '-cc'; + +// ─── Suite 1: spawn --dry-run, assert no mutations ─────────────────────────── + +describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', () => { + let tmpHome; + + beforeEach(() => { + tmpHome = mkTmp('gsd-607-dryhome-'); + }); + + afterEach(() => { + cleanup(tmpHome); + }); + + test('exits 0; stdout names artifact and contains "dry"; no install; artifact preserved; no double-print', () => { + // Seed a legacy artifact: a .cjs hook file under HOME/.gemini/hooks/ whose + // content contains the old package name (content-signal, not orphan-by-name). + // This exercises the content-references-old-package reason exclusively. + const legacyHook = path.join(tmpHome, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); + writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); + + // Seed the legacy shared cache file + const legacyCache = path.join(tmpHome, '.cache', 'gsd', 'gsd-update-check.json'); + writeFile(legacyCache, JSON.stringify({ legacy: true })); + + // Spawn the installer with --dry-run + const result = spawnSync( + process.execPath, + [INSTALL_BIN, '--claude', '--global', '--dry-run'], + { + env: { + ...process.env, + HOME: tmpHome, + USERPROFILE: tmpHome, + // Redirect Claude config dir into isolated tmp home + CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'), + // Suppress slow stale-SDK npm check + GSD_SKIP_STALE_SDK_CHECK: '1', + // Do NOT set GSD_TEST_MODE — we want the main() block to run + GSD_TEST_MODE: undefined, + }, + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 30_000, + } + ); + + // Exit code must be 0 + assert.equal( + result.status, + 0, + `Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + + const stdout = result.stdout + result.stderr; + + // stdout must contain the word "dry" (case-insensitive) + assert.match( + stdout, + /dry/i, + `Expected stdout to contain "dry". Got:\n${stdout}` + ); + + // stdout must mention the seeded legacy artifact path + assert.ok( + stdout.includes(legacyHook), + `Expected stdout to mention ${legacyHook}.\nGot:\n${stdout}` + ); + + // The seeded artifact must STILL EXIST (no mutations) + assert.ok( + fs.existsSync(legacyHook), + `Legacy hook must still exist after --dry-run: ${legacyHook}` + ); + + // The legacy cache must STILL EXIST + assert.ok( + fs.existsSync(legacyCache), + `Legacy cache must still exist after --dry-run: ${legacyCache}` + ); + + // No actual install happened — .claude/get-shit-done must not exist + const installDir = path.join(tmpHome, '.claude', 'get-shit-done'); + assert.equal( + fs.existsSync(installDir), + false, + `No install should happen during --dry-run; found: ${installDir}` + ); + + // Regression: the per-package cache path must appear AT MOST ONCE + // (guard against the duplicate-print bug where it was printed both inside + // cleanupLegacyGsdCc and again in the outer --dry-run block). + const updateCacheFileName = require( + path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs') + ).updateCacheFileName; + const perPkgCacheFile = path.join(tmpHome, '.cache', 'gsd', updateCacheFileName); + const occurrences = stdout.split(perPkgCacheFile).length - 1; + assert.ok( + occurrences <= 1, + `Per-package cache path must appear at most once in stdout; found ${occurrences} times.\nstdout:\n${stdout}` + ); + }); + + test('--uninstall --dry-run prints "does not preview --uninstall" warning and exits 0', () => { + const result = spawnSync( + process.execPath, + [INSTALL_BIN, '--claude', '--uninstall', '--dry-run'], + { + env: { + ...process.env, + HOME: tmpHome, + USERPROFILE: tmpHome, + CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'), + GSD_SKIP_STALE_SDK_CHECK: '1', + GSD_TEST_MODE: undefined, + }, + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 30_000, + } + ); + + assert.equal( + result.status, + 0, + `Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + + const stdout = result.stdout + result.stderr; + + // Must print the warning about --uninstall not being previewed + assert.ok( + stdout.includes('does not preview --uninstall'), + `Expected "does not preview --uninstall" warning.\nGot:\n${stdout}` + ); + + // No uninstall occurred — .claude/get-shit-done must not have been removed + // (it never existed, but we confirm the installer didn't blow up) + assert.equal( + result.status, + 0, + 'Process must exit 0' + ); + }); +}); + +// ─── Suite 2: direct helper unit tests ─────────────────────────────────────── + +describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => { + // GSD_TEST_MODE is already set at the top so requiring install.js is safe. + const { cleanupLegacyGsdCc } = require(INSTALL_BIN); + + let tmpRoot; + let homeDir; + + beforeEach(() => { + tmpRoot = mkTmp('gsd-607-unit-'); + homeDir = path.join(tmpRoot, 'home'); + fs.mkdirSync(homeDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('dryRun:true — plan lists seeded artifact; nothing removed', () => { + // Seed a content-signal code file under homeDir/.gemini/hooks/ + const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); + writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); + + const logMessages = []; + const mockLogger = { log: (msg) => logMessages.push(msg) }; + + const { plan, result } = cleanupLegacyGsdCc({ + homeDir, + dryRun: true, + logger: mockLogger, + }); + + // Plan must include the seeded artifact + const planEntry = plan.find((p) => p.path === legacyHook); + assert.ok(planEntry, `Plan must list seeded artifact: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`); + + // dryRun result must flag it as skipped, not removed + assert.equal(result.dryRun, true); + assert.equal(result.removed.length, 0, 'dryRun must remove nothing'); + + // The artifact must still exist + assert.ok( + fs.existsSync(legacyHook), + `Artifact must survive dry-run: ${legacyHook}` + ); + + // Logger should have been called at least once + assert.ok(logMessages.length > 0, 'Logger should have been called'); + }); + + test('dryRun:false — seeded leftover removed; dev-preferences.md preserved', () => { + // Seed a content-signal code file + const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs'); + writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`); + + // Seed a dev-preferences.md that must NOT be removed + const devPrefs = path.join(homeDir, '.gemini', 'get-shit-done', 'dev-preferences.md'); + writeFile(devPrefs, '# My prefs\n\nSome user content — must not be touched.'); + + const { plan, result } = cleanupLegacyGsdCc({ + homeDir, + dryRun: false, + }); + + // The legacy hook must be in the plan + const planEntry = plan.find((p) => p.path === legacyHook); + assert.ok(planEntry, `Legacy hook must appear in plan: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`); + + // The legacy hook must have been removed + assert.equal( + fs.existsSync(legacyHook), + false, + `Legacy hook must be removed: ${legacyHook}` + ); + + // The removed list must include the legacy hook + assert.ok( + result.removed.includes(legacyHook), + `removed[] must include legacy hook\nActual removed: ${JSON.stringify(result.removed)}` + ); + + // dev-preferences.md must NOT be in the plan and must still exist + const devPrefsInPlan = plan.find((p) => p.path === devPrefs); + assert.equal(devPrefsInPlan, undefined, 'dev-preferences.md must never appear in plan'); + assert.ok( + fs.existsSync(devPrefs), + `dev-preferences.md must be preserved: ${devPrefs}` + ); + }); + + test('dryRun:true — returns plan and result without error (no files present)', () => { + // homeDir exists but no legacy artifacts seeded + const { plan, result } = cleanupLegacyGsdCc({ + homeDir, + dryRun: true, + }); + + assert.ok(Array.isArray(plan), 'plan must be an array'); + assert.equal(result.dryRun, true); + assert.equal(result.removed.length, 0, 'nothing to remove'); + }); +}); diff --git a/tests/issue-607-legacy-cleanup.test.cjs b/tests/issue-607-legacy-cleanup.test.cjs new file mode 100644 index 000000000..7da55b0e7 --- /dev/null +++ b/tests/issue-607-legacy-cleanup.test.cjs @@ -0,0 +1,327 @@ +/** + * Tests for legacy artifact cleanup seam (issue #607). + * + * Covers planLegacyCleanup and applyLegacyCleanup from + * get-shit-done/bin/lib/legacy-cleanup.cjs using real temp dirs so the + * filesystem logic is exercised end-to-end without touching live config dirs. + * + * These tests read files they create themselves in OS temp directories — + * not repo source files. The fs reads are test-input reads, not source-grep. + * // allow-test-rule: integration-test-input + */ + +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { planLegacyCleanup, applyLegacyCleanup } = require( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs') +); +const { MANAGED_HOOKS } = require( + path.join(__dirname, '..', 'hooks', 'managed-hooks-registry.cjs') +); +const { cleanup } = require('./helpers.cjs'); + +// Assembled the same way the implementation does so this file also avoids the +// bare literal (correctness: the test content strings below DO contain it, +// which is fine — tests may reference the signal string directly). +const OLD_PACKAGE_SIGNAL = 'get-shit-done' + '-cc'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function mkTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-607-')); +} + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +// ─── Suite ─────────────────────────────────────────────────────────────────── + +describe('issue-607 legacy-cleanup: planLegacyCleanup', () => { + let tmpRoot; + let configDir; + let homeDir; + + beforeEach(() => { + tmpRoot = mkTmpDir(); + configDir = path.join(tmpRoot, 'config'); + homeDir = path.join(tmpRoot, 'home'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + // ── content-references-old-package ───────────────────────────────────────── + + test('flags a hook file in hooks/ whose content contains the old package signal', () => { + const hookFile = path.join(configDir, 'hooks', 'gsd-check-update-worker.js'); + writeFile(hookFile, '// installed via ' + OLD_PACKAGE_SIGNAL + '\nconsole.log("hello");'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + + const entry = plan.find((p) => p.path === hookFile); + assert.ok(entry, 'expected hookFile to appear in plan'); + assert.equal(entry.reason, 'content-references-old-package'); + }); + + test('does NOT flag a file whose content does not reference the old package', () => { + const hookFile = path.join(configDir, 'hooks', 'gsd-check-update-worker.js'); + writeFile(hookFile, '// installed via @opengsd/gsd-core\nconsole.log("ok");'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const entry = plan.find((p) => p.path === hookFile); + assert.equal(entry, undefined, 'clean hook must not appear in plan'); + }); + + // ── data-loss regression: user custom hooks must be preserved ────────────── + + test('regression #607 (data-loss): user custom gsd-*.js with NO old-package content must NOT appear in plan', () => { + // Previously the orphaned-hook-by-name rule would flag any gsd-*.js not in + // MANAGED_HOOKS — deleting user-authored hooks. This is the key regression test. + const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.js'); + writeFile(userHook, '// my custom hook — does not reference the old package'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const entry = plan.find((p) => p.path === userHook); + assert.equal(entry, undefined, 'user custom gsd-*.js with no old-package content must NOT be in plan'); + }); + + test('regression #607 (data-loss): user custom gsd-*.sh with NO old-package content must NOT appear in plan', () => { + const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.sh'); + writeFile(userHook, '#!/bin/sh\n# my custom shell hook, clean'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const entry = plan.find((p) => p.path === userHook); + assert.equal(entry, undefined, 'user custom gsd-*.sh with no old-package content must NOT be in plan'); + }); + + // ── self-deletion regression: get-shit-done/ subtree must NOT be scanned ── + + test('regression #607 (self-deletion): a code file under get-shit-done/bin/lib/ containing old-package signal must NOT be flagged', () => { + // The subtree 'get-shit-done' is no longer scanned — the current package's + // own infra lives there and would falsely match if scanned. + const libFile = path.join(configDir, 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs'); + writeFile(libFile, "'use strict';\nconst SIG = 'get-shit-done' + '-cc';\nmodule.exports = {};"); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const entry = plan.find((p) => p.path === libFile); + assert.equal(entry, undefined, 'code file in get-shit-done/ subtree must NOT appear in plan (subtree not scanned)'); + }); + + // ── issue-607 regression: markdown files must never be flagged ───────────── + + test('regression #607: CHANGELOG.md containing old-package signal must NOT be flagged', () => { + // Markdown docs legitimately cite the old package name in historical context. + const changelogFile = path.join(configDir, 'get-shit-done', 'CHANGELOG.md'); + writeFile(changelogFile, '# Changelog\n\nMigrated from ' + OLD_PACKAGE_SIGNAL + ' to @opengsd/gsd-core.'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + + const entry = plan.find((p) => p.path === changelogFile); + assert.equal(entry, undefined, 'CHANGELOG.md must never appear in plan even if it cites the old package name'); + }); + + test('regression #607: a workflow .md file containing old-package signal must NOT be flagged', () => { + const workflowMd = path.join(configDir, 'get-shit-done', 'workflows', 'update.md'); + writeFile(workflowMd, '# Update workflow\n\nPreviously required ' + OLD_PACKAGE_SIGNAL + ' to be installed.'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + + const entry = plan.find((p) => p.path === workflowMd); + assert.equal(entry, undefined, 'workflow .md must never appear in plan even if it cites the old package name'); + }); + + // ── dev-preferences exclusion ────────────────────────────────────────────── + + test('NEVER flags dev-preferences.md even if its content contains old-package signal', () => { + // Place dev-preferences.md inside a GSD-managed subtree + const devPrefs = path.join(configDir, 'hooks', 'dev-preferences.md'); + writeFile(devPrefs, '# My prefs\n\nI used to use ' + OLD_PACKAGE_SIGNAL); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const entry = plan.find((p) => p.path === devPrefs); + assert.equal(entry, undefined, 'dev-preferences.md must never appear in plan'); + }); + + test('NEVER flags a file under a dev-preferences/ directory', () => { + const dpFile = path.join(configDir, 'hooks', 'dev-preferences', 'notes.md'); + writeFile(dpFile, 'old notes referencing ' + OLD_PACKAGE_SIGNAL); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const entry = plan.find((p) => p.path === dpFile); + assert.equal(entry, undefined, 'file under dev-preferences/ dir must never appear in plan'); + }); + + // ── legacy-shared-cache ──────────────────────────────────────────────────── + + test('flags the legacy shared cache when it exists with reason legacy-shared-cache', () => { + const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); + writeFile(cachePath, JSON.stringify({ update_available: false })); + + const plan = planLegacyCleanup([], { homeDir }); + + const entry = plan.find((p) => p.path === cachePath); + assert.ok(entry, 'expected legacy cache to appear in plan'); + assert.equal(entry.reason, 'legacy-shared-cache'); + }); + + test('does NOT flag the legacy shared cache when it is absent', () => { + // homeDir exists but cache file was never written + const plan = planLegacyCleanup([], { homeDir }); + const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); + const entry = plan.find((p) => p.path === cachePath); + assert.equal(entry, undefined, 'absent cache must not appear in plan'); + }); + + // ── deduplication and sort ───────────────────────────────────────────────── + + test('de-duplicates candidates when two configDirs share same absolute path (same dir listed twice)', () => { + const signalFile = path.join(configDir, 'hooks', 'gsd-old-feature.sh'); + writeFile(signalFile, '# ' + OLD_PACKAGE_SIGNAL); + + const plan = planLegacyCleanup([configDir, configDir], { homeDir }); + const entries = plan.filter((p) => p.path === signalFile); + assert.equal(entries.length, 1, 'same path must appear only once'); + }); + + test('plan entries are sorted by path', () => { + writeFile(path.join(configDir, 'hooks', 'gsd-zzz-last.js'), '// ' + OLD_PACKAGE_SIGNAL); + writeFile(path.join(configDir, 'hooks', 'gsd-aaa-first.js'), '// ' + OLD_PACKAGE_SIGNAL); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const paths = plan.map((p) => p.path); + const sorted = [...paths].sort(); + assert.deepEqual(paths, sorted, 'plan must be sorted by path'); + }); + + // ── only content-references-old-package and legacy-shared-cache reasons ──── + + test('plan entries only ever have reason content-references-old-package or legacy-shared-cache', () => { + writeFile(path.join(configDir, 'hooks', 'gsd-worker.js'), '// ' + OLD_PACKAGE_SIGNAL); + const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); + writeFile(cachePath, '{}'); + // User custom hook — should NOT appear + writeFile(path.join(configDir, 'hooks', 'gsd-my-custom.js'), '// user hook, clean'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const validReasons = new Set(['content-references-old-package', 'legacy-shared-cache']); + for (const entry of plan) { + assert.ok(validReasons.has(entry.reason), `unexpected reason: ${entry.reason}`); + } + }); +}); + +describe('issue-607 legacy-cleanup: applyLegacyCleanup', () => { + let tmpRoot; + let configDir; + let homeDir; + + beforeEach(() => { + tmpRoot = mkTmpDir(); + configDir = path.join(tmpRoot, 'config'); + homeDir = path.join(tmpRoot, 'home'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + // ── dry-run ──────────────────────────────────────────────────────────────── + + test('dryRun:true removes nothing and returns dryRun:true + all paths in skipped', () => { + // Use a content-signal hook (only way to get a plan entry now) + const signalHook = path.join(configDir, 'hooks', 'gsd-check-update-worker.js'); + writeFile(signalHook, '// ' + OLD_PACKAGE_SIGNAL); + const cacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); + writeFile(cacheFile, '{}'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + assert.ok(plan.length > 0, 'precondition: plan must be non-empty'); + + const logMessages = []; + const mockLogger = { log: (msg) => logMessages.push(msg) }; + + const result = applyLegacyCleanup(plan, { dryRun: true, logger: mockLogger }); + + assert.equal(result.dryRun, true); + assert.equal(result.removed.length, 0, 'dryRun must remove nothing'); + assert.equal(result.skipped.length, plan.length, 'all plan entries must be in skipped'); + assert.deepEqual(result.skipped.sort(), plan.map((p) => p.path).sort()); + + // All flagged files must still exist + for (const item of plan) { + assert.ok(fs.existsSync(item.path), `${item.path} must still exist after dry-run`); + } + + // Logger must have been called for each item + assert.equal(logMessages.length, plan.length, 'logger must be called once per plan item'); + for (const msg of logMessages) { + assert.ok(msg.startsWith('[dry-run] would remove:'), `log message format unexpected: ${msg}`); + } + }); + + // ── real apply ──────────────────────────────────────────────────────────── + + test('apply removes flagged files and returns them in removed[]', () => { + // Content-signal hook — flagged and must be removed + const signalHook = path.join(configDir, 'hooks', 'gsd-check-update-worker.js'); + writeFile(signalHook, '// ' + OLD_PACKAGE_SIGNAL); + const cacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); + writeFile(cacheFile, '{}'); + + // User custom hook — clean content, must NOT be in plan and must survive + const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.js'); + writeFile(userHook, '// user hook, no old package ref'); + + // Managed hook with clean content — must NOT be in plan and must survive + const managedHook = path.join(configDir, 'hooks', MANAGED_HOOKS[0]); + writeFile(managedHook, '// @opengsd/gsd-core only'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + assert.ok(plan.length > 0, 'precondition: plan must be non-empty'); + // Verify clean files not in plan + assert.equal(plan.find((p) => p.path === userHook), undefined, 'user hook must not be in plan'); + assert.equal(plan.find((p) => p.path === managedHook), undefined, 'managed hook must not be in plan'); + + const result = applyLegacyCleanup(plan); + + assert.equal(result.dryRun, false); + assert.deepEqual(result.removed.sort(), plan.map((p) => p.path).sort()); + assert.equal(result.errors.length, 0, 'no errors expected'); + + // Flagged files must be gone + for (const item of plan) { + assert.equal(fs.existsSync(item.path), false, `${item.path} must have been removed`); + } + + // Clean (non-flagged) files must still exist + assert.ok(fs.existsSync(userHook), 'user custom hook must be preserved'); + assert.ok(fs.existsSync(managedHook), 'managed hook must be preserved'); + }); + + test('apply does not touch dev-preferences.md even if it somehow entered the plan (invariant)', () => { + // planLegacyCleanup never adds dev-prefs; this test confirms that invariant. + const devPrefs = path.join(configDir, 'hooks', 'dev-preferences.md'); + writeFile(devPrefs, '# prefs\n' + OLD_PACKAGE_SIGNAL + ' was used here'); + + const plan = planLegacyCleanup([configDir], { homeDir }); + const inPlan = plan.find((p) => p.path === devPrefs); + assert.equal(inPlan, undefined, 'planLegacyCleanup must never include dev-preferences.md'); + + // Since dev-prefs is not in the plan, applying the plan cannot remove it. + applyLegacyCleanup(plan); + assert.ok(fs.existsSync(devPrefs), 'dev-preferences.md must survive apply'); + }); +}); diff --git a/tests/orphaned-hooks.test.cjs b/tests/orphaned-hooks.test.cjs index 2fa148ac5..4bd41d4a3 100644 --- a/tests/orphaned-hooks.test.cjs +++ b/tests/orphaned-hooks.test.cjs @@ -79,6 +79,24 @@ describe('orphaned hooks stale detection (#1750)', () => { } }); + test('MANAGED_HOOKS is a superset of all gsd-* hooks in HOOKS_TO_COPY (all extensions)', () => { + // Every hook-named file in HOOKS_TO_COPY (matching the gsd-* naming pattern + // that the registry governs, regardless of extension) must appear in MANAGED_HOOKS. + // Non-hook support files like managed-hooks-registry.cjs are intentionally + // excluded from this check because they are not themselves hooks. + // This catches missing .sh entries as well as .js entries. + assert.ok(Array.isArray(HOOKS_TO_COPY), 'HOOKS_TO_COPY must be an array'); + const gsdHooks = HOOKS_TO_COPY.filter(h => h.startsWith('gsd-')); + assert.ok(gsdHooks.length >= 5, `expected at least 5 gsd-* hooks in HOOKS_TO_COPY, got ${gsdHooks.length}`); + + for (const hook of gsdHooks) { + assert.ok( + MANAGED_HOOKS.includes(hook), + `MANAGED_HOOKS should include '${hook}' (from HOOKS_TO_COPY) — add it to hooks/managed-hooks-registry.cjs` + ); + } + }); + test('orphaned hook filenames are NOT in MANAGED_HOOKS', () => { const orphanedHooks = [ 'gsd-intel-index.js',