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}`); + }); +});