Merge pull request #3402 from gsd-build/codex/installer-migrations-phase-four
Feat(installer): Phase 4 install/update integration
This commit is contained in:
5
.changeset/vivid-foxes-romp.md
Normal file
5
.changeset/vivid-foxes-romp.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3402
|
||||
---
|
||||
Wire installer migrations into the normal install flow for every supported runtime with baseline scanning, collapsed action reporting, blocked-action protection, transactional rollback, install-state persistence, and lock-guarded execution before package materialization.
|
||||
125
bin/install.js
125
bin/install.js
@@ -77,8 +77,13 @@ const {
|
||||
stageSkillsForMode,
|
||||
} = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
||||
const {
|
||||
discoverInstallerMigrations,
|
||||
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);
|
||||
@@ -7493,7 +7498,18 @@ function reportLocalPatches(configDir, runtime = 'claude') {
|
||||
return meta.files || [];
|
||||
}
|
||||
|
||||
function install(isGlobal, runtime = 'claude') {
|
||||
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', options = {}) {
|
||||
const isOpencode = runtime === 'opencode';
|
||||
const isGemini = runtime === 'gemini';
|
||||
const isKilo = runtime === 'kilo';
|
||||
@@ -7751,11 +7767,20 @@ 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',
|
||||
migrations: options.installerMigrations,
|
||||
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.
|
||||
@@ -8383,6 +8408,12 @@ function install(isGlobal, runtime = 'claude') {
|
||||
}
|
||||
|
||||
} catch (_earlyInstallErr) {
|
||||
// Installer Migration Module Phase 4: docs/installer-migrations.md
|
||||
// requires safe migrations to run before package materialization without
|
||||
// leaving stale state behind when materialization fails. Roll migration
|
||||
// actions back for every runtime; Codex then layers its broader runtime
|
||||
// snapshot rollback on top.
|
||||
rollbackInstallerMigrations();
|
||||
// #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
|
||||
// agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
|
||||
// rollback so the caller is never left in a partially-installed state.
|
||||
@@ -9119,6 +9150,7 @@ function install(isGlobal, runtime = 'claude') {
|
||||
updateBannerCommand,
|
||||
runtime,
|
||||
configDir: targetDir,
|
||||
rollbackInstallerMigrations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9896,6 +9928,12 @@ function installSdkIfNeeded(opts) {
|
||||
if (!fs.existsSync(sdkCliPath)) {
|
||||
const ir = buildSdkFailFastReport(sdkDir, sdkCliPath);
|
||||
renderSdkFailFastReport(ir);
|
||||
if (opts.throwOnFailure) {
|
||||
const error = new Error(`GSD SDK prebuilt artifact missing: ${sdkCliPath}`);
|
||||
error.code = 'GSD_SDK_MISSING_DIST';
|
||||
error.exitCode = 1;
|
||||
throw error;
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -10580,40 +10618,74 @@ function trySelfLinkGsdSdkWindows(shimSrc) {
|
||||
*/
|
||||
function installAllRuntimes(runtimes, isGlobal, isInteractive) {
|
||||
const results = [];
|
||||
const installerMigrations = discoverInstallerMigrations({
|
||||
migrationsDir: path.join(_gsdLibDir, 'installer-migrations'),
|
||||
});
|
||||
|
||||
for (const runtime of runtimes) {
|
||||
const result = install(isGlobal, runtime);
|
||||
results.push(result);
|
||||
const rollbackFinalizedInstallerMigrations = (error) => {
|
||||
const rollbackFailures = [];
|
||||
for (const result of [...results].reverse()) {
|
||||
if (!result || typeof result.rollbackInstallerMigrations !== 'function') continue;
|
||||
try {
|
||||
result.rollbackInstallerMigrations();
|
||||
} catch (rollbackError) {
|
||||
rollbackFailures.push({
|
||||
runtime: result.runtime,
|
||||
error: rollbackError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
error.installerMigrationRollbackFailures = rollbackFailures;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const runtime of runtimes) {
|
||||
const result = install(isGlobal, runtime, { installerMigrations });
|
||||
results.push(result);
|
||||
}
|
||||
} catch (error) {
|
||||
rollbackFinalizedInstallerMigrations(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const statuslineRuntimes = ['claude', 'gemini'];
|
||||
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
|
||||
|
||||
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
|
||||
// Verify sdk/dist/cli.js is present and executable. The dist is shipped
|
||||
// prebuilt in the tarball (fix/2441-sdk-decouple); gsd-sdk reaches users via
|
||||
// the parent package's bin/gsd-sdk.js shim, so no sub-install is needed.
|
||||
// Skip with --no-sdk. Skip with isLocal (#2678 — local installs don't own global npm).
|
||||
// #3033: pass forceSdk so --sdk overrides the local-install skip.
|
||||
installSdkIfNeeded({ isLocal: !isGlobal, forceSdk: hasSdk });
|
||||
try {
|
||||
// Verify sdk/dist/cli.js is present and executable. The dist is shipped
|
||||
// prebuilt in the tarball (fix/2441-sdk-decouple); gsd-sdk reaches users via
|
||||
// the parent package's bin/gsd-sdk.js shim, so no sub-install is needed.
|
||||
// Skip with --no-sdk. Skip with isLocal (#2678 — local installs don't own global npm).
|
||||
// #3033: pass forceSdk so --sdk overrides the local-install skip.
|
||||
installSdkIfNeeded({ isLocal: !isGlobal, forceSdk: hasSdk, throwOnFailure: true });
|
||||
|
||||
const printSummaries = () => {
|
||||
for (const result of results) {
|
||||
const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
useStatusline,
|
||||
result.runtime,
|
||||
isGlobal,
|
||||
result.configDir,
|
||||
{ shouldInstallBanner: !!shouldInstallBanner, bannerCommand: result.updateBannerCommand }
|
||||
);
|
||||
}
|
||||
};
|
||||
const printSummaries = () => {
|
||||
for (const result of results) {
|
||||
const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
useStatusline,
|
||||
result.runtime,
|
||||
isGlobal,
|
||||
result.configDir,
|
||||
{ shouldInstallBanner: !!shouldInstallBanner, bannerCommand: result.updateBannerCommand }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
printSummaries();
|
||||
printSummaries();
|
||||
} catch (error) {
|
||||
// Phase 4 install/update integration requires safe migrations to roll
|
||||
// back when later package/finalization materialization fails:
|
||||
// docs/installer-migrations.md#phase-4-installupdate-integration.
|
||||
rollbackFinalizedInstallerMigrations(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Statusline first; if it won't actually be installed (declined, or local
|
||||
@@ -10688,6 +10760,7 @@ if (process.env.GSD_TEST_MODE) {
|
||||
readGsdRuntimeProfileResolver,
|
||||
readGsdEffectiveModelOverrides,
|
||||
install,
|
||||
installAllRuntimes,
|
||||
uninstall,
|
||||
installSdkIfNeeded,
|
||||
buildSdkFailFastReport,
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
"init-command-router.cjs",
|
||||
"init.cjs",
|
||||
"install-profiles.cjs",
|
||||
"installer-migration-report.cjs",
|
||||
"installer-migrations.cjs",
|
||||
"intel.cjs",
|
||||
"learnings.cjs",
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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, persists install state only after
|
||||
package materialization and finalization succeed, 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.
|
||||
|
||||
96
get-shit-done/bin/lib/installer-migration-report.cjs
Normal file
96
get-shit-done/bin/lib/installer-migration-report.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
'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 baselineSummaryLabel(count, noun) {
|
||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function baselineSummaryRow(type, actions) {
|
||||
const count = actions.length;
|
||||
if (type === 'record-baseline') {
|
||||
return {
|
||||
label: 'recorded',
|
||||
relPath: baselineSummaryLabel(count, 'managed baseline file'),
|
||||
reason: 'first-time baseline scan',
|
||||
action: { type: 'record-baseline-summary', count, actions },
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'preserved',
|
||||
relPath: baselineSummaryLabel(count, 'user baseline file'),
|
||||
reason: 'first-time baseline scan',
|
||||
action: { type: 'baseline-preserve-user-summary', count, actions },
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
const rows = [];
|
||||
const baselineIndexes = new Map();
|
||||
const baselineActions = new Map();
|
||||
|
||||
for (const action of actions) {
|
||||
const type = action && action.type;
|
||||
if (type === 'record-baseline' || type === 'baseline-preserve-user') {
|
||||
if (!baselineActions.has(type)) {
|
||||
baselineActions.set(type, []);
|
||||
baselineIndexes.set(type, rows.length);
|
||||
rows.push(null);
|
||||
}
|
||||
baselineActions.get(type).push(action);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
label: blockedSet.has(action) ? 'blocked' : installerMigrationActionLabel(action),
|
||||
relPath: action.relPath,
|
||||
reason: action.reason || '',
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 4 requires action reporting without flooding first-time baseline installs:
|
||||
// docs/installer-migrations.md#phase-4-installupdate-integration.
|
||||
for (const [type, baselineRows] of baselineActions) {
|
||||
rows[baselineIndexes.get(type)] = baselineSummaryRow(type, baselineRows);
|
||||
}
|
||||
|
||||
return {
|
||||
hasReportableActions: actions.length > 0 || blocked.length > 0,
|
||||
blocked,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -6,10 +6,25 @@ const crypto = require('crypto');
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const INSTALL_STATE_NAME = 'gsd-install-state.json';
|
||||
const INSTALL_MIGRATION_LOCK_NAME = 'gsd-install-migration.lock';
|
||||
const DEFAULT_MIGRATIONS_DIR = path.join(__dirname, 'installer-migrations');
|
||||
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
|
||||
const STRICT_JSON = Symbol('strict-json');
|
||||
|
||||
function sha256File(filePath) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
const hash = crypto.createHash('sha256');
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function sha256Text(value) {
|
||||
@@ -20,7 +35,10 @@ function readJsonIfPresent(filePath, fallback) {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (fallback === STRICT_JSON) {
|
||||
throw new Error(`invalid installer migration state JSON: ${filePath}: ${error.message}`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +57,7 @@ function readInstallManifest(configDir) {
|
||||
}
|
||||
|
||||
function readInstallState(configDir) {
|
||||
const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), null);
|
||||
const state = readJsonIfPresent(path.join(configDir, INSTALL_STATE_NAME), STRICT_JSON);
|
||||
if (!state || typeof state !== 'object') {
|
||||
return { schemaVersion: 1, appliedMigrations: [] };
|
||||
}
|
||||
@@ -118,7 +136,8 @@ function appliedMigrationEntries(state) {
|
||||
}
|
||||
|
||||
function migrationChecksum(migration) {
|
||||
if (typeof migration.checksum === 'string' && migration.checksum) return migration.checksum;
|
||||
const checksum = migration.checksum;
|
||||
if (typeof checksum === 'string' && checksum) return checksum;
|
||||
const serializable = {
|
||||
id: migration.id,
|
||||
title: migration.title || null,
|
||||
@@ -133,8 +152,7 @@ function migrationChecksum(migration) {
|
||||
return `sha256:${sha256Text(JSON.stringify(serializable))}`;
|
||||
}
|
||||
|
||||
function assertAppliedMigrationChecksums(state, migrations) {
|
||||
const applied = appliedMigrationEntries(state);
|
||||
function assertAppliedMigrationChecksums(applied, migrations) {
|
||||
for (const migration of migrations) {
|
||||
const entry = applied.get(migration.id);
|
||||
if (!entry || !entry.checksum) continue;
|
||||
@@ -178,11 +196,10 @@ function discoverInstallerMigrations({ migrationsDir }) {
|
||||
.sort()
|
||||
.flatMap((fileName) => {
|
||||
const source = path.join(migrationsDir, fileName);
|
||||
const checksum = `sha256:${sha256File(source)}`;
|
||||
delete require.cache[require.resolve(source)];
|
||||
const exported = require(source);
|
||||
const records = Array.isArray(exported) ? exported : [exported];
|
||||
return records.map((record) => validateMigrationRecord({ ...record, checksum: record.checksum || checksum }, source));
|
||||
return records.map((record) => validateMigrationRecord(record, source));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,6 +207,55 @@ function journalTimestamp(now) {
|
||||
return now().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
function migrationRunId(appliedAt) {
|
||||
return `${journalTimestamp(() => appliedAt)}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
function sleepSync(ms) {
|
||||
const buffer = new SharedArrayBuffer(4);
|
||||
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
||||
}
|
||||
|
||||
function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEOUT_MS } = {}) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const lockPath = path.join(configDir, INSTALL_MIGRATION_LOCK_NAME);
|
||||
const started = Date.now();
|
||||
|
||||
while (true) {
|
||||
let fd = null;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeFileSync(fd, JSON.stringify({
|
||||
pid: process.pid,
|
||||
acquiredAt: new Date().toISOString(),
|
||||
}) + '\n');
|
||||
return () => {
|
||||
const failures = [];
|
||||
try { fs.closeSync(fd); } catch (error) { failures.push(error); }
|
||||
try { fs.rmSync(lockPath, { force: true }); } catch (error) { failures.push(error); }
|
||||
if (failures.length > 0) {
|
||||
const releaseError = new Error(`failed to release installer migration lock: ${lockPath}`);
|
||||
releaseError.failures = failures;
|
||||
throw releaseError;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (fd !== null) {
|
||||
try { fs.closeSync(fd); } catch { /* best-effort */ }
|
||||
try { fs.rmSync(lockPath, { force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
if (error && error.code === 'EEXIST') {
|
||||
if (Date.now() - started >= timeoutMs) {
|
||||
throw new Error(`installer migration lock is held: ${lockPath}`);
|
||||
}
|
||||
sleepSync(Math.min(50, Math.max(1, timeoutMs - (Date.now() - started))));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInsideConfig(configDir, relPath) {
|
||||
const normalized = normalizeRelPath(relPath);
|
||||
const fullPath = path.resolve(configDir, normalized);
|
||||
@@ -242,8 +308,8 @@ function planInstallerMigrations({
|
||||
const scopedMigrations = migrations.filter((migration) =>
|
||||
migration && migrationMatchesContext(migration, { runtime, scope })
|
||||
);
|
||||
assertAppliedMigrationChecksums(state, scopedMigrations);
|
||||
const applied = appliedMigrationIds(state);
|
||||
const applied = appliedMigrationEntries(state);
|
||||
assertAppliedMigrationChecksums(applied, scopedMigrations);
|
||||
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id));
|
||||
const actions = [];
|
||||
const blocked = [];
|
||||
@@ -277,6 +343,7 @@ function planInstallerMigrations({
|
||||
if (!Array.isArray(plannedActions)) {
|
||||
throw new Error(`migration ${migration.id} plan must return an array`);
|
||||
}
|
||||
const checksum = migrationChecksum(migration);
|
||||
for (const rawAction of plannedActions) {
|
||||
const relPath = normalizeRelPath(rawAction.relPath);
|
||||
const classification = rawAction.classification
|
||||
@@ -295,7 +362,7 @@ function planInstallerMigrations({
|
||||
}
|
||||
const action = {
|
||||
migrationId: migration.id,
|
||||
migrationChecksum: migrationChecksum(migration),
|
||||
migrationChecksum: checksum,
|
||||
type: protectedType,
|
||||
relPath,
|
||||
reason: rawAction.reason || migration.description || '',
|
||||
@@ -307,7 +374,7 @@ function planInstallerMigrations({
|
||||
action.requestedType = rawAction.type;
|
||||
}
|
||||
if (action.type === 'backup-and-remove') {
|
||||
action.backupRelPath = path.posix.join('gsd-migration-backups', migration.id, relPath);
|
||||
action.backupRelPath = null;
|
||||
}
|
||||
if (action.type === 'rewrite-json') {
|
||||
action.value = rawAction.value;
|
||||
@@ -334,6 +401,7 @@ function planInstallerMigrations({
|
||||
manifest,
|
||||
state,
|
||||
pendingMigrationIds: pending.map((migration) => migration.id),
|
||||
pendingMigrations: pending,
|
||||
actions,
|
||||
blocked,
|
||||
};
|
||||
@@ -343,7 +411,7 @@ function uniqueActionMigrationIds(actions) {
|
||||
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
|
||||
}
|
||||
|
||||
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, previousInstallStateBytes }) {
|
||||
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }) {
|
||||
const failures = [];
|
||||
for (const action of [...journal.actions].reverse()) {
|
||||
if (!action.rollbackRelPath) continue;
|
||||
@@ -380,6 +448,7 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
|
||||
try {
|
||||
fs.rmSync(journalPath, { force: true });
|
||||
fs.rmSync(rollbackRoot, { recursive: true, force: true });
|
||||
fs.rmSync(backupRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// journal cleanup is best-effort; the rollback above is the safety-critical part
|
||||
}
|
||||
@@ -391,6 +460,12 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupMigrationRunArtifacts(journalPath, rollbackRoot, backupRoot) {
|
||||
try { fs.rmSync(journalPath, { force: true }); } catch { /* best-effort */ }
|
||||
try { fs.rmSync(rollbackRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { fs.rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) {
|
||||
if (!configDir) throw new Error('configDir is required');
|
||||
if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required');
|
||||
@@ -399,10 +474,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
}
|
||||
|
||||
const appliedAt = now();
|
||||
const journalRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}.json`);
|
||||
const runId = migrationRunId(appliedAt);
|
||||
const journalRelPath = path.posix.join('gsd-migration-journal', `${runId}.json`);
|
||||
const journalPath = path.join(configDir, journalRelPath);
|
||||
const rollbackRootRelPath = path.posix.join('gsd-migration-journal', `${journalTimestamp(() => appliedAt)}-rollback`);
|
||||
const rollbackRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-rollback`);
|
||||
const rollbackRoot = path.join(configDir, rollbackRootRelPath);
|
||||
const backupRootRelPath = path.posix.join('gsd-migration-journal', `${runId}-backups`);
|
||||
const backupRoot = path.join(configDir, backupRootRelPath);
|
||||
const journal = {
|
||||
schemaVersion: 1,
|
||||
appliedAt,
|
||||
@@ -416,6 +494,9 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
: null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
||||
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
|
||||
|
||||
for (const action of plan.actions) {
|
||||
if (
|
||||
action.type !== 'remove-managed' &&
|
||||
@@ -459,7 +540,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
}
|
||||
|
||||
if (action.type === 'backup-and-remove') {
|
||||
const backupRelPath = action.backupRelPath || path.posix.join('gsd-migration-backups', action.migrationId, normalized);
|
||||
const backupRelPath = action.backupRelPath || path.posix.join(backupRootRelPath, normalized);
|
||||
const backupPath = path.join(configDir, backupRelPath);
|
||||
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
||||
fs.copyFileSync(fullPath, backupPath);
|
||||
@@ -475,7 +556,6 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
fs.rmSync(fullPath, { force: true });
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
||||
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
|
||||
|
||||
const state = readInstallState(configDir);
|
||||
@@ -506,13 +586,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
return {
|
||||
appliedMigrationIds: journal.appliedMigrationIds,
|
||||
journalRelPath,
|
||||
rollback: () => rollbackAppliedMigrationResult({
|
||||
configDir,
|
||||
journal,
|
||||
journalPath,
|
||||
rollbackRoot,
|
||||
previousInstallStateBytes,
|
||||
}),
|
||||
rollback: () => rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, backupRoot, previousInstallStateBytes }),
|
||||
};
|
||||
} catch (error) {
|
||||
const rollbackFailures = [];
|
||||
@@ -535,56 +609,41 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
||||
rollbackError.rollbackFailures = rollbackFailures;
|
||||
throw rollbackError;
|
||||
}
|
||||
cleanupMigrationRunArtifacts(journalPath, rollbackRoot, backupRoot);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, previousInstallStateBytes }) {
|
||||
const failures = [];
|
||||
for (const action of [...journal.actions].reverse()) {
|
||||
if (!action.rollbackRelPath) continue;
|
||||
const rollbackPath = path.join(configDir, action.rollbackRelPath);
|
||||
const dest = path.join(configDir, action.relPath);
|
||||
try {
|
||||
if (fs.existsSync(rollbackPath)) {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(rollbackPath, dest);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ relPath: action.relPath, error: error.message });
|
||||
}
|
||||
if (action.backupRelPath) {
|
||||
try {
|
||||
fs.rmSync(path.join(configDir, action.backupRelPath), { force: true });
|
||||
} catch {
|
||||
// backup cleanup is best-effort; preserve restore failures above
|
||||
}
|
||||
}
|
||||
function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().toISOString() }) {
|
||||
if (!plan || !Array.isArray(plan.pendingMigrationIds) || plan.pendingMigrationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
if (previousInstallStateBytes === null) {
|
||||
fs.rmSync(path.join(configDir, INSTALL_STATE_NAME), { force: true });
|
||||
} else {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), previousInstallStateBytes);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push({ relPath: INSTALL_STATE_NAME, error: error.message });
|
||||
const appliedAt = now();
|
||||
const state = readInstallState(configDir);
|
||||
const applied = appliedMigrationIds(state);
|
||||
const checksumsByMigrationId = new Map();
|
||||
for (const migration of plan.pendingMigrations || []) {
|
||||
checksumsByMigrationId.set(migration.id, migrationChecksum(migration));
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(journalPath, { force: true });
|
||||
fs.rmSync(rollbackRoot, { recursive: true, force: true });
|
||||
} catch {
|
||||
// journal cleanup is best-effort; the rollback above is the safety-critical part
|
||||
const nextApplied = [...state.appliedMigrations];
|
||||
const newlyApplied = [];
|
||||
for (const id of plan.pendingMigrationIds) {
|
||||
if (applied.has(id)) continue;
|
||||
nextApplied.push({
|
||||
id,
|
||||
appliedAt,
|
||||
journal: null,
|
||||
checksum: checksumsByMigrationId.get(id) || null,
|
||||
});
|
||||
newlyApplied.push(id);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const error = new Error('migration rollback incomplete');
|
||||
error.rollbackFailures = failures;
|
||||
throw error;
|
||||
if (newlyApplied.length > 0) {
|
||||
writeInstallState(configDir, {
|
||||
schemaVersion: 1,
|
||||
appliedMigrations: nextApplied,
|
||||
});
|
||||
}
|
||||
return newlyApplied;
|
||||
}
|
||||
|
||||
function runInstallerMigrations({
|
||||
@@ -595,29 +654,55 @@ function runInstallerMigrations({
|
||||
migrations = discoverInstallerMigrations({ migrationsDir }),
|
||||
baselineScan = false,
|
||||
now = () => new Date().toISOString(),
|
||||
lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now });
|
||||
if (plan.actions.length === 0) {
|
||||
return {
|
||||
appliedMigrationIds: [],
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
};
|
||||
const releaseLock = acquireInstallMigrationLock(configDir, { timeoutMs: lockTimeoutMs });
|
||||
let primaryError = null;
|
||||
let completed = false;
|
||||
try {
|
||||
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, baselineScan, now });
|
||||
if (plan.actions.length === 0) {
|
||||
const appliedMigrationIds = markPendingMigrationsApplied({ configDir, plan, now });
|
||||
completed = true;
|
||||
return {
|
||||
appliedMigrationIds,
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
};
|
||||
}
|
||||
if (plan.blocked.length > 0) {
|
||||
completed = true;
|
||||
return {
|
||||
appliedMigrationIds: [],
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
blocked: plan.blocked,
|
||||
};
|
||||
}
|
||||
const result = applyInstallerMigrationPlan({ configDir, plan, now });
|
||||
completed = true;
|
||||
return { ...result, plan };
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
releaseLock();
|
||||
} catch (releaseError) {
|
||||
if (primaryError) {
|
||||
primaryError.suppressed = [...(primaryError.suppressed || []), releaseError];
|
||||
} else if (completed) {
|
||||
throw releaseError;
|
||||
} else {
|
||||
throw releaseError;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (plan.blocked.length > 0) {
|
||||
return {
|
||||
appliedMigrationIds: [],
|
||||
journalRelPath: null,
|
||||
plan,
|
||||
blocked: plan.blocked,
|
||||
};
|
||||
}
|
||||
const result = applyInstallerMigrationPlan({ configDir, plan, now });
|
||||
return { ...result, plan };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MIGRATIONS_DIR,
|
||||
INSTALL_MIGRATION_LOCK_NAME,
|
||||
INSTALL_STATE_NAME,
|
||||
MANIFEST_NAME,
|
||||
applyInstallerMigrationPlan,
|
||||
|
||||
@@ -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(/^\/+/, '');
|
||||
@@ -53,8 +56,7 @@ function baselineInstallSurfaces(runtime) {
|
||||
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));
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const relPath = path.posix.join(relDir, entry.name);
|
||||
if (relDir === '' && INTERNAL_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
||||
@@ -80,7 +82,7 @@ function scanBaselineFiles(configDir, runtime) {
|
||||
relPaths.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...relPaths].sort();
|
||||
return [...relPaths];
|
||||
}
|
||||
|
||||
function isUserOwnedBaselinePath(relPath) {
|
||||
@@ -91,6 +93,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 +161,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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -150,6 +150,21 @@ describe('bug #3033: --sdk flag (opts.forceSdk) must be wired into installSdkIfN
|
||||
);
|
||||
});
|
||||
|
||||
test('throwOnFailure=true converts missing SDK dist into catchable error', () => {
|
||||
fs.mkdirSync(sdkDir, { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => captureConsole(() => {
|
||||
installSdkIfNeeded({ sdkDir, isLocal: true, forceSdk: true, throwOnFailure: true });
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'GSD_SDK_MISSING_DIST');
|
||||
assert.equal(error.exitCode, 1);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('forceSdk=false (default) + isLocal=true + dist missing: retains #2678 soft-skip', () => {
|
||||
// Verify the #2678 contract is not broken for the default (no --sdk) path.
|
||||
fs.mkdirSync(sdkDir, { recursive: true });
|
||||
|
||||
316
tests/installer-migration-install-integration.test.cjs
Normal file
316
tests/installer-migration-install-integration.test.cjs
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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 withWriteFailure(matchPath, fn) {
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
fs.writeFileSync = (filePath, ...args) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(matchPath)) {
|
||||
throw new Error(`injected write failure for ${path.basename(matchPath)}`);
|
||||
}
|
||||
return originalWriteFileSync.call(fs, filePath, ...args);
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.writeFileSync = originalWriteFileSync;
|
||||
}
|
||||
}
|
||||
|
||||
function withSdkDistPresent(fn) {
|
||||
const sdkCliPath = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalStatSync = fs.statSync;
|
||||
const originalChmodSync = fs.chmodSync;
|
||||
fs.existsSync = (filePath) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return true;
|
||||
return originalExistsSync.call(fs, filePath);
|
||||
};
|
||||
fs.statSync = (filePath, ...args) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) {
|
||||
return { mode: 0o755 };
|
||||
}
|
||||
return originalStatSync.call(fs, filePath, ...args);
|
||||
};
|
||||
fs.chmodSync = (filePath, ...args) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return;
|
||||
return originalChmodSync.call(fs, filePath, ...args);
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
fs.statSync = originalStatSync;
|
||||
fs.chmodSync = originalChmodSync;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('rolls back applied migrations when package materialization fails for non-Codex installs', () => {
|
||||
const claudeHome = path.join(tmpRoot, '.claude');
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
||||
writeManifest(claudeHome, {
|
||||
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => captureConsole(() =>
|
||||
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
||||
withWriteFailure(path.join(claudeHome, 'get-shit-done', 'VERSION'), () => install(true, 'claude'))
|
||||
)
|
||||
),
|
||||
/injected write failure for VERSION/
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
|
||||
'legacy managed hook\n'
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
||||
});
|
||||
|
||||
test('rolls back applied migrations when multi-runtime finalization fails', () => {
|
||||
const claudeHome = path.join(tmpRoot, '.claude');
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
||||
writeManifest(claudeHome, {
|
||||
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => captureConsole(() =>
|
||||
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
||||
withSdkDistPresent(() =>
|
||||
withWriteFailure(path.join(claudeHome, 'settings.json'), () =>
|
||||
installModule.installAllRuntimes(['claude'], true, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
/injected write failure for settings\.json/
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
|
||||
'legacy managed hook\n'
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
||||
});
|
||||
|
||||
test('rolls back completed runtime migrations when a later runtime install fails', () => {
|
||||
const claudeHome = path.join(tmpRoot, '.claude');
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
||||
writeManifest(claudeHome, {
|
||||
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
||||
});
|
||||
|
||||
writeFile(codexHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
||||
writeManifest(codexHome, {
|
||||
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => captureConsole(() =>
|
||||
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
||||
withEnv('CODEX_HOME', codexHome, () =>
|
||||
withWriteFailure(path.join(codexHome, 'get-shit-done', 'VERSION'), () =>
|
||||
installModule.installAllRuntimes(['claude', 'codex'], true, false)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
/injected write failure for VERSION/
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
|
||||
'legacy managed hook\n'
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(codexHome, 'hooks/statusline.js'), 'utf8'),
|
||||
'legacy managed hook\n'
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(codexHome, 'gsd-install-state.json')), 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
159
tests/installer-migration-report.test.cjs
Normal file
159
tests/installer-migration-report.test.cjs
Normal file
@@ -0,0 +1,159 @@
|
||||
'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: '1 user baseline file',
|
||||
reason: 'first-time baseline scan',
|
||||
},
|
||||
{
|
||||
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('collapses first-time baseline report rows without hiding destructive actions', () => {
|
||||
const blockedAction = {
|
||||
type: 'prompt-user',
|
||||
relPath: 'hooks/gsd-ambiguous.js',
|
||||
reason: 'needs a user choice',
|
||||
};
|
||||
const result = {
|
||||
blocked: [blockedAction],
|
||||
plan: {
|
||||
actions: [
|
||||
{
|
||||
type: 'record-baseline',
|
||||
relPath: 'hooks/statusline.js',
|
||||
reason: 'first-time baseline scan',
|
||||
},
|
||||
{
|
||||
type: 'record-baseline',
|
||||
relPath: 'hooks/workflow-guard.js',
|
||||
reason: 'first-time baseline scan',
|
||||
},
|
||||
{
|
||||
type: 'baseline-preserve-user',
|
||||
relPath: 'hooks/custom.js',
|
||||
reason: 'first-time baseline scan',
|
||||
},
|
||||
{
|
||||
type: 'remove-managed',
|
||||
relPath: 'hooks/retired.js',
|
||||
reason: 'retired hook',
|
||||
},
|
||||
blockedAction,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
summarizeInstallerMigrationResult(result).rows.map((row) => ({
|
||||
label: row.label,
|
||||
relPath: row.relPath,
|
||||
reason: row.reason,
|
||||
})),
|
||||
[
|
||||
{
|
||||
label: 'recorded',
|
||||
relPath: '2 managed baseline files',
|
||||
reason: 'first-time baseline scan',
|
||||
},
|
||||
{
|
||||
label: 'preserved',
|
||||
relPath: '1 user baseline file',
|
||||
reason: 'first-time baseline scan',
|
||||
},
|
||||
{
|
||||
label: 'removed',
|
||||
relPath: 'hooks/retired.js',
|
||||
reason: 'retired hook',
|
||||
},
|
||||
{
|
||||
label: 'blocked',
|
||||
relPath: 'hooks/gsd-ambiguous.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/
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const crypto = require('crypto');
|
||||
|
||||
const {
|
||||
applyInstallerMigrationPlan,
|
||||
classifyArtifact,
|
||||
discoverInstallerMigrations,
|
||||
INSTALL_STATE_NAME,
|
||||
planInstallerMigrations,
|
||||
@@ -197,6 +198,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 {
|
||||
@@ -284,7 +335,7 @@ test('plans backup before removal for a modified managed file', () => {
|
||||
assert.equal(plan.actions[0].classification, 'managed-modified');
|
||||
assert.equal(plan.actions[0].originalHash, sha256('managed hook\n'));
|
||||
assert.equal(plan.actions[0].currentHash, sha256('user changed hook\n'));
|
||||
assert.equal(plan.actions[0].backupRelPath, 'gsd-migration-backups/2026-05-11-remove-old-hook/hooks/old-hook.js');
|
||||
assert.equal(plan.actions[0].backupRelPath, null);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
@@ -324,6 +375,77 @@ test('blocks removal of unknown files by preserving them by default', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when install state JSON is malformed', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), '{ not json\n', 'utf8');
|
||||
|
||||
assert.throws(
|
||||
() => readInstallState(configDir),
|
||||
/invalid installer migration state JSON/
|
||||
);
|
||||
});
|
||||
|
||||
test('computes each migration checksum once per planned migration', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
writeFile(configDir, 'hooks/first.js', 'first hook\n');
|
||||
writeFile(configDir, 'hooks/second.js', 'second hook\n');
|
||||
writeManifest(configDir, {
|
||||
'hooks/first.js': sha256('first hook\n'),
|
||||
'hooks/second.js': sha256('second hook\n'),
|
||||
});
|
||||
let checksumReads = 0;
|
||||
const migration = {
|
||||
id: '2026-05-11-remove-two-hooks',
|
||||
description: 'Remove retired hooks',
|
||||
get checksum() {
|
||||
checksumReads += 1;
|
||||
return 'sha256:precomputed';
|
||||
},
|
||||
plan: () => [
|
||||
{ type: 'remove-managed', relPath: 'hooks/first.js', reason: 'retired hook' },
|
||||
{ type: 'remove-managed', relPath: 'hooks/second.js', reason: 'retired hook' },
|
||||
],
|
||||
};
|
||||
|
||||
const plan = planInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [migration],
|
||||
});
|
||||
|
||||
assert.equal(plan.actions.length, 2);
|
||||
assert.equal(checksumReads, 1);
|
||||
});
|
||||
|
||||
test('classifies large files without loading the whole file through readFileSync', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
t.after(() => {
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
cleanup(configDir);
|
||||
});
|
||||
|
||||
const relPath = 'skills/gsd-large/SKILL.md';
|
||||
const fullPath = path.join(configDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, Buffer.alloc(1024 * 1024 + 1, 'a'));
|
||||
|
||||
fs.readFileSync = (filePath, ...args) => {
|
||||
if (path.resolve(String(filePath)) === path.resolve(fullPath)) {
|
||||
throw new Error('large file should be streamed for hashing');
|
||||
}
|
||||
return originalReadFileSync.call(fs, filePath, ...args);
|
||||
};
|
||||
|
||||
const artifact = classifyArtifact(configDir, relPath, { files: {} });
|
||||
|
||||
assert.equal(artifact.classification, 'unknown');
|
||||
assert.match(artifact.currentHash, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('applies an unblocked plan with a journal and install-state update', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
@@ -358,7 +480,10 @@ test('applies an unblocked plan with a journal and install-state update', () =>
|
||||
|
||||
assert.equal(fs.existsSync(path.join(configDir, 'hooks/old-hook.js')), false);
|
||||
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
|
||||
assert.equal(result.journalRelPath, 'gsd-migration-journal/2026-05-11T00-00-01-000Z.json');
|
||||
assert.match(
|
||||
result.journalRelPath,
|
||||
/^gsd-migration-journal\/2026-05-11T00-00-01-000Z-[0-9a-f]+\.json$/
|
||||
);
|
||||
|
||||
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
||||
assert.deepEqual(journal.appliedMigrationIds, ['2026-05-11-remove-old-hook']);
|
||||
@@ -372,6 +497,174 @@ test('applies an unblocked plan with a journal and install-state update', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('uses unique journal paths for applies that share a timestamp', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
writeFile(configDir, 'hooks/first.js', 'first hook\n');
|
||||
writeFile(configDir, 'hooks/second.js', 'second hook\n');
|
||||
writeManifest(configDir, {
|
||||
'hooks/first.js': sha256('first hook\n'),
|
||||
'hooks/second.js': sha256('second hook\n'),
|
||||
});
|
||||
const now = () => '2026-05-11T00:00:09.000Z';
|
||||
|
||||
const first = applyInstallerMigrationPlan({
|
||||
configDir,
|
||||
plan: {
|
||||
blocked: [],
|
||||
actions: [{
|
||||
migrationId: 'first-migration',
|
||||
migrationChecksum: 'sha256:first',
|
||||
type: 'remove-managed',
|
||||
relPath: 'hooks/first.js',
|
||||
reason: 'first',
|
||||
classification: 'managed-pristine',
|
||||
originalHash: sha256('first hook\n'),
|
||||
currentHash: sha256('first hook\n'),
|
||||
}],
|
||||
},
|
||||
now,
|
||||
});
|
||||
const second = applyInstallerMigrationPlan({
|
||||
configDir,
|
||||
plan: {
|
||||
blocked: [],
|
||||
actions: [{
|
||||
migrationId: 'second-migration',
|
||||
migrationChecksum: 'sha256:second',
|
||||
type: 'remove-managed',
|
||||
relPath: 'hooks/second.js',
|
||||
reason: 'second',
|
||||
classification: 'managed-pristine',
|
||||
originalHash: sha256('second hook\n'),
|
||||
currentHash: sha256('second hook\n'),
|
||||
}],
|
||||
},
|
||||
now,
|
||||
});
|
||||
|
||||
assert.notEqual(first.journalRelPath, second.journalRelPath);
|
||||
assert.equal(fs.existsSync(path.join(configDir, first.journalRelPath)), true);
|
||||
assert.equal(fs.existsSync(path.join(configDir, second.journalRelPath)), true);
|
||||
});
|
||||
|
||||
test('stores modified-file backups under the unique migration run journal', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
||||
writeManifest(configDir, {
|
||||
'hooks/old-hook.js': sha256('managed hook\n'),
|
||||
});
|
||||
|
||||
const plan = planInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [
|
||||
{
|
||||
id: '2026-05-11-remove-old-hook',
|
||||
description: 'Remove retired hook',
|
||||
plan: () => [{
|
||||
type: 'remove-managed',
|
||||
relPath: 'hooks/old-hook.js',
|
||||
reason: 'retired hook',
|
||||
}],
|
||||
},
|
||||
],
|
||||
now: () => '2026-05-11T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = applyInstallerMigrationPlan({
|
||||
configDir,
|
||||
plan,
|
||||
now: () => '2026-05-11T00:00:10.000Z',
|
||||
});
|
||||
const journal = JSON.parse(fs.readFileSync(path.join(configDir, result.journalRelPath), 'utf8'));
|
||||
const backupRelPath = journal.actions[0].backupRelPath;
|
||||
|
||||
assert.match(backupRelPath, /^gsd-migration-journal\/2026-05-11T00-00-10-000Z-[0-9a-f]+-backups\/hooks\/old-hook\.js$/);
|
||||
assert.equal(fs.readFileSync(path.join(configDir, backupRelPath), 'utf8'), 'user changed hook\n');
|
||||
});
|
||||
|
||||
test('successful migration rollback removes run-scoped backup directories', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
||||
writeManifest(configDir, {
|
||||
'hooks/old-hook.js': sha256('managed hook\n'),
|
||||
});
|
||||
|
||||
const plan = planInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [
|
||||
{
|
||||
id: '2026-05-11-remove-old-hook',
|
||||
description: 'Remove retired hook',
|
||||
plan: () => [{
|
||||
type: 'remove-managed',
|
||||
relPath: 'hooks/old-hook.js',
|
||||
reason: 'retired hook',
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyInstallerMigrationPlan({
|
||||
configDir,
|
||||
plan,
|
||||
now: () => '2026-05-11T00:00:11.000Z',
|
||||
});
|
||||
|
||||
result.rollback();
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
|
||||
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
|
||||
assert.equal(
|
||||
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) => name.includes('backups')),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses to run migrations while another installer owns the migration lock', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
t.after(() => cleanup(configDir));
|
||||
fs.writeFileSync(path.join(configDir, 'gsd-install-migration.lock'), 'held by test\n', 'utf8');
|
||||
|
||||
assert.throws(
|
||||
() => runInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [],
|
||||
lockTimeoutMs: 0,
|
||||
}),
|
||||
/installer migration lock is held/
|
||||
);
|
||||
});
|
||||
|
||||
test('reports lock release failures after migration work completes', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
const originalRmSync = fs.rmSync;
|
||||
t.after(() => {
|
||||
fs.rmSync = originalRmSync;
|
||||
cleanup(configDir);
|
||||
});
|
||||
|
||||
fs.rmSync = (targetPath, ...args) => {
|
||||
if (path.basename(String(targetPath)) === 'gsd-install-migration.lock') {
|
||||
throw new Error('simulated lock unlink failure');
|
||||
}
|
||||
return originalRmSync.call(fs, targetPath, ...args);
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => runInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [],
|
||||
}),
|
||||
/failed to release installer migration lock/
|
||||
);
|
||||
});
|
||||
|
||||
test('rollback handle restores files and install state after a successful apply', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
@@ -468,12 +761,71 @@ test('rolls back touched files and leaves state unchanged when apply fails', ()
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
|
||||
assert.deepEqual(readInstallState(configDir).appliedMigrations, []);
|
||||
assert.equal(fs.existsSync(path.join(configDir, 'gsd-migration-journal', '2026-05-11T00-00-02-000Z.json')), false);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(configDir, 'gsd-migration-journal')) &&
|
||||
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
|
||||
name.startsWith('2026-05-11T00-00-02-000Z')
|
||||
),
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('cleans rollback and backup artifacts when migration apply fails', (t) => {
|
||||
const configDir = createTempInstall();
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
writeFile(configDir, 'hooks/old-hook.js', 'user changed hook\n');
|
||||
writeManifest(configDir, {
|
||||
'hooks/old-hook.js': sha256('managed hook\n'),
|
||||
});
|
||||
const plan = {
|
||||
blocked: [],
|
||||
actions: [
|
||||
{
|
||||
migrationId: '2026-05-11-remove-old-hook',
|
||||
migrationChecksum: 'sha256:remove',
|
||||
type: 'backup-and-remove',
|
||||
relPath: 'hooks/old-hook.js',
|
||||
reason: 'retired hook',
|
||||
classification: 'managed-modified',
|
||||
originalHash: sha256('managed hook\n'),
|
||||
currentHash: sha256('user changed hook\n'),
|
||||
},
|
||||
{
|
||||
migrationId: '2026-05-11-remove-old-hook',
|
||||
migrationChecksum: 'sha256:remove',
|
||||
type: 'unsupported-test-action',
|
||||
relPath: 'hooks/other.js',
|
||||
reason: 'force failure',
|
||||
classification: 'managed-pristine',
|
||||
originalHash: null,
|
||||
currentHash: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => applyInstallerMigrationPlan({
|
||||
configDir,
|
||||
plan,
|
||||
now: () => '2026-05-11T00:00:12.000Z',
|
||||
}),
|
||||
/unsupported migration action type/
|
||||
);
|
||||
|
||||
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'user changed hook\n');
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(configDir, 'gsd-migration-journal')) &&
|
||||
fs.readdirSync(path.join(configDir, 'gsd-migration-journal')).some((name) =>
|
||||
name.startsWith('2026-05-11T00-00-12-000Z')
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('reports rollback restore failures instead of swallowing them', () => {
|
||||
const configDir = createTempInstall();
|
||||
const originalCopyFileSync = fs.copyFileSync;
|
||||
@@ -508,7 +860,7 @@ test('reports rollback restore failures instead of swallowing them', () => {
|
||||
};
|
||||
|
||||
fs.copyFileSync = (src, dest) => {
|
||||
if (String(src).includes('2026-05-11T00-00-04-000Z-rollback')) {
|
||||
if (/2026-05-11T00-00-04-000Z-[0-9a-f]+-rollback/.test(String(src))) {
|
||||
throw new Error('simulated rollback copy failure');
|
||||
}
|
||||
return originalCopyFileSync(src, dest);
|
||||
@@ -634,6 +986,33 @@ test('skips migration records already present in install state', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('marks zero-action pending migrations as applied', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
writeManifest(configDir, {});
|
||||
|
||||
const result = runInstallerMigrations({
|
||||
configDir,
|
||||
migrations: [
|
||||
{
|
||||
id: '2026-05-11-noop-cleanup',
|
||||
description: 'No-op cleanup',
|
||||
plan: () => [],
|
||||
},
|
||||
],
|
||||
now: () => '2026-05-11T00:00:06.000Z',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.appliedMigrationIds, ['2026-05-11-noop-cleanup']);
|
||||
assert.equal(result.journalRelPath, null);
|
||||
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [
|
||||
'2026-05-11-noop-cleanup',
|
||||
]);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses to plan an already-applied migration whose checksum changed', () => {
|
||||
const configDir = createTempInstall();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user