diff --git a/bin/gstack-brain-context-load.ts b/bin/gstack-brain-context-load.ts index 250341cfc..6af90eb3f 100644 --- a/bin/gstack-brain-context-load.ts +++ b/bin/gstack-brain-context-load.ts @@ -540,7 +540,8 @@ async function main(): Promise { } const failures = results.filter((r) => r.failed); - if (!args.quiet && failures.length > 0 && gbrainAvailable() && existsSync(join(gbrainConfigDir(), "config.json"))) { + const configDir = gbrainConfigDir(); + if (!args.quiet && failures.length > 0 && gbrainAvailable() && configDir && existsSync(join(configDir, "config.json"))) { const reasons = [...new Set(failures.map((r) => r.reason))].join("; "); console.error(`brain context: ${failures.length}/${results.length} queries failed (${reasons})`); } diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 322eafa0a..7f21389f7 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -62,8 +62,10 @@ const CONFIG_BIN = join(SCRIPT_DIR, "gstack-config"); // GBRAIN_HOME is a parent dir, `.gbrain` is appended) — must stay consistent // with lib/gbrain-local-status's config resolution, or the detect JSON // reports gbrain_local_status "ok" alongside gbrain_config_exists false for -// relocated-home users. Both route through gbrainConfigDir. -const GBRAIN_CONFIG = join(gbrainConfigDir(), "config.json"); +// relocated-home users. Both route through gbrainConfigDir, which returns +// null for a GBRAIN_HOME gbrain refuses; that reads as no config. +const GBRAIN_CONFIG_DIR = gbrainConfigDir(); +const GBRAIN_CONFIG = GBRAIN_CONFIG_DIR ? join(GBRAIN_CONFIG_DIR, "config.json") : null; const CLAUDE_JSON = join(userHome(), ".claude.json"); function userHome(): string { @@ -106,7 +108,7 @@ function detectGbrain(): { onPath: boolean; version: string | null } { // --- gbrain config existence + engine kind --- function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null } { - if (!existsSync(GBRAIN_CONFIG)) return { exists: false, engine: null }; + if (!GBRAIN_CONFIG || !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") { @@ -121,6 +123,7 @@ function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null // a PgBouncer transaction-mode pooler (port 6543). Surfaced so /sync-gbrain // and /setup-gbrain can advise users when search may require GBRAIN_PREPARE. function detectPoolerMode(): "transaction" | "session" | "unknown" | null { + if (!GBRAIN_CONFIG) return null; const parsed = tryReadJSON(GBRAIN_CONFIG) as { database_url?: string } | null; if (!parsed?.database_url) return null; return isTransactionModePooler(parsed.database_url) ? "transaction" : "session"; @@ -289,7 +292,8 @@ function detectMcpMode(): McpMode | "none" { /** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */ function readRemoteMcpUrl(): string { - const cfg = tryReadJSON(join(gbrainConfigDir(), "config.json")) as + if (!GBRAIN_CONFIG) return ""; + const cfg = tryReadJSON(GBRAIN_CONFIG) as | { remote_mcp?: { mcp_url?: string } } | null; return cfg?.remote_mcp?.mcp_url || ""; diff --git a/bin/gstack-gbrain-home.sh b/bin/gstack-gbrain-home.sh index cc174e5c4..242b98e96 100644 --- a/bin/gstack-gbrain-home.sh +++ b/bin/gstack-gbrain-home.sh @@ -6,18 +6,33 @@ # # gstack_gbrain_config_path prints the path of gbrain's config.json using # gbrain's own configDir() rule (src/core/config.ts in garrytan/gbrain): -# GBRAIN_HOME, trimmed of surrounding whitespace, is the PARENT directory and -# ".gbrain" is appended; unset or blank means $HOME/.gbrain. gbrain refuses a -# GBRAIN_HOME that is not absolute or that has a ".." segment, so for one of +# GBRAIN_HOME, trimmed the way JavaScript trim() trims, is the PARENT directory +# and ".gbrain" is appended; unset or blank means $HOME/.gbrain. gbrain refuses +# a GBRAIN_HOME that is not absolute or that has a ".." segment, so for one of # those this prints nothing and returns 1, and the caller must not fall back to # $HOME/.gbrain (that would be a brain gbrain never reads). gbrainConfigDir() in -# lib/gbrain-exec.ts applies the same parent-directory rule for TypeScript -# callers but does not yet trim or refuse. Every gstack shell read of gbrain's -# config goes through here (#4325). +# lib/gbrain-exec.ts is the TypeScript twin, and test/gbrain-home-parity.test.ts +# holds both to one table. Every gstack shell read of gbrain's config goes +# through here (#4325). +# +# POSIX [[:space:]] is not trim()'s set: it misses NBSP and the BOM, and in a +# UTF-8 locale it follows the C library. So the set is spelled out as UTF-8 +# bytes: ASCII tab through carriage return and space, U+00A0, U+1680, +# U+2000-U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 and U+FEFF. gstack_gbrain_config_path() { - local home="${GBRAIN_HOME:-}" - home="${home#"${home%%[![:space:]]*}"}" - home="${home%"${home##*[![:space:]]}"}" + local home="${GBRAIN_HOME:-}" prev ws + local -a spaces=(' ' $'\t' $'\n' $'\v' $'\f' $'\r' $'\xc2\xa0' $'\xe1\x9a\x80' + $'\xe2\x80\x80' $'\xe2\x80\x81' $'\xe2\x80\x82' $'\xe2\x80\x83' $'\xe2\x80\x84' $'\xe2\x80\x85' + $'\xe2\x80\x86' $'\xe2\x80\x87' $'\xe2\x80\x88' $'\xe2\x80\x89' $'\xe2\x80\x8a' $'\xe2\x80\xa8' + $'\xe2\x80\xa9' $'\xe2\x80\xaf' $'\xe2\x81\x9f' $'\xe3\x80\x80' $'\xef\xbb\xbf') + while :; do + prev="$home" + for ws in "${spaces[@]}"; do + home="${home#"$ws"}" + home="${home%"$ws"}" + done + [ "$home" = "$prev" ] && break + done if [ -z "$home" ]; then printf '%s\n' "$HOME/.gbrain/config.json" return 0 diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index dba4605fe..51a7963fb 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -2406,8 +2406,10 @@ export function transcriptSourceId(remote: string): string { */ function brainIsRemote(remoteHttpMode: boolean): boolean { if (remoteHttpMode) return true; + const configDir = gbrainConfigDir(); + if (!configDir) return false; try { - const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(), "config.json"), "utf-8")); + const cfg = JSON.parse(readFileSync(join(configDir, "config.json"), "utf-8")); return (typeof cfg?.database_url === "string" && cfg.database_url.trim() !== "" && cfg.engine !== "pglite") || !!cfg?.remote_mcp; } catch { return false; diff --git a/changelog.d/fix-gbrain-home-health-check.md b/changelog.d/fix-gbrain-home-health-check.md new file mode 100644 index 000000000..12533eb13 --- /dev/null +++ b/changelog.d/fix-gbrain-home-health-check.md @@ -0,0 +1 @@ +- **`/health` and every check that picks gbrain's health probe now look for gbrain's config under `GBRAIN_HOME`, and gstack trims `GBRAIN_HOME` exactly as gbrain does.** The health selection in `lib/checks.ts` read only `~/.gbrain/config.json`, so on a machine where `GBRAIN_HOME` names the brain (gbrain's usual unattended setup) it judged a different brain's config: it could skip the gbrain check for the brain gbrain actually uses, or run it because of an unrelated `~/.gbrain`. It now uses the same rule as gbrain's own `configDir()`: `GBRAIN_HOME` trimmed with JavaScript `trim()`, with `.gbrain` appended, else `~/.gbrain`; a `GBRAIN_HOME` gbrain would refuse (relative, or with a `..` segment) selects nothing instead of falling back to `~/.gbrain`. gstack's TypeScript gbrain helpers (`gbrainConfigDir()`, which seeds gbrain's database URL for every gbrain call gstack makes) now trim and refuse the same way, and the shell helper `bin/gstack-gbrain-home.sh` now treats a `GBRAIN_HOME` made only of Unicode spaces such as a no-break space or a byte-order mark as blank, as gbrain does, instead of refusing it. Nothing changes when `GBRAIN_HOME` is unset. diff --git a/lib/checks.ts b/lib/checks.ts index 5396ba09e..20b050111 100644 --- a/lib/checks.ts +++ b/lib/checks.ts @@ -18,6 +18,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { slugFromEnvironment } from './bin-context'; import { resolveStateRoot } from './state-root'; +import { gbrainConfigDir } from './gbrain-exec'; import { wtreeFingerprint } from './script-invocation'; import type { PregateConfig } from './pregate/config'; import { buildContext, defaultUserConfigPath, describeRefusal, withFingerprint } from './pregate/context'; @@ -151,6 +152,8 @@ export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process. try { testScript = pkgText?.includes('"test"') ? JSON.parse(pkgText).scripts?.test : undefined; } catch { /* an unreadable package.json declares nothing */ } const listDir = (dir: string) => { try { return fs.readdirSync(dir); } catch { return []; } }; const shellFiles = ['.', 'scripts', 'bin'].some(dir => listDir(path.join(root, dir)).some(f => f.endsWith('.sh'))); + const gbrainDir = gbrainConfigDir(env); + const gbrainConfigLabel = env.GBRAIN_HOME?.trim() ? '$GBRAIN_HOME/.gbrain/config.json' : '~/.gbrain/config.json'; const found: Array<[string, string, string] | false> = [ has('tsconfig.json') && ['typecheck', 'tsc --noEmit', 'tsconfig.json'], (has('biome.json') || has('biome.jsonc')) && ['lint', 'biome check .', 'biome.json'], @@ -163,7 +166,7 @@ export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process. which('knip') && ['deadcode', 'knip', 'PATH'], !!pkgText?.includes('"knip"') && ['deadcode', 'npx knip', 'package.json'], which('shellcheck') && shellFiles && ['shell', 'shellcheck', 'PATH, *.sh in the root, scripts/ or bin/'], - which('gbrain') && !!env.HOME && fs.existsSync(path.join(env.HOME, '.gbrain', 'config.json')) && ['gbrain', 'timeout 5s gbrain doctor --json', 'PATH, ~/.gbrain/config.json'], + which('gbrain') && !!env.HOME && !!gbrainDir && fs.existsSync(path.join(gbrainDir, 'config.json')) && ['gbrain', 'timeout 5s gbrain doctor --json', `PATH, ${gbrainConfigLabel}`], ]; const checks = found.filter((c): c is [string, string, string] => !!c).map(([dimension, command, source]) => ({ dimension, command, source })); const reasons: Record = { @@ -172,7 +175,7 @@ export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process. test: typeof testScript === 'string' && /[\r\n]/.test(testScript) ? 'the package.json test script spans lines; list it in a CLAUDE.md Health Stack section' : 'no package.json test script, pytest in pyproject.toml, Cargo.toml or go.mod', deadcode: 'knip is not on PATH or in package.json', shell: which('shellcheck') ? 'no *.sh file in the root, scripts/ or bin/' : 'shellcheck is not on PATH', - gbrain: 'gbrain is not on PATH or ~/.gbrain/config.json is absent', + gbrain: gbrainDir ? `gbrain is not on PATH or ${gbrainConfigLabel} is absent` : 'GBRAIN_HOME is relative or has a ".." segment, which gbrain refuses', }; return { checks, missing: HEALTH_DIMENSIONS.filter(d => !checks.some(c => c.dimension === d)).map(dimension => ({ dimension, reason: reasons[dimension] })) }; } diff --git a/lib/gbrain-exec.ts b/lib/gbrain-exec.ts index ae33568e3..c1171ef1e 100644 --- a/lib/gbrain-exec.ts +++ b/lib/gbrain-exec.ts @@ -34,7 +34,7 @@ */ import { existsSync, readFileSync } from "fs"; -import { join } from "path"; +import { isAbsolute, join } from "path"; import { homedir, tmpdir } from "os"; import { spawnSync, spawn, execFileSync, type SpawnSyncReturns, type ChildProcess, type SpawnOptions } from "child_process"; import { resolveWindowsBash, type BashProbe } from "./script-invocation"; @@ -80,16 +80,22 @@ export function isTransactionModePooler(url: string): boolean { } /** - * gbrain's config directory, matching gbrain's own configDir() contract - * (#2521): `GBRAIN_HOME` is a PARENT directory — gbrain always appends - * `.gbrain` itself, so GBRAIN_HOME=/tmp/x reads /tmp/x/.gbrain/config.json. - * Unset → ~/.gbrain. Every gstack-side gbrain-config read MUST resolve - * through this helper, or gstack classifies engine status from a file - * gbrain never reads. + * gbrain's config directory, by gbrain's own configDir() rule (src/core/config.ts + * in garrytan/gbrain, #2521): `GBRAIN_HOME`, trimmed with JavaScript trim(), is a + * PARENT directory and `.gbrain` is appended, so GBRAIN_HOME=/tmp/x reads + * /tmp/x/.gbrain/config.json. Unset or blank → ~/.gbrain. gbrain refuses a + * GBRAIN_HOME that is not absolute or has a `..` segment, so for one of those + * this returns null and the caller treats it as no config; falling back to + * ~/.gbrain would read a brain gbrain never uses. bin/gstack-gbrain-home.sh is + * the shell twin, and test/gbrain-home-parity.test.ts holds both to one table. + * Every gstack-side gbrain-config read MUST resolve through this helper, or + * gstack classifies engine status from a file gbrain never reads. */ -export function gbrainConfigDir(env: NodeJS.ProcessEnv = process.env): string { - if (env.GBRAIN_HOME) return join(env.GBRAIN_HOME, ".gbrain"); - return join(env.HOME || homedir(), ".gbrain"); +export function gbrainConfigDir(env: NodeJS.ProcessEnv = process.env): string | null { + const home = env.GBRAIN_HOME?.trim(); + if (!home) return join(env.HOME || homedir(), ".gbrain"); + if (!isAbsolute(home) || home.split(/[\\/]/).includes("..")) return null; + return join(home, ".gbrain"); } /** @@ -122,7 +128,9 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process const out: NodeJS.ProcessEnv = { ...baseEnv }; if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out; - const configPath = join(gbrainConfigDir(baseEnv), "config.json"); + const configDir = gbrainConfigDir(baseEnv); + if (!configDir) return out; + const configPath = join(configDir, "config.json"); if (!existsSync(configPath)) return out; let cfg: GbrainConfig = {}; @@ -244,7 +252,7 @@ export function gbrainInvocation(args: string[]): { cmd: string; argv: string[]; export function gbrainChildCwd(cwd?: string, env: NodeJS.ProcessEnv = process.env): string { if (cwd) return cwd; const dir = gbrainConfigDir(env); - return existsSync(dir) ? dir : tmpdir(); + return dir && existsSync(dir) ? dir : tmpdir(); } export interface SpawnGbrainOptions { diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 1dbeb7caa..f7717a6e2 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -134,9 +134,9 @@ export function cacheFilePath(): string { * (#2521): GBRAIN_HOME is a parent dir, `.gbrain` is appended. Same * resolution as buildGbrainEnv — both route through gbrainConfigDir. */ -function gbrainConfigPath(env?: NodeJS.ProcessEnv): string { - const e = env ?? process.env; - return join(gbrainConfigDir(e), "config.json"); +function gbrainConfigPath(env?: NodeJS.ProcessEnv): string | null { + const dir = gbrainConfigDir(env ?? process.env); + return dir && join(dir, "config.json"); } /** @@ -244,8 +244,10 @@ export function hasRemoteOnlyGbrainMcp( } function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null { + const configPath = gbrainConfigPath(env); + if (!configPath) return null; try { - const parsed = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { engine?: string }; + const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { engine?: string }; return parsed.engine === "pglite" || parsed.engine === "postgres" ? parsed.engine : null; } catch { return null; @@ -337,8 +339,10 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string { } function configFingerprint(env?: NodeJS.ProcessEnv): { mtime: number; size: number } { + const configPath = gbrainConfigPath(env); + if (!configPath) return { mtime: 0, size: 0 }; try { - const st = statSync(gbrainConfigPath(env)); + const st = statSync(configPath); return { mtime: Math.floor(st.mtimeMs), size: st.size }; } catch { return { mtime: 0, size: 0 }; @@ -421,12 +425,15 @@ function networkFailureDetail(stderr: string, env?: NodeJS.ProcessEnv): string | const m = stderr.match(NETWORK_ERROR_RE); if (!m) return null; let host = ""; - try { - const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(env ?? process.env), "config.json"), "utf-8")); - if (typeof cfg?.database_url === "string") { - host = new URL(cfg.database_url.replace(/^postgres(ql)?:\/\//, "http://")).hostname; - } - } catch {} + const configPath = gbrainConfigPath(env); + if (configPath) { + try { + const cfg = JSON.parse(readFileSync(configPath, "utf-8")); + if (typeof cfg?.database_url === "string") { + host = new URL(cfg.database_url.replace(/^postgres(ql)?:\/\//, "http://")).hostname; + } + } catch {} + } if (!host) host = stderr.match(new RegExp(`${m[1]}\\s+([A-Za-z0-9.-]+)`))?.[1] ?? ""; return dbUnreachableReason(m[1], host); } @@ -456,7 +463,8 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { // 2. Config file present? A bearer thin client (#2520) may never have run // a local init, so config.json can be absent while the remote-HTTP MCP // registration IS the user's brain. - if (!existsSync(gbrainConfigPath(env))) { + const configPath = gbrainConfigPath(env); + if (!configPath || !existsSync(configPath)) { return hasRemoteOnlyGbrainMcp(env) ? "thin-client" : "missing-config"; } @@ -469,7 +477,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { // immune to gbrain error-string drift. Remote reachability is deliberately // NOT probed here — a classifier network probe is the #1964 pathology. try { - const cfg = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { + const cfg = JSON.parse(readFileSync(configPath, "utf-8")) as { remote_mcp?: unknown; }; if (cfg && typeof cfg === "object" && cfg.remote_mcp) { diff --git a/lib/gbrain-mcp-locality.ts b/lib/gbrain-mcp-locality.ts index 559f15fb9..3825fcb9c 100644 --- a/lib/gbrain-mcp-locality.ts +++ b/lib/gbrain-mcp-locality.ts @@ -73,8 +73,10 @@ export function isLoopbackMcpUrl(url: string | undefined | null): boolean { * isThinClient). */ export function hasLocalPostgresEngine(env: NodeJS.ProcessEnv = process.env): boolean { + const configDir = gbrainConfigDir(env); + if (!configDir) return false; try { - const cfg = JSON.parse(readFileSync(join(gbrainConfigDir(env), "config.json"), "utf-8")) as { + const cfg = JSON.parse(readFileSync(join(configDir, "config.json"), "utf-8")) as { engine?: unknown; database_url?: unknown; remote_mcp?: unknown; diff --git a/lib/gstack-memory-helpers.ts b/lib/gstack-memory-helpers.ts index 50a956a50..731d121a9 100644 --- a/lib/gstack-memory-helpers.ts +++ b/lib/gstack-memory-helpers.ts @@ -382,8 +382,9 @@ export function detectEngineTier(): EngineDetect { // gbrain >=0.25 dropped the top-level `engine` field // from doctor output, so this file is the only reliable source for engine // detection on that version. See #1415. -function gbrainConfigPath(): string { - return join(gbrainConfigDir(process.env), "config.json"); +function gbrainConfigPath(): string | null { + const dir = gbrainConfigDir(process.env); + return dir && join(dir, "config.json"); } // Best-effort JSONL append to ~/.gstack/.gbrain-errors.jsonl. Never throws. @@ -433,9 +434,10 @@ function freshDetectEngineTier(): EngineDetect { // uses engine:"postgres" for real Supabase AND any other remote postgres // (e.g. local-postgres-for-testing). Downstream sync code treats them the // same, so the label compression is intentional. - if (engine === "unknown") { + const configPath = gbrainConfigPath(); + if (engine === "unknown" && configPath) { try { - const cfg = JSON.parse(readFileSync(gbrainConfigPath(), "utf-8")); + const cfg = JSON.parse(readFileSync(configPath, "utf-8")); if (cfg?.engine === "pglite") engine = "pglite"; else if (cfg?.engine === "postgres" || cfg?.database_url) engine = "supabase"; } catch (cfgErr) { diff --git a/test/gbrain-detect-shape.test.ts b/test/gbrain-detect-shape.test.ts index 761b7326b..7cb0c6d12 100644 --- a/test/gbrain-detect-shape.test.ts +++ b/test/gbrain-detect-shape.test.ts @@ -307,6 +307,23 @@ exit 0 } }); + it("a GBRAIN_HOME gbrain refuses reads no config, with no ~/.gbrain fallback, and still prints JSON", () => { + const tmp = mkdtempSync(join(tmpdir(), "detect-refused-")); + try { + mkdirSync(join(tmp, ".gbrain"), { recursive: true }); + writeFileSync(join(tmp, ".gbrain", "config.json"), JSON.stringify({ engine: "pglite" })); + for (const refused of ["relative/home", "/srv/../brain"]) { + const parsed = JSON.parse( + runDetect({ HOME: tmp, PATH: "/usr/bin:/bin", GSTACK_HOME: tmp, GSTACK_DETECT_NO_CACHE: "1", GBRAIN_HOME: refused }), + ) as DetectShape; + expect(parsed.gbrain_config_exists).toBe(false); + expect(parsed.gbrain_engine).toBeNull(); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + it("exit code agrees with the JSON gbrain_local_status (no skew)", () => { // Run both surfaces against the same env and assert they never disagree. const tmp = mkdtempSync(join(tmpdir(), "detect-isok-")); diff --git a/test/gbrain-home-parity.test.ts b/test/gbrain-home-parity.test.ts new file mode 100644 index 000000000..771a486af --- /dev/null +++ b/test/gbrain-home-parity.test.ts @@ -0,0 +1,162 @@ +/** + * gbrain's config home, resolved two ways (#4325, GST-2625): bin/gstack-gbrain-home.sh for + * shell helpers and gbrainConfigDir() in lib/gbrain-exec.ts for TypeScript. Both follow + * gbrain's configDir() (src/core/config.ts in garrytan/gbrain): GBRAIN_HOME trimmed with + * JavaScript trim(), unset or blank means ~/.gbrain, and a relative value or one with a ".." + * segment is refused with no ~/.gbrain fallback. One table drives both resolvers so they + * cannot drift, and lib/checks.ts health selection is checked against two distinct homes. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildGbrainEnv, gbrainConfigDir } from '../lib/gbrain-exec'; +import { healthDimensions } from '../lib/checks'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const HELPER = path.join(ROOT, 'bin', 'gstack-gbrain-home.sh'); +const HOME = '/home/fixture'; +const DEFAULT = `${HOME}/.gbrain/config.json`; + +// Every code point JavaScript trim() removes, read from the runtime rather than listed by hand. +const TRIM_SET: string[] = []; +for (let c = 0; c <= 0x10ffff; c++) { + if (c >= 0xd800 && c <= 0xdfff) continue; + if (String.fromCodePoint(c).trim() === '') TRIM_SET.push(String.fromCodePoint(c)); +} +const hex = (s: string) => [...s].map(ch => 'U+' + ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')).join(' '); + +const TABLE: Array<[string, string | undefined, string | null]> = [ + ['unset', undefined, DEFAULT], + ['empty', '', DEFAULT], + ['ASCII blank', ' \t\n\r\v\f ', DEFAULT], + ['NBSP only', '\u00a0', DEFAULT], + ['BOM only', '\ufeff', DEFAULT], + ['mixed Unicode blank', '\u00a0\ufeff\u2003\u3000\u2028 ', DEFAULT], + ...TRIM_SET.map((ch): [string, string, string] => [`trim() blank ${hex(ch)}`, ch, DEFAULT]), + ['NEL is not trim() whitespace', '\u0085', null], + ['zero-width space is not trim() whitespace', '\u200b', null], + ['ordinary', '/srv/brain', '/srv/brain/.gbrain/config.json'], + ['ASCII padded', ' /srv/brain \t', '/srv/brain/.gbrain/config.json'], + ['Unicode padded', '\u00a0\ufeff/srv/brain\u3000\u00a0', '/srv/brain/.gbrain/config.json'], + ['trailing slash', '/srv/brain/', '/srv/brain/.gbrain/config.json'], + ['trailing slashes', '/srv/brain///', '/srv/brain/.gbrain/config.json'], + ['root', '/', '/.gbrain/config.json'], + ['relative', 'relative/home', null], + ['dot relative', './brain', null], + ['tilde is not expanded', '~/brain', null], + ['padded relative', '\u00a0relative/home ', null], + ['parent segment', '/srv/../brain', null], + ['trailing parent segment', '/srv/brain/..', null], + ['bare parent', '..', null], + ['backslash parent segment', '/srv\\..\\brain', null], + ['drive path off Windows', 'C:\\brain', null], +]; + +function shellResolve(gbrainHome: string | undefined): string | null { + const env: Record = { PATH: process.env.PATH ?? '', HOME, LC_ALL: 'C.UTF-8' }; + if (gbrainHome !== undefined) env.GBRAIN_HOME = gbrainHome; + const r = spawnSync('bash', ['-c', '. "$1" && gstack_gbrain_config_path', 'helper', HELPER], { env, encoding: 'utf8', timeout: 10_000 }); + if (r.status === 1 && r.stdout === '') return null; + expect(r.status).toBe(0); + // The shell helper does not collapse repeated separators; they name the same file. + return path.posix.normalize(r.stdout.replace(/\n$/, '')); +} + +function tsResolve(gbrainHome: string | undefined): string | null { + const env: NodeJS.ProcessEnv = { HOME }; + if (gbrainHome !== undefined) env.GBRAIN_HOME = gbrainHome; + const dir = gbrainConfigDir(env); + return dir && path.join(dir, 'config.json'); +} + +// The expected paths are POSIX paths, and the shell helper is sourced through bash. +describe.skipIf(process.platform === 'win32')('GBRAIN_HOME parity: shell and TypeScript resolvers', () => { + test('the runtime trim() set includes NBSP and the BOM', () => { + expect(TRIM_SET).toContain('\u00a0'); + expect(TRIM_SET).toContain('\ufeff'); + }); + + for (const [name, value, expected] of TABLE) { + test(`${name}`, () => { + expect({ ts: tsResolve(value), shell: shellResolve(value) }).toEqual({ ts: expected, shell: expected }); + }); + } +}); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-home-parity-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); +const stubs = path.join(tmp, 'stubs'); +fs.mkdirSync(stubs); +fs.writeFileSync(path.join(stubs, 'gbrain'), '#!/bin/sh\nexit 0\n'); +fs.chmodSync(path.join(stubs, 'gbrain'), 0o755); +function home(name: string, config?: object): string { + const dir = path.join(tmp, name); + fs.mkdirSync(path.join(dir, '.gbrain'), { recursive: true }); + if (config) fs.writeFileSync(path.join(dir, '.gbrain', 'config.json'), JSON.stringify(config)); + return dir; +} +const named = home('named', { database_url: 'postgresql://postgres:postgres@localhost:5432/named' }); +const fallback = home('default', { database_url: 'postgresql://postgres:postgres@localhost:5432/default' }); +const bare = home('bare'); +const repo = path.join(tmp, 'repo'); +fs.mkdirSync(repo); + +// The gbrain stub is an extensionless #!/bin/sh file, which windows-latest cannot launch. +describe.skipIf(process.platform === 'win32')('lib/checks.ts health selection follows GBRAIN_HOME', () => { + const gbrain = (env: NodeJS.ProcessEnv) => { + const d = healthDimensions(repo, { PATH: stubs, ...env }); + return { check: d.checks.find(c => c.dimension === 'gbrain'), missing: d.missing.find(m => m.dimension === 'gbrain')?.reason }; + }; + + test('GBRAIN_HOME names the brain that has a config, the default home has none: selected', () => { + expect(gbrain({ HOME: bare, GBRAIN_HOME: named }).check).toEqual({ + dimension: 'gbrain', command: 'timeout 5s gbrain doctor --json', source: 'PATH, $GBRAIN_HOME/.gbrain/config.json', + }); + }); + + test('GBRAIN_HOME names a brain with no config while the default home has one: not selected', () => { + expect(gbrain({ HOME: fallback, GBRAIN_HOME: bare })).toEqual({ + check: undefined, missing: 'gbrain is not on PATH or $GBRAIN_HOME/.gbrain/config.json is absent', + }); + }); + + test('a GBRAIN_HOME gbrain refuses selects nothing, with no ~/.gbrain fallback', () => { + expect(gbrain({ HOME: fallback, GBRAIN_HOME: 'relative/home' })).toEqual({ + check: undefined, missing: 'GBRAIN_HOME is relative or has a ".." segment, which gbrain refuses', + }); + }); + + test('a Unicode-blank GBRAIN_HOME means the default home', () => { + expect(gbrain({ HOME: fallback, GBRAIN_HOME: '\u00a0\ufeff' }).check?.source).toBe('PATH, ~/.gbrain/config.json'); + }); + + test('negative control: GBRAIN_HOME unset keeps the ~/.gbrain selection and wording', () => { + expect(gbrain({ HOME: fallback }).check).toEqual({ dimension: 'gbrain', command: 'timeout 5s gbrain doctor --json', source: 'PATH, ~/.gbrain/config.json' }); + expect(gbrain({ HOME: named }).check?.source).toBe('PATH, ~/.gbrain/config.json'); + expect(gbrain({ HOME: bare })).toEqual({ check: undefined, missing: 'gbrain is not on PATH or ~/.gbrain/config.json is absent' }); + }); +}); + +describe('buildGbrainEnv follows the same GBRAIN_HOME rule', () => { + const url = (env: NodeJS.ProcessEnv) => buildGbrainEnv({ baseEnv: env }).DATABASE_URL; + + test('GBRAIN_HOME selects its own brain over the default one', () => { + expect(url({ HOME: fallback, GBRAIN_HOME: named })).toBe('postgresql://postgres:postgres@localhost:5432/named'); + }); + + test('a Unicode-blank GBRAIN_HOME means the default brain', () => { + expect(url({ HOME: fallback, GBRAIN_HOME: '\u00a0' })).toBe('postgresql://postgres:postgres@localhost:5432/default'); + }); + + test('a refused GBRAIN_HOME seeds nothing, with no ~/.gbrain fallback', () => { + for (const refused of [path.relative(process.cwd(), named), `${named}/../named`, ` ${path.relative(process.cwd(), named)} `]) { + expect(url({ HOME: fallback, GBRAIN_HOME: refused })).toBeUndefined(); + } + }); + + test('negative control: GBRAIN_HOME unset seeds the default brain', () => { + expect(url({ HOME: fallback })).toBe('postgresql://postgres:postgres@localhost:5432/default'); + }); +});