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