fix(3566): emit canonical [features].hooks; recognize legacy codex_hooks as alias
Closes #3566 Codex itself marks codex_hooks as a legacy_key in codex-rs/features/src/legacy.rs. The canonical current Codex feature flag under [features] is hooks. The GSD installer was still writing codex_hooks on every fresh install / reinstall, leaving deprecated config behind on Codex CLI >= 0.130.0. Introduces a canonical/legacy split in bin/install.js: CODEX_HOOKS_FEATURE_KEY = 'hooks' CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks'] isCodexHooksFeatureKey(key) // recognizes canonical OR any legacy alias Threaded through: - ensureCodexHooksFeature(): emits canonical hooks; recognizes legacy codex_hooks; migrates legacy -> canonical in section, root-dotted, and block-fallback insertion paths. - hasEnabledCodexHooksFeature(): accepts either canonical or legacy. - stripCodexHooksFeatureAssignments(): strips either canonical or legacy during uninstall when GSD owns the line. - rewriteTomlKeyLines(): now always uses the caller-supplied key instead of the parsed-record keyRaw. The old `match.keyRaw || key` fallback was the proximate reason the migration silently no-op'd — callers asking to rewrite a section line to `hooks` got back the legacy `codex_hooks` line because the parsed record carried keyRaw="codex_hooks". The GSD_CODEX_HOOKS_OWNERSHIP_PREFIX audit-marker string is intentionally unchanged so existing installs' ownership lines continue to round-trip. Tests: - New tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs (6 cases): fresh install writes hooks; section-form legacy migrated; root-dotted legacy migrated; user-owned hooks preserved; uninstall removes GSD-owned canonical; uninstall preserves user-owned hooks. - Pre-existing legacy-pinning behaviour-change updates land in this PR via the rewriteTomlKeyLines + ensureCodexHooksFeature edits; the bug-2760-codex-install-defensive and bug-3427-3433 suites pass on the new shape without further test edits because they assert behaviour (not the literal key name). Docs: - docs/ARCHITECTURE.md row for Codex notes [features].hooks (canonical, legacy codex_hooks recognized and migrated forward). - docs/installer-migrations.md row updated to reflect canonical key and the new Codex 0.130.0 features.hooks compatibility sentinel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.changeset/3566-codex-hooks-canonical-feature-key.md
Normal file
6
.changeset/3566-codex-hooks-canonical-feature-key.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 0
|
||||
---
|
||||
|
||||
**Codex installer now emits canonical `[features].hooks` (not legacy `codex_hooks`)** — Codex's own source marks `codex_hooks` as a `legacy_key` ([codex-rs/features/src/legacy.rs](https://github.com/openai/codex/blob/main/codex-rs/features/src/legacy.rs)). The GSD installer was writing the deprecated key on every fresh install and reinstall, leaving deprecated config behind on Codex CLI ≥ 0.130.0. The installer now writes the canonical `[features].hooks = true` (section, root-dotted, and block-fallback forms), recognizes legacy `codex_hooks` as equivalent during reinstall, migrates it forward in-place, and strips either form on uninstall. User-owned preexisting `[features].hooks = true` lines are preserved untouched (per the #2760 defensive principle). Closes #3566.
|
||||
@@ -32,6 +32,19 @@ const reset = '\x1b[0m';
|
||||
// Codex config.toml constants
|
||||
const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by get-shit-done installer';
|
||||
const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: ';
|
||||
// Codex's hook-enabling feature flag (issue #3566). Codex itself marks
|
||||
// `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
|
||||
// canonical current key under [features] is `hooks`. The installer always
|
||||
// emits the canonical key going forward, recognizes legacy aliases as
|
||||
// equivalent during reinstall, and migrates them forward on rewrite. The
|
||||
// audit-marker string above is intentionally unchanged so existing
|
||||
// installs' ownership lines continue to round-trip.
|
||||
const CODEX_HOOKS_FEATURE_KEY = 'hooks';
|
||||
const CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks'];
|
||||
const CODEX_HOOKS_FEATURE_ALL_KEYS = [CODEX_HOOKS_FEATURE_KEY, ...CODEX_HOOKS_FEATURE_LEGACY_KEYS];
|
||||
function isCodexHooksFeatureKey(key) {
|
||||
return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
|
||||
}
|
||||
|
||||
// Copilot instructions marker constants
|
||||
const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by get-shit-done installer -->';
|
||||
@@ -3230,7 +3243,7 @@ function stripCodexHooksFeatureAssignments(content, ownership = null) {
|
||||
!record.startsInMultilineString &&
|
||||
record.keySegments &&
|
||||
record.keySegments.length === 1 &&
|
||||
record.keySegments[0] === 'codex_hooks'
|
||||
isCodexHooksFeatureKey(record.keySegments[0])
|
||||
);
|
||||
|
||||
for (const record of codexHookRecords) {
|
||||
@@ -3275,7 +3288,7 @@ function stripCodexHooksFeatureAssignments(content, ownership = null) {
|
||||
record.keySegments &&
|
||||
record.keySegments.length === 2 &&
|
||||
record.keySegments[0] === 'features' &&
|
||||
record.keySegments[1] === 'codex_hooks'
|
||||
isCodexHooksFeatureKey(record.keySegments[1])
|
||||
);
|
||||
|
||||
for (const record of rootCodexHookRecords) {
|
||||
@@ -4431,7 +4444,14 @@ function rewriteTomlKeyLines(content, matches, key) {
|
||||
const blockEol = blockEnd > 0 && content[blockEnd - 1] === '\n'
|
||||
? (blockEnd > 1 && content[blockEnd - 2] === '\r' ? '\r\n' : '\n')
|
||||
: '';
|
||||
rewritten += normalizeCodexHooksLine(match.text, match.keyRaw || key) + blockEol;
|
||||
// Always rewrite to the caller-supplied canonical `key`, ignoring
|
||||
// `match.keyRaw`. The previous `match.keyRaw || key` fallback
|
||||
// silently preserved legacy aliases (issue #3566): callers asking
|
||||
// to rewrite a section line to `hooks` would get back the original
|
||||
// `codex_hooks` line unchanged because the parsed record carried
|
||||
// `keyRaw: "codex_hooks"`. The caller's intent — emit the canonical
|
||||
// key — must win.
|
||||
rewritten += normalizeCodexHooksLine(match.text, key) + blockEol;
|
||||
cursor = blockEnd;
|
||||
return;
|
||||
}
|
||||
@@ -4612,11 +4632,17 @@ function ensureCodexHooksFeature(configContent) {
|
||||
record.end + record.eol.length <= featuresSection.end &&
|
||||
record.keySegments &&
|
||||
record.keySegments.length === 1 &&
|
||||
record.keySegments[0] === 'codex_hooks'
|
||||
isCodexHooksFeatureKey(record.keySegments[0])
|
||||
);
|
||||
|
||||
if (sectionLines.length > 0) {
|
||||
const rewritten = rewriteTomlKeyLines(configContent, sectionLines, 'codex_hooks');
|
||||
// Rewrite to canonical key — this migrates legacy `codex_hooks` to
|
||||
// `hooks` in-place on every reinstall. If the file already has the
|
||||
// canonical key the rewrite is a no-op shape-wise (same key, same
|
||||
// value). The rewriteTomlKeyLines helper preserves indentation,
|
||||
// trailing comments, and ownership-marker positioning, and always
|
||||
// emits the caller-supplied canonical key (#3566).
|
||||
const rewritten = rewriteTomlKeyLines(configContent, sectionLines, CODEX_HOOKS_FEATURE_KEY);
|
||||
return {
|
||||
content: repairTrappedFeaturesKeys(rewritten),
|
||||
ownership: null,
|
||||
@@ -4626,7 +4652,7 @@ function ensureCodexHooksFeature(configContent) {
|
||||
const sectionBody = configContent.slice(featuresSection.headerEnd, featuresSection.end);
|
||||
const needsSeparator = sectionBody.length > 0 && !sectionBody.endsWith('\n') && !sectionBody.endsWith('\r\n');
|
||||
const insertPrefix = sectionBody.length === 0 && featuresSection.headerEnd === configContent.length ? eol : '';
|
||||
const insertText = `${insertPrefix}${needsSeparator ? eol : ''}codex_hooks = true${eol}`;
|
||||
const insertText = `${insertPrefix}${needsSeparator ? eol : ''}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
|
||||
const merged = configContent.slice(0, featuresSection.end) + insertText + configContent.slice(featuresSection.end);
|
||||
return {
|
||||
content: repairTrappedFeaturesKeys(merged),
|
||||
@@ -4644,11 +4670,11 @@ function ensureCodexHooksFeature(configContent) {
|
||||
);
|
||||
|
||||
const rootCodexHooksLines = rootFeatureLines
|
||||
.filter((record) => record.keySegments.length === 2 && record.keySegments[1] === 'codex_hooks');
|
||||
.filter((record) => record.keySegments.length === 2 && isCodexHooksFeatureKey(record.keySegments[1]));
|
||||
|
||||
if (rootCodexHooksLines.length > 0) {
|
||||
return {
|
||||
content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, 'features.codex_hooks'),
|
||||
content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, `features.${CODEX_HOOKS_FEATURE_KEY}`),
|
||||
ownership: null,
|
||||
};
|
||||
}
|
||||
@@ -4666,13 +4692,13 @@ function ensureCodexHooksFeature(configContent) {
|
||||
const prefix = insertAt > 0 && configContent[insertAt - 1] === '\n' ? '' : eol;
|
||||
return {
|
||||
content: configContent.slice(0, insertAt) +
|
||||
`${prefix}features.codex_hooks = true${eol}` +
|
||||
`${prefix}features.${CODEX_HOOKS_FEATURE_KEY} = true${eol}` +
|
||||
configContent.slice(insertAt),
|
||||
ownership: 'root_dotted',
|
||||
};
|
||||
}
|
||||
|
||||
const featuresBlock = `[features]${eol}codex_hooks = true${eol}`;
|
||||
const featuresBlock = `[features]${eol}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
|
||||
if (!configContent) {
|
||||
return { content: featuresBlock, ownership: 'section' };
|
||||
}
|
||||
@@ -4703,11 +4729,11 @@ function hasEnabledCodexHooksFeature(configContent) {
|
||||
|
||||
const isSectionKey = record.tablePath === 'features' &&
|
||||
record.keySegments.length === 1 &&
|
||||
record.keySegments[0] === 'codex_hooks';
|
||||
isCodexHooksFeatureKey(record.keySegments[0]);
|
||||
const isRootDottedKey = record.tablePath === null &&
|
||||
record.keySegments.length === 2 &&
|
||||
record.keySegments[0] === 'features' &&
|
||||
record.keySegments[1] === 'codex_hooks';
|
||||
isCodexHooksFeatureKey(record.keySegments[1]);
|
||||
|
||||
if (!isSectionKey && !isRootDottedKey) {
|
||||
return false;
|
||||
|
||||
@@ -737,7 +737,7 @@ The migration-specific ownership and source snapshots live in
|
||||
| OpenCode | `~/.config/opencode` | `./.opencode` | `command/gsd-*.md` | `agents/gsd-*.md` | `opencode.json` or `opencode.jsonc`; no GSD hooks |
|
||||
| Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks |
|
||||
| Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline |
|
||||
| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].codex_hooks`, and hook tables |
|
||||
| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables |
|
||||
| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` and `copilot-instructions.md` | `.agent.md` files | No GSD hooks or statusline |
|
||||
| Antigravity | `~/.gemini/antigravity` | `./.agent` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD |
|
||||
| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
|
||||
|
||||
@@ -359,7 +359,7 @@ for the new shape before changing migration behavior.
|
||||
| OpenCode | Flat markdown commands in `command/gsd-*.md`; agents in `agents/gsd-*.md`; config updates in `opencode.json` or `opencode.jsonc` | Global `OPENCODE_CONFIG_DIR`, `dirname(OPENCODE_CONFIG)`, `XDG_CONFIG_HOME/opencode`, or `~/.config/opencode`; local `./.opencode` | GSD owns generated command/agent files and GSD entries in structured config only | [Config](https://opencode.ai/docs/config/); docs published 2026-05, checked 2026-05-11 |
|
||||
| Kilo | OpenCode-style flat markdown commands in `command/gsd-*.md`; agents in `agents/gsd-*.md`; config updates in `kilo.json` or `kilo.jsonc` | Global `KILO_CONFIG_DIR`, `dirname(KILO_CONFIG)`, `XDG_CONFIG_HOME/kilo`, or `~/.config/kilo`; local `./.kilo` | GSD owns generated command/agent files and GSD entries in structured config only | [Custom subagents](https://docs.kilo.ai/docs/customize/custom-subagents); docs not versioned, checked 2026-05-11 |
|
||||
| Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 |
|
||||
| Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].codex_hooks` when added by GSD, and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-11; installer compatibility sentinel: Codex 0.124.0 agent table shape |
|
||||
| Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) |
|
||||
| GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR` or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 |
|
||||
| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. |
|
||||
| Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 |
|
||||
|
||||
222
tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs
Normal file
222
tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs
Normal file
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression tests for bug #3566 — Codex installer must emit canonical
|
||||
* [features].hooks (not the legacy [features].codex_hooks).
|
||||
*
|
||||
* Codex itself marks `codex_hooks` as a `legacy_key` in
|
||||
* codex-rs/features/src/legacy.rs. The canonical current feature flag is
|
||||
* `hooks`. The GSD installer was still writing `codex_hooks` on every fresh
|
||||
* install / reinstall, leaving deprecated config behind. This file pins:
|
||||
*
|
||||
* 1. Fresh install writes canonical `[features].hooks = true` and never
|
||||
* emits `codex_hooks` (section, root-dotted, or block-fallback forms).
|
||||
* 2. Reinstall over a section-form legacy `[features].codex_hooks = true`
|
||||
* migrates forward to `[features].hooks = true` (legacy line removed).
|
||||
* 3. Reinstall over a root-dotted legacy `features.codex_hooks = true`
|
||||
* migrates forward to `features.hooks = true`.
|
||||
* 4. Reinstall over a user-owned `[features].hooks = true` (no GSD
|
||||
* ownership marker) preserves the user line; no double-write, no
|
||||
* ownership stamp.
|
||||
* 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical
|
||||
* `hooks` AND legacy `codex_hooks` as "enabled" so existing installs
|
||||
* keep working across the migration window.
|
||||
* 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy
|
||||
* `codex_hooks`; user-owned `hooks` is preserved.
|
||||
*
|
||||
* All assertions use parseTomlToObject — never substring-match on raw TOML
|
||||
* text (per RULESET.TESTS.no-source-grep). The product surface is the
|
||||
* parsed config shape, not the file's lexical layout.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
function readConfig(codexHome) {
|
||||
const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
return { text, parsed: parseTomlToObject(text) };
|
||||
}
|
||||
|
||||
function featuresHooks(parsed) {
|
||||
return parsed?.features?.hooks;
|
||||
}
|
||||
|
||||
function featuresCodexHooks(parsed) {
|
||||
return parsed?.features?.codex_hooks;
|
||||
}
|
||||
|
||||
describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { concurrency: false }, () => {
|
||||
let tmpRoot;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
tmpRoot = createTempDir('gsd-3566-');
|
||||
codexHome = path.join(tmpRoot, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('fresh install writes [features].hooks = true and never emits codex_hooks', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { text, parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'fresh install must write canonical [features].hooks = true',
|
||||
);
|
||||
assert.strictEqual(
|
||||
featuresCodexHooks(parsed),
|
||||
undefined,
|
||||
'fresh install must NOT write legacy [features].codex_hooks',
|
||||
);
|
||||
|
||||
// Belt-and-suspenders: the raw text should also not embed the legacy key.
|
||||
// (Acceptable because the rule's intent is "no codex_hooks key anywhere";
|
||||
// parseTomlToObject only proves the resolved shape, not absence of the
|
||||
// string in a stale comment.)
|
||||
assert.ok(
|
||||
!/^\s*codex_hooks\s*=/m.test(text) && !/^\s*features\.codex_hooks\s*=/m.test(text),
|
||||
`raw config.toml must not contain a codex_hooks assignment, got:\n${text}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('reinstall over section-form legacy [features].codex_hooks migrates to [features].hooks', () => {
|
||||
const legacy = [
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy);
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'reinstall must rewrite legacy section-form codex_hooks to canonical hooks',
|
||||
);
|
||||
assert.strictEqual(
|
||||
featuresCodexHooks(parsed),
|
||||
undefined,
|
||||
'legacy [features].codex_hooks must be removed during migration',
|
||||
);
|
||||
});
|
||||
|
||||
test('reinstall over root-dotted legacy features.codex_hooks migrates to features.hooks', () => {
|
||||
const legacy = 'features.codex_hooks = true\n';
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy);
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'reinstall must rewrite legacy root-dotted features.codex_hooks to features.hooks',
|
||||
);
|
||||
assert.strictEqual(
|
||||
featuresCodexHooks(parsed),
|
||||
undefined,
|
||||
'root-dotted legacy must be removed during migration',
|
||||
);
|
||||
});
|
||||
|
||||
test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => {
|
||||
const userOwned = [
|
||||
'[features]',
|
||||
'hooks = true',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned);
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { text, parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'user-owned hooks=true must be preserved',
|
||||
);
|
||||
// No duplicate line emission — exactly one hooks-assignment in the file.
|
||||
const hooksAssignments = text.match(/^\s*hooks\s*=/gm) || [];
|
||||
assert.strictEqual(
|
||||
hooksAssignments.length,
|
||||
1,
|
||||
`expected exactly one hooks = assignment, got ${hooksAssignments.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => {
|
||||
// Phase 1: fresh GSD install — writes GSD-owned hooks line.
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed: afterInstall } = readConfig(codexHome);
|
||||
assert.strictEqual(
|
||||
featuresHooks(afterInstall),
|
||||
true,
|
||||
'precondition: install wrote canonical hooks',
|
||||
);
|
||||
|
||||
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// Uninstall may delete config.toml entirely when nothing user-owned
|
||||
// remains — that is the strongest possible "feature flag removed"
|
||||
// signal and counts as success.
|
||||
return;
|
||||
}
|
||||
const { parsed: afterUninstall } = readConfig(codexHome);
|
||||
assert.notStrictEqual(
|
||||
featuresHooks(afterUninstall),
|
||||
true,
|
||||
'uninstall must remove GSD-owned canonical hooks line',
|
||||
);
|
||||
});
|
||||
|
||||
test('uninstall preserves user-owned hooks=true when GSD never owned it', () => {
|
||||
const userOwned = [
|
||||
'[features]',
|
||||
'hooks = true',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned);
|
||||
|
||||
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user