Add first-time installer baseline migration
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user