Editing the body of an already-released installer migration drifts its computed checksum (it hashes plan.toString()). The integrity guard then hard-aborted every prior install on upgrade with "applied migration checksum changed" — a 100% reproducible blocker (v1.3.0, all platforms). Already-applied migrations are filtered out of `pending` and never re-run, so a drifted checksum is functionally inert. ADR-0008 anticipates checksum-mismatch state as something the install-state layer must handle gracefully (plan -> apply -> recover/report), not abort on. This supersedes the published-checksum allowlist merged in #674 (per-release maintenance debt — every historical checksum hand-pinned, still throws for any unregistered value) with a general, self-healing recovery: - Replace the throwing guard with non-fatal `collectAppliedChecksumDrift`, surfaced on `plan.checksumDrift`. - Reconcile drifted stored checksums durably on the next state write (`reconcileDriftedChecksums`), idempotently (no perpetual writes). - Relocate the "shipped migration bodies are immutable" rule to a CI baseline test that locks every shipped migration's checksum and fails on body drift — where #615 should have been caught, instead of blocking users. Removes #674's legacyChecksums field, per-migration checksum pins, published-checksums.json fixture, and compat test. Fixes #670 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.0 KiB
TypeScript
139 lines
4.0 KiB
TypeScript
/**
|
|
* Installer migration: remove legacy Codex hooks.json GSD hook registrations.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written
|
|
* bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs collapsed to a
|
|
* TypeScript source of truth. Behaviour is preserved byte-for-behaviour from
|
|
* the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import { isManagedHookCommand } from '../shell-command-projection.cjs';
|
|
|
|
type JsonValue =
|
|
| string
|
|
| number
|
|
| boolean
|
|
| null
|
|
| undefined
|
|
| JsonValue[]
|
|
| { [key: string]: JsonValue };
|
|
|
|
interface PruneResult {
|
|
value: JsonValue;
|
|
changed: boolean;
|
|
}
|
|
|
|
interface HooksJsonRead {
|
|
exists: boolean;
|
|
error?: boolean;
|
|
value?: JsonValue;
|
|
}
|
|
|
|
interface MigrationAction {
|
|
type: string;
|
|
relPath: string;
|
|
value: JsonValue;
|
|
deleteIfEmpty: boolean;
|
|
reason: string;
|
|
ownershipEvidence: string;
|
|
}
|
|
|
|
interface MigrationPlanContext {
|
|
configDir: string;
|
|
readJson(relPath: string): HooksJsonRead;
|
|
}
|
|
|
|
interface InstallerMigration {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
introducedIn: string;
|
|
runtimes: string[];
|
|
scopes: string[];
|
|
destructive: boolean;
|
|
runtimeContract: string;
|
|
plan: (ctx: MigrationPlanContext) => MigrationAction[];
|
|
}
|
|
|
|
function isStructurallyEmpty(value: JsonValue): boolean {
|
|
if (value === null || value === undefined) return true;
|
|
if (Array.isArray(value)) return value.length === 0;
|
|
if (typeof value !== 'object') return false;
|
|
for (const _key in value) return false;
|
|
return true;
|
|
}
|
|
|
|
function isManagedCodexHookCommand(command: unknown, configDir: string): boolean {
|
|
return isManagedHookCommand(command, {
|
|
surface: 'codex-hooks-json',
|
|
includeLegacyAliases: true,
|
|
configDir,
|
|
});
|
|
}
|
|
|
|
function pruneLegacyCodexHooksJsonValue(value: JsonValue, configDir: string): PruneResult {
|
|
if (Array.isArray(value)) {
|
|
let changed = false;
|
|
const next: JsonValue[] = [];
|
|
for (const item of value) {
|
|
const pruned = pruneLegacyCodexHooksJsonValue(item, configDir);
|
|
if (pruned.changed) changed = true;
|
|
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
|
|
else next.push(pruned.value);
|
|
}
|
|
return { value: next, changed };
|
|
}
|
|
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
const valueObj = value as Record<string, JsonValue>;
|
|
const command = valueObj['command'];
|
|
if (isManagedCodexHookCommand(command, configDir)) {
|
|
return { value: null, changed: true };
|
|
}
|
|
|
|
let changed = false;
|
|
const next: { [key: string]: JsonValue } = {};
|
|
for (const [key, child] of Object.entries(valueObj)) {
|
|
const pruned = pruneLegacyCodexHooksJsonValue(child, configDir);
|
|
if (pruned.changed) changed = true;
|
|
if (pruned.changed && isStructurallyEmpty(pruned.value)) changed = true;
|
|
else next[key] = pruned.value;
|
|
}
|
|
return { value: next, changed };
|
|
}
|
|
|
|
return { value, changed: false };
|
|
}
|
|
|
|
const migration: InstallerMigration = {
|
|
id: '2026-05-11-codex-legacy-hooks-json',
|
|
title: 'Remove legacy Codex hooks.json GSD hook registrations',
|
|
description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.',
|
|
introducedIn: '1.50.0',
|
|
runtimes: ['codex'],
|
|
scopes: ['global', 'local'],
|
|
destructive: true,
|
|
runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row',
|
|
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
|
|
const { configDir } = ctx;
|
|
const hooksJson = ctx.readJson('hooks.json');
|
|
if (!hooksJson.exists || hooksJson.error) return [];
|
|
|
|
const pruned = pruneLegacyCodexHooksJsonValue(hooksJson.value, configDir);
|
|
if (!pruned.changed) return [];
|
|
|
|
return [
|
|
{
|
|
type: 'rewrite-json',
|
|
relPath: 'hooks.json',
|
|
value: pruned.value,
|
|
deleteIfEmpty: true,
|
|
reason: 'legacy Codex hooks.json GSD registration retired by installer migration',
|
|
ownershipEvidence: 'pruned command matches generated GSD hook command under the install hooks directory',
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
export = migration;
|