diff --git a/bin/install.js b/bin/install.js index 7cc17d601..750b62ed6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 1e3b768e7..c2bcdbc84 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -277,6 +277,7 @@ "init-command-router.cjs", "init.cjs", "install-profiles.cjs", + "installer-migration-report.cjs", "installer-migrations.cjs", "intel.cjs", "learnings.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index f89271737..9a50be43a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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` | diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 64a39a7b5..8f07804fb 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -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. diff --git a/get-shit-done/bin/lib/installer-migration-report.cjs b/get-shit-done/bin/lib/installer-migration-report.cjs new file mode 100644 index 000000000..d0ff12275 --- /dev/null +++ b/get-shit-done/bin/lib/installer-migration-report.cjs @@ -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, +}; diff --git a/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs b/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs index 56f790b27..8d03a5359 100644 --- a/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs +++ b/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs @@ -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', diff --git a/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs b/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs index d216bb366..b8868288a 100644 --- a/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs +++ b/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs @@ -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) { diff --git a/tests/installer-migration-install-integration.test.cjs b/tests/installer-migration-install-integration.test.cjs new file mode 100644 index 000000000..4ff6b82aa --- /dev/null +++ b/tests/installer-migration-install-integration.test.cjs @@ -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); + }); + } +}); diff --git a/tests/installer-migration-report.test.cjs b/tests/installer-migration-report.test.cjs new file mode 100644 index 000000000..0770c33ae --- /dev/null +++ b/tests/installer-migration-report.test.cjs @@ -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/ + ); +}); diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index e2fdfae65..a0bd17c7c 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -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 {