feat(#607): rebuild get-shit-done-cc → gsd-core migration (per-package cache + installer auto-cleanup + --dry-run) (#611)

* feat(#607): rebuild get-shit-done-cc → gsd-core migration

Leftover get-shit-done-cc installs poisoned the shared update cache,
causing a permanent false "update available". Rebuild the migration so a
stale old install is both harmless and actively removed.

- Per-package update cache filename (gsd-update-check-<slug>.json) in the
  shared ~/.cache/gsd dir, single-sourced via package-identity; writers
  stamp package_name and readers reject foreign/absent lineage. Multi-
  runtime visibility preserved (same shared dir + filename across runtimes).
- New get-shit-done/bin/lib/legacy-cleanup.cjs seam: detects code-file
  references to the old package + the legacy fixed-name cache across home
  runtime dirs; installer auto-cleans on every install; --dry-run previews
  and mutates nothing. User hooks and dev-preferences are never touched.
- update.md cache-clear globs gsd-update-check*.json across ALL supported
  runtimes (adds cursor/windsurf/augment/trae/qwen/hermes/codebuddy/cline).
- Fix worker MODULE_NOT_FOUND post-install (ship managed-hooks-registry.cjs
  + degrade gracefully) so the per-package cache is always written.
- Diataxis how-to: docs/cleanup-get-shit-done-cc.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#607): add changeset for PR #611

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#607): set USERPROFILE alongside HOME in dry-run install test for Windows

os.homedir() reads USERPROFILE on win32, so HOME-only isolation let the
spawned installer scan the real runner home on windows-latest. Set both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-02 12:19:57 -04:00
committed by GitHub
parent df04aae5e4
commit 91f5bd7cb1
26 changed files with 1546 additions and 75 deletions

View File

@@ -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-<slug>.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.

View File

@@ -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=<name> or --profile=<n1>,<n2> (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 <runtime>
const runtimeArg = args[args.indexOf('--skills-root') + 1];

View File

@@ -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",

View File

@@ -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 |

View File

@@ -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
---

View File

@@ -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
```

View File

@@ -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<string, string>} 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,
};

View File

@@ -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,
});

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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,
};
/**

View File

@@ -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/<updateCacheFileName> (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);

View File

@@ -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',

View File

@@ -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 };

View File

@@ -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')
);

View File

@@ -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;
}

View File

@@ -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'
);
});
});

View File

@@ -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);

View File

@@ -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';

View File

@@ -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';

View File

@@ -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(

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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',