Merge pull request #3399 from gsd-build/codex/installer-migrations-phase-two

Feat(installer): Phase 2 port existing cleanup behavior
This commit is contained in:
Tom Boucher
2026-05-11 14:59:57 -04:00
committed by GitHub
10 changed files with 700 additions and 118 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3399
---
**Installer migrations now handle legacy Codex hooks cleanup transactionally** - GSD-owned hooks.json entries are removed through the migration runner with runtime filtering, rollback, and checksum drift protection.

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.
@@ -7741,6 +7671,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) {
@@ -7817,6 +7748,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).
@@ -8743,10 +8683,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
@@ -10839,7 +10775,6 @@ if (process.env.GSD_TEST_MODE) {
rewriteLegacyManagedNodeHookCommands,
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
cleanupLegacyCodexHooksJson,
};
} else {

View File

@@ -484,7 +484,8 @@ UI-SPEC.md (per phase) ───────────────────
```
~/.claude/ # Claude Code (global install)
├── commands/gsd/*.md # Slash commands (authoritative roster: docs/INVENTORY.md)
├── skills/gsd-*/SKILL.md # Global skills (authoritative roster: docs/INVENTORY.md)
├── commands/gsd/*.md # Local Claude installs use slash commands instead of global skills
├── get-shit-done/
│ ├── bin/gsd-tools.cjs # CLI utility
│ ├── bin/lib/*.cjs # Domain modules (authoritative roster: docs/INVENTORY.md)
@@ -500,12 +501,20 @@ UI-SPEC.md (per phase) ───────────────────
Equivalent paths for other runtimes:
- **OpenCode:** `~/.config/opencode/` or `~/.opencode/`
- **Kilo:** `~/.config/kilo/` or `~/.kilo/`
- **Gemini CLI:** `~/.gemini/`
- **Codex:** `~/.codex/` (uses skills instead of commands)
- **Copilot:** `~/.github/`
- **Antigravity:** `~/.gemini/antigravity/` (global) or `./.agent/` (local)
- **OpenCode:** `~/.config/opencode/` global or `./.opencode/` local
- **Kilo:** `~/.config/kilo/` global or `./.kilo/` local
- **Gemini CLI:** `~/.gemini/` global or `./.gemini/` local
- **Codex:** `~/.codex/` global or `./.codex/` local
- **Copilot:** `~/.copilot/` global or `./.github/` local
- **Antigravity:** `~/.gemini/antigravity/` global or `./.agent/` local
- **Cursor:** `~/.cursor/` global or `./.cursor/` local
- **Windsurf:** `~/.codeium/windsurf/` global or `./.windsurf/` local
- **Augment Code:** `~/.augment/` global or `./.augment/` local
- **Trae:** `~/.trae/` global or `./.trae/` local
- **Qwen Code:** `~/.qwen/` global or `./.qwen/` local
- **Hermes Agent:** `~/.hermes/` global or `./.hermes/` local
- **CodeBuddy:** `~/.codebuddy/` global or `./.codebuddy/` local
- **Cline:** `~/.cline/` global or project-root `.clinerules` local
### Project Files (`.planning/`)
@@ -587,11 +596,11 @@ verification.
## Installer Architecture
The installer (`bin/install.js`, ~3,000 lines) handles:
The installer (`bin/install.js`, ~10,700 lines) handles:
1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--trae`, `--cline`, `--augment`, `--all`)
1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, `--all`)
2. **Location selection** — Global (`--global`) or local (`--local`)
3. **File deployment** — Copies commands, workflows, references, templates, agents, hooks
3. **File deployment** — Copies commands, skills, workflows, references, templates, agents, and hooks
4. **Runtime adaptation** — Transforms file content per runtime:
- Claude Code: Uses as-is
- OpenCode: Converts commands/agents to OpenCode-compatible flat command + subagent format
@@ -600,7 +609,12 @@ The installer (`bin/install.js`, ~3,000 lines) handles:
- Copilot: Maps tool names (Read→read, Bash→execute, etc.)
- Gemini: Adjusts hook event names (`AfterTool` instead of `PostToolUse`)
- Antigravity: Skills-first with Google model equivalents
- Cursor: Skills-first with Cursor rule references
- Windsurf: Skills-first with Windsurf rule references
- Trae: Skills-first install to `~/.trae` / `./.trae` with no `settings.json` or hook integration
- Qwen Code: Skills-first with Qwen-branded path and prompt rewrites
- Hermes Agent: Category-based skills under `skills/gsd/`
- CodeBuddy: Skills-first with CodeBuddy path and prompt rewrites
- Cline: Writes `.clinerules` for rule-based integration
- Augment Code: Skills-first with full skill conversion and config management
5. **Path normalization** — Replaces `~/.claude/` paths with runtime-specific paths
@@ -708,20 +722,46 @@ The researcher → planner → executor pipeline includes a supply-chain gate ag
GSD supports multiple AI coding runtimes through a unified command/workflow architecture:
### Runtime Install Contract Matrix
| Runtime | Command Format | Agent System | Config Location |
| ------------ | -------------- | ---------------- | ------------------------ |
| Claude Code | `/gsd-command` | Task spawning | `~/.claude/` |
| OpenCode | `/gsd-command` | Subagent mode | `~/.config/opencode/` |
| Kilo | `/gsd-command` | Subagent mode | `~/.config/kilo/` |
| Gemini CLI | `/gsd-command` | Task spawning | `~/.gemini/` |
| Codex | `$gsd-command` | Skills | `~/.codex/` |
| Copilot | `/gsd-command` | Agent delegation | `~/.github/` |
| Antigravity | Skills | Skills | `~/.gemini/antigravity/` |
| Trae | Skills | Skills | `~/.trae/` |
| Cline | Rules | Rules | `.clinerules` |
| Augment Code | Skills | Skills | Augment config |
This matrix describes the runtime surfaces the installer materializes today.
The migration-specific ownership and source snapshots live in
[Installer Migrations](installer-migrations.md#runtime-configuration-contract-registry).
| Runtime | Global root | Local root | Invocation surface | Agent surface | Config and hooks |
| --- | --- | --- | --- | --- | --- |
| Claude Code | `~/.claude` | `./.claude` | Global `skills/gsd-*/SKILL.md`; local `commands/gsd/*.md` | `agents/gsd-*.md` | `settings.json` hook and statusLine entries |
| 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 |
| 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 |
| Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
| Augment Code | `~/.augment` | `./.augment` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | No GSD hooks or statusline |
| Trae | `~/.trae` | `./.trae` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
| Qwen Code | `~/.qwen` | `./.qwen` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported |
| Hermes Agent | `~/.hermes` | `./.hermes` | `skills/gsd/DESCRIPTION.md` plus `skills/gsd/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported |
| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported |
| Cline | `~/.cline` | project root | `.clinerules` | Rules only | No GSD hooks or statusline |
### Upstream Contract Sources
Runtime install expectations are checked against primary documentation where
available. The current source snapshot is 2026-05-11:
- Claude Code: Anthropic slash commands, settings, hooks, and subagents docs.
- OpenCode and Kilo: OpenCode config docs and Kilo custom subagent docs.
- Gemini CLI and Qwen Code: command/config docs; Qwen command docs were last
updated 2026-05-06.
- Codex: OpenAI Codex docs and `config-schema.json`; the installer also carries
Codex 0.124.0 compatibility for agent table shape.
- Copilot, Cursor, Cline, Augment, Hermes, and CodeBuddy: vendor docs for
custom instructions, rules, skills, or config.
- Antigravity, Windsurf, and Trae: source-limited rows. The installer documents
current compatibility shims, and migrations must refresh those sources before
rewriting their config.
### Abstraction Points

View File

@@ -15,6 +15,22 @@ We decided to introduce an explicit Installer Migration Module for install-time
- Require migrations to plan first, then apply through a shared executor that owns backup, rollback, and reporting.
- Default ambiguous or unknown files to preserve; destructive changes need managed-file evidence or explicit user choice.
- Support dry-run output using the same planner used by apply mode.
- Treat the runtime configuration contract registry in `docs/installer-migrations.md` as the source of truth for migrations that touch host runtime config.
## Runtime Contract Decision
Every migration that rewrites runtime config, moves an invocation surface, or
retires a generated runtime artifact must cite the registry row in
`docs/installer-migrations.md`. If the migration changes where a runtime loads
commands, skills, agents, hooks, or rules, the PR must update both the registry
and `docs/ARCHITECTURE.md`.
The registry records what GSD installs, where it installs it, when migrations
may touch it, who owns the surrounding config, and why the shape matches the
host runtime. When upstream docs do not publish an API or docs version, the
checked date is the drift sentinel. A later upstream docs or CLI release that
changes command, skill, agent, hook, or rule loading requires a new registry
snapshot before migration work proceeds.
## Consequences
@@ -23,6 +39,7 @@ We decided to introduce an explicit Installer Migration Module for install-time
- Locally modified managed files get a consistent backup path before removal or replacement.
- Future rollback work can become runtime-neutral instead of Codex-specific.
- Migration authors must define ownership evidence, conflict behavior, runtime scope, and non-interactive behavior.
- Migration authors must also define which runtime contract they are relying on and whether the upstream documentation is versioned.
- The installer gains another state file, so tests must cover missing, legacy, and checksum-mismatch state.
## Scope

View File

@@ -195,11 +195,20 @@ tests for line-ending and ordering variations.
Use for runtime config, hook registrations, feature flags, and generated
agent registration blocks.
The initial executor support is `rewrite-json`: a migration reads JSON through
`readJson(relPath)`, returns the next parsed value in the action, and may set
`deleteIfEmpty: true` when the remaining structure is empty. The executor owns
the disk write, journal entry, rollback snapshot, and runtime/scope filtering.
Use this for legacy JSON config cleanup such as Codex `hooks.json`, where GSD
can prove ownership of individual generated hook commands but not the whole
file.
### preserve-user
Declare that a path is user-owned and must survive surrounding directory
replacement. This action is informational in dry-run output and becomes a
copy-through or restore operation during apply.
replacement. This action is informational in dry-run output and blocks
non-interactive apply until a later interactive baseline migration can ask for
an explicit user choice.
Use for profile, preferences, hand-authored instructions, and future workflow
outputs.
@@ -279,6 +288,57 @@ sections, or hook entries, but it does not own the whole file unless the file
was created as a GSD-only file. Config migrations should remove or rewrite
only the owned portion.
## Runtime Configuration Contract Registry
Last upstream documentation check: 2026-05-11.
This registry is the source of truth for migrations that touch host runtime
configuration. Each row records:
- **What:** the GSD invocation, agent, skill, rule, hook, or config surface
- **Where:** the global and local roots the installer targets
- **When:** install, upgrade, uninstall, and migration touch points
- **Who:** the ownership boundary for surrounding user config
- **Why:** the upstream loader contract or current GSD compatibility shim
Migration authors must read the matching row before producing a
`rewrite-config`, `move-managed`, or destructive cleanup action. If upstream
docs change, update this registry, update `docs/ARCHITECTURE.md`, and add tests
for the new shape before changing migration behavior.
| Runtime | What GSD installs | Where GSD installs it | Config ownership boundary | Upstream contract snapshot |
| --- | --- | --- | --- | --- |
| Claude Code | Global skills in `skills/gsd-*/SKILL.md`; local slash commands in `commands/gsd/*.md`; agents in `agents/gsd-*.md`; hooks in `hooks/`; `settings.json` registrations | Global `CLAUDE_CONFIG_DIR` or `~/.claude`; local `./.claude` | GSD owns only generated skills, local commands, `gsd-*` agents, hook files, and GSD hook/statusLine entries in `settings.json` | [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands), [settings](https://docs.anthropic.com/en/docs/claude-code/settings), [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks), [subagents](https://docs.anthropic.com/en/docs/claude-code/sub-agents); docs not versioned, checked 2026-05-11 |
| 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 |
| 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 | Checked 2026-05-11 against available Antigravity install/config material; this row records GSD's Gemini-compatible settings contract as the compatibility baseline for installer migrations. |
| 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 |
| Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules |
| Augment Code | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/` | Global `AUGMENT_CONFIG_DIR` or `~/.augment`; local `./.augment` | GSD owns generated skills/agents only; no hook/statusline ownership | [Augment Agent Skills](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/skills); IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11 |
| Trae | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `TRAE_CONFIG_DIR` or `~/.trae`; local `./.trae` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Public Trae docs expose AI settings and `.rules` announcements, but no stable skills/config API was found as of 2026-05-11; migrations must treat this row as source-limited |
| Qwen Code | Claude-compatible skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; optional common hook/settings integration through GSD | Global `QWEN_CONFIG_DIR` or `~/.qwen`; local `./.qwen` | GSD owns generated skills/agents/hooks and GSD settings entries only | [Qwen commands and skills](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/); docs last updated 2026-05-06 |
| Hermes Agent | Category skills under `skills/gsd/` with `DESCRIPTION.md` plus nested `gsd-*/SKILL.md`; agents in `agents/`; optional common hook/settings integration through GSD | Global `HERMES_HOME` or `~/.hermes`; local `./.hermes` | GSD owns generated `skills/gsd/` category content, generated agents, and GSD settings entries only | [Hermes configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration), [Hermes skills](https://hermes-agent.nousresearch.com/docs/zh-Hans/user-guide/features/skills), [working with skills](https://hermes-agent.nousresearch.com/docs/guides/work-with-skills); docs checked 2026-05-11 |
| CodeBuddy | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; optional common hook/settings integration through GSD | Global `CODEBUDDY_CONFIG_DIR` or `~/.codebuddy`; local `./.codebuddy` | GSD owns generated skills/agents/hooks and GSD settings entries only | [CodeBuddy CLI skills](https://www.codebuddy.ai/docs/cli/skills), [CodeBuddy IDE skills](https://www.codebuddy.ai/docs/ide/Features/Skills); docs checked 2026-05-11 |
| Cline | Rule-based integration via `.clinerules` for current installer output | Global `CLINE_CONFIG_DIR` or `~/.cline`; local project root `.clinerules` | GSD owns the generated `.clinerules` file only when it created or manifest-tracked it; no hooks/statusline ownership | [Cline rules](https://docs.cline.bot/customization/cline-rules); docs prefer `.clinerules/` directory and still detect legacy rule files, checked 2026-05-11 |
### Registry Authoring Rules
- Use structured parsers for config files whenever the runtime provides JSON,
JSONC, TOML, or YAML. Marker-block rewrites need line-ending and ordering
tests.
- Do not claim ownership of a mixed config file. Own only generated entries,
generated files, and explicit marker blocks.
- Preserve unknown user config, even when it sits inside a GSD-managed runtime
root.
- Add or update the upstream snapshot date and version note when a runtime's
docs, CLI schema, or loader behavior changes.
- Treat source-limited rows as high-risk. A migration that rewrites those
runtimes needs either a new primary source or an installer-level probe with
tests.
### Rollback
Before applying a migration, the executor records enough data to restore:

View File

@@ -12,6 +12,10 @@ function sha256File(filePath) {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function sha256Text(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function readJsonIfPresent(filePath, fallback) {
if (!fs.existsSync(filePath)) return fallback;
try {
@@ -47,10 +51,22 @@ function readInstallState(configDir) {
function writeInstallState(configDir, state) {
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n', 'utf8');
writeFileAtomicSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n');
return state;
}
function readJson(configDir, relPath) {
const { fullPath } = ensureInsideConfig(configDir, relPath);
if (!fs.existsSync(fullPath)) {
return { exists: false, value: null, error: null };
}
try {
return { exists: true, value: JSON.parse(fs.readFileSync(fullPath, 'utf8')), error: null };
} catch (error) {
return { exists: true, value: null, error };
}
}
function normalizeRelPath(relPath) {
if (typeof relPath !== 'string' || relPath.trim() === '') {
throw new Error('migration action relPath must be a non-empty string');
@@ -91,6 +107,56 @@ function appliedMigrationIds(state) {
);
}
function appliedMigrationEntries(state) {
const entries = new Map();
for (const entry of state.appliedMigrations) {
if (entry && typeof entry.id === 'string' && !entries.has(entry.id)) {
entries.set(entry.id, entry);
}
}
return entries;
}
function migrationChecksum(migration) {
if (typeof migration.checksum === 'string' && migration.checksum) return migration.checksum;
const serializable = {
id: migration.id,
title: migration.title || null,
description: migration.description || null,
introducedIn: migration.introducedIn || null,
runtimes: migration.runtimes || null,
scopes: migration.scopes || null,
destructive: migration.destructive === true,
runtimeContract: migration.runtimeContract || null,
plan: typeof migration.plan === 'function' ? migration.plan.toString() : null,
};
return `sha256:${sha256Text(JSON.stringify(serializable))}`;
}
function assertAppliedMigrationChecksums(state, migrations) {
const applied = appliedMigrationEntries(state);
for (const migration of migrations) {
const entry = applied.get(migration.id);
if (!entry || !entry.checksum) continue;
const checksum = migrationChecksum(migration);
if (entry.checksum !== checksum) {
throw new Error(
`applied migration checksum changed for ${migration.id}; create a new fix-forward migration id`
);
}
}
}
function migrationMatchesContext(migration, { runtime, scope }) {
if (Array.isArray(migration.runtimes) && migration.runtimes.length > 0) {
if (!runtime || !migration.runtimes.includes(runtime)) return false;
}
if (Array.isArray(migration.scopes) && migration.scopes.length > 0) {
if (!scope || !migration.scopes.includes(scope)) return false;
}
return true;
}
function validateMigrationRecord(record, source) {
if (!record || typeof record !== 'object') {
throw new Error(`migration record must export an object: ${source}`);
@@ -112,10 +178,11 @@ function discoverInstallerMigrations({ migrationsDir }) {
.sort()
.flatMap((fileName) => {
const source = path.join(migrationsDir, fileName);
const checksum = `sha256:${sha256File(source)}`;
delete require.cache[require.resolve(source)];
const exported = require(source);
const records = Array.isArray(exported) ? exported : [exported];
return records.map((record) => validateMigrationRecord(record, source));
return records.map((record) => validateMigrationRecord({ ...record, checksum: record.checksum || checksum }, source));
});
}
@@ -133,14 +200,44 @@ function ensureInsideConfig(configDir, relPath) {
return { normalized, fullPath };
}
function planInstallerMigrations({ configDir, migrations, now = () => new Date().toISOString() }) {
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 writeFileAtomicSync(filePath, content) {
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
try {
fs.writeFileSync(tmpPath, content, 'utf8');
fs.renameSync(tmpPath, filePath);
} catch (error) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {
// best-effort cleanup only; preserve the original write failure
}
throw error;
}
}
function journalAction(action, status, extras = {}) {
const { value, ...safeAction } = action;
return { ...safeAction, ...extras, status };
}
function planInstallerMigrations({ configDir, runtime = null, scope = null, migrations, now = () => new Date().toISOString() }) {
if (!configDir) throw new Error('configDir is required');
if (!Array.isArray(migrations)) throw new Error('migrations must be an array');
const manifest = readInstallManifest(configDir);
const state = readInstallState(configDir);
const scopedMigrations = migrations.filter((migration) =>
migration && migrationMatchesContext(migration, { runtime, scope })
);
assertAppliedMigrationChecksums(state, scopedMigrations);
const applied = appliedMigrationIds(state);
const pending = migrations.filter((migration) => migration && !applied.has(migration.id));
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id));
const actions = [];
const blocked = [];
const classifications = new Map();
@@ -161,10 +258,13 @@ function planInstallerMigrations({ configDir, migrations, now = () => new Date()
}
const plannedActions = migration.plan({
configDir,
runtime,
scope,
manifest,
state,
now,
classifyArtifact: classify,
readJson: (relPath) => readJson(configDir, relPath),
});
if (!Array.isArray(plannedActions)) {
throw new Error(`migration ${migration.id} plan must return an array`);
@@ -181,6 +281,7 @@ function planInstallerMigrations({ configDir, migrations, now = () => new Date()
}
const action = {
migrationId: migration.id,
migrationChecksum: migrationChecksum(migration),
type: protectedType,
relPath,
reason: rawAction.reason || migration.description || '',
@@ -194,7 +295,11 @@ function planInstallerMigrations({ configDir, migrations, now = () => new Date()
if (action.type === 'backup-and-remove') {
action.backupRelPath = path.posix.join('gsd-migration-backups', migration.id, relPath);
}
if (action.classification === 'unknown') blocked.push(action);
if (action.type === 'rewrite-json') {
action.value = rawAction.value;
action.deleteIfEmpty = rawAction.deleteIfEmpty === true;
}
if (action.classification === 'unknown' && action.type !== 'rewrite-json') blocked.push(action);
actions.push(action);
}
}
@@ -213,6 +318,54 @@ function uniqueActionMigrationIds(actions) {
return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))];
}
function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, previousInstallStateBytes }) {
const failures = [];
for (const action of [...journal.actions].reverse()) {
if (!action.rollbackRelPath) continue;
const rollbackPath = path.join(configDir, action.rollbackRelPath);
const dest = path.join(configDir, action.relPath);
try {
if (fs.existsSync(rollbackPath)) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(rollbackPath, dest);
}
} catch (error) {
failures.push({ relPath: action.relPath, error: error.message });
}
if (action.backupRelPath) {
try {
fs.rmSync(path.join(configDir, action.backupRelPath), { force: true });
} catch {
// backup cleanup is best-effort; preserve restore failures above
}
}
}
try {
if (previousInstallStateBytes === null) {
fs.rmSync(path.join(configDir, INSTALL_STATE_NAME), { force: true });
} else {
fs.mkdirSync(configDir, { recursive: true });
writeFileAtomicSync(path.join(configDir, INSTALL_STATE_NAME), previousInstallStateBytes);
}
} catch (error) {
failures.push({ relPath: INSTALL_STATE_NAME, error: error.message });
}
try {
fs.rmSync(journalPath, { force: true });
fs.rmSync(rollbackRoot, { recursive: true, force: true });
} catch {
// journal cleanup is best-effort; the rollback above is the safety-critical part
}
if (failures.length > 0) {
const error = new Error('migration rollback incomplete');
error.rollbackFailures = failures;
throw error;
}
}
function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) {
if (!configDir) throw new Error('configDir is required');
if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required');
@@ -239,17 +392,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
try {
for (const action of plan.actions) {
if (action.type === 'preserve-user') {
journal.actions.push({ ...action, status: 'preserved' });
continue;
}
if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove') {
if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove' && action.type !== 'rewrite-json') {
throw new Error(`unsupported migration action type: ${action.type}`);
}
const { normalized, fullPath } = ensureInsideConfig(configDir, action.relPath);
if (!fs.existsSync(fullPath)) {
journal.actions.push({ ...action, status: 'missing' });
journal.actions.push(journalAction(action, 'missing'));
continue;
}
@@ -258,14 +407,34 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
fs.copyFileSync(fullPath, rollbackPath);
rollback.push({ relPath: normalized, rollbackPath });
if (action.type === 'rewrite-json') {
if (action.deleteIfEmpty && isStructurallyEmpty(action.value)) {
fs.rmSync(fullPath, { force: true });
journal.actions.push(journalAction(action, 'removed', {
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
}));
} else {
writeFileAtomicSync(fullPath, JSON.stringify(action.value, null, 2) + '\n');
journal.actions.push(journalAction(action, 'rewritten', {
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
}));
}
continue;
}
if (action.type === 'backup-and-remove') {
const backupRelPath = action.backupRelPath || path.posix.join('gsd-migration-backups', action.migrationId, normalized);
const backupPath = path.join(configDir, backupRelPath);
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
fs.copyFileSync(fullPath, backupPath);
journal.actions.push({ ...action, backupRelPath, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' });
journal.actions.push(journalAction(action, 'removed', {
backupRelPath,
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
}));
} else {
journal.actions.push({ ...action, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' });
journal.actions.push(journalAction(action, 'removed', {
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
}));
}
fs.rmSync(fullPath, { force: true });
}
@@ -278,7 +447,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
const nextApplied = [...state.appliedMigrations];
for (const id of journal.appliedMigrationIds) {
if (!applied.has(id)) {
nextApplied.push({ id, appliedAt, journal: journalRelPath });
const action = plan.actions.find((candidate) => candidate.migrationId === id);
nextApplied.push({
id,
appliedAt,
journal: journalRelPath,
checksum: action && action.migrationChecksum ? action.migrationChecksum : null,
});
}
}
writeInstallState(configDir, {
@@ -372,11 +547,13 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
function runInstallerMigrations({
configDir,
runtime = null,
scope = null,
migrationsDir = DEFAULT_MIGRATIONS_DIR,
migrations = discoverInstallerMigrations({ migrationsDir }),
now = () => new Date().toISOString(),
} = {}) {
const plan = planInstallerMigrations({ configDir, migrations, now });
const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, now });
if (plan.actions.length === 0) {
return {
appliedMigrationIds: [],

View File

@@ -7,7 +7,11 @@ const LEGACY_ORPHAN_FILES = [
module.exports = {
id: '2026-05-11-legacy-orphan-files',
title: 'Remove manifest-managed legacy orphan hook files',
description: 'Remove legacy orphan hook files that are still manifest-managed.',
introducedIn: '1.50.0',
scopes: ['global', 'local'],
destructive: true,
plan: ({ classifyArtifact }) => {
const actions = [];
for (const relPath of LEGACY_ORPHAN_FILES) {

View File

@@ -0,0 +1,80 @@
'use strict';
const path = require('path');
function isStructurallyEmpty(value) {
if (value === null || value === undefined) return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value !== 'object') return false;
for (const _key in value) return false;
return true;
}
function isManagedCodexHookCommand(command, configDir) {
if (typeof command !== 'string') return false;
if (typeof configDir !== 'string' || configDir.length === 0) return false;
const normalizedCommand = command.replace(/\\/g, '/');
const managedHooksDir = `${path.join(configDir, 'hooks').replace(/\\/g, '/')}/`;
if (!normalizedCommand.includes(managedHooksDir)) return false;
return /(^|[\\/\s"'])(gsd-check-update\.js|gsd-update-check\.js)(?=$|[\s"'])/.test(normalizedCommand);
}
function pruneLegacyCodexHooksJsonValue(value, configDir) {
if (Array.isArray(value)) {
let changed = false;
const next = [];
for (const item of value) {
const pruned = pruneLegacyCodexHooksJsonValue(item, configDir);
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, configDir)) {
return { value: null, changed: true };
}
let changed = false;
const next = {};
for (const [key, child] of Object.entries(value)) {
const pruned = pruneLegacyCodexHooksJsonValue(child, configDir);
if (pruned.changed) changed = true;
if (!isStructurallyEmpty(pruned.value)) next[key] = pruned.value;
else changed = true;
}
return { value: next, changed };
}
return { value, changed: false };
}
module.exports = {
id: '2026-05-11-codex-legacy-hooks-json',
title: 'Remove legacy Codex hooks.json GSD hook registrations',
description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.',
introducedIn: '1.50.0',
runtimes: ['codex'],
scopes: ['global', 'local'],
destructive: true,
runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row',
plan: ({ configDir, readJson }) => {
const hooksJson = readJson('hooks.json');
if (!hooksJson.exists || hooksJson.error) return [];
const pruned = pruneLegacyCodexHooksJsonValue(hooksJson.value, configDir);
if (!pruned.changed) return [];
return [
{
type: 'rewrite-json',
relPath: 'hooks.json',
value: pruned.value,
deleteIfEmpty: true,
reason: 'legacy Codex hooks.json GSD registration retired by installer migration',
},
];
},
};

View File

@@ -15,7 +15,9 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { install, parseTomlToObject } = require('../bin/install.js');
const installModule = require('../bin/install.js');
const { readInstallState } = require('../get-shit-done/bin/lib/installer-migrations.cjs');
const { install, parseTomlToObject } = installModule;
const { createTempDir, cleanup } = require('./helpers.cjs');
function withCodexHome(codexHome, fn) {
@@ -67,6 +69,7 @@ describe('#3357 — Codex install removes legacy GSD hooks.json entries', { conc
});
afterEach(() => {
delete installModule.__codexSchemaValidator;
cleanup(tmpRoot);
});
@@ -104,4 +107,25 @@ describe('#3357 — Codex install removes legacy GSD hooks.json entries', { conc
]);
assert.equal(tomlGsdHookCount(codexHome), 1);
});
test('restores migrated hooks.json and install state when later Codex validation fails', () => {
const before = JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2);
fs.writeFileSync(path.join(codexHome, 'hooks.json'), before);
installModule.__codexSchemaValidator = () => ({
ok: false,
reason: 'forced migration rollback test',
});
assert.throws(
() => withCodexHome(codexHome, () => install(true, 'codex')),
/forced migration rollback test/
);
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), before);
assert.equal(
readInstallState(codexHome).appliedMigrations.some((entry) => entry.id === '2026-05-11-codex-legacy-hooks-json'),
false
);
});
});

View File

@@ -8,6 +8,7 @@ const crypto = require('crypto');
const {
applyInstallerMigrationPlan,
discoverInstallerMigrations,
INSTALL_STATE_NAME,
planInstallerMigrations,
readInstallState,
runInstallerMigrations,
@@ -45,6 +46,28 @@ function writeManifest(root, files) {
);
}
function legacyCodexHook(configDir) {
return {
hooks: [
{
type: 'command',
command: `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`,
},
],
};
}
function userHook(command) {
return {
hooks: [
{
type: 'command',
command,
},
],
};
}
test('plans a pending migration against an unchanged managed file', () => {
const configDir = createTempInstall();
try {
@@ -73,7 +96,17 @@ test('plans a pending migration against an unchanged managed file', () => {
assert.deepEqual(plan.pendingMigrationIds, ['2026-05-11-remove-old-hook']);
assert.equal(plan.blocked.length, 0);
assert.deepEqual(plan.actions, [
assert.equal(plan.actions.length, 1);
assert.deepEqual(
{
migrationId: plan.actions[0].migrationId,
type: plan.actions[0].type,
relPath: plan.actions[0].relPath,
reason: plan.actions[0].reason,
classification: plan.actions[0].classification,
originalHash: plan.actions[0].originalHash,
currentHash: plan.actions[0].currentHash,
},
{
migrationId: '2026-05-11-remove-old-hook',
type: 'remove-managed',
@@ -82,8 +115,9 @@ test('plans a pending migration against an unchanged managed file', () => {
classification: 'managed-pristine',
originalHash: sha256('managed hook\n'),
currentHash: sha256('managed hook\n'),
},
]);
}
);
assert.match(plan.actions[0].migrationChecksum, /^sha256:/);
} finally {
cleanup(configDir);
}
@@ -203,6 +237,7 @@ test('applies an unblocked plan with a journal and install-state update', () =>
const state = readInstallState(configDir);
assert.deepEqual(state.appliedMigrations.map((entry) => entry.id), ['2026-05-11-remove-old-hook']);
assert.match(state.appliedMigrations[0].checksum, /^sha256:/);
} finally {
cleanup(configDir);
}
@@ -369,6 +404,70 @@ test('reports rollback restore failures instead of swallowing them', () => {
}
});
test('rejects executable preserve-user actions because preservation blocks non-interactive apply', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
assert.throws(
() => applyInstallerMigrationPlan({
configDir,
plan: {
blocked: [],
actions: [
{
migrationId: '2026-05-11-preserve-user',
type: 'preserve-user',
relPath: 'hooks/custom-user-hook.js',
reason: 'unknown user hook',
classification: 'unknown',
originalHash: null,
currentHash: sha256('user hook\n'),
},
],
},
}),
/unsupported migration action type: preserve-user/
);
} finally {
cleanup(configDir);
}
});
test('keeps prior install state intact when a state write fails mid-write', () => {
const configDir = createTempInstall();
const originalWriteFileSync = fs.writeFileSync;
try {
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [{ id: 'already-safe', appliedAt: '2026-05-11T00:00:00.000Z' }],
});
fs.writeFileSync = (filePath, content, ...rest) => {
if (path.basename(filePath).startsWith(`${INSTALL_STATE_NAME}.tmp-`)) {
throw new Error('simulated temp state write failure');
}
return originalWriteFileSync(filePath, content, ...rest);
};
assert.throws(
() => writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [{ id: 'new-migration', appliedAt: '2026-05-11T00:00:01.000Z' }],
}),
/simulated temp state write failure/
);
} finally {
fs.writeFileSync = originalWriteFileSync;
}
try {
assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-safe']);
} finally {
cleanup(configDir);
}
});
test('skips migration records already present in install state', () => {
const configDir = createTempInstall();
try {
@@ -406,6 +505,83 @@ test('skips migration records already present in install state', () => {
}
});
test('refuses to plan an already-applied migration whose checksum changed', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [
{
id: '2026-05-11-remove-old-hook',
checksum: 'sha256:old-definition',
appliedAt: '2026-05-11T00:00:00.000Z',
journal: 'gsd-migration-journal/prior.json',
},
],
});
assert.throws(
() => planInstallerMigrations({
configDir,
migrations: [
{
id: '2026-05-11-remove-old-hook',
checksum: 'sha256:new-definition',
description: 'Remove retired hook',
plan: () => [],
},
],
}),
/applied migration checksum changed/
);
} finally {
cleanup(configDir);
}
});
test('ignores checksum drift for applied migrations outside the active runtime scope', () => {
const configDir = createTempInstall();
try {
writeManifest(configDir, {});
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: [
{
id: '2026-05-11-codex-only',
checksum: 'sha256:old-definition',
appliedAt: '2026-05-11T00:00:00.000Z',
journal: 'gsd-migration-journal/prior.json',
},
],
});
const plan = planInstallerMigrations({
configDir,
runtime: 'claude',
scope: 'global',
migrations: [
{
id: '2026-05-11-codex-only',
checksum: 'sha256:new-definition',
runtimes: ['codex'],
scopes: ['global'],
description: 'Codex-only migration',
plan: () => {
throw new Error('out-of-scope migration planner must not run');
},
},
],
});
assert.deepEqual(plan.pendingMigrationIds, []);
assert.deepEqual(plan.actions, []);
assert.deepEqual(plan.blocked, []);
} finally {
cleanup(configDir);
}
});
test('discovers migration records from a directory in filename order', () => {
const configDir = createTempInstall();
try {
@@ -501,6 +677,7 @@ test('runs discovered installer migrations against manifest-managed legacy orpha
const result = runInstallerMigrations({
configDir,
scope: 'global',
now: () => '2026-05-11T00:00:05.000Z',
});
@@ -512,3 +689,66 @@ test('runs discovered installer migrations against manifest-managed legacy orpha
cleanup(configDir);
}
});
test('runs a Codex legacy hooks.json cleanup migration without removing user hooks', () => {
const configDir = createTempInstall();
try {
writeFile(
configDir,
'hooks.json',
JSON.stringify({
SessionStart: [
legacyCodexHook(configDir),
userHook('node "/Users/example/bin/user-hook.js"'),
userHook('node "/Users/example/bin/gsd-check-update.js"'),
],
}, null, 2)
);
writeManifest(configDir, {});
const result = runInstallerMigrations({
configDir,
runtime: 'codex',
scope: 'global',
now: () => '2026-05-11T00:00:06.000Z',
});
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
assert.deepEqual(commands, [
'node "/Users/example/bin/user-hook.js"',
'node "/Users/example/bin/gsd-check-update.js"',
]);
assert.ok(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'));
} finally {
cleanup(configDir);
}
});
test('skips runtime-specific migration records for other runtimes', () => {
const configDir = createTempInstall();
try {
writeFile(
configDir,
'hooks.json',
JSON.stringify({
SessionStart: [legacyCodexHook(configDir)],
}, null, 2)
);
writeManifest(configDir, {});
const result = runInstallerMigrations({
configDir,
runtime: 'claude',
scope: 'global',
now: () => '2026-05-11T00:00:07.000Z',
});
const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8'));
assert.equal(hooksJson.SessionStart[0].hooks[0].command, `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`);
assert.equal(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'), false);
} finally {
cleanup(configDir);
}
});