feat: migrate legacy codex hooks cleanup

This commit is contained in:
Tom Boucher
2026-05-10 23:08:35 -04:00
parent 162935969f
commit 3943146484
9 changed files with 708 additions and 121 deletions

View File

@@ -777,76 +777,6 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner) {
return { content: updated, changed };
}
function isManagedCodexHookCommand(command, targetDir) {
if (typeof command !== 'string') return false;
if (typeof targetDir !== 'string' || targetDir.length === 0) return false;
const normalizedCommand = command.replace(/\\/g, '/');
const managedHooksDir = `${path.join(targetDir, 'hooks').replace(/\\/g, '/')}/`;
if (!normalizedCommand.includes(managedHooksDir)) return false;
return /(^|[\\/\s"'])(gsd-check-update\.js|gsd-update-check\.js)(?=$|[\s"'])/.test(normalizedCommand);
}
function pruneGsdManagedHooksJsonValue(value, targetDir) {
if (Array.isArray(value)) {
let changed = false;
const next = [];
for (const item of value) {
const pruned = pruneGsdManagedHooksJsonValue(item, targetDir);
if (pruned.changed) changed = true;
if (!isStructurallyEmpty(pruned.value)) next.push(pruned.value);
else changed = true;
}
return { value: next, changed };
}
if (value && typeof value === 'object') {
if (isManagedCodexHookCommand(value.command, targetDir)) {
return { value: null, changed: true };
}
let changed = false;
const next = {};
for (const [key, child] of Object.entries(value)) {
const pruned = pruneGsdManagedHooksJsonValue(child, targetDir);
if (pruned.changed) changed = true;
if (!isStructurallyEmpty(pruned.value)) next[key] = pruned.value;
else changed = true;
}
return { value: next, changed };
}
return { value, changed: false };
}
function isStructurallyEmpty(value) {
if (value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0;
return typeof value === 'object' && Object.keys(value).length === 0;
}
function cleanupLegacyCodexHooksJson(targetDir) {
const hooksPath = path.join(targetDir, 'hooks.json');
if (!fs.existsSync(hooksPath)) return { changed: false, removedFile: false };
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
} catch {
return { changed: false, removedFile: false, skipped: 'invalid_json' };
}
const pruned = pruneGsdManagedHooksJsonValue(parsed, targetDir);
if (!pruned.changed) return { changed: false, removedFile: false };
if (isStructurallyEmpty(pruned.value)) {
fs.unlinkSync(hooksPath);
return { changed: true, removedFile: true };
}
atomicWriteFileSync(hooksPath, JSON.stringify(pruned.value, null, 2) + '\n', 'utf8');
return { changed: true, removedFile: false };
}
/**
* Build a hook command path using forward slashes for cross-platform compatibility.
* On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path.
@@ -7620,9 +7550,6 @@ function install(isGlobal, runtime = 'claude') {
isGlobal,
});
// Run manifest-backed cleanup migrations before package materialization.
runInstallerMigrations({ configDir: targetDir });
// #3245 — Codex idempotent rollback. Capture pre-install state of ALL
// directories and files GSD will mutate so that any post-install validation
// failure (config.toml schema check, write failure, etc.) can revert the
@@ -7653,6 +7580,13 @@ function install(isGlobal, runtime = 'claude') {
// Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
const codexPreInstallAgentContents = new Map();
let codexPreInstallVersionBytes = null;
let installerMigrationResult = null;
const rollbackInstallerMigrations = () => {
if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return;
const rollback = installerMigrationResult.rollback;
installerMigrationResult = null;
rollback();
};
if (isCodex && !isMinimalMode(installMode)) {
const _preSkillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(_preSkillsDir)) {
@@ -7708,6 +7642,7 @@ function install(isGlobal, runtime = 'claude') {
// The full restoreCodexSnapshot() (defined inside the config block) additionally
// handles config.toml, which is not yet touched at this point in the pipeline.
const _codexPreConfigRollback = !isCodex || isMinimalMode(installMode) ? null : () => {
rollbackInstallerMigrations();
// skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
const _earlySkillsDir = path.join(targetDir, 'skills');
for (const skillName of codexPreInstallSkillNames) {
@@ -7784,6 +7719,15 @@ function install(isGlobal, runtime = 'claude') {
_earlyCleanTmpFiles(targetDir);
};
// 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.
installerMigrationResult = runInstallerMigrations({
configDir: targetDir,
runtime,
scope: isGlobal ? 'global' : 'local',
});
// #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.
// Non-Codex paths are unaffected (_codexPreConfigRollback is null for them).
@@ -8445,6 +8389,7 @@ function install(isGlobal, runtime = 'claude') {
// existence checks. Safe to call before any snapshots are captured (variables
// default to empty Set / null). Does NOT touch non-gsd-* user content.
const restoreCodexSnapshot = () => {
rollbackInstallerMigrations();
// 1. config.toml
if (codexConfigPreInstallSnapshot !== null) {
try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); }
@@ -8708,10 +8653,6 @@ function install(isGlobal, runtime = 'claude') {
throw wrapped;
}
console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart)`);
const legacyHooksCleanup = cleanupLegacyCodexHooksJson(targetDir);
if (legacyHooksCleanup.changed) {
console.log(` ${green}✓${reset} Removed legacy GSD hooks.json entries`);
}
} catch (e) {
// #2760 — schema-validation and write failures must be loud and fatal
// so the user is never left with a config Codex refuses to load (or no
@@ -10804,7 +10745,6 @@ if (process.env.GSD_TEST_MODE) {
rewriteLegacyManagedNodeHookCommands,
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
cleanupLegacyCodexHooksJson,
};
} else {