* fix(#637): route 3 more workflows through gsd_run launcher (hardcoded $HOME sweep) The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs"` invocation form fixed in plan-phase.md (#621) survived in three more workflows. Same bug class: on a global/shim-only install with no project-local runtime, the hardcoded path can miss a working install, so the step reports the tool "not found" instead of resolving it via the launcher. #3668 introduced gsd_run resolution; these sites were missed. - plan-review-convergence.md: convert the 3 hardcoded invocations (init, roadmap get-phase, state planned-phase) to gsd_run. File already carried the canonical preamble (first gsd_run is the earlier convergence-enabled check). - ingest-docs.md, spec-phase.md: convert their hardcoded invocations to gsd_run and inject the canonical launcher preamble via `node scripts/sync-runtime-launcher.cjs` (these files previously had no gsd_run and no preamble). The injected preamble is byte-equal to _runtime-launcher.snippet.sh and precedes the first gsd_run call, per runtime-launcher-parity invariant (B). - Add tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs: repo-wide regression guard asserting NO workflow .md invokes gsd-tools via a hardcoded $HOME path. Generalizes the plan-phase-only guard from #621 — the parity test guards retired $GSD_SDK / bare /gsd-tools tokens but not this form, which is how it survived across four files. Fails on the pre-fix files, passes after. runtime-launcher-parity 7/7; full unit suite green (3477 pass / 0 fail). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#637): add changeset fragment for PR #642 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#637): update stale bug-2801 assertion to expect gsd_run bug-2801 pinned ingest-docs.md to the hardcoded node "$HOME/.../gsd-tools.cjs" init form, which #637 replaces with the gsd_run launcher. Flip the assertion to expect gsd_run init ingest-docs; the bare-gsd-tools rejection and CLI-handler tests are unchanged, and bug-637's repo-wide guard now owns the no-hardcoded-$HOME invariant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
5
.changeset/humble-tigers-tumble.md
Normal file
5
.changeset/humble-tigers-tumble.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 642
|
||||
---
|
||||
**`/gsd-import`, `/gsd-plan-review-convergence`, and `/gsd-spec-phase` now run on global installs** — these workflows resolve `gsd-tools` via the runtime launcher instead of a hardcoded `$HOME` path, so they no longer falsely report the tool as "not found" (and stop short) when only a global/shim install is present and no project-local runtime exists.
|
||||
@@ -52,7 +52,8 @@ If `PATH_NOT_FOUND` or `MANIFEST_NOT_FOUND`: display error and exit.
|
||||
Run the init query:
|
||||
|
||||
```bash
|
||||
INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init ingest-docs)
|
||||
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi
|
||||
INIT=$(gsd_run init ingest-docs)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
@@ -295,7 +296,7 @@ Preview the merge diff to the user and gate via approve-revise-abort before writ
|
||||
Commit the ingest results:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" commit \
|
||||
gsd_run commit \
|
||||
"docs: ingest {N} docs from {SCAN_PATH} (#2387)" --files \
|
||||
.planning/PROJECT.md \
|
||||
.planning/REQUIREMENTS.md \
|
||||
|
||||
@@ -63,7 +63,7 @@ Then re-run: /gsd:plan-review-convergence {PHASE}
|
||||
## 2. Initialize
|
||||
|
||||
```bash
|
||||
INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init plan-phase "$PHASE")
|
||||
INIT=$(gsd_run init plan-phase "$PHASE")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
@@ -76,7 +76,7 @@ Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from in
|
||||
## 3. Validate Phase + Pre-flight Gate
|
||||
|
||||
```bash
|
||||
PHASE_INFO=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}")
|
||||
PHASE_INFO=$(gsd_run roadmap get-phase "${PHASE}")
|
||||
```
|
||||
|
||||
**If `found` is false:** Error with available phases. Exit.
|
||||
@@ -230,7 +230,7 @@ fi
|
||||
**If HIGH_COUNT == 0 (converged):**
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" state planned-phase --phase "${PHASE}" --name "${phase_name}" --plans "${PLAN_COUNT}"
|
||||
gsd_run state planned-phase --phase "${PHASE}" --name "${phase_name}" --plans "${PLAN_COUNT}"
|
||||
```
|
||||
|
||||
Display:
|
||||
|
||||
@@ -56,7 +56,8 @@ Rotate through these perspectives — each naturally surfaces different blindspo
|
||||
## Step 1: Initialize
|
||||
|
||||
```bash
|
||||
INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init phase-op "${PHASE}")
|
||||
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi
|
||||
INIT=$(gsd_run init phase-op "${PHASE}")
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
|
||||
@@ -126,19 +126,21 @@ describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('ingest-docs.md init step uses canonical node-path gsd-tools.cjs invocation', () => {
|
||||
test('ingest-docs.md init step uses the gsd_run launcher (#637)', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8');
|
||||
// Parse fenced bash blocks structurally — do not match raw markdown text.
|
||||
const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g;
|
||||
const bashLines = [...content.matchAll(codeBlockRe)]
|
||||
.flatMap((m) => m[1].split('\n'))
|
||||
.filter((l) => !/^\s*#/.test(l));
|
||||
// Per #2851 the only valid form is the absolute-path node invocation; the
|
||||
// legacy bare `gsd-tools` is the bug being fixed and must not be accepted.
|
||||
// #637 routes ingest-docs through the resolved `gsd_run` launcher instead of
|
||||
// the hardcoded `node "$HOME/.../gsd-tools.cjs"` path (which misses global
|
||||
// installs). The legacy bare `gsd-tools` form remains the bug and is still
|
||||
// rejected by bug-2851's repo-wide guard.
|
||||
const initLine = bashLines.find((l) =>
|
||||
/\bnode\s+["']?\$HOME\/\.claude\/gsd-core\/bin\/gsd-tools\.cjs["']?\s+init\s+ingest-docs\b/.test(l)
|
||||
/\bgsd_run\s+init\s+ingest-docs\b/.test(l)
|
||||
);
|
||||
assert.ok(initLine, 'workflow must invoke init ingest-docs via canonical node-path gsd-tools.cjs');
|
||||
assert.ok(initLine, 'workflow must invoke init ingest-docs via the gsd_run launcher (#637)');
|
||||
});
|
||||
|
||||
test('cmdInitIngestDocs is exported from init.cjs', () => {
|
||||
|
||||
67
tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs
Normal file
67
tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs
Normal file
@@ -0,0 +1,67 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md text IS what the runtime loads and the agent executes, so
|
||||
// asserting on its shell invocations tests the deployed contract directly.
|
||||
//
|
||||
// Repo-wide regression guard for #637 (generalizes the plan-phase-only guard
|
||||
// from #621): NO workflow .md may invoke gsd-tools via a hardcoded
|
||||
// `node "$HOME/.../gsd-tools.cjs"` path. On a global/shim-only install with no
|
||||
// project-local runtime, that path can miss a working install, so the step
|
||||
// reports the tool "not found" instead of resolving it. Every invocation must
|
||||
// go through the `gsd_run` launcher (defined once per file in the canonical
|
||||
// preamble, which resolves RUNTIME_DIR → .claude → PATH → $HOME in order).
|
||||
//
|
||||
// The parity test (runtime-launcher-parity) guards the retired $GSD_SDK and
|
||||
// bare /gsd-tools tokens but NOT this hardcoded-node form — which is exactly
|
||||
// how it survived across plan-phase.md (#621) and three more files (#637).
|
||||
|
||||
'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 WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
|
||||
// Hardcoded direct invocation form. Distinct from the canonical preamble, which
|
||||
// references $HOME only inside a `[ -f "$HOME/..." ]` probe / `GSD_TOOLS=`
|
||||
// assignment and always invokes `node "$GSD_TOOLS"` — never `node "$HOME/..."`.
|
||||
const HARDCODED_HOME_INVOCATION = /node\s+"\$HOME\/[^"]*gsd-tools\.cjs"/;
|
||||
|
||||
function collectWorkflowMarkdown(dir) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...collectWorkflowMarkdown(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('bug #637: no workflow .md hardcodes a $HOME gsd-tools invocation', () => {
|
||||
test('every gsd-core/workflows/**/*.md resolves gsd-tools via gsd_run, not a hardcoded $HOME path', () => {
|
||||
const files = collectWorkflowMarkdown(WORKFLOWS_DIR);
|
||||
assert.ok(files.length > 0, 'expected workflow markdown files to exist');
|
||||
|
||||
const offenders = [];
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
||||
lines.forEach((line, i) => {
|
||||
if (HARDCODED_HOME_INVOCATION.test(line)) {
|
||||
offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Workflow files must invoke gsd-tools via the resolved `gsd_run` launcher, ' +
|
||||
'not a hardcoded `node "$HOME/.../gsd-tools.cjs"` path. Offenders:\n' +
|
||||
offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user