diff --git a/bin/gstack-gbrain-install b/bin/gstack-gbrain-install index 84091c236..e27d0ab7d 100755 --- a/bin/gstack-gbrain-install +++ b/bin/gstack-gbrain-install @@ -96,6 +96,19 @@ if ! $VALIDATE_ONLY; then fi fi +# Read a string field (dotted path, e.g. bin.gbrain) from a package.json with +# bun, which this installer already requires. It used to need jq, which it +# never checked for, so a successful install reported failure (#1675). +pkg_field() { + GSTACK_PKG_JSON="$1" GSTACK_PKG_FIELD="$2" bun -e ' + try { + const pkg = JSON.parse(require("fs").readFileSync(process.env.GSTACK_PKG_JSON, "utf8")); + const v = process.env.GSTACK_PKG_FIELD.split(".").reduce((o, k) => (o == null ? o : o[k]), pkg); + if (typeof v === "string") process.stdout.write(v); + } catch {} + ' 2>/dev/null || true +} + # --- D5 detect-first: probe common locations before cloning fresh --- # Accept any directory that looks like a gbrain clone: has package.json # with name "gbrain" and a `bin.gbrain` entry. Don't accept version mismatches @@ -105,10 +118,10 @@ is_valid_clone() { [ -d "$dir" ] || return 1 [ -f "$dir/package.json" ] || return 1 local name - name=$(jq -r '.name // empty' "$dir/package.json" 2>/dev/null || true) + name=$(pkg_field "$dir/package.json" name) [ "$name" = "gbrain" ] || return 1 local bin - bin=$(jq -r '.bin.gbrain // empty' "$dir/package.json" 2>/dev/null || true) + bin=$(pkg_field "$dir/package.json" bin.gbrain) [ -n "$bin" ] || return 1 return 0 } @@ -199,7 +212,7 @@ _bun_exe_hint() { # Read the version from the install-dir's package.json; compare to # `gbrain --version`. If they disagree, PATH is returning a DIFFERENT # gbrain than the one we just linked. Fail hard with remediation. -expected_version=$(jq -r '.version // empty' "$INSTALL_DIR/package.json" 2>/dev/null || true) +expected_version=$(pkg_field "$INSTALL_DIR/package.json" version) if [ -z "$expected_version" ]; then fail "cannot read version from $INSTALL_DIR/package.json (install may be broken)" fi diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index 05e50b1df..7b9ace1b6 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -36,7 +36,7 @@ # at src/core/config.ts:53), so a process that flips config.json mid-sync # can't redirect us at a different brain mid-stream. # -# Depends on: jq (transitive via gstack-gbrain-detect). +# Depends on: bun (already a gstack dependency), gbrain, git. set -euo pipefail @@ -204,21 +204,34 @@ ensure_worktree() { } # ---- gbrain sources operations ---- +# Read .sources[] | select(.id==$id) | .local_path out of `gbrain sources list` +# with bun (a gstack dependency). This used to need jq, which nothing installs +# and which git-bash on Windows lacks, so the wireup was skipped every run +# (#2369). Prints the path, or nothing when the id is absent or the payload +# is not JSON. +source_local_path() { + gbrain sources list --json 2>/dev/null \ + | GB_SOURCE_ID="$1" bun -e ' + const raw = await Bun.stdin.text(); + try { + const s = (JSON.parse(raw).sources || []).find((x) => x.id === process.env.GB_SOURCE_ID); + process.stdout.write(s?.local_path ?? ""); + } catch {} + ' 2>/dev/null \ + | tr -d '[:space:]' +} + # Returns 0 if source with id exists at expected path. 1 if exists but path differs. 2 if absent. -# Hard-fails (exits non-zero via die) if jq is missing — without jq we cannot -# distinguish "absent" from "missing-tool" and would falsely re-add an existing -# source. jq is documented as a dependency of gstack-gbrain-detect (transitive) -# but adversarial review flagged the silent-fall-through path; this probe makes -# the failure mode loud. +# Hard-fails (exits non-zero via die) if the JSON reader is unavailable: we +# could not then tell "absent" from "missing tool" and would falsely re-add an +# existing source. check_source_state() { local id="$1" - if ! command -v jq >/dev/null 2>&1; then - die "jq required for source state detection. Install jq (brew install jq) and re-run." 1 + if ! command -v bun >/dev/null 2>&1; then + die "bun required for source state detection. Install bun (https://bun.sh) and re-run." 1 fi local existing_path - existing_path=$(gbrain sources list --json 2>/dev/null \ - | jq -r --arg id "$id" '.sources[] | select(.id==$id) | .local_path' 2>/dev/null \ - | tr -d '[:space:]') || existing_path="" + existing_path=$(source_local_path "$id") || existing_path="" if [ -z "$existing_path" ]; then return 2 fi @@ -299,9 +312,7 @@ do_wireup() { # registration. Just sync from our local worktree — gbrain stores pages # by content, not by local_path. The metadata is informational only. local existing_path - existing_path=$(gbrain sources list --json 2>/dev/null \ - | jq -r --arg id "$id" '.sources[] | select(.id==$id) | .local_path' 2>/dev/null \ - | tr -d '[:space:]') || existing_path="" + existing_path=$(source_local_path "$id") || existing_path="" if [ "$(basename "$existing_path")" = "$(basename "$WORKTREE")" ] \ && [ "$existing_path" != "$WORKTREE" ]; then warn "source $id is registered at $existing_path (likely another machine's local copy of the same brain repo). Skipping re-registration; will sync from local worktree." diff --git a/test/gbrain-detect-install.test.ts b/test/gbrain-detect-install.test.ts index c234d3a2d..34ff1803d 100644 --- a/test/gbrain-detect-install.test.ts +++ b/test/gbrain-detect-install.test.ts @@ -324,3 +324,39 @@ describe('gstack-gbrain-install argument handling', () => { expect(r.stderr).toContain('unknown flag'); }); }); + +// B8 (#1675): the installer reads package.json with bun; it used to need jq, +// never checked for it, and reported a successful install as failed. +describe('gstack-gbrain-install without jq (B8)', () => { + function pathWithoutJq(extra: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'no-jq-bin-')); + for (const base of ['/usr/bin', '/bin', '/usr/sbin', '/sbin']) { + if (!fs.existsSync(base)) continue; + for (const name of fs.readdirSync(base)) { + if (name === 'jq' || name === 'gbrain' || fs.existsSync(path.join(dir, name))) continue; + try { fs.symlinkSync(path.join(base, name), path.join(dir, name)); } catch {} + } + } + return `${extra}:${dir}:${BUN_ONLY_DIR}`; + } + + test('validates a matching install and reuses a detected clone with no jq on PATH', async () => { + const installDir = path.join(tmpHomeReal, 'gbrain-install'); + fs.mkdirSync(installDir, { recursive: true }); + fs.writeFileSync(path.join(installDir, 'package.json'), JSON.stringify({ name: 'gbrain', version: '0.60.37', bin: { gbrain: 'src/cli.ts' } })); + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-gbrain-')); + fs.writeFileSync(path.join(fakeBin, 'gbrain'), '#!/bin/sh\necho "gbrain 0.60.37"\n', { mode: 0o755 }); + const PATH = pathWithoutJq(fakeBin); + const ok = await run(INSTALL, ['--validate-only', '--install-dir', installDir], { env: { PATH } }); + expect(ok.stderr).not.toContain('cannot read version'); + expect(ok.status).toBe(0); + + const clone = path.join(tmpHomeReal, 'git', 'gbrain'); + fs.mkdirSync(clone, { recursive: true }); + fs.writeFileSync(path.join(clone, 'package.json'), JSON.stringify({ name: 'gbrain', version: '0.60.37', bin: { gbrain: 'src/cli.ts' } })); + const dry = await run(INSTALL, ['--dry-run'], { env: { PATH } }); + expect(dry.stdout).toContain(`detected existing gbrain clone at ${clone}`); + fs.rmSync(fakeBin, { recursive: true, force: true }); + }); +}); + diff --git a/test/gstack-gbrain-source-wireup.test.ts b/test/gstack-gbrain-source-wireup.test.ts index 4b7858c5f..2fc906441 100644 --- a/test/gstack-gbrain-source-wireup.test.ts +++ b/test/gstack-gbrain-source-wireup.test.ts @@ -565,3 +565,34 @@ describe('gstack-gbrain-source-wireup — defensive paths', () => { expect(fs.existsSync(path.join(worktreeDir, 'unrelated.txt'))).toBe(false); // stray gone }); }); + +// B8 (#2369): the wireup reads `gbrain sources list --json` with bun, so a +// machine without jq (git-bash on Windows, minimal Linux) still wires up. +describe('gstack-gbrain-source-wireup without jq (B8)', () => { + function pathWithoutJq(): string { + const dir = path.join(tmpHome, 'no-jq-bin'); + fs.mkdirSync(dir, { recursive: true }); + for (const base of ['/usr/bin', '/bin', '/usr/sbin', '/sbin']) { + if (!fs.existsSync(base)) continue; + for (const name of fs.readdirSync(base)) { + if (name === 'jq' || fs.existsSync(path.join(dir, name))) continue; + try { fs.symlinkSync(path.join(base, name), path.join(dir, name)); } catch {} + } + } + return `${fakeBinDir}:${dir}:${BUN_ONLY_DIR}`; + } + + test('detects an already-registered source and re-runs cleanly with no jq on PATH', () => { + setupGstackRepo('git@github.com:user/gstack-brain-user.git'); + makeFakeGbrain({}); + const PATH = pathWithoutJq(); + expect(spawnSync('sh', ['-c', 'command -v jq'], { env: { PATH }, timeout: 10_000 }).status).not.toBe(0); + const first = run([], { env: { PATH, GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(first.stderr).not.toContain('jq required'); + expect(first.status).toBe(0); + const second = run([], { env: { PATH, GSTACK_BRAIN_NO_SYNC: '1' } }); + expect(second.status).toBe(0); + expect(readState().sources).toHaveLength(1); + }); +}); +