Wire installer migrations into install flow

This commit is contained in:
Tom Boucher
2026-05-11 09:11:11 -04:00
parent 7a4e5e2efd
commit 0b62129847
10 changed files with 472 additions and 7 deletions

View File

@@ -79,6 +79,10 @@ const {
const {
runInstallerMigrations,
} = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
const {
assertInstallerMigrationsUnblocked,
summarizeInstallerMigrationResult,
} = require(path.join(_gsdLibDir, 'installer-migration-report.cjs'));
// Parse args
const args = process.argv.slice(2);
@@ -7467,6 +7471,17 @@ function reportLocalPatches(configDir, runtime = 'claude') {
return meta.files || [];
}
function reportInstallerMigrationResult(result) {
const summary = summarizeInstallerMigrationResult(result);
if (!summary.hasReportableActions) return;
console.log(` ${green}✓${reset} Installer migrations`);
for (const row of summary.rows) {
const reason = row.reason ? ` — ${row.reason}` : '';
console.log(` ${row.label} ${dim}${row.relPath}${reset}${reason}`);
}
}
function install(isGlobal, runtime = 'claude') {
const isOpencode = runtime === 'opencode';
const isGemini = runtime === 'gemini';
@@ -7722,11 +7737,19 @@ function install(isGlobal, runtime = 'claude') {
// Run manifest-backed cleanup migrations after rollback snapshots exist and
// before package materialization. Codex rollback paths invoke the migration
// rollback handle if a later install step fails.
//
// Runtime scope comes from docs/installer-migrations.md#runtime-configuration-contract-registry:
// every supported runtime uses this same planner/apply/report path, while
// individual migration records decide whether a runtime-specific config
// rewrite is allowed by that runtime's documented ownership boundary.
installerMigrationResult = runInstallerMigrations({
configDir: targetDir,
runtime,
scope: isGlobal ? 'global' : 'local',
baselineScan: true,
});
reportInstallerMigrationResult(installerMigrationResult);
assertInstallerMigrationsUnblocked(installerMigrationResult);
// #3245 CR finding 2 — wrap the pre-config install operations in a try/catch so
// that ANY throw between snapshot capture and the Codex config block triggers rollback.

View File

@@ -277,6 +277,7 @@
"init-command-router.cjs",
"init.cjs",
"install-profiles.cjs",
"installer-migration-report.cjs",
"installer-migrations.cjs",
"intel.cjs",
"learnings.cjs",

View File

@@ -359,7 +359,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (51 shipped)
## CLI Modules (52 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -385,6 +385,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
| `init.cjs` | Compound context loading for each workflow type |
| `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs |
| `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration |
| `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers |
| `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` |
| `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` |

View File

@@ -254,6 +254,21 @@ The installer runs migrations before materializing the new package payload.
11. Write the new manifest and install state.
12. Report backups, preserved files, removed stale files, and skipped actions.
The Phase 4 install integration wires this flow into the normal install/update
entry point for every supported runtime: Claude Code, Antigravity, Augment,
Cline, CodeBuddy, Codex, Copilot, Cursor, Gemini, Hermes Agent, Kilo, OpenCode,
Qwen Code, Trae, and Windsurf. The installer invokes the same migration runner
with `baselineScan: true`, reports the projected action rows, applies safe
non-interactive actions before materialization, writes install state after a
successful apply, and fails before writing new package files when the runner
returns blocked user-choice actions.
Phase 1-3 built the planning, apply, rollback, install-state, baseline, and
migration-record mechanics. Those phases did not prove the normal install entry
point across every runtime. Phase 4 owns that guardrail with an all-runtime
install matrix that exercises safe managed cleanup and blocked user-choice
artifacts for each runtime above.
If any apply step fails, the executor uses the journal to restore modified
paths where possible. Rollback must never delete files that were not created
or modified by the current installer run.

View File

@@ -0,0 +1,50 @@
'use strict';
function installerMigrationActionLabel(action) {
if (!action || !action.type) return 'skipped';
if (action.type === 'backup-and-remove') return 'backed up and removed';
if (action.type === 'remove-managed') return 'removed';
if (action.type === 'rewrite-json') return action.deleteIfEmpty ? 'rewrote or removed' : 'rewrote';
if (action.type === 'record-baseline') return 'recorded';
if (action.type === 'baseline-preserve-user') return 'preserved';
if (action.type === 'preserve-user') return 'preserved';
if (action.type === 'prompt-user') return 'blocked';
return 'skipped';
}
function blockedInstallerMigrationActions(result) {
if (result && Array.isArray(result.blocked)) return result.blocked;
const plan = result && result.plan;
if (plan && Array.isArray(plan.blocked)) return plan.blocked;
return [];
}
function summarizeInstallerMigrationResult(result) {
const plan = result && result.plan;
const actions = plan && Array.isArray(plan.actions) ? plan.actions : [];
const blocked = blockedInstallerMigrationActions(result);
const blockedSet = new Set(blocked);
return {
hasReportableActions: actions.length > 0 || blocked.length > 0,
blocked,
rows: actions.map((action) => ({
label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action),
relPath: action.relPath,
reason: action.reason || '',
action,
})),
};
}
function assertInstallerMigrationsUnblocked(result) {
const blocked = blockedInstallerMigrationActions(result);
if (blocked.length === 0) return;
const paths = blocked.map((action) => action.relPath).join(', ');
throw new Error(`installer migration blocked pending user choice: ${paths}`);
}
module.exports = {
assertInstallerMigrationsUnblocked,
summarizeInstallerMigrationResult,
};

View File

@@ -2,10 +2,16 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const BASELINE_MIGRATION_ID = '2026-05-11-first-time-baseline-scan';
// Runtime install surfaces must stay aligned with:
// - docs/installer-migrations.md#runtime-configuration-contract-registry
// - docs/ARCHITECTURE.md#runtime-install-contract-matrix
//
// The registry rows are based on each runtime's upstream loader docs where
// available. Source-limited rows are intentionally conservative: scan generated
// files GSD materializes, but do not infer ownership of undocumented host config.
const RUNTIME_SURFACES = {
claude: ['get-shit-done', 'commands/gsd', 'skills', 'agents', 'hooks', 'settings.json'],
codex: ['get-shit-done', 'skills', 'agents', 'hooks', 'config.toml', 'hooks.json'],
@@ -36,10 +42,7 @@ const USER_OWNED_PATHS = new Set([
'commands/gsd/dev-preferences.md',
'skills/gsd-dev-preferences/SKILL.md',
]);
function sha256File(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
let knownGeneratedAgentNames = null;
function normalizeRelPath(relPath) {
return relPath.replace(/\\/g, '/').replace(/^\/+/, '');
@@ -91,6 +94,36 @@ function isUserOwnedBaselinePath(relPath) {
return false;
}
function listKnownGeneratedAgentNames() {
if (knownGeneratedAgentNames) return knownGeneratedAgentNames;
knownGeneratedAgentNames = new Set();
const agentsDir = path.resolve(__dirname, '..', '..', '..', '..', 'agents');
try {
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
knownGeneratedAgentNames.add(entry.name.replace(/\.md$/, ''));
}
}
} catch {
// If the source agent directory is unavailable, fail closed and treat
// GSD-looking agent files as user-choice artifacts.
}
return knownGeneratedAgentNames;
}
function isKnownGeneratedAgentPath(relPath, runtime) {
const parts = relPath.split('/');
if (parts.length !== 2 || parts[0] !== 'agents') return false;
const fileName = parts[1];
const extension = path.posix.extname(fileName);
if (extension !== '.md' && !(runtime === 'codex' && extension === '.toml')) return false;
const agentName = fileName.slice(0, -extension.length);
return listKnownGeneratedAgentNames().has(agentName);
}
function isStaleGsdLookingPath(relPath) {
const baseName = path.posix.basename(relPath);
if (/^gsd[-_]/.test(baseName)) return true;
@@ -129,7 +162,19 @@ module.exports = {
continue;
}
const currentHash = fs.existsSync(path.join(configDir, relPath)) ? sha256File(path.join(configDir, relPath)) : null;
const currentHash = artifact.currentHash;
if (isKnownGeneratedAgentPath(relPath, runtime)) {
actions.push({
type: 'record-baseline',
relPath,
reason: 'known installer-generated agent included in first-time migration baseline',
classification: artifact.classification,
originalHash: artifact.originalHash,
currentHash,
});
continue;
}
if (isUserOwnedBaselinePath(relPath)) {
actions.push({
type: 'baseline-preserve-user',

View File

@@ -12,6 +12,10 @@ module.exports = {
introducedIn: '1.50.0',
scopes: ['global', 'local'],
destructive: true,
// Retired generated hook files are removed only with manifest-managed
// evidence. This follows docs/installer-migrations.md#ownership and avoids
// relying on whether a runtime currently registers host hook config in the
// runtime contract registry.
plan: ({ classifyArtifact }) => {
const actions = [];
for (const relPath of LEGACY_ORPHAN_FILES) {

View File

@@ -0,0 +1,183 @@
/**
* 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 { install } = installModule;
const { createTempDir, cleanup } = require('./helpers.cjs');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
const SUPPORTED_RUNTIMES = installModule.allRuntimes;
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;
}
}
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 stripAnsi(value) {
return value.replace(/\x1b\[[0-9;]*m/g, '');
}
function runInstallerCli(runtime, targetDir) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
return spawnSync(
process.execPath,
[installScript, `--${runtime}`, '--global', '--config-dir', targetDir, '--minimal', '--no-sdk'],
{
encoding: 'utf8',
env,
}
);
}
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(() =>
withEnv('CODEX_HOME', 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 get-shit-done'),
'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.js', 'old gsd hook\n');
assert.throws(
() => captureConsole(() =>
withEnv('CODEX_HOME', codexHome, () => install(true, 'codex'))
),
/installer migration blocked/
);
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks/gsd-retired-hook.js'), 'utf8'), 'old gsd hook\n');
assert.equal(fs.existsSync(path.join(codexHome, 'skills')), false);
assert.equal(fs.existsSync(path.join(codexHome, 'get-shit-done', 'VERSION')), false);
});
for (const runtime of SUPPORTED_RUNTIMES) {
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, 'get-shit-done/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+get-shit-done\/gsd-retired-tool\.cjs/);
assert.match(output, /installer migration blocked/);
assert.equal(
fs.readFileSync(path.join(targetDir, 'get-shit-done/gsd-retired-tool.cjs'), 'utf8'),
'old ambiguous artifact\n'
);
assert.equal(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')), false);
});
}
});

View File

@@ -0,0 +1,93 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
assertInstallerMigrationsUnblocked,
summarizeInstallerMigrationResult,
} = require('../get-shit-done/bin/lib/installer-migration-report.cjs');
test('summarizes every installer migration report category', () => {
const blockedAction = {
type: 'prompt-user',
relPath: 'hooks/gsd-retired-hook.js',
reason: 'needs a user choice',
};
const result = {
blocked: [blockedAction],
plan: {
actions: [
{
type: 'remove-managed',
relPath: 'hooks/statusline.js',
reason: 'retired hook',
},
{
type: 'backup-and-remove',
relPath: 'hooks/modified.js',
reason: 'modified managed hook retired',
},
{
type: 'baseline-preserve-user',
relPath: 'hooks/custom.js',
reason: 'user-owned hook',
},
{
type: 'unknown-action',
relPath: 'hooks/unknown.js',
reason: 'unsupported in this installer',
},
blockedAction,
],
},
};
assert.deepEqual(
summarizeInstallerMigrationResult(result).rows.map((row) => ({
label: row.label,
relPath: row.relPath,
reason: row.reason,
})),
[
{
label: 'removed',
relPath: 'hooks/statusline.js',
reason: 'retired hook',
},
{
label: 'backed up and removed',
relPath: 'hooks/modified.js',
reason: 'modified managed hook retired',
},
{
label: 'preserved',
relPath: 'hooks/custom.js',
reason: 'user-owned hook',
},
{
label: 'skipped',
relPath: 'hooks/unknown.js',
reason: 'unsupported in this installer',
},
{
label: 'blocked',
relPath: 'hooks/gsd-retired-hook.js',
reason: 'needs a user choice',
},
]
);
});
test('throws when installer migrations require user choice', () => {
assert.throws(
() => assertInstallerMigrationsUnblocked({
blocked: [
{
relPath: 'hooks/gsd-retired-hook.js',
},
],
}),
/installer migration blocked pending user choice: hooks\/gsd-retired-hook\.js/
);
});

View File

@@ -197,6 +197,56 @@ test('blocks stale GSD-looking baseline artifacts for explicit user choice', ()
}
});
test('records known generated agent artifacts so profile cleanup can remove them', () => {
const configDir = createTempInstall();
try {
writeFile(configDir, 'agents/gsd-executor.md', 'old generated agent\n');
writeFile(configDir, 'agents/gsd-executor.toml', 'old generated agent config\n');
writeFile(configDir, 'agents/gsd-local-experiment.md', 'user experiment\n');
writeManifest(configDir, {});
const result = runInstallerMigrations({
configDir,
runtime: 'codex',
scope: 'global',
migrations: [firstTimeBaselineMigration],
baselineScan: true,
now: () => '2026-05-11T00:00:03.000Z',
});
assert.deepEqual(
result.plan.actions.map((action) => ({
type: action.type,
relPath: action.relPath,
classification: action.classification,
})),
[
{
type: 'record-baseline',
relPath: 'agents/gsd-executor.md',
classification: 'unknown',
},
{
type: 'record-baseline',
relPath: 'agents/gsd-executor.toml',
classification: 'unknown',
},
{
type: 'prompt-user',
relPath: 'agents/gsd-local-experiment.md',
classification: 'stale-gsd-looking',
},
]
);
assert.deepEqual(result.blocked.map((action) => action.relPath), ['agents/gsd-local-experiment.md']);
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.md'), 'utf8'), 'old generated agent\n');
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-executor.toml'), 'utf8'), 'old generated agent config\n');
assert.equal(fs.readFileSync(path.join(configDir, 'agents/gsd-local-experiment.md'), 'utf8'), 'user experiment\n');
} finally {
cleanup(configDir);
}
});
test('plans a pending migration against an unchanged managed file', () => {
const configDir = createTempInstall();
try {