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:
@@ -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')
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
177
tests/issue-607-cache-lineage.test.cjs
Normal file
177
tests/issue-607-cache-lineage.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
302
tests/issue-607-installer-dry-run.install.test.cjs
Normal file
302
tests/issue-607-installer-dry-run.install.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
327
tests/issue-607-legacy-cleanup.test.cjs
Normal file
327
tests/issue-607-legacy-cleanup.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user