* fix(#2544): stage the CommonJS marker in GSD-owned dirs, not the config root installSharedHooksBundle wrote `{"type":"commonjs"}` over <configRoot>/package.json unconditionally — no existence check, no merge, no backup — on every install and every /gsd-update re-install. On the 11 affected runtimes that file is often user-owned; on OpenCode and Kilo it is the documented place to declare local-plugin npm dependencies, so a user's name/type/dependencies/scripts were destroyed on each run. The uninstall path already read the file and unlinked it only on an exact content match. That asymmetry was the defect: the discipline existed in the codebase, it just was not applied on the write side. Move the marker into the directories GSD creates and fills with its own .js files — hooks/ (all shared-hooks runtimes, incl. Kimi's own root) and the nativePlugin dir (plugins/ for OpenCode+Kilo, extensions/ for pi) — and stop writing the config root entirely. New src/commonjs-marker.cts owns the marker string plus one ownership predicate (absent / gsd-owned / foreign, fail-closed on an unreadable file) shared by ensureCommonJsMarker and removeCommonJsMarker, so install and uninstall cannot drift apart again. Nothing else depended on the config-root marker: package identity is baked at build time (#378/#498) and version resolution prefers gsd-core/VERSION and already tolerates a missing root package.json (#1383) — Codex has installed without one all along. A package.json in plugins/ or extensions/ is inert to plugin discovery, which globs *.{ts,js} only (see installer-migration 006). Uninstall retires the pre-fix config-root marker, so upgrading users are cleaned up on removal, and still never touches a file it did not write. * fix(#2544): point the changeset fragment at the filed PR The fragment's `pr:` field is only knowable after `gh pr create` returns. * fix(#2544): register commonjs-marker.cjs in the tsc-generated ESLint ignore set bin/lib/commonjs-marker.cjs is tsc output (src/commonjs-marker.cts is the linted source), so it belongs in the ADR-457 ignore list like its siblings. Clears the lint-tests no-var failure and the repo-invariants "linted xor ignored" migration-state test. * fix(#2544): pin the kimi CommonJS marker to hooks/, not the ~/.kimi root The UPGRADE 1 test still asserted the pre-#2544 marker location (~/.kimi/package.json). The marker now lives inside ~/.kimi/hooks — the directory GSD itself creates — matching the updated golden-install-parity and install-tree fixtures. Also asserts the root marker is NOT written. * fix(#2544): make the CommonJS marker write path non-fatal Review round 2, Major 3 + Minor 1 + the stagedHooks nit. ensureCommonJsMarker rethrew any non-EEXIST write error and neither call site caught it, so EACCES on a read-only hooks/, EROFS, or ENOSPC aborted the whole install with a raw stack trace. Every other marker interaction in the module is best-effort — removeCommonJsMarker swallows unlink failures, classifyMarker swallows read failures — and this was the write path, i.e. the one most likely to fail on a locked-down config dir. It now returns a new 'failed' outcome and both call sites warn and continue. Sibling found while sweeping for the same defect class: fs.mkdirSync sat OUTSIDE the try block, so an unwritable parent threw past the guard entirely. Creating the directory is the same environmental hazard as writing into it, so it moved inside. Also in this file: - The hooks marker is now gated on `stagedHooks && hooksOk`, not stagedHooks alone. stagedHooks is computed from the SOURCE listing before the copy loop, so it stays true when the copies land but verifyInstalled() then fails — marking a hooks/ GSD did not successfully populate claims an ownership the install did not earn. - The uninstall rmdir of the native plugin dir is gated on GSD having actually removed something from it. Hoisting it out of the adapter-exists guard (so the marker-only case could prune) had silently widened it into deleting a user-created but empty plugins/ or extensions/ dir — the same "don't touch territory GSD didn't fill" principle this issue is about, inverted. - Kimi's pre-#2544 marker at its native hook root (~/.kimi) is retired at the same call site that writes its replacement. That path is outside kimi's configDir, so installer-migration 007 structurally cannot reach it. * fix(#2544): retire the stale config-root marker via installer-migration 007 Review round 2, Major 1 — the PR's headline claim was false for existing installs. Upgraders kept BOTH markers: the new one under hooks/ and the stale {"type":"commonjs"} at the config root, so their config root stayed pinned to CommonJS and their dependency manifest stayed gone until they uninstalled. The migration is unusual in one way, and it is the part worth reviewing: the config-root marker was never recorded in gsd-file-manifest.json (writeManifest records hooks/, agents/, commands/, scripts/ and the native plugin, never a root package.json), so classifyArtifact answers 'unknown' for it and the planner's own guard downgrades a remove-managed on an 'unknown' classification to preserve-user. 007 therefore supplies the "purpose-built detector for an old GSD-owned shape" that docs/installer-migrations.md#remove-managed sanctions — exact content match, the same predicate removeCommonJsMarker has always used — and declares the resulting classification on the action. A package.json with any other content is left untouched, and there is deliberately no backup-and-remove branch: a non-matching file here is not a patched GSD artifact, it is somebody else's file. Scope is all runtimes. The `runtimes` field is OMITTED rather than `[]`: validateStringArray requires the field to be non-empty WHEN PRESENT, while the runtime filter treats an empty array as "all" — so `runtimes: []` throws at plan time and the migration never runs. The metadata test pins this. Kimi is a deliberate carve-out, named in the migration's own header: its marker lived at ~/.kimi, outside kimi's configDir, and migration relPaths are structurally confined to configDir. It is retired by the installer instead. Registration: shipped-migrations table, .gitignore for the emitted .cjs, the EXPECTED_CHECKSUMS baseline, and the ESLint ignore set. That last one is not copied from migration 006 by rote — 006 needs no entry because it imports nothing, while 007 imports node builtins, so tsc emits its __importDefault helper and the `var` in it trips no-var. This is the same lint gate that made round 1 red. * test(#2544): fault-injection and multi-runtime marker coverage Review round 2, Major 2 + Minors 4 and 5. Major 2 — CONTRIBUTING.md:514-531 is mandatory for install/uninstall flows and the suite had no fs monkeypatching at all. Every branch now covered is one whose doc comment claims it as the module's safety posture: - classifyMarker non-ENOENT lstat error -> 'foreign' (the fail-closed rule), with an ENOENT control alongside it so the test discriminates rather than just asserting one side - classifyMarker readFileSync throw -> 'foreign' (present-but-unreadable never downgrades to the permissive answer) — the fixture's bytes are exactly GSD's marker, so the test fails if the code ever answers on content it could not read - a DIRECTORY at the marker path (CONTRIBUTING:521; the symlink case was already covered with a real symlink, the directory case needs no injection at all) - the ensureCommonJsMarker TOCTOU EEXIST branch — the entire reason for flag:'wx' - the new 'failed' outcome, for both writeFileSync (EACCES/EROFS/ENOSPC) and the mkdirSync that used to sit outside the guard - removeCommonJsMarker unlink throw -> false These save and restore fs methods in `finally` rather than using chmod 0o000, which does not fault under root and would pass vacuously in root Docker and CI. Minor 4 — uninstall was driven for opencode only. pi's extensions/ and both kimi locations now have behavioral coverage, install and uninstall, each paired with a user-authored-file case proving GSD leaves it alone. Minor 5 — the stagedHooks gate had no assertion behind its stated reason. A pre-existing, GSD-untouched hooks/ directory is now driven through a runtime that declares skipSharedHooksInstall and asserted to stay marker-free, with its user content intact. Also regression-tests the uninstall rmdir gate from the previous commit: an empty plugin dir GSD removed nothing from must survive. * docs(#2544): correct stale marker prose, register the module, document the trade-off Review round 2, Minors 2, 3 and 6. Minor 2 — six files asserted the installed ROOT ships the synthetic marker. None was load-bearing (all three walk-up consumers are VERSION-first with try/catch and the marker never carried a `version`), but ADR-457:52 is the rationale for keeping a generated module, so a future reader would mis-derive the constraint from it. Each site is corrected to what is now true: the installed tree carries no package.json with a .name at all, because the only ones GSD stages are {"type":"commonjs"} markers and they now live in GSD's own directories. Two of the six needed more than a location swap. hooks/gsd-check-update-worker.js and the platform-gate test both described `require('../package.json').name` resolving to undefined; post-#2544 that require does not resolve at all, so the history is kept accurate and the present-tense claim corrected rather than just moved. And src/runtime-artifact-conversion.cts described the no-root-package.json case as Codex-only — it is now every runtime, which strengthens that comment's own argument for lazy resolution. The generated .cjs sibling needs no edit: it is gitignored build output, not a tracked file. Minor 3 — src/commonjs-marker.cts had no CONTEXT.md entry, unlike every peer module, and CONTEXT.md is the #2 co-change partner of bin/install.js. Added, including the fail-closed posture and the never-throws contract. Minor 6 — the plugins//extensions/ marker shadows the config root for all .js siblings, so an OpenCode/Kilo user's ESM plugin/*.js stays broken. That is exactly what #2544's Fix section prescribed and it is disclosed in the PR body, but the PR body is not documentation. It now lives in the OpenCode section of docs/how-to/install-on-your-runtime.md, stated as a real constraint rather than a pure improvement, with the .ts mitigation and a fallback for ESM plugins. * test(#2544): attribute the CommonJS marker in the emitted-provenance rules The differential emitted-attribution gate (#2723, landed on `next` after this branch was cut) went red on the macOS shards once this PR rebased onto it. Two distinct causes, both real gaps rather than noise: 1. `plugins/package.json` and `extensions/package.json` matched NO rule — the `native-plugin` rule covers `*.{js,cjs,mjs}` only, so the marker read as an unattributed emitted family. 2. `hooks/package.json` fell through to `hooks-built`, which attributes an emitted `hooks/<X>` to a repo source `hooks/<X>`. There is no `hooks/package.json` in the repo, so it resolved to a nonexistent path. Cause 2 is exactly the failure already documented three lines above it for Copilot's `gsd-session.json` — "a code literal, not a built script" — so the fix follows that precedent rather than inventing one: `package.json` is excluded from `hooks-built` the same way, and a dedicated `commonjs-marker` rule attributes the family across all four roots it can appear in (both hooks roots plus `plugins`/`extensions`) to the sources that actually emit it. Deliberately a RULE, not an entry in tests/emitted-drift-ack.json. An ack is for a one-off ripple and goes stale by design — the gate fails a stale ack precisely so it cannot pre-clear the next change on that path. These markers are a permanent part of the emitted tree from #2544 onward, so they need standing attribution. Verified by reproducing the CI failure locally with GSD_EMITTED_BASE: 3 provenance errors + 12 unattributed paths before, 35/35 green after. * fix(#2544): route the #2717 hooks-surface marker helpers through commonjs-marker #2717 landed a second copy of ensureCommonJsMarker/removeCommonJsMarkerIfGsdOwned in src/runtime-hooks-surface.cts for the runtimes that stage .js hooks via dedicated paths (cursor/windsurf/codex). That copy had drifted from this PR's module on the two properties that matter: - ownership probe: `fs.existsSync` FOLLOWS symlinks and reports false for a DANGLING one, so a dangling package.json symlink classified as absent and the write went straight through it. Demonstrated: against the pre-fix copy, ensureCommonJsMarker() on a hooks/ dir holding a dangling package.json symlink returns true and creates {"type":"commonjs"} OUTSIDE that directory. - create: a plain writeFileSync leaves the classify->write window open, where commonjs-marker creates with flag:'wx' (O_EXCL). Both helpers now delegate to src/commonjs-marker.cts, which is what this PR's own docstring already claimed was the single place these rules are enforced. Exported signatures are unchanged (still boolean), so bin/install.js and the #2717 tests are unaffected. The new subtest is the only coverage that fails if the duplicate is ever reintroduced — the two implementations agree on every non-adversarial input, so the existing suites pass against both. * test(#2544): pin the stagedHooks gate on zcode, not windsurf The Minor-5 coverage picked windsurf because hostBehaviors.skipSharedHooksInstall kept it out of the shared hooks bundle, so GSD staged nothing into hooks/ and the marker was correctly absent. #2717 changed that premise: cursor/windsurf/codex now stage their .js hooks via dedicated paths and get the marker beside those scripts. Measured on this tree, windsurf stages 2 .js hooks and receives a marker — so the assertion was pinning behaviour that is now wrong, not the gate it was written for. ZCode is the durable choice: per #1821 it has hooksSurface:'none' AND no plugin surface to spawn hooks, so GSD stages no .js there by either route (measured: 0 staged, no marker). The property under test is unchanged — a user-created hooks/ directory GSD never fills stays marker-free. * test(#2544): use the shared cleanup helper in the migration test Addresses the review's Major 1. The suppression's stated reason — "no helpers import available" — was not correct: tests/helpers.cjs exports cleanup, and the other test file added in this same PR imports it (tests/commonjs-marker.test.cjs). The local reimplementation dropped two protections that are live on this repo's windows-latest lane: the CWD guard (Windows cannot remove a directory that is the current working directory) and the 20 x 250ms retry budget that absorbs the deferred-scan handle Windows Defender holds on newly-written files. Local function and suppression both removed; local/no-raw-rmsync-in-tests now passes without one. * test(#2544): expect hooks/package.json for the #2717 runtimes The fresh-install contract table predates #2717, which stages cursor/windsurf/ codex .js hooks via dedicated paths and writes the CommonJS marker beside them. All three therefore now receive hooks/package.json legitimately. Measured on this tree: codex stages 3 .js hooks, cursor 6, windsurf 2 — each with the marker; cline/copilot/trae/zcode stage none and get none, so their contracts are unchanged. * fix(#2544): gate the #2717 marker writes on having staged something The three dedicated marker writers #2717 added ran unconditionally. Each one mkdirs hooks/ up front and stages its scripts conditionally on the source existing, so with an absent or empty hook source they created a directory, filled it with nothing, and marked it as GSD's anyway. That is the same write-into-someone-else's-territory this issue is about, and installSharedHooksBundle already guards the identical case with `stagedHooks`. The dedicated paths now carry the matching gate: - cursor / windsurf: `installedScripts.size > 0` - codex: a new `codexStagedHooks` flag. The enclosing guard only proves that hooks/dist EXISTS; it says nothing about whether any CODEX_HOOKS_TO_COPY entry landed. Covered for cursor and windsurf by driving each writer against a src tree whose hooks/ dir is empty. The codex leg is defensive and deliberately uncovered: its trigger state needs a package tree where hooks/dist exists but holds none of the allowlist, which is not constructible from a real checkout. * test(#2544): scope the commonjs-marker sources per root The rule declared one flat source list for every marker root, so `extensions/package.json` was attributed to runtime-hooks-surface.cts (which never writes there) and `.kimi/hooks/package.json` to install-engine.cts. That is not merely untidy. emitted-diff.cjs accepts the FIRST satisfied source, so a flat list containing bin/install.js let any change anywhere in that 13k-line file authorise marker drift for every root — the blanket escape hatch this file's own agents-verbatim comment refuses for exactly the same reason. Sources are now derived per root from ctx.rel. Note the rule ctx is `{ rel, runtime }` and carries no `root`, so keying on ctx.root would have sent every path down one branch silently. * test(#2544): state precisely what the zcode assertion pins The comment claimed the test pinned installSharedHooksBundle's `stagedHooks` gate. It does not, and neither did the windsurf version it replaced: zcode declares skipSharedHooksInstall, so the outer guard skips that helper entirely and the gate is never evaluated. The test passes on the runtime exclusion. What it does pin — the outcome a pre-existing, GSD-untouched hooks/ stays marker-free — is still worth having, and is what the review asked for. The two `staging zero hook scripts` tests are the ones that pin a real staged-nothing gate. Comment corrected rather than left implying coverage that is not there. --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2820 lines
104 KiB
JavaScript
2820 lines
104 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const {
|
|
applyInstallerMigrationPlan,
|
|
classifyArtifact,
|
|
discoverInstallerMigrations,
|
|
INSTALL_STATE_NAME,
|
|
migrationChecksum,
|
|
planInstallerMigrations,
|
|
readInstallState,
|
|
runInstallerMigrations,
|
|
writeInstallState,
|
|
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const firstTimeBaselineMigration = require('../gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs');
|
|
const opencodeBaselineCommandsDirMigration = require('../gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs');
|
|
|
|
function createTempInstall() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-'));
|
|
}
|
|
|
|
function cleanup(dir) {
|
|
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup predates helpers.cjs; name collision prevents import
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
function sha256(content) {
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
function writeFile(root, relPath, content) {
|
|
const fullPath = path.join(root, relPath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content, 'utf8');
|
|
}
|
|
|
|
function writeManifest(root, files) {
|
|
fs.writeFileSync(
|
|
path.join(root, 'gsd-file-manifest.json'),
|
|
JSON.stringify({
|
|
version: '1.49.0',
|
|
timestamp: '2026-05-10T00:00:00.000Z',
|
|
mode: 'full',
|
|
files,
|
|
}, null, 2),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function migrationRecord(overrides = {}) {
|
|
return {
|
|
id: '2026-05-11-remove-old-hook',
|
|
title: 'Remove retired hook',
|
|
description: 'Remove retired hook',
|
|
introducedIn: '1.50.0',
|
|
scopes: ['global', 'local'],
|
|
destructive: true,
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function legacyCodexHook(configDir) {
|
|
return {
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function userHook(command) {
|
|
return {
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
test('records a first-time baseline while preserving user-owned artifacts', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'gsd-core/workflows/plan.md', 'managed workflow\n');
|
|
writeFile(configDir, 'gsd-core/USER-PROFILE.md', 'user profile\n');
|
|
writeManifest(configDir, {
|
|
'gsd-core/workflows/plan.md': sha256('managed workflow\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-first-time-baseline-scan']);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'gsd-core/workflows/plan.md'), 'utf8'), 'managed workflow\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'gsd-core/USER-PROFILE.md'), 'utf8'), 'user profile\n');
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'gsd-core/workflows/plan.md',
|
|
classification: 'managed-pristine',
|
|
},
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'gsd-core/USER-PROFILE.md',
|
|
classification: 'user-owned',
|
|
},
|
|
]
|
|
);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-first-time-baseline-scan',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('preserves unknown files discovered in known install surfaces by default', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.blocked, undefined);
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
classification: 'unknown',
|
|
},
|
|
]
|
|
);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom-user-hook.js'), 'utf8'), 'user hook\n');
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-first-time-baseline-scan',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('preserves user-owned skill files during baseline without hashing their content', (t) => {
|
|
const configDir = createTempInstall();
|
|
const originalOpenSync = fs.openSync;
|
|
t.after(() => {
|
|
fs.openSync = originalOpenSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
writeFile(configDir, 'skills/custom-user-skill/SKILL.md', 'user skill\n');
|
|
writeManifest(configDir, {});
|
|
const userSkillPath = path.join(configDir, 'skills/custom-user-skill/SKILL.md');
|
|
fs.openSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(userSkillPath)) {
|
|
throw new Error('user-owned skill content should not be hashed during baseline');
|
|
}
|
|
return originalOpenSync.call(fs, filePath, ...args);
|
|
};
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
currentHash: action.currentHash,
|
|
})),
|
|
[
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'skills/custom-user-skill/SKILL.md',
|
|
classification: 'user-owned',
|
|
currentHash: null,
|
|
},
|
|
]
|
|
);
|
|
});
|
|
|
|
test('blocks stale GSD-looking baseline artifacts for explicit user choice', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/gsd-retired-hook.js', 'old gsd hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:02.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.equal(result.journalRelPath, null);
|
|
assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/gsd-retired-hook.js'), 'utf8'), 'old gsd hook\n');
|
|
assert.deepEqual(
|
|
result.blocked.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
choices: action.choices,
|
|
})),
|
|
[
|
|
{
|
|
type: 'prompt-user',
|
|
relPath: 'hooks/gsd-retired-hook.js',
|
|
classification: 'stale-gsd-looking',
|
|
choices: ['keep', 'remove'],
|
|
},
|
|
]
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('records known generated agent artifacts so profile cleanup can remove them', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'agents/gsd-executor.md', 'old generated agent\n');
|
|
writeFile(configDir, 'agents/gsd-executor.toml', 'old generated agent config\n');
|
|
writeFile(configDir, 'agents/gsd-local-experiment.md', 'user experiment\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'codex',
|
|
scope: 'global',
|
|
migrations: [firstTimeBaselineMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-05-11T00:00:03.000Z',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'agents/gsd-executor.md',
|
|
classification: 'unknown',
|
|
},
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'agents/gsd-executor.toml',
|
|
classification: 'unknown',
|
|
},
|
|
{
|
|
type: 'prompt-user',
|
|
relPath: 'agents/gsd-local-experiment.md',
|
|
classification: 'stale-gsd-looking',
|
|
},
|
|
]
|
|
);
|
|
assert.deepEqual(result.blocked.map((action) => action.relPath), ['agents/gsd-local-experiment.md']);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.md'), 'utf8'), 'old generated agent\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.toml'), 'utf8'), 'old generated agent config\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-local-experiment.md'), 'utf8'), 'user experiment\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Migration 005: OpenCode commands/ (plural) baseline scan (#2329 follow-up)
|
|
//
|
|
// 000-first-time-baseline.cts's RUNTIME_SURFACES.opencode is a shipped,
|
|
// immutable body that still only names the legacy singular `command/`
|
|
// directory (see docs/installer-migrations.md#state-files). These tests
|
|
// pin migration 005's widened scan of the plural `commands/` surface.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('baselines pre-existing OpenCode commands/ files: managed, unknown, and stale-GSD-looking', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n');
|
|
writeFile(configDir, 'commands/my-custom-command.md', 'user command\n');
|
|
writeFile(configDir, 'commands/gsd-retired-command.md', 'stale gsd-looking file, not in manifest\n');
|
|
writeManifest(configDir, {
|
|
'commands/gsd-plan-phase.md': sha256('managed command\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:00.000Z',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.plan.actions.map((action) => ({
|
|
type: action.type,
|
|
relPath: action.relPath,
|
|
classification: action.classification,
|
|
})),
|
|
[
|
|
{
|
|
type: 'record-baseline',
|
|
relPath: 'commands/gsd-plan-phase.md',
|
|
classification: 'managed-pristine',
|
|
},
|
|
{
|
|
type: 'baseline-preserve-user',
|
|
relPath: 'commands/my-custom-command.md',
|
|
classification: 'unknown',
|
|
},
|
|
{
|
|
type: 'prompt-user',
|
|
relPath: 'commands/gsd-retired-command.md',
|
|
classification: 'stale-gsd-looking',
|
|
},
|
|
]
|
|
);
|
|
// The stale-GSD-looking file blocks the plan (needs explicit user choice),
|
|
// so nothing was applied and no install state was written yet.
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false);
|
|
// Every file on disk is untouched — baseline-preserve-user/record-baseline/
|
|
// prompt-user are all non-mutating classification actions.
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/my-custom-command.md'), 'utf8'), 'user command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-retired-command.md'), 'utf8'), 'stale gsd-looking file, not in manifest\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('OpenCode commands/ baseline is idempotent — a second run does not re-plan already-applied files', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n');
|
|
writeFile(configDir, 'commands/my-custom-command.md', 'user command\n');
|
|
writeManifest(configDir, {
|
|
'commands/gsd-plan-phase.md': sha256('managed command\n'),
|
|
});
|
|
|
|
const first = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:01.000Z',
|
|
});
|
|
assert.deepEqual(first.appliedMigrationIds, ['2026-07-17-opencode-baseline-commands-dir']);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-07-17-opencode-baseline-commands-dir',
|
|
]);
|
|
|
|
// Second run: the migration id is now applied, so it must never re-run,
|
|
// regardless of what baselineScan is passed.
|
|
const second = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:02.000Z',
|
|
});
|
|
assert.deepEqual(second.appliedMigrationIds, []);
|
|
assert.deepEqual(second.plan.actions, []);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-07-17-opencode-baseline-commands-dir',
|
|
]);
|
|
// Files remain untouched across both runs.
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/my-custom-command.md'), 'utf8'), 'user command\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('OpenCode commands/ baseline migration is scoped to opencode and never plans for Kilo', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
// Kilo's descriptor keeps the singular `command/` dir; a `commands/` (plural)
|
|
// directory here would be unrelated to Kilo's install surface. This proves the
|
|
// migration's `runtimes: ['opencode']` scoping keeps Kilo installs untouched.
|
|
writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n');
|
|
writeFile(configDir, 'command/gsd-plan-phase.md', 'kilo managed command\n');
|
|
writeManifest(configDir, {
|
|
'commands/gsd-plan-phase.md': sha256('managed command\n'),
|
|
'command/gsd-plan-phase.md': sha256('kilo managed command\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'kilo',
|
|
scope: 'global',
|
|
migrations: [opencodeBaselineCommandsDirMigration],
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:03.000Z',
|
|
});
|
|
|
|
// migrationMatchesContext filters this migration out entirely for kilo
|
|
// (runtimes: ['opencode']) before plan() is ever invoked: it is not
|
|
// "pending", produces zero actions, is never applied, and no install-state
|
|
// file is written for this run at all.
|
|
assert.deepEqual(result.plan.actions, []);
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n');
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'command/gsd-plan-phase.md'), 'utf8'), 'kilo managed command\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('regression (#2329 follow-up): pre-existing unmanifested commands/gsd-*.md is no longer silently destroyed', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
// Simulates a pre-existing, non-GSD file sitting under OpenCode's commands/
|
|
// directory before GSD's first-ever migration-tracked run against this
|
|
// configDir (no manifest, no install state yet).
|
|
writeFile(configDir, 'commands/gsd-retired-plan.md', 'pre-existing file, not GSD-written\n');
|
|
|
|
// Full default migration set (all shipped migrations, including 000 AND 005),
|
|
// matching production: bin/install.js calls runInstallerMigrations with no
|
|
// explicit `migrations` override.
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'opencode',
|
|
scope: 'global',
|
|
baselineScan: true,
|
|
now: () => '2026-07-17T00:00:04.000Z',
|
|
});
|
|
|
|
// Before this fix, RUNTIME_SURFACES.opencode omitted `commands/`, so this file
|
|
// was invisible to every migration and ordinary materialization would delete it
|
|
// unconditionally with a clean exit. Now it is caught and blocks the install
|
|
// pending an explicit user choice — the same protection the legacy `command/`
|
|
// surface already had.
|
|
assert.deepEqual(result.appliedMigrationIds, []);
|
|
assert.ok(Array.isArray(result.blocked) && result.blocked.length > 0, 'expected a blocked prompt-user action');
|
|
const blockedForFile = result.blocked.find((action) => action.relPath === 'commands/gsd-retired-plan.md');
|
|
assert.ok(blockedForFile, 'expected commands/gsd-retired-plan.md to be blocked pending user choice');
|
|
assert.equal(blockedForFile.type, 'prompt-user');
|
|
assert.equal(blockedForFile.migrationId, '2026-07-17-opencode-baseline-commands-dir');
|
|
// The file itself was never touched — migrations only classify, they do not
|
|
// mutate disk.
|
|
assert.equal(
|
|
fs.readFileSync(path.join(configDir, 'commands/gsd-retired-plan.md'), 'utf8'),
|
|
'pre-existing file, not GSD-written\n'
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('plans a pending migration against an unchanged managed file', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.deepEqual(plan.pendingMigrationIds, ['2026-05-11-remove-old-hook']);
|
|
assert.equal(plan.blocked.length, 0);
|
|
assert.equal(plan.actions.length, 1);
|
|
assert.deepEqual(
|
|
{
|
|
migrationId: plan.actions[0].migrationId,
|
|
type: plan.actions[0].type,
|
|
relPath: plan.actions[0].relPath,
|
|
reason: plan.actions[0].reason,
|
|
classification: plan.actions[0].classification,
|
|
originalHash: plan.actions[0].originalHash,
|
|
currentHash: plan.actions[0].currentHash,
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('managed hook\n'),
|
|
}
|
|
);
|
|
assert.match(plan.actions[0].migrationChecksum, /^sha256:/);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('plans backup before removal for a modified managed file', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.equal(plan.blocked.length, 0);
|
|
assert.equal(plan.actions.length, 1);
|
|
assert.equal(plan.actions[0].type, 'backup-and-remove');
|
|
assert.equal(plan.actions[0].classification, 'managed-modified');
|
|
assert.equal(plan.actions[0].originalHash, sha256('managed hook\n'));
|
|
assert.equal(plan.actions[0].currentHash, sha256('user changed hook\n'));
|
|
assert.equal(plan.actions[0].backupRelPath, null);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('blocks removal of unknown files by preserving them by default', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n');
|
|
writeManifest(configDir, {});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture asks to retire a matching hook path',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
assert.equal(plan.actions.length, 1);
|
|
assert.equal(plan.actions[0].type, 'preserve-user');
|
|
assert.equal(plan.actions[0].requestedType, 'remove-managed');
|
|
assert.equal(plan.actions[0].classification, 'unknown');
|
|
assert.deepEqual(plan.blocked, [plan.actions[0]]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('fails closed when install state JSON is malformed', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), '{ not json\n', 'utf8');
|
|
|
|
assert.throws(
|
|
() => readInstallState(configDir),
|
|
/invalid installer migration state JSON/
|
|
);
|
|
});
|
|
|
|
test('computes each migration checksum once per planned migration', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/first.js', 'first hook\n');
|
|
writeFile(configDir, 'hooks/second.js', 'second hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/first.js': sha256('first hook\n'),
|
|
'hooks/second.js': sha256('second hook\n'),
|
|
});
|
|
let checksumReads = 0;
|
|
const migration = {
|
|
...migrationRecord({
|
|
id: '2026-05-11-remove-two-hooks',
|
|
title: 'Remove two retired hooks',
|
|
description: 'Remove retired hooks',
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/first.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/second.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
get checksum() {
|
|
checksumReads += 1;
|
|
return 'sha256:precomputed';
|
|
},
|
|
};
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [migration],
|
|
scope: 'global',
|
|
});
|
|
|
|
assert.equal(plan.actions.length, 2);
|
|
assert.equal(checksumReads, 1);
|
|
});
|
|
|
|
test('tolerates an applied-migration checksum drift instead of aborting the upgrade', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
// A migration that a prior release recorded as applied under a DIFFERENT body,
|
|
// so the stored checksum no longer matches the current computed checksum.
|
|
const migration = migrationRecord({ id: '2026-05-11-remove-old-hook' });
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
appliedAt: '2026-01-01T00:00:00.000Z',
|
|
journal: null,
|
|
checksum: 'sha256:stale-pre-1-3-0-value',
|
|
},
|
|
],
|
|
});
|
|
|
|
// Planning must NOT throw, must skip the already-applied migration, and must
|
|
// surface the drift on the plan for downstream reconciliation.
|
|
let plan;
|
|
assert.doesNotThrow(() => {
|
|
plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [migration],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
});
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.equal(plan.actions.length, 0);
|
|
assert.ok(Array.isArray(plan.checksumDrift));
|
|
const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook');
|
|
assert.ok(drift, 'expected checksum drift to be reported for the applied migration');
|
|
assert.equal(drift.storedChecksum, 'sha256:stale-pre-1-3-0-value');
|
|
assert.match(drift.currentChecksum, /^sha256:/);
|
|
assert.notEqual(drift.currentChecksum, drift.storedChecksum);
|
|
});
|
|
|
|
test('classifies large files without loading the whole file through readFileSync', (t) => {
|
|
const configDir = createTempInstall();
|
|
const originalReadFileSync = fs.readFileSync;
|
|
t.after(() => {
|
|
fs.readFileSync = originalReadFileSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
const relPath = 'skills/gsd-large/SKILL.md';
|
|
const fullPath = path.join(configDir, relPath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, Buffer.alloc(1024 * 1024 + 1, 'a'));
|
|
|
|
fs.readFileSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(fullPath)) {
|
|
throw new Error('large file should be streamed for hashing');
|
|
}
|
|
return originalReadFileSync.call(fs, filePath, ...args);
|
|
};
|
|
|
|
const artifact = classifyArtifact(configDir, relPath, { files: {} });
|
|
|
|
assert.equal(artifact.classification, 'unknown');
|
|
assert.match(artifact.currentHash, /^[0-9a-f]{64}$/);
|
|
});
|
|
|
|
test('applies an unblocked plan with a journal and install-state update', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
assert.equal(fs.existsSync(path.join(configDir, 'hooks/old-hook.js')), false);
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
|
|
assert.match(
|
|
result.journalRelPath,
|
|
/^gsd-migration-journal\/2026-05-11T00-00-01-000Z-[0-9a-f]+\.json$/
|
|
);
|
|
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
assert.deepEqual(journal.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
|
|
assert.equal(journal.actions[0].relPath, 'hooks/old-hook.js');
|
|
|
|
const state = readInstallState(configDir);
|
|
assert.deepEqual(state.appliedMigrations.map((entry) => entry.id), ['2026-05-11-remove-old-hook']);
|
|
assert.match(state.appliedMigrations[0].checksum, /^sha256:/);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('uses unique journal paths for applies that share a timestamp', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/first.js', 'first hook\n');
|
|
writeFile(configDir, 'hooks/second.js', 'second hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/first.js': sha256('first hook\n'),
|
|
'hooks/second.js': sha256('second hook\n'),
|
|
});
|
|
const now = () => '2026-05-11T00:00:09.000Z';
|
|
|
|
const first = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan: {
|
|
blocked: [],
|
|
actions: [{
|
|
migrationId: 'first-migration',
|
|
migrationChecksum: 'sha256:first',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/first.js',
|
|
reason: 'first',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('first hook\n'),
|
|
currentHash: sha256('first hook\n'),
|
|
}],
|
|
},
|
|
now,
|
|
});
|
|
const second = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan: {
|
|
blocked: [],
|
|
actions: [{
|
|
migrationId: 'second-migration',
|
|
migrationChecksum: 'sha256:second',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/second.js',
|
|
reason: 'second',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('second hook\n'),
|
|
currentHash: sha256('second hook\n'),
|
|
}],
|
|
},
|
|
now,
|
|
});
|
|
|
|
assert.notEqual(first.journalRelPath, second.journalRelPath);
|
|
assert.equal(fs.existsSync(path.join(configDir, first.journalRelPath)), true);
|
|
assert.equal(fs.existsSync(path.join(configDir, second.journalRelPath)), true);
|
|
});
|
|
|
|
test('stores modified-file backups under the unique migration run journal', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:10.000Z',
|
|
});
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
const backupRelPath = journal.actions[0].backupRelPath;
|
|
|
|
assert.match(backupRelPath, /^gsd-migration-journal\/2026-05-11T00-00-10-000Z-[0-9a-f]+-backups\/hooks\/old-hook\.js$/);
|
|
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user changed hook\n');
|
|
});
|
|
|
|
test('successful migration rollback removes run-scoped backup directories', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord(),
|
|
],
|
|
scope: 'global',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:11.000Z',
|
|
});
|
|
|
|
result.rollback();
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
|
|
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
|
|
assert.equal(
|
|
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) => name.includes('backups')),
|
|
false
|
|
);
|
|
});
|
|
|
|
test('refuses to run migrations while another installer owns the migration lock', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
fs.writeFileSync(path.join(configDir, 'gsd-install-migration.lock'), 'held by test\n', 'utf8');
|
|
|
|
assert.throws(
|
|
() => runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 0,
|
|
}),
|
|
/installer migration lock is held/
|
|
);
|
|
});
|
|
|
|
test('reports lock release failures after migration work completes', (t) => {
|
|
const configDir = createTempInstall();
|
|
const originalUnlinkSync = fs.unlinkSync;
|
|
t.after(() => {
|
|
fs.unlinkSync = originalUnlinkSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
// The release closure uses fs.unlinkSync (not fs.rmSync) so that EPERM is
|
|
// NOT silently swallowed on Windows (#3670). Mock unlinkSync to simulate
|
|
// a Windows NTFS EPERM condition when the lock file is removed.
|
|
fs.unlinkSync = (targetPath) => {
|
|
if (path.basename(String(targetPath)) === 'gsd-install-migration.lock') {
|
|
throw new Error('simulated lock unlink failure');
|
|
}
|
|
return originalUnlinkSync.call(fs, targetPath);
|
|
};
|
|
|
|
assert.throws(
|
|
() => runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
}),
|
|
/failed to release installer migration lock/
|
|
);
|
|
});
|
|
|
|
test('rollback handle restores files and install state after a successful apply', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: 'already-applied',
|
|
appliedAt: '2026-05-10T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-remove-old-hook',
|
|
title: 'Remove retired hook',
|
|
description: 'Remove retired hook',
|
|
introducedIn: '1.50.0',
|
|
scopes: ['global'],
|
|
destructive: true,
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
const result = applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
result.rollback();
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-applied']);
|
|
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rolls back touched files and leaves state unchanged when apply fails', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = {
|
|
pendingMigrationIds: ['2026-05-11-remove-old-hook'],
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('managed hook\n'),
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'unsupported-test-action',
|
|
relPath: 'hooks/other.js',
|
|
reason: 'force failure',
|
|
classification: 'managed-pristine',
|
|
originalHash: null,
|
|
currentHash: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:02.000Z',
|
|
}),
|
|
/unsupported migration action type/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations, []);
|
|
assert.equal(
|
|
fs.existsSync(path.join(configDir, 'gsd-migration-journal')) &&
|
|
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
|
|
name.startsWith('2026-05-11T00-00-02-000Z')
|
|
),
|
|
false
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('cleans rollback and backup artifacts when migration apply fails', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
const plan = {
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
migrationChecksum: 'sha256:remove',
|
|
type: 'backup-and-remove',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-modified',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('user changed hook\n'),
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
migrationChecksum: 'sha256:remove',
|
|
type: 'unsupported-test-action',
|
|
relPath: 'hooks/other.js',
|
|
reason: 'force failure',
|
|
classification: 'managed-pristine',
|
|
originalHash: null,
|
|
currentHash: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:12.000Z',
|
|
}),
|
|
/unsupported migration action type/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
|
|
assert.equal(
|
|
fs.existsSync(path.join(configDir, 'gsd-migration-journal')) &&
|
|
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
|
|
name.startsWith('2026-05-11T00-00-12-000Z')
|
|
),
|
|
false
|
|
);
|
|
});
|
|
|
|
test('reports rollback restore failures instead of swallowing them', () => {
|
|
const configDir = createTempInstall();
|
|
const originalCopyFileSync = fs.copyFileSync;
|
|
try {
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
const plan = {
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retired hook',
|
|
classification: 'managed-pristine',
|
|
originalHash: sha256('managed hook\n'),
|
|
currentHash: sha256('managed hook\n'),
|
|
},
|
|
{
|
|
migrationId: '2026-05-11-remove-old-hook',
|
|
type: 'unsupported-test-action',
|
|
relPath: 'hooks/other.js',
|
|
reason: 'force failure',
|
|
classification: 'managed-pristine',
|
|
originalHash: null,
|
|
currentHash: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
fs.copyFileSync = (src, dest) => {
|
|
if (/2026-05-11T00-00-04-000Z-[0-9a-f]+-rollback/.test(String(src))) {
|
|
throw new Error('simulated rollback copy failure');
|
|
}
|
|
return originalCopyFileSync(src, dest);
|
|
};
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:04.000Z',
|
|
}),
|
|
(error) => {
|
|
assert.match(error.message, /rollback incomplete/);
|
|
assert.equal(error.rollbackFailures.length, 1);
|
|
assert.equal(error.rollbackFailures[0].relPath, 'hooks/old-hook.js');
|
|
return true;
|
|
}
|
|
);
|
|
} finally {
|
|
fs.copyFileSync = originalCopyFileSync;
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rejects executable preserve-user actions because preservation blocks non-interactive apply', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
assert.throws(
|
|
() => applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan: {
|
|
blocked: [],
|
|
actions: [
|
|
{
|
|
migrationId: '2026-05-11-preserve-user',
|
|
type: 'preserve-user',
|
|
relPath: 'hooks/custom-user-hook.js',
|
|
reason: 'unknown user hook',
|
|
classification: 'unknown',
|
|
originalHash: null,
|
|
currentHash: sha256('user hook\n'),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
/unsupported migration action type: preserve-user/
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('keeps prior install state intact when a state write fails mid-write', () => {
|
|
const configDir = createTempInstall();
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
try {
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [{ id: 'already-safe', appliedAt: '2026-05-11T00:00:00.000Z' }],
|
|
});
|
|
|
|
fs.writeFileSync = (filePath, content, ...rest) => {
|
|
if (path.basename(filePath).startsWith(`${INSTALL_STATE_NAME}.tmp-`)) {
|
|
throw new Error('simulated temp state write failure');
|
|
}
|
|
return originalWriteFileSync(filePath, content, ...rest);
|
|
};
|
|
|
|
assert.throws(
|
|
() => writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [{ id: 'new-migration', appliedAt: '2026-05-11T00:00:01.000Z' }],
|
|
}),
|
|
/simulated temp state write failure/
|
|
);
|
|
} finally {
|
|
fs.writeFileSync = originalWriteFileSync;
|
|
}
|
|
|
|
try {
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-safe']);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('skips migration records already present in install state', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
plan: () => {
|
|
throw new Error('already-applied migration planner must not run');
|
|
},
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:03.000Z',
|
|
});
|
|
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.deepEqual(plan.actions, []);
|
|
assert.deepEqual(plan.blocked, []);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('marks zero-action pending migrations as applied', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-noop-cleanup',
|
|
title: 'No-op cleanup',
|
|
description: 'No-op cleanup',
|
|
destructive: false,
|
|
plan: () => [],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:06.000Z',
|
|
});
|
|
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-noop-cleanup']);
|
|
assert.equal(result.journalRelPath, null);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
|
'2026-05-11-noop-cleanup',
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('surfaces checksum drift for an already-applied migration without aborting', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-remove-old-hook',
|
|
checksum: 'sha256:old-definition',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
let plan;
|
|
assert.doesNotThrow(() => {
|
|
plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
checksum: 'sha256:new-definition',
|
|
plan: () => [],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
});
|
|
});
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.ok(Array.isArray(plan.checksumDrift));
|
|
const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook');
|
|
assert.ok(drift, 'expected drift entry for the applied migration');
|
|
assert.equal(drift.storedChecksum, 'sha256:old-definition');
|
|
assert.equal(drift.currentChecksum, 'sha256:new-definition');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('ignores checksum drift for applied migrations outside the active runtime scope', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: '2026-05-11-codex-only',
|
|
checksum: 'sha256:old-definition',
|
|
appliedAt: '2026-05-11T00:00:00.000Z',
|
|
journal: 'gsd-migration-journal/prior.json',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-codex-only',
|
|
title: 'Codex-only migration',
|
|
description: 'Codex-only migration',
|
|
checksum: 'sha256:new-definition',
|
|
runtimes: ['codex'],
|
|
scopes: ['global'],
|
|
plan: () => {
|
|
throw new Error('out-of-scope migration planner must not run');
|
|
},
|
|
}),
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(plan.pendingMigrationIds, []);
|
|
assert.deepEqual(plan.actions, []);
|
|
assert.deepEqual(plan.blocked, []);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('discovers migration records from a directory in filename order', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
const migrationsDir = path.join(configDir, 'migrations');
|
|
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(migrationsDir, '002-second.cjs'),
|
|
"module.exports = { id: 'second', title: 'Second', description: 'second', introducedIn: '1.50.0', scopes: ['global', 'local'], destructive: false, plan: () => [] };\n",
|
|
'utf8'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(migrationsDir, '001-first.cjs'),
|
|
"module.exports = { id: 'first', title: 'First', description: 'first', introducedIn: '1.50.0', scopes: ['global', 'local'], destructive: false, plan: () => [] };\n",
|
|
'utf8'
|
|
);
|
|
|
|
const migrations = discoverInstallerMigrations({ migrationsDir });
|
|
|
|
assert.deepEqual(migrations.map((migration) => migration.id), ['first', 'second']);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rejects migration actions that escape the install root', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
assert.throws(
|
|
() => planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: '2026-05-11-bad-path',
|
|
title: 'Bad path',
|
|
description: 'Bad path',
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/../../outside.js',
|
|
reason: 'bad path',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
}),
|
|
/relPath must stay inside configDir/
|
|
);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('rejects migration actions that normalize to the install root', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeManifest(configDir, {});
|
|
|
|
for (const relPath of ['.', 'hooks/..']) {
|
|
assert.throws(
|
|
() => planInstallerMigrations({
|
|
configDir,
|
|
migrations: [
|
|
migrationRecord({
|
|
id: `2026-05-11-bad-path-${relPath.replace(/[^a-z0-9]/gi, '-')}`,
|
|
title: 'Bad path',
|
|
description: 'Bad path',
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath,
|
|
reason: 'bad path',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
scope: 'global',
|
|
}),
|
|
/relPath must stay inside configDir/
|
|
);
|
|
}
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('runs discovered installer migrations against manifest-managed legacy orphan files', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeFile(configDir, 'hooks/custom.js', 'custom hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:05.000Z',
|
|
});
|
|
|
|
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom.js'), 'utf8'), 'custom hook\n');
|
|
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-legacy-orphan-files']);
|
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['2026-05-11-legacy-orphan-files']);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('backs up modified legacy orphan files before removing them', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(configDir, 'hooks/statusline.js', 'user modified legacy hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: discoverInstallerMigrations({
|
|
migrationsDir: path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'installer-migrations'),
|
|
}),
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:05.000Z',
|
|
});
|
|
const action = plan.actions.find((item) => item.relPath === 'hooks/statusline.js');
|
|
|
|
assert.equal(action.type, 'backup-and-remove');
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:05.000Z',
|
|
});
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
const backupRelPath = journal.actions.find((item) => item.relPath === 'hooks/statusline.js').backupRelPath;
|
|
|
|
assert.equal(fs.existsSync(path.join(configDir, 'hooks/statusline.js')), false);
|
|
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user modified legacy hook\n');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('runs a Codex legacy hooks.json cleanup migration without removing user hooks', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [
|
|
legacyCodexHook(configDir),
|
|
userHook('node "/Users/example/bin/user-hook.js"'),
|
|
userHook('node "/Users/example/bin/gsd-check-update.js"'),
|
|
],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'codex',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:06.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
|
|
assert.deepEqual(commands, [
|
|
'node "/Users/example/bin/user-hook.js"',
|
|
'node "/Users/example/bin/gsd-check-update.js"',
|
|
]);
|
|
assert.ok(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'));
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('preserves unrelated empty hooks.json structure while pruning legacy Codex hooks', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [
|
|
legacyCodexHook(configDir),
|
|
{ hooks: [] },
|
|
{ metadata: null },
|
|
],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'codex',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:06.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
|
|
assert.deepEqual(hooksJson.SessionStart, [
|
|
{ hooks: [] },
|
|
{ metadata: null },
|
|
]);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
test('skips runtime-specific migration records for other runtimes', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
writeFile(
|
|
configDir,
|
|
'hooks.json',
|
|
JSON.stringify({
|
|
SessionStart: [legacyCodexHook(configDir)],
|
|
}, null, 2)
|
|
);
|
|
writeManifest(configDir, {});
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'claude',
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:07.000Z',
|
|
});
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
|
|
assert.equal(hooksJson.SessionStart[0].hooks[0].command, `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`);
|
|
assert.equal(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'), false);
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Checksum-baseline guardrail (issue #670)
|
|
//
|
|
// Shipped installer-migration bodies are immutable: editing a released body
|
|
// breaks the stored checksum for any user who has already applied that
|
|
// migration, which was the root cause of issue #670.
|
|
//
|
|
// This test locks every shipped migration to its committed checksum so that CI
|
|
// catches accidental body edits. When you INTENTIONALLY change the behaviour
|
|
// of a migration you must add a NEW fix-forward migration id instead; if for
|
|
// some extraordinary reason you truly need to update an existing baseline, add
|
|
// the new checksum here with a comment explaining why.
|
|
//
|
|
// Mechanism: compute each migration's checksum directly via the exported
|
|
// migrationChecksum() (scope-independent) and assert it matches the committed
|
|
// baseline. This is simpler and more robust than the previous plan()-based
|
|
// approach because it doesn't depend on runtime/scope filtering.
|
|
// ---------------------------------------------------------------------------
|
|
test('shipped installer-migration checksums are locked to a committed baseline (issue #670 guardrail)', () => {
|
|
// Committed baseline — update ONLY when adding a new migration or performing an
|
|
// extraordinary intentional body change (add a comment explaining why). Editing a
|
|
// shipped migration body breaks the stored checksum for everyone who already applied
|
|
// it (root cause of #670) — add a NEW fix-forward migration id instead.
|
|
const EXPECTED_CHECKSUMS = {
|
|
'2026-05-11-first-time-baseline-scan':
|
|
'sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a',
|
|
'2026-05-11-legacy-orphan-files':
|
|
'sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c',
|
|
'2026-05-11-codex-legacy-hooks-json':
|
|
'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058',
|
|
'2026-06-02-rename-get-shit-done-to-gsd-core':
|
|
'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95',
|
|
// Migration 004: prune stale gsd-pristine/get-shit-done/ snapshots (#934) // gsd-allow-legacy-name
|
|
'2026-06-09-prune-stale-pristine-get-shit-done': // gsd-allow-legacy-name
|
|
'sha256:6555dd044659276fbc204e81793cd92c5315d54e7316bcdd82d2c98d15a7e9e8',
|
|
// Migration 005 (NEW, added here per this test's own sanctioned "adding a new
|
|
// migration" case — not a shipped-body edit): baseline OpenCode's commands/
|
|
// (plural) directory during the first-time scan. #2329 moved OpenCode command
|
|
// materialization from legacy command/ to commands/, but 000's RUNTIME_SURFACES
|
|
// is a shipped, immutable body that still only names command/, so this
|
|
// fix-forward migration widens the scanned surface without touching 000.
|
|
'2026-07-17-opencode-baseline-commands-dir':
|
|
'sha256:0f6080b5f9b75fb5adbe9664a71152e23a5336813453b0a77e4df6fd483ad38e',
|
|
// Migration 006 (NEW, added here per this test's own sanctioned "adding a new
|
|
// migration" case — not a shipped-body edit): retire pi's stale
|
|
// extensions/gsd.cjs. #2470 renamed the installed extension to
|
|
// extensions/gsd.js because pi's isExtensionFile() auto-discovery accepts
|
|
// only .ts/.js and silently skips everything else; without this migration the
|
|
// old path drops out of the manifest and uninstall can never remove it.
|
|
'2026-07-20-pi-extension-cjs-to-js':
|
|
'sha256:185fa926ae24d83cbdd95c31a9ad2cc8d123e176ad543669b3b0ed75e6ca6f4a',
|
|
// Migration 007 (NEW, added here per this test's own sanctioned "adding a new
|
|
// migration" case — not a shipped-body edit): retire the pre-#2544
|
|
// {"type":"commonjs"} marker at the runtime config root. #2544 moved that
|
|
// marker into the directories GSD fills, so without this an upgraded install
|
|
// keeps both and the config root stays pinned to CommonJS. Ownership is proven
|
|
// by exact content match rather than the manifest — the config-root marker was
|
|
// never manifest-recorded — so the action declares its own classification.
|
|
'2026-07-28-retire-config-root-commonjs-marker':
|
|
'sha256:8f2140cbe8f2dd8f7dfd52a0f6957c5edfe966c52d7e6e4d74ec7366930e0e1d',
|
|
};
|
|
|
|
const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const migrations = discoverInstallerMigrations({ migrationsDir: DEFAULT_MIGRATIONS_DIR });
|
|
const discoveredIds = new Set(migrations.map((m) => m.id));
|
|
|
|
// No stale baseline entries.
|
|
for (const id of Object.keys(EXPECTED_CHECKSUMS)) {
|
|
assert.ok(discoveredIds.has(id),
|
|
`EXPECTED_CHECKSUMS has a stale entry for '${id}' — that migration no longer exists; remove it`);
|
|
}
|
|
// Every discovered migration has a committed baseline entry.
|
|
for (const id of discoveredIds) {
|
|
assert.ok(Object.prototype.hasOwnProperty.call(EXPECTED_CHECKSUMS, id),
|
|
`new migration '${id}' has no committed checksum baseline — add it to EXPECTED_CHECKSUMS in tests/installer-migrations.test.cjs`);
|
|
}
|
|
// Core lock: each shipped migration's current checksum must match its committed baseline,
|
|
// computed directly (scope-independent).
|
|
for (const m of migrations) {
|
|
assert.strictEqual(computeChecksum(m), EXPECTED_CHECKSUMS[m.id],
|
|
`'${m.id}' body changed — its checksum drifted from the committed baseline; ` +
|
|
`add a NEW fix-forward migration id instead of editing a shipped migration body, ` +
|
|
`or intentionally update the baseline in EXPECTED_CHECKSUMS`);
|
|
}
|
|
});
|
|
|
|
test('reconciles a drifted applied-migration checksum into install state on apply', () => {
|
|
const configDir = createTempInstall();
|
|
try {
|
|
// Set up: one already-applied migration with a stale checksum, one pending migration
|
|
// that will produce an action (so applyInstallerMigrationPlan writes state).
|
|
const alreadyAppliedMigration = migrationRecord({
|
|
id: '2026-05-11-already-applied-with-drift',
|
|
title: 'Already applied with drift',
|
|
description: 'Already applied with drift',
|
|
scopes: ['global'],
|
|
destructive: false,
|
|
plan: () => [],
|
|
});
|
|
const pendingMigration = migrationRecord({
|
|
id: '2026-05-11-pending-to-trigger-apply',
|
|
title: 'Pending migration',
|
|
description: 'Pending migration',
|
|
scopes: ['global'],
|
|
destructive: true,
|
|
plan: () => [
|
|
{
|
|
type: 'remove-managed',
|
|
relPath: 'hooks/old-hook.js',
|
|
reason: 'retiring hook',
|
|
ownershipEvidence: 'test fixture manifest-managed hook',
|
|
},
|
|
],
|
|
});
|
|
|
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
|
writeManifest(configDir, {
|
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
|
});
|
|
|
|
// Seed install state: alreadyAppliedMigration recorded with a STALE checksum.
|
|
writeInstallState(configDir, {
|
|
schemaVersion: 1,
|
|
appliedMigrations: [
|
|
{
|
|
id: alreadyAppliedMigration.id,
|
|
appliedAt: '2026-01-01T00:00:00.000Z',
|
|
journal: null,
|
|
checksum: 'sha256:stale-old',
|
|
},
|
|
],
|
|
});
|
|
|
|
const plan = planInstallerMigrations({
|
|
configDir,
|
|
migrations: [alreadyAppliedMigration, pendingMigration],
|
|
scope: 'global',
|
|
now: () => '2026-05-11T00:00:00.000Z',
|
|
});
|
|
|
|
// The already-applied migration should appear in checksumDrift.
|
|
const drift = plan.checksumDrift.find((d) => d.id === alreadyAppliedMigration.id);
|
|
assert.ok(drift, 'expected checksumDrift entry for the already-applied migration');
|
|
assert.equal(drift.storedChecksum, 'sha256:stale-old');
|
|
|
|
// Apply the plan (the pending migration has an action, so this writes state).
|
|
applyInstallerMigrationPlan({
|
|
configDir,
|
|
plan,
|
|
now: () => '2026-05-11T00:00:01.000Z',
|
|
});
|
|
|
|
// Re-read install state and assert the stale checksum was reconciled.
|
|
const stateAfter = readInstallState(configDir);
|
|
const reconciledEntry = stateAfter.appliedMigrations.find(
|
|
(entry) => entry.id === alreadyAppliedMigration.id
|
|
);
|
|
assert.ok(reconciledEntry, 'expected the already-applied entry to still be in install state');
|
|
const expectedChecksum = migrationChecksum(alreadyAppliedMigration);
|
|
assert.strictEqual(
|
|
reconciledEntry.checksum,
|
|
expectedChecksum,
|
|
`expected checksum to be reconciled to current value (${expectedChecksum}), not the stale 'sha256:stale-old'`
|
|
);
|
|
assert.notEqual(reconciledEntry.checksum, 'sha256:stale-old',
|
|
'stale checksum must not remain after apply');
|
|
} finally {
|
|
cleanup(configDir);
|
|
}
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3357-codex-legacy-hooks-json-migration (consolidation epic #1969 B5 #1974)", () => {
|
|
/**
|
|
* Regression test for bug #3357.
|
|
*
|
|
* Older Codex installs carried legacy GSD SessionStart commands in hooks.json.
|
|
* Current install keeps the managed SessionStart hook in hooks.json (single
|
|
* representation per layer) and strips stale managed entries before writing
|
|
* exactly one canonical managed command.
|
|
*
|
|
* Bug #1348 (addendum): reconcileCodexHooksJsonEvent must always write the
|
|
* canonical nested { "hooks": { "<Event>": [...] } } shape — never top-level
|
|
* event keys — mirroring reconcileCursorHooksJson.
|
|
*/
|
|
|
|
'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 { execFileSync } = require('node:child_process');
|
|
|
|
const installModule = require('../bin/install.js');
|
|
const { readInstallState } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const { install, parseTomlToObject, reconcileCodexHooksJsonEvent } = installModule;
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
|
|
function withCodexHome(codexHome, fn) {
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
// #2088 (ADR-1239 upgrade 3): Codex skills now install to $HOME/.agents/skills
|
|
// (os.homedir()-relative, independent of CODEX_HOME). Sandbox HOME (and
|
|
// USERPROFILE) to codexHome so in-process installs never write to the
|
|
// developer/CI machine's real home directory.
|
|
const previousHome = process.env.HOME;
|
|
const previousUserProfile = process.env.USERPROFILE;
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HOME = codexHome;
|
|
process.env.USERPROFILE = codexHome;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (previousCodexHome == null) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = previousCodexHome;
|
|
if (previousHome == null) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousUserProfile == null) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = previousUserProfile;
|
|
}
|
|
}
|
|
|
|
function legacyGsdHook(codexHome) {
|
|
return {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js')}"`,
|
|
}],
|
|
};
|
|
}
|
|
|
|
function userHook() {
|
|
return {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: 'node "/Users/example/bin/user-hook.js"',
|
|
}],
|
|
};
|
|
}
|
|
|
|
function tomlGsdHookCount(codexHome) {
|
|
const parsed = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
|
|
const sessionStart = parsed.hooks?.SessionStart ?? [];
|
|
return sessionStart
|
|
.flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks : [])
|
|
.filter((hook) => typeof hook.command === 'string' && hook.command.includes('gsd-check-update'))
|
|
.length;
|
|
}
|
|
|
|
describe('#3357 — Codex install removes legacy GSD hooks.json entries', { concurrency: false }, () => {
|
|
let tmpRoot;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
|
}
|
|
tmpRoot = createTempDir('gsd-3357-');
|
|
codexHome = path.join(tmpRoot, '.codex');
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete installModule.__codexSchemaValidator;
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
test('rewrites hooks.json to one managed SessionStart hook when file only had legacy managed entry', () => {
|
|
fs.writeFileSync(
|
|
path.join(codexHome, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2),
|
|
);
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
// #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
'hooks.json must use nested { hooks: { ... } } shape (bug #1348)',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
'hooks.json must NOT have a top-level SessionStart key (bug #1348)',
|
|
);
|
|
const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update'));
|
|
assert.equal(managed.length, 1);
|
|
assert.equal(tomlGsdHookCount(codexHome), 0);
|
|
});
|
|
|
|
test('preserves user hooks.json entries while removing the legacy GSD hook', () => {
|
|
const userOwnedSameBasenameHook = {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: 'node "/Users/example/bin/gsd-check-update.js"',
|
|
}],
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(codexHome, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [legacyGsdHook(codexHome), userHook(), userOwnedSameBasenameHook] }, null, 2),
|
|
);
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
// #1348: output must be nested { hooks: { SessionStart: [...] } }, not top-level
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
'hooks.json must use nested { hooks: { ... } } shape (bug #1348)',
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
'hooks.json must NOT have a top-level SessionStart key (bug #1348)',
|
|
);
|
|
const commands = hooksJson.hooks.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update'));
|
|
assert.equal(commands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
|
assert.equal(commands.includes('node "/Users/example/bin/gsd-check-update.js"'), true);
|
|
assert.equal(managed.length, 2);
|
|
assert.equal(tomlGsdHookCount(codexHome), 0);
|
|
});
|
|
|
|
test('restores migrated hooks.json and install state when later Codex validation fails', () => {
|
|
const before = JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2);
|
|
fs.writeFileSync(path.join(codexHome, 'hooks.json'), before);
|
|
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'forced migration rollback test',
|
|
});
|
|
|
|
assert.throws(
|
|
() => withCodexHome(codexHome, () => install(true, 'codex')),
|
|
/forced migration rollback test/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), before);
|
|
assert.equal(
|
|
readInstallState(codexHome).appliedMigrations.some((entry) => entry.id === '2026-05-11-codex-legacy-hooks-json'),
|
|
false
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #1348 — reconcileCodexHooksJsonEvent must always write canonical nested shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#1348 — reconcileCodexHooksJsonEvent canonical nested shape', { concurrency: false }, () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-1348-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// (a) Fresh/absent hooks.json: register → { "hooks": { "SessionStart": [...] } }
|
|
test('(a) fresh/absent hooks.json writes nested { hooks: { SessionStart: [...] } } shape', () => {
|
|
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist');
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must be created');
|
|
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
`Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
`hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
assert.ok(
|
|
Array.isArray(hooksJson.hooks.SessionStart) && hooksJson.hooks.SessionStart.length > 0,
|
|
`Expected hooks.hooks.SessionStart to be a non-empty array; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
});
|
|
|
|
// (b) Legacy migration: seed top-level { "SessionStart": [<user>] }, register →
|
|
// nested hooks.SessionStart contains BOTH migrated user entry AND managed entry
|
|
test('(b) legacy top-level shape: user entries migrate into hooks.SessionStart alongside managed entry', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] };
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [userEntry] }, null, 2),
|
|
);
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
|
|
// Canonical nested shape
|
|
assert.ok(
|
|
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks),
|
|
`Expected nested { hooks: { ... } } shape; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
`hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
|
|
// User entry was migrated under hooks.SessionStart (not dropped)
|
|
const allCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
allCommands.includes('node "/Users/alice/my-hook.js"'),
|
|
`User entry must be preserved under hooks.SessionStart; commands: ${JSON.stringify(allCommands)}`,
|
|
);
|
|
|
|
// Managed entry is also present
|
|
const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 1, 'Exactly one managed entry must be present under hooks.SessionStart');
|
|
});
|
|
|
|
// (c-i) Dedup: re-registering the same managed command does not duplicate it
|
|
test('(c-i) re-registering managed command produces exactly one managed entry', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
const allCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 1, 'Re-register must yield exactly one managed entry');
|
|
});
|
|
|
|
// (c-ii) Removal: user entries remain under hooks, managed entry is gone
|
|
test('(c-ii) removing managed hook leaves user entry under hooks.SessionStart', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
const userEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/my-hook.js"' }] };
|
|
// Seed already-nested file with both user + managed
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
// Now manually seed a user entry into the existing nested file
|
|
const seeded = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
seeded.hooks.SessionStart = [userEntry, ...seeded.hooks.SessionStart];
|
|
fs.writeFileSync(path.join(tmpDir, 'hooks.json'), JSON.stringify(seeded, null, 2));
|
|
|
|
// Remove managed
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
// User entry must still be under hooks.SessionStart
|
|
const allCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
allCommands.includes('node "/Users/alice/my-hook.js"'),
|
|
`User entry must remain after managed removal; commands: ${JSON.stringify(allCommands)}`,
|
|
);
|
|
// No managed entry
|
|
const managedCount = allCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 0, 'No managed entry must remain after removal');
|
|
});
|
|
|
|
// (c-iii) Removal from absent file does NOT materialize { "hooks": {} }
|
|
test('(c-iii) removing from absent hooks.json does not write a spurious empty { "hooks": {} }', () => {
|
|
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
|
assert.ok(!fs.existsSync(hooksJsonPath), 'precondition: hooks.json must not exist');
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: null });
|
|
|
|
assert.ok(
|
|
!fs.existsSync(hooksJsonPath),
|
|
'hooks.json must NOT be created when removing from absent file (no spurious { "hooks": {} })',
|
|
);
|
|
});
|
|
|
|
// (d) Mixed nested + top-level shape: { "hooks": { "PreToolUse": [...] }, "SessionStart": [...] }
|
|
// The stray top-level event array must be lifted into hooks and merged; no top-level key survives.
|
|
test('(d) mixed nested + top-level shape: stray top-level event array is lifted and merged', () => {
|
|
const FAKE_CMD = `"/usr/local/bin/node" "${path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/')}"`;
|
|
const existingNestedEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/pre-tool.js"' }] };
|
|
const userTopLevelEntry = { hooks: [{ type: 'command', command: 'node "/Users/alice/session-start.js"' }] };
|
|
|
|
// Seed a mixed-shape file: nested PreToolUse AND top-level SessionStart
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, 'hooks.json'),
|
|
JSON.stringify(
|
|
{
|
|
hooks: { PreToolUse: [existingNestedEntry] },
|
|
SessionStart: [userTopLevelEntry],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
reconcileCodexHooksJsonEvent(tmpDir, 'SessionStart', { managedCommand: FAKE_CMD });
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
|
|
|
// No stray top-level SessionStart key
|
|
assert.ok(
|
|
!Object.prototype.hasOwnProperty.call(hooksJson, 'SessionStart'),
|
|
`hooks.json must NOT have a top-level SessionStart key; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
|
|
// hooks.SessionStart contains the migrated user entry AND exactly one managed entry
|
|
assert.ok(
|
|
Array.isArray(hooksJson.hooks.SessionStart),
|
|
`hooks.hooks.SessionStart must be an array; got: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
const sessionCommands = hooksJson.hooks.SessionStart
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
sessionCommands.includes('node "/Users/alice/session-start.js"'),
|
|
`Migrated user entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}; full: ${JSON.stringify(hooksJson)}`,
|
|
);
|
|
const managedCount = sessionCommands.filter((c) => typeof c === 'string' && c.includes('gsd-check-update')).length;
|
|
assert.equal(managedCount, 1, `Exactly one managed entry must be present in hooks.SessionStart; commands: ${JSON.stringify(sessionCommands)}`);
|
|
|
|
// hooks.PreToolUse is untouched
|
|
assert.ok(
|
|
Array.isArray(hooksJson.hooks.PreToolUse) && hooksJson.hooks.PreToolUse.length === 1,
|
|
`hooks.hooks.PreToolUse must be preserved with one entry; got: ${JSON.stringify(hooksJson.hooks.PreToolUse)}`,
|
|
);
|
|
const preToolCommands = hooksJson.hooks.PreToolUse
|
|
.flatMap((e) => Array.isArray(e.hooks) ? e.hooks : [])
|
|
.map((h) => h.command);
|
|
assert.ok(
|
|
preToolCommands.includes('node "/Users/alice/pre-tool.js"'),
|
|
`Existing nested PreToolUse entry must be preserved; commands: ${JSON.stringify(preToolCommands)}`,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-3670-cursor-local-install-migration-lock.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-3670-cursor-local-install-migration-lock (consolidation epic #1969 B5 #1974)", () => {
|
|
/**
|
|
* Regression tests for issue #3670: --cursor --local install self-deadlocks
|
|
* on gsd-install-migration.lock.
|
|
*
|
|
* Root cause: On Windows, `fs.rmSync(lockPath, { force: true })` in the lock
|
|
* release closure silently swallows EPERM errors that NTFS returns when a
|
|
* recently-closed file descriptor's handle has not yet been fully released by
|
|
* the OS. The lock file is left on disk. The next `runInstallerMigrations`
|
|
* call in the same install() invocation hits EEXIST, spins for
|
|
* DEFAULT_LOCK_TIMEOUT_MS (30 s), then throws "installer migration lock is
|
|
* held". There is also no stale-PID reclamation: if the lock names the
|
|
* current process's PID, the helper should reclaim rather than spin.
|
|
*
|
|
* Windows wall-clock deadlock repro depends on Docker matrix Windows runners.
|
|
* These tests reproduce the failure modes via mock-injected fs faults on any
|
|
* platform (macOS/Linux/Windows). They fail deterministically WITHOUT the fix
|
|
* and pass WITH it.
|
|
*
|
|
* Test plan:
|
|
* T1 (same-process re-entry / stale-PID reclamation — primary regression)
|
|
* Pre-seed the lock file with {pid: process.pid, ...}. Verify that a
|
|
* runInstallerMigrations call reclaims the lock and succeeds rather than
|
|
* spinning 30 s and throwing.
|
|
*
|
|
* T2 (dead-PID reclamation — cross-invocation stale lock)
|
|
* Pre-seed the lock file with a PID known to be dead. Verify that acquire
|
|
* reclaims rather than throws.
|
|
*
|
|
* T3 (silent rmSync swallow / Windows EPERM simulation)
|
|
* Inject a fault that makes fs.rmSync throw EPERM for the lock file only
|
|
* (simulating Windows NTFS delete-pending). Verify that the lock file IS
|
|
* removed by an alternative path (or that the error propagates) — i.e.
|
|
* verify that the fix does not silently leave the lock on disk.
|
|
*
|
|
* T4 (counter-test: normal single acquire/release round-trip still works)
|
|
* No pre-seeded lock. One runInstallerMigrations call. Must succeed and
|
|
* leave no lock file behind.
|
|
*
|
|
* T5 (counter-test: genuinely-held live lock still surfaces an error)
|
|
* Pre-seed lock with a live PID (process.pid) AND simulate a lock that
|
|
* has been "truly acquired" (fd still open). With lockTimeoutMs: 0 and a
|
|
* truly un-reclaimable lock, must still throw with a useful message naming
|
|
* the holder PID. (This guards against over-reclamation.)
|
|
*
|
|
* @see https://github.com/open-gsd/gsd-core/issues/3670
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, mock } = 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 {
|
|
INSTALL_MIGRATION_LOCK_NAME,
|
|
runInstallerMigrations,
|
|
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createTempDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3670-'));
|
|
}
|
|
|
|
function lockPath(dir) {
|
|
return path.join(dir, INSTALL_MIGRATION_LOCK_NAME);
|
|
}
|
|
|
|
function writeLockFile(dir, pid, acquiredAt) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
lockPath(dir),
|
|
JSON.stringify({ pid, acquiredAt: acquiredAt || new Date().toISOString() }) + '\n',
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find a PID that is guaranteed to be dead on this host.
|
|
* We probe a set of high candidate PIDs (far outside the running set) and
|
|
* pick the first one for which process.kill(pid, 0) throws ESRCH.
|
|
* Falls back to 99999 if the probe loop exhausts (extremely unlikely).
|
|
*/
|
|
function findDeadPid() {
|
|
// Avoid process.pid ± small numbers — those could be live siblings.
|
|
for (let candidate = 600000; candidate < 700000; candidate += 1000) {
|
|
try {
|
|
process.kill(candidate, 0);
|
|
// Still alive (or permission denied but exists) — try next
|
|
} catch (err) {
|
|
if (err.code === 'ESRCH') return candidate;
|
|
}
|
|
}
|
|
return 99999; // fallback: extremely unlikely to be a live PID
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T1: Same-process re-entry — stale lock with current process.pid reclaimed
|
|
// ---------------------------------------------------------------------------
|
|
test('T1: reclaims stale lock that names the current process PID (same-process re-entry)', (t) => {
|
|
const configDir = createTempDir();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
// Pre-seed lock file with the CURRENT process's PID — exactly what happens
|
|
// on Windows when rmSync swallows EPERM after the first runInstallerMigrations
|
|
// call releases (or fails to release) the lock.
|
|
writeLockFile(configDir, process.pid);
|
|
|
|
// Without the fix: this would spin for lockTimeoutMs then throw.
|
|
// With the fix: detects own PID → reclaims → succeeds.
|
|
// lockTimeoutMs: 200 (fail fast so the test doesn't hang for 30 s without fix)
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 200,
|
|
});
|
|
|
|
assert.ok(result, 'runInstallerMigrations must return a result object');
|
|
// Lock file must be removed after the call completes.
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'lock file must not remain on disk after successful runInstallerMigrations'
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T2: Dead-PID reclamation — cross-invocation stale lock
|
|
// ---------------------------------------------------------------------------
|
|
test('T2: reclaims stale lock whose PID is no longer alive', (t) => {
|
|
const configDir = createTempDir();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
const deadPid = findDeadPid();
|
|
writeLockFile(configDir, deadPid);
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 200,
|
|
});
|
|
|
|
assert.ok(result, 'runInstallerMigrations must return a result object');
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'lock file must not remain on disk after stale-PID reclamation'
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T3: Windows EPERM simulation — unlinkSync failure surfaces (not silently swallowed)
|
|
// ---------------------------------------------------------------------------
|
|
test('T3: lock release does not silently leave lock file on disk when unlink fails (Windows EPERM simulation)', (t) => {
|
|
const configDir = createTempDir();
|
|
const originalUnlinkSync = fs.unlinkSync;
|
|
|
|
t.after(() => {
|
|
fs.unlinkSync = originalUnlinkSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
// The fix uses fs.unlinkSync (not fs.rmSync with { force: true }) in the
|
|
// release closure. Inject EPERM on the lock file to simulate the Windows
|
|
// NTFS condition where the recently-closed handle has not been fully
|
|
// released by the OS.
|
|
//
|
|
// The fix's contract: EPERM must NOT be silently swallowed.
|
|
// Either (a) the error propagates as a releaseError, or (b) some alternative
|
|
// deletion path succeeds. Silent-swallow (no error + file still exists) is
|
|
// the failure condition we guard against.
|
|
let unlinkCallCount = 0;
|
|
fs.unlinkSync = function faultInjectUnlinkSync(targetPath) {
|
|
const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME;
|
|
if (isLock) {
|
|
unlinkCallCount++;
|
|
// Simulate Windows EPERM (file handle not fully released by OS)
|
|
const err = Object.assign(
|
|
new Error('EPERM: operation not permitted, unlink ' + targetPath),
|
|
{ code: 'EPERM' }
|
|
);
|
|
throw err;
|
|
}
|
|
return originalUnlinkSync.call(fs, targetPath);
|
|
};
|
|
|
|
// With the fix: unlinkSync throws EPERM → releaseError is thrown by the
|
|
// release closure → runInstallerMigrations throws releaseError.
|
|
// With the buggy code (rmSync + force:true): EPERM was swallowed silently,
|
|
// no error thrown, lock file left on disk.
|
|
//
|
|
// Assert: if the call succeeds (no throw), the lock file must be gone.
|
|
// If the call throws, the error message must reference the lock.
|
|
let threw = false;
|
|
let thrownError = null;
|
|
try {
|
|
runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 500,
|
|
});
|
|
} catch (err) {
|
|
threw = true;
|
|
thrownError = err;
|
|
}
|
|
|
|
if (threw) {
|
|
// Acceptable: error surfaced. Verify it's lock-related (not a bug elsewhere).
|
|
assert.match(
|
|
thrownError.message,
|
|
/lock/i,
|
|
'thrown error must reference the lock file'
|
|
);
|
|
} else {
|
|
// If no error was thrown, the lock file must have been removed by some
|
|
// alternative path (not left silently on disk).
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'if unlinkSync EPERM is encountered but no error thrown, lock file must still be removed'
|
|
);
|
|
}
|
|
|
|
// Sanity: the fault injection was actually triggered.
|
|
assert.ok(unlinkCallCount > 0, 'unlinkSync must have been called for the lock file at least once');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T4: Counter-test — normal single acquire/release round-trip still works
|
|
// ---------------------------------------------------------------------------
|
|
test('T4: normal (non-recursive) runInstallerMigrations acquires and releases lock correctly', (t) => {
|
|
const configDir = createTempDir();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
// No pre-seeded lock. Standard happy path.
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
});
|
|
|
|
assert.ok(result, 'runInstallerMigrations must return a result');
|
|
assert.equal(
|
|
fs.existsSync(lockPath(configDir)),
|
|
false,
|
|
'lock file must be cleaned up after normal completion'
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// T5: Counter-test — unreclaimable live lock must surface a bounded error
|
|
// ---------------------------------------------------------------------------
|
|
// This test guards against over-reclamation: if the reclaim-unlink fails
|
|
// (e.g. Windows EPERM on a live open handle), the fix must NOT spin
|
|
// indefinitely — it must fall through to the timeout path and throw.
|
|
//
|
|
// Conditions forced by this test:
|
|
// 1. Lock file contains the CURRENT process.pid (triggers isSameProcess branch).
|
|
// 2. fs.unlinkSync is mocked to throw EPERM for the lock file (reclaim fails).
|
|
// 3. lockTimeoutMs: 200 — timeout must fire within a short wall-clock window.
|
|
//
|
|
// Expected outcome: throws with /installer migration lock is held/ within
|
|
// ~200ms. SUCCESS (no throw) is NOT acceptable here — that would mean the fix
|
|
// over-reclaimed a lock that it couldn't actually remove.
|
|
test('T5: unreclaimable same-PID lock throws bounded error (reclaim-unlink failure falls through to timeout)', (t) => {
|
|
const configDir = createTempDir();
|
|
const originalUnlinkSync = fs.unlinkSync;
|
|
|
|
t.after(() => {
|
|
mock.restoreAll();
|
|
fs.unlinkSync = originalUnlinkSync;
|
|
cleanup(configDir);
|
|
});
|
|
|
|
// Pre-seed lock file with the CURRENT process's PID.
|
|
// This triggers the isSameProcess reclamation path inside acquireInstallerMigrationLock.
|
|
writeLockFile(configDir, process.pid);
|
|
|
|
// Mock unlinkSync to throw EPERM for the lock file only.
|
|
// This simulates Windows NTFS refusing to delete a file with an open handle.
|
|
// With the fix: reclaim-unlink fails → reclaimed=false → falls through to
|
|
// the timeout check → throws "installer migration lock is held" after ≤200ms.
|
|
// Without the fix (original code): unlink throws but continue runs anyway →
|
|
// spins indefinitely, never reaches the timeout check → deadlock.
|
|
mock.method(fs, 'unlinkSync', function faultInjectUnlinkSync(targetPath) {
|
|
const isLock = path.basename(String(targetPath)) === INSTALL_MIGRATION_LOCK_NAME;
|
|
if (isLock) {
|
|
const err = Object.assign(
|
|
new Error('EPERM: operation not permitted, unlink ' + targetPath),
|
|
{ code: 'EPERM' }
|
|
);
|
|
throw err;
|
|
}
|
|
return originalUnlinkSync.call(fs, targetPath);
|
|
});
|
|
|
|
assert.throws(
|
|
() => runInstallerMigrations({
|
|
configDir,
|
|
migrations: [],
|
|
lockTimeoutMs: 200,
|
|
}),
|
|
(err) => {
|
|
assert.match(err.message, /installer migration lock is held/, 'error must name the held lock');
|
|
return true;
|
|
},
|
|
'must throw "installer migration lock is held" when reclaim-unlink fails — not spin indefinitely'
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/installer-migrations/001-legacy-orphan-files.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:installer-migrations/001-legacy-orphan-files (consolidation epic #1969 B5 #1974)", () => {
|
|
'use strict';
|
|
|
|
/**
|
|
* Characterization tests for the 001-legacy-orphan-files installer migration.
|
|
* Locks the migration metadata shape and plan() logic (managed-pristine and
|
|
* managed-modified classification paths; unmanaged artifacts are skipped).
|
|
*/
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const migration = require('../gsd-core/bin/lib/installer-migrations/001-legacy-orphan-files.cjs');
|
|
|
|
describe('migration metadata', () => {
|
|
test('exports a single migration object with required fields', () => {
|
|
assert.equal(typeof migration, 'object');
|
|
assert.equal(migration.id, '2026-05-11-legacy-orphan-files');
|
|
assert.equal(typeof migration.title, 'string');
|
|
assert.equal(typeof migration.description, 'string');
|
|
assert.equal(migration.introducedIn, '1.50.0');
|
|
assert.ok(Array.isArray(migration.scopes));
|
|
assert.ok(migration.scopes.includes('global'));
|
|
assert.ok(migration.scopes.includes('local'));
|
|
assert.strictEqual(migration.destructive, true);
|
|
assert.equal(typeof migration.plan, 'function');
|
|
});
|
|
});
|
|
|
|
describe('migration.plan()', () => {
|
|
function makeClassifier(classification) {
|
|
return { classifyArtifact: () => ({ classification }) };
|
|
}
|
|
|
|
test('returns remove-managed action for managed-pristine artifact', () => {
|
|
const actions = migration.plan(makeClassifier('managed-pristine'));
|
|
assert.equal(actions.length, 2); // two files in LEGACY_ORPHAN_FILES
|
|
for (const action of actions) {
|
|
assert.equal(action.type, 'remove-managed');
|
|
assert.equal(typeof action.relPath, 'string');
|
|
assert.equal(typeof action.reason, 'string');
|
|
assert.equal(typeof action.ownershipEvidence, 'string');
|
|
}
|
|
});
|
|
|
|
test('returns backup-and-remove action for managed-modified artifact', () => {
|
|
const actions = migration.plan(makeClassifier('managed-modified'));
|
|
assert.equal(actions.length, 2);
|
|
for (const action of actions) {
|
|
assert.equal(action.type, 'backup-and-remove');
|
|
}
|
|
});
|
|
|
|
test('returns no actions for unmanaged artifact', () => {
|
|
const actions = migration.plan(makeClassifier('unmanaged'));
|
|
assert.deepStrictEqual(actions, []);
|
|
});
|
|
|
|
test('relPaths match the two legacy orphan hook files', () => {
|
|
const actions = migration.plan(makeClassifier('managed-pristine'));
|
|
const relPaths = actions.map((a) => a.relPath).sort();
|
|
assert.deepStrictEqual(relPaths, [
|
|
'hooks/gsd-notify.sh',
|
|
'hooks/statusline.js',
|
|
]);
|
|
});
|
|
|
|
test('plan handles mixed classifications per file', () => {
|
|
let callCount = 0;
|
|
const ctx = {
|
|
classifyArtifact: (_relPath) => {
|
|
callCount++;
|
|
// first call: managed-pristine; second call: unmanaged
|
|
return { classification: callCount === 1 ? 'managed-pristine' : 'unmanaged' };
|
|
},
|
|
};
|
|
const actions = migration.plan(ctx);
|
|
assert.equal(actions.length, 1);
|
|
assert.equal(actions[0].type, 'remove-managed');
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Symlinked managed path: backup must never dereference (#2470 security review)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// `fs.copyFileSync` follows symlinks. Before this hardening, a managed path
|
|
// replaced by a link would have had the LINK TARGET's bytes copied into the
|
|
// journal's backup tree — e.g. a `gsd.cjs` symlinked at a private key would
|
|
// land that key's contents under gsd-migration-journal/. Nothing GSD installs
|
|
// is ever a symlink, so the faithful snapshot is the link itself.
|
|
|
|
{
|
|
const { describe, test } = require('node:test');
|
|
describe('symlinked managed path is snapshotted as a link, never dereferenced', () => {
|
|
const piExtensionMigration = require('../gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs');
|
|
const SECRET = 'TOP-SECRET-PRIVATE-KEY-MATERIAL\n';
|
|
|
|
test('backup-and-remove on a symlinked managed file copies the link, not the referent', (t) => {
|
|
const configDir = createTempInstall();
|
|
const secretDir = createTempInstall();
|
|
try {
|
|
const secretPath = path.join(secretDir, 'id_rsa');
|
|
fs.writeFileSync(secretPath, SECRET, 'utf8');
|
|
|
|
const linkPath = path.join(configDir, 'extensions', 'gsd.cjs');
|
|
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(secretPath, linkPath);
|
|
} catch {
|
|
t.skip('symlink creation unsupported on this platform/privilege');
|
|
return;
|
|
}
|
|
|
|
// Manifest records the path as managed with a hash that cannot match the
|
|
// referent -> classification 'managed-modified' -> backup-and-remove.
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
});
|
|
|
|
const backupAction = result.plan.actions.find((a) => a.type === 'backup-and-remove');
|
|
assert.ok(backupAction, 'expected a backup-and-remove action for the modified managed file');
|
|
|
|
// The referent is untouched and still holds its content.
|
|
assert.ok(fs.existsSync(secretPath), 'symlink target must survive');
|
|
assert.equal(fs.readFileSync(secretPath, 'utf8'), SECRET, 'symlink target content must be unchanged');
|
|
|
|
// The link itself is gone from the install tree.
|
|
assert.equal(
|
|
fs.lstatSync(linkPath, { throwIfNoEntry: false }),
|
|
undefined,
|
|
'the symlink at the managed path must be removed',
|
|
);
|
|
|
|
// Nothing anywhere under configDir may contain the referent's bytes.
|
|
const leaked = [];
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isSymbolicLink()) continue; // a link is fine; its content is not copied
|
|
if (entry.isDirectory()) { walk(full); continue; }
|
|
let body;
|
|
try { body = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
if (body.includes('TOP-SECRET')) leaked.push(path.relative(configDir, full));
|
|
}
|
|
};
|
|
walk(configDir);
|
|
assert.deepEqual(leaked, [], `symlink referent content leaked into: ${leaked.join(', ')}`);
|
|
} finally {
|
|
cleanup(configDir);
|
|
cleanup(secretDir);
|
|
}
|
|
});
|
|
|
|
test('a regular managed file is still backed up by content (no behavior change)', (t) => {
|
|
const configDir = createTempInstall();
|
|
t.after(() => cleanup(configDir));
|
|
|
|
writeFile(configDir, 'extensions/gsd.cjs', 'locally patched extension\n');
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
});
|
|
|
|
const backupAction = result.plan.actions.find((a) => a.type === 'backup-and-remove');
|
|
assert.ok(backupAction, 'expected backup-and-remove for the locally patched file');
|
|
|
|
// The PLAN carries backupRelPath: null — the concrete backup location is
|
|
// chosen during apply and recorded in the journal, so read it from there.
|
|
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
|
const journalled = journal.actions.find((a) => a.backupRelPath);
|
|
assert.ok(journalled, 'apply must record the backup path in the journal for the user');
|
|
const backupPath = path.join(configDir, journalled.backupRelPath);
|
|
assert.equal(
|
|
fs.readFileSync(backupPath, 'utf8'),
|
|
'locally patched extension\n',
|
|
'a real file must still be backed up by content so the user can recover it',
|
|
);
|
|
assert.ok(!fs.existsSync(path.join(configDir, 'extensions', 'gsd.cjs')));
|
|
});
|
|
|
|
// In-flight failure recovery: when a later step of the SAME apply() attempt
|
|
// throws, the catch block replays the rollback snapshots it already took.
|
|
// Those snapshots are themselves symlinks, so a raw copy there dereferences
|
|
// and writes the referent's bytes back to the LIVE install path — worse than
|
|
// the journal-tree leak, because it is user-visible at a predictable path.
|
|
//
|
|
// The failure is injected by monkeypatching fs.rmSync (restored in finally)
|
|
// rather than by chmod/permission tricks: deterministic, root- and
|
|
// OS-independent. The delete of the managed path is allowed to SUCCEED and
|
|
// then throws once, modelling a later step failing after the delete. That
|
|
// ordering is load-bearing: if the live path still existed, the pre-fix
|
|
// copyFileSync would hit a same-file collision and throw instead of leaking,
|
|
// and this test would pass against the very bug it exists to catch.
|
|
test('apply failure after a symlinked snapshot does not leak the referent into the live tree', (t) => {
|
|
const configDir = createTempInstall();
|
|
const secretDir = createTempInstall();
|
|
const realRmSync = fs.rmSync;
|
|
try {
|
|
const secretPath = path.join(secretDir, 'id_rsa');
|
|
fs.writeFileSync(secretPath, SECRET, 'utf8');
|
|
|
|
const linkPath = path.join(configDir, 'extensions', 'gsd.cjs');
|
|
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(secretPath, linkPath);
|
|
} catch {
|
|
t.skip('symlink creation unsupported on this platform/privilege');
|
|
return;
|
|
}
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
let fired = false;
|
|
fs.rmSync = function patched(target, options) {
|
|
const result = realRmSync.call(fs, target, options);
|
|
if (!fired && path.resolve(String(target)) === path.resolve(linkPath)) {
|
|
fired = true;
|
|
throw new Error('injected post-delete failure');
|
|
}
|
|
return result;
|
|
};
|
|
|
|
assert.throws(() => runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
}), /injected post-delete failure/, 'the injected failure must propagate, not be swallowed');
|
|
|
|
fs.rmSync = realRmSync;
|
|
|
|
// The referent is untouched...
|
|
assert.ok(fs.existsSync(secretPath));
|
|
assert.equal(fs.readFileSync(secretPath, 'utf8'), SECRET);
|
|
|
|
// ...the managed path is restored as a LINK, not a dereferenced copy...
|
|
const restored = fs.lstatSync(linkPath, { throwIfNoEntry: false });
|
|
assert.ok(restored, 'failure recovery must restore the managed path');
|
|
assert.ok(
|
|
restored.isSymbolicLink(),
|
|
'restored path must be a symlink — a regular file here means the referent was dereferenced into the live tree',
|
|
);
|
|
|
|
// ...and its bytes appear nowhere under the install tree.
|
|
const leaked = [];
|
|
const walk = (dir) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isSymbolicLink()) continue;
|
|
if (entry.isDirectory()) { walk(full); continue; }
|
|
let body;
|
|
try { body = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
if (body.includes('TOP-SECRET')) leaked.push(path.relative(configDir, full));
|
|
}
|
|
};
|
|
walk(configDir);
|
|
assert.deepEqual(leaked, [], `referent content leaked into: ${leaked.join(', ')}`);
|
|
} finally {
|
|
fs.rmSync = realRmSync;
|
|
cleanup(configDir);
|
|
cleanup(secretDir);
|
|
}
|
|
});
|
|
|
|
test('rollback() restores a symlinked managed path as a link, not a dereferenced copy', (t) => {
|
|
const configDir = createTempInstall();
|
|
const secretDir = createTempInstall();
|
|
try {
|
|
const targetPath = path.join(secretDir, 'id_rsa');
|
|
fs.writeFileSync(targetPath, SECRET, 'utf8');
|
|
|
|
const linkPath = path.join(configDir, 'extensions', 'gsd.cjs');
|
|
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
try {
|
|
fs.symlinkSync(targetPath, linkPath);
|
|
} catch {
|
|
t.skip('symlink creation unsupported on this platform/privilege');
|
|
return;
|
|
}
|
|
writeManifest(configDir, { 'extensions/gsd.cjs': sha256('the original extension\n') });
|
|
|
|
const result = runInstallerMigrations({
|
|
configDir,
|
|
runtime: 'pi',
|
|
scope: 'global',
|
|
migrations: [piExtensionMigration],
|
|
now: () => '2026-07-20T00:00:00.000Z',
|
|
});
|
|
assert.equal(fs.lstatSync(linkPath, { throwIfNoEntry: false }), undefined, 'link removed by apply');
|
|
|
|
result.rollback();
|
|
|
|
const restored = fs.lstatSync(linkPath, { throwIfNoEntry: false });
|
|
assert.ok(restored, 'rollback must restore the managed path');
|
|
assert.ok(restored.isSymbolicLink(), 'restored path must be a symlink, not a dereferenced copy');
|
|
assert.equal(fs.readlinkSync(linkPath), targetPath, 'restored link must point at the original target');
|
|
assert.equal(fs.readFileSync(targetPath, 'utf8'), SECRET, 'target content must be untouched throughout');
|
|
} finally {
|
|
cleanup(configDir);
|
|
cleanup(secretDir);
|
|
}
|
|
});
|
|
});
|
|
}
|