B8: read gbrain JSON with bun instead of jq in install and wireup

gstack-gbrain-install read package.json with jq but never checked for it,
so on a machine without jq a successful install failed with "cannot read
version". gstack-gbrain-source-wireup hard-failed without jq ("brew
install jq" is not actionable on git-bash), so the wireup was skipped on
every run. Both now read the JSON with bun, which they already require.

Co-authored-by: benjamin beres <benjamin.beres@bienpreter.com>
This commit is contained in:
garrytanandbenjamin beres committed 2026-10-03 21:34:15 +00:00
1 parent c612f28339
commit 6c0917aed2
4 files changed
+108 -17

No files matched your search

+16 -3
View File
@@ -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
+25 -14
View File
@@ -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."
+36
View File
@@ -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 });
});
});
+31
View File
@@ -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);
});
});