From d5cd64dde57e9e3ecd2748a43cf079391da0f1e0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 26 Apr 2026 13:33:43 -0400 Subject: [PATCH] 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 --- bin/install.js | 112 +++++++++++++++++++++++++++ tests/codex-config.test.cjs | 150 ++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) diff --git a/bin/install.js b/bin/install.js index c5ed680ed..8f91430e2 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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, diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 38b1b3dc5..f7d1b603c 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -21,6 +21,7 @@ const { generateCodexAgentToml, generateCodexConfigBlock, stripGsdFromCodexConfig, + migrateCodexHooksMapFormat, mergeCodexConfig, install, GSD_CODEX_MARKER, @@ -554,6 +555,155 @@ describe('stripGsdFromCodexConfig', () => { }); }); +// ─── migrateCodexHooksMapFormat ───────────────────────────────────────────────── + +describe('migrateCodexHooksMapFormat', () => { + test('returns content unchanged when no legacy [hooks] map sections present', () => { + const content = [ + '[features]', + 'codex_hooks = true', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node /home/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + assert.strictEqual(migrateCodexHooksMapFormat(content), content); + }); + + test('returns content unchanged for empty string', () => { + assert.strictEqual(migrateCodexHooksMapFormat(''), ''); + }); + + test('converts [hooks.shell] with command key to [[hooks]] with type = "shell"', () => { + const content = [ + '[features]', + 'codex_hooks = true', + '', + '[hooks]', + '', + '[hooks.shell]', + 'command = "node /home/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + const result = migrateCodexHooksMapFormat(content); + // Old format removed + assert.ok(!result.includes('[hooks.shell]'), 'removes [hooks.shell] map header'); + assert.ok(!result.match(/^\[hooks\]$/m), 'removes bare [hooks] container'); + // New format present + assert.ok(result.includes('[[hooks]]'), 'adds [[hooks]] array header'); + assert.ok(result.includes('type = "shell"'), 'adds type = "shell" key'); + assert.ok(result.includes('command = "node /home/.codex/hooks/gsd-check-update.js"'), 'preserves command value'); + // User content preserved + assert.ok(result.includes('[features]'), 'preserves [features] section'); + assert.ok(result.includes('codex_hooks = true'), 'preserves codex_hooks key'); + }); + + test('converts [hooks.exec] to [[hooks]] with type = "exec"', () => { + const content = [ + '[hooks.exec]', + 'command = "echo hello"', + 'event = "SessionStart"', + '', + ].join('\n'); + const result = migrateCodexHooksMapFormat(content); + assert.ok(!result.includes('[hooks.exec]'), 'removes [hooks.exec] map header'); + assert.ok(result.includes('[[hooks]]'), 'adds [[hooks]] array header'); + assert.ok(result.includes('type = "exec"'), 'adds type = "exec" key'); + assert.ok(result.includes('command = "echo hello"'), 'preserves command'); + assert.ok(result.includes('event = "SessionStart"'), 'preserves event'); + }); + + test('converts multiple [hooks.TYPE] sections to separate [[hooks]] blocks', () => { + const content = [ + '[hooks.shell]', + 'command = "node /home/.codex/hooks/gsd-check-update.js"', + '', + '[hooks.exec]', + 'command = "echo done"', + '', + ].join('\n'); + const result = migrateCodexHooksMapFormat(content); + assert.ok(!result.includes('[hooks.shell]'), 'removes [hooks.shell]'); + assert.ok(!result.includes('[hooks.exec]'), 'removes [hooks.exec]'); + const hookHeaders = (result.match(/\[\[hooks\]\]/g) || []).length; + assert.strictEqual(hookHeaders, 2, 'produces two [[hooks]] array entries'); + assert.ok(result.includes('type = "shell"'), 'first entry has type = "shell"'); + assert.ok(result.includes('type = "exec"'), 'second entry has type = "exec"'); + }); + + test('leaves user-authored [[hooks]] array entries untouched when no legacy [hooks] map present', () => { + const content = [ + '[[hooks]]', + 'event = "AfterCommand"', + 'command = "echo custom"', + '', + ].join('\n'); + assert.strictEqual(migrateCodexHooksMapFormat(content), content); + }); + + test('end-to-end: install on config with old [hooks] map format produces [[hooks]] array format (#2637)', () => { + // Simulates the exact old GSD config.toml format that broke on Codex 0.124.0 + const oldContent = [ + '[features]', + 'codex_hooks = true', + '', + '[hooks]', + '', + ' [hooks.shell]', + ' command = "node /home/.codex/hooks/gsd-check-update.js"', + '', + ].join('\n'); + const result = migrateCodexHooksMapFormat(oldContent); + // Must not contain any [hooks] or [hooks.*] map-style headers + assert.ok(!result.match(/^\s*\[hooks\]\s*$/m), 'no bare [hooks] map header'); + assert.ok(!result.match(/^\s*\[hooks\./m), 'no [hooks.TYPE] map headers'); + // Must contain [[hooks]] array format + assert.ok(result.includes('[[hooks]]'), 'has [[hooks]] array-of-tables header'); + // type key must be present + assert.ok(result.includes('type = "shell"'), 'has type = "shell" in [[hooks]] entry'); + // command is preserved + assert.ok(result.includes('command = "node /home/.codex/hooks/gsd-check-update.js"'), 'command preserved'); + // [features] user content preserved + assert.ok(result.includes('[features]'), 'preserves [features]'); + assert.ok(result.includes('codex_hooks = true'), 'preserves codex_hooks'); + }); + + test('bare [hooks] section without sub-tables is dropped (no [[hooks]] block added)', () => { + const content = [ + '[features]', + 'codex_hooks = true', + '', + '[hooks]', + '# no sub-tables, just an empty container', + '', + '[model]', + 'name = "o3"', + '', + ].join('\n'); + const result = migrateCodexHooksMapFormat(content); + assert.ok(!result.match(/^\[hooks\]$/m), 'removes bare [hooks] section'); + assert.ok(!result.includes('[[hooks]]'), 'no [[hooks]] added for bare [hooks] with no sub-tables'); + assert.ok(result.includes('[features]'), 'preserves [features]'); + assert.ok(result.includes('[model]'), 'preserves [model]'); + }); + + test('CRLF line endings are preserved through migration', () => { + const content = [ + '[features]', + 'codex_hooks = true', + '', + '[hooks.shell]', + 'command = "node /home/.codex/hooks/gsd-check-update.js"', + '', + ].join('\r\n'); + const result = migrateCodexHooksMapFormat(content); + assert.ok(result.includes('[[hooks]]\r\n'), 'uses CRLF in [[hooks]] header'); + assert.ok(result.includes('type = "shell"\r\n'), 'uses CRLF in type line'); + assert.ok(!result.includes('[hooks.shell]'), 'removes legacy [hooks.shell]'); + }); +}); + // ─── mergeCodexConfig ─────────────────────────────────────────────────────────── describe('mergeCodexConfig', () => {