diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 989fb8fe0..bace7b30b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -627,6 +627,9 @@ Install-time file moves, stale-artifact cleanup, config rewrites, and user-data preservation are governed by the Installer Migration Module. See [Installer Migrations](installer-migrations.md) and [ADR 0008](adr/0008-installer-migration-module.md). +The migration module also owns the gated first-time baseline scan for legacy +installs, classifying known runtime install surfaces before later migrations +remove or rewrite anything. ### Platform Handling diff --git a/docs/adr/0008-installer-migration-module.md b/docs/adr/0008-installer-migration-module.md index 10883f311..25509d37a 100644 --- a/docs/adr/0008-installer-migration-module.md +++ b/docs/adr/0008-installer-migration-module.md @@ -15,6 +15,7 @@ We decided to introduce an explicit Installer Migration Module for install-time - Require migrations to plan first, then apply through a shared executor that owns backup, rollback, and reporting. - Default ambiguous or unknown files to preserve; destructive changes need managed-file evidence or explicit user choice. - Support dry-run output using the same planner used by apply mode. +- Include a first-time baseline scanner for legacy installs that need classification before destructive migrations can be trusted. - Treat the runtime configuration contract registry in `docs/installer-migrations.md` as the source of truth for migrations that touch host runtime config. ## Runtime Contract Decision diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index d8a54b635..64a39a7b5 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -213,6 +213,22 @@ an explicit user choice. Use for profile, preferences, hand-authored instructions, and future workflow outputs. +### record-baseline + +Record a manifest-managed file in the first-time baseline without mutating it. +The executor writes a journal entry and install-state entry so later upgrades +know the baseline scan completed. + +Use only from the first-time baseline scanner. + +### baseline-preserve-user + +Record a user-owned or unknown file discovered under a known install surface +without mutating it. Unknown files default to this action unless they look like +retired GSD-generated artifacts that need an explicit user choice. + +Use only from the first-time baseline scanner. + ### prompt-user Stop non-interactive destructive migration and ask in interactive mode. The @@ -365,6 +381,18 @@ It should: 5. offer actions for ambiguous files instead of deleting them 6. write install state after successful classification +The Phase 3 implementation adds a gated baseline migration record, +`2026-05-11-first-time-baseline-scan`. The runner passes `baselineScan: true` +when the installer wants this first-time scan. Without that flag, discovery is +safe for normal migration runs and the baseline record plans no actions. + +The baseline action contract is: + +- `record-baseline` for manifest-managed files +- `baseline-preserve-user` for known user-owned files and unknown files that do + not look like stale GSD-generated artifacts +- `prompt-user` for stale GSD-looking artifacts that are not manifest-proven + This baseline is the escape hatch for old installs that predate full migration tracking. It gives the user a reviewable redistribution/removal plan without requiring the installer to infer every past release transition perfectly. diff --git a/get-shit-done/bin/lib/installer-migrations.cjs b/get-shit-done/bin/lib/installer-migrations.cjs index ac2c6a243..b69272995 100644 --- a/get-shit-done/bin/lib/installer-migrations.cjs +++ b/get-shit-done/bin/lib/installer-migrations.cjs @@ -222,7 +222,14 @@ function journalAction(action, status, extras = {}) { return { ...safeAction, ...extras, status }; } -function planInstallerMigrations({ configDir, runtime = null, scope = null, migrations, now = () => new Date().toISOString() }) { +function planInstallerMigrations({ + configDir, + runtime = null, + scope = null, + migrations, + baselineScan = false, + now = () => new Date().toISOString(), +}) { if (!configDir) throw new Error('configDir is required'); if (!Array.isArray(migrations)) throw new Error('migrations must be an array'); @@ -258,6 +265,7 @@ function planInstallerMigrations({ configDir, runtime = null, scope = null, migr scope, manifest, state, + baselineScan, now, classifyArtifact: classify, readJson: (relPath) => readJson(configDir, relPath), @@ -267,7 +275,13 @@ function planInstallerMigrations({ configDir, runtime = null, scope = null, migr } for (const rawAction of plannedActions) { const relPath = normalizeRelPath(rawAction.relPath); - const classification = classify(relPath); + const classification = rawAction.classification + ? { + classification: rawAction.classification, + originalHash: rawAction.originalHash || null, + currentHash: rawAction.currentHash || null, + } + : classify(relPath); let protectedType = rawAction.type; if (rawAction.type === 'remove-managed' && classification.classification === 'managed-modified') { protectedType = 'backup-and-remove'; @@ -295,7 +309,18 @@ function planInstallerMigrations({ configDir, runtime = null, scope = null, migr action.value = rawAction.value; action.deleteIfEmpty = rawAction.deleteIfEmpty === true; } - if (action.classification === 'unknown' && action.type !== 'rewrite-json') blocked.push(action); + if (rawAction.prompt) action.prompt = rawAction.prompt; + if (Array.isArray(rawAction.choices)) action.choices = rawAction.choices; + if (action.type === 'prompt-user') { + blocked.push(action); + } else if ( + action.classification === 'unknown' && + action.type !== 'rewrite-json' && + action.type !== 'record-baseline' && + action.type !== 'baseline-preserve-user' + ) { + blocked.push(action); + } actions.push(action); } } @@ -388,7 +413,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t try { for (const action of plan.actions) { - if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove' && action.type !== 'rewrite-json') { + if ( + action.type !== 'remove-managed' && + action.type !== 'backup-and-remove' && + action.type !== 'rewrite-json' && + action.type !== 'record-baseline' && + action.type !== 'baseline-preserve-user' + ) { throw new Error(`unsupported migration action type: ${action.type}`); } @@ -398,6 +429,11 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t continue; } + if (action.type === 'record-baseline' || action.type === 'baseline-preserve-user') { + journal.actions.push(journalAction(action, action.type === 'record-baseline' ? 'recorded' : 'preserved')); + continue; + } + const rollbackPath = path.join(rollbackRoot, normalized); fs.mkdirSync(path.dirname(rollbackPath), { recursive: true }); fs.copyFileSync(fullPath, rollbackPath); @@ -441,9 +477,15 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t const state = readInstallState(configDir); const applied = appliedMigrationIds(state); const nextApplied = [...state.appliedMigrations]; + const actionsByMigrationId = new Map(); + for (const action of plan.actions) { + if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) { + actionsByMigrationId.set(action.migrationId, action); + } + } for (const id of journal.appliedMigrationIds) { if (!applied.has(id)) { - const action = plan.actions.find((candidate) => candidate.migrationId === id); + const action = actionsByMigrationId.get(id); nextApplied.push({ id, appliedAt, @@ -493,9 +535,10 @@ function runInstallerMigrations({ scope = null, migrationsDir = DEFAULT_MIGRATIONS_DIR, migrations = discoverInstallerMigrations({ migrationsDir }), + baselineScan = false, now = () => new Date().toISOString(), } = {}) { - const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, now }); + const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now }); if (plan.actions.length === 0) { return { appliedMigrationIds: [], 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 new file mode 100644 index 000000000..56f790b27 --- /dev/null +++ b/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs @@ -0,0 +1,173 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const BASELINE_MIGRATION_ID = '2026-05-11-first-time-baseline-scan'; + +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'], + gemini: ['get-shit-done', 'commands/gsd', 'hooks'], + opencode: ['get-shit-done', 'command', 'skills', 'agents'], + kilo: ['get-shit-done', 'command', 'skills', 'agents'], + copilot: ['get-shit-done', 'skills', 'agents'], + antigravity: ['get-shit-done', 'skills', 'agents'], + cursor: ['get-shit-done', 'skills', 'agents'], + windsurf: ['get-shit-done', 'skills', 'agents', 'rules'], + augment: ['get-shit-done', 'skills', 'agents'], + trae: ['get-shit-done', 'skills', 'agents', 'rules'], + qwen: ['get-shit-done', 'skills', 'agents'], + hermes: ['get-shit-done', 'skills/gsd', 'agents'], + cline: ['get-shit-done', 'skills', 'agents'], + codebuddy: ['get-shit-done', 'skills', 'agents'], +}; + +const COMMON_SURFACES = ['get-shit-done', 'skills', 'agents', 'hooks']; +const INTERNAL_TOP_LEVEL_NAMES = new Set([ + 'gsd-file-manifest.json', + 'gsd-install-state.json', + 'gsd-migration-backups', + 'gsd-migration-journal', +]); +const USER_OWNED_PATHS = new Set([ + 'get-shit-done/USER-PROFILE.md', + 'commands/gsd/dev-preferences.md', + 'skills/gsd-dev-preferences/SKILL.md', +]); + +function sha256File(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function normalizeRelPath(relPath) { + return relPath.replace(/\\/g, '/').replace(/^\/+/, ''); +} + +function baselineInstallSurfaces(runtime) { + if (runtime && RUNTIME_SURFACES[runtime]) return RUNTIME_SURFACES[runtime]; + return COMMON_SURFACES; +} + +function walkFiles(root, relDir, files) { + const dir = path.join(root, relDir); + if (!fs.existsSync(dir)) return; + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const relPath = path.posix.join(relDir, entry.name); + if (relDir === '' && INTERNAL_TOP_LEVEL_NAMES.has(entry.name)) continue; + const fullPath = path.join(root, relPath); + if (entry.isDirectory()) { + walkFiles(root, relPath, files); + } else if (entry.isFile()) { + files.add(normalizeRelPath(relPath)); + } + } +} + +function scanBaselineFiles(configDir, runtime) { + const relPaths = new Set(); + for (const surface of baselineInstallSurfaces(runtime)) { + const normalized = normalizeRelPath(surface); + const fullPath = path.join(configDir, normalized); + if (!fs.existsSync(fullPath)) continue; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + walkFiles(configDir, normalized, relPaths); + } else if (stat.isFile() && !INTERNAL_TOP_LEVEL_NAMES.has(normalized)) { + relPaths.add(normalized); + } + } + return [...relPaths].sort(); +} + +function isUserOwnedBaselinePath(relPath) { + if (USER_OWNED_PATHS.has(relPath)) return true; + const parts = relPath.split('/'); + if (parts[0] === 'skills' && parts[1] && !parts[1].startsWith('gsd-')) return true; + if (parts[0] === 'agents' && parts[1] && !parts[1].startsWith('gsd-')) return true; + return false; +} + +function isStaleGsdLookingPath(relPath) { + const baseName = path.posix.basename(relPath); + if (/^gsd[-_]/.test(baseName)) return true; + const parts = relPath.split('/'); + if ((parts[0] === 'skills' || parts[0] === 'agents') && parts[1] && parts[1].startsWith('gsd-')) { + return true; + } + return false; +} + +function baselineActionRank(action) { + if (action.type === 'record-baseline') return 0; + if (action.type === 'baseline-preserve-user') return 1; + return 2; +} + +module.exports = { + id: BASELINE_MIGRATION_ID, + title: 'Record first-time installer migration baseline', + description: 'Classify existing install surfaces before destructive installer migrations run.', + introducedIn: '1.50.0', + scopes: ['global', 'local'], + destructive: false, + plan: ({ configDir, runtime, baselineScan, classifyArtifact }) => { + if (!baselineScan) return []; + + const actions = []; + for (const relPath of scanBaselineFiles(configDir, runtime)) { + const artifact = classifyArtifact(relPath); + if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') { + actions.push({ + type: 'record-baseline', + relPath, + reason: 'existing manifest-managed file included in first-time migration baseline', + }); + continue; + } + + const currentHash = fs.existsSync(path.join(configDir, relPath)) ? sha256File(path.join(configDir, relPath)) : null; + if (isUserOwnedBaselinePath(relPath)) { + actions.push({ + type: 'baseline-preserve-user', + relPath, + reason: 'known user-owned artifact preserved by first-time migration baseline', + classification: 'user-owned', + originalHash: null, + currentHash, + }); + continue; + } + + if (isStaleGsdLookingPath(relPath)) { + actions.push({ + type: 'prompt-user', + relPath, + reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice', + classification: 'stale-gsd-looking', + originalHash: artifact.originalHash, + currentHash, + prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.', + choices: ['keep', 'remove'], + }); + continue; + } + + actions.push({ + type: 'baseline-preserve-user', + relPath, + reason: 'unknown install-surface file preserved by first-time migration baseline', + classification: artifact.classification, + originalHash: artifact.originalHash, + currentHash, + }); + } + + return actions.sort((left, right) => + baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath) + ); + }, +}; diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index 6ba99a390..e2fdfae65 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -14,6 +14,7 @@ const { runInstallerMigrations, writeInstallState, } = require('../get-shit-done/bin/lib/installer-migrations.cjs'); +const firstTimeBaselineMigration = require('../get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs'); function createTempInstall() { return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-')); @@ -68,6 +69,134 @@ function userHook(command) { }; } +test('records a first-time baseline while preserving user-owned artifacts', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'get-shit-done/workflows/plan.md', 'managed workflow\n'); + writeFile(configDir, 'get-shit-done/USER-PROFILE.md', 'user profile\n'); + writeManifest(configDir, { + 'get-shit-done/workflows/plan.md': sha256('managed workflow\n'), + }); + + const result = runInstallerMigrations({ + configDir, + runtime: 'claude', + scope: 'global', + migrations: [firstTimeBaselineMigration], + baselineScan: true, + now: () => '2026-05-11T00:00:00.000Z', + }); + + assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-first-time-baseline-scan']); + assert.equal(fs.readFileSync(path.join(configDir, 'get-shit-done/workflows/plan.md'), 'utf8'), 'managed workflow\n'); + assert.equal(fs.readFileSync(path.join(configDir, 'get-shit-done/USER-PROFILE.md'), 'utf8'), 'user profile\n'); + + assert.deepEqual( + result.plan.actions.map((action) => ({ + type: action.type, + relPath: action.relPath, + classification: action.classification, + })), + [ + { + type: 'record-baseline', + relPath: 'get-shit-done/workflows/plan.md', + classification: 'managed-pristine', + }, + { + type: 'baseline-preserve-user', + relPath: 'get-shit-done/USER-PROFILE.md', + classification: 'user-owned', + }, + ] + ); + assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [ + '2026-05-11-first-time-baseline-scan', + ]); + } finally { + cleanup(configDir); + } +}); + +test('preserves unknown files discovered in known install surfaces by default', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/custom-user-hook.js', 'user hook\n'); + writeManifest(configDir, {}); + + const result = runInstallerMigrations({ + configDir, + runtime: 'claude', + scope: 'global', + migrations: [firstTimeBaselineMigration], + baselineScan: true, + now: () => '2026-05-11T00:00:01.000Z', + }); + + assert.deepEqual(result.blocked, undefined); + assert.deepEqual( + result.plan.actions.map((action) => ({ + type: action.type, + relPath: action.relPath, + classification: action.classification, + })), + [ + { + type: 'baseline-preserve-user', + relPath: 'hooks/custom-user-hook.js', + classification: 'unknown', + }, + ] + ); + assert.equal(fs.readFileSync(path.join(configDir, 'hooks/custom-user-hook.js'), 'utf8'), 'user hook\n'); + assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [ + '2026-05-11-first-time-baseline-scan', + ]); + } finally { + cleanup(configDir); + } +}); + +test('blocks stale GSD-looking baseline artifacts for explicit user choice', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'hooks/gsd-retired-hook.js', 'old gsd hook\n'); + writeManifest(configDir, {}); + + const result = runInstallerMigrations({ + configDir, + runtime: 'claude', + scope: 'global', + migrations: [firstTimeBaselineMigration], + baselineScan: true, + now: () => '2026-05-11T00:00:02.000Z', + }); + + assert.deepEqual(result.appliedMigrationIds, []); + assert.equal(result.journalRelPath, null); + assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false); + assert.equal(fs.readFileSync(path.join(configDir, 'hooks/gsd-retired-hook.js'), 'utf8'), 'old gsd hook\n'); + assert.deepEqual( + result.blocked.map((action) => ({ + type: action.type, + relPath: action.relPath, + classification: action.classification, + choices: action.choices, + })), + [ + { + type: 'prompt-user', + relPath: 'hooks/gsd-retired-hook.js', + classification: 'stale-gsd-looking', + choices: ['keep', 'remove'], + }, + ] + ); + } finally { + cleanup(configDir); + } +}); + test('plans a pending migration against an unchanged managed file', () => { const configDir = createTempInstall(); try {