fix: roll back installer migration state
This commit is contained in:
@@ -7634,6 +7634,13 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
|
|
||||||
// Track installation failures
|
// Track installation failures
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
let installerMigrationResult = null;
|
||||||
|
const rollbackInstallerMigrations = () => {
|
||||||
|
if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
|
||||||
|
const rollback = installerMigrationResult.rollback;
|
||||||
|
installerMigrationResult = null;
|
||||||
|
rollback();
|
||||||
|
};
|
||||||
|
|
||||||
// Save any locally modified GSD files before they get wiped.
|
// Save any locally modified GSD files before they get wiped.
|
||||||
// The pristine context lets saveLocalPatches populate gsd-pristine/ via
|
// The pristine context lets saveLocalPatches populate gsd-pristine/ via
|
||||||
@@ -7647,7 +7654,7 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Run manifest-backed cleanup migrations before package materialization.
|
// Run manifest-backed cleanup migrations before package materialization.
|
||||||
runInstallerMigrations({ configDir: targetDir });
|
installerMigrationResult = runInstallerMigrations({ configDir: targetDir });
|
||||||
|
|
||||||
// #3245 — Codex idempotent rollback. Capture pre-install state of ALL
|
// #3245 — Codex idempotent rollback. Capture pre-install state of ALL
|
||||||
// directories and files GSD will mutate so that any post-install validation
|
// directories and files GSD will mutate so that any post-install validation
|
||||||
@@ -8439,6 +8446,7 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
// #3245 CR finding 2 — any throw in the pre-config install operations (skills copy,
|
// #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
|
// agents copy, VERSION write, manifest write, etc.) triggers the Codex pre-config
|
||||||
// rollback so the caller is never left in a partially-installed state.
|
// rollback so the caller is never left in a partially-installed state.
|
||||||
|
rollbackInstallerMigrations();
|
||||||
if (_codexPreConfigRollback) {
|
if (_codexPreConfigRollback) {
|
||||||
_codexPreConfigRollback();
|
_codexPreConfigRollback();
|
||||||
}
|
}
|
||||||
@@ -8471,6 +8479,7 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
// existence checks. Safe to call before any snapshots are captured (variables
|
// existence checks. Safe to call before any snapshots are captured (variables
|
||||||
// default to empty Set / null). Does NOT touch non-gsd-* user content.
|
// default to empty Set / null). Does NOT touch non-gsd-* user content.
|
||||||
const restoreCodexSnapshot = () => {
|
const restoreCodexSnapshot = () => {
|
||||||
|
rollbackInstallerMigrations();
|
||||||
// 1. config.toml
|
// 1. config.toml
|
||||||
if (codexConfigPreInstallSnapshot !== null) {
|
if (codexConfigPreInstallSnapshot !== null) {
|
||||||
try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); }
|
try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); }
|
||||||
|
|||||||
@@ -232,6 +232,10 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
|||||||
actions: [],
|
actions: [],
|
||||||
};
|
};
|
||||||
const rollback = [];
|
const rollback = [];
|
||||||
|
const installStatePath = path.join(configDir, INSTALL_STATE_NAME);
|
||||||
|
const previousInstallStateBytes = fs.existsSync(installStatePath)
|
||||||
|
? fs.readFileSync(installStatePath)
|
||||||
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const action of plan.actions) {
|
for (const action of plan.actions) {
|
||||||
@@ -285,6 +289,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
|||||||
return {
|
return {
|
||||||
appliedMigrationIds: journal.appliedMigrationIds,
|
appliedMigrationIds: journal.appliedMigrationIds,
|
||||||
journalRelPath,
|
journalRelPath,
|
||||||
|
rollback: () => rollbackAppliedMigrationResult({
|
||||||
|
configDir,
|
||||||
|
journal,
|
||||||
|
journalPath,
|
||||||
|
rollbackRoot,
|
||||||
|
previousInstallStateBytes,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const rollbackFailures = [];
|
const rollbackFailures = [];
|
||||||
@@ -311,6 +322,54 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
const error = new Error('migration rollback incomplete');
|
||||||
|
error.rollbackFailures = failures;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runInstallerMigrations({
|
function runInstallerMigrations({
|
||||||
configDir,
|
configDir,
|
||||||
migrationsDir = DEFAULT_MIGRATIONS_DIR,
|
migrationsDir = DEFAULT_MIGRATIONS_DIR,
|
||||||
|
|||||||
@@ -208,6 +208,58 @@ test('applies an unblocked plan with a journal and install-state update', () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rollback handle restores files and install state after a successful apply', () => {
|
||||||
|
const configDir = createTempInstall();
|
||||||
|
try {
|
||||||
|
writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n');
|
||||||
|
writeManifest(configDir, {
|
||||||
|
'hooks/old-hook.js': sha256('managed hook\n'),
|
||||||
|
});
|
||||||
|
writeInstallState(configDir, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
appliedMigrations: [
|
||||||
|
{
|
||||||
|
id: 'already-applied',
|
||||||
|
appliedAt: '2026-05-10T00:00:00.000Z',
|
||||||
|
journal: 'gsd-migration-journal/prior.json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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:01.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
result.rollback();
|
||||||
|
|
||||||
|
assert.equal(fs.readFileSync(path.join(configDir, 'hooks/old-hook.js'), 'utf8'), 'managed hook\n');
|
||||||
|
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-applied']);
|
||||||
|
assert.equal(fs.existsSync(path.join(configDir, result.journalRelPath)), false);
|
||||||
|
} finally {
|
||||||
|
cleanup(configDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('rolls back touched files and leaves state unchanged when apply fails', () => {
|
test('rolls back touched files and leaves state unchanged when apply fails', () => {
|
||||||
const configDir = createTempInstall();
|
const configDir = createTempInstall();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user