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:
112
bin/install.js
112
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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user