Files
msd-core/src/installer-migrations/002-codex-legacy-hooks-json.cts

145 lines
4.2 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;
checksum: string;
legacyChecksums?: 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',
checksum: 'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058',
legacyChecksums: [
'sha256:41f1545704dc72dfc3ab019207677a8652e389b200e8c450d52317df5bc198da',
],
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;