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:
253
get-shit-done/bin/lib/legacy-cleanup.cjs
Normal file
253
get-shit-done/bin/lib/legacy-cleanup.cjs
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user