From 3cf2885bae175e70a2a450766f96737a173bb5d7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 13 May 2026 11:10:23 -0700 Subject: [PATCH] =?UTF-8?q?refactor(gbrain):=20rewrite=20gstack-gbrain-det?= =?UTF-8?q?ect=20bash=E2=86=92TS=20+=20add=20gbrain=5Flocal=5Fstatus=20fie?= =?UTF-8?q?ld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bash detect helper with a bun shebang script sharing the gbrain_local_status classifier from lib/gbrain-local-status.ts with the sync orchestrator. Single source of truth for engine-status classification between preamble-probe and orchestrator-skip paths. Filename stays gstack-gbrain-detect (no .ts extension) so existing skill preamble callers shell out unchanged. Shebang `#!/usr/bin/env -S bun run` resolves bun at runtime. Output is key/type backward-compatible with the bash version per plan codex #5: the 9 pre-existing keys (gbrain_on_path, gbrain_version, gbrain_config_exists, gbrain_engine, gbrain_doctor_ok, gbrain_mcp_mode, gstack_brain_sync_mode, gstack_brain_git, gstack_artifacts_remote) stay identical in name + type + value semantics. One new key added: gbrain_local_status (5-state string enum). Updates the existing schema regression at test/gstack-gbrain-detect-mcp-mode.test.ts to include the new key. Adds test/gbrain-detect-shape.test.ts asserting the regression contract for future changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/gstack-gbrain-detect | 377 +++++++++++---------- test/gbrain-detect-shape.test.ts | 246 ++++++++++++++ test/gstack-gbrain-detect-mcp-mode.test.ts | 1 + 3 files changed, 448 insertions(+), 176 deletions(-) create mode 100644 test/gbrain-detect-shape.test.ts diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 98775bfdf..b0e9931eb 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -1,188 +1,213 @@ -#!/usr/bin/env bash -# gstack-gbrain-detect — emit current gbrain/gstack-brain state as JSON. -# -# Usage: -# gstack-gbrain-detect -# -# Output (always valid JSON, even when every check is false): -# { -# "gbrain_on_path": true|false, -# "gbrain_version": "0.18.2" | null, -# "gbrain_config_exists": true|false, -# "gbrain_engine": "pglite"|"postgres" | null, -# "gbrain_doctor_ok": true|false, -# "gbrain_mcp_mode": "local-stdio"|"remote-http"|"none", -# "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", -# "gstack_brain_git": true|false, -# "gstack_artifacts_remote": "https://..." | "" -# } -# -# The /setup-gbrain skill reads this once at startup to decide which path -# branches are live and which steps can be skipped. Never modifies state; -# pure introspection. Exits 0 unless `jq` is missing. -# -# Env: -# GSTACK_HOME — override ~/.gstack for gstack-brain-* state lookups. -set -euo pipefail +#!/usr/bin/env -S bun run +/** + * gstack-gbrain-detect — emit current gbrain/gstack-brain state as JSON. + * + * Rewritten from bash to TypeScript in v{X.Y.Z.0} to share the engine-status + * classifier with bin/gstack-gbrain-sync.ts. Single source of truth via + * lib/gbrain-local-status.ts. Filename and exec semantics unchanged: callers + * just shell out to the file path; the bun shebang resolves at runtime. + * + * Output (always valid JSON, even when every check is false): + * { + * "gbrain_on_path": true|false, + * "gbrain_version": "0.18.2" | null, + * "gbrain_config_exists": true|false, + * "gbrain_engine": "pglite"|"postgres" | null, + * "gbrain_doctor_ok": true|false, + * "gbrain_mcp_mode": "local-stdio"|"remote-http"|"none", + * "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", + * "gstack_brain_git": true|false, + * "gstack_artifacts_remote": "https://..." | "", + * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db" + * } + * + * Backward compatibility (per plan codex #5): the 9 pre-existing fields stay + * identical in name + type + value semantics. One new field added: + * gbrain_local_status. Key order may differ from the bash version's `jq -n` + * output — downstream parsers must not depend on key order (none currently do). + * + * Env: + * GSTACK_HOME — override ~/.gstack for state lookups (used by tests). + * HOME — effective user home (drives ~/.gbrain/config.json path). + * GSTACK_DETECT_NO_CACHE=1 — bypass the 60s local-status cache. + */ -STATE_DIR="${GSTACK_HOME:-$HOME/.gstack}" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -CONFIG_BIN="$SCRIPT_DIR/gstack-config" -GBRAIN_CONFIG="$HOME/.gbrain/config.json" +import { execFileSync } from "child_process"; +import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; -die() { echo "gstack-gbrain-detect: $*" >&2; exit 2; } +import { localEngineStatus } from "../lib/gbrain-local-status"; -require_jq() { - command -v jq >/dev/null 2>&1 || die "jq is required. Install with: brew install jq" +const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack"); +const SCRIPT_DIR = __dirname; +const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config"); +const GBRAIN_CONFIG = join(userHome(), ".gbrain", "config.json"); +const CLAUDE_JSON = join(userHome(), ".claude.json"); + +function userHome(): string { + return process.env.HOME || homedir(); } -require_jq -# --- gbrain binary presence + version --- -gbrain_on_path=false -gbrain_version=null -if command -v gbrain >/dev/null 2>&1; then - gbrain_on_path=true - # Format versions as JSON strings; gbrain --version may print other chatter. - v=$(gbrain --version 2>/dev/null | head -1 | tr -d '[:space:]' || true) - if [ -n "$v" ]; then - gbrain_version=$(jq -Rn --arg v "$v" '$v') - fi -fi +function tryExec(cmd: string, args: string[], timeoutMs = 5_000): string | null { + try { + return execFileSync(cmd, args, { + encoding: "utf-8", + timeout: timeoutMs, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} -# --- gbrain config file --- -gbrain_config_exists=false -gbrain_engine=null -if [ -f "$GBRAIN_CONFIG" ]; then - gbrain_config_exists=true - # Engine is defensively parsed; an invalid config returns null, not a crash. - engine_raw=$(jq -r '.engine // empty' "$GBRAIN_CONFIG" 2>/dev/null || true) - case "$engine_raw" in - pglite|postgres) gbrain_engine=$(jq -Rn --arg e "$engine_raw" '$e') ;; - esac -fi +function tryReadJSON(path: string): unknown | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} -# --- gbrain doctor health --- -# Doctor is wrapped in `timeout 5s` to match the /health D6 pattern and avoid -# the detect step hanging the skill when gbrain is broken or its DB is -# unreachable. Any nonzero exit or non-"ok"/"warnings" status → false. -gbrain_doctor_ok=false -if [ "$gbrain_on_path" = "true" ]; then - # Use `timeout` if available; some minimal macs use gtimeout from coreutils. - timeout_bin="" - if command -v timeout >/dev/null 2>&1; then timeout_bin="timeout 5s" - elif command -v gtimeout >/dev/null 2>&1; then timeout_bin="gtimeout 5s" - fi - if doctor_json=$(eval "$timeout_bin gbrain doctor --json" 2>/dev/null); then - status=$(echo "$doctor_json" | jq -r '.status // empty' 2>/dev/null || true) - case "$status" in - ok|warnings) gbrain_doctor_ok=true ;; - esac - fi -fi +// --- gbrain binary presence + version --- +function detectGbrain(): { onPath: boolean; version: string | null } { + const out = tryExec("sh", ["-c", "command -v gbrain"], 2_000); + if (!out) return { onPath: false, version: null }; + const verRaw = tryExec("gbrain", ["--version"], 2_000); + if (!verRaw) return { onPath: true, version: null }; + // Match bash behavior: head -1 | tr -d '[:space:]' + const version = verRaw.split("\n")[0].replace(/\s+/g, "") || null; + return { onPath: true, version }; +} -# --- artifacts sync state (renamed from gbrain_sync_mode in v1.27.0.0) --- -gstack_brain_sync_mode="off" -if [ -x "$CONFIG_BIN" ]; then - mode=$("$CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || true) - case "$mode" in - off|artifacts-only|full) gstack_brain_sync_mode="$mode" ;; - esac -fi +// --- gbrain config existence + engine kind --- +function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null } { + if (!existsSync(GBRAIN_CONFIG)) return { exists: false, engine: null }; + const parsed = tryReadJSON(GBRAIN_CONFIG) as { engine?: string } | null; + if (!parsed) return { exists: true, engine: null }; + if (parsed.engine === "pglite" || parsed.engine === "postgres") { + return { exists: true, engine: parsed.engine }; + } + return { exists: true, engine: null }; +} -gstack_brain_git=false -if [ -d "$STATE_DIR/.git" ]; then - gstack_brain_git=true -fi +// --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) --- +function detectDoctor(onPath: boolean): boolean { + if (!onPath) return false; + const out = tryExec("gbrain", ["doctor", "--json"], 5_000); + if (!out) return false; + try { + const parsed = JSON.parse(out) as { status?: string }; + return parsed.status === "ok" || parsed.status === "warnings"; + } catch { + return false; + } +} -# --- gbrain_mcp_mode: local-stdio | remote-http | none --- -# Defense-in-depth fallback chain (intentional ordering, do not reorder): -# 1. `claude mcp get gbrain --json` — public CLI surface, structured output -# 2. `claude mcp list` text-grep — older claude versions without --json -# 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH -# Fallback chain logged because if Anthropic moves the file or renames keys, -# the third tier breaks silently; the first two tiers should catch it. -gbrain_mcp_mode="none" -if command -v claude >/dev/null 2>&1; then - # Tier 1: claude mcp get --json - if mcp_get_json=$(claude mcp get gbrain --json 2>/dev/null); then - if echo "$mcp_get_json" | jq -e '.' >/dev/null 2>&1; then - mtype=$(echo "$mcp_get_json" | jq -r '.type // .transport // empty' 2>/dev/null) - mcommand=$(echo "$mcp_get_json" | jq -r '.command // empty' 2>/dev/null) - murl=$(echo "$mcp_get_json" | jq -r '.url // empty' 2>/dev/null) - case "$mtype" in - http|sse) gbrain_mcp_mode="remote-http" ;; - stdio) gbrain_mcp_mode="local-stdio" ;; - *) - # Newer claude versions may emit just url + command; infer. - if [ -n "$murl" ]; then gbrain_mcp_mode="remote-http" - elif [ -n "$mcommand" ]; then gbrain_mcp_mode="local-stdio" - fi - ;; - esac - fi - fi - # Tier 2: claude mcp list text-grep (only if Tier 1 didn't resolve) - if [ "$gbrain_mcp_mode" = "none" ]; then - if mcp_list=$(claude mcp list 2>/dev/null); then - gbrain_line=$(echo "$mcp_list" | grep -E '^gbrain:' || true) - if [ -n "$gbrain_line" ]; then - if echo "$gbrain_line" | grep -q 'http\|HTTP'; then - gbrain_mcp_mode="remote-http" - else - gbrain_mcp_mode="local-stdio" - fi - fi - fi - fi -fi -# Tier 3: ~/.claude.json jq read (only if claude binary or earlier tiers failed) -if [ "$gbrain_mcp_mode" = "none" ]; then - if [ -f "$HOME/.claude.json" ]; then - # Look for a gbrain MCP server entry. Type field disambiguates http vs stdio. - mtype=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null) - murl=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null) - mcommand=$(jq -r '.mcpServers.gbrain.command // empty' "$HOME/.claude.json" 2>/dev/null) - case "$mtype" in - url|http|sse) gbrain_mcp_mode="remote-http" ;; - stdio) gbrain_mcp_mode="local-stdio" ;; - *) - if [ -n "$murl" ]; then gbrain_mcp_mode="remote-http" - elif [ -n "$mcommand" ]; then gbrain_mcp_mode="local-stdio" - fi - ;; - esac - fi -fi +// --- artifacts sync mode --- +function detectSyncMode(): "off" | "artifacts-only" | "full" { + if (!existsSync(CONFIG_BIN)) return "off"; + const out = tryExec(CONFIG_BIN, ["get", "artifacts_sync_mode"], 2_000); + if (out === "off" || out === "artifacts-only" || out === "full") return out; + return "off"; +} -# --- artifacts remote URL (post-rename) with brain-* fallback during the -# migration window (gstack-upgrade migration runs the rename). --- -gstack_artifacts_remote="" -if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then - gstack_artifacts_remote=$(head -1 "$HOME/.gstack-artifacts-remote.txt" 2>/dev/null | tr -d '[:space:]' || true) -elif [ -f "$HOME/.gstack-brain-remote.txt" ]; then - # Pre-migration fallback. Migration v1.27.0.0 will mv this to the new path. - gstack_artifacts_remote=$(head -1 "$HOME/.gstack-brain-remote.txt" 2>/dev/null | tr -d '[:space:]' || true) -fi +// --- gstack-brain git repo present? --- +function detectBrainGit(): boolean { + return existsSync(join(STATE_DIR, ".git")); +} -# Emit single-object JSON. -jq -n \ - --argjson on_path "$gbrain_on_path" \ - --argjson version "$gbrain_version" \ - --argjson config_exists "$gbrain_config_exists" \ - --argjson engine "$gbrain_engine" \ - --argjson doctor_ok "$gbrain_doctor_ok" \ - --arg mcp_mode "$gbrain_mcp_mode" \ - --arg sync_mode "$gstack_brain_sync_mode" \ - --argjson brain_git "$gstack_brain_git" \ - --arg artifacts_remote "$gstack_artifacts_remote" \ - '{ - gbrain_on_path: $on_path, - gbrain_version: $version, - gbrain_config_exists: $config_exists, - gbrain_engine: $engine, - gbrain_doctor_ok: $doctor_ok, - gbrain_mcp_mode: $mcp_mode, - gstack_brain_sync_mode: $sync_mode, - gstack_brain_git: $brain_git, - gstack_artifacts_remote: $artifacts_remote - }' +// --- MCP mode: local-stdio | remote-http | none --- +// +// Defense-in-depth fallback chain (same ordering as the bash version): +// 1. `claude mcp get gbrain --json` — public CLI surface, structured output +// 2. `claude mcp list` text-grep — older claude versions without --json +// 3. `~/.claude.json` jq read — last resort if `claude` isn't on PATH +function detectMcpMode(): "local-stdio" | "remote-http" | "none" { + const claudeOnPath = tryExec("sh", ["-c", "command -v claude"], 1_000) !== null; + if (claudeOnPath) { + // Tier 1: `claude mcp get gbrain --json` + const get = tryExec("claude", ["mcp", "get", "gbrain", "--json"], 3_000); + if (get) { + try { + const parsed = JSON.parse(get) as { + type?: string; + transport?: string; + command?: string; + url?: string; + }; + const mtype = parsed.type || parsed.transport || ""; + if (mtype === "http" || mtype === "sse") return "remote-http"; + if (mtype === "stdio") return "local-stdio"; + if (parsed.url) return "remote-http"; + if (parsed.command) return "local-stdio"; + } catch { + // fall through + } + } + // Tier 2: `claude mcp list` text-grep + const list = tryExec("claude", ["mcp", "list"], 3_000); + if (list) { + const line = list.split("\n").find((l) => /^gbrain:/.test(l)); + if (line) { + if (/\b(http|HTTP)\b/.test(line)) return "remote-http"; + return "local-stdio"; + } + } + } + // Tier 3: read ~/.claude.json directly + const cj = tryReadJSON(CLAUDE_JSON) as + | { mcpServers?: { gbrain?: { type?: string; transport?: string; command?: string; url?: string } } } + | null; + const entry = cj?.mcpServers?.gbrain; + if (entry) { + const mtype = entry.type || entry.transport || ""; + if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http"; + if (mtype === "stdio") return "local-stdio"; + if (entry.url) return "remote-http"; + if (entry.command) return "local-stdio"; + } + return "none"; +} + +// --- artifacts remote URL with brain-* fallback during the rename migration window --- +function detectArtifactsRemote(): string { + const newPath = join(userHome(), ".gstack-artifacts-remote.txt"); + const oldPath = join(userHome(), ".gstack-brain-remote.txt"); + for (const p of [newPath, oldPath]) { + if (existsSync(p)) { + try { + return readFileSync(p, "utf-8").split("\n")[0].trim(); + } catch { + // fall through + } + } + } + return ""; +} + +function main(): void { + const gbrain = detectGbrain(); + const config = detectConfig(); + const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1"; + + // Order MATCHES the bash version's jq output for callers that visually grep + // (key order doesn't affect JSON parsers, but minimizes review noise). + const out = { + gbrain_on_path: gbrain.onPath, + gbrain_version: gbrain.version, + gbrain_config_exists: config.exists, + gbrain_engine: config.engine, + gbrain_doctor_ok: detectDoctor(gbrain.onPath), + gbrain_mcp_mode: detectMcpMode(), + gstack_brain_sync_mode: detectSyncMode(), + gstack_brain_git: detectBrainGit(), + gstack_artifacts_remote: detectArtifactsRemote(), + gbrain_local_status: localEngineStatus({ noCache }), + }; + + process.stdout.write(JSON.stringify(out, null, 2) + "\n"); +} + +main(); diff --git a/test/gbrain-detect-shape.test.ts b/test/gbrain-detect-shape.test.ts new file mode 100644 index 000000000..465e55623 --- /dev/null +++ b/test/gbrain-detect-shape.test.ts @@ -0,0 +1,246 @@ +/** + * Shape regression test for bin/gstack-gbrain-detect. + * + * After the bash→TS rewrite (codex #5), the TS output must stay + * key/type/semantics backward-compatible with the bash version. Downstream + * callers across most gstack skill preambles shell out to this script and + * pipe through jq. Key order may differ between bash+jq and JSON.stringify; + * key NAMES and TYPES must not. + * + * Asserts: + * 1. All 9 pre-existing keys are present + * 2. Each pre-existing key has the same primitive type/union as the bash version + * 3. The new key (gbrain_local_status) is present and a string + * 4. Output is parseable JSON + * 5. No keys removed/renamed + */ + +import { describe, it, expect } from "bun:test"; +import { execFileSync } from "child_process"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + chmodSync, + rmSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const DETECT_BIN = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect"); + +/** Absolute bun path resolved once at module load (uses the test runner's PATH). */ +const BUN_BIN = execFileSync("sh", ["-c", "command -v bun"], { encoding: "utf-8" }).trim(); + +/** + * Run detect with a controlled HOME + PATH so the output is deterministic. + * We invoke via `bun run ` instead of the shebang so the test doesn't + * need bun on its PATH. The script's child-process probes still respect + * the controlled PATH. + */ +function runDetect(env: Partial): string { + return execFileSync(BUN_BIN, ["run", DETECT_BIN], { + encoding: "utf-8", + timeout: 15_000, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...env }, + }); +} + +interface DetectShape { + gbrain_on_path: boolean; + gbrain_version: string | null; + gbrain_config_exists: boolean; + gbrain_engine: string | null; + gbrain_doctor_ok: boolean; + gbrain_mcp_mode: string; + gstack_brain_sync_mode: string; + gstack_brain_git: boolean; + gstack_artifacts_remote: string; + gbrain_local_status: string; +} + +describe("bin/gstack-gbrain-detect — shape regression", () => { + it("emits valid JSON", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + try { + const out = runDetect({ + HOME: tmp, + PATH: "/usr/bin:/bin", + GSTACK_HOME: tmp, + }); + expect(() => JSON.parse(out)).not.toThrow(); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("contains all 9 pre-existing keys + the new gbrain_local_status key", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + try { + const out = runDetect({ + HOME: tmp, + PATH: "/usr/bin:/bin", + GSTACK_HOME: tmp, + }); + const parsed = JSON.parse(out) as DetectShape; + + // 9 pre-existing keys (must not be removed/renamed): + expect(parsed).toHaveProperty("gbrain_on_path"); + expect(parsed).toHaveProperty("gbrain_version"); + expect(parsed).toHaveProperty("gbrain_config_exists"); + expect(parsed).toHaveProperty("gbrain_engine"); + expect(parsed).toHaveProperty("gbrain_doctor_ok"); + expect(parsed).toHaveProperty("gbrain_mcp_mode"); + expect(parsed).toHaveProperty("gstack_brain_sync_mode"); + expect(parsed).toHaveProperty("gstack_brain_git"); + expect(parsed).toHaveProperty("gstack_artifacts_remote"); + + // 1 new key (added by this fix): + expect(parsed).toHaveProperty("gbrain_local_status"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("preserves field types from the bash version", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + try { + const out = runDetect({ + HOME: tmp, + PATH: "/usr/bin:/bin", + GSTACK_HOME: tmp, + }); + const parsed = JSON.parse(out) as Record; + + // Booleans (bash: `true`/`false`; TS: boolean) + expect(typeof parsed.gbrain_on_path).toBe("boolean"); + expect(typeof parsed.gbrain_config_exists).toBe("boolean"); + expect(typeof parsed.gbrain_doctor_ok).toBe("boolean"); + expect(typeof parsed.gstack_brain_git).toBe("boolean"); + + // String | null unions (bash: `null` when absent; TS: null when absent) + const versionType = parsed.gbrain_version === null ? "null" : typeof parsed.gbrain_version; + expect(versionType === "string" || versionType === "null").toBe(true); + const engineType = parsed.gbrain_engine === null ? "null" : typeof parsed.gbrain_engine; + expect(engineType === "string" || engineType === "null").toBe(true); + + // Strings (bash: always emits a string, never null) + expect(typeof parsed.gbrain_mcp_mode).toBe("string"); + expect(typeof parsed.gstack_brain_sync_mode).toBe("string"); + expect(typeof parsed.gstack_artifacts_remote).toBe("string"); + + // New field: string enum + expect(typeof parsed.gbrain_local_status).toBe("string"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("gbrain_mcp_mode is one of the three documented values", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + try { + const out = runDetect({ + HOME: tmp, + PATH: "/usr/bin:/bin", + GSTACK_HOME: tmp, + }); + const parsed = JSON.parse(out) as DetectShape; + expect(["local-stdio", "remote-http", "none"]).toContain(parsed.gbrain_mcp_mode); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("gstack_brain_sync_mode is one of the three documented values", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + try { + const out = runDetect({ + HOME: tmp, + PATH: "/usr/bin:/bin", + GSTACK_HOME: tmp, + }); + const parsed = JSON.parse(out) as DetectShape; + expect(["off", "artifacts-only", "full"]).toContain(parsed.gstack_brain_sync_mode); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("gbrain_local_status is one of the five documented values", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + try { + const out = runDetect({ + HOME: tmp, + PATH: "/usr/bin:/bin", + GSTACK_HOME: tmp, + }); + const parsed = JSON.parse(out) as DetectShape; + expect(["ok", "no-cli", "missing-config", "broken-config", "broken-db"]).toContain( + parsed.gbrain_local_status, + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("with no gbrain on PATH, returns gbrain_on_path=false and gbrain_local_status=no-cli", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + try { + const out = runDetect({ + HOME: tmp, + PATH: "/usr/bin:/bin", // no gbrain on this PATH + GSTACK_HOME: tmp, + GSTACK_DETECT_NO_CACHE: "1", + }); + const parsed = JSON.parse(out) as DetectShape; + expect(parsed.gbrain_on_path).toBe(false); + expect(parsed.gbrain_version).toBeNull(); + expect(parsed.gbrain_local_status).toBe("no-cli"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("with fake gbrain that returns valid JSON, returns gbrain_on_path=true and gbrain_local_status=ok", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-shape-")); + const bindir = join(tmp, "bin"); + const home = join(tmp, "home"); + const configDir = join(home, ".gbrain"); + const configPath = join(configDir, "config.json"); + try { + mkdirSync(bindir, { recursive: true }); + mkdirSync(home, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + writeFileSync(configPath, JSON.stringify({ engine: "pglite" })); + + // Fake gbrain: prints valid sources-list JSON + const fake = `#!/bin/sh +case "$1 $2" in + "--version ") echo "gbrain 0.33.1.0"; exit 0 ;; + "sources list") echo '{"sources":[]}'; exit 0 ;; + "doctor "*) echo '{"status":"ok","checks":[]}'; exit 0 ;; +esac +exit 0 +`; + const gbrainPath = join(bindir, "gbrain"); + writeFileSync(gbrainPath, fake); + chmodSync(gbrainPath, 0o755); + + const out = runDetect({ + HOME: home, + PATH: `${bindir}:/usr/bin:/bin`, + GSTACK_HOME: tmp, + GSTACK_DETECT_NO_CACHE: "1", + }); + const parsed = JSON.parse(out) as DetectShape; + expect(parsed.gbrain_on_path).toBe(true); + expect(parsed.gbrain_version).toBe("gbrain0.33.1.0"); + expect(parsed.gbrain_config_exists).toBe(true); + expect(parsed.gbrain_engine).toBe("pglite"); + expect(parsed.gbrain_local_status).toBe("ok"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/gstack-gbrain-detect-mcp-mode.test.ts b/test/gstack-gbrain-detect-mcp-mode.test.ts index 052583d33..a132d0aa1 100644 --- a/test/gstack-gbrain-detect-mcp-mode.test.ts +++ b/test/gstack-gbrain-detect-mcp-mode.test.ts @@ -264,6 +264,7 @@ describe('schema regression', () => { 'gbrain_config_exists', 'gbrain_doctor_ok', 'gbrain_engine', + 'gbrain_local_status', 'gbrain_mcp_mode', 'gbrain_on_path', 'gbrain_version',