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
+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) {
|
||||
|
||||
Reference in new issue
Block a user