Files
msd-core/tests/installer-migration-install.integration.test.cjs
Tom Boucher e9868a92ba fix(#1875): route installer settings/defaults writes through atomic, lock-guarded primitives (#3966)
* fix(#1875): route writeSettings through atomicWriteFileSync

writeSettings is the sole writer of settings.json/settings.local.json for
six runtimes and wrote them with a naked fs.writeFileSync. Hosts discard the
entire settings file on any parse failure, so a crash mid-write cost the user
every hook, permission, env var, and statusline they had — not just GSD's.

Route it through the atomicWriteFileSync (temp+rename) already used elsewhere
in the installer and already bound in this file.

withWriteFailure in the migration integration harness matched only the final
destination path, so an atomic write bypassed the injection entirely and turned
a rollback assertion into a vacuous pass. It now also matches the .tmp- sibling.

Refs open-gsd/gsd-core#1874 (F5)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1875): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1876): honor the readSettings null contract in the #338 local-merge leg

readSettings returns null only for an unparseable file — its documented
"preserve existing, don't touch" signal. The #338 migration coerced that null
to {} and wrote the result back, so a settings.local.json with one stray comma
lost all its non-GSD content on the next install.

The guard stands the whole migration down rather than just the local write:
skipping the merge while still stripping the shared file would destroy the GSD
entries outright instead of relocating them.

Aborting here reaches a pre-existing latent crash that the clobber had been
masking. Both are fixed, with their own regression test:

- the unparseable-settings guard returned bare `undefined` while all five
  sibling early exits return the full result shape, so installAllRuntimes'
  statusline lookup (results.find(r => r.runtime)) threw;
- handleStatusline dereferences result.settings, which is null on every early
  exit, so the call site now falls through to the banner branch.

Both crashes reproduce on unmodified next with a malformed settings.local.json
and no migration involved.

Refs open-gsd/gsd-core#1874 (F6)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1876): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1877): lock and atomically write the machine-global ~/.gsd/defaults.json

Every non-Claude install read-modify-writes ~/.gsd/defaults.json with no lock
and two separate naked whole-file writes. The file is read by every runtime and
project on the machine, so concurrent installs lost each other's key, and a
crash in either write window truncated it — silently, because the read path
swallows parse errors and treats a corrupt file as absent.

Take the existing acquireInstallMigrationLock around the read-modify-write and
apply both mutations in one atomicWriteFileSync. An install that changes nothing
no longer rewrites the file at all.

Existing semantics are unchanged: the explicit resolve_model_ids:true opt-in
(#1569) and an existing "omit" are preserved, non-canonical values still default
to "omit" (#1156), a pre-existing runtime string is preserved (#2395), the
malformed-non-object recovery (#1657) stands, and both console lines still print
when both keys change.

The #2834 structural test sliced a fixed 1200-character window from the function
source; the added lock comment pushed an asserted token past it. The window now
tracks the function body, so a comment or guard cannot red it spuriously.

Refs open-gsd/gsd-core#1874 (F18)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#1877): add changeset fragment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#1874): preserve target mode and create temp files exclusively in atomicWriteFileSync

* fix(#1874): write the migration-lock payload through the exclusive descriptor

* chore(#1874): bold-led changeset fragments + fragment for the hardening pass

* chore(#1874): rename the shadowed lock-release catch binding

* test(#1874): fix source-grep/try-finally violations, add missing fault-injection cases

- drop the /TypeError/.test(stderr) source-grep assertion (exitCode already
  proves the installer didn't crash)
- convert inline try/finally fs-mock restoration to t.after() across the F5/F18
  test suites, per this repo's no-try/finally-in-test-body rule
- add a rename-failure fault-injection case for atomicWriteFileSync
- add a read-only-.gsd-directory fault-injection case for the F18 lock+write path
- extract MAX_TEMP_FILE_ATTEMPTS constant, dedupe the partial-write-then-throw
  mock into a shared tests/helpers.cjs helper

Found during this session's own Standards-axis code-review pass on resurrected
PR #3385.

* chore(#1874): reset changeset fragments to pr:0 placeholder

The resurrected fragments carried the closed PR's number (3385). This is a
new PR, so reset to the pr:0 placeholder and backfill the real number once
gh pr create returns it, per CONTRIBUTING.md's PR Number Handling.

* chore(#1874): backfill changeset PR number (#3966)

---------

Co-authored-by: Richard Spiers <1355479+richardspiers@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <sim@local>
2026-08-27 23:27:43 -04:00

679 lines
29 KiB
JavaScript

/**
* Phase 4 installer migration integration tests.
*
* These exercise the public install() entry point so the migration runner is
* pinned at the install/update seam, not just as a standalone library.
*/
'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 crypto = require('node:crypto');
const { runNode } = require('./helpers/process-seam.cjs');
const installModule = require('../bin/install.js');
const pkg = require('../package.json');
const { install } = installModule;
const { createTempDir, cleanup } = require('./helpers.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
const SUPPORTED_RUNTIMES = installModule.allRuntimes;
const RUNTIME_INSTALL_CONTRACTS = {
claude: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
antigravity: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
augment: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
cline: { surface: 'clinerules', settings: false, hooksPackageJson: false },
codebuddy: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
// codex/cursor/windsurf: #2717 stages their .js hooks via dedicated paths
// (skipSharedHooksInstall / the !isCodex gate keep them out of the shared
// bundle) and writes the marker beside those scripts, so hooks/package.json
// is expected for all three. Measured on this tree: codex stages 3 .js hooks,
// cursor 6, windsurf 2 — each with the marker.
codex: { surface: 'flat-skills', settings: false, hooksPackageJson: true, codexConfig: true },
copilot: { surface: 'flat-skills', settings: false, hooksPackageJson: false, copilotInstructions: true },
cursor: { surface: 'flat-skills', settings: false, hooksPackageJson: true },
gemini: { surface: 'commands-gsd', settings: true, hooksPackageJson: true },
hermes: { surface: 'hermes-skills', settings: true, hooksPackageJson: true },
kimi: { surface: 'kimi-skills-agents', settings: false, hooksPackageJson: false },
// #2454: Kimi Code (Node CLI) has NO custom named subagents (per official
// docs), so its install surface is skills-only (flat-skills), NOT
// kimi-skills-agents. The kimi-agents YAML layout is Python kimi-cli only.
'kimi-code': { surface: 'flat-skills', settings: false, hooksPackageJson: false },
// #2305: Kilo's native plugin spawns the staged guard hooks, so it receives
// the shared hooks bundle + the CommonJS package.json marker, like OpenCode.
// (#1821 excluded Kilo on the false premise that it had no plugin surface.)
kilo: { surface: 'flat-command', settings: false, hooksPackageJson: true },
// #2329: OpenCode discovers commands from the PLURAL `commands/` dir — the
// singular `command/` (still correct for Kilo) made all /gsd-* commands
// invisible to OpenCode. commandDirName overrides the flat-command default.
opencode: { surface: 'flat-command', settings: true, hooksPackageJson: true, commandDirName: 'commands' },
// #2102 Stage 1/2: pi is a PLUGIN-ONLY install (hostBehaviors.pluginOnlyInstall)
// for commands/agents/skills — NO commands/, agents/, or skills/ dir. pi's
// /gsd command is registered programmatically by the native extension
// (extensions/gsd.cjs) and dispatches via a bounded subprocess to
// gsd-tools.cjs; it has no host-read markdown surface. Stage 2 (adversarial-
// review fix): pi's native extension DOES spawn the shared hooks/*.js bundle
// as bounded subprocesses (session_start/before_agent_start/session_before_
// compact bridges) and its /gsd tokenizer requires hooks/lib/git-cmd.js, so
// `hostBehaviors.skipSharedHooksInstall` was removed — pi now receives
// hooks/ + hooks/lib/ + the {"type":"commonjs"} package.json marker, exactly
// like OpenCode and (since #2305) Kilo — architecturally identical:
// hooksSurface:'none' + a native plugin that spawns the staged hooks — NOT
// like ZCode (no plugin surface, where the same hooks are dead weight).
pi: { surface: 'plugin-only', settings: false, hooksPackageJson: true },
qwen: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
trae: { surface: 'flat-skills', settings: false, hooksPackageJson: false },
windsurf: { surface: 'global-artifacts-noop', settings: false, hooksPackageJson: true },
// #1821: ZCode (hooksSurface:none, no plugin surface) no longer receives the
// dead hook scripts or the CommonJS package.json marker.
zcode: { surface: 'flat-skills', settings: false, hooksPackageJson: false },
};
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 withEnv(key, value, fn) {
const previous = process.env[key];
process.env[key] = value;
try {
return fn();
} finally {
if (previous == null) delete process.env[key];
else process.env[key] = previous;
}
}
// #2088: Codex CLI skills install to `$HOME/.agents/skills` (resolved via
// os.homedir()), not `$CODEX_HOME/skills`. In-process codex installs must
// sandbox HOME (and USERPROFILE, for Windows os.homedir() resolution) to the
// test's codexHome dir, or skills get materialized into the real developer
// home directory. withEnv saves/restores a single key, so nesting is safe.
function withCodexEnv(codexHome, fn) {
return withEnv('CODEX_HOME', codexHome, () =>
withEnv('HOME', codexHome, () =>
withEnv('USERPROFILE', codexHome, fn)
)
);
}
function captureConsole(fn) {
const originalLog = console.log;
const originalWarn = console.warn;
const lines = [];
console.log = (...args) => { lines.push(args.join(' ')); };
console.warn = (...args) => { lines.push(args.join(' ')); };
try {
return { value: fn(), output: lines.join('\n') };
} finally {
console.log = originalLog;
console.warn = originalWarn;
}
}
function withWriteFailure(matchPath, fn) {
const originalWriteFileSync = fs.writeFileSync;
const resolvedMatch = path.resolve(matchPath);
// Atomic writers (atomicWriteFileSync) never write the destination directly —
// they write `<target>.tmp-<pid>-<n>` and rename. Matching only the final path
// would make this injection silently stop firing for any write that becomes
// atomic, turning a rollback assertion into a vacuous pass.
const targetsMatch = (filePath) => {
const resolved = path.resolve(String(filePath));
return resolved === resolvedMatch || resolved.startsWith(`${resolvedMatch}.tmp-`);
};
fs.writeFileSync = (filePath, ...args) => {
if (targetsMatch(filePath)) {
throw new Error(`injected write failure for ${path.basename(matchPath)}`);
}
return originalWriteFileSync.call(fs, filePath, ...args);
};
try {
return fn();
} finally {
fs.writeFileSync = originalWriteFileSync;
}
}
function withSdkDistPresent(fn) {
const sdkCliPath = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
const originalExistsSync = fs.existsSync;
const originalStatSync = fs.statSync;
const originalChmodSync = fs.chmodSync;
fs.existsSync = (filePath) => {
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return true;
return originalExistsSync.call(fs, filePath);
};
fs.statSync = (filePath, ...args) => {
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) {
return { mode: 0o755 };
}
return originalStatSync.call(fs, filePath, ...args);
};
fs.chmodSync = (filePath, ...args) => {
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return;
return originalChmodSync.call(fs, filePath, ...args);
};
try {
return fn();
} finally {
fs.existsSync = originalExistsSync;
fs.statSync = originalStatSync;
fs.chmodSync = originalChmodSync;
}
}
function stripAnsi(value) {
// eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output
return value.replace(/\x1b\[[0-9;]*m/g, '');
}
function runInstallerCli(runtime, targetDir, options = {}) {
const { minimal = true } = options;
const env = { ...process.env };
delete env.GSD_TEST_MODE;
env.HOME = path.join(path.dirname(targetDir), 'home');
env.USERPROFILE = env.HOME;
return runNode(
[
installScript,
`--${runtime}`,
'--global',
'--config-dir',
targetDir,
...(minimal ? ['--minimal'] : []),
'--no-sdk',
],
{
env,
timeoutMs: INSTALL_TIMEOUT_MS,
}
);
}
function listDirNames(root, relPath) {
const dir = path.join(root, relPath);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).map((entry) => entry.name);
}
function assertHasGsdDirectory(root, relPath) {
assert.ok(
listDirNames(root, relPath).some((name) => name.startsWith('gsd-')),
`${relPath} should contain generated GSD entries`
);
}
function assertFreshInstallContract(runtime, targetDir) {
const contract = RUNTIME_INSTALL_CONTRACTS[runtime];
assert.ok(contract, `missing runtime install contract for ${runtime}`);
// #3023: the shared hooks bundle's staged directory name is per-runtime
// (hostBehaviors.sharedHooksDirName; pi renames it to `gsd-hooks/` to avoid
// pi's own host-reserved `hooks/`) — resolve it the same way the installer
// does rather than hardcoding 'hooks', which is only the default.
const hooksDirName = installModule.resolveSharedHooksDirName(runtime);
if (contract.workflowPayload !== false) {
assert.equal(
fs.readFileSync(path.join(targetDir, 'gsd-core', 'VERSION'), 'utf8'),
pkg.version,
`${runtime} should install the package VERSION`
);
assert.ok(
fs.existsSync(path.join(targetDir, 'gsd-core', 'bin', 'gsd-tools.cjs')),
`${runtime} should install the GSD tool payload`
);
assert.ok(
fs.existsSync(path.join(targetDir, 'gsd-file-manifest.json')),
`${runtime} should write the install manifest`
);
const manifest = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-file-manifest.json'), 'utf8'));
assert.equal(manifest.version, pkg.version, `${runtime} manifest should record the package version`);
assert.equal(manifest.mode, 'full', `${runtime} manifest should record a full install`);
assert.ok(
manifest.files['gsd-core/VERSION'],
`${runtime} manifest should track the installed VERSION file`
);
} else {
assert.equal(
fs.existsSync(path.join(targetDir, 'gsd-core')),
false,
`${runtime} should not install the GSD workflow payload`
);
}
if (contract.surface === 'flat-skills') {
if (runtime === 'codex') {
// #2088: Codex CLI skills install to the canonical `$HOME/.agents/skills`
// root (resolved via os.homedir()), NOT `<config-dir>/skills`. Here
// runInstallerCli sandboxes HOME to <dirname(targetDir)>/home, so assert
// the skill dir under that sandboxed home instead of under targetDir.
const codexSandboxHome = path.join(path.dirname(targetDir), 'home');
assertHasGsdDirectory(path.join(codexSandboxHome, '.agents'), 'skills');
} else if (runtime === 'antigravity') {
// #3738: Antigravity's machine-local discovery scans ~/.gemini/config, so
// global skills install under the sandboxed home's .gemini/config root
// (kind `home` override), NOT `<config-dir>/skills`. Same sandboxed-HOME
// reasoning as the codex branch above.
const agySandboxHome = path.join(path.dirname(targetDir), 'home');
assertHasGsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'skills');
assertHasGsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'agents');
} else {
// Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs
// (assumption: Codex auto-discovers from workflows). That assumption
// does not hold for Codex CLI 0.130.0 — fresh installs now materialize
// the same flat-skills surface as the other runtimes.
assertHasGsdDirectory(targetDir, 'skills');
}
} else if (contract.surface === 'hermes-skills') {
// Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix).
// Assert that the category dir contains at least one skill dir with SKILL.md.
const hermesGsdDir = path.join(targetDir, 'skills', 'gsd');
const hermesSkillCount = fs.existsSync(hermesGsdDir)
? fs.readdirSync(hermesGsdDir, { withFileTypes: true })
.filter(e => e.isDirectory() && fs.existsSync(path.join(hermesGsdDir, e.name, 'SKILL.md')))
.length
: 0;
assert.ok(hermesSkillCount > 0, `skills/gsd should contain generated GSD entries (got ${hermesSkillCount})`);
assert.ok(
fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
'Hermes should install the nested GSD category description'
);
} else if (contract.surface === 'flat-command') {
// #2329: dir name is per-runtime (opencode='commands', kilo stays 'command').
const commandDirName = contract.commandDirName || 'command';
assert.ok(
listDirNames(targetDir, commandDirName).some((name) => name.startsWith('gsd-') && name.endsWith('.md')),
`${runtime} should install flattened command markdown files in ${commandDirName}/`
);
} else if (contract.surface === 'plugin-only') {
// #2102 Stage 1/2: pi — PLUGIN-ONLY install for commands/agents/skills
// (hostBehaviors.pluginOnlyInstall). pi's /gsd command is registered
// programmatically by the native extension and dispatches via a bounded
// subprocess to gsd-tools.cjs — pi has no host-read markdown surface, so
// NO commands/, agents/, or skills/ dir is written. The extension DOES
// spawn the shared hooks bundle as bounded subprocesses (Stage 2
// adversarial-review fix — hooksSurface:'none' no longer implies
// skipSharedHooksInstall for pi, mirroring OpenCode), so the bundle + the
// git-cmd.js tokenizer helper ARE part of the artifact surface now. #3023:
// that bundle is staged under `gsd-hooks/` for pi (hooksDirName above), not
// the generic `hooks/` — pi reserves `hooks/` for its own deprecated
// extension location.
// #2470: the dest filename comes from pi's descriptor, and must satisfy
// pi's isExtensionFile() auto-discovery filter (.ts/.js only) — otherwise
// the file installs but pi never loads it and /gsd never registers.
const piNativePlugin = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'pi', 'capability.json'), 'utf8')
).runtime.hostBehaviors.nativePlugin;
assert.ok(
piNativePlugin.file.endsWith('.ts') || piNativePlugin.file.endsWith('.js'),
`${runtime}'s extension "${piNativePlugin.file}" must end in .ts or .js for pi to discover it (#2470)`
);
assert.ok(
fs.existsSync(path.join(targetDir, piNativePlugin.dir, piNativePlugin.file)),
`${runtime} should install the native extension file at ${piNativePlugin.dir}/${piNativePlugin.file}`
);
assert.ok(
fs.existsSync(path.join(targetDir, hooksDirName, 'gsd-ensure-canonical-path.js')),
`${runtime} should install the shared ${hooksDirName}/ bundle (spawned by the native extension's event bridges)`
);
assert.ok(
fs.existsSync(path.join(targetDir, hooksDirName, 'lib', 'git-cmd.js')),
`${runtime} should install ${hooksDirName}/lib/git-cmd.js (the /gsd command tokenizer)`
);
assert.equal(
fs.existsSync(path.join(targetDir, 'commands')),
false,
`${runtime} should NOT install a commands/ dir (plugin-only, no host-read markdown surface)`
);
assert.equal(
fs.existsSync(path.join(targetDir, 'agents')),
false,
`${runtime} should NOT install an agents/ dir (plugin-only, no named-dispatch toolkit)`
);
assert.equal(
fs.existsSync(path.join(targetDir, 'skills')),
false,
`${runtime} should NOT install a skills/ dir (plugin-only)`
);
} else if (contract.surface === 'commands-gsd') {
assert.ok(
listDirNames(targetDir, path.join('commands', 'gsd')).length > 0,
`${runtime} should install commands/gsd entries`
);
} else if (contract.surface === 'kimi-skills-agents') {
assertHasGsdDirectory(targetDir, 'skills');
assert.ok(
fs.existsSync(path.join(targetDir, 'agents', 'gsd.yaml')),
'Kimi should install the root agent YAML'
);
assert.ok(
fs.existsSync(path.join(targetDir, 'agents', 'gsd.md')),
'Kimi should install the root agent prompt'
);
assert.ok(
fs.existsSync(path.join(targetDir, 'agents', 'subagents', 'gsd-executor.yaml')),
'Kimi should install GSD subagent YAML'
);
} else if (contract.surface === 'clinerules') {
// #787: Cline now uses the .clinerules/ directory form (rules at gsd.md).
assert.match(
fs.readFileSync(path.join(targetDir, '.clinerules', 'gsd.md'), 'utf8'),
/GSD workflows live in `gsd-core\/workflows\/`/,
'Cline should install .clinerules/gsd.md guidance'
);
} else if (contract.surface === 'global-artifacts-noop') {
assert.equal(
fs.existsSync(path.join(targetDir, 'skills')),
false,
`${runtime} should not install unsupported global skills artifacts`
);
assert.equal(
fs.existsSync(path.join(targetDir, 'workflows')),
false,
`${runtime} should not install unsupported global workflow artifacts`
);
}
if (contract.surface !== 'kimi-skills-agents' && contract.surface !== 'global-artifacts-noop' && contract.surface !== 'plugin-only') {
if (runtime === 'antigravity') {
// #3738: antigravity agents install under the sandboxed home's
// .gemini/config root (see the flat-skills branch above), not targetDir.
const agySandboxHomeForAgents = path.join(path.dirname(targetDir), 'home');
assert.ok(
listDirNames(path.join(agySandboxHomeForAgents, '.gemini', 'config'), 'agents').some((name) => name.startsWith('gsd-')),
`${runtime} full install should install agents`
);
} else {
assert.ok(
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
`${runtime} full install should install agents`
);
}
}
assert.equal(
fs.existsSync(path.join(targetDir, 'settings.json')),
contract.settings,
`${runtime} settings.json presence should match the runtime contract`
);
// #2544: the CommonJS marker lives in the shared hooks dir (the dir GSD
// fills with its own .js scripts — `gsd-hooks/` for pi, `hooks/` for every
// other runtime, #3023), never at the config root — that file is user-owned
// territory on OpenCode/Kilo and was being clobbered on every install.
assert.equal(
fs.existsSync(path.join(targetDir, hooksDirName, 'package.json')),
contract.hooksPackageJson,
`${runtime} ${hooksDirName}/package.json presence should match the runtime contract`
);
assert.equal(
fs.existsSync(path.join(targetDir, 'package.json')),
false,
`${runtime} must not receive a GSD package.json at the config root (#2544)`
);
if (contract.codexConfig) {
assert.match(
fs.readFileSync(path.join(targetDir, 'config.toml'), 'utf8'),
/GSD Agent Configuration/,
'Codex should install config.toml with the GSD marker'
);
}
if (contract.copilotInstructions) {
assert.match(
fs.readFileSync(path.join(targetDir, 'copilot-instructions.md'), 'utf8'),
/GSD Configuration/,
'Copilot should install managed copilot instructions'
);
}
}
describe('installer migration install integration', { concurrency: false }, () => {
let tmpRoot;
let codexHome;
beforeEach(() => {
tmpRoot = createTempDir('gsd-install-migrations-');
codexHome = path.join(tmpRoot, '.codex');
fs.mkdirSync(codexHome, { recursive: true });
});
afterEach(() => {
cleanup(tmpRoot);
});
test('reports applied migration actions before package materialization', () => {
writeFile(codexHome, 'hooks/statusline.js', 'legacy managed hook\n');
writeManifest(codexHome, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
const { output } = captureConsole(() =>
withCodexEnv(codexHome, () => install(true, 'codex'))
);
const plainOutput = stripAnsi(output);
assert.match(plainOutput, /Installer migrations/);
assert.match(plainOutput, /removed\s+hooks\/statusline\.js/);
assert.ok(
plainOutput.indexOf('Installer migrations') < plainOutput.indexOf('Installed workflow assets'),
'migration report should appear before package materialization'
);
assert.equal(fs.existsSync(path.join(codexHome, 'hooks/statusline.js')), false);
});
test('blocks install before materialization when baseline needs explicit user choice', () => {
writeFile(codexHome, 'hooks/gsd-retired-hook.txt', 'old gsd hook\n');
assert.throws(
() => captureConsole(() =>
withCodexEnv(codexHome, () => install(true, 'codex'))
),
/installer migration blocked/
);
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks/gsd-retired-hook.txt'), 'utf8'), 'old gsd hook\n');
assert.equal(fs.existsSync(path.join(codexHome, 'skills')), false);
// #2088: with HOME sandboxed to codexHome via withCodexEnv, Codex's
// canonical skill root ($HOME/.agents/skills) resolves under codexHome
// too — assert nothing was materialized there when the install is blocked.
assert.equal(fs.existsSync(path.join(codexHome, '.agents', 'skills')), false);
assert.equal(fs.existsSync(path.join(codexHome, 'gsd-core', 'VERSION')), false);
});
test('rolls back applied migrations when package materialization fails for non-Codex installs', () => {
const claudeHome = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeHome, { recursive: true });
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
writeManifest(claudeHome, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
assert.throws(
() => captureConsole(() =>
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
withWriteFailure(path.join(claudeHome, 'gsd-core', 'VERSION'), () => install(true, 'claude'))
)
),
/injected write failure for VERSION/
);
assert.equal(
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
'legacy managed hook\n'
);
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
});
test('rolls back applied migrations when multi-runtime finalization fails', () => {
const claudeHome = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeHome, { recursive: true });
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
writeManifest(claudeHome, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
assert.throws(
() => captureConsole(() =>
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
withSdkDistPresent(() =>
withWriteFailure(path.join(claudeHome, 'settings.json'), () =>
installModule.installAllRuntimes(['claude'], true, false)
)
)
)
),
/injected write failure for settings\.json/
);
assert.equal(
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
'legacy managed hook\n'
);
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
});
test('rolls back completed runtime migrations when a later runtime install fails', () => {
const claudeHome = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeHome, { recursive: true });
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
writeManifest(claudeHome, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
writeFile(codexHome, 'hooks/statusline.js', 'legacy managed hook\n');
writeManifest(codexHome, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
assert.throws(
() => captureConsole(() =>
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
withCodexEnv(codexHome, () =>
withWriteFailure(path.join(codexHome, 'gsd-core', 'VERSION'), () =>
installModule.installAllRuntimes(['claude', 'codex'], true, false)
)
)
)
),
/injected write failure for VERSION/
);
assert.equal(
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
'legacy managed hook\n'
);
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
assert.equal(
fs.readFileSync(path.join(codexHome, 'hooks/statusline.js'), 'utf8'),
'legacy managed hook\n'
);
assert.equal(fs.existsSync(path.join(codexHome, 'gsd-install-state.json')), false);
});
for (const runtime of SUPPORTED_RUNTIMES) {
test(`runs a full end-to-end install for ${runtime}`, () => {
const targetDir = path.join(tmpRoot, `.${runtime}-full-install`);
fs.mkdirSync(targetDir, { recursive: true });
writeFile(targetDir, 'hooks/statusline.js', 'legacy managed hook\n');
writeManifest(targetDir, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
const result = runInstallerCli(runtime, targetDir, { minimal: false });
assert.equal(result.exitCode, 0, result.stderr || result.stdout);
const output = stripAnsi(`${result.stdout}\n${result.stderr}`);
assert.match(output, /Installing for /);
assert.match(output, /Installer migrations/);
assert.match(output, /removed\s+hooks\/statusline\.js/);
if (runtime === 'kimi') {
assert.match(output, /Generated Kimi root agent/);
} else {
assert.match(output, /Installed workflow assets/);
}
assert.match(output, /Done!/);
assert.equal(fs.existsSync(path.join(targetDir, 'hooks/statusline.js')), false);
const installState = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-install-state.json'), 'utf8'));
assert.ok(
installState.appliedMigrations.some((entry) => entry.id === '2026-05-11-legacy-orphan-files'),
`${runtime} should track the applied cleanup migration in install state`
);
assertFreshInstallContract(runtime, targetDir);
});
test(`runs managed cleanup migrations for ${runtime}`, () => {
const targetDir = path.join(tmpRoot, `.${runtime}-managed-cleanup`);
fs.mkdirSync(targetDir, { recursive: true });
writeFile(targetDir, 'hooks/statusline.js', 'legacy managed hook\n');
writeManifest(targetDir, {
'hooks/statusline.js': sha256('legacy managed hook\n'),
});
const result = runInstallerCli(runtime, targetDir);
assert.equal(result.exitCode, 0, result.stderr || result.stdout);
const output = stripAnsi(`${result.stdout}\n${result.stderr}`);
assert.match(output, /Installer migrations/);
assert.match(output, /removed\s+hooks\/statusline\.js/);
assert.equal(fs.existsSync(path.join(targetDir, 'hooks/statusline.js')), false);
const installState = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-install-state.json'), 'utf8'));
assert.ok(
installState.appliedMigrations.some((entry) => entry.id === '2026-05-11-legacy-orphan-files'),
'successful install should write install state for the applied cleanup migration'
);
});
test(`blocks ambiguous GSD-looking user-choice artifacts for ${runtime}`, () => {
const targetDir = path.join(tmpRoot, `.${runtime}-blocked`);
fs.mkdirSync(targetDir, { recursive: true });
writeFile(targetDir, 'gsd-core/gsd-retired-tool.cjs', 'old ambiguous artifact\n');
const result = runInstallerCli(runtime, targetDir);
assert.notEqual(result.exitCode, 0, 'install should fail before materialization');
const output = stripAnsi(`${result.stdout}\n${result.stderr}`);
assert.match(output, /Installer migrations/);
assert.match(output, /blocked\s+gsd-core\/gsd-retired-tool\.cjs/);
assert.match(output, /installer migration blocked/);
assert.equal(
fs.readFileSync(path.join(targetDir, 'gsd-core/gsd-retired-tool.cjs'), 'utf8'),
'old ambiguous artifact\n'
);
assert.equal(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')), false);
});
}
});