* 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>
328 lines
16 KiB
JavaScript
328 lines
16 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|