Files
msd-core/tests/installer-migration-install.integration.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02: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.MSD_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-msd', 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 /msd-* 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
// /msd command is registered programmatically by the native extension
// (extensions/msd.cjs) and dispatches via a bounded subprocess to
// msd-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 /msd 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, 'msd-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.MSD_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 assertHasMsdDirectory(root, relPath) {
assert.ok(
listDirNames(root, relPath).some((name) => name.startsWith('msd-')),
`${relPath} should contain generated MSD 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 `msd-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, 'msd-core', 'VERSION'), 'utf8'),
pkg.version,
`${runtime} should install the package VERSION`
);
assert.ok(
fs.existsSync(path.join(targetDir, 'msd-core', 'bin', 'msd-tools.cjs')),
`${runtime} should install the MSD tool payload`
);
assert.ok(
fs.existsSync(path.join(targetDir, 'msd-file-manifest.json')),
`${runtime} should write the install manifest`
);
const manifest = JSON.parse(fs.readFileSync(path.join(targetDir, 'msd-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['msd-core/VERSION'],
`${runtime} manifest should track the installed VERSION file`
);
} else {
assert.equal(
fs.existsSync(path.join(targetDir, 'msd-core')),
false,
`${runtime} should not install the MSD 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');
assertHasMsdDirectory(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');
assertHasMsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'skills');
assertHasMsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'agents');
} else {
// Pre-#3562: codex was special-cased to expect zero msd-* 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.
assertHasMsdDirectory(targetDir, 'skills');
}
} else if (contract.surface === 'hermes-skills') {
// Hermes layout uses prefix: '' — skill dirs have bare stem names (no msd- prefix).
// Assert that the category dir contains at least one skill dir with SKILL.md.
const hermesMsdDir = path.join(targetDir, 'skills', 'msd');
const hermesSkillCount = fs.existsSync(hermesMsdDir)
? fs.readdirSync(hermesMsdDir, { withFileTypes: true })
.filter(e => e.isDirectory() && fs.existsSync(path.join(hermesMsdDir, e.name, 'SKILL.md')))
.length
: 0;
assert.ok(hermesSkillCount > 0, `skills/msd should contain generated MSD entries (got ${hermesSkillCount})`);
assert.ok(
fs.existsSync(path.join(targetDir, 'skills', 'msd', 'DESCRIPTION.md')),
'Hermes should install the nested MSD 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('msd-') && 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 /msd command is registered
// programmatically by the native extension and dispatches via a bounded
// subprocess to msd-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 `msd-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 /msd 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, 'msd-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 /msd 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-msd') {
assert.ok(
listDirNames(targetDir, path.join('commands', 'msd')).length > 0,
`${runtime} should install commands/msd entries`
);
} else if (contract.surface === 'kimi-skills-agents') {
assertHasMsdDirectory(targetDir, 'skills');
assert.ok(
fs.existsSync(path.join(targetDir, 'agents', 'msd.yaml')),
'Kimi should install the root agent YAML'
);
assert.ok(
fs.existsSync(path.join(targetDir, 'agents', 'msd.md')),
'Kimi should install the root agent prompt'
);
assert.ok(
fs.existsSync(path.join(targetDir, 'agents', 'subagents', 'msd-executor.yaml')),
'Kimi should install MSD subagent YAML'
);
} else if (contract.surface === 'clinerules') {
// #787: Cline now uses the .clinerules/ directory form (rules at msd.md).
assert.match(
fs.readFileSync(path.join(targetDir, '.clinerules', 'msd.md'), 'utf8'),
/MSD workflows live in `msd-core\/workflows\/`/,
'Cline should install .clinerules/msd.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('msd-')),
`${runtime} full install should install agents`
);
} else {
assert.ok(
listDirNames(targetDir, 'agents').some((name) => name.startsWith('msd-')),
`${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 MSD
// fills with its own .js scripts — `msd-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 MSD package.json at the config root (#2544)`
);
if (contract.codexConfig) {
assert.match(
fs.readFileSync(path.join(targetDir, 'config.toml'), 'utf8'),
/MSD Agent Configuration/,
'Codex should install config.toml with the MSD marker'
);
}
if (contract.copilotInstructions) {
assert.match(
fs.readFileSync(path.join(targetDir, 'copilot-instructions.md'), 'utf8'),
/MSD Configuration/,
'Copilot should install managed copilot instructions'
);
}
}
describe('installer migration install integration', { concurrency: false }, () => {
let tmpRoot;
let codexHome;
beforeEach(() => {
tmpRoot = createTempDir('msd-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/msd-retired-hook.txt', 'old msd hook\n');
assert.throws(
() => captureConsole(() =>
withCodexEnv(codexHome, () => install(true, 'codex'))
),
/installer migration blocked/
);
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks/msd-retired-hook.txt'), 'utf8'), 'old msd 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, 'msd-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, 'msd-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, 'msd-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, 'msd-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, 'msd-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, 'msd-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, 'msd-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, 'msd-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, 'msd-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 MSD-looking user-choice artifacts for ${runtime}`, () => {
const targetDir = path.join(tmpRoot, `.${runtime}-blocked`);
fs.mkdirSync(targetDir, { recursive: true });
writeFile(targetDir, 'msd-core/msd-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+msd-core\/msd-retired-tool\.cjs/);
assert.match(output, /installer migration blocked/);
assert.equal(
fs.readFileSync(path.join(targetDir, 'msd-core/msd-retired-tool.cjs'), 'utf8'),
'old ambiguous artifact\n'
);
assert.equal(fs.existsSync(path.join(targetDir, 'msd-core', 'VERSION')), false);
});
}
});