diff --git a/.changeset/bold-finches-leap.md b/.changeset/bold-finches-leap.md new file mode 100644 index 000000000..131cf2e70 --- /dev/null +++ b/.changeset/bold-finches-leap.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2354 +--- +**OpenCode's first-time install baseline now protects pre-existing files under the `commands/` directory, not just the legacy `command/` alias** — after #2329 moved OpenCode command materialization to `commands/`, the baseline scan that guards a machine's very first GSD-tracked install still only knew about the legacy `command/` directory, so a pre-existing, unrelated `commands/gsd-*.md` file was silently deleted by ordinary command materialization instead of blocking the install for an explicit keep/remove choice — the same protection `command/` already had. The scan now covers both directories. Kilo is unaffected and keeps using `command/`. diff --git a/.changeset/zesty-finches-jump.md b/.changeset/zesty-finches-jump.md new file mode 100644 index 000000000..96b75f1c4 --- /dev/null +++ b/.changeset/zesty-finches-jump.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2354 +--- +**OpenCode slash commands now install to the supported `commands/` directory instead of OpenCode's legacy `command/` alias** — GSD wrote all ~71 `/gsd-*` commands to `command/` (singular), which OpenCode's docs list only as a backwards-compatibility alias for the documented `commands/` (plural) convention. Commands now land in `~/.config/opencode/commands/` (global) and `.opencode/commands/` (local), and upgrading migrates the legacy directory, preserving any files you put there yourself. OpenCode currently resolves both names, so this is an alignment rather than a rescue — it takes GSD off a path the vendor may withdraw. Kilo is unaffected. diff --git a/.gitignore b/.gitignore index fdec0b734..3df1d9a5d 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,7 @@ build/ /gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs /gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs /gsd-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs +/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs /gsd-core/bin/lib/observability/logger.cjs /gsd-core/bin/lib/active-workstream-store.cjs /gsd-core/bin/lib/adr-parser.cjs diff --git a/bin/install.js b/bin/install.js index 64d5aa3a8..a7b389a19 100755 --- a/bin/install.js +++ b/bin/install.js @@ -9373,9 +9373,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { } } if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) { + // #2329: derive the manifest key prefix from the SAME descriptor value used + // to compute opencodeCommandDir above, instead of a separately-hardcoded + // literal — a divergence here would silently break the manifest even after + // the destSubpath descriptor is corrected (Generative Fix Divergence guard). + const flatCommandDirPrefix = _hostBehaviors(runtime).flatCommandDir || 'command'; for (const file of fs.readdirSync(opencodeCommandDir)) { if (file.startsWith('gsd-') && file.endsWith('.md')) { - manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file)); + manifest.files[flatCommandDirPrefix + '/' + file] = fileHash(path.join(opencodeCommandDir, file)); } } } diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index f18c39c5e..152393f2a 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -3,7 +3,7 @@ "role": "runtime", "version": "1.7.0-rc.6", "title": "OpenCode", - "description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.", + "description": "OpenCode — XDG-based config dir; flat commands/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.", "tier": "core", "requires": [], "engines": { @@ -25,7 +25,7 @@ "global": [ { "kind": "commands", - "destSubpath": "command", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, @@ -43,7 +43,7 @@ "local": [ { "kind": "commands", - "destSubpath": "command", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, @@ -88,7 +88,7 @@ "hostBehaviors": { "reapplyCommand": "/gsd-update --reapply", "attributionConfigResolver": "opencode", - "flatCommandDir": "command", + "flatCommandDir": "commands", "combinedFamilyInstall": true, "frontmatterDialect": "opencode", "nativePlugin": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b3e154ab..cd80f98af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -834,7 +834,7 @@ The migration-specific ownership and source snapshots live in | Runtime | Global root | Local root | Invocation surface | Agent surface | Config and hooks | | --- | --- | --- | --- | --- | --- | | Claude Code | `~/.claude` | `./.claude` | Global `skills/gsd-*/SKILL.md` (flat, #924); 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 | +| OpenCode | `~/.config/opencode` | `./.opencode` | `commands/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 | | Kimi CLI | First-existing generic root: `~/.config/agents` recommended, then `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not | Deferred and guarded | `skills/gsd-*/SKILL.md` (flat) invoked as `/skill:gsd-*` | `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*` YAML/prompt pairs | Explicit `kimi --agent-file /agents/gsd.yaml`; no GSD hooks or statusline | | Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` (flat) | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 58e3507ec..b04ebdfb4 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -121,7 +121,7 @@ This path is **additive** and changes nothing about the Claude Code plugin insta npx @opengsd/gsd-core@latest --opencode --global ``` -The installer writes four surfaces under `~/.config/opencode/` (XDG) or `~/.opencode/`: flat slash commands in `command/`, file-based subagents in `agents/`, on-demand skills in `skills//SKILL.md`, and a native plugin in `plugins/gsd-core.js`. It converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex — and emits each skill with spec-compliant frontmatter (`name` matching the skill directory plus a `description`). Skills are loaded on demand via OpenCode's native skill tool; commands remain invokable as `/gsd-*`. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes. +The installer writes four surfaces under `~/.config/opencode/` (XDG) or `~/.opencode/`: flat slash commands in `commands/` (plural — the directory OpenCode discovers slash commands from, #2329), file-based subagents in `agents/`, on-demand skills in `skills//SKILL.md`, and a native plugin in `plugins/gsd-core.js`. It converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex — and emits each skill with spec-compliant frontmatter (`name` matching the skill directory plus a `description`). Skills are loaded on demand via OpenCode's native skill tool; commands remain invokable as `/gsd-*`. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes. **GSD safety hooks on OpenCode.** OpenCode does not register lifecycle hooks the way Claude Code does (its `hooksSurface` is `none`), so GSD's prompt-injection guard, read-before-edit guard, injection scanner, and context monitor would otherwise be inert. The bundled plugin (`plugins/gsd-core.js`) closes that gap: OpenCode auto-discovers `plugins/*.{ts,js}` files under its config directory at startup and the adapter bridges OpenCode's event bus (`tool.execute.before`/`after`, `session.created`, `file.edited`) onto GSD's existing hook scripts, spawning them as subprocesses. No `opencode.json` entry is needed — the plugin is loaded by directory auto-discovery (the config `plugin` array is for npm packages only). A blocking hook aborts the tool call; an advisory hook surfaces its message without blocking. diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 0edc4c6b9..0978ec094 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -365,7 +365,7 @@ 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 | +| OpenCode | Flat markdown commands in `commands/gsd-*.md` (plural — OpenCode discovers slash commands from `commands/`, not the legacy singular `command/`, #2329); 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/), [Commands](https://opencode.ai/docs/commands/); docs published 2026-05, checked 2026-07-16 | | 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 | | Kimi CLI | Agent Skills in `skills/gsd-*/SKILL.md`; explicit custom agent YAML/prompt artifacts in `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*`; `gsd-core/` payload files referenced by generated skills; manifest, pristine, local-patch, and migration journal files from the normal installer safety pipeline | Global `KIMI_CONFIG_DIR`, explicit `--config-dir`, or first-existing generic skills root: `~/.config/agents` when `~/.config/agents/skills` exists or no generic skills root exists yet, otherwise `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not; `KIMI_CONFIG_DIR` and `--config-dir` are GSD write-location overrides and arbitrary roots require Kimi-side `--skills-dir` or `extra_skill_dirs` configuration for skill discovery; local `--kimi --local` is guarded and writes no project-level artifacts | GSD owns only generated `skills/gsd-*`, `agents/gsd.*`, `agents/subagents/gsd-*`, installed `gsd-core/` payload files, and manifest/preservation/migration records. GSD does not own Kimi config files, hooks, settings, rules, statusline, update-banner registration, or non-GSD Kimi skills/agents. Reinstall/update must preserve locally modified generated Kimi artifacts through manifest-backed `gsd-local-patches/`; uninstall removes only GSD-owned Kimi artifacts and preserves non-GSD user content. | [Agent Skills](https://moonshotai.github.io/kimi-cli/en/customization/skills.html), [Agents and Subagents](https://moonshotai.github.io/kimi-cli/en/customization/agents.html), [Tools](https://moonshotai.github.io/kimi-code/en/reference/tools.html); docs checked 2026-06-07 | | Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) | @@ -500,6 +500,8 @@ Each row corresponds to one migration record in `src/installer-migrations/`. | `2026-05-11-legacy-orphan-files` | `001-legacy-orphan-files.cts` | 1.50.0 | global, local | Yes | Removes manifest-managed legacy orphan hook files (`hooks/gsd-notify.sh`, `hooks/statusline.js`) retired by the installer. | | `2026-05-11-codex-legacy-hooks-json` | `002-codex-legacy-hooks-json.cts` | 1.50.0 | global, local | Yes | Removes legacy GSD hook registrations from Codex `hooks.json` after the `config.toml` migration. | | `2026-06-02-rename-get-shit-done-to-gsd-core` | `003-rename-get-shit-done-to-gsd-core.cts` | 1.2.0 | global, local | Yes | Removes managed files from the stale `get-shit-done/` runtime directory after the rename to `gsd-core/` (#604). User-added files are preserved; emptied directories may remain (framework limitation). | +| `2026-06-09-prune-stale-pristine-get-shit-done` | `004-prune-stale-pristine-snapshots.cts` | 1.4.3 | global, local | Yes | Removes stale `gsd-pristine/get-shit-done/` snapshot files left behind by migration 003, which caused false `verify-reapply-patches` failures (#934). +| `2026-07-17-opencode-baseline-commands-dir` | `005-opencode-baseline-commands-dir.cts` | 1.7.0 | global, local | No | Baselines pre-existing files under OpenCode's `commands/` (plural) directory during the first-time scan. #2329 moved OpenCode command materialization to `commands/`, but 000's `RUNTIME_SURFACES.opencode` is a shipped, immutable body that still only names the legacy `command/` alias, so this fix-forward migration widens the scanned surface. OpenCode only; Kilo is unaffected. | ## Prior Art diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c83da7fc..8000a5de9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,6 +129,7 @@ export default tseslint.config( 'gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs', 'gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs', 'gsd-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs', + 'gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs', 'gsd-core/bin/lib/observability/logger.cjs', 'gsd-core/bin/lib/active-workstream-store.cjs', 'gsd-core/bin/lib/adr-parser.cjs', diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index a08ab1b2e..ea7d2de2a 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -1925,7 +1925,7 @@ const capabilities = { "role": "runtime", "version": "1.7.0-rc.6", "title": "OpenCode", - "description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.", + "description": "OpenCode — XDG-based config dir; flat commands/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.", "tier": "core", "requires": [], "engines": { @@ -1947,7 +1947,7 @@ const capabilities = { "global": [ { "kind": "commands", - "destSubpath": "command", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, @@ -1965,7 +1965,7 @@ const capabilities = { "local": [ { "kind": "commands", - "destSubpath": "command", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, @@ -2010,7 +2010,7 @@ const capabilities = { "hostBehaviors": { "reapplyCommand": "/gsd-update --reapply", "attributionConfigResolver": "opencode", - "flatCommandDir": "command", + "flatCommandDir": "commands", "combinedFamilyInstall": true, "frontmatterDialect": "opencode", "nativePlugin": { @@ -4987,7 +4987,7 @@ const runtimes = { "role": "runtime", "version": "1.7.0-rc.6", "title": "OpenCode", - "description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.", + "description": "OpenCode — XDG-based config dir; flat commands/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.", "tier": "core", "requires": [], "engines": { @@ -5009,7 +5009,7 @@ const runtimes = { "global": [ { "kind": "commands", - "destSubpath": "command", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, @@ -5027,7 +5027,7 @@ const runtimes = { "local": [ { "kind": "commands", - "destSubpath": "command", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, @@ -5072,7 +5072,7 @@ const runtimes = { "hostBehaviors": { "reapplyCommand": "/gsd-update --reapply", "attributionConfigResolver": "opencode", - "flatCommandDir": "command", + "flatCommandDir": "commands", "combinedFamilyInstall": true, "frontmatterDialect": "opencode", "nativePlugin": { diff --git a/src/install-engine.cts b/src/install-engine.cts index 2404f5f04..34de44ade 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -27,6 +27,7 @@ import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs'); import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs'); import runtimeNamePolicy = require('./runtime-name-policy.cjs'); import installProfiles = require('./install-profiles.cjs'); +import installerMigrations = require('./installer-migrations.cjs'); import { posixNormalize } from './shell-command-projection.cjs'; const { processAttribution } = runtimeArtifactConversion; @@ -620,6 +621,10 @@ function installRuntimeArtifacts( // previously lived inline in bin/install.js. const behaviors = _hostBehaviors(runtime); if (behaviors.combinedFamilyInstall) { + // #2329: combined-family runtimes (OpenCode/Kilo) bypass + // _runLegacyInstallMigrations below entirely (early return), so their + // legacy-directory cleanup needs its own pre-materialization hook here. + _migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors); installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors); return; } @@ -937,6 +942,76 @@ function _installNativePluginIfDeclared( } } +// --------------------------------------------------------------------------- +// _migrateLegacyOpencodeCommandDir +// --------------------------------------------------------------------------- + +/** + * #2329: migrate a pre-fix OpenCode install's legacy singular `command/` + * command directory into the current descriptor-driven destination (plural + * `commands/` for OpenCode — the dir OpenCode actually discovers slash + * commands from; unaffected for Kilo, whose descriptor still declares + * `command`, so `currentName === LEGACY_NAME` short-circuits below). + * + * Runs BEFORE materialization writes the fresh command set to the new + * location (mirroring `_runLegacyInstallMigrations`'s ordering for the + * generic branch, which combined-family runtimes otherwise skip entirely). + * + * Ownership safety mirrors installer-migrations 003 + * (rename-get-shit-done-to-gsd-core): only files present, and unchanged or + * locally modified, in the PRIOR install manifest under the legacy + * `command/` key are removed here — the materialization call + * immediately following writes the current command set fresh into the new + * location, so removing the stale copies is safe. Anything not proven + * manifest-managed (unrelated user content someone dropped into `command/`) + * is left untouched, never deleted. The emptied legacy directory is removed + * only once nothing else is left inside it. + * + * Implemented as inline pre-materialization cleanup rather than a + * `src/installer-migrations/*.cts` record: the formal migrations framework + * only ever DELETES individual files (never directories, and never a + * relocate/move primitive — see docs/installer-migrations.md's Action + * Types), so the empty-directory removal below would need this same + * hand-written glue regardless. It also intentionally is NOT reachable via + * combinedFamilyInstall's early return above `_runLegacyInstallMigrations`, + * matching the existing precedent that OpenCode/Kilo's bespoke install path + * owns its own legacy cleanup rather than routing through the generic + * layout-driven migrations hook. + */ +function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, behaviors: any): void { + const LEGACY_NAME = 'command'; + const currentName = behaviors.flatCommandDir || LEGACY_NAME; + if (currentName === LEGACY_NAME) return; // e.g. Kilo — legacy IS the current location; nothing to migrate + const legacyDir = path.join(configDir, LEGACY_NAME); + if (!fs.existsSync(legacyDir)) return; + // Never follow a symlinked legacy dir out of configDir. + if (fs.lstatSync(legacyDir).isSymbolicLink()) return; + + const manifest = installerMigrations.readInstallManifest(configDir); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(legacyDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + // command/ is a flat directory of gsd-*.md files; skip anything that + // isn't a plain file (nested dirs, symlinks) rather than guess intent. + if (!entry.isFile()) continue; + const relPath = `${LEGACY_NAME}/${entry.name}`; + const { classification } = installerMigrations.classifyArtifact(configDir, relPath, manifest); + if (classification === 'managed-pristine' || classification === 'managed-modified') { + try { fs.unlinkSync(path.join(legacyDir, entry.name)); } catch { /* best-effort */ } + } + // 'unknown' (not manifest-tracked) is left untouched — GSD cannot prove + // ownership, so it must never be deleted as collateral damage. + } + + try { + if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir); + } catch { /* best-effort — a non-empty or otherwise-busy dir is left in place */ } +} + // --------------------------------------------------------------------------- // installOpencodeFamilyArtifacts // --------------------------------------------------------------------------- @@ -981,7 +1056,17 @@ function installOpencodeFamilyArtifacts( homeDir: posixNormalize(os.homedir()), }); - const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command'); + // #2329: destDir is derived from the SAME hostBehaviors.flatCommandDir + // descriptor value read by writeManifest's manifest-key prefix and by + // resolveRuntimeArtifactLayout's commands-kind destSubpath — a hardcoded + // literal here would silently diverge from the descriptor the moment either + // is edited (Generative Fix Divergence guard). OpenCode uses 'commands' + // (plural, the dir OpenCode actually discovers slash commands from); Kilo + // keeps its own descriptor value ('command', singular) unchanged. + const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome( + configDir, + behaviors.flatCommandDir || 'command', + ); installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution); installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution); diff --git a/src/installer-migrations/005-opencode-baseline-commands-dir.cts b/src/installer-migrations/005-opencode-baseline-commands-dir.cts new file mode 100644 index 000000000..33ee1ed5d --- /dev/null +++ b/src/installer-migrations/005-opencode-baseline-commands-dir.cts @@ -0,0 +1,187 @@ +/** + * Installer migration 005: baseline pre-existing OpenCode commands/ (plural) + * files during the first-time installer migration baseline scan (#2329 + * follow-up). + * + * Background: #2329 moved OpenCode's slash-command install target from the + * legacy singular `command/` alias to the documented plural `commands/` + * convention. The first-time baseline scan migration + * (2026-05-11-first-time-baseline-scan, 000-first-time-baseline.cts) is a + * SHIPPED migration body — docs/installer-migrations.md#state-files requires + * shipped migration bodies stay immutable so an already-applied migration's + * checksum never drifts for a user who ran it before this fix (issue #670). + * Its RUNTIME_SURFACES.opencode list still only names the legacy `command` + * directory, so the baseline scan never classified pre-existing files under + * `commands/` before this fix. + * + * Consequence proven by probe (see tests/installer-migrations.test.cjs): a + * pre-existing, unmanifested file at `commands/gsd-.md` that predates + * any GSD install is silently destroyed by ordinary OpenCode command + * materialization (which unconditionally removes every `gsd-*.md` file under + * its destination before writing the fresh set) with a clean exit code — no + * report, no backup, no prompt. The identical scenario under the + * already-covered legacy `command/` surface instead halts the install with a + * blocked `prompt-user` action, exactly as designed. This migration closes + * that gap for `commands/` without editing 000's shipped body. + * + * This is a NEW fix-forward migration id (per + * docs/installer-migrations.md#state-files) rather than an edit to 000: an + * already-applied migration never re-runs, so editing 000 would only protect + * fresh installs and leave every machine that already applied 000 + * permanently unprotected for `commands/`. A new id runs for both + * populations (installs that never ran a baseline scan AND installs that + * already applied the original 000 scan) and drifts no checksum. + * + * Scope: OpenCode only. Kilo shares the same combined-family install path, + * but its command directory descriptor is still the singular `command/` + * (already covered by 000's RUNTIME_SURFACES.kilo), so this migration must + * never touch Kilo installs — enforced by the `runtimes: ['opencode']` + * scoping below. + * + * Classification mirrors 000-first-time-baseline.cts exactly (record-baseline + * for manifest-proven files, prompt-user for stale-GSD-looking unmanifested + * files, baseline-preserve-user for everything else) — this migration only + * widens the surface scanned, not the classification policy. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const SURFACE = 'commands'; + +interface ClassifiedArtifact { + classification: string; + originalHash?: string | null; + currentHash?: string | null; + [key: string]: unknown; +} + +interface BaselineAction { + type: string; + relPath: string; + reason: string; + classification?: string; + originalHash?: string | null; + currentHash?: string | null; + prompt?: string; + choices?: string[]; +} + +interface PlanContext { + configDir: string; + baselineScan?: boolean; + classifyArtifact: (relPath: string) => ClassifiedArtifact; +} + +interface InstallerMigration { + id: string; + title: string; + description: string; + introducedIn: string; + runtimes: string[]; + scopes: string[]; + destructive: boolean; + plan: (ctx: PlanContext) => BaselineAction[]; +} + +function normalizeRelPath(relPath: string): string { + return relPath.replace(/\\/g, '/').replace(/^\/+/, ''); +} + +function walkFiles(root: string, relDir: string, files: Set): void { + const dir = path.join(root, relDir); + if (!fs.existsSync(dir)) return; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const relPath = path.posix.join(relDir, entry.name); + if (entry.isDirectory()) { + walkFiles(root, relPath, files); + } else if (entry.isFile()) { + files.add(normalizeRelPath(relPath)); + } + } +} + +function scanCommandsSurface(configDir: string): string[] { + const relPaths = new Set(); + const fullPath = path.join(configDir, SURFACE); + if (!fs.existsSync(fullPath)) return []; + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + walkFiles(configDir, SURFACE, relPaths); + } else if (stat.isFile()) { + relPaths.add(SURFACE); + } + return [...relPaths]; +} + +function isStaleGsdLookingPath(relPath: string): boolean { + return /^gsd[-_]/.test(path.posix.basename(relPath)); +} + +function baselineActionRank(action: BaselineAction): number { + if (action.type === 'record-baseline') return 0; + if (action.type === 'baseline-preserve-user') return 1; + return 2; +} + +const migration: InstallerMigration = { + id: '2026-07-17-opencode-baseline-commands-dir', + title: "Baseline OpenCode's commands/ directory in the first-time scan (#2329 follow-up)", + description: + "Classify pre-existing files under OpenCode's commands/ (plural) directory during the first-time installer " + + 'migration baseline scan. #2329 moved OpenCode command materialization from the legacy command/ alias to ' + + "commands/, but the shipped 000-first-time-baseline.cts RUNTIME_SURFACES.opencode list is immutable and still " + + 'only names command/; this fix-forward migration widens the scanned surface without editing the shipped body.', + introducedIn: '1.7.0', + runtimes: ['opencode'], + scopes: ['global', 'local'], + destructive: false, + plan: ({ configDir, baselineScan, classifyArtifact }: PlanContext): BaselineAction[] => { + if (!baselineScan) return []; + + const actions: BaselineAction[] = []; + for (const relPath of scanCommandsSurface(configDir)) { + const artifact = classifyArtifact(relPath); + if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') { + actions.push({ + type: 'record-baseline', + relPath, + reason: 'existing manifest-managed OpenCode commands/ file included in first-time migration baseline', + }); + continue; + } + + const currentHash = artifact.currentHash ?? null; + if (isStaleGsdLookingPath(relPath)) { + actions.push({ + type: 'prompt-user', + relPath, + reason: 'GSD-looking file is not proven manifest-managed and needs explicit user choice', + classification: 'stale-gsd-looking', + originalHash: artifact.originalHash ?? null, + currentHash, + prompt: 'Choose whether to remove this stale-looking GSD artifact or keep it as user-owned.', + choices: ['keep', 'remove'], + }); + continue; + } + + actions.push({ + type: 'baseline-preserve-user', + relPath, + reason: 'unknown OpenCode commands/ file preserved by first-time migration baseline', + classification: artifact.classification, + originalHash: artifact.originalHash ?? null, + currentHash, + }); + } + + return actions.sort( + (left, right) => + baselineActionRank(left) - baselineActionRank(right) || left.relPath.localeCompare(right.relPath) + ); + }, +}; + +export = migration; diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 9a9a6be7a..f4d525e08 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -35,77 +35,77 @@ "agents/gsd-ui-researcher.md": "ecb617901cb7ad06", "agents/gsd-user-profiler.md": "d825b4c0a6431f8b", "agents/gsd-verifier.md": "b9f2e801f9395a35", - "command/gsd-add-tests.md": "b9cd93ed01945f75", - "command/gsd-ai-integration-phase.md": "9d4bc4dcce7f1ee0", - "command/gsd-audit-fix.md": "5615403843d5e491", - "command/gsd-audit-milestone.md": "4c700c081f3918ba", - "command/gsd-audit-uat.md": "47dce58d5823da8d", - "command/gsd-autonomous.md": "d6444c6d39585b07", - "command/gsd-capture.md": "a1085d451e138028", - "command/gsd-cleanup.md": "66b219076f1e2aa3", - "command/gsd-code-review.md": "3256032e666821b5", - "command/gsd-complete-milestone.md": "92ee70af4d6235bb", - "command/gsd-config.md": "11045b66313fb788", - "command/gsd-debug.md": "cb6ac21129dff67f", - "command/gsd-discuss-phase.md": "a2a59956edf33a2c", - "command/gsd-docs-update.md": "668979b6c56708c0", - "command/gsd-eval-review.md": "28499776a3fde3ad", - "command/gsd-execute-phase.md": "da51f21998c75175", - "command/gsd-explore.md": "7618219025e28c15", - "command/gsd-extract-learnings.md": "42fd40385c7a7a3b", - "command/gsd-fast.md": "d1fcda0719ae90be", - "command/gsd-forensics.md": "051270e8c76c1b13", - "command/gsd-graphify.md": "1a4ec781f74862e2", - "command/gsd-health.md": "66d298ed71d57cb0", - "command/gsd-help.md": "a26c6ba391002635", - "command/gsd-import.md": "e1bff34deb61c802", - "command/gsd-inbox.md": "cc850bab897aed1f", - "command/gsd-ingest-docs.md": "b47c812c247e58a4", - "command/gsd-manager.md": "86ece154b34b6d1b", - "command/gsd-map-codebase.md": "9aba6b4eed565912", - "command/gsd-mempalace-capture.md": "8b6bc24d7baf1e9b", - "command/gsd-mempalace-recall.md": "d358cdd89c935877", - "command/gsd-milestone-summary.md": "86a827271bc647c5", - "command/gsd-mvp-phase.md": "d45fd76cb9cff3ef", - "command/gsd-new-milestone.md": "613902b46d2ba6ef", - "command/gsd-new-project.md": "0086f0de26002219", - "command/gsd-next.md": "b0ef81652d72d894", - "command/gsd-ns-context.md": "9a2b55b64f2e1e4b", - "command/gsd-ns-ideate.md": "27e7fcdc69eeeb5f", - "command/gsd-ns-manage.md": "eded59052ca4b240", - "command/gsd-ns-project.md": "c56395edb9bc7467", - "command/gsd-ns-review.md": "61fe33e28eb1b961", - "command/gsd-ns-workflow.md": "13c1ab91080c0cc3", - "command/gsd-onboard.md": "3aafaeb5d3941efe", - "command/gsd-pause-work.md": "bb5bf91a2e3e480e", - "command/gsd-phase.md": "6bcda1539f949d5a", - "command/gsd-plan-phase.md": "2c673275cb9517fe", - "command/gsd-plan-review-convergence.md": "88c4284aa8b3dd7e", - "command/gsd-pr-branch.md": "31fca4f1d6c4ee62", - "command/gsd-profile-user.md": "725c14ae7203b5b6", - "command/gsd-progress.md": "001f2754c427a0a9", - "command/gsd-quick.md": "07c02ab7547aec8e", - "command/gsd-resume-work.md": "3ac18b2a8cc41066", - "command/gsd-review-backlog.md": "1e99d6a7a3806fa5", - "command/gsd-review.md": "97bf8bc6ffff9475", - "command/gsd-secure-phase.md": "b536ad6e68af3a26", - "command/gsd-settings.md": "a3d2cb48b06c9b90", - "command/gsd-ship.md": "062d0bb5656fa31b", - "command/gsd-sketch.md": "1af54bb16c370798", - "command/gsd-spec-phase.md": "b92bca076ca2c5c6", - "command/gsd-spike.md": "1b2fa4b468e831dc", - "command/gsd-stats.md": "51af934859f87623", - "command/gsd-surface.md": "e559ca6733ce288f", - "command/gsd-thread.md": "ae3b000bee0ee1e0", - "command/gsd-ui-phase.md": "21c9c043d813dc0a", - "command/gsd-ui-review.md": "ef36603b519e8fe8", - "command/gsd-ultraplan-phase.md": "d18d13428ae3f8e6", - "command/gsd-undo.md": "dbbf9423ce795b89", - "command/gsd-update.md": "df2be717088fe8d0", - "command/gsd-validate-phase.md": "f320705816d725f3", - "command/gsd-verify-work.md": "f23fff8e6d71f704", - "command/gsd-workspace.md": "1e581bdb33bc8f55", - "command/gsd-workstreams.md": "5e57eed1881c3891", + "commands/gsd-add-tests.md": "b9cd93ed01945f75", + "commands/gsd-ai-integration-phase.md": "9d4bc4dcce7f1ee0", + "commands/gsd-audit-fix.md": "5615403843d5e491", + "commands/gsd-audit-milestone.md": "4c700c081f3918ba", + "commands/gsd-audit-uat.md": "47dce58d5823da8d", + "commands/gsd-autonomous.md": "d6444c6d39585b07", + "commands/gsd-capture.md": "a1085d451e138028", + "commands/gsd-cleanup.md": "66b219076f1e2aa3", + "commands/gsd-code-review.md": "3256032e666821b5", + "commands/gsd-complete-milestone.md": "92ee70af4d6235bb", + "commands/gsd-config.md": "11045b66313fb788", + "commands/gsd-debug.md": "cb6ac21129dff67f", + "commands/gsd-discuss-phase.md": "a2a59956edf33a2c", + "commands/gsd-docs-update.md": "668979b6c56708c0", + "commands/gsd-eval-review.md": "28499776a3fde3ad", + "commands/gsd-execute-phase.md": "da51f21998c75175", + "commands/gsd-explore.md": "7618219025e28c15", + "commands/gsd-extract-learnings.md": "42fd40385c7a7a3b", + "commands/gsd-fast.md": "d1fcda0719ae90be", + "commands/gsd-forensics.md": "051270e8c76c1b13", + "commands/gsd-graphify.md": "1a4ec781f74862e2", + "commands/gsd-health.md": "66d298ed71d57cb0", + "commands/gsd-help.md": "a26c6ba391002635", + "commands/gsd-import.md": "e1bff34deb61c802", + "commands/gsd-inbox.md": "cc850bab897aed1f", + "commands/gsd-ingest-docs.md": "b47c812c247e58a4", + "commands/gsd-manager.md": "86ece154b34b6d1b", + "commands/gsd-map-codebase.md": "9aba6b4eed565912", + "commands/gsd-mempalace-capture.md": "8b6bc24d7baf1e9b", + "commands/gsd-mempalace-recall.md": "d358cdd89c935877", + "commands/gsd-milestone-summary.md": "86a827271bc647c5", + "commands/gsd-mvp-phase.md": "d45fd76cb9cff3ef", + "commands/gsd-new-milestone.md": "613902b46d2ba6ef", + "commands/gsd-new-project.md": "0086f0de26002219", + "commands/gsd-next.md": "b0ef81652d72d894", + "commands/gsd-ns-context.md": "9a2b55b64f2e1e4b", + "commands/gsd-ns-ideate.md": "27e7fcdc69eeeb5f", + "commands/gsd-ns-manage.md": "eded59052ca4b240", + "commands/gsd-ns-project.md": "c56395edb9bc7467", + "commands/gsd-ns-review.md": "61fe33e28eb1b961", + "commands/gsd-ns-workflow.md": "13c1ab91080c0cc3", + "commands/gsd-onboard.md": "3aafaeb5d3941efe", + "commands/gsd-pause-work.md": "bb5bf91a2e3e480e", + "commands/gsd-phase.md": "6bcda1539f949d5a", + "commands/gsd-plan-phase.md": "2c673275cb9517fe", + "commands/gsd-plan-review-convergence.md": "88c4284aa8b3dd7e", + "commands/gsd-pr-branch.md": "31fca4f1d6c4ee62", + "commands/gsd-profile-user.md": "725c14ae7203b5b6", + "commands/gsd-progress.md": "001f2754c427a0a9", + "commands/gsd-quick.md": "07c02ab7547aec8e", + "commands/gsd-resume-work.md": "3ac18b2a8cc41066", + "commands/gsd-review-backlog.md": "1e99d6a7a3806fa5", + "commands/gsd-review.md": "97bf8bc6ffff9475", + "commands/gsd-secure-phase.md": "b536ad6e68af3a26", + "commands/gsd-settings.md": "a3d2cb48b06c9b90", + "commands/gsd-ship.md": "062d0bb5656fa31b", + "commands/gsd-sketch.md": "1af54bb16c370798", + "commands/gsd-spec-phase.md": "b92bca076ca2c5c6", + "commands/gsd-spike.md": "1b2fa4b468e831dc", + "commands/gsd-stats.md": "51af934859f87623", + "commands/gsd-surface.md": "e559ca6733ce288f", + "commands/gsd-thread.md": "ae3b000bee0ee1e0", + "commands/gsd-ui-phase.md": "21c9c043d813dc0a", + "commands/gsd-ui-review.md": "ef36603b519e8fe8", + "commands/gsd-ultraplan-phase.md": "d18d13428ae3f8e6", + "commands/gsd-undo.md": "dbbf9423ce795b89", + "commands/gsd-update.md": "df2be717088fe8d0", + "commands/gsd-validate-phase.md": "f320705816d725f3", + "commands/gsd-verify-work.md": "f23fff8e6d71f704", + "commands/gsd-workspace.md": "1e581bdb33bc8f55", + "commands/gsd-workstreams.md": "5e57eed1881c3891", "gsd-core/.gsd-runtime": "0d74cc0a7af2a416", "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index bceedbaae..b91d0f4aa 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -35,77 +35,77 @@ "agents/gsd-ui-researcher.md", "agents/gsd-user-profiler.md", "agents/gsd-verifier.md", - "command/gsd-add-tests.md", - "command/gsd-ai-integration-phase.md", - "command/gsd-audit-fix.md", - "command/gsd-audit-milestone.md", - "command/gsd-audit-uat.md", - "command/gsd-autonomous.md", - "command/gsd-capture.md", - "command/gsd-cleanup.md", - "command/gsd-code-review.md", - "command/gsd-complete-milestone.md", - "command/gsd-config.md", - "command/gsd-debug.md", - "command/gsd-discuss-phase.md", - "command/gsd-docs-update.md", - "command/gsd-eval-review.md", - "command/gsd-execute-phase.md", - "command/gsd-explore.md", - "command/gsd-extract-learnings.md", - "command/gsd-fast.md", - "command/gsd-forensics.md", - "command/gsd-graphify.md", - "command/gsd-health.md", - "command/gsd-help.md", - "command/gsd-import.md", - "command/gsd-inbox.md", - "command/gsd-ingest-docs.md", - "command/gsd-manager.md", - "command/gsd-map-codebase.md", - "command/gsd-mempalace-capture.md", - "command/gsd-mempalace-recall.md", - "command/gsd-milestone-summary.md", - "command/gsd-mvp-phase.md", - "command/gsd-new-milestone.md", - "command/gsd-new-project.md", - "command/gsd-next.md", - "command/gsd-ns-context.md", - "command/gsd-ns-ideate.md", - "command/gsd-ns-manage.md", - "command/gsd-ns-project.md", - "command/gsd-ns-review.md", - "command/gsd-ns-workflow.md", - "command/gsd-onboard.md", - "command/gsd-pause-work.md", - "command/gsd-phase.md", - "command/gsd-plan-phase.md", - "command/gsd-plan-review-convergence.md", - "command/gsd-pr-branch.md", - "command/gsd-profile-user.md", - "command/gsd-progress.md", - "command/gsd-quick.md", - "command/gsd-resume-work.md", - "command/gsd-review-backlog.md", - "command/gsd-review.md", - "command/gsd-secure-phase.md", - "command/gsd-settings.md", - "command/gsd-ship.md", - "command/gsd-sketch.md", - "command/gsd-spec-phase.md", - "command/gsd-spike.md", - "command/gsd-stats.md", - "command/gsd-surface.md", - "command/gsd-thread.md", - "command/gsd-ui-phase.md", - "command/gsd-ui-review.md", - "command/gsd-ultraplan-phase.md", - "command/gsd-undo.md", - "command/gsd-update.md", - "command/gsd-validate-phase.md", - "command/gsd-verify-work.md", - "command/gsd-workspace.md", - "command/gsd-workstreams.md", + "commands/gsd-add-tests.md", + "commands/gsd-ai-integration-phase.md", + "commands/gsd-audit-fix.md", + "commands/gsd-audit-milestone.md", + "commands/gsd-audit-uat.md", + "commands/gsd-autonomous.md", + "commands/gsd-capture.md", + "commands/gsd-cleanup.md", + "commands/gsd-code-review.md", + "commands/gsd-complete-milestone.md", + "commands/gsd-config.md", + "commands/gsd-debug.md", + "commands/gsd-discuss-phase.md", + "commands/gsd-docs-update.md", + "commands/gsd-eval-review.md", + "commands/gsd-execute-phase.md", + "commands/gsd-explore.md", + "commands/gsd-extract-learnings.md", + "commands/gsd-fast.md", + "commands/gsd-forensics.md", + "commands/gsd-graphify.md", + "commands/gsd-health.md", + "commands/gsd-help.md", + "commands/gsd-import.md", + "commands/gsd-inbox.md", + "commands/gsd-ingest-docs.md", + "commands/gsd-manager.md", + "commands/gsd-map-codebase.md", + "commands/gsd-mempalace-capture.md", + "commands/gsd-mempalace-recall.md", + "commands/gsd-milestone-summary.md", + "commands/gsd-mvp-phase.md", + "commands/gsd-new-milestone.md", + "commands/gsd-new-project.md", + "commands/gsd-next.md", + "commands/gsd-ns-context.md", + "commands/gsd-ns-ideate.md", + "commands/gsd-ns-manage.md", + "commands/gsd-ns-project.md", + "commands/gsd-ns-review.md", + "commands/gsd-ns-workflow.md", + "commands/gsd-onboard.md", + "commands/gsd-pause-work.md", + "commands/gsd-phase.md", + "commands/gsd-plan-phase.md", + "commands/gsd-plan-review-convergence.md", + "commands/gsd-pr-branch.md", + "commands/gsd-profile-user.md", + "commands/gsd-progress.md", + "commands/gsd-quick.md", + "commands/gsd-resume-work.md", + "commands/gsd-review-backlog.md", + "commands/gsd-review.md", + "commands/gsd-secure-phase.md", + "commands/gsd-settings.md", + "commands/gsd-ship.md", + "commands/gsd-sketch.md", + "commands/gsd-spec-phase.md", + "commands/gsd-spike.md", + "commands/gsd-stats.md", + "commands/gsd-surface.md", + "commands/gsd-thread.md", + "commands/gsd-ui-phase.md", + "commands/gsd-ui-review.md", + "commands/gsd-ultraplan-phase.md", + "commands/gsd-undo.md", + "commands/gsd-update.md", + "commands/gsd-validate-phase.md", + "commands/gsd-verify-work.md", + "commands/gsd-workspace.md", + "commands/gsd-workstreams.md", "gsd-core/.gsd-runtime", "gsd-core/VERSION", "gsd-core/bin/check-latest-version.cjs", diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index adf1883e9..6b1498bb5 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -370,14 +370,19 @@ describe('installRuntimeArtifacts — cline skills (#782)', () => { }); describe('installRuntimeArtifacts — opencode / kilo flat commands', () => { + // #2329: OpenCode discovers commands from the PLURAL `commands/` dir — the + // singular `command/` made all /gsd-* commands invisible to OpenCode. Kilo + // is deliberately unaffected and still uses the singular `command/` dir + // (see tests/opencode-command-dir-plural.test.cjs). + const RUNTIME_COMMAND_DIRS = { opencode: 'commands', kilo: 'command' }; for (const runtime of ['opencode', 'kilo']) { - test(`${runtime}: command/gsd-help.md exists`, (t) => { + test(`${runtime}: ${RUNTIME_COMMAND_DIRS[runtime]}/gsd-help.md exists`, (t) => { const configDir = createTempDir(`gsd-ial-${runtime}-`); t.after(() => cleanup(configDir)); installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE); - const commandDir = path.join(configDir, 'command'); + const commandDir = path.join(configDir, RUNTIME_COMMAND_DIRS[runtime]); assert.ok(fs.existsSync(commandDir)); assert.ok(fs.existsSync(path.join(commandDir, 'gsd-help.md'))); }); diff --git a/tests/installer-migration-install.integration.test.cjs b/tests/installer-migration-install.integration.test.cjs index abdbaae3a..0428bbf40 100644 --- a/tests/installer-migration-install.integration.test.cjs +++ b/tests/installer-migration-install.integration.test.cjs @@ -38,7 +38,10 @@ const RUNTIME_INSTALL_CONTRACTS = { // #1821: Kilo (hooksSurface:none, no plugin surface) no longer receives the // dead hook scripts or the CommonJS package.json marker. kilo: { surface: 'flat-command', settings: false, packageJson: false }, - opencode: { surface: 'flat-command', settings: true, packageJson: true }, + // #2329: OpenCode discovers commands from the PLURAL `commands/` dir — the + // singular `command/` (still correct for Kilo) made all /gsd-* commands + // invisible to OpenCode. commandDirName overrides the flat-command default. + opencode: { surface: 'flat-command', settings: true, packageJson: true, commandDirName: 'commands' }, // #2102 Stage 1/2: pi is a PLUGIN-ONLY install (hostBehaviors.pluginOnlyInstall) // for commands/agents/skills — NO commands/, agents/, or skills/ dir. pi's // /gsd command is registered programmatically by the native extension @@ -272,9 +275,11 @@ function assertFreshInstallContract(runtime, targetDir) { 'Hermes should install the nested GSD category description' ); } else if (contract.surface === 'flat-command') { + // #2329: dir name is per-runtime (opencode='commands', kilo stays 'command'). + const commandDirName = contract.commandDirName || 'command'; assert.ok( - listDirNames(targetDir, 'command').some((name) => name.startsWith('gsd-') && name.endsWith('.md')), - `${runtime} should install flattened command markdown files` + listDirNames(targetDir, commandDirName).some((name) => name.startsWith('gsd-') && name.endsWith('.md')), + `${runtime} should install flattened command markdown files in ${commandDirName}/` ); } else if (contract.surface === 'plugin-only') { // #2102 Stage 1/2: pi — PLUGIN-ONLY install for commands/agents/skills diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index 3c01bc2f9..d1b30279f 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -17,6 +17,7 @@ const { writeInstallState, } = require('../gsd-core/bin/lib/installer-migrations.cjs'); const firstTimeBaselineMigration = require('../gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs'); +const opencodeBaselineCommandsDirMigration = require('../gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs'); function createTempInstall() { return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-installer-migrations-')); @@ -315,6 +316,194 @@ test('records known generated agent artifacts so profile cleanup can remove them } }); +// --------------------------------------------------------------------------- +// Migration 005: OpenCode commands/ (plural) baseline scan (#2329 follow-up) +// +// 000-first-time-baseline.cts's RUNTIME_SURFACES.opencode is a shipped, +// immutable body that still only names the legacy singular `command/` +// directory (see docs/installer-migrations.md#state-files). These tests +// pin migration 005's widened scan of the plural `commands/` surface. +// --------------------------------------------------------------------------- + +test('baselines pre-existing OpenCode commands/ files: managed, unknown, and stale-GSD-looking', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n'); + writeFile(configDir, 'commands/my-custom-command.md', 'user command\n'); + writeFile(configDir, 'commands/gsd-retired-command.md', 'stale gsd-looking file, not in manifest\n'); + writeManifest(configDir, { + 'commands/gsd-plan-phase.md': sha256('managed command\n'), + }); + + const result = runInstallerMigrations({ + configDir, + runtime: 'opencode', + scope: 'global', + migrations: [opencodeBaselineCommandsDirMigration], + baselineScan: true, + now: () => '2026-07-17T00:00:00.000Z', + }); + + assert.deepEqual( + result.plan.actions.map((action) => ({ + type: action.type, + relPath: action.relPath, + classification: action.classification, + })), + [ + { + type: 'record-baseline', + relPath: 'commands/gsd-plan-phase.md', + classification: 'managed-pristine', + }, + { + type: 'baseline-preserve-user', + relPath: 'commands/my-custom-command.md', + classification: 'unknown', + }, + { + type: 'prompt-user', + relPath: 'commands/gsd-retired-command.md', + classification: 'stale-gsd-looking', + }, + ] + ); + // The stale-GSD-looking file blocks the plan (needs explicit user choice), + // so nothing was applied and no install state was written yet. + assert.deepEqual(result.appliedMigrationIds, []); + assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false); + // Every file on disk is untouched — baseline-preserve-user/record-baseline/ + // prompt-user are all non-mutating classification actions. + assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n'); + assert.equal(fs.readFileSync(path.join(configDir, 'commands/my-custom-command.md'), 'utf8'), 'user command\n'); + assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-retired-command.md'), 'utf8'), 'stale gsd-looking file, not in manifest\n'); + } finally { + cleanup(configDir); + } +}); + +test('OpenCode commands/ baseline is idempotent — a second run does not re-plan already-applied files', () => { + const configDir = createTempInstall(); + try { + writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n'); + writeFile(configDir, 'commands/my-custom-command.md', 'user command\n'); + writeManifest(configDir, { + 'commands/gsd-plan-phase.md': sha256('managed command\n'), + }); + + const first = runInstallerMigrations({ + configDir, + runtime: 'opencode', + scope: 'global', + migrations: [opencodeBaselineCommandsDirMigration], + baselineScan: true, + now: () => '2026-07-17T00:00:01.000Z', + }); + assert.deepEqual(first.appliedMigrationIds, ['2026-07-17-opencode-baseline-commands-dir']); + assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [ + '2026-07-17-opencode-baseline-commands-dir', + ]); + + // Second run: the migration id is now applied, so it must never re-run, + // regardless of what baselineScan is passed. + const second = runInstallerMigrations({ + configDir, + runtime: 'opencode', + scope: 'global', + migrations: [opencodeBaselineCommandsDirMigration], + baselineScan: true, + now: () => '2026-07-17T00:00:02.000Z', + }); + assert.deepEqual(second.appliedMigrationIds, []); + assert.deepEqual(second.plan.actions, []); + assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), [ + '2026-07-17-opencode-baseline-commands-dir', + ]); + // Files remain untouched across both runs. + assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n'); + assert.equal(fs.readFileSync(path.join(configDir, 'commands/my-custom-command.md'), 'utf8'), 'user command\n'); + } finally { + cleanup(configDir); + } +}); + +test('OpenCode commands/ baseline migration is scoped to opencode and never plans for Kilo', () => { + const configDir = createTempInstall(); + try { + // Kilo's descriptor keeps the singular `command/` dir; a `commands/` (plural) + // directory here would be unrelated to Kilo's install surface. This proves the + // migration's `runtimes: ['opencode']` scoping keeps Kilo installs untouched. + writeFile(configDir, 'commands/gsd-plan-phase.md', 'managed command\n'); + writeFile(configDir, 'command/gsd-plan-phase.md', 'kilo managed command\n'); + writeManifest(configDir, { + 'commands/gsd-plan-phase.md': sha256('managed command\n'), + 'command/gsd-plan-phase.md': sha256('kilo managed command\n'), + }); + + const result = runInstallerMigrations({ + configDir, + runtime: 'kilo', + scope: 'global', + migrations: [opencodeBaselineCommandsDirMigration], + baselineScan: true, + now: () => '2026-07-17T00:00:03.000Z', + }); + + // migrationMatchesContext filters this migration out entirely for kilo + // (runtimes: ['opencode']) before plan() is ever invoked: it is not + // "pending", produces zero actions, is never applied, and no install-state + // file is written for this run at all. + assert.deepEqual(result.plan.actions, []); + assert.deepEqual(result.appliedMigrationIds, []); + assert.equal(fs.existsSync(path.join(configDir, INSTALL_STATE_NAME)), false); + assert.equal(fs.readFileSync(path.join(configDir, 'commands/gsd-plan-phase.md'), 'utf8'), 'managed command\n'); + assert.equal(fs.readFileSync(path.join(configDir, 'command/gsd-plan-phase.md'), 'utf8'), 'kilo managed command\n'); + } finally { + cleanup(configDir); + } +}); + +test('regression (#2329 follow-up): pre-existing unmanifested commands/gsd-*.md is no longer silently destroyed', () => { + const configDir = createTempInstall(); + try { + // Simulates a pre-existing, non-GSD file sitting under OpenCode's commands/ + // directory before GSD's first-ever migration-tracked run against this + // configDir (no manifest, no install state yet). + writeFile(configDir, 'commands/gsd-retired-plan.md', 'pre-existing file, not GSD-written\n'); + + // Full default migration set (all shipped migrations, including 000 AND 005), + // matching production: bin/install.js calls runInstallerMigrations with no + // explicit `migrations` override. + const result = runInstallerMigrations({ + configDir, + runtime: 'opencode', + scope: 'global', + baselineScan: true, + now: () => '2026-07-17T00:00:04.000Z', + }); + + // Before this fix, RUNTIME_SURFACES.opencode omitted `commands/`, so this file + // was invisible to every migration and ordinary materialization would delete it + // unconditionally with a clean exit. Now it is caught and blocks the install + // pending an explicit user choice — the same protection the legacy `command/` + // surface already had. + assert.deepEqual(result.appliedMigrationIds, []); + assert.ok(Array.isArray(result.blocked) && result.blocked.length > 0, 'expected a blocked prompt-user action'); + const blockedForFile = result.blocked.find((action) => action.relPath === 'commands/gsd-retired-plan.md'); + assert.ok(blockedForFile, 'expected commands/gsd-retired-plan.md to be blocked pending user choice'); + assert.equal(blockedForFile.type, 'prompt-user'); + assert.equal(blockedForFile.migrationId, '2026-07-17-opencode-baseline-commands-dir'); + // The file itself was never touched — migrations only classify, they do not + // mutate disk. + assert.equal( + fs.readFileSync(path.join(configDir, 'commands/gsd-retired-plan.md'), 'utf8'), + 'pre-existing file, not GSD-written\n' + ); + } finally { + cleanup(configDir); + } +}); + test('plans a pending migration against an unchanged managed file', () => { const configDir = createTempInstall(); try { @@ -1468,6 +1657,14 @@ test('shipped installer-migration checksums are locked to a committed baseline ( // Migration 004: prune stale gsd-pristine/get-shit-done/ snapshots (#934) // gsd-allow-legacy-name '2026-06-09-prune-stale-pristine-get-shit-done': // gsd-allow-legacy-name 'sha256:6555dd044659276fbc204e81793cd92c5315d54e7316bcdd82d2c98d15a7e9e8', + // Migration 005 (NEW, added here per this test's own sanctioned "adding a new + // migration" case — not a shipped-body edit): baseline OpenCode's commands/ + // (plural) directory during the first-time scan. #2329 moved OpenCode command + // materialization from legacy command/ to commands/, but 000's RUNTIME_SURFACES + // is a shipped, immutable body that still only names command/, so this + // fix-forward migration widens the scanned surface without touching 000. + '2026-07-17-opencode-baseline-commands-dir': + 'sha256:0f6080b5f9b75fb5adbe9664a71152e23a5336813453b0a77e4df6fd483ad38e', }; const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs'); diff --git a/tests/opencode-command-dir-plural.test.cjs b/tests/opencode-command-dir-plural.test.cjs new file mode 100644 index 000000000..4477e66e9 --- /dev/null +++ b/tests/opencode-command-dir-plural.test.cjs @@ -0,0 +1,458 @@ +'use strict'; + +/** + * Regression coverage for #2329: OpenCode install target writes slash + * commands to `command/` (singular); OpenCode discovers them from + * `commands/` (plural), so none of the ~71 `/gsd-*` commands appear in the + * OpenCode TUI. + * + * Per OpenCode's own docs (opencode.ai/docs/commands/), the documented + * discovery directories are the plural `commands/` (global + * `~/.config/opencode/commands/`, per-project `.opencode/commands/`). + * OpenCode ALSO accepts singular names for backwards compatibility per its + * config docs, but the reporter empirically confirmed on OpenCode 1.17.13 + * that `command/` (what GSD currently writes) is NOT discovered and that + * `mv command commands` fixes it. These tests assert only what GSD + * controls: where GSD writes its command files. + * + * Four sites must agree (Generative Fix Divergence guard, repo rule): + * - capabilities/opencode/capability.json:28 (global artifactLayout commands.destSubpath) + * - capabilities/opencode/capability.json:46 (local artifactLayout commands.destSubpath) + * - capabilities/opencode/capability.json:91 (hostBehaviors.flatCommandDir) + * - bin/install.js:9352 (writeManifest's separate hardcoded + * `manifest.files['command/' + file]` literal) + * + * See also tests/runtime-artifact-layout-descriptor-drive.test.cjs, whose + * GOLDEN table was updated in lockstep with this file (opencode/global and + * opencode/local destSubpath entries) — that is the canonical descriptor- + * layer pin; this file covers the full install → manifest → migration + * behavior end to end. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { + runMinimalInstall, + installerEnv, + INSTALL_SCRIPT, +} = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); +const { + resolveRuntimeArtifactLayout, +} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +const CAPABILITY_PATH = path.join(__dirname, '..', 'capabilities', 'opencode', 'capability.json'); +const EXPECTED_COMMAND_DIR = 'commands'; + +/** Re-run the installer against an EXISTING configDir/root to simulate an + * upgrade/reapply pass (the installer runs its migration planner on every + * invocation — see bin/install.js installAllRuntimes -> install() -> + * runInstallerMigrations, unconditional, not gated on first-install). */ +function reinstallOpencode(root, scope = 'global') { + const args = [INSTALL_SCRIPT, '--opencode']; + let cwd = process.cwd(); + if (scope === 'global') { + args.push('--global', '--config-dir', root); + } else { + args.push('--local'); + cwd = root; + } + return spawnSync(process.execPath, args, { + cwd, + encoding: 'utf8', + env: installerEnv({ HOME: root, USERPROFILE: root }), + }); +} + +function gsdMdFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter((f) => f.startsWith('gsd-') && f.endsWith('.md')); +} + +/** + * #2329: fabricate a pre-fix "legacy" OpenCode install fixture. + * + * Under the FIXED installer, a fresh install lands its command files + * straight into commands/ (plural) and never creates command/ (singular) — + * that is the fix working correctly. So the pre-fix "existing install with a + * populated command/ dir" starting state a real upgrading user has can no + * longer be reproduced by simply calling the installer once (as a prior + * version of these tests assumed); it must be fabricated by hand-rewriting a + * fresh install's output into the shape the OLD, buggy installer actually + * left on disk. + * + * The migration under test (_migrateLegacyOpencodeCommandDir in + * src/install-engine.cts) only deletes a command/ entry when + * classifyArtifact() proves it GSD-managed via gsd-file-manifest.json (key + * `command/` present, hash matches or was locally modified — see + * installerMigrations.classifyArtifact). A legacyDir populated with files but + * no matching manifest entries would classify as 'unknown' and never be + * touched, silently turning the migration tests into no-ops. So this helper + * also rewrites the manifest's `commands/` keys to `command/` + * (matching the OLD writeManifest's opencodeCommandDir-prefix behavior) + * alongside physically moving the files, so the fabricated state is exactly + * what a pre-fix install would have left: files AND a manifest that proves + * GSD manages them at the legacy path. + * + * @param {string} configDir + * @param {object} [opts] + * @param {boolean} [opts.keepPluralDir=false] - when true, leaves the + * (now-empty) commands/ dir in place instead of removing it. Used by the + * both-dirs-exist scenario, which separately seeds commands/ with + * unrelated content after calling this helper. + * @returns {{legacyDir:string, pluralDir:string, manifestPath:string, movedFiles:string[]}} + */ +function fabricateLegacyOpencodeCommandDir(configDir, opts = {}) { + const { keepPluralDir = false } = opts; + const legacyDir = path.join(configDir, 'command'); + const pluralDir = path.join(configDir, 'commands'); + const manifestPath = path.join(configDir, 'gsd-file-manifest.json'); + + const movedFiles = gsdMdFiles(pluralDir); + assert.ok( + movedFiles.length >= 60, + `fabricateLegacyOpencodeCommandDir: expected a fresh install to have ` + + `populated >=60 gsd-*.md files under ${pluralDir} before fabrication, got ${movedFiles.length}` + ); + + fs.mkdirSync(legacyDir, { recursive: true }); + for (const file of movedFiles) { + fs.renameSync(path.join(pluralDir, file), path.join(legacyDir, file)); + } + if (!keepPluralDir && fs.existsSync(pluralDir) && fs.readdirSync(pluralDir).length === 0) { + fs.rmdirSync(pluralDir); + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + for (const file of movedFiles) { + const commandsKey = `commands/${file}`; + const legacyKey = `command/${file}`; + assert.ok( + Object.prototype.hasOwnProperty.call(manifest.files, commandsKey), + `fabricateLegacyOpencodeCommandDir: manifest is missing expected key ${commandsKey} — ` + + 'cannot fabricate a manifest-proven legacy fixture' + ); + manifest.files[legacyKey] = manifest.files[commandsKey]; + delete manifest.files[commandsKey]; + } + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + + return { legacyDir, pluralDir, manifestPath, movedFiles }; +} + +// --------------------------------------------------------------------------- +// 1 + 2. Command files land under commands/ (plural); command/ (singular) +// is never created — both scopes. +// --------------------------------------------------------------------------- + +describe('#2329: opencode install writes commands to commands/ (plural), not command/ (singular)', () => { + for (const scope of ['global', 'local']) { + test(`opencode --${scope}: command files land under commands/, and command/ does NOT exist`, (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'opencode', scope }); + t.after(() => cleanup(root)); + + const pluralDir = path.join(configDir, 'commands'); + const singularDir = path.join(configDir, 'command'); + + const pluralFiles = gsdMdFiles(pluralDir); + assert.ok( + pluralFiles.length >= 60, + `expected >=60 gsd-*.md command files under ${pluralDir}, got ${pluralFiles.length}. ` + + `OpenCode discovers commands from commands/ (plural) per opencode.ai/docs/commands/.` + ); + assert.ok( + pluralFiles.includes('gsd-help.md'), + `gsd-help.md must exist under ${pluralDir}` + ); + + assert.ok( + !fs.existsSync(singularDir), + `${singularDir} must NOT exist — OpenCode does not discover commands from the singular ` + + 'command/ dir (empirically confirmed on OpenCode 1.17.13, issue #2329)' + ); + }); + } +}); + +// --------------------------------------------------------------------------- +// 3. Manifest records files under the commands/ prefix, not command/. +// --------------------------------------------------------------------------- + +describe('#2329: opencode install manifest records the commands/ prefix', () => { + test('manifest.files keys use "commands/" prefix; none use "command/"', (t) => { + const { manifest, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' }); + t.after(() => cleanup(root)); + + assert.ok(manifest && manifest.files, 'manifest.files must exist'); + const keys = Object.keys(manifest.files); + + const pluralKeys = keys.filter((k) => k.startsWith('commands/')); + assert.ok( + pluralKeys.length >= 60, + `expected >=60 manifest keys under "commands/", got ${pluralKeys.length}. ` + + `Sample keys: ${keys.slice(0, 5).join(', ')}` + ); + + const singularKeys = keys.filter((k) => k.startsWith('command/')); + assert.deepStrictEqual( + singularKeys, [], + 'no manifest key may use the old singular "command/" prefix — ' + + 'this is the bin/install.js:9352 writeManifest hardcode this issue must fix' + ); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Single source of truth: the two destSubpath declarations, +// hostBehaviors.flatCommandDir, and the descriptor-driven layout resolver +// all agree on "commands" — and the manifest prefix is DERIVED from the +// descriptor, not a separate hardcoded literal (Generative Fix +// Divergence guard). +// --------------------------------------------------------------------------- + +describe('#2329: opencode command-dir declarations are a single source of truth', () => { + test('capability.json global/local artifactLayout commands.destSubpath === "commands"', () => { + const cap = JSON.parse(fs.readFileSync(CAPABILITY_PATH, 'utf8')); + const globalCommands = cap.runtime.artifactLayout.global.find((k) => k.kind === 'commands'); + const localCommands = cap.runtime.artifactLayout.local.find((k) => k.kind === 'commands'); + + assert.ok(globalCommands, 'capability.json global artifactLayout must have a commands kind'); + assert.ok(localCommands, 'capability.json local artifactLayout must have a commands kind'); + + assert.strictEqual( + globalCommands.destSubpath, EXPECTED_COMMAND_DIR, + 'capabilities/opencode/capability.json global commands.destSubpath must be "commands" (#2329)' + ); + assert.strictEqual( + localCommands.destSubpath, EXPECTED_COMMAND_DIR, + 'capabilities/opencode/capability.json local commands.destSubpath must be "commands" (#2329)' + ); + }); + + test('capability.json hostBehaviors.flatCommandDir === "commands"', () => { + const cap = JSON.parse(fs.readFileSync(CAPABILITY_PATH, 'utf8')); + assert.strictEqual( + cap.runtime.hostBehaviors.flatCommandDir, EXPECTED_COMMAND_DIR, + 'capabilities/opencode/capability.json runtime.hostBehaviors.flatCommandDir must be "commands" (#2329)' + ); + }); + + test('resolveRuntimeArtifactLayout("opencode", ...) commands kind destSubpath === "commands" for both scopes', () => { + for (const scope of ['global', 'local']) { + const layout = resolveRuntimeArtifactLayout('opencode', '/tmp/fake-opencode-config-dir', scope); + const commandsKind = layout.kinds.find((k) => k.kind === 'commands'); + assert.ok(commandsKind, `opencode/${scope} layout must have a commands kind`); + assert.strictEqual( + commandsKind.destSubpath, EXPECTED_COMMAND_DIR, + `resolveRuntimeArtifactLayout('opencode', dir, '${scope}').kinds commands.destSubpath must be "commands" (#2329)` + ); + } + }); + + test('installed manifest prefix is derived from the descriptor value, not a separate hardcoded literal (bin/install.js:9352 guard)', (t) => { + const cap = JSON.parse(fs.readFileSync(CAPABILITY_PATH, 'utf8')); + const globalCommands = cap.runtime.artifactLayout.global.find((k) => k.kind === 'commands'); + const descriptorPrefix = globalCommands.destSubpath; + + const { manifest, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' }); + t.after(() => cleanup(root)); + + const keys = Object.keys(manifest.files); + const descriptorPrefixKeys = keys.filter((k) => k.split('/')[0] === descriptorPrefix); + assert.ok( + descriptorPrefixKeys.length >= 60, + `manifest must record command files under the descriptor's "${descriptorPrefix}/" prefix ` + + `(read from capability.json), got ${descriptorPrefixKeys.length} matching keys. This proves ` + + 'bin/install.js:9352 derives its manifest prefix from the same descriptor value instead of a ' + + 'separately-hardcoded literal — a divergence would silently break the manifest even after the ' + + 'capability.json descriptor is fixed.' + ); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Migration: an existing install with a populated command/ dir is +// migrated cleanly on upgrade/reapply — without destroying user content. +// --------------------------------------------------------------------------- + +describe('#2329: upgrading an install with an orphaned command/ dir migrates it to commands/ safely', () => { + test('reinstall relocates all gsd-owned files from command/ into commands/ and removes the now-empty orphan', (t) => { + // Under the FIXED source, a first install writes straight into commands/ + // (plural) — that's the fix working. To exercise the upgrade/migration + // path, fabricate the pre-fix "legacy" starting state a real upgrading + // user has (populated command/ dir + a manifest that proves GSD manages + // those files at the legacy path) by hand-rewriting a fresh install's + // output — see fabricateLegacyOpencodeCommandDir's doc comment. + const { configDir, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' }); + t.after(() => cleanup(root)); + + const legacyDir = path.join(configDir, 'command'); + const pluralDir = path.join(configDir, 'commands'); + + const { movedFiles } = fabricateLegacyOpencodeCommandDir(configDir); + const filesBefore = new Set(movedFiles); + assert.ok(filesBefore.size >= 60, `sanity: expected >=60 gsd command files from the first install, got ${filesBefore.size}`); + // Sanity: the migration must genuinely FIRE against this fixture, not + // no-op — confirm the legacy dir is populated and commands/ is gone + // before the reinstall runs. + assert.ok(gsdMdFiles(legacyDir).length >= 60, 'sanity: legacyDir must be populated before reinstall'); + assert.ok(!fs.existsSync(pluralDir), 'sanity: pluralDir must not exist before reinstall (fabricated pre-fix state)'); + + const result = reinstallOpencode(root, 'global'); + assert.strictEqual( + result.status, 0, + `reinstall (upgrade) must exit 0\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + + const filesAfter = new Set(gsdMdFiles(pluralDir)); + assert.deepStrictEqual( + [...filesAfter].sort(), [...filesBefore].sort(), + 'after the upgrade reinstall, commands/ must contain the exact same gsd-* command file set ' + + 'that was previously under command/' + ); + + assert.ok( + !fs.existsSync(legacyDir), + 'the orphaned command/ dir must be removed once fully migrated — it held only GSD-owned files ' + + '(all present, unmodified, in the manifest), so removing it is safe and expected' + ); + }); + + test('an unrelated user file placed inside command/ survives the migration (not destroyed)', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' }); + t.after(() => cleanup(root)); + + const legacyDir = path.join(configDir, 'command'); + const pluralDir = path.join(configDir, 'commands'); + + fabricateLegacyOpencodeCommandDir(configDir); + // Sanity: the migration must genuinely fire against this fixture. + assert.ok(gsdMdFiles(legacyDir).length >= 60, 'sanity: legacyDir must be populated before reinstall'); + assert.ok(!fs.existsSync(pluralDir), 'sanity: pluralDir must not exist before reinstall (fabricated pre-fix state)'); + + const userContent = 'precious user notes — not a GSD-managed file\n'; + const userFileLegacy = path.join(legacyDir, 'my-notes.md'); + fs.writeFileSync(userFileLegacy, userContent, 'utf8'); + + const result = reinstallOpencode(root, 'global'); + assert.strictEqual( + result.status, 0, + `reinstall (upgrade) must exit 0\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + + // The migration must actually have happened (commands/ now populated) — + // otherwise "the user file is still where it was" would be a vacuous pass. + assert.ok( + gsdMdFiles(pluralDir).length >= 60, + `commands/ must contain the migrated gsd-* command set after the upgrade, got ` + + `${gsdMdFiles(pluralDir).length}` + ); + + // The unrelated file is not a GSD-managed artifact (no manifest entry — + // it never matched the "gsd-*.md" filter GSD tracks), so it must never + // be silently deleted. It may legitimately end up EITHER left in place + // in the (now GSD-file-free) command/ dir, OR carried over into + // commands/ alongside the migrated files — either is safe; deletion is + // not. + const userFilePlural = path.join(pluralDir, 'my-notes.md'); + const survivedInLegacy = fs.existsSync(userFileLegacy); + const survivedInPlural = fs.existsSync(userFilePlural); + assert.ok( + survivedInLegacy || survivedInPlural, + 'unrelated user file my-notes.md must survive the migration (either in place under command/, ' + + 'or carried over to commands/) — it must NOT be deleted as collateral damage' + ); + const survivingPath = survivedInLegacy ? userFileLegacy : userFilePlural; + assert.strictEqual( + fs.readFileSync(survivingPath, 'utf8'), userContent, + 'unrelated user file content must be byte-identical after the migration' + ); + }); + + test('both-dirs-exist case: pre-existing unrelated content already in commands/ is not clobbered while command/ is still relocated', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' }); + t.after(() => cleanup(root)); + + const legacyDir = path.join(configDir, 'command'); + const pluralDir = path.join(configDir, 'commands'); + + fabricateLegacyOpencodeCommandDir(configDir, { keepPluralDir: true }); + + // Simulate a commands/ dir that already independently exists (e.g. the + // user manually created it while working around #2329, or a partial + // prior migration attempt left it behind) with content GSD does not own. + fs.mkdirSync(pluralDir, { recursive: true }); + const preexistingPath = path.join(pluralDir, 'unrelated-project-doc.md'); + const preexistingContent = 'unrelated content that already lived in commands/\n'; + fs.writeFileSync(preexistingPath, preexistingContent, 'utf8'); + + const filesBefore = new Set(gsdMdFiles(legacyDir)); + assert.ok(filesBefore.size >= 60, `sanity: expected >=60 gsd command files in command/, got ${filesBefore.size}`); + // Sanity: the migration must genuinely fire against this fixture — the + // manifest must actually prove ownership of the legacy files (not just + // their physical presence), otherwise classifyArtifact would treat them + // as 'unknown' and the migration would silently no-op. + const manifestBefore = JSON.parse(fs.readFileSync(path.join(configDir, 'gsd-file-manifest.json'), 'utf8')); + const legacyManifestKeys = Object.keys(manifestBefore.files).filter((k) => k.startsWith('command/')); + assert.ok( + legacyManifestKeys.length >= 60, + `sanity: manifest must record >=60 "command/" keys before reinstall, got ${legacyManifestKeys.length}` + ); + + const result = reinstallOpencode(root, 'global'); + assert.strictEqual( + result.status, 0, + `reinstall (upgrade) must exit 0\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + + // Pre-existing unrelated content in commands/ must survive untouched. + assert.ok( + fs.existsSync(preexistingPath), + 'pre-existing unrelated-project-doc.md in commands/ must not be deleted by the migration' + ); + assert.strictEqual( + fs.readFileSync(preexistingPath, 'utf8'), preexistingContent, + 'pre-existing unrelated file content in commands/ must be untouched' + ); + + // All gsd command files must now be present in commands/ too. + const filesAfter = new Set(gsdMdFiles(pluralDir)); + for (const f of filesBefore) { + assert.ok(filesAfter.has(f), `${f} must be present in commands/ after the upgrade (was in command/ before)`); + } + }); +}); + +// --------------------------------------------------------------------------- +// 6. No collateral: another flat-command-dir runtime (Kilo) is unaffected. +// --------------------------------------------------------------------------- +// +// NOTE: this test is expected to PASS both before and after the #2329 fix — +// it is a regression guard against the fix accidentally widening scope to +// Kilo (which also uses a flat, singular `command/` dir per its own +// capability.json and is NOT part of this issue), not a red-phase test for +// the bug itself. + +describe('#2329: the fix must not affect other runtimes\' command output directories', () => { + test('kilo global install still writes to command/ (singular) — unaffected by the opencode-only fix', (t) => { + const { configDir, root } = runMinimalInstall({ runtime: 'kilo', scope: 'global' }); + t.after(() => cleanup(root)); + + const singularDir = path.join(configDir, 'command'); + const pluralDir = path.join(configDir, 'commands'); + + assert.ok( + gsdMdFiles(singularDir).length >= 60, + `kilo must still write its command files to the singular command/ dir, got ` + + `${gsdMdFiles(singularDir).length} files under ${singularDir}` + ); + assert.ok( + !fs.existsSync(pluralDir), + `kilo must NOT gain a commands/ (plural) dir as collateral from the opencode-only #2329 fix` + ); + }); +}); diff --git a/tests/opencode-imperative-reference.test.cjs b/tests/opencode-imperative-reference.test.cjs index 8b7c0e842..ee90025cd 100644 --- a/tests/opencode-imperative-reference.test.cjs +++ b/tests/opencode-imperative-reference.test.cjs @@ -99,7 +99,9 @@ test('opencode descriptor declares runtime.hostBehaviors (the folded-in behavior assert.equal(hb.combinedFamilyInstall, true, 'commands+skills+plugin install runs through the engine (adapter)'); assert.equal(hb.reapplyCommand, '/gsd-update --reapply'); assert.equal(hb.attributionConfigResolver, 'opencode'); - assert.equal(hb.flatCommandDir, 'command'); + // #2329: OpenCode discovers commands from the PLURAL `commands/` dir; the + // singular `command/` made all /gsd-* commands invisible to OpenCode. + assert.equal(hb.flatCommandDir, 'commands'); assert.equal(hb.frontmatterDialect, 'opencode'); assert.equal(hb.skipHomePrefixSubstitution, true); assert.equal(hb.skipSettingsUi, true); diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index e1034f0b1..9727e3380 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -194,13 +194,17 @@ const GOLDEN = { // ── opencode ───────────────────────────────────────────────────────────────── // Old switch: no scope branch → local == global. 5b backfill restores this. + // #2329: destSubpath corrected from singular 'command' to plural 'commands' — + // OpenCode discovers slash commands from commands/ (plural); the singular + // command/ dir GSD previously wrote to is not scanned by OpenCode 1.17.13, + // so none of the ~71 /gsd-* commands ever appeared in the OpenCode TUI. 'opencode/global': [ - { kind: 'commands', destSubpath: 'command', prefix: 'gsd-' }, - { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, + { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, ], 'opencode/local': [ - { kind: 'commands', destSubpath: 'command', prefix: 'gsd-' }, - { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, + { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, + { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, ], // ── kilo ───────────────────────────────────────────────────────────────────── diff --git a/tests/runtime-artifact-layout-surface.test.cjs b/tests/runtime-artifact-layout-surface.test.cjs index 7c0a89fab..80a090361 100644 --- a/tests/runtime-artifact-layout-surface.test.cjs +++ b/tests/runtime-artifact-layout-surface.test.cjs @@ -316,8 +316,8 @@ describe('applySurface', () => { // (matching installRuntimeArtifacts/_copyStaged behaviour) and must NOT prune // user-created command files that install would preserve. // - // Affected runtimes have a FLAT command dir (opencode `command/`, cursor - // `commands/`, augment `commands/`, kilo `command/`) with kind.prefix='gsd-'. + // Affected runtimes have a FLAT command dir (opencode `commands/` — #2329, + // cursor `commands/`, augment `commands/`, kilo `command/`) with kind.prefix='gsd-'. // _copyStaged names files `gsd-.md` but the buggy _syncGsdDir copies // them as `.md` (unprefixed) and also deletes ALL .md files not in the // staged set, including user files. @@ -333,7 +333,7 @@ describe('applySurface', () => { explicitRemoves: [], }); - // Determine the command dest dir for opencode: commandsKind destSubpath='command' + // Determine the command dest dir for opencode: commandsKind destSubpath='commands' (#2329) const layout = resolveRuntimeArtifactLayout('opencode', configDir, 'global'); const commandsKind = layout.kinds.find(k => k.kind === 'commands'); assert.ok(commandsKind, 'opencode layout must have a commands kind'); @@ -384,8 +384,8 @@ describe('applySurface', () => { // against future drift between _syncGsdDir (surface) and _copyStaged (install) // command-naming logic. // - // Matrix: opencode/kilo = flat command/ + prefix gsd-; - // cursor/augment = flat commands/ + prefix gsd-. + // Matrix: opencode/cursor/augment = flat commands/ + prefix gsd- (#2329: opencode + // moved from singular command/ to commands/); kilo = flat command/ + prefix gsd-. // For each runtime we: run install into installDir, run applySurface into // surfaceDir (same 'standard' profile both sides), then compare sorted .md // filename sets in the commands dest dir. On a fresh dir (no superseded files) diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index dc6261ac0..18ce8fa81 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -333,7 +333,9 @@ describe('resolveRuntimeArtifactLayout — opencode', () => { const commands = layout.kinds.find((k) => k.kind === 'commands'); assert.ok(commands, 'should have a commands kind'); - assert.strictEqual(commands.destSubpath, 'command'); + // #2329: OpenCode discovers commands from the PLURAL `commands/` dir — the + // singular `command/` made all /gsd-* commands invisible to OpenCode. + assert.strictEqual(commands.destSubpath, 'commands'); assert.strictEqual(commands.prefix, 'gsd-'); assert.strictEqual(typeof commands.stage, 'function');