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:
5
.changeset/gentle-jays-zip.md
Normal file
5
.changeset/gentle-jays-zip.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user