* test(#3023): failing-first guard — pi must not stage hooks in its reserved dir pi reserves <configDir>/hooks as its deprecated extension location and warns on every startup when it exists. Assert a pi install stages the shared hook bundle under gsd-hooks/ instead, manifests it there, and never creates hooks/. Also adds pi to the local-scope dir table in install-shared.cjs: pi was in RUNTIME_META but not LOCAL_DIR_NAME, so scope:'local' resolved path.join(root, undefined) and no local pi install could be exercised. Fails before the fix. Verified via the remote runner. * fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ dir pi reserves <configDir>/hooks as its now-deprecated extension location and warns on every startup when that directory merely exists — checkDeprecatedExtensionDirs() guards the warning with a bare existsSync(), unlike its tools/ sibling. GSD staged its shared hook bundle exactly there, and pi's advised remediation (move it to extensions/) would break the adapter's paths and expose GSD's .js helpers to pi's extension auto-discovery. The bundle directory name is now runtime-descriptor-driven: hostBehaviors .sharedHooksDirName, defaulting to 'hooks' so all 18 other runtimes are byte-identical. pi sets 'gsd-hooks'. The name is validated as a single path segment — separators, dot-only segments, trailing dots, absolute paths, NUL, and Windows reserved device names all fall back to the default, because the value is joined onto a user's config root and written to. Renamed in place rather than relocated: hook scripts resolve siblings via __dirname/.., so a depth change would silently break them. - install / uninstall / manifest sites all read the resolved name - pi/gsd.cjs probes gsd-hooks then hooks, so dev checkouts and half-upgraded trees still resolve; the never-throws contract is preserved - new migration 009 retires the legacy pi hooks/ dir on upgrade, using a new non-recursive remove-empty-dir engine primitive (rmdirSync only, symlink-refusing, containment-guarded); ADR-0008 amended accordingly - fixes two latent name-dependencies the rename exposed: the stale-hook scan and the injection scanner's self-exclusion both hardcoded 'hooks' Verified on the remote runner. Closes #3023 * fix(#3023): close review findings and align emitted provenance with the rename Adversarial review found two defects, and the remote runner found four failure clusters. All fixed here. Review BLOCKER — detect-custom-files was blind to the renamed bundle. GSD_PREFIX_MANAGED_DIRS in gsd-tools.cjs hardcoded 'hooks', so for pi the whole gsd-hooks/ tree was invisible to the custom-file scan and user-added files there were never backed up before the next update's clean-install wipe. The dir set now resolves via the .gsd-runtime marker plus the shipped capability registry (never bin/install.js, which is not shipped into installed trees), and falls back to scanning every known candidate when the runtime cannot be determined — over-scanning is safe, under-scanning is the data loss. Review MAJOR — the pi adapter bound to an empty bundle. resolveSharedHooksDir accepted any directory, so an interrupted install left gsd-hooks/ winning over a fully-staged legacy hooks/ and every hook silently no-opped. A candidate now qualifies only if it is non-empty. Remote-runner clusters: - emitted-provenance had no rule for the gsd-hooks/ family; added two pi-scoped rules pointing at the same sources the existing hooks/ rules use. The table is total, so an unattributed family is a hard failure by design. - pi tests in install-minimal-hooks and the install integration suite asserted the old layout; updated to derive the dir name from the descriptor rather than hardcoding either name. - 19 unrelated-looking failures on node22 only were a leaked fs mock: t.after() runs in registration order, cleanup was registered before mock.restoreAll(), and node22's JS rimraf calls the public fs.rmdirSync while node24's native path does not — so the EACCES stub leaked process-wide on one lane. Restore now runs first. Verified on the remote runner. * fix(#3023): honor PI_CODING_AGENT_DIR, ack the rename ripple, fix expandTilde pi resolves its agent dir as PI_CODING_AGENT_DIR ?? ~/<CONFIG_DIR_NAME>/agent (packages/coding-agent/src/config.ts). GSD's pi descriptor declared an empty configHome.env, so a user with that variable set had GSD installed where pi never looks. Added the env name; the dot-home-nested resolver already handled the override, so no resolver logic changed. Also fixes expandTilde in the shared runtime-homes resolver, found while adding that: it hardcoded os.homedir() and ignored the opts.home every caller threads, so EVERY runtime's tilde-valued env override (claude, antigravity, windsurf, pi) silently resolved against the real home. That is a correctness bug and a test-escape hazard — a sandboxed test asserting on a tilde override reached the developer's actual home directory. Now threaded through every branch; behavior with no injected home is unchanged. Adds the emitted-drift ack fragment for the 58 pi paths whose emitted location moved with the rename. The provenance rules satisfy the totality gate; the differential gate needs the ack because the hook sources are byte-unchanged — only the installer's target directory moved. The two hook files this branch genuinely edits stay attributed and are not double-acked. Note on piConfig.configDir: it is read from pi's OWN installed package.json (getPackageDir walks up from pi's __dirname), alongside piConfig.name — a white-label setting for a redistributed pi fork, not a per-project user setting. Documented accordingly rather than treated as an unsupported override. Verified on the remote runner. * fix(#3023): reject blank env overrides, pin adapter/descriptor parity Three review findings, all fixed. A whitespace-only config-dir override was accepted verbatim: the guard was `if (val)`, falsy only for the empty string, so PI_CODING_AGENT_DIR=' ' resolved to a literal three-space directory name instead of falling back to the descriptor default. Fixed across every env-consuming branch — dot-home, dot-home-nested, all three xdg steps, and generic-agents-root — not just pi's. Non-blank values are still never trimmed, so '~/My Agent Dir' keeps working. pi/gsd.cjs's probe list and the descriptor were two independent sources of truth for the bundle directory name; a future rename would have desynced them silently and left every pi hook quiet with no error. The probe list stays deliberate — it must resolve in a dev checkout and a half-upgraded tree, where the registry's answer would be wrong — so this adds the parity assertion the repo's generative-fix-divergence rule calls for: the descriptor value must be the FIRST candidate, and the default must remain present. Changeset body rewritten to cover the two later user-facing fixes it had not caught up with. Verified on the remote runner. * chore(#3023): backfill changeset PR number * fix(#3023): anchor injection-scan patterns and fix a macOS detection hole CI's security job flagged CONTEXT.md:124 — pre-existing prose reading 'not the same fact as a genuinely empty or absent one'. The match was the 'act as a' INSIDE 'f-act as a': the pattern had no left word boundary, so any word ending in act tripped it (fact, impact, contract, artifact, interact, redact, abstract). My four-line CONTEXT.md edit dragged the latent false positive into this PR because the scan is diff-scoped by file but reads whole files. Anchored with (^|[^[:alnum:]]) rather than rewording maintainer-owned prose, which would have left the class alive for the next PR touching any file saying 'fact as a'. Auditing the rest of the list for the same class surfaced a real detection hole: the eval/exec/Function patterns matched a quote via \x27, a GNU-grep-only hex escape. BSD/macOS grep reads it as four literal characters, so single-quoted eval('...')/exec('...') payloads were NEVER detected there while passing on GNU-grep CI. Replaced with a literal apostrophe class. Boundaries were added only where a real word-suffix collision exists; exec, jailbreak, developer mode and the role-manipulation family were audited and deliberately left unanchored. 22 new cases cover both directions — the false positives now scan clean, and every real payload still fires, including the quote/punctuation/start-of-line boundary forms. Also builds this branch's injection test fixture at runtime instead of carrying the literal phrase, so the payload keeps its teeth without tripping the scan. Verified on the remote runner. --------- Co-authored-by: sim <sim@local>
649 lines
27 KiB
JavaScript
649 lines
27 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 { spawnSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
|
|
const installModule = require('../bin/install.js');
|
|
const pkg = require('../package.json');
|
|
const { install } = installModule;
|
|
const { createTempDir, cleanup } = require('./helpers.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;
|
|
fs.writeFileSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(matchPath)) {
|
|
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 spawnSync(
|
|
process.execPath,
|
|
[
|
|
installScript,
|
|
`--${runtime}`,
|
|
'--global',
|
|
'--config-dir',
|
|
targetDir,
|
|
...(minimal ? ['--minimal'] : []),
|
|
'--no-sdk',
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
env,
|
|
}
|
|
);
|
|
}
|
|
|
|
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 {
|
|
// 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') {
|
|
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.status, 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.status, 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.status, 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);
|
|
});
|
|
}
|
|
});
|