From 6f2520786d0e3231958c06d036dade79d5083e2a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 30 May 2026 16:53:44 -0400 Subject: [PATCH] feat(#498): single Package Identity seam for /gsd:update + fix runtime undefined-name bug (#499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#498): generated package-identity seam derived from package.json Introduce a single source for GSD's published-package coordinates: scripts/generate-package-identity.cjs (pure deriveIdentity + formatManualInstall + render) emits the generated get-shit-done/bin/lib/package-identity.cjs with values baked from package.json at build time. Baking is required because the installed tree carries only a synthetic {"type":"commonjs"} package.json, so a runtime require('package.json').name resolves to undefined (#378). Reconciles Wired into npm run build; a parity test fails CI if the committed file drifts from package.json. Refs #498 Co-Authored-By: Claude Opus 4.8 * fix(#498): repoint update worker + check-latest-version at the seam - check-latest-version.cjs sources PACKAGE_NAME from the package-identity seam instead of a re-typed literal (single source; #2992's constant guarantee is preserved since the seam bakes from package.json). - gsd-check-update-worker.js no longer does require('../package.json').name (resolved to undefined in the installed tree → background update check silently broken, #378). It now delegates the latest-version lookup to checkLatestVersion(), collapsing the duplicated npm-view call onto the single deterministic adapter and inheriting its typed {ok,version,reason} surface. - Move the PR #3102 Windows shell-gate contract test onto execNpm (where the spawn now lives) and assert the worker no longer spawns npm directly. - Rewrite the #378 contract: worker must NOT use require(package.json).name and must delegate; check-latest-version PACKAGE_NAME is single-sourced from the seam. Fixes #378-class runtime breakage. Refs #498 Co-Authored-By: Claude Opus 4.8 * chore(#498): changeset for package-identity seam + update-check fix Co-Authored-By: Claude Opus 4.8 * feat(#498): drift-guard lint — value-check GSD coordinate literals against the seam scripts/lint-package-identity-drift.cjs scans the runtime/code surface (bin/, hooks/, scripts/, get-shit-done/) and asserts every GSD package name and GitHub repo slug literal equals the Package Identity seam's current value. Passes today; fails the moment a repoint isn't propagated (rename package.json, regenerate the seam, and stale literals are reported until updated). This is the second adapter that makes the seam real and a repoint mechanically safe. Enforced via tests/issue-498-identity-drift-lint.test.cjs (scanRepo === []) under npm test; also exposed as `npm run check:identity-drift`. Refs #498 Co-Authored-By: Claude Opus 4.8 * feat(#498): update-context projection — port update.md resolution to a tested seam Add get-shit-done/bin/lib/update-context.cjs: a pure, injected-fs port of update.md's ~280-line get_installed_version bash. resolveUpdateContext() reproduces the full precedence cascade (preferred fast-path -> local probe -> global probe via env overrides then $HOME -> LOCAL-if-distinct -> scope cascade -> UNKNOWN) and returns the 4-field contract { installedVersion, scope, runtime, gsdDir }. The fs is injected so every branch is finally testable without a live multi-runtime install. Expose it as `gsd-tools update-context [--config-dir ] [--runtime ] --json`. Purely additive — update.md is unchanged in this commit; the workflow swap follows separately. Refs #498 Co-Authored-By: Claude Opus 4.8 * feat(#498): swap update.md resolution to the update-context projection Replace ~280 lines of inline runtime/scope/config-dir bash in update.md's get_installed_version step with a call to `gsd-tools update-context --json` (60 lines: derive PREFERRED_* from execution_context, resolve gsd-tools.cjs, parse the 4-field JSON). Behavior is unchanged — the projection reproduces the same cascade — but the logic is now tested in update-context.cjs instead of untestable bash-in-markdown. Relocate the #3608 antigravity-first-class contract onto the projection (RUNTIME_DIRS order, inferPreferredRuntime, envRuntimeDirs) plus a behavioral test; keep the execution_context path-classification assertion on update.md. Re-point install.test's custom-config-dir assertion (kilo.jsonc/KILO_CONFIG) to update-context.cjs where that detection now lives. Full root suite: 2022 pass / 0 fail. Refs #498 Co-Authored-By: Claude Opus 4.8 * docs(#498): record Update Context Module in CONTEXT.md Co-Authored-By: Claude Opus 4.8 * fix(#498): CI — avoid bare gsd-tools in update.md; register new CLI modules - update.md update-context invocation: resolve the PATH gsd-tools shim into a variable and call "$GSD_TOOLS" (never a bare `gsd-tools` command) — satisfies the #2851 workflow-bare-gsd-tools guard. - Register package-identity.cjs and update-context.cjs in docs/INVENTORY.md (CLI Modules 76 -> 78 + rows) and regenerate docs/INVENTORY-MANIFEST.json, fixing inventory-counts and inventory-manifest-sync. Co-Authored-By: Claude Opus 4.8 * test(#498): make update-context + parity tests OS-agnostic (Windows CI) Two Windows-only test failures, both test-portability (production code is fine — the real-fs CLI integration test passed on Windows): - update-context resolver tests + bug-3608 behavioral test used POSIX path-string keys in their fake fs, but the resolver builds lookups via path.join/resolve (backslash + drive letter on Windows) → keys never matched → everything resolved to UNKNOWN/claude. Normalize fake-fs keys and gsdDir comparisons through path.resolve so they match on both platforms. - package-identity parity test compared render() (LF) to the committed file, which Windows git checks out as CRLF (no .gitattributes eol rule). Normalize line endings before comparing, matching the repo convention (autonomous-decomposition, bug-3707). Co-Authored-By: Claude Opus 4.8 * fix(#498): update.md backup must use GSD_DIR (adversarial-review finding) The get_installed_version rewrite emits GSD_DIR but dropped the probe-loop variables LOCAL_DIR/GLOBAL_DIR. The backup_custom_files step still read those, so RUNTIME_DIR went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped — and since the update then runs a clean install that wipes managed dirs (commands/gsd, get-shit-done), user-added files could be deleted without the intended backup. Set RUNTIME_DIR="$GSD_DIR" directly (the resolved config dir; empty for UNKNOWN scope, which still skips the backup). Add a structural regression (tests/issue-498-update-backup-runtime-dir.test.cjs). Co-Authored-By: Claude Opus 4.8 * test(#503): re-point Antigravity .agent detection at the #498 projection #499 moves the runtime/scope detection cascade out of update.md inline bash into get-shit-done/bin/lib/update-context.cjs. The #503 regression test asserted on the inline RUNTIME_DIRS array, which no longer exists, so it would fail against the projected update.md even though the .agent guarantee is preserved. Rewrite it to verify the surviving surfaces: - behavioral: resolveUpdateContext resolves a LOCAL ./.agent install to the antigravity runtime (the original root cause, now covered by adding ['antigravity', '.agent'] to the projection RUNTIME_DIRS table) - update.md prose classifier still maps /.agent/ -> antigravity - the post-update cache-clear for-dir loop still includes .agent Co-Authored-By: Claude Opus 4.8 * fix(#498): finish de-hardcoding consumers + close adversarial-review parity gaps Restore the consumer de-hardcoding that is the point of the seam, and close the parity gaps an adversarial review (codex) found in the update-context projection. De-hardcode the repo slug + install command in the changeset tooling — #516 only single-sourced the package NAME, leaving 'open-gsd/get-shit-done-redux' hardcoded in scripts/changeset/cli.cjs and github-release-notes.cjs. Route both through the seam's repoSlug/packageName so a rename is a regenerate, not a hand edit. The drift-lint real scan now reports zero divergent coordinate literals. Projection parity vs the old inline bash, as ONE consistent rule (trustedVersionAt) applied on every path: - expand a leading ~/ in preferredConfigDir before the fast path (the bash ran expand_home first; a custom --config-dir ~/foo otherwise fell to UNKNOWN) - trust a version only when BOTH VERSION and the update.md marker exist — fast path AND LOCAL/GLOBAL cascade; a partial dir falls to 0.0.0 keeping scope - apply the same same-path dedup to the 0.0.0 fallback so a partial install probed from cwd===home is not misdetected as LOCAL Adds regression tests for tilde expansion, VERSION-only (cascade + fast path), and the cwd===home partial-install dedup. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/498-package-identity-seam.md | 5 + CONTEXT.md | 6 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 5 +- get-shit-done/bin/check-latest-version.cjs | 9 +- get-shit-done/bin/gsd-tools.cjs | 32 ++ get-shit-done/bin/lib/package-identity.cjs | 44 ++- get-shit-done/bin/lib/update-context.cjs | 209 ++++++++++++ get-shit-done/workflows/update.md | 309 +++--------------- hooks/gsd-check-update-worker.js | 29 +- package.json | 4 +- scripts/changeset/cli.cjs | 6 +- scripts/changeset/github-release-notes.cjs | 6 +- scripts/generate-package-identity.cjs | 104 ++++++ scripts/lint-package-identity-drift.cjs | 141 ++++++++ ...ity-update-runtime-classification.test.cjs | 263 +++++---------- .../bug-378-update-check-scoped-name.test.cjs | 131 ++++---- ...pdate-agent-antigravity-detection.test.cjs | 100 +++--- ...check-update-worker-platform-gate.test.cjs | 126 ++++--- tests/install.test.cjs | 12 +- tests/issue-498-identity-drift-lint.test.cjs | 69 ++++ tests/issue-498-package-identity.test.cjs | 113 +++++++ ...sue-498-update-backup-runtime-dir.test.cjs | 68 ++++ tests/issue-498-update-context.test.cjs | 195 +++++++++++ 24 files changed, 1326 insertions(+), 661 deletions(-) create mode 100644 .changeset/498-package-identity-seam.md create mode 100644 get-shit-done/bin/lib/update-context.cjs create mode 100644 scripts/generate-package-identity.cjs create mode 100644 scripts/lint-package-identity-drift.cjs create mode 100644 tests/issue-498-identity-drift-lint.test.cjs create mode 100644 tests/issue-498-package-identity.test.cjs create mode 100644 tests/issue-498-update-backup-runtime-dir.test.cjs create mode 100644 tests/issue-498-update-context.test.cjs diff --git a/.changeset/498-package-identity-seam.md b/.changeset/498-package-identity-seam.md new file mode 100644 index 000000000..a49cb37e9 --- /dev/null +++ b/.changeset/498-package-identity-seam.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 498 +--- +The SessionStart update check now reports available updates again: the worker previously resolved the package name via `require('package.json').name`, which is `undefined` in the installed tree, so `npm view` always failed. Package coordinates now come from a single build-time Package Identity seam derived from package.json. diff --git a/CONTEXT.md b/CONTEXT.md index 39e17673a..3fe5ef440 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -106,6 +106,12 @@ Module owning validation for Installer Migration Module records and planned acti ### Installer Module Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getGlobalDir(runtime[, explicitDir])` → global path (env-var–aware per runtime); `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `get-shit-done/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. +### Package Identity Module [Planned] +Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `get-shit-done/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. + +### Update Context Module [Planned] +Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home, cwd, env, fs, preferredConfigDir, preferredRuntime })` is a pure, injected-fs port of update.md's former ~280-line `get_installed_version` bash; it reproduces the full precedence cascade — preferred-config-dir fast path, local-over-global probe with same-path dedup, env-var overrides (`CLAUDE_CONFIG_DIR`, `OPENCODE_CONFIG`, `KILO_CONFIG`, `XDG_CONFIG_HOME`, `CODEX_HOME`, …), and semver validation — and returns the 4-field contract `{ installedVersion, scope, runtime, gsdDir }` (scope ∈ `LOCAL`/`GLOBAL`/`UNKNOWN`). Antigravity is modelled first-class (its `.gemini/antigravity{,-ide,-cli}` dirs probe before bare `.gemini`; #3608). Exposed to the workflow as `gsd-tools update-context [--config-dir ] [--runtime ] --json`; `loadUpdateContext` wires the real fs. The workflow keeps only the execution_context path → `PREFERRED_*` derivation (the one input it alone knows). Source: `get-shit-done/bin/lib/update-context.cjs`; tests: `tests/issue-498-update-context.test.cjs`. See Installer Module and Package Identity Module. + ### Skill Surface Budget Module Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `get-shit-done/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `get-shit-done/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `get-shit-done/bin/lib/clusters.cjs`; per-runtime state persists in `/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ae9390af6..3c7f59355 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -328,6 +328,7 @@ "task-command-router.cjs", "template.cjs", "uat.cjs", + "update-context.cjs", "validate-command-router.cjs", "validate.cjs", "verify-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index d2d150529..1b84728b2 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (77 shipped) +## CLI Modules (78 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -406,7 +406,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | -| `package-identity.cjs` | Single source of truth for the package's own identity — exports `PACKAGE_NAME` derived from `package.json` `name` so a rename is a one-line change (#516) | +| `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) | | `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` | | `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | @@ -436,6 +436,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `task-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools task` | | `template.cjs` | Template selection and filling with variable substitution | | `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support | +| `update-context.cjs` | Pure install-context resolver for `/gsd:update` — runtime/scope/config-dir/version detection (LOCAL/GLOBAL/UNKNOWN) ported from update.md bash; backs `gsd-tools update-context` (#498) | | `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` | | `validate.cjs` | Pure phase variant normalization helpers (`phaseVariants`, `buildRoadmapPhaseVariants`, `buildNotStartedPhaseVariants`) used by `verify.cjs` for W006/W007 checks; no I/O, no async | | `verify-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verify` | diff --git a/get-shit-done/bin/check-latest-version.cjs b/get-shit-done/bin/check-latest-version.cjs index f1e7391e5..3fcff384e 100755 --- a/get-shit-done/bin/check-latest-version.cjs +++ b/get-shit-done/bin/check-latest-version.cjs @@ -21,11 +21,12 @@ */ const { execNpm } = require('./lib/shell-command-projection.cjs'); -const { PACKAGE_NAME } = require('./lib/package-identity.cjs'); -// Sourced from package.json via package-identity.cjs (#516). Do not -// parameterise — the whole point of this script is that the package name is -// not a runtime choice for the caller. +// Sourced from the single Package Identity seam (#498), not re-typed. The seam +// bakes the value from package.json at build time, so it is a code constant — +// still NOT a runtime choice for the caller (#2992) — and a rename propagates +// from one place (#378). The drift-guard lint forbids re-introducing a literal. +const { packageName: PACKAGE_NAME } = require('./lib/package-identity.cjs'); const CHECK_REASON = Object.freeze({ OK: 'ok', diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 499af42c4..83c472c36 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -1615,6 +1615,38 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'update-context': { + // #498: resolve the installed GSD version, scope, runtime, and config dir + // for /gsd:update. Replaces ~280 lines of inline bash in update.md with a + // tested projection. Emits the contract as JSON: { installedVersion, + // scope, runtime, gsdDir }. Optional --config-dir / --runtime carry the + // workflow's execution_context hints (the one thing only it can know). + const { loadUpdateContext } = require('./lib/update-context.cjs'); + const ucArgs = args.slice(1); + let preferredConfigDir = ''; + let preferredRuntime = ''; + for (let i = 0; i < ucArgs.length; i++) { + const a = ucArgs[i]; + if (a.startsWith('--config-dir=')) { preferredConfigDir = a.slice('--config-dir='.length); continue; } + if (a.startsWith('--runtime=')) { preferredRuntime = a.slice('--runtime='.length); continue; } + if (a === '--config-dir') { + const v = ucArgs[i + 1]; + if (v === undefined || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE); + preferredConfigDir = v; i++; continue; + } + if (a === '--runtime') { + const v = ucArgs[i + 1]; + if (v === undefined || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE); + preferredRuntime = v; i++; continue; + } + if (a === '--json') continue; // JSON is the only output; accepted for symmetry + if (a.startsWith('-')) error(`Unknown flag for update-context: ${a}`, ERROR_REASON.USAGE); + } + const ctx = loadUpdateContext({ preferredConfigDir, preferredRuntime }); + process.stdout.write(JSON.stringify(ctx) + '\n'); + break; + } + default: { // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim // above split it so `command` here is the head ("foo"). Use diff --git a/get-shit-done/bin/lib/package-identity.cjs b/get-shit-done/bin/lib/package-identity.cjs index 8032b7b87..dd387bf2b 100644 --- a/get-shit-done/bin/lib/package-identity.cjs +++ b/get-shit-done/bin/lib/package-identity.cjs @@ -1,19 +1,31 @@ +// @generated by scripts/generate-package-identity.cjs from package.json — DO NOT EDIT. +// Single source for GSD package coordinates (issue #498). Regenerate with: +// node scripts/generate-package-identity.cjs 'use strict'; -/** - * Single source of truth for the package name at runtime (#516). - * - * Why this exists: the package name `@opengsd/get-shit-done-redux` was - * hardcoded as a string literal in ~15 runtime .cjs/.js files. When the - * package is renamed (e.g. to `@opengsd/gsd-core`), changing this one - * require path propagates the new name everywhere in runtime code. - * - * Path: get-shit-done/bin/lib/package-identity.cjs - * Resolves package.json both in-repo (3 levels up) and when shipped - * (package root is always 3 dirs above this file). - */ -const pkg = require('../../../package.json'); +const packageName = "@opengsd/get-shit-done-redux"; +const binName = "get-shit-done-redux"; +const repoSlug = "open-gsd/get-shit-done-redux"; +const repoUrl = "https://github.com/open-gsd/get-shit-done-redux"; +const changelogRawUrl = "https://raw.githubusercontent.com/open-gsd/get-shit-done-redux/main/CHANGELOG.md"; -module.exports = { - PACKAGE_NAME: pkg.name, -}; +function formatManualInstall({ packageName, binName, scope, runtime } = {}) { + const runtimeFlag = runtime ? ` --${runtime}` : ''; + return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`; +} + +function manualInstallCommand(opts = {}) { + return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime }); +} + +module.exports = Object.freeze({ + packageName, + // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it + // survives the installed tree’s synthetic package.json (fixes the #378 undefined). + PACKAGE_NAME: packageName, + binName, + repoSlug, + repoUrl, + changelogRawUrl, + manualInstallCommand, +}); diff --git a/get-shit-done/bin/lib/update-context.cjs b/get-shit-done/bin/lib/update-context.cjs new file mode 100644 index 000000000..61396670a --- /dev/null +++ b/get-shit-done/bin/lib/update-context.cjs @@ -0,0 +1,209 @@ +'use strict'; + +/** + * Update-context resolver (issue #498, candidate 3). + * + * Faithful Node port of the ~280-line `get_installed_version` bash step in + * `workflows/update.md`. That logic resolved the installed GSD version, the + * install scope (LOCAL / GLOBAL / UNKNOWN), the target runtime, and the config + * dir — entirely as inline bash inside an LLM prompt, untestable through its + * interface. This module makes the same cascade a pure, injected-fs function so + * the workflow shrinks to: call → compare → confirm → install. + * + * The fs is injected ({ exists, readFile }) so every precedence branch is + * testable without a live multi-runtime install. `loadUpdateContext` wires the + * real fs for the CLI. + */ + +const path = require('node:path'); + +// Runtime -> candidate relative dir. Order matters: it is the probe order, and +// mirrors the RUNTIME_DIRS array the bash used (a runtime may have several +// candidate dirs). Kept here, not derived from the installer's getDirName, +// because update detection probes ALL historical dirs per runtime. +const RUNTIME_DIRS = [ + ['claude', '.claude'], + ['opencode', '.config/opencode'], + ['opencode', '.opencode'], + ['antigravity', '.gemini/antigravity-ide'], + ['antigravity', '.gemini/antigravity-cli'], + ['antigravity', '.gemini/antigravity'], + ['antigravity', '.agent'], // local Antigravity install dir (#503; bin/install.js getDirName('antigravity')) + ['gemini', '.gemini'], + ['kilo', '.config/kilo'], + ['kilo', '.kilo'], + ['codex', '.codex'], +]; + +const SEMVER_PREFIX = /^\d+\.\d+\.\d+/; + +function expandHome(p, home) { + if (!p) return ''; + return p.startsWith('~/') ? path.join(home, p.slice(2)) : p; +} + +function versionFile(dir) { return path.join(dir, 'get-shit-done', 'VERSION'); } +function markerFile(dir) { return path.join(dir, 'get-shit-done', 'workflows', 'update.md'); } + +// Detection: a dir "has GSD" if it carries a VERSION file or the update.md +// workflow marker. +function hasInstall(fs, dir) { + return fs.exists(versionFile(dir)) || fs.exists(markerFile(dir)); +} + +// Read VERSION at dir; return a trimmed semver string, or null if missing/invalid. +function validVersionAt(fs, dir) { + const raw = fs.readFile(versionFile(dir)); + if (raw == null) return null; + const trimmed = String(raw).trim(); + return SEMVER_PREFIX.test(trimmed) ? trimmed : null; +} + +// A version is TRUSTED only when BOTH the VERSION file and the update.md marker +// exist (and VERSION is valid semver) — the old inline cascade required both +// (update.md ~lines 230/241). A VERSION-only or marker-only dir is a partial +// install, so its version is not trusted (caller treats it as 0.0.0 = reinstall). +// One rule, applied on every path (fast path + LOCAL/GLOBAL cascade). +function trustedVersionAt(fs, dir) { + return dir && fs.exists(markerFile(dir)) ? validVersionAt(fs, dir) : null; +} + +// Infer the preferred runtime from preferredConfigDir config files, then env. +function inferPreferredRuntime({ fs, env, preferredConfigDir }) { + if (preferredConfigDir) { + if (fs.exists(path.join(preferredConfigDir, 'kilo.json')) || + fs.exists(path.join(preferredConfigDir, 'kilo.jsonc'))) return 'kilo'; + if (fs.exists(path.join(preferredConfigDir, 'opencode.json')) || + fs.exists(path.join(preferredConfigDir, 'opencode.jsonc'))) return 'opencode'; + if (fs.exists(path.join(preferredConfigDir, 'config.toml'))) return 'codex'; + } + if (env.CODEX_HOME) return 'codex'; + if (env.ANTIGRAVITY_CONFIG_DIR) return 'antigravity'; + if (env.GEMINI_CONFIG_DIR) return 'gemini'; + if (env.KILO_CONFIG_DIR || env.KILO_CONFIG) return 'kilo'; + if (env.OPENCODE_CONFIG_DIR || env.OPENCODE_CONFIG) return 'opencode'; + if (env.CLAUDE_CONFIG_DIR) return 'claude'; + return 'claude'; +} + +// Absolute env-override candidates, mirroring the bash ENV_RUNTIME_DIRS block. +function envRuntimeDirs({ env, home }) { + const out = []; + const ex = (v) => expandHome(v, home); + if (env.CLAUDE_CONFIG_DIR) out.push(['claude', ex(env.CLAUDE_CONFIG_DIR)]); + if (env.ANTIGRAVITY_CONFIG_DIR) out.push(['antigravity', ex(env.ANTIGRAVITY_CONFIG_DIR)]); + if (env.GEMINI_CONFIG_DIR) out.push(['gemini', ex(env.GEMINI_CONFIG_DIR)]); + if (env.KILO_CONFIG_DIR) out.push(['kilo', ex(env.KILO_CONFIG_DIR)]); + else if (env.KILO_CONFIG) out.push(['kilo', path.dirname(ex(env.KILO_CONFIG))]); + else if (env.XDG_CONFIG_HOME) out.push(['kilo', path.join(ex(env.XDG_CONFIG_HOME), 'kilo')]); + if (env.OPENCODE_CONFIG_DIR) out.push(['opencode', ex(env.OPENCODE_CONFIG_DIR)]); + else if (env.OPENCODE_CONFIG) out.push(['opencode', path.dirname(ex(env.OPENCODE_CONFIG))]); + else if (env.XDG_CONFIG_HOME) out.push(['opencode', path.join(ex(env.XDG_CONFIG_HOME), 'opencode')]); + if (env.CODEX_HOME) out.push(['codex', ex(env.CODEX_HOME)]); + return out; +} + +// Stable reorder: entries whose runtime === preferred first, original order kept. +function preferFirst(entries, preferred) { + const pref = entries.filter(([rt]) => rt === preferred); + const rest = entries.filter(([rt]) => rt !== preferred); + return [...pref, ...rest]; +} + +/** + * Pure resolver. Returns { installedVersion, scope, runtime, gsdDir }. + */ +function resolveUpdateContext({ home, cwd, env = {}, fs, preferredConfigDir = '', preferredRuntime = '' }) { + // Expand a leading `~/` before any probe — the old inline bash ran + // `expand_home "$PREFERRED_CONFIG_DIR"` first, and a quoted shell path never + // tilde-expands, so a custom --config-dir like `~/custom-gsd` must resolve + // here or the fast path below silently misses the install (#498 parity). + preferredConfigDir = expandHome(preferredConfigDir, home); + const preferred = preferredRuntime || inferPreferredRuntime({ fs, env, preferredConfigDir }); + + // Fast path: a validated preferredConfigDir (custom --config-dir install). + if (preferredConfigDir && hasInstall(fs, preferredConfigDir)) { + const resolvedPref = path.resolve(preferredConfigDir); + let scope = 'GLOBAL'; + for (const [, reldir] of RUNTIME_DIRS) { + if (path.resolve(cwd, reldir) === resolvedPref) { scope = 'LOCAL'; break; } + } + return { + installedVersion: trustedVersionAt(fs, preferredConfigDir) || '0.0.0', + scope, + runtime: preferred, + gsdDir: preferredConfigDir, + }; + } + + const orderedEnv = preferFirst(envRuntimeDirs({ env, home }), preferred); + const orderedRuntime = preferFirst(RUNTIME_DIRS, preferred); + + // LOCAL probe (relative to cwd). + let localRuntime = '', localDir = ''; + for (const [rt, reldir] of orderedRuntime) { + const cand = path.resolve(cwd, reldir); + if (hasInstall(fs, cand)) { localRuntime = rt; localDir = cand; break; } + } + + // GLOBAL probe: absolute env candidates first, then $HOME-relative. + let globalRuntime = '', globalDir = ''; + for (const [rt, absdir] of orderedEnv) { + if (hasInstall(fs, absdir)) { globalRuntime = rt; globalDir = path.resolve(absdir); break; } + } + if (!globalRuntime) { + for (const [rt, reldir] of orderedRuntime) { + const cand = path.resolve(home, reldir); + if (hasInstall(fs, cand)) { globalRuntime = rt; globalDir = cand; break; } + } + } + + const localValid = trustedVersionAt(fs, localDir); + const isLocal = !!localValid && (!globalDir || localDir !== globalDir); + + if (isLocal) { + return { installedVersion: localValid, scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir }; + } + const globalValid = trustedVersionAt(fs, globalDir); + if (globalValid) { + return { installedVersion: globalValid, scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir }; + } + // A runtime dir was detected (VERSION or marker present) but is not a + // complete, valid install: keep scope/runtime/dir and report 0.0.0 so the + // caller re-installs (old inline `elif [ -n "$LOCAL_DIR" ]`). Apply the same + // same-path dedup as the trusted path so cwd===home does not misdetect as LOCAL. + if (localRuntime && (!globalDir || localDir !== globalDir)) { + return { installedVersion: '0.0.0', scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir }; + } + if (globalRuntime) { + return { installedVersion: '0.0.0', scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir }; + } + return { installedVersion: '0.0.0', scope: 'UNKNOWN', runtime: 'claude', gsdDir: '' }; +} + +/** + * CLI wiring: resolve against the real filesystem. + */ +function loadUpdateContext(opts = {}) { + const nodeFs = require('node:fs'); + const fs = { + exists: (p) => nodeFs.existsSync(p), + readFile: (p) => { try { return nodeFs.readFileSync(p, 'utf8'); } catch (e) { return null; } }, + }; + return resolveUpdateContext({ + home: opts.home || require('node:os').homedir(), + cwd: opts.cwd || process.cwd(), + env: opts.env || process.env, + fs, + preferredConfigDir: opts.preferredConfigDir || '', + preferredRuntime: opts.preferredRuntime || '', + }); +} + +module.exports = { + resolveUpdateContext, + loadUpdateContext, + RUNTIME_DIRS, + inferPreferredRuntime, + envRuntimeDirs, +}; diff --git a/get-shit-done/workflows/update.md b/get-shit-done/workflows/update.md index 22ab36fdb..969308918 100644 --- a/get-shit-done/workflows/update.md +++ b/get-shit-done/workflows/update.md @@ -9,285 +9,70 @@ Read all files referenced by the invoking prompt's execution_context before star -Detect whether GSD is installed locally or globally by checking both locations and validating install integrity. +Detect the installed GSD version, scope, runtime, and config dir. -First, derive `PREFERRED_CONFIG_DIR` and `PREFERRED_RUNTIME` from the invoking prompt's `execution_context` path: -- If the path contains `/get-shit-done/workflows/update.md`, strip that suffix and store the remainder as `PREFERRED_CONFIG_DIR` -- Path contains `/.codex/` -> `codex` -- Path contains `/.gemini/antigravity-ide/` -> `antigravity` -- Path contains `/.gemini/antigravity-cli/` -> `antigravity` -- Path contains `/.gemini/antigravity/` -> `antigravity` -- Path contains `/.agent/` -> `antigravity` (local Antigravity install dir; see bin/install.js getDirName('antigravity')) -- Path contains `/.gemini/` -> `gemini` -- Path contains `/.config/kilo/` or `/.kilo/`, or `PREFERRED_CONFIG_DIR` contains `kilo.json` / `kilo.jsonc` -> `kilo` -- Path contains `/.config/opencode/` or `/.opencode/`, or `PREFERRED_CONFIG_DIR` contains `opencode.json` / `opencode.jsonc` -> `opencode` -- Otherwise -> `claude` +First, derive `PREFERRED_CONFIG_DIR` and `PREFERRED_RUNTIME` from the invoking prompt's `execution_context` path — this is the one input only the workflow knows: +- If the path contains `/get-shit-done/workflows/update.md`, strip that suffix and store the remainder as `PREFERRED_CONFIG_DIR`. +- Infer `PREFERRED_RUNTIME` from the path: `/.codex/` -> `codex`; `/.gemini/antigravity-ide/`, `/.gemini/antigravity-cli/`, `/.gemini/antigravity/`, `/.agent/` -> `antigravity` (`.agent` is the local Antigravity install dir; see bin/install.js `getDirName('antigravity')`, #503); `/.gemini/` -> `gemini`; `/.config/kilo/` or `/.kilo/` -> `kilo`; `/.config/opencode/` or `/.opencode/` -> `opencode`; otherwise `claude`. -Use `PREFERRED_CONFIG_DIR` when available so custom `--config-dir` installs are checked before default locations. -Use `PREFERRED_RUNTIME` as the first runtime checked so `/gsd:update` targets the runtime that invoked it. - -Kilo config precedence must match the installer: `KILO_CONFIG_DIR` -> `dirname(KILO_CONFIG)` -> `XDG_CONFIG_HOME/kilo` -> `~/.config/kilo`. +Then resolve the install context via the deterministic projection (#498). **Do NOT re-derive scope, runtime, or version by hand** — `update-context` owns that cascade in tested code (`get-shit-done/bin/lib/update-context.cjs`), the same way `check-latest-version` owns the package name (#2992): ```bash -expand_home() { - case "$1" in - "~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;; - *) printf '%s\n' "$1" ;; +# Resolve gsd-tools.cjs WITHOUT yet knowing GSD_DIR. The running workflow lives +# at /get-shit-done/workflows/update.md, so its sibling +# bin/gsd-tools.cjs is the authoritative tool for THIS install. Fall back to a +# global copy, then to gsd-tools on PATH. +GSD_TOOLS="" +for cand in \ + "$PREFERRED_CONFIG_DIR/get-shit-done/bin/gsd-tools.cjs" \ + "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs"; do + if [ -n "$cand" ] && [ -f "$cand" ]; then GSD_TOOLS="$cand"; break; fi +done +# Last resort: the gsd-tools shim on PATH — resolved to its absolute path and +# invoked via the variable (never a bare `gsd-tools` command; see #2851). +if [ -z "$GSD_TOOLS" ] && command -v gsd-tools >/dev/null 2>&1; then + GSD_TOOLS="$(command -v gsd-tools)" +fi + +UC="" +if [ -n "$GSD_TOOLS" ]; then + case "$GSD_TOOLS" in + *.cjs) UC="$(node "$GSD_TOOLS" update-context --config-dir "$PREFERRED_CONFIG_DIR" --runtime "$PREFERRED_RUNTIME" --json 2>/dev/null)" ;; + *) UC="$("$GSD_TOOLS" update-context --config-dir "$PREFERRED_CONFIG_DIR" --runtime "$PREFERRED_RUNTIME" --json 2>/dev/null)" ;; esac -} - -# Runtime candidates: ":" stored as an array. -# Using an array instead of a space-separated string ensures correct -# iteration in both bash and zsh (zsh does not word-split unquoted -# variables by default). Fixes #1173. -RUNTIME_DIRS=( "claude:.claude" "opencode:.config/opencode" "opencode:.opencode" "antigravity:.gemini/antigravity-ide" "antigravity:.gemini/antigravity-cli" "antigravity:.gemini/antigravity" "antigravity:.agent" "gemini:.gemini" "kilo:.config/kilo" "kilo:.kilo" "codex:.codex" ) -ENV_RUNTIME_DIRS=() - -# PREFERRED_CONFIG_DIR / PREFERRED_RUNTIME should be set from execution_context -# before running this block. -if [ -n "$PREFERRED_CONFIG_DIR" ]; then - PREFERRED_CONFIG_DIR="$(expand_home "$PREFERRED_CONFIG_DIR")" - if [ -z "$PREFERRED_RUNTIME" ]; then - if [ -f "$PREFERRED_CONFIG_DIR/kilo.json" ] || [ -f "$PREFERRED_CONFIG_DIR/kilo.jsonc" ]; then - PREFERRED_RUNTIME="kilo" - elif [ -f "$PREFERRED_CONFIG_DIR/opencode.json" ] || [ -f "$PREFERRED_CONFIG_DIR/opencode.jsonc" ]; then - PREFERRED_RUNTIME="opencode" - elif [ -f "$PREFERRED_CONFIG_DIR/config.toml" ]; then - PREFERRED_RUNTIME="codex" - fi - fi fi -# If runtime is still unknown, infer from runtime env vars; fallback to claude. -if [ -z "$PREFERRED_RUNTIME" ]; then - if [ -n "$CODEX_HOME" ]; then - PREFERRED_RUNTIME="codex" - elif [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then - PREFERRED_RUNTIME="antigravity" - elif [ -n "$GEMINI_CONFIG_DIR" ]; then - PREFERRED_RUNTIME="gemini" - elif [ -n "$KILO_CONFIG_DIR" ]; then - PREFERRED_RUNTIME="kilo" - elif [ -n "$KILO_CONFIG" ]; then - PREFERRED_RUNTIME="kilo" - elif [ -n "$OPENCODE_CONFIG_DIR" ] || [ -n "$OPENCODE_CONFIG" ]; then - PREFERRED_RUNTIME="opencode" - elif [ -n "$CLAUDE_CONFIG_DIR" ]; then - PREFERRED_RUNTIME="claude" - else - PREFERRED_RUNTIME="claude" - fi -fi - -# If execution_context already points at an installed config dir, trust it first. -# This covers custom --config-dir installs that do not live under the default -# runtime directories. -if [ -n "$PREFERRED_CONFIG_DIR" ] && { [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION" ] || [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/workflows/update.md" ]; }; then - INSTALL_SCOPE="GLOBAL" - # Normalize a path for comparison: on Windows with Git Bash, pwd returns - # POSIX-style /c/Users/... but PREFERRED_CONFIG_DIR may carry C:/Users/... - # Convert Windows drive-letter paths to POSIX form so the comparison works - # on both Windows (Git Bash) and POSIX systems. - normalize_path() { - local p="$1" - case "$p" in - [A-Za-z]:/*) - local drive rest - drive="${p%%:*}" - rest="${p#?:}" - p="/$(printf '%s' "$drive" | tr '[:upper:]' '[:lower:]')$rest" - ;; - esac - printf '%s' "$p" - } - normalized_preferred="$(normalize_path "$PREFERRED_CONFIG_DIR")" - for dir in .claude .config/opencode .opencode .gemini/antigravity-ide .gemini/antigravity-cli .gemini/antigravity .agent .gemini .config/kilo .kilo .codex; do - resolved_local="$(cd "./$dir" 2>/dev/null && pwd)" - normalized_local="$(normalize_path "$resolved_local")" - if [ -n "$normalized_local" ] && [ "$normalized_local" = "$normalized_preferred" ]; then - INSTALL_SCOPE="LOCAL" - break - fi - done - - if [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION"; then - INSTALLED_VERSION="$(cat "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION")" - else - INSTALLED_VERSION="0.0.0" - fi - - echo "$INSTALLED_VERSION" - echo "$INSTALL_SCOPE" - echo "${PREFERRED_RUNTIME:-claude}" - # 4-line output contract (#2993 CR): early-return path must also emit - # GSD_DIR or downstream check_latest_version misreads the install as - # UNKNOWN. PREFERRED_CONFIG_DIR is the resolved config dir we just - # validated above (line 95-96); it is the right GSD_DIR value for - # this fast path. - echo "$PREFERRED_CONFIG_DIR" - exit 0 -fi - -# Absolute global candidates from env overrides (covers custom config dirs). -if [ -n "$CLAUDE_CONFIG_DIR" ]; then - ENV_RUNTIME_DIRS+=( "claude:$(expand_home "$CLAUDE_CONFIG_DIR")" ) -fi -if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then - ENV_RUNTIME_DIRS+=( "antigravity:$(expand_home "$ANTIGRAVITY_CONFIG_DIR")" ) -fi -if [ -n "$GEMINI_CONFIG_DIR" ]; then - ENV_RUNTIME_DIRS+=( "gemini:$(expand_home "$GEMINI_CONFIG_DIR")" ) -fi -if [ -n "$KILO_CONFIG_DIR" ]; then - ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$KILO_CONFIG_DIR")" ) -elif [ -n "$KILO_CONFIG" ]; then - ENV_RUNTIME_DIRS+=( "kilo:$(dirname "$(expand_home "$KILO_CONFIG")")" ) -elif [ -n "$XDG_CONFIG_HOME" ]; then - ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$XDG_CONFIG_HOME")/kilo" ) -fi -if [ -n "$OPENCODE_CONFIG_DIR" ]; then - ENV_RUNTIME_DIRS+=( "opencode:$(expand_home "$OPENCODE_CONFIG_DIR")" ) -elif [ -n "$OPENCODE_CONFIG" ]; then - ENV_RUNTIME_DIRS+=( "opencode:$(dirname "$(expand_home "$OPENCODE_CONFIG")")" ) -elif [ -n "$XDG_CONFIG_HOME" ]; then - ENV_RUNTIME_DIRS+=( "opencode:$(expand_home "$XDG_CONFIG_HOME")/opencode" ) -fi -if [ -n "$CODEX_HOME" ]; then - ENV_RUNTIME_DIRS+=( "codex:$(expand_home "$CODEX_HOME")" ) -fi - -# Reorder entries so preferred runtime is checked first. -ORDERED_RUNTIME_DIRS=() -for entry in "${RUNTIME_DIRS[@]}"; do - runtime="${entry%%:*}" - if [ "$runtime" = "$PREFERRED_RUNTIME" ]; then - ORDERED_RUNTIME_DIRS+=( "$entry" ) - fi -done -ORDERED_ENV_RUNTIME_DIRS=() -for entry in "${ENV_RUNTIME_DIRS[@]}"; do - runtime="${entry%%:*}" - if [ "$runtime" = "$PREFERRED_RUNTIME" ]; then - ORDERED_ENV_RUNTIME_DIRS+=( "$entry" ) - fi -done -for entry in "${ENV_RUNTIME_DIRS[@]}"; do - runtime="${entry%%:*}" - if [ "$runtime" != "$PREFERRED_RUNTIME" ]; then - ORDERED_ENV_RUNTIME_DIRS+=( "$entry" ) - fi -done -for entry in "${RUNTIME_DIRS[@]}"; do - runtime="${entry%%:*}" - if [ "$runtime" != "$PREFERRED_RUNTIME" ]; then - ORDERED_RUNTIME_DIRS+=( "$entry" ) - fi -done - -# Check local first (takes priority only if valid and distinct from global) -LOCAL_VERSION_FILE="" LOCAL_MARKER_FILE="" LOCAL_DIR="" LOCAL_RUNTIME="" -for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do - runtime="${entry%%:*}" - dir="${entry#*:}" - if [ -f "./$dir/get-shit-done/VERSION" ] || [ -f "./$dir/get-shit-done/workflows/update.md" ]; then - LOCAL_RUNTIME="$runtime" - LOCAL_VERSION_FILE="./$dir/get-shit-done/VERSION" - LOCAL_MARKER_FILE="./$dir/get-shit-done/workflows/update.md" - LOCAL_DIR="$(cd "./$dir" 2>/dev/null && pwd)" - break - fi -done - -GLOBAL_VERSION_FILE="" GLOBAL_MARKER_FILE="" GLOBAL_DIR="" GLOBAL_RUNTIME="" -for entry in "${ORDERED_ENV_RUNTIME_DIRS[@]}"; do - runtime="${entry%%:*}" - dir="${entry#*:}" - if [ -f "$dir/get-shit-done/VERSION" ] || [ -f "$dir/get-shit-done/workflows/update.md" ]; then - GLOBAL_RUNTIME="$runtime" - GLOBAL_VERSION_FILE="$dir/get-shit-done/VERSION" - GLOBAL_MARKER_FILE="$dir/get-shit-done/workflows/update.md" - GLOBAL_DIR="$(cd "$dir" 2>/dev/null && pwd)" - break - fi -done - -if [ -z "$GLOBAL_RUNTIME" ]; then - for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do - runtime="${entry%%:*}" - dir="${entry#*:}" - if [ -f "$HOME/$dir/get-shit-done/VERSION" ] || [ -f "$HOME/$dir/get-shit-done/workflows/update.md" ]; then - GLOBAL_RUNTIME="$runtime" - GLOBAL_VERSION_FILE="$HOME/$dir/get-shit-done/VERSION" - GLOBAL_MARKER_FILE="$HOME/$dir/get-shit-done/workflows/update.md" - GLOBAL_DIR="$(cd "$HOME/$dir" 2>/dev/null && pwd)" - break - fi - done -fi - -# Only treat as LOCAL if the resolved paths differ (prevents misdetection when CWD=$HOME) -IS_LOCAL=false -if [ -n "$LOCAL_VERSION_FILE" ] && [ -f "$LOCAL_VERSION_FILE" ] && [ -f "$LOCAL_MARKER_FILE" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$LOCAL_VERSION_FILE"; then - if [ -z "$GLOBAL_DIR" ] || [ "$LOCAL_DIR" != "$GLOBAL_DIR" ]; then - IS_LOCAL=true - fi -fi - -if [ "$IS_LOCAL" = true ]; then - INSTALLED_VERSION="$(cat "$LOCAL_VERSION_FILE")" - INSTALL_SCOPE="LOCAL" - TARGET_RUNTIME="$LOCAL_RUNTIME" - RESOLVED_GSD_DIR="$LOCAL_DIR" -elif [ -n "$GLOBAL_VERSION_FILE" ] && [ -f "$GLOBAL_VERSION_FILE" ] && [ -f "$GLOBAL_MARKER_FILE" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$GLOBAL_VERSION_FILE"; then - INSTALLED_VERSION="$(cat "$GLOBAL_VERSION_FILE")" - INSTALL_SCOPE="GLOBAL" - TARGET_RUNTIME="$GLOBAL_RUNTIME" - RESOLVED_GSD_DIR="$GLOBAL_DIR" -elif [ -n "$LOCAL_RUNTIME" ] && [ -f "$LOCAL_MARKER_FILE" ]; then - # Runtime detected but VERSION missing/corrupt: treat as unknown version, keep runtime target - INSTALLED_VERSION="0.0.0" - INSTALL_SCOPE="LOCAL" - TARGET_RUNTIME="$LOCAL_RUNTIME" - RESOLVED_GSD_DIR="$LOCAL_DIR" -elif [ -n "$GLOBAL_RUNTIME" ] && [ -f "$GLOBAL_MARKER_FILE" ]; then - INSTALLED_VERSION="0.0.0" - INSTALL_SCOPE="GLOBAL" - TARGET_RUNTIME="$GLOBAL_RUNTIME" - RESOLVED_GSD_DIR="$GLOBAL_DIR" +if [ -n "$UC" ]; then + INSTALLED_VERSION="$(printf '%s' "$UC" | jq -r '.installedVersion')" + INSTALL_SCOPE="$(printf '%s' "$UC" | jq -r '.scope')" + TARGET_RUNTIME="$(printf '%s' "$UC" | jq -r '.runtime')" + GSD_DIR="$(printf '%s' "$UC" | jq -r '.gsdDir')" else + # No tool resolvable / projection failed -> treat as a fresh install. INSTALLED_VERSION="0.0.0" INSTALL_SCOPE="UNKNOWN" TARGET_RUNTIME="claude" - RESOLVED_GSD_DIR="" + GSD_DIR="" fi echo "$INSTALLED_VERSION" echo "$INSTALL_SCOPE" echo "$TARGET_RUNTIME" -echo "$RESOLVED_GSD_DIR" +echo "$GSD_DIR" ``` Parse output: - Line 1 = installed version (`0.0.0` means unknown version) - Line 2 = install scope (`LOCAL`, `GLOBAL`, or `UNKNOWN`) -- Line 3 = target runtime (`claude`, `opencode`, `gemini`, `kilo`, or `codex`) -- Line 4 = resolved GSD config dir (e.g. `/Users/me/.claude`, `/Users/me/.gemini`); empty if scope is `UNKNOWN`. Capture this as `GSD_DIR` and pass it to subsequent steps so they don't have to re-derive the runtime path. -- If scope is `UNKNOWN`, proceed to install step using `--claude --global` fallback. +- Line 3 = target runtime (`claude`, `opencode`, `gemini`, `kilo`, `codex`, `antigravity`) +- Line 4 = resolved GSD config dir (e.g. `/Users/me/.claude`, `/Users/me/.gemini`); empty if scope is `UNKNOWN`. Capture this as `GSD_DIR` and pass it to subsequent steps so they don't re-derive the runtime path. +- If scope is `UNKNOWN`, proceed to install using the `--claude --global` fallback. + +`update-context` reproduces the previous detection cascade — preferred-config-dir fast path, local-over-global with same-path dedup (so `CWD=$HOME` does not misdetect as LOCAL), env-var overrides (`CLAUDE_CONFIG_DIR`, `OPENCODE_CONFIG_DIR`, `KILO_CONFIG`, `XDG_CONFIG_HOME`, `CODEX_HOME`, …), and semver validation — but as a tested projection rather than ~280 lines of inline bash. Branch coverage lives in `tests/issue-498-update-context.test.cjs`. If multiple runtime installs are detected and the invoking runtime cannot be determined from execution_context, ask the user which runtime to update before running install. -**If VERSION file missing:** -``` -## GSD Update - -**Installed version:** Unknown - -Your installation doesn't include version tracking. - -Running fresh install... -``` - -Proceed to install step (treat as version 0.0.0 for comparison). +**If VERSION file missing (version resolves to `0.0.0`):** report the installed version as Unknown and proceed to install (treated as `0.0.0` for comparison). @@ -466,16 +251,10 @@ First, resolve the config directory (`RUNTIME_DIR`) from the install scope detected in `get_installed_version`: ```bash -# RUNTIME_DIR is the resolved config directory (e.g. ~/.config/opencode, ~/.gemini) -# It should already be set from get_installed_version as GLOBAL_DIR or LOCAL_DIR. -# Use the appropriate variable based on INSTALL_SCOPE. -if [ "$INSTALL_SCOPE" = "LOCAL" ]; then - RUNTIME_DIR="$LOCAL_DIR" -elif [ "$INSTALL_SCOPE" = "GLOBAL" ]; then - RUNTIME_DIR="$GLOBAL_DIR" -else - RUNTIME_DIR="" -fi +# RUNTIME_DIR is the resolved config directory (e.g. ~/.config/opencode, ~/.gemini). +# get_installed_version emits it as GSD_DIR (LOCAL or GLOBAL install dir, or empty +# when scope is UNKNOWN). Empty RUNTIME_DIR skips the backup below. +RUNTIME_DIR="$GSD_DIR" ``` If `RUNTIME_DIR` is empty or does not exist, skip this step (no config dir to diff --git a/hooks/gsd-check-update-worker.js b/hooks/gsd-check-update-worker.js index c7ed4c6c8..100d270a0 100644 --- a/hooks/gsd-check-update-worker.js +++ b/hooks/gsd-check-update-worker.js @@ -11,11 +11,14 @@ const fs = require('fs'); const path = require('path'); -const { execFileSync } = require('child_process'); const { isSemverNewer } = require('../get-shit-done/bin/lib/semver-compare.cjs'); -// Derive the published package name from package.json so this survives -// future renames and always matches the actual registry entry (#378). -const PACKAGE_NAME = require('../package.json').name; +// Latest-version lookup is delegated to the single deterministic adapter +// (#498). checkLatestVersion() owns the npm-view call, the timeout/semver +// policy, and the package name — sourced from the baked Package Identity seam. +// The previous `require('../package.json').name` (#378) resolved to undefined +// in the installed tree (only a {"type":"commonjs"} marker ships), so the +// background check never reported updates. +const { checkLatestVersion } = require('../get-shit-done/bin/check-latest-version.cjs'); // Authoritative list of managed hooks — shared with tests to retire source-grep // assertions (pending-migration-to-typed-ir [#455]). const { MANAGED_HOOKS } = require('./managed-hooks-registry.cjs'); @@ -69,20 +72,14 @@ if (configDir) { } catch (e) {} } +// Single adapter for the registry lookup (#498). checkLatestVersion() routes +// through the shell-projection seam, which already owns the Windows shell-flag +// policy, the timeout, and semver validation. A non-ok result leaves latest +// null, exactly as the previous inline try/catch did. let latest = null; try { - latest = execFileSync('npm', ['view', PACKAGE_NAME, 'version'], { - encoding: 'utf8', - timeout: 10000, - windowsHide: true, - // On Windows, 'npm' is distributed as npm.cmd. Node's execFileSync does - // not apply PATHEXT resolution and looks for a literal 'npm' binary, - // failing with ENOENT. Setting shell:true on Windows routes through - // cmd.exe which resolves npm.cmd via PATHEXT. - // POSIX (Linux/macOS) is left untouched — no shell spawn, no extra - // signal/exit-code semantics, no overhead. - shell: process.platform === 'win32', - }).trim(); + const lv = checkLatestVersion(); + if (lv && lv.ok) latest = lv.version; } catch (e) {} const result = { diff --git a/package.json b/package.json index 35fc800f6..830f096f8 100644 --- a/package.json +++ b/package.json @@ -67,9 +67,11 @@ "sync:launcher": "node scripts/sync-runtime-launcher.cjs", "check:env": "node scripts/check-env.cjs", "check:alias-drift": "node scripts/check-alias-drift.cjs", + "check:identity-drift": "node scripts/lint-package-identity-drift.cjs", "check:integrity": "node scripts/check-npm-integrity.cjs", - "build": "npm run build:hooks", + "build": "npm run generate:identity && npm run build:hooks", "build:hooks": "node scripts/build-hooks.js", + "generate:identity": "node scripts/generate-package-identity.cjs", "prepublishOnly": "npm run build:hooks", "pretest": "npm run lint:skill-deps", "pretest:coverage": "npm run lint:skill-deps", diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index d1d34d455..3ed429c6d 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -25,7 +25,7 @@ const { compareSemverCore, isStableTripletSemver, } = require('../../get-shit-done/bin/lib/semver-compare.cjs'); -const { PACKAGE_NAME } = require('../../get-shit-done/bin/lib/package-identity.cjs'); +const { packageName, repoSlug: defaultRepoSlug } = require('../../get-shit-done/bin/lib/package-identity.cjs'); function parseArgs(argv) { const opts = { @@ -37,8 +37,8 @@ function parseArgs(argv) { toRef: null, changelog: null, output: null, - repoSlug: 'open-gsd/get-shit-done-redux', - installCommand: `npx ${PACKAGE_NAME}@latest`, + repoSlug: defaultRepoSlug, + installCommand: `npx ${packageName}@latest`, json: false, }; if (argv.length === 0) return { ok: true, opts }; diff --git a/scripts/changeset/github-release-notes.cjs b/scripts/changeset/github-release-notes.cjs index f08ded6ef..101699154 100644 --- a/scripts/changeset/github-release-notes.cjs +++ b/scripts/changeset/github-release-notes.cjs @@ -4,7 +4,7 @@ const cp = require('node:child_process'); const path = require('node:path'); const { parseFragment } = require('./parse.cjs'); -const { PACKAGE_NAME } = require('../../get-shit-done/bin/lib/package-identity.cjs'); +const { packageName, repoSlug: defaultRepoSlug } = require('../../get-shit-done/bin/lib/package-identity.cjs'); const SECTION_ORDER = ['Fixed', 'Added', 'Changed', 'Deprecated', 'Removed', 'Security']; @@ -146,8 +146,8 @@ function serializeGithubReleaseNotes({ ir, fromRef, toRef, - repoSlug = 'open-gsd/get-shit-done-redux', - installCommand = `npx ${PACKAGE_NAME}@latest`, + repoSlug = defaultRepoSlug, + installCommand = `npx ${packageName}@latest`, }) { if (installCommand.includes('`')) { throw new Error('installCommand cannot contain backtick characters'); diff --git a/scripts/generate-package-identity.cjs b/scripts/generate-package-identity.cjs new file mode 100644 index 000000000..561d6229b --- /dev/null +++ b/scripts/generate-package-identity.cjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Single source for GSD's published-package coordinates (issue #498). + * + * `deriveIdentity(pkg)` is the pure core: it turns a parsed package.json into + * the coordinate record every consumer needs. The generated runtime module + * `get-shit-done/bin/lib/package-identity.cjs` bakes those values at build + * time, because the installed tree carries only a synthetic + * `{"type":"commonjs"}` package.json (no `.name`) — so a runtime + * `require('package.json').name` resolves to `undefined` (the #378 bug this + * seam retires). Baking from package.json reconciles #378 (renames survive) + * with #2992 (the value is never an LLM runtime choice). + */ + +/** + * Parse `owner/name` out of a package.json `repository.url`, stripping the + * `git+` prefix and `.git` suffix npm conventionally adds. + */ +function parseRepoSlug(repository) { + const url = typeof repository === 'string' ? repository : (repository && repository.url) || ''; + const m = url.replace(/^git\+/, '').replace(/\.git$/, '').match(/github\.com[/:]([^/]+\/[^/]+)$/); + return m ? m[1] : ''; +} + +/** + * Pure: package.json object -> the package identity coordinates. + */ +function deriveIdentity(pkg = {}) { + const packageName = pkg.name || ''; + const binName = pkg.bin ? Object.keys(pkg.bin)[0] || '' : ''; + const repoSlug = parseRepoSlug(pkg.repository); + const repoUrl = repoSlug ? `https://github.com/${repoSlug}` : ''; + const changelogRawUrl = repoSlug + ? `https://raw.githubusercontent.com/${repoSlug}/main/CHANGELOG.md` + : ''; + return { packageName, binName, repoSlug, repoUrl, changelogRawUrl }; +} + +/** + * Pure: format the `npx` fallback install command. Shape matches the literal + * the update workflow embeds: `npx -y --package=@latest -- + * [--] --`. The runtime flag is omitted when not supplied. + * + * This function is the canonical source — `render()` serializes it verbatim + * into the generated module, so the runtime copy can never drift from it. + */ +function formatManualInstall({ packageName, binName, scope, runtime } = {}) { + const runtimeFlag = runtime ? ` --${runtime}` : ''; + return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`; +} + +const GENERATED_HEADER = + '// @generated by scripts/generate-package-identity.cjs from package.json — DO NOT EDIT.\n' + + '// Single source for GSD package coordinates (issue #498). Regenerate with:\n' + + '// node scripts/generate-package-identity.cjs\n'; + +/** + * Render the generated runtime module text for a derived identity. Values are + * baked as literals; `formatManualInstall` is embedded by `.toString()` so the + * runtime command builder is byte-identical to the tested source above. + */ +function render(identity) { + const { packageName, binName, repoSlug, repoUrl, changelogRawUrl } = identity; + const j = (v) => JSON.stringify(v); + return ( + GENERATED_HEADER + + "'use strict';\n\n" + + `const packageName = ${j(packageName)};\n` + + `const binName = ${j(binName)};\n` + + `const repoSlug = ${j(repoSlug)};\n` + + `const repoUrl = ${j(repoUrl)};\n` + + `const changelogRawUrl = ${j(changelogRawUrl)};\n\n` + + `${formatManualInstall.toString()}\n\n` + + 'function manualInstallCommand(opts = {}) {\n' + + ' return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });\n' + + '}\n\n' + + 'module.exports = Object.freeze({\n' + + ' packageName,\n' + + ' // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it\n' + + ' // survives the installed tree’s synthetic package.json (fixes the #378 undefined).\n' + + ' PACKAGE_NAME: packageName,\n' + + ' binName,\n' + + ' repoSlug,\n' + + ' repoUrl,\n' + + ' changelogRawUrl,\n' + + ' manualInstallCommand,\n' + + '});\n' + ); +} + +function main() { + const fs = require('node:fs'); + const path = require('node:path'); + const pkg = require(path.join(__dirname, '..', 'package.json')); + const out = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'package-identity.cjs'); + fs.writeFileSync(out, render(deriveIdentity(pkg))); + process.stdout.write(`wrote ${path.relative(path.join(__dirname, '..'), out)}\n`); +} + +if (require.main === module) main(); + +module.exports = { deriveIdentity, parseRepoSlug, formatManualInstall, render, main }; diff --git a/scripts/lint-package-identity-drift.cjs b/scripts/lint-package-identity-drift.cjs new file mode 100644 index 000000000..bf9b04da1 --- /dev/null +++ b/scripts/lint-package-identity-drift.cjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Drift-guard lint for the Package Identity seam (issue #498). + * + * The seam (`get-shit-done/bin/lib/package-identity.cjs`, derived from + * package.json) is the single source of GSD's published coordinates. Many + * runtime surfaces still carry a literal copy of those coordinates because + * they cannot `require()` the seam at runtime: the bash launcher snippet (and + * its byte-equal copies across ~85 workflows, kept in lockstep by the + * runtime-launcher parity test) and the installer's user-facing install/help + * strings. + * + * This lint makes those literals *value-checked*: every GSD package/repo + * coordinate that appears as a literal must equal the seam's current value. + * It passes today (the literals are correct) and FAILS the moment a repoint is + * not propagated — rename package.json, regenerate the seam, and every stale + * literal is reported until updated. That is what turns a repoint into a + * one-line change with mechanical enforcement. + * + * Scope: the runtime/code surface (bin/, hooks/, scripts/, get-shit-done/). + * Pure-prose docs and localized READMEs are intentionally out of scope. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +// A GSD package coordinate: a scoped npm name whose package part contains +// "get-shit-done" (so @opengsd/gsd-sdk and unrelated scopes never match). +const PACKAGE_RE = /@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]*get-shit-done[A-Za-z0-9._-]*/g; +// A GSD repo slug, only inside a GitHub URL context so it never overlaps the +// scoped package literal above. The `.git` suffix is trimmed before compare. +const SLUG_RE = /(?:github\.com[/:]|raw\.githubusercontent\.com\/)([A-Za-z0-9._-]+\/[A-Za-z0-9._-]*get-shit-done[A-Za-z0-9._-]*)/g; + +function lineOf(text, index) { + let line = 1; + for (let i = 0; i < index && i < text.length; i++) { + if (text[i] === '\n') line++; + } + return line; +} + +/** + * Pure: find every GSD coordinate literal in `text` that does not match the + * expected seam values. Returns [{ kind, found, expected, line }]. + */ +function findCoordinateDrift(text, { packageName, repoSlug }) { + const out = []; + for (const m of text.matchAll(PACKAGE_RE)) { + if (m[0] !== packageName) { + out.push({ kind: 'package', found: m[0], expected: packageName, line: lineOf(text, m.index) }); + } + } + for (const m of text.matchAll(SLUG_RE)) { + const slug = m[1].replace(/\.git$/, ''); + if (slug !== repoSlug) { + out.push({ kind: 'slug', found: slug, expected: repoSlug, line: lineOf(text, m.index) }); + } + } + return out; +} + +// Directories scanned, relative to repo root. +const SCAN_DIRS = ['bin', 'hooks', 'scripts', 'get-shit-done']; +const SCAN_EXT = new Set(['.js', '.cjs', '.sh', '.md']); +// Files exempt because they ARE the source of truth / the tooling that defines +// the coordinate patterns. The generated seam holds the correct value by +// construction; the generator and this lint carry regex/templates, not stray +// literals. +const EXEMPT = new Set([ + path.join('get-shit-done', 'bin', 'lib', 'package-identity.cjs'), + path.join('scripts', 'generate-package-identity.cjs'), + path.join('scripts', 'lint-package-identity-drift.cjs'), +]); + +function walk(dir, acc) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + return acc; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(full, acc); + } else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) { + acc.push(full); + } + } + return acc; +} + +/** + * Scan the repo's runtime/code surface and return all coordinate drift, each + * annotated with the repo-relative file path. + */ +function scanRepo(root) { + const seam = require(path.join(root, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs')); + const expected = { packageName: seam.packageName, repoSlug: seam.repoSlug }; + const violations = []; + for (const dir of SCAN_DIRS) { + const files = walk(path.join(root, dir), []); + for (const file of files) { + const rel = path.relative(root, file); + if (EXEMPT.has(rel)) continue; + let text; + try { + text = fs.readFileSync(file, 'utf8'); + } catch (e) { + continue; + } + for (const d of findCoordinateDrift(text, expected)) { + violations.push({ file: rel, ...d }); + } + } + } + return violations; +} + +function main() { + const root = path.join(__dirname, '..'); + const violations = scanRepo(root); + if (violations.length === 0) { + process.stdout.write('ok identity-drift: all GSD coordinate literals match the seam\n'); + return; + } + process.stderr.write('identity-drift: stale GSD coordinate literal(s) found.\n'); + process.stderr.write('Repoint by editing package.json, then `node scripts/generate-package-identity.cjs`,\n'); + process.stderr.write('and update the value-checked materialization sites below:\n'); + for (const d of violations) { + process.stderr.write(` ${d.file}:${d.line} ${d.kind} '${d.found}' != '${d.expected}'\n`); + } + process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { findCoordinateDrift, scanRepo }; diff --git a/tests/bug-3608-antigravity-update-runtime-classification.test.cjs b/tests/bug-3608-antigravity-update-runtime-classification.test.cjs index 901f2b18e..4c7b85a3c 100644 --- a/tests/bug-3608-antigravity-update-runtime-classification.test.cjs +++ b/tests/bug-3608-antigravity-update-runtime-classification.test.cjs @@ -1,204 +1,111 @@ -// allow-test-rule: source-text-is-the-product -// update.md is loaded verbatim by the runtime as the /gsd-update workflow. -// The bash blocks inside it ARE the deployed program — the agent runs them. -// Asserting on the structural shape of those bash arrays is asserting on the -// deployed contract, identical to asserting on a workflow's instructions. - /** - * Bug #3608: get-shit-done/workflows/update.md does not model Antigravity as - * a first-class runtime, so /gsd-update invoked from an Antigravity install - * (~/.gemini/antigravity) classifies the runtime as base Gemini. + * Bug #3608: /gsd:update must model Antigravity as a first-class runtime, so an + * Antigravity install (~/.gemini/antigravity*) is not misclassified as base + * Gemini. * - * The installer (bin/install.js) and SDK already treat Antigravity as a - * distinct runtime with its own config dir (~/.gemini/antigravity), env var - * (ANTIGRAVITY_CONFIG_DIR), and CLI flag (--antigravity). update.md must - * agree, or /gsd-update routes Antigravity installs through the base Gemini - * path. + * The installer (bin/install.js) and SDK already treat Antigravity as a distinct + * runtime with its own config dirs, env var (ANTIGRAVITY_CONFIG_DIR), and CLI + * flag (--antigravity). The update flow must agree. * - * Order matters: every bash array / env-var ladder / scan list that contains - * a Gemini entry MUST list the more-specific Antigravity entry first. + * Relocation (#498): the update flow's runtime/scope detection moved out of + * ~280 lines of inline bash in update.md into the tested projection + * `get-shit-done/bin/lib/update-context.cjs` (resolveUpdateContext). The + * antigravity-first-class contract now lives there as data + behavior, so this + * test asserts it on the projection. The only piece still authored in update.md + * is the execution_context path classification (prose the agent applies), which + * this test still checks for antigravity-before-gemini ordering. + * + * Order matters: every probe list / env ladder that contains a Gemini entry + * MUST place the more-specific Antigravity entry first, else an install with + * both signals present falls through to gemini. */ 'use strict'; +process.env.GSD_TEST_MODE = '1'; const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const UPDATE_MD = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'); +const ROOT = path.join(__dirname, '..'); +const { + RUNTIME_DIRS, + inferPreferredRuntime, + envRuntimeDirs, + resolveUpdateContext, +} = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'update-context.cjs')); +const UPDATE_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'update.md'); -function readUpdateMd() { - return fs.readFileSync(UPDATE_MD, 'utf-8'); +function runtimeOrder() { + return RUNTIME_DIRS.map(([rt]) => rt); } - -// Parse a single bash array literal from a line like: -// RUNTIME_DIRS=( "claude:.claude" "gemini:.gemini" ... ) -// Returns the entries as an ordered list of "runtime:dir" strings. -function parseBashArray(content, varName) { - const re = new RegExp(`${varName}=\\(\\s*([^)]*?)\\s*\\)`, 'm'); - const m = content.match(re); - if (!m) return null; - return [...m[1].matchAll(/"([^"]+)"/g)].map((mm) => mm[1]); -} - -// Extract the runtime tokens (the part before ':') in declaration order. -function runtimeTokens(entries) { - return entries.map((e) => e.split(':')[0]); -} - function firstIndex(arr, token) { return arr.indexOf(token); } -describe('bug #3608: update.md models Antigravity as a first-class runtime', () => { - const content = readUpdateMd(); - - test('RUNTIME_DIRS contains antigravity before gemini', () => { - const entries = parseBashArray(content, 'RUNTIME_DIRS'); - assert.ok(entries, 'RUNTIME_DIRS array literal not found in update.md'); - - const tokens = runtimeTokens(entries); - const antIdx = firstIndex(tokens, 'antigravity'); - const gemIdx = firstIndex(tokens, 'gemini'); - - assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity entry'); - assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini entry'); - assert.ok( - antIdx < gemIdx, - `antigravity must precede gemini in RUNTIME_DIRS (got antigravity@${antIdx}, gemini@${gemIdx}). ` + - `Order matters: classification iterates this list and the first match wins.`, - ); +describe('bug #3608 / #498: update-context models Antigravity as a first-class runtime', () => { + test('RUNTIME_DIRS lists antigravity before gemini', () => { + const order = runtimeOrder(); + const antIdx = firstIndex(order, 'antigravity'); + const gemIdx = firstIndex(order, 'gemini'); + assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity'); + assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini'); + assert.ok(antIdx < gemIdx, `antigravity (@${antIdx}) must precede gemini (@${gemIdx}) — first match wins`); }); - test('RUNTIME_DIRS includes antigravity entries for 2.x + legacy dirs', () => { - const entries = parseBashArray(content, 'RUNTIME_DIRS'); - assert.ok(entries); - - const antEntries = entries.filter((e) => e.startsWith('antigravity:')); - assert.ok(antEntries.length >= 1, 'antigravity entry missing'); - assert.ok( - antEntries.includes('antigravity:.gemini/antigravity-ide'), - 'RUNTIME_DIRS must include antigravity:.gemini/antigravity-ide', - ); - assert.ok( - antEntries.includes('antigravity:.gemini/antigravity-cli'), - 'RUNTIME_DIRS must include antigravity:.gemini/antigravity-cli', - ); - assert.ok( - antEntries.includes('antigravity:.gemini/antigravity'), - 'RUNTIME_DIRS must include legacy antigravity:.gemini/antigravity fallback', - ); - }); - - test('PREFERRED_RUNTIME env-var inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => { - // Extract the inference block — the if/elif ladder that maps env vars to runtime. - // Match from the comment marker through the closing `fi` of the inference block. - const blockMatch = content.match( - /If runtime is still unknown, infer from runtime env vars[\s\S]*?\r?\nfi\r?\n/, - ); - assert.ok(blockMatch, 'env-var inference block not found'); - - const block = blockMatch[0]; - const antPos = block.indexOf('ANTIGRAVITY_CONFIG_DIR'); - const gemPos = block.indexOf('GEMINI_CONFIG_DIR'); - - assert.notStrictEqual( - antPos, - -1, - 'env-var inference must check ANTIGRAVITY_CONFIG_DIR (used by bin/install.js)', - ); - assert.notStrictEqual(gemPos, -1, 'env-var inference must check GEMINI_CONFIG_DIR'); - assert.ok( - antPos < gemPos, - `ANTIGRAVITY_CONFIG_DIR must be checked before GEMINI_CONFIG_DIR ` + - `(got antigravity@${antPos}, gemini@${gemPos}) — otherwise an Antigravity ` + - `install with both env vars set falls through to gemini.`, - ); - }); - - test('ENV_RUNTIME_DIRS appends an antigravity entry when ANTIGRAVITY_CONFIG_DIR is set', () => { - // Match the `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then` block and require an - // `ENV_RUNTIME_DIRS+=( "antigravity:..." )` push inside it. The pushed value - // may contain nested `"$VAR"` quotes (bash command substitution), so we - // don't try to match the full string literal — just the leading runtime - // tag `"antigravity:`. - const re = /if \[ -n "\$ANTIGRAVITY_CONFIG_DIR" \];\s*then\s+ENV_RUNTIME_DIRS\+=\(\s*"antigravity:/; - assert.match( - content, - re, - 'expected `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then ENV_RUNTIME_DIRS+=( "antigravity:..." )`', - ); - }); - - test('local-scope scan dir list includes 2.x antigravity dirs before .gemini', () => { - // Lines like: for dir in .claude .config/opencode .opencode .gemini ... - // The first iteration of the local scan ranges over a hardcoded list. - const forLoops = [...content.matchAll(/for dir in ([^;]+); do/g)]; - assert.ok(forLoops.length > 0, 'no `for dir in ...; do` scan loops found'); - - for (const m of forLoops) { - const tokens = m[1].trim().split(/\s+/); - const antLegacyIdx = tokens.indexOf('.gemini/antigravity'); - const antIdeIdx = tokens.indexOf('.gemini/antigravity-ide'); - const antCliIdx = tokens.indexOf('.gemini/antigravity-cli'); - const gemIdx = tokens.indexOf('.gemini'); - if (gemIdx === -1) continue; // scan loop without .gemini — irrelevant - assert.notStrictEqual( - antIdeIdx, - -1, - `scan loop "for dir in ${m[1].trim()}" mentions .gemini but not .gemini/antigravity-ide — ` + - `the more-specific Antigravity 2.x dir must be present`, - ); - assert.ok( - antIdeIdx < gemIdx, - `scan loop "for dir in ${m[1].trim()}": .gemini/antigravity-ide (idx ${antIdeIdx}) must precede .gemini (idx ${gemIdx})`, - ); - assert.notStrictEqual( - antCliIdx, - -1, - `scan loop "for dir in ${m[1].trim()}" must include .gemini/antigravity-cli for Antigravity 2.x CLI installs`, - ); - assert.ok( - antCliIdx < gemIdx, - `scan loop "for dir in ${m[1].trim()}": .gemini/antigravity-cli (idx ${antCliIdx}) must precede .gemini (idx ${gemIdx})`, - ); - assert.notStrictEqual( - antLegacyIdx, - -1, - `scan loop "for dir in ${m[1].trim()}" must retain .gemini/antigravity legacy fallback`, - ); + test('RUNTIME_DIRS includes antigravity 2.x (ide/cli) + legacy dirs', () => { + const dirs = RUNTIME_DIRS.filter(([rt]) => rt === 'antigravity').map(([, d]) => d); + assert.ok(dirs.includes('.gemini/antigravity-ide'), 'missing .gemini/antigravity-ide'); + assert.ok(dirs.includes('.gemini/antigravity-cli'), 'missing .gemini/antigravity-cli'); + assert.ok(dirs.includes('.gemini/antigravity'), 'missing legacy .gemini/antigravity fallback'); + // All antigravity dirs precede the .gemini probe. + const order = RUNTIME_DIRS.map(([, d]) => d); + const gemIdx = order.indexOf('.gemini'); + for (const d of dirs) { + assert.ok(order.indexOf(d) < gemIdx, `${d} must precede .gemini in the probe order`); } }); - test('path-to-runtime classification documents antigravity 2.x and legacy paths before /.gemini/', () => { - // The markdown bullet list near the top of get_installed_version step. - // We assert the antigravity bullet exists and appears before the gemini bullet - // in the file (textual order = evaluation order in the prose contract). - const antIdeBullet = content.search(/Path contains `\/\.gemini\/antigravity-ide\/?` -> `antigravity`/); - const antCliBullet = content.search(/Path contains `\/\.gemini\/antigravity-cli\/?` -> `antigravity`/); - const antLegacyBullet = content.search(/Path contains `\/\.gemini\/antigravity\/?` -> `antigravity`/); - const gemBullet = content.search(/Path contains `\/\.gemini\/` -> `gemini`/); + test('env inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => { + const rt = inferPreferredRuntime({ + fs: { exists: () => false }, + env: { ANTIGRAVITY_CONFIG_DIR: '/x', GEMINI_CONFIG_DIR: '/y' }, + preferredConfigDir: '', + }); + assert.equal(rt, 'antigravity', 'both env vars set must resolve to antigravity, not gemini'); + }); - assert.notStrictEqual( - antIdeBullet, - -1, - 'classification bullet for /.gemini/antigravity-ide/ -> antigravity is missing', - ); - assert.notStrictEqual( - antCliBullet, - -1, - 'classification bullet for /.gemini/antigravity-cli/ -> antigravity is missing', - ); - assert.notStrictEqual( - antLegacyBullet, - -1, - 'classification bullet for /.gemini/antigravity/ -> antigravity is missing', - ); - assert.notStrictEqual(gemBullet, -1, 'classification bullet for /.gemini/ -> gemini is missing'); - assert.ok( - antIdeBullet < gemBullet && antCliBullet < gemBullet && antLegacyBullet < gemBullet, - 'all antigravity bullets must precede gemini bullet in path-classification list', - ); + test('envRuntimeDirs emits an antigravity entry (before gemini) when ANTIGRAVITY_CONFIG_DIR is set', () => { + const entries = envRuntimeDirs({ env: { ANTIGRAVITY_CONFIG_DIR: '/x/ag', GEMINI_CONFIG_DIR: '/x/gem' }, home: '/home/u' }); + const order = entries.map(([rt]) => rt); + assert.ok(order.includes('antigravity'), 'expected an antigravity env candidate'); + assert.ok(order.indexOf('antigravity') < order.indexOf('gemini'), 'antigravity env candidate must precede gemini'); + }); + + test('behavioral: an Antigravity install resolves to runtime "antigravity", not "gemini"', () => { + // Normalize paths so the fake fs matches the resolver's path.join/resolve + // lookups on Windows (backslash + drive) as well as POSIX. + const normKey = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase(); + const HOME = '/home/u'; + const agDir = path.join(HOME, '.gemini', 'antigravity'); + const verFile = normKey(path.join(agDir, 'get-shit-done', 'VERSION')); + const markerFile = normKey(path.join(agDir, 'get-shit-done', 'workflows', 'update.md')); + const fakeFs = { + exists: (p) => normKey(p) === verFile || normKey(p) === markerFile, + readFile: (p) => (normKey(p) === verFile ? '1.40.0\n' : null), + }; + const r = resolveUpdateContext({ home: HOME, cwd: path.resolve('/work'), env: {}, fs: fakeFs }); + assert.equal(r.runtime, 'antigravity'); + assert.equal(normKey(r.gsdDir), normKey(agDir)); + }); + + test('update.md execution_context classification still lists antigravity paths before /.gemini/', () => { + const content = fs.readFileSync(UPDATE_MD, 'utf-8'); + const antIde = content.indexOf('/.gemini/antigravity-ide/'); + const gemBare = content.indexOf('`/.gemini/` -> `gemini`'); + assert.notStrictEqual(antIde, -1, 'update.md must document the antigravity-ide execution_context path'); + assert.notStrictEqual(gemBare, -1, 'update.md must document the bare /.gemini/ -> gemini classification'); + assert.ok(antIde < gemBare, 'antigravity path classification must precede the bare /.gemini/ rule'); }); }); diff --git a/tests/bug-378-update-check-scoped-name.test.cjs b/tests/bug-378-update-check-scoped-name.test.cjs index ac8818e42..c8ea82e9b 100644 --- a/tests/bug-378-update-check-scoped-name.test.cjs +++ b/tests/bug-378-update-check-scoped-name.test.cjs @@ -1,33 +1,43 @@ /** - * Regression test for #378: gsd-check-update-worker.js must query - * the SCOPED package name (@opengsd/get-shit-done-redux) when calling - * `npm view version`. + * Regression test for #378 / #498: the SessionStart update worker must end up + * querying the SCOPED package name (@opengsd/get-shit-done-redux) when it asks + * npm for the latest version. * - * Background: the worker previously hardcoded the unscoped string - * 'get-shit-done-redux', which returns E404 from the npm registry because - * the published package is scoped. This caused `latest` to stay null and - * `update_available` to be permanently false — users never saw update - * notifications. + * Background (#378): the worker once hardcoded the unscoped 'get-shit-done-redux', + * which 404s from the registry, leaving update_available permanently false. * - * The fix derives the name from package.json (most robust — survives - * future renames). This test locks the contract in two ways: + * Original #378 fix derived the name from `require('../package.json').name`. + * That is broken at runtime (#498): the installed tree carries only a synthetic + * `{"type":"commonjs"}` package.json (no `.name`), so post-install the worker + * queried `npm view undefined version` → latest stayed null → update_available + * permanently false. The old structural test passed only because it grepped the + * DEV tree, where package.json still has a name. * - * 1. Structural: the worker must NOT contain the bare unscoped literal - * 'get-shit-done-redux' as a standalone npm view argument. - * 2. Derived: the worker must read the package name from package.json - * and the package.json name MUST be the scoped string - * '@opengsd/get-shit-done-redux'. + * New contract (#498): the worker no longer resolves the package name itself. + * It delegates the latest-version lookup to check-latest-version.cjs's + * `checkLatestVersion()`, whose `PACKAGE_NAME` is sourced from the baked Package + * Identity seam (`get-shit-done/bin/lib/package-identity.cjs`). The seam's value + * is a build-time constant, correct in every install layout, so the + * undefined-at-runtime failure cannot recur. This test locks that contract: * - * Source-grep policy: this test reads hook source via readFileSync. - * The repo's lint-no-source-grep rule targets bin/lib/get-shit-done — hooks/ - * is out of scope. The shape we need to lock (which string is passed as the - * npm view argument) only manifests at runtime against the live registry; - * a structural assertion is the minimum-cost contract. + * 1. Structural: worker must NOT contain the bare unscoped literal. + * 2. Structural: worker must NOT use `require(...package.json...).name` + * (the runtime-broken path). + * 3. Structural: worker delegates to check-latest-version's + * `checkLatestVersion` rather than calling `npm view` itself. + * 4. Single-source: check-latest-version's PACKAGE_NAME === the seam's + * packageName === the scoped '@opengsd/get-shit-done-redux'. + * + * Source-grep policy: this test reads hook source via readFileSync. The repo's + * lint-no-source-grep rule targets bin/lib/get-shit-done — hooks/ is out of + * scope. The behavior (correct name → no E404) only manifests at runtime + * against the live registry; structural assertions are the minimum-cost + * contract for the worker, the same rationale #378 carried. */ -// allow-test-rule: structural assertion on hook npm-view argument; the -// behavior being tested (correct package name → no E404) only manifests at -// runtime against the live npm registry, which CI does not call. +// allow-test-rule: structural assertion on hook delegation; the behavior being +// tested (correct package name → no E404) only manifests at runtime against the +// live npm registry, which CI does not call. 'use strict'; @@ -38,61 +48,58 @@ const path = require('path'); const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'); const PKG_PATH = path.join(__dirname, '..', 'package.json'); +const SEAM = require('../get-shit-done/bin/lib/package-identity.cjs'); +const { PACKAGE_NAME } = require('../get-shit-done/bin/check-latest-version.cjs'); -describe('bug #378: update-check worker uses scoped package name', () => { +function workerCodeOnly() { + const src = fs.readFileSync(WORKER_PATH, 'utf8'); + return src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +describe('bug #378 / #498: update worker queries the scoped name via the seam', () => { test('worker file exists', () => { assert.ok(fs.existsSync(WORKER_PATH), `worker not found at ${WORKER_PATH}`); }); test('package.json name is the scoped @opengsd/get-shit-done-redux', () => { const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')); - assert.equal( - pkg.name, - '@opengsd/get-shit-done-redux', - 'package.json must declare the scoped name — this is what npm view must query', - ); + assert.equal(pkg.name, '@opengsd/get-shit-done-redux'); }); - test('worker does NOT hardcode the unscoped get-shit-done-redux as an npm view argument', () => { - const src = fs.readFileSync(WORKER_PATH, 'utf8'); - - // Strip comments so doc-prose mentions don't trigger the check. - const codeOnly = src - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); - - // The unscoped bare string as a string literal used in code. - // A match here means the bug is present: npm view 'get-shit-done-redux' - // → E404 → update_available permanently false. - const unscopedLiteral = /['"]get-shit-done-redux['"]/; - + test('worker does NOT hardcode the unscoped get-shit-done-redux as a string literal', () => { assert.doesNotMatch( - codeOnly, - unscopedLiteral, + workerCodeOnly(), + /['"]get-shit-done-redux['"]/, + "Worker must not pass the unscoped 'get-shit-done-redux' to npm — it 404s.", + ); + }); + + test('worker does NOT resolve the name via require(package.json).name (broken at runtime)', () => { + assert.doesNotMatch( + workerCodeOnly(), + /require\s*\(\s*['"][^'"]*package\.json['"]\s*\)\s*\.name/, [ - "Worker must not pass the unscoped 'get-shit-done-redux' to `npm view`.", - 'That name returns E404, leaving update_available permanently false.', - 'Use the scoped name from package.json: @opengsd/get-shit-done-redux.', + 'require(package.json).name resolves to undefined in the installed tree', + '(only a {"type":"commonjs"} marker ships). The worker must delegate to', + 'checkLatestVersion(), which sources the name from the baked seam.', ].join(' '), ); }); - test('worker derives package name from package.json (require + .name)', () => { - const src = fs.readFileSync(WORKER_PATH, 'utf8'); - - // Structural check: worker must load package.json and read .name from it. - // This is the robust form — survives future renames without code edits. - const requiresPkgJson = /require\s*\(\s*['"][^'"]*package\.json['"]\s*\)\.name/; - + test('worker delegates the latest-version lookup to checkLatestVersion', () => { + const code = workerCodeOnly(); assert.match( - src, - requiresPkgJson, - [ - 'Worker must derive the npm view package name via', - "`require('../package.json').name` (or similar).", - 'Hardcoding the scoped literal is less robust: a future rename', - 'would silently break update checks again.', - ].join(' '), + code, + /check-latest-version/, + 'Worker must require check-latest-version.cjs and call checkLatestVersion().', ); + assert.match(code, /checkLatestVersion\s*\(/); + }); + + test('check-latest-version PACKAGE_NAME is single-sourced from the seam', () => { + assert.equal(PACKAGE_NAME, SEAM.packageName); + assert.equal(SEAM.packageName, '@opengsd/get-shit-done-redux'); }); }); diff --git a/tests/bug-503-update-agent-antigravity-detection.test.cjs b/tests/bug-503-update-agent-antigravity-detection.test.cjs index d503c1d30..a6965cbee 100644 --- a/tests/bug-503-update-agent-antigravity-detection.test.cjs +++ b/tests/bug-503-update-agent-antigravity-detection.test.cjs @@ -1,23 +1,28 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + // allow-test-rule: source-text-is-the-product -// update.md is a workflow file whose text IS the contract the runtime loads -// and executes (the embedded bash detection cascade). Asserting on its text -// tests the deployed behavior. Per CONTRIBUTING.md exception matrix. +// update.md's embedded classifier + cache-clear loop are workflow text the +// runtime loads and executes, so asserting on that text tests deployed +// behavior. The runtime/scope detection cascade itself moved out of inline +// bash into the update-context projection (issue #498), so the core guarantee +// is exercised behaviorally against resolveUpdateContext rather than by +// matching a `RUNTIME_DIRS=(...)` literal that no longer lives in update.md. +// Per CONTRIBUTING.md exception matrix. /** * Bug #503: /gsd:update misclassifies local Antigravity (.agent) installs as claude * * The installer places a LOCAL Antigravity install in ./.agent/ - * (bin/install.js: getDirName('antigravity') === '.agent'). But the - * /gsd:update detection cascade in get-shit-done/workflows/update.md only - * knew the GLOBAL Antigravity layout (.gemini/antigravity{,-ide,-cli}), so a - * local .agent install fell through to the `Otherwise -> claude` default and - * the update refreshed Claude artifacts instead of the Antigravity install. + * (bin/install.js: getDirName('antigravity') === '.agent'). The /gsd:update + * detection cascade must map .agent -> antigravity across three surfaces: + * 1. the execution_context path classifier (update.md prose), + * 2. the RUNTIME_DIRS candidate table (now in the update-context projection), + * 3. the post-update cache-clear `for dir in` loop (update.md). * - * The cascade has three coupled detection surfaces; .agent must be mapped to - * antigravity in all three: - * 1. the execution_context path classifier, - * 2. the RUNTIME_DIRS candidate array, - * 3. the LOCAL-scope discovery `for dir in ...` loop. + * Surface (2) is the original root cause and is now verified behaviorally: a + * LOCAL .agent install must resolve to the antigravity runtime. Before the fix + * (.agent absent from RUNTIME_DIRS) it fell through to UNKNOWN/claude. */ const { describe, test } = require('node:test'); @@ -25,50 +30,69 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const ROOT = path.join(__dirname, '..'); const UPDATE_MD = fs.readFileSync( - path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'), - 'utf-8' + path.join(ROOT, 'get-shit-done', 'workflows', 'update.md'), + 'utf-8', +); +const { resolveUpdateContext } = require( + path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'update-context.cjs'), ); +function normKey(p) { return path.resolve(p).replace(/\\/g, '/').toLowerCase(); } +function fakeFs(files) { + const set = new Map(); + for (const [k, v] of Object.entries(files)) set.set(normKey(k), v); + return { + exists: (p) => set.has(normKey(p)), + readFile: (p) => { const k = normKey(p); return set.has(k) ? set.get(k) : null; }, + }; +} + describe('/gsd:update detects local Antigravity (.agent) installs (#503)', () => { - test('execution_context classifier maps a /.agent/ path to antigravity', () => { - // A rule line of the form: Path contains `/.agent/` -> `antigravity` + test('projection resolves a LOCAL ./.agent install to the antigravity runtime', () => { + const HOME = '/home/u'; + const CWD = '/work/proj'; + const agentDir = `${CWD}/.agent`; + const ffs = fakeFs({ + [`${agentDir}/get-shit-done/VERSION`]: '1.40.0\n', + [`${agentDir}/get-shit-done/workflows/update.md`]: 'x', + }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs }); + assert.equal( + r.runtime, + 'antigravity', + `a local .agent install must map to the antigravity runtime, got "${r.runtime}"`, + ); + assert.equal(r.scope, 'LOCAL'); + assert.equal(r.installedVersion, '1.40.0'); + }); + + test('execution_context classifier maps a /.agent/ path to antigravity (update.md)', () => { const hasAgentClassifierRule = /\/\.agent\/[^\n]*->[^\n]*antigravity/.test(UPDATE_MD); assert.ok( hasAgentClassifierRule, - 'update.md classifier must map a `/.agent/` path to the `antigravity` runtime' + 'update.md classifier must map a `/.agent/` path to the `antigravity` runtime', ); }); - test('RUNTIME_DIRS candidate array includes antigravity:.agent', () => { - const runtimeDirsLine = UPDATE_MD - .split('\n') - .find((l) => l.includes('RUNTIME_DIRS=(')); - assert.ok(runtimeDirsLine, 'RUNTIME_DIRS array must exist in update.md'); - assert.ok( - runtimeDirsLine.includes('antigravity:.agent'), - `RUNTIME_DIRS must contain "antigravity:.agent", got: ${runtimeDirsLine}` - ); - }); - - test('every runtime-dir `for dir in` loop includes .agent', () => { - // Both the LOCAL-scope discovery loop AND the post-update cache-clear loop - // enumerate the runtime config dirs as a literal `.claude ... .codex` list. - // The same root cause (.agent missing from the runtime-dir list) breaks - // detection in the first and leaves a stale update indicator in the second, - // so ALL such loops must include .agent. + test('every runtime-dir `for dir in` loop in update.md includes .agent', () => { + // The LOCAL-scope discovery loop moved into the projection (#498); the + // post-update cache-clear loop remains inline and still enumerates the + // runtime config dirs as a literal `.claude ... .codex` list, so it must + // include .agent or a local Antigravity install keeps a stale indicator. const runtimeDirLoops = UPDATE_MD .split('\n') .filter((l) => /for dir in .*\.claude.*\.codex/.test(l)); assert.ok( - runtimeDirLoops.length >= 2, - `expected at least 2 runtime-dir loops in update.md, found ${runtimeDirLoops.length}` + runtimeDirLoops.length >= 1, + `expected at least 1 runtime-dir loop in update.md, found ${runtimeDirLoops.length}`, ); for (const loop of runtimeDirLoops) { assert.ok( /(^|\s)\.agent(\s|$)/.test(loop), - `every runtime-dir loop must include .agent, got: ${loop.trim()}` + `every runtime-dir loop must include .agent, got: ${loop.trim()}`, ); } }); diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index 2814c8077..810e1f216 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -1,31 +1,30 @@ /** - * Tests for gsd-check-update-worker.js — Windows npm resolution platform gate. + * Tests for the Windows npm resolution platform gate. * * Background (issue #3103, PR #3102): - * On Windows, `npm` ships as `npm.cmd`. Node's execFileSync does not apply - * PATHEXT resolution (unlike execSync/exec) and fails with ENOENT. The fix - * is to spawn through a shell on Windows (cmd.exe resolves npm.cmd via - * PATHEXT). On POSIX, `npm` is a node-script symlink and resolves without - * a shell, so spawning `/bin/sh -c` is pure overhead and changes signal / - * exit-code semantics — undesirable. + * On Windows, `npm` ships as `npm.cmd`. Node's spawn does not apply PATHEXT + * resolution and fails with ENOENT. The fix is to spawn through a shell on + * Windows (cmd.exe resolves npm.cmd via PATHEXT). On POSIX, `npm` resolves + * without a shell, so spawning `/bin/sh -c` is pure overhead and changes + * signal / exit-code semantics — undesirable. * - * This test locks the contract: shell must be platform-gated to win32 only, - * never an unconditional `shell: true`. A regression that re-introduces - * `shell: true` would change POSIX runtime behavior silently — exactly the - * cross-platform risk that adversarial review on PR #3102 flagged. + * Relocation (#498): the SessionStart worker no longer spawns npm itself. It + * delegates the latest-version lookup to check-latest-version's + * `checkLatestVersion()`, which routes through `execNpm` in the shell-command + * projection seam. The PR #3102 contract therefore now lives on `execNpm`. + * This test locks it there, and additionally locks that the worker does NOT + * re-introduce a direct npm spawn (which would re-open the gate question in a + * second place). * - * Source-grep policy: this test reads the worker source via readFileSync. - * The repo's lint-no-source-grep rule (scripts/lint-no-source-grep.cjs) - * targets `.cjs` files in bin/lib/get-shit-done — `hooks/*.js` is out of - * scope. The behavior we need to lock is a single static-spawn-options - * shape, which only manifests at runtime under Windows; runtime testing - * would require a Windows CI lane. A structural assertion is the - * minimum-cost contract. + * Source-grep policy: these structural assertions read source via readFileSync. + * The behavior (Windows-only shell resolution) is platform-gated at runtime and + * cannot be reached on POSIX CI without a Windows lane; a structural assertion + * is the minimum-cost contract. */ -// allow-test-rule: structural assertion on hook spawn-options shape; the -// behavior being tested (Windows-only shell resolution) is platform-gated -// at runtime and cannot be reached on POSIX CI without a Windows lane. +// allow-test-rule: structural assertion on spawn-options shape; the behavior +// (Windows-only shell resolution) is platform-gated at runtime and cannot be +// reached on POSIX CI without a Windows lane. 'use strict'; @@ -35,68 +34,53 @@ const fs = require('fs'); const path = require('path'); const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'); +const PROJECTION_PATH = path.join( + __dirname, '..', 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs', +); -describe('gsd-check-update-worker: Windows npm spawn platform gate', () => { - test('worker file exists', () => { - assert.ok(fs.existsSync(WORKER_PATH), `worker not found at ${WORKER_PATH}`); +function codeOnly(file) { + return fs.readFileSync(file, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +describe('execNpm: Windows npm spawn platform gate (PR #3102, relocated #498)', () => { + test('projection seam exists', () => { + assert.ok(fs.existsSync(PROJECTION_PATH), `not found at ${PROJECTION_PATH}`); }); - test('shell option is gated to process.platform === "win32"', () => { - const src = fs.readFileSync(WORKER_PATH, 'utf8'); - const codeOnly = src - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); - - // Locks the platform gate. Allows whitespace/quote variation around - // the comparison so trivial style fixes do not break the contract. - const platformGate = - /shell:\s*process\.platform\s*===\s*['"]win32['"]/; - + test('execNpm gates shell to process.platform === "win32"', () => { assert.match( - codeOnly, - platformGate, + codeOnly(PROJECTION_PATH), + /shell:\s*process\.platform\s*===\s*['"]win32['"]/, [ - 'shell option must be `process.platform === "win32"`.', + 'execNpm must gate shell to `process.platform === "win32"`.', 'A regression to `shell: true` would spawn /bin/sh -c on POSIX', - '(adds shell overhead, changes signal/exit semantics, can mask', - 'windowsHide on some Node versions). See PR #3102.', + '(adds shell overhead, changes signal/exit semantics). See PR #3102.', ].join(' '), ); }); test('no unconditional shell: true on the npm spawn', () => { - const src = fs.readFileSync(WORKER_PATH, 'utf8'); - - // Strip line and block comments so prose mentions of "shell:true" in - // documentation comments do not trigger the regression check. - const codeOnly = src - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); - - // Reject literal `shell: true` in CODE only. The correct fix uses - // `shell: process.platform === 'win32'` (an expression, not the - // literal `true`), so this never matches the platform-gated form. - // Trailing `[,\s}]` ensures we match an object-property assignment, - // not an unrelated identifier. - const naiveShell = /shell\s*:\s*true\s*[,\s}]/; - assert.doesNotMatch( - codeOnly, - naiveShell, - 'shell: true is forbidden — use `process.platform === "win32"` gate.', - ); - }); - - test('execFileSync is still the spawn primitive (not exec/execSync)', () => { - const src = fs.readFileSync(WORKER_PATH, 'utf8'); - - // execFileSync is intentional: it does not invoke a shell on POSIX, - // unlike exec/execSync. A regression that swaps to execSync would - // silently always spawn a shell, defeating the platform gate. - assert.match( - src, - /execFileSync\s*\(\s*['"]npm['"]/, - 'npm spawn must use execFileSync (not exec/execSync) to keep POSIX shell-free.', + codeOnly(PROJECTION_PATH), + /shell\s*:\s*true\s*[,\s}]/, + 'shell: true is forbidden — use the `process.platform === "win32"` gate.', ); }); }); + +describe('worker delegates the npm spawn (does not re-open the gate, #498)', () => { + test('worker does NOT spawn npm directly', () => { + const code = codeOnly(WORKER_PATH); + assert.doesNotMatch( + code, + /(execFileSync|spawnSync|execSync|exec)\s*\(\s*['"]npm['"]/, + 'Worker must delegate to checkLatestVersion(), not spawn npm itself.', + ); + }); + + test('worker requires check-latest-version for the lookup', () => { + assert.match(codeOnly(WORKER_PATH), /check-latest-version/); + }); +}); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 1af638783..826f4105e 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -643,6 +643,11 @@ describe('Kilo source integration assertions', () => { const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8'); const updateWorkflowSrc = fs.readFileSync( path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'), 'utf8'); + // #498: update.md's runtime/scope/config-dir resolution moved into the tested + // projection get-shit-done/bin/lib/update-context.cjs. Custom-config-dir + // detection (kilo.jsonc, KILO_CONFIG) is now asserted there. + const updateContextSrc = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'update-context.cjs'), 'utf8'); test('--kilo flag parsing exists', () => { assert.ok(src.includes("args.includes('--kilo')")); @@ -668,8 +673,11 @@ describe('Kilo source integration assertions', () => { }); test('update workflow checks preferred custom config dirs', () => { + // update.md still derives the preferred config dir from execution_context… assert.ok(updateWorkflowSrc.includes('PREFERRED_CONFIG_DIR')); - assert.ok(updateWorkflowSrc.includes('kilo.jsonc')); - assert.ok(updateWorkflowSrc.includes('KILO_CONFIG')); + // …and the custom-dir detection (kilo.jsonc config marker, KILO_CONFIG env) + // now lives in the tested update-context projection (#498). + assert.ok(updateContextSrc.includes('kilo.jsonc')); + assert.ok(updateContextSrc.includes('KILO_CONFIG')); }); }); diff --git a/tests/issue-498-identity-drift-lint.test.cjs b/tests/issue-498-identity-drift-lint.test.cjs new file mode 100644 index 000000000..acbc8b984 --- /dev/null +++ b/tests/issue-498-identity-drift-lint.test.cjs @@ -0,0 +1,69 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +// Issue #498: the drift-guard lint. Every GSD package/repo coordinate that +// appears as a literal anywhere in the runtime/code surface must equal the +// value the Package Identity seam derives from package.json. This is what +// makes a repoint a one-line change: rename package.json, regenerate the seam, +// and any stale literal fails CI until it is updated. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { findCoordinateDrift } = require( + path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs'), +); + +const SEAM = { packageName: '@opengsd/get-shit-done-redux', repoSlug: 'open-gsd/get-shit-done-redux' }; + +describe('Issue #498: findCoordinateDrift (pure)', () => { + test('a correct package literal is not drift', () => { + const v = findCoordinateDrift('run npx -y @opengsd/get-shit-done-redux@latest', SEAM); + assert.deepEqual(v, []); + }); + + test('a stale package literal (post-rename) is flagged', () => { + const v = findCoordinateDrift('npx @opengsd/get-shit-done-classic@latest', SEAM); + assert.equal(v.length, 1); + assert.equal(v[0].found, '@opengsd/get-shit-done-classic'); + assert.equal(v[0].expected, SEAM.packageName); + assert.equal(v[0].kind, 'package'); + }); + + test('a different package (@opengsd/gsd-sdk) is NOT a get-shit-done coordinate', () => { + assert.deepEqual(findCoordinateDrift("require('@opengsd/gsd-sdk')", SEAM), []); + }); + + test('a correct github repo slug is not drift', () => { + const v = findCoordinateDrift('https://github.com/open-gsd/get-shit-done-redux/issues', SEAM); + assert.deepEqual(v, []); + }); + + test('a stale repo slug in a github url is flagged', () => { + const v = findCoordinateDrift('https://github.com/tches/get-shit-done-classic.git', SEAM); + assert.equal(v.length, 1); + assert.equal(v[0].kind, 'slug'); + assert.equal(v[0].found, 'tches/get-shit-done-classic'); + }); + + test('reports 1-based line numbers', () => { + const text = 'line1\nnpx @opengsd/get-shit-done-OLD@latest\nline3'; + const v = findCoordinateDrift(text, SEAM); + assert.equal(v[0].line, 2); + }); +}); + +describe('Issue #498: the live repo passes the drift lint', () => { + test('scanRepo finds zero drift against the current seam', () => { + const { scanRepo } = require(path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs')); + const violations = scanRepo(ROOT); + assert.deepEqual( + violations, + [], + 'stale GSD coordinate literal(s) found:\n' + + violations.map((d) => ` ${d.file}:${d.line} ${d.kind} '${d.found}' != '${d.expected}'`).join('\n'), + ); + }); +}); diff --git a/tests/issue-498-package-identity.test.cjs b/tests/issue-498-package-identity.test.cjs new file mode 100644 index 000000000..ed093c2dd --- /dev/null +++ b/tests/issue-498-package-identity.test.cjs @@ -0,0 +1,113 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +// Issue #498: single Package Identity seam. +// The package coordinates (npm name, bin name, repo slug, changelog URL) are +// DERIVED from package.json, not re-typed. deriveIdentity is the pure core; +// the generated runtime module get-shit-done/bin/lib/package-identity.cjs +// bakes those values at build time so it survives the install layout where +// the only package.json present is the synthetic {"type":"commonjs"} marker. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const fs = require('node:fs'); + +const ROOT = path.join(__dirname, '..'); +const { deriveIdentity, formatManualInstall, render } = require( + path.join(ROOT, 'scripts', 'generate-package-identity.cjs'), +); +const GENERATED = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs'); + +describe('Issue #498: deriveIdentity (pure, package.json -> coordinates)', () => { + const FAKE_PKG = { + name: '@scope/example-pkg', + bin: { 'example-pkg': 'bin/install.js', 'extra-tool': 'x.cjs' }, + repository: { type: 'git', url: 'git+https://github.com/acme/example-pkg.git' }, + }; + + test('packageName is package.json .name', () => { + assert.equal(deriveIdentity(FAKE_PKG).packageName, '@scope/example-pkg'); + }); + + test('binName is the FIRST bin key (primary launcher)', () => { + assert.equal(deriveIdentity(FAKE_PKG).binName, 'example-pkg'); + }); + + test('repoSlug is owner/name parsed from repository.url (git+ and .git stripped)', () => { + assert.equal(deriveIdentity(FAKE_PKG).repoSlug, 'acme/example-pkg'); + }); + + test('repoUrl is the cleaned https github url', () => { + assert.equal(deriveIdentity(FAKE_PKG).repoUrl, 'https://github.com/acme/example-pkg'); + }); + + test('changelogRawUrl points at raw.githubusercontent main CHANGELOG', () => { + assert.equal( + deriveIdentity(FAKE_PKG).changelogRawUrl, + 'https://raw.githubusercontent.com/acme/example-pkg/main/CHANGELOG.md', + ); + }); + + test('derives the real GSD coordinates from the repo package.json', () => { + const real = require(path.join(ROOT, 'package.json')); + const id = deriveIdentity(real); + assert.equal(id.packageName, '@opengsd/get-shit-done-redux'); + assert.equal(id.binName, 'get-shit-done-redux'); + assert.equal(id.repoSlug, 'open-gsd/get-shit-done-redux'); + }); +}); + +describe('Issue #498: formatManualInstall (the npx fallback command)', () => { + test('global scope, no runtime -> npx with --global only', () => { + assert.equal( + formatManualInstall({ packageName: '@scope/example-pkg', binName: 'example-pkg', scope: 'global' }), + 'npx -y --package=@scope/example-pkg@latest -- example-pkg --global', + ); + }); + + test('local scope with runtime -> -- before --', () => { + assert.equal( + formatManualInstall({ packageName: '@scope/example-pkg', binName: 'example-pkg', scope: 'local', runtime: 'claude' }), + 'npx -y --package=@scope/example-pkg@latest -- example-pkg --claude --local', + ); + }); + + test('matches the literal update.md uses for the real package (global+claude)', () => { + const id = deriveIdentity(require(path.join(ROOT, 'package.json'))); + assert.equal( + formatManualInstall({ packageName: id.packageName, binName: id.binName, scope: 'global', runtime: 'claude' }), + 'npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --claude --global', + ); + }); +}); + +describe('Issue #498: generated runtime module (baked, drift-checked)', () => { + test('the committed generated file is in sync with package.json (no drift)', () => { + // Normalize line endings: on Windows the file is checked out with CRLF + // (no .gitattributes eol rule), while render() emits LF. The repo's + // convention is to compare normalized content (see autonomous-decomposition, + // bug-3707). The sync check is about content, not the checkout's eol. + const norm = (s) => s.replace(/\r\n/g, '\n'); + const expected = render(deriveIdentity(require(path.join(ROOT, 'package.json')))); + const actual = fs.readFileSync(GENERATED, 'utf8'); + assert.equal(norm(actual), norm(expected), + 'package-identity.cjs is stale — run `node scripts/generate-package-identity.cjs`'); + }); + + test('requiring the generated module exposes the real coordinates', () => { + const id = require(GENERATED); + assert.equal(id.packageName, '@opengsd/get-shit-done-redux'); + assert.equal(id.binName, 'get-shit-done-redux'); + assert.equal(id.repoSlug, 'open-gsd/get-shit-done-redux'); + }); + + test('generated manualInstallCommand closes over the baked coordinates', () => { + const id = require(GENERATED); + assert.equal( + id.manualInstallCommand({ scope: 'global', runtime: 'claude' }), + 'npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --claude --global', + ); + }); +}); diff --git a/tests/issue-498-update-backup-runtime-dir.test.cjs b/tests/issue-498-update-backup-runtime-dir.test.cjs new file mode 100644 index 000000000..5436017c0 --- /dev/null +++ b/tests/issue-498-update-backup-runtime-dir.test.cjs @@ -0,0 +1,68 @@ +/** + * Regression (#498, adversarial-review finding): the custom-file backup step in + * update.md must derive RUNTIME_DIR from GSD_DIR. + * + * The get_installed_version step was rewritten to call `gsd-tools update-context` + * and now emits GSD_DIR (the resolved config dir) instead of the old probe-loop + * variables LOCAL_DIR / GLOBAL_DIR. The backup_custom_files step still read + * LOCAL_DIR / GLOBAL_DIR, which are no longer assigned anywhere — so RUNTIME_DIR + * went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped. + * Because the update then runs a clean install that wipes managed dirs + * (commands/gsd, get-shit-done), user-added files inside those dirs could be + * deleted without the intended backup. + * + * This locks the fix: RUNTIME_DIR comes from GSD_DIR, and the dead LOCAL_DIR / + * GLOBAL_DIR references are gone. + * + * Source-text-is-the-product: update.md's bash blocks ARE the deployed /gsd:update + * program; asserting their shape is asserting on the deployed contract. + */ + +// allow-test-rule: structural assertion on the deployed update.md backup bash; +// the data-loss behavior only manifests against a real install during a clean +// reinstall, which CI does not perform. + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const UPDATE_MD = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'); + +function codeOnly(file) { + // Strip fenced-block prose is unnecessary here; we assert on the whole doc + // but ignore markdown comment prose by only matching shell-assignment forms. + return fs.readFileSync(file, 'utf8'); +} + +describe('#498 regression: update.md backup uses GSD_DIR, not the removed LOCAL_DIR/GLOBAL_DIR', () => { + const src = codeOnly(UPDATE_MD); + + test('RUNTIME_DIR is assigned from GSD_DIR', () => { + assert.match( + src, + /RUNTIME_DIR="\$GSD_DIR"/, + 'backup_custom_files must set RUNTIME_DIR="$GSD_DIR" (the resolved config dir from update-context)', + ); + }); + + test('no shell assignment reads the removed LOCAL_DIR/GLOBAL_DIR probe variables', () => { + // The get_installed_version rewrite no longer assigns LOCAL_DIR/GLOBAL_DIR. + // Any RUNTIME_DIR="$LOCAL_DIR" / "$GLOBAL_DIR" would silently resolve to empty. + assert.doesNotMatch( + src, + /="\$(LOCAL_DIR|GLOBAL_DIR)"/, + 'update.md still reads LOCAL_DIR/GLOBAL_DIR, which get_installed_version no longer sets — backup will be skipped', + ); + }); + + test('detect-custom-files stays gated on a non-empty RUNTIME_DIR', () => { + assert.match( + src, + /\[ -n "\$RUNTIME_DIR" \][\s\S]*?detect-custom-files --config-dir "\$RUNTIME_DIR"/, + 'backup must still skip when RUNTIME_DIR is empty (UNKNOWN scope)', + ); + }); +}); diff --git a/tests/issue-498-update-context.test.cjs b/tests/issue-498-update-context.test.cjs new file mode 100644 index 000000000..880adf0df --- /dev/null +++ b/tests/issue-498-update-context.test.cjs @@ -0,0 +1,195 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +// Issue #498 (candidate 3): resolveUpdateContext ports update.md's ~280-line +// get_installed_version bash into a pure, injected-fs function. It returns the +// same 4-field contract the workflow emits: { installedVersion, scope, runtime, +// gsdDir }. The fs is injected (exists/readFile) so the precedence cascade is +// finally testable without a live multi-runtime install. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const nodeFs = require('node:fs'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); +const GSD_TOOLS = path.join(ROOT, 'get-shit-done', 'bin', 'gsd-tools.cjs'); +const { resolveUpdateContext } = require( + path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'update-context.cjs'), +); + +// Normalize a path to a platform-agnostic key: resolve to absolute, then +// lowercase forward-slash form. This makes the fake fs match the resolver's +// path.join/path.resolve lookups on Windows (backslash + drive letter) as well +// as POSIX, so these unit tests are not OS-coupled. +function normKey(p) { return path.resolve(p).replace(/\\/g, '/').toLowerCase(); } + +// Build an injected fs from a map of absolute path -> contents. Marker files +// (VERSION, workflows/update.md) just need to "exist". +function fakeFs(files) { + const set = new Map(); + for (const [k, v] of Object.entries(files)) set.set(normKey(k), v); + return { + exists: (p) => set.has(normKey(p)), + readFile: (p) => { const k = normKey(p); return set.has(k) ? set.get(k) : null; }, + }; +} + +// Compare resolved-dir results without coupling to OS path style. +function sameDir(a, b) { return normKey(a) === normKey(b); } + +const HOME = '/home/u'; +const CWD = '/work/proj'; + +function ver(dir) { return `${dir}/get-shit-done/VERSION`; } +function marker(dir) { return `${dir}/get-shit-done/workflows/update.md`; } + +describe('resolveUpdateContext: scope cascade', () => { + test('GLOBAL claude install under $HOME/.claude', () => { + const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n', [marker(`${HOME}/.claude`)]: 'x' }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs }); + assert.equal(r.installedVersion, '1.40.0'); + assert.equal(r.scope, 'GLOBAL'); + assert.equal(r.runtime, 'claude'); + assert.ok(sameDir(r.gsdDir, `${HOME}/.claude`), `gsdDir was ${r.gsdDir}`); + }); + + test('LOCAL install under ./.claude takes priority over global', () => { + const fs = fakeFs({ + [ver(`${CWD}/.claude`)]: '1.39.0\n', [marker(`${CWD}/.claude`)]: 'x', + [ver(`${HOME}/.claude`)]: '1.40.0\n', [marker(`${HOME}/.claude`)]: 'x', + }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs }); + assert.equal(r.scope, 'LOCAL'); + assert.equal(r.installedVersion, '1.39.0'); + assert.ok(sameDir(r.gsdDir, `${CWD}/.claude`), `gsdDir was ${r.gsdDir}`); + }); + + test('cwd === home does NOT misdetect as LOCAL (dedup)', () => { + const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n', [marker(`${HOME}/.claude`)]: 'x' }); + const r = resolveUpdateContext({ home: HOME, cwd: HOME, env: {}, fs }); + assert.equal(r.scope, 'GLOBAL'); + }); + + test('runtime detected but VERSION missing -> 0.0.0, keep scope/runtime', () => { + const fs = fakeFs({ [marker(`${HOME}/.gemini`)]: 'x' }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs }); + assert.equal(r.installedVersion, '0.0.0'); + assert.equal(r.scope, 'GLOBAL'); + assert.equal(r.runtime, 'gemini'); + }); + + test('no install anywhere -> UNKNOWN / claude / empty gsdDir', () => { + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: fakeFs({}) }); + assert.deepEqual(r, { installedVersion: '0.0.0', scope: 'UNKNOWN', runtime: 'claude', gsdDir: '' }); + }); +}); + +describe('resolveUpdateContext: runtime probing + env overrides', () => { + test('opencode global under $HOME/.config/opencode', () => { + const dir = `${HOME}/.config/opencode`; + const fs = fakeFs({ [ver(dir)]: '1.40.0\n', [marker(dir)]: 'x' }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs }); + assert.equal(r.runtime, 'opencode'); + assert.ok(sameDir(r.gsdDir, dir), `gsdDir was ${r.gsdDir}`); + }); + + test('CLAUDE_CONFIG_DIR env override locates a custom global dir', () => { + const custom = '/opt/claude-home'; + const fs = fakeFs({ [ver(custom)]: '1.40.0\n', [marker(custom)]: 'x' }); + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: { CLAUDE_CONFIG_DIR: custom }, fs }); + assert.equal(r.scope, 'GLOBAL'); + assert.equal(r.runtime, 'claude'); + assert.ok(sameDir(r.gsdDir, custom), `gsdDir was ${r.gsdDir}`); + }); + + test('preferredConfigDir fast-path: trusts a validated custom dir as GLOBAL', () => { + const custom = '/opt/gsd-x'; + const fs = fakeFs({ [ver(custom)]: '1.41.0\n', [marker(custom)]: 'x' }); + const r = resolveUpdateContext({ + home: HOME, cwd: CWD, env: {}, fs, + preferredConfigDir: custom, preferredRuntime: 'kilo', + }); + assert.equal(r.scope, 'GLOBAL'); + assert.equal(r.runtime, 'kilo'); + assert.ok(sameDir(r.gsdDir, custom), `gsdDir was ${r.gsdDir}`); + assert.equal(r.installedVersion, '1.41.0'); + }); +}); + +describe('gsd-tools update-context (CLI): emits the JSON contract', () => { + test('--config-dir fixture resolves to the documented 4-field JSON', () => { + const tmp = nodeFs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uc-')); + try { + nodeFs.mkdirSync(path.join(tmp, 'get-shit-done', 'workflows'), { recursive: true }); + nodeFs.writeFileSync(path.join(tmp, 'get-shit-done', 'VERSION'), '1.42.0\n'); + nodeFs.writeFileSync(path.join(tmp, 'get-shit-done', 'workflows', 'update.md'), 'x'); + const out = execFileSync( + process.execPath, + [GSD_TOOLS, 'update-context', '--config-dir', tmp, '--runtime', 'kilo', '--json'], + { encoding: 'utf8', env: { ...process.env, GSD_TEST_MODE: '1' } }, + ); + const ctx = JSON.parse(out); + assert.deepEqual(Object.keys(ctx).sort(), ['gsdDir', 'installedVersion', 'runtime', 'scope']); + assert.equal(ctx.installedVersion, '1.42.0'); + assert.equal(ctx.scope, 'GLOBAL'); + assert.equal(ctx.runtime, 'kilo'); + } finally { + nodeFs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe('resolveUpdateContext: parity with the old inline bash (adversarial-review)', () => { + test('preferredConfigDir with a leading ~/ is expanded before the fast path', () => { + // The old inline bash ran `expand_home "$PREFERRED_CONFIG_DIR"` first, so a + // custom --config-dir like ~/custom-gsd must resolve, not fall to UNKNOWN. + const fs = fakeFs({ + [ver(`${HOME}/custom-gsd`)]: '1.41.0\n', + [marker(`${HOME}/custom-gsd`)]: 'x', + }); + const r = resolveUpdateContext({ + home: HOME, cwd: CWD, env: {}, fs, preferredConfigDir: '~/custom-gsd', + }); + assert.equal(r.installedVersion, '1.41.0'); + assert.equal(r.scope, 'GLOBAL'); + assert.ok(sameDir(r.gsdDir, `${HOME}/custom-gsd`), `gsdDir was ${r.gsdDir}`); + }); + + test('a VERSION-only dir (no update.md marker) is NOT trusted as a real version', () => { + // The old cascade required BOTH VERSION and the update.md marker before + // trusting the version; a partial dir falls to 0.0.0 but keeps scope. + const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n' }); // marker absent + const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs }); + assert.equal(r.installedVersion, '0.0.0', 'VERSION-only dir must not be trusted'); + assert.equal(r.scope, 'GLOBAL'); + assert.equal(r.runtime, 'claude'); + assert.ok(sameDir(r.gsdDir, `${HOME}/.claude`), `gsdDir was ${r.gsdDir}`); + }); + + test('fast path also requires the marker: VERSION-only preferredConfigDir -> 0.0.0', () => { + // The "trust = VERSION + marker" rule is consistent across every path, not + // just the cascade. A custom --config-dir with VERSION but no marker is a + // partial install: keep the dir/scope, report 0.0.0. + const custom = '/opt/gsd-partial'; + const fs = fakeFs({ [ver(custom)]: '1.41.0\n' }); // marker absent + const r = resolveUpdateContext({ + home: HOME, cwd: CWD, env: {}, fs, preferredConfigDir: custom, preferredRuntime: 'kilo', + }); + assert.equal(r.installedVersion, '0.0.0', 'VERSION-only fast path must not be trusted'); + assert.equal(r.scope, 'GLOBAL'); + assert.ok(sameDir(r.gsdDir, custom), `gsdDir was ${r.gsdDir}`); + }); + + test('partial install with cwd===home does NOT misdetect as LOCAL (fallback dedup)', () => { + // Same same-path dedup the trusted path uses must apply to the 0.0.0 + // fallback: a VERSION-only ~/.claude probed from cwd===home is GLOBAL. + const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n' }); // marker absent + const r = resolveUpdateContext({ home: HOME, cwd: HOME, env: {}, fs }); + assert.equal(r.installedVersion, '0.0.0'); + assert.equal(r.scope, 'GLOBAL', 'cwd===home partial must be GLOBAL, not LOCAL'); + assert.ok(sameDir(r.gsdDir, `${HOME}/.claude`), `gsdDir was ${r.gsdDir}`); + }); +});