Files
msd-core/tests/installer-migrations.test.cjs
Tom Boucher ed06b6a4b9 fix(#2329): write opencode slash commands to commands/ (plural), migrate legacy command/ (#2354)
* test(#2329): fail-first tests for opencode commands/ (plural) command dir

Red phase, empirically probed: global/local install lands in command/ (singular)
with 71 gsd-*.md files and no commands/; the manifest records 71 keys under
command/ and zero under commands/; all four declaring sites report 'command'.
Migration coverage is black-box (two sequential install runs against one
configDir) so it holds regardless of how the fix implements cleanup.

The Kilo guard passes today by design — a forward-looking no-collateral check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* fix(#2329): write opencode commands to commands/ (plural), migrate legacy command/

OpenCode discovers slash commands from commands/ (plural); the installer wrote
them to command/ (singular), so none of the ~71 /gsd-* commands appeared in the
TUI. Five sites declared the directory and all had to agree:

- capabilities/opencode/capability.json: both artifactLayout destSubpath entries
  (global + local) and hostBehaviors.flatCommandDir
- bin/install.js: the manifest prefix was a SEPARATE hardcoded 'command/' literal,
  so the manifest would have diverged from the descriptor even after a rename. It
  now derives from _hostBehaviors(runtime).flatCommandDir.
- src/install-engine.cts installOpencodeFamilyArtifacts: the actual write target,
  which bypasses resolveRuntimeArtifactLayout via combinedFamilyInstall. This was
  a fifth site the issue did not list — without it the descriptor change alone
  would not have moved a single file.

Migration: an upgrade over a pre-fix install removes only manifest-proven
GSD-managed files from the legacy command/ dir and rmdirs it once empty.
Unmanifested user files are preserved, never deleted.

Kilo shares the opencode family install path and is explicitly unaffected —
pinned by a no-collateral test.

Note on the tests: the migration cases originally built their legacy fixture by
running the installer and relying on it to produce command/ — i.e. they depended
on the bug to set up the fixture, and became unsatisfiable the moment it was
fixed (block 1 requires command/ to be absent after a fresh install). They now
fabricate the legacy layout explicitly, including rewriting the manifest keys to
the command/ prefix — which is load-bearing, since the migration only removes
manifest-proven files and an unrewritten fixture would silently no-op and pass
even against a broken migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* test(#2329): regenerate opencode install golden after rebase onto next

The golden conflicted on rebase because #2322 also regenerated it. Resolved by
regenerating from the merged source rather than hand-merging a generated file;
the only delta is the 71 command/gsd-*.md -> commands/gsd-*.md key renames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* test(#2329): update stale tests that pinned opencode's singular command/ dir

Seven tests encoded the old contract (opencode: command/gsd-help.md exists, the
descriptor's flatCommandDir, the install-integration contract, and the
resolveRuntimeArtifactLayout golden). They passed in the red phase precisely
because they pinned the buggy singular dir; the fix intentionally changes that
contract, so these are stale-test corrections, not regressions.

Kilo shares the opencode family install path and is deliberately NOT changing —
it stays on command/ (singular). The shared opencode/kilo test is now split via
an explicit per-runtime dir map so the two cannot be conflated, and Kilo's own
layout test is untouched. tests/opencode-command-dir-plural.test.cjs
independently pins Kilo unchanged end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* docs(#2329): changeset for opencode commands/ dir fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* docs(#2329): backfill PR number 2354 into changeset

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* docs(#2329): correct the changeset — do not assert opencode ignores command/

The changeset repeated the issue's stated mechanism ("OpenCode discovers them
from commands/ ... a clean install produced no usable commands in the TUI at
all"). OpenCode's source contradicts that: packages/core/src/v1/config/command.ts
globs {command,commands}/**/*.md, so BOTH names resolve, and its own skill doc
still calls .opencode/command/ typical. Shipping that claim as a release note
would document a mechanism that does not exist.

The change is still right, for the stronger reason: OpenCode's config docs list
plural as the convention and singular as backwards compatibility, so GSD was
shipping on the alias the vendor may withdraw. Reworded to describe it as the
alignment it is, decided on OpenCode's source and docs rather than on bug reports
in either repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* fix(#2329): baseline opencode's commands/ surface — closes a data-loss path this PR opened

Not a bookkeeping gap. Moving opencode's command dir to commands/ moved the
install destination to a surface the first-time baseline scan does not cover:
000-first-time-baseline's RUNTIME_SURFACES.opencode lists ['gsd-core','command',
'skills','agents'] — no 'commands'.

installOpencodeFamilyCommands unconditionally unlinks every gsd-*.md under its
destination before writing the fresh set (install-engine.cts:870-873), with zero
manifest or migration involvement. The only thing that protects a pre-existing
file is assertInstallerMigrationsUnblocked, which runs before materialization and
halts when the baseline scan flags an unknown file at a KNOWN surface.

Probed: a pre-existing commands/gsd-plan.md is silently destroyed (install exits
0). The identical file under the legacy, already-baselined command/ surface
correctly halts the install with "installer migration blocked pending user
choice". So this PR would have traded a protected surface for an unprotected one.

Fixed with a NEW fix-forward migration rather than editing 000, per
docs/installer-migrations.md:131-134 — an applied migration never re-runs, so
editing 000 would only protect fresh installs and leave every existing machine
exposed. A new id runs for both populations and drifts no shipped checksum;
adding its entry to EXPECTED_CHECKSUMS is the case that test explicitly sanctions.
All five pre-existing shipped checksums verified byte-identical.

Kilo is excluded by the migration's runtimes filter and keeps command/.

This was previously deferred as a PR-body note claiming "low impact — nothing
else acts on baseline-scan misses". That claim was never probed and was wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

* docs(#2329): drop the parenthetical product description from the changeset

The product-name purity guard (#1777) rejects "Kilo (which still uses
command/)" — fragment prose renders verbatim into CHANGELOG.md, so a product
name must not carry a parenthetical. Reworded to a plain sentence; the meaning
is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 08:14:59 -04:00

2568 lines
92 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',
};
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');
});
});
});
}