Add first-time installer baseline migration

This commit is contained in:
Tom Boucher
2026-05-10 23:25:11 -04:00
parent 9889d0a9aa
commit 3084ecc2a6
6 changed files with 383 additions and 6 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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: [],

View File

@@ -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)
);
},
};

View File

@@ -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 {