mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 20:07:08 +01:00
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:
1 parent
43c815be88
commit
6b033e79fa
12 files changed
+272
-47
No files matched your search
@@ -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})`);
|
||||
}
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user