fix(#2637): migrate legacy Codex [hooks] map format to [[hooks]] array on install (#2747)

Codex 0.124.0 changed the required config.toml hooks format from the old
map-style ([hooks.shell]) to array-of-tables ([[hooks]]). Old GSD installs
that wrote the legacy format now cause a startup parse error on upgrade.

Add migrateCodexHooksMapFormat() which detects non-array [hooks] and
[hooks.TYPE] sections and rewrites them to [[hooks]] entries with an
injected type = "TYPE" key. The migration runs at the start of every Codex
install so affected configs self-heal on the next `gsd install --codex`.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-26 13:33:43 -04:00
committed by GitHub
parent 8f2ec0e8f7
commit d5cd64dde5
2 changed files with 262 additions and 0 deletions

View File

@@ -2846,6 +2846,107 @@ function stripLeakedGsdCodexSections(content) {
return collapseTomlBlankLines(cleaned);
}
/**
* Migrate legacy Codex [hooks] map format to [[hooks]] array-of-tables format.
*
* Codex 0.124.0 changed from the old map-style hooks config:
* [hooks]
* [hooks.shell]
* command = "..."
*
* to the new array-of-tables format:
* [[hooks]]
* type = "shell"
* command = "..."
*
* This function detects any non-array hooks sections in the config and
* converts them to the [[hooks]] format, preserving all key-value pairs and
* user comments. Bare [hooks] container sections (no key-value content) are
* dropped. User-authored [[hooks]] array entries are left untouched.
*
* Returns the migrated content, or the original content unchanged if no
* legacy hooks sections were found.
*/
function migrateCodexHooksMapFormat(content) {
const sections = getTomlTableSections(content);
// Find all non-array hooks sections: [hooks] or [hooks.TYPE]
const legacyHooksSections = sections.filter(
(section) => !section.array && (section.path === 'hooks' || section.path.startsWith('hooks.'))
);
if (legacyHooksSections.length === 0) {
return content;
}
const eol = detectLineEnding(content);
// Build [[hooks]] blocks for each [hooks.TYPE] section (skipping bare [hooks])
const newHooksBlocks = [];
for (const section of legacyHooksSections) {
if (section.path === 'hooks') {
// Bare [hooks] container — drop it (no key-value content to convert)
continue;
}
// Extract the type from the path: "hooks.shell" → "shell"
const type = section.path.slice('hooks.'.length);
const body = content.slice(section.headerEnd, section.end);
// Build [[hooks]] block: type line + original body lines
const block = `[[hooks]]${eol}type = "${type}"${eol}${body}`;
newHooksBlocks.push(block);
}
// Remove all legacy hooks sections from the content
let result = removeContentRanges(
content,
legacyHooksSections.map(({ start, end }) => ({ start, end })),
);
result = collapseTomlBlankLines(result);
// Insert new [[hooks]] blocks at the position of the first legacy section
// (adjusted for removed content), or append if nothing remains before EOF.
if (newHooksBlocks.length > 0) {
const insertionText = newHooksBlocks.join('');
// Find a good insertion point: before the first remaining table section
// that came after our removed hooks, or just append.
const remainingSections = getTomlTableSections(result);
const firstHooksSection = legacyHooksSections[0];
// Find the first remaining section whose original start was after the legacy hooks block
const anchorSection = remainingSections.find((s) => {
// Use content position in the result string as a heuristic
// We insert before the first non-hooks section if any exists
return s.start > 0;
});
// Prefer to insert the new blocks right before the first remaining table
// that was originally positioned after the legacy hooks area, but since
// positions shift after removal, we simply append before the first table
// header or at end-of-file.
if (remainingSections.length > 0) {
// Find where to insert: after any leading top-level keys, before first table
const firstTable = remainingSections[0];
const before = result.slice(0, firstTable.start);
const after = result.slice(firstTable.start);
const needsLeadingGap = before.length > 0 && !before.endsWith(eol + eol);
const needsTrailingGap = after.length > 0 && !insertionText.endsWith(eol + eol);
result = before +
(needsLeadingGap ? eol : '') +
insertionText +
(needsTrailingGap ? eol : '') +
after;
} else {
// No remaining sections — append
const needsGap = result.length > 0 && !result.endsWith(eol + eol);
result = result + (needsGap ? eol : '') + insertionText;
}
}
return result;
}
function normalizeCodexHooksLine(line, key) {
const leadingWhitespace = line.match(/^\s*/)[0];
const commentStart = findTomlCommentStart(line);
@@ -6282,6 +6383,16 @@ function install(isGlobal, runtime = 'claude') {
try {
let configContent = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf-8') : '';
const eol = detectLineEnding(configContent);
// Migrate legacy [hooks] map format to [[hooks]] array-of-tables (#2637).
// Codex 0.124.0 requires [[hooks]] array-of-tables; old GSD installs wrote
// [hooks.shell] map tables which now cause a startup parse error.
const migratedContent = migrateCodexHooksMapFormat(configContent);
if (migratedContent !== configContent) {
configContent = migratedContent;
console.log(` ${green}✓${reset} Migrated legacy Codex [hooks] map format to [[hooks]] array-of-tables`);
}
const codexHooksFeature = ensureCodexHooksFeature(configContent);
configContent = setManagedCodexHooksOwnership(codexHooksFeature.content, codexHooksFeature.ownership);
@@ -7318,6 +7429,7 @@ if (process.env.GSD_TEST_MODE) {
generateCodexAgentToml,
generateCodexConfigBlock,
stripGsdFromCodexConfig,
migrateCodexHooksMapFormat,
mergeCodexConfig,
installCodexConfig,
readGsdRuntimeProfileResolver,