Health checks find gbrain's config under GBRAIN_HOME, trimmed the way gbrain does (GST-2625) (#4344)

* Health checks find gbrain config under GBRAIN_HOME, trimmed the way gbrain trims it (GST-2625)

lib/checks.ts chose the gbrain health check from ~/.gbrain/config.json only, so under GBRAIN_HOME it judged the wrong brain. gbrainConfigDir() in lib/gbrain-exec.ts now follows gbrain configDir() exactly (trim(), refuse relative or .. values, return null), every caller treats null as no config, and checks.ts uses it. bin/gstack-gbrain-home.sh trims the JavaScript trim() set instead of POSIX [[:space:]], so a NBSP- or BOM-only GBRAIN_HOME means the default home. test/gbrain-home-parity.test.ts runs one table against both resolvers.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* gstack-gbrain-detect no longer crashes when GBRAIN_HOME is relative or has a .. segment

gbrainConfigDir() now returns null for a GBRAIN_HOME gbrain refuses. The detect script joined that value at load time and threw a TypeError before printing any JSON. It now treats a refused GBRAIN_HOME as no config, like the other callers, and the shape test covers it.

Review-Finding: p1/specialist-batched#1 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-13 18:22:43 +00:00
1 parent 43c815be88
commit 6b033e79fa
12 files changed
+272 -47

No files matched your search

+2 -1
View File
@@ -540,7 +540,8 @@ async function main(): Promise<void> {
}
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})`);
}
+8 -4
View File
@@ -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 || "";
+24 -9
View File
@@ -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
+3 -1
View File
@@ -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;
@@ -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.
+5 -2
View File
@@ -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<string, string> = {
@@ -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] })) };
}
+20 -12
View File
@@ -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 {
+21 -13
View File
@@ -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) {
+3 -1
View File
@@ -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;
+6 -4
View File
@@ -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) {
+17
View File
@@ -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-"));
+162
View File
@@ -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<string, string> = { 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');
});
});