mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 17:05:28 +02:00
Merge origin/main (v1.67.0.0) — reconcile convergent iOS Release-guard fixes
main's v1.67.0.0 independently landed the DebugBridgeTouch Release compile-out with a stronger shape (`#if !defined(DEBUG)` short-circuit before the platform gate, measured via nm -j on a real Release binary) than this branch's `#if TARGET_OS_IOS && DEBUG`. Resolution: take main's templates/fixtures, keep this branch's free-tier static tripwire and adapt it to pin main's shape (short-circuit present, ordered before the platform branch, cSettings DEBUG define intact, no bare platform-only gate). VERSION/package.json stay 1.67.1.0; CHANGELOG keeps both entries with 1.67.1.0 on top, its iOS claims reworded to the residual contribution (the tripwire, not the compile-out itself). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+92
-2
@@ -6,12 +6,102 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, join } from "path";
|
||||
|
||||
/** Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`). */
|
||||
/** Keep the slug inside the [a-zA-Z0-9._-] alphabet gstack-slug promises (`tr -cd`). */
|
||||
function sanitizeSlug(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9._-]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A Windows path in the MSYS form git-bash's `pwd` reports:
|
||||
* `C:\Users\j\foo` → `/c/Users/j/foo`. gstack-slug keys its cache on THAT form
|
||||
* (`tr '/' '_'`), so a native lookup must reproduce it exactly or it misses the very
|
||||
* entry gstack-slug wrote and silently re-derives instead of staying consistent.
|
||||
* Exported for the cache-key test; non-Windows paths pass through unchanged.
|
||||
*/
|
||||
export function toMsysPath(p: string): string {
|
||||
const drive = p.match(/^([A-Za-z]):[\\/]/);
|
||||
const body = (drive ? p.slice(2) : p).replace(/\\/g, "/");
|
||||
return drive ? `/${drive[1].toLowerCase()}${body}` : body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native port of bin/gstack-slug's resolution order, used when that script cannot be
|
||||
* spawned (see resolveSlug). Same three steps, same alphabet, same cache file — so
|
||||
* this and the shell path always agree. They must: the bins WRITE using this, while
|
||||
* the Context Recovery preamble READS using the script.
|
||||
*/
|
||||
export function slugFromEnvironment(gstackHome?: string, cwd: string = process.cwd()): string {
|
||||
const home = gstackHome || process.env.GSTACK_HOME || join(homedir(), ".gstack");
|
||||
const cacheDir = join(home, "slug-cache");
|
||||
const cacheFile = join(cacheDir, toMsysPath(cwd).replace(/\//g, "_"));
|
||||
|
||||
let slug = "";
|
||||
// 1. cached slug wins (guarantees consistency across sessions)
|
||||
if (existsSync(cacheFile)) {
|
||||
try {
|
||||
slug = sanitizeSlug(readFileSync(cacheFile, "utf-8").trim());
|
||||
} catch {
|
||||
slug = "";
|
||||
}
|
||||
}
|
||||
// 2. else derive from the git remote: [:/]<owner>/<repo>[.git] → owner-repo
|
||||
if (!slug) {
|
||||
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8", cwd });
|
||||
const m = (r.stdout || "").trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (m) slug = sanitizeSlug(m[1].replace(/\//g, "-"));
|
||||
}
|
||||
// 3. else the directory name
|
||||
if (!slug) slug = sanitizeSlug(basename(cwd));
|
||||
if (!slug) return "unknown";
|
||||
|
||||
// 4. cache it, as gstack-slug does — atomic, and failures stay silent (`|| true`)
|
||||
try {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
const tmp = `${cacheFile}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, slug, "utf-8");
|
||||
renameSync(tmp, cacheFile);
|
||||
} catch {
|
||||
// best-effort cache; a miss only costs a re-derive on the next call
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** Windows cannot exec an extensionless `#!/usr/bin/env bash` script (no shebang, no
|
||||
* PATHEXT match for an explicit path), so gstack-slug spawns ENOENT there. */
|
||||
export const NEEDS_NATIVE_SLUG_ON_WINDOWS = process.platform === "win32";
|
||||
|
||||
/**
|
||||
* Resolve the project slug via the `gstack-slug` helper (parses `SLUG=...`).
|
||||
*
|
||||
* On Windows that spawn fails ENOENT (see NEEDS_NATIVE_SLUG_ON_WINDOWS) and `r.stdout`
|
||||
* is undefined — the same class of hazard as the gbrain shim spawns in lib/gbrain-exec.ts
|
||||
* (#1731). Returning the literal "unknown" filed every decision under
|
||||
* ~/.gstack/projects/unknown/ — one bucket shared by every project on the machine —
|
||||
* while the bash-side Context Recovery preamble resolved the real slug, found no
|
||||
* decisions.active.json there, and skipped through a bare `if [ -f … ]` with no else.
|
||||
*
|
||||
* Nothing failed, for ten days: BOTH decision bins (log and search) missed identically,
|
||||
* so writes and searches stayed consistent with each other, and the only component that
|
||||
* resolved correctly was silent by design.
|
||||
*
|
||||
* `shell: true` is NOT the fix here, unlike #1731: cmd.exe cannot run a bash script
|
||||
* either. Nor is re-spawning through `bash` — on Windows that frequently resolves to
|
||||
* WSL, whose $HOME and /mnt/c paths yield a different slug AND a different cache
|
||||
* directory, trading one split store for another.
|
||||
*
|
||||
* POSIX behaviour is unchanged: the fallback is win32-only, where the previous result
|
||||
* was unconditionally wrong and so has nothing to regress.
|
||||
*/
|
||||
export function resolveSlug(slugBinPath: string): string {
|
||||
const r = spawnSync(slugBinPath, { encoding: "utf-8" });
|
||||
const m = (r.stdout || "").match(/^SLUG=(.+)$/m);
|
||||
return m ? m[1].trim() : "unknown";
|
||||
if (m) return m[1].trim();
|
||||
if (NEEDS_NATIVE_SLUG_ON_WINDOWS) return slugFromEnvironment();
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Current git branch, or undefined on detached HEAD / outside a repo. */
|
||||
|
||||
+118
-15
@@ -21,10 +21,12 @@
|
||||
* spawn. This is the central bug the helper exists to prevent
|
||||
* regressing on.
|
||||
*
|
||||
* 3. **`GBRAIN_HOME` honored consistently.** Other gstack helpers
|
||||
* (`detectEngineTier`) already honor `GBRAIN_HOME`. `buildGbrainEnv`
|
||||
* reads from `${GBRAIN_HOME:-$HOME/.gbrain}/config.json` so all
|
||||
* gstack-side gbrain calls agree on which config file matters.
|
||||
* 3. **`GBRAIN_HOME` honored consistently — with gbrain's own semantics
|
||||
* (#2521).** gbrain's configDir() treats `GBRAIN_HOME` as a PARENT
|
||||
* directory and always appends `.gbrain` itself (GBRAIN_HOME=/tmp/x
|
||||
* → /tmp/x/.gbrain/config.json). Every gstack-side read goes through
|
||||
* `gbrainConfigDir()` below so gstack and gbrain agree on which
|
||||
* config file matters.
|
||||
*
|
||||
* **Escape hatch:** `GSTACK_RESPECT_ENV_DATABASE_URL=1` returns the
|
||||
* caller's env unchanged. Use only when the brain intentionally lives in
|
||||
@@ -75,8 +77,21 @@ export function isTransactionModePooler(url: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an env dict with DATABASE_URL seeded from
|
||||
* `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Returns the base env
|
||||
* 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.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an env dict with DATABASE_URL seeded from gbrain's config.json
|
||||
* (resolved via `gbrainConfigDir`). Returns the base env
|
||||
* unchanged when:
|
||||
* - `GSTACK_RESPECT_ENV_DATABASE_URL=1` (intentional opt-out),
|
||||
* - the config file is missing or unparseable,
|
||||
@@ -98,9 +113,7 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
|
||||
const out: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
if (baseEnv.GSTACK_RESPECT_ENV_DATABASE_URL === "1") return out;
|
||||
|
||||
const homeBase = baseEnv.HOME || homedir();
|
||||
const gbrainHome = baseEnv.GBRAIN_HOME || join(homeBase, ".gbrain");
|
||||
const configPath = join(gbrainHome, "config.json");
|
||||
const configPath = join(gbrainConfigDir(baseEnv), "config.json");
|
||||
if (!existsSync(configPath)) return out;
|
||||
|
||||
let cfg: GbrainConfig = {};
|
||||
@@ -136,6 +149,93 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
|
||||
*/
|
||||
export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32";
|
||||
|
||||
/** Where Git for Windows puts bash, most-specific first. */
|
||||
const WINDOWS_BASH_CANDIDATES = [
|
||||
"C:\\Program Files\\Git\\bin\\bash.exe",
|
||||
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
||||
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
||||
];
|
||||
|
||||
export interface ScriptInvocation {
|
||||
cmd: string;
|
||||
argv: string[];
|
||||
/** Always false: we resolve the interpreter ourselves rather than via cmd.exe. */
|
||||
shell: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to invoke a **bash shebang script** (`gstack-brain-sync`) on this platform.
|
||||
*
|
||||
* POSIX execs it directly — the shebang does the work. Windows cannot, and
|
||||
* `shell: true` does NOT rescue it: that routes through cmd.exe, which resolves
|
||||
* `.cmd`/`.bat` via PATHEXT but has no concept of a shebang, so an
|
||||
* extension-less bash script comes back as *"is not recognized as an internal
|
||||
* or external command"*. This is why #1731's `shell: NEEDS_SHELL_ON_WINDOWS`
|
||||
* fix genuinely cured the `gbrain.cmd` shim while leaving the brain-sync stage
|
||||
* failing on **every** run on Windows. The two cases look identical and are not:
|
||||
* a `.cmd` shim needs a shell, a shebang script needs an interpreter.
|
||||
*
|
||||
* The consequence was quiet rather than loud. `artifacts_sync_mode` defaults to
|
||||
* pushing curated artifacts to git, so a Windows user's learnings accumulated in
|
||||
* `~/.gstack` and were never committed, while `/sync-gbrain` printed one red
|
||||
* line among four green ones.
|
||||
*
|
||||
* Git for Windows' bash is preferred over a bare `bash` on PATH because
|
||||
* WindowsApps ships a `bash.exe` that is the WSL launcher; if it wins PATH
|
||||
* order it interprets `C:\...` as a Linux path and the script never sees the
|
||||
* repo. `GSTACK_BASH` overrides everything for unusual installs.
|
||||
*
|
||||
* Returns `null` when no bash can be found, so the caller can say so plainly
|
||||
* instead of surfacing a spawn error nobody can act on.
|
||||
*/
|
||||
export function bashScriptInvocation(
|
||||
scriptPath: string,
|
||||
args: string[],
|
||||
opts: { platform?: string; exists?: (p: string) => boolean; env?: NodeJS.ProcessEnv } = {},
|
||||
): ScriptInvocation | null {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform !== "win32") return { cmd: scriptPath, argv: args, shell: false };
|
||||
|
||||
const exists = opts.exists ?? existsSync;
|
||||
const env = opts.env ?? process.env;
|
||||
|
||||
const override = env.GSTACK_BASH?.trim();
|
||||
const candidates = [...(override ? [override] : []), ...WINDOWS_BASH_CANDIDATES];
|
||||
const bash = candidates.find((p) => exists(p));
|
||||
if (!bash) return null;
|
||||
|
||||
// Forward slashes: bash treats backslashes as escapes, so a Windows path
|
||||
// passed verbatim loses its separators.
|
||||
return { cmd: bash, argv: [scriptPath.replace(/\\/g, "/"), ...args], shell: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one argument for cmd.exe's re-parse (#2471). With `shell: true` on
|
||||
* Windows, Node/Bun JOIN the argv into a single cmd.exe string WITHOUT
|
||||
* quoting, so any argument containing a space — the default
|
||||
* `C:\Users\First Last\repo` home layout — splits into two arguments and the
|
||||
* gbrain call silently targets the wrong path. Pass-through for the safe
|
||||
* charset; everything else is double-quoted with embedded quotes doubled
|
||||
* (cmd.exe's escape). POSIX callers never see this (shell is false there).
|
||||
*/
|
||||
export function windowsShellQuote(arg: string): string {
|
||||
if (arg !== "" && /^[A-Za-z0-9_\-.:\\/=,@+]+$/.test(arg)) return arg;
|
||||
return '"' + arg.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* The single seam for building a gbrain CLI invocation (#2471). Every
|
||||
* spawnSync/execFileSync of the `gbrain` shim must construct its
|
||||
* (cmd, argv, shell) triple here so the Windows quoting fix lives in exactly
|
||||
* one place — a direct spawn of the literal "gbrain" string with a shell
|
||||
* flag reopens the space-in-path split this exists to close.
|
||||
*/
|
||||
export function gbrainInvocation(args: string[]): { cmd: string; argv: string[]; shell: boolean } {
|
||||
return NEEDS_SHELL_ON_WINDOWS
|
||||
? { cmd: "gbrain", argv: args.map(windowsShellQuote), shell: true }
|
||||
: { cmd: "gbrain", argv: args, shell: false };
|
||||
}
|
||||
|
||||
export interface SpawnGbrainOptions {
|
||||
/** Timeout in milliseconds. Defaults to 30s. */
|
||||
timeout?: number;
|
||||
@@ -159,13 +259,14 @@ export interface SpawnGbrainOptions {
|
||||
* `stderr` exactly as they would with `spawnSync` directly.
|
||||
*/
|
||||
export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): SpawnSyncReturns<string> {
|
||||
return spawnSync("gbrain", args, {
|
||||
const inv = gbrainInvocation(args);
|
||||
return spawnSync(inv.cmd, inv.argv, {
|
||||
encoding: "utf-8",
|
||||
timeout: opts.timeout ?? 30_000,
|
||||
cwd: opts.cwd,
|
||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,11 +295,12 @@ export function spawnGbrainAsync(
|
||||
args: string[],
|
||||
opts: { stdio?: SpawnOptions["stdio"]; cwd?: string; baseEnv?: NodeJS.ProcessEnv } = {},
|
||||
): ChildProcess {
|
||||
return spawn("gbrain", args, {
|
||||
const inv = gbrainInvocation(args);
|
||||
return spawn(inv.cmd, inv.argv, {
|
||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||
cwd: opts.cwd,
|
||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }),
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,12 +309,13 @@ export function spawnGbrainAsync(
|
||||
* for callers that want to surface gbrain's stderr as the error message.
|
||||
*/
|
||||
export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): string {
|
||||
return execFileSync("gbrain", args, {
|
||||
const inv = gbrainInvocation(args);
|
||||
return execFileSync(inv.cmd, inv.argv, {
|
||||
encoding: "utf-8",
|
||||
timeout: opts.timeout ?? 30_000,
|
||||
cwd: opts.cwd,
|
||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain
|
||||
import { parseSourcesList, type GbrainSourceRow } from "./gbrain-sources";
|
||||
|
||||
export function gbrainHome(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.GBRAIN_HOME || join(homedir(), ".gbrain");
|
||||
// #2521: GBRAIN_HOME is a PARENT dir per gbrain's configDir() contract —
|
||||
// gbrain appends `.gbrain` itself, so gstack must too.
|
||||
return env.GBRAIN_HOME ? join(env.GBRAIN_HOME, ".gbrain") : join(homedir(), ".gbrain");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+134
-34
@@ -24,12 +24,14 @@
|
||||
* Timeout → probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no
|
||||
* recognized error — engine is likely healthy but slow (e.g. a cold
|
||||
* pooler connection, #1964). Consumers treat this as usable.
|
||||
* Thin-client → config carries gbrain's remote_mcp marker (#2051): NO local
|
||||
* engine by design; queries go to a remote-HTTP MCP brain. Usable
|
||||
* for brain-aware prose gates; sync stages that need a LOCAL engine
|
||||
* (code/memory/dream) skip. Remote reachability is verified at USE
|
||||
* time (gbrain calls degrade gracefully), never by a classifier
|
||||
* network probe — that's the #1964 pathology.
|
||||
* Thin-client → config carries gbrain's remote_mcp marker (#2051), OR the
|
||||
* agent host's MCP registration is remote-HTTP-only (#2520 — bearer
|
||||
* installs via `gbrain connect --token` never get the marker): NO
|
||||
* local engine by design; queries go to a remote-HTTP MCP brain.
|
||||
* Usable for brain-aware prose gates; sync stages that need a LOCAL
|
||||
* engine (code/memory/dream) skip. Remote reachability is verified
|
||||
* at USE time (gbrain calls degrade gracefully), never by a
|
||||
* classifier network probe — that's the #1964 pathology.
|
||||
* Ok → DB reachable, sources list returned valid JSON.
|
||||
*/
|
||||
|
||||
@@ -46,7 +48,7 @@ import {
|
||||
import { atomicWriteSync } from "./fs-atomic";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join } from "path";
|
||||
import { buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||
import { buildGbrainEnv, gbrainConfigDir, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||
|
||||
export type LocalEngineStatus =
|
||||
| "ok"
|
||||
@@ -120,11 +122,84 @@ export function cacheFilePath(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */
|
||||
/**
|
||||
* Honors GBRAIN_HOME (codex D11) with gbrain's own configDir() semantics
|
||||
* (#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;
|
||||
const gbrainHome = e.GBRAIN_HOME || join(userHome(e), ".gbrain");
|
||||
return join(gbrainHome, "config.json");
|
||||
return join(gbrainConfigDir(e), "config.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer-token thin-client evidence (#2520). `gbrain connect <url> --token`
|
||||
* registers a remote-HTTP MCP server with the agent host but never writes
|
||||
* gbrain's remote_mcp marker into config.json — that marker is OAuth-only,
|
||||
* written by `gbrain init --mcp-only`. So the config-file marker check misses
|
||||
* bearer installs entirely: they fall through to the local probe, which fails
|
||||
* against the dead-or-absent local engine and lands on missing-config /
|
||||
* broken-db / broken-config / engine-locked, silently suppressing brain
|
||||
* blocks for a fully-working remote brain.
|
||||
*
|
||||
* Evidence read: ~/.claude.json MCP registrations — user scope AND project
|
||||
* scope (project-scoped registrations are otherwise invisible, #2499).
|
||||
* File-read only: no subprocess, no network (a classifier network probe is
|
||||
* the #1964 pathology). Returns true only when a gbrain registration is
|
||||
* remote-HTTP AND no gbrain registration is local-stdio — a local-stdio
|
||||
* entry means the user runs a local engine (possibly alongside a remote one,
|
||||
* e.g. federation), and local-engine statuses like engine-locked must keep
|
||||
* their precise meaning there.
|
||||
*/
|
||||
export function hasRemoteOnlyGbrainMcp(env?: NodeJS.ProcessEnv): boolean {
|
||||
interface McpEntry {
|
||||
type?: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
}
|
||||
let cj: unknown;
|
||||
try {
|
||||
cj = JSON.parse(readFileSync(join(userHome(env), ".claude.json"), "utf-8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Same classification rules as gstack-gbrain-detect's detectMcpMode tier 3,
|
||||
// including the #2051 name generalization (gbrain, gbrain-remote, gbrain_work).
|
||||
const classify = (entry: McpEntry): "remote" | "local" | null => {
|
||||
const mtype = entry.type || entry.transport || "";
|
||||
if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote";
|
||||
if (mtype === "stdio") return "local";
|
||||
if (entry.url) return "remote";
|
||||
if (entry.command) return "local";
|
||||
return null;
|
||||
};
|
||||
let sawRemote = false;
|
||||
let sawLocal = false;
|
||||
const scan = (servers: unknown): void => {
|
||||
if (!servers || typeof servers !== "object") return;
|
||||
for (const [name, entry] of Object.entries(servers as Record<string, McpEntry>)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const isGbrainName = /^gbrain([-_][\w-]*)?$/.test(name);
|
||||
const cmdMentionsGbrain =
|
||||
typeof entry.command === "string" && /\bgbrain\b/.test(entry.command);
|
||||
if (!isGbrainName && !cmdMentionsGbrain) continue;
|
||||
const c = classify(entry);
|
||||
if (c === "remote") sawRemote = true;
|
||||
if (c === "local") sawLocal = true;
|
||||
}
|
||||
};
|
||||
const root = cj as {
|
||||
mcpServers?: unknown;
|
||||
projects?: Record<string, { mcpServers?: unknown }>;
|
||||
} | null;
|
||||
scan(root?.mcpServers);
|
||||
if (root?.projects && typeof root.projects === "object") {
|
||||
for (const proj of Object.values(root.projects)) {
|
||||
if (proj && typeof proj === "object") scan(proj.mcpServers);
|
||||
}
|
||||
}
|
||||
return sawRemote && !sawLocal;
|
||||
}
|
||||
|
||||
function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null {
|
||||
@@ -146,6 +221,10 @@ function hashPath(p: string): string {
|
||||
* call share one fork-exec (~200ms saved per skill preamble).
|
||||
*/
|
||||
const _gbrainBinCache = new Map<string, string | null>();
|
||||
// On Windows the shim is `gbrain.cmd` → `bun run cli.ts`; a cold spawn can
|
||||
// exceed 2s, and a false negative here poisons the 60s status cache with
|
||||
// "no-cli". Give the shim headroom; POSIX keeps the tight timeout.
|
||||
const VERSION_PROBE_TIMEOUT_MS = NEEDS_SHELL_ON_WINDOWS ? 10_000 : 2_000;
|
||||
export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
|
||||
const e = env ?? process.env;
|
||||
const key = e.PATH || "";
|
||||
@@ -154,7 +233,7 @@ export function resolveGbrainBin(env?: NodeJS.ProcessEnv): string | null {
|
||||
try {
|
||||
execFileSync("gbrain", ["--version"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 2_000,
|
||||
timeout: VERSION_PROBE_TIMEOUT_MS,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
env: e,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
@@ -177,7 +256,7 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
|
||||
try {
|
||||
const out = execFileSync("gbrain", ["--version"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 2_000,
|
||||
timeout: VERSION_PROBE_TIMEOUT_MS,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
env: e,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
@@ -271,8 +350,12 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
|
||||
const gbrainBin = resolveGbrainBin(env);
|
||||
if (!gbrainBin) return "no-cli";
|
||||
|
||||
// 2. Config file present?
|
||||
if (!existsSync(gbrainConfigPath(env))) return "missing-config";
|
||||
// 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))) {
|
||||
return hasRemoteOnlyGbrainMcp(env) ? "thin-client" : "missing-config";
|
||||
}
|
||||
|
||||
// 2.5 Thin client? gbrain's own marker (mirrors gbrain isThinClient():
|
||||
// truthy remote_mcp in config). A thin client has NO local engine — gbrain
|
||||
@@ -329,28 +412,45 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
|
||||
// couldn't read — gbrain's dispatch guard says e.g. "`gbrain sources` is
|
||||
// not routable ... (thin-client of <url>)"), then the more specific
|
||||
// DB-unreachable signal.
|
||||
if (/thin[- ]client/i.test(stderr)) return "thin-client";
|
||||
if (stderr.includes("Cannot connect to database")) return "broken-db";
|
||||
if (stderr.includes("config.json")) return "broken-config";
|
||||
const raw = ((): LocalEngineStatus => {
|
||||
if (/thin[- ]client/i.test(stderr)) return "thin-client";
|
||||
if (stderr.includes("Cannot connect to database")) return "broken-db";
|
||||
if (stderr.includes("config.json")) return "broken-config";
|
||||
|
||||
// PGLite is single-process. A long-lived `gbrain serve` can own the
|
||||
// embedded database, causing the CLI to finish with its own exit 124 and
|
||||
// "connect timed out" message. This is neither our watchdog timeout nor
|
||||
// evidence that the valid config is malformed (#2194).
|
||||
if (stderr.includes("connect timed out") || e.status === 124) {
|
||||
return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db";
|
||||
// PGLite is single-process. A long-lived `gbrain serve` can own the
|
||||
// embedded database, causing the CLI to finish with its own exit 124 and
|
||||
// "connect timed out" message. This is neither our watchdog timeout nor
|
||||
// evidence that the valid config is malformed (#2194).
|
||||
if (stderr.includes("connect timed out") || e.status === 124) {
|
||||
return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db";
|
||||
}
|
||||
|
||||
// Probe killed by the timeout with no recognized error: the engine is
|
||||
// most likely healthy but slow (cold pooler connections measured at
|
||||
// 6.9-10.7s in #1964). Don't tell the user their config is malformed.
|
||||
if (e.killed === true || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") {
|
||||
return "timeout";
|
||||
}
|
||||
|
||||
// Defensive default per codex #8: unrecognized failures classify as
|
||||
// broken-config so the user sees the raw stderr surfaced upstream.
|
||||
return "broken-config";
|
||||
})();
|
||||
|
||||
// #2520 bearer-token fallback: the local probe failed, but the user's
|
||||
// only gbrain MCP registration is remote-HTTP — the dead-or-locked local
|
||||
// engine is not their brain (typical shape: a leftover local config plus
|
||||
// `gbrain connect --token`). Reclassify as thin-client so brain blocks
|
||||
// stay rendered and sync's local stages skip with the accurate "nothing
|
||||
// to do locally" message. "timeout" is deliberately excluded: it already
|
||||
// counts as usable and may be a genuinely healthy slow LOCAL engine.
|
||||
if (
|
||||
(raw === "broken-db" || raw === "broken-config" || raw === "engine-locked") &&
|
||||
hasRemoteOnlyGbrainMcp(env)
|
||||
) {
|
||||
return "thin-client";
|
||||
}
|
||||
|
||||
// Probe killed by the timeout with no recognized error: the engine is
|
||||
// most likely healthy but slow (cold pooler connections measured at
|
||||
// 6.9-10.7s in #1964). Don't tell the user their config is malformed.
|
||||
if (e.killed === true || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") {
|
||||
return "timeout";
|
||||
}
|
||||
|
||||
// Defensive default per codex #8: unrecognized failures classify as
|
||||
// broken-config so the user sees the raw stderr surfaced upstream.
|
||||
return "broken-config";
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-18
@@ -12,7 +12,7 @@
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import { realpathSync } from "fs";
|
||||
import { withErrorContext } from "./gstack-memory-helpers";
|
||||
import { execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||
import { execGbrainJson, gbrainInvocation } from "./gbrain-exec";
|
||||
import {
|
||||
detectAutopilot,
|
||||
decideSourceRemove,
|
||||
@@ -117,12 +117,13 @@ function samePath(registered: string | undefined, requested: string): boolean {
|
||||
export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync("gbrain", ["sources", "list", "--json"], {
|
||||
const inv = gbrainInvocation(["sources", "list", "--json"]);
|
||||
stdout = execFileSync(inv.cmd, inv.argv, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
||||
@@ -224,29 +225,28 @@ export async function ensureSourceRegistered(
|
||||
throw new Error(`refusing drift re-register of ${id}: ${decision.reason}`);
|
||||
}
|
||||
|
||||
const rm = spawnSync(
|
||||
"gbrain",
|
||||
["sources", "remove", id, "--yes", "--confirm-destructive", ...decision.extraArgs],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
},
|
||||
);
|
||||
const rmInv = gbrainInvocation(["sources", "remove", id, "--yes", "--confirm-destructive", ...decision.extraArgs]);
|
||||
const rm = spawnSync(rmInv.cmd, rmInv.argv, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env,
|
||||
shell: rmInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
|
||||
});
|
||||
if (rm.status !== 0) {
|
||||
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add.
|
||||
// Add. `path` is a user repo path — the #2471 space-in-path victim; the
|
||||
// invocation seam quotes it for cmd.exe's re-parse.
|
||||
const addArgs = ["sources", "add", id, "--path", path];
|
||||
if (federated) addArgs.push("--federated");
|
||||
const add = spawnSync("gbrain", addArgs, {
|
||||
const addInv = gbrainInvocation(addArgs);
|
||||
const add = spawnSync(addInv.cmd, addInv.argv, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
shell: addInv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
|
||||
});
|
||||
if (add.status !== 0) {
|
||||
throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`);
|
||||
@@ -267,12 +267,13 @@ export async function ensureSourceRegistered(
|
||||
export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | null {
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync("gbrain", ["sources", "list", "--json"], {
|
||||
const inv = gbrainInvocation(["sources", "list", "--json"]);
|
||||
stdout = execFileSync(inv.cmd, inv.argv, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
shell: inv.shell, // #1731: gbrain is a .cmd shim on Windows (+#2471 quoting)
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
|
||||
import { appendJsonl } from "./jsonl-store";
|
||||
import { gbrainConfigDir } from "./gbrain-exec";
|
||||
import { dirname, join } from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import { homedir } from "os";
|
||||
@@ -256,12 +257,13 @@ export function detectEngineTier(): EngineDetect {
|
||||
}
|
||||
|
||||
// Returns gbrain's config.json path, honoring GBRAIN_HOME env var with a
|
||||
// fallback to ~/.gbrain. gbrain >=0.25 dropped the top-level `engine` field
|
||||
// fallback to ~/.gbrain. Resolution matches gbrain's own configDir()
|
||||
// contract (#2521): GBRAIN_HOME is a parent dir, `.gbrain` is appended.
|
||||
// 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 {
|
||||
const root = process.env.GBRAIN_HOME || join(homedir(), ".gbrain");
|
||||
return join(root, "config.json");
|
||||
return join(gbrainConfigDir(process.env), "config.json");
|
||||
}
|
||||
|
||||
// Best-effort JSONL append to ~/.gstack/.gbrain-errors.jsonl. Never throws.
|
||||
|
||||
+109
-6
@@ -108,6 +108,39 @@ export function shannonEntropy(s: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
// env.kv name-shape calibration: the regex's zero-or-more-prefix net matches
|
||||
// ANY identifier ending in a credential suffix, so `cacheKey:`, `sortKey:`,
|
||||
// `partitionKey:`, `hotkey:`, even `monkey:` with an 8+-char entropic value
|
||||
// all hit a MEDIUM confirm prompt — a gate that cries wolf gets ignored.
|
||||
// A matched name only counts when its shape is credential-semantic:
|
||||
// (i) suffix separated from the prefix by _ / - / . (api_key, x-access-key,
|
||||
// AUTH.TOKEN)
|
||||
// (ii) the whole name IS the bare suffix (key:, token:)
|
||||
// (iii) the name is ALL-CAPS env style (APIKEY=, MY_APIKEY=)
|
||||
// (iv) a lowercase/camel compound whose prefix ends in a credential word
|
||||
// (apiKey, authToken, clientSecret, stripeApiKey) — cacheKey/sortKey/
|
||||
// monkey have no credential prefix and are rejected.
|
||||
const ENV_KV_NAME =
|
||||
/^[ \t]*(?:export[ \t]+)?["']?([A-Za-z0-9_.-]*?(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE))["']?[ \t]*[:=]/i;
|
||||
const ENV_KV_SUFFIX =
|
||||
/(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)$/i;
|
||||
const ENV_KV_CRED_PREFIX =
|
||||
/(api|auth|access|secret|private|app|client|server|master|admin|signing|encryption|session|csrf|jwt|oauth|bearer)$/i;
|
||||
|
||||
/** True when the full env.kv match starts with a credential-shaped name. */
|
||||
export function isCredentialShapedEnvName(fullMatch: string): boolean {
|
||||
const nameMatch = ENV_KV_NAME.exec(fullMatch);
|
||||
if (!nameMatch) return false;
|
||||
const name = nameMatch[1];
|
||||
const suffixMatch = ENV_KV_SUFFIX.exec(name);
|
||||
if (!suffixMatch) return false;
|
||||
const prefix = name.slice(0, name.length - suffixMatch[1].length);
|
||||
if (prefix === "") return true; // (ii) bare suffix
|
||||
if (/[_.\-]$/.test(prefix)) return true; // (i) separator before suffix
|
||||
if (!/[a-z]/.test(name)) return true; // (iii) ALL-CAPS env style
|
||||
return ENV_KV_CRED_PREFIX.test(prefix); // (iv) credential-semantic compound
|
||||
}
|
||||
|
||||
/** True when an IPv4 string is a public address (not RFC1918/loopback/etc). */
|
||||
export function isPublicIPv4(ip: string): boolean {
|
||||
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
@@ -168,6 +201,58 @@ function looksLikeCompactTimestamp(span: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Context window for pairing a normalized parcel ID with its punctuated form. */
|
||||
const PARCEL_CONTEXT_CHARS = 400;
|
||||
|
||||
/**
|
||||
* County tax-map parcel ID (APN). Ohio's dominant form is NN-NNNNNNN.NNN, and
|
||||
* some counties use a hyphen before the 3-4 digit suffix instead of a dot.
|
||||
*/
|
||||
const PARCEL_PUNCT_RE = /\b\d{2}-\d{4,8}[.\-]\d{3,4}\b/g;
|
||||
|
||||
/**
|
||||
* A parcel ID reads as a national-format phone number to pii.phone.e164 — the
|
||||
* same collision class as the digit-only UUID that `insideUuid` already guards.
|
||||
* Land/title repos carry these by the hundred, so the false positives are not
|
||||
* incidental: they arrive on every branch that touches title, and a guardrail
|
||||
* that cries wolf on the domain's primary identifier trains people to wave the
|
||||
* warning through, which is how a real HIGH finding eventually gets ignored.
|
||||
*
|
||||
* Deliberately narrow, in two tiers:
|
||||
*
|
||||
* 1. The DOTTED form is exempt on its own shape. No phone convention places a
|
||||
* dot before a trailing 3-4 digit group after a 4-8 digit middle, so this
|
||||
* cannot swallow a real number. The hyphen-only variants (22-0001-000) are
|
||||
* NOT exempted by shape — those genuinely are phone-shaped.
|
||||
*
|
||||
* 2. A DIGITS-ONLY span is phone-shaped in isolation, so it earns the exemption
|
||||
* only by evidence: it must be the exact digit-normalization of a punctuated
|
||||
* parcel ID within the surrounding window. Fixtures and marts always carry
|
||||
* the pair ({parcel_id: "12-3456789.000", norm: "123456789000"}), and a bare
|
||||
* phone number has no such twin nearby — so this reads the document's own
|
||||
* evidence rather than guessing from digits.
|
||||
*/
|
||||
export function looksLikeParcelId(span: string, match: RegExpExecArray): boolean {
|
||||
if (/^\d{2}-\d{4,8}\.\d{3,4}$/.test(span)) return true;
|
||||
if (!/^\d{10,14}$/.test(span)) return false;
|
||||
|
||||
const input = match.input ?? "";
|
||||
const spanStartInMatch = match[1] !== undefined ? match[0].indexOf(match[1]) : 0;
|
||||
const spanStart = match.index + Math.max(0, spanStartInMatch);
|
||||
const spanEnd = spanStart + span.length;
|
||||
const window = input.slice(
|
||||
Math.max(0, spanStart - PARCEL_CONTEXT_CHARS),
|
||||
spanEnd + PARCEL_CONTEXT_CHARS,
|
||||
);
|
||||
|
||||
PARCEL_PUNCT_RE.lastIndex = 0;
|
||||
let p: RegExpExecArray | null;
|
||||
while ((p = PARCEL_PUNCT_RE.exec(window)) !== null) {
|
||||
if (p[0].replace(/\D/g, "") === span) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Placeholder suppression (per-matched-span, NOT per-line) ─────────────────
|
||||
|
||||
/**
|
||||
@@ -520,10 +605,26 @@ export const PATTERNS: RedactPattern[] = [
|
||||
id: "env.kv",
|
||||
tier: "MEDIUM",
|
||||
category: "secret",
|
||||
description: "Env-style SECRET assignment with high-entropy value",
|
||||
regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[ \t]*=[ \t]*['"]?([^\s'"]{8,})['"]?/,
|
||||
// Only fire on high-entropy values — kills `FOO_KEY=changeme` FPs.
|
||||
validate: (span) =>
|
||||
description: "Secret-named assignment (env/YAML/JSON) with high-entropy value",
|
||||
// #1946 gap 3: the original shape required an UPPERCASE name and an `=`
|
||||
// assignment, so `api_key=…`, `apiKey: "…"`, and `password: …` (YAML/JSON
|
||||
// colon form) produced NO finding at all — a detection fail-open on the
|
||||
// most common config shapes. Now case-insensitive with `:` or `=`
|
||||
// assignment and optional quotes around the key (JSON). Still MEDIUM and
|
||||
// entropy-gated: this is the calibrated generic net, not a blocker.
|
||||
// The name part is `[A-Za-z0-9_.-]*` + suffix (zero-or-more prefix, not
|
||||
// one-or-more): a mandatory first char would swallow the suffix's own
|
||||
// first letter and bare names like `password:` / `key:` would never match.
|
||||
// The wide net is then calibrated by isCredentialShapedEnvName in
|
||||
// validate — without it, any identifier that merely ENDS in a suffix
|
||||
// (cacheKey:, sortKey:, monkey:) fires a MEDIUM confirm on entropic
|
||||
// values. The value must stay capture group 1 (the engine masks group 1),
|
||||
// so name-shape checking lives in validate, not in a second group.
|
||||
regex: /^[ \t]*(?:export[ \t]+)?["']?[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)["']?[ \t]*[:=][ \t]*["']?([^\s'"]{8,})["']?/i,
|
||||
// Only fire on credential-shaped names with high-entropy values — kills
|
||||
// `FOO_KEY=changeme` and `cacheKey: <entropic-id>` FPs.
|
||||
validate: (span, match) =>
|
||||
isCredentialShapedEnvName(match[0]) &&
|
||||
!isPlaceholderSpan(span) &&
|
||||
!/^\$\{?[A-Za-z_]/.test(span) &&
|
||||
shannonEntropy(span) >= 3.0,
|
||||
@@ -565,11 +666,13 @@ export const PATTERNS: RedactPattern[] = [
|
||||
regex: /(?<![\w.])(\+?[1-9]\d{0,2}[ \-.]?\(?\d{2,4}\)?[ \-.]?\d{3,4}[ \-.]?\d{3,4})(?![\w.])/,
|
||||
autoRedactable: true,
|
||||
redactToken: "<REDACTED-PHONE>",
|
||||
// A digit-only UUID's hyphen groups read as national phone formatting.
|
||||
// A digit-only UUID's hyphen groups read as national phone formatting, and
|
||||
// so does a county tax-map parcel ID (see looksLikeParcelId).
|
||||
validate: (span, match) =>
|
||||
!insideUuid(match) &&
|
||||
span.replace(/\D/g, "").length >= 10 &&
|
||||
!looksLikeCompactTimestamp(span),
|
||||
!looksLikeCompactTimestamp(span) &&
|
||||
!looksLikeParcelId(span, match),
|
||||
},
|
||||
{
|
||||
id: "pii.ssn",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// version-source — where a repo's version lives, and how wide it is.
|
||||
//
|
||||
// gstack's native shape is a plain-text VERSION file at the repo root holding a
|
||||
// 4-digit MAJOR.MINOR.PATCH.MICRO — and for gstack itself that file STAYS the
|
||||
// source of truth (decision pinned in the v1.67 fix-wave plan: package.json is
|
||||
// a translated mirror, never the authority). This module exists for the two
|
||||
// real-world shapes that did not fit and both failed CLOSED in a way that
|
||||
// silently disabled /ship's version tooling (#2501):
|
||||
//
|
||||
// 1. The version's home is a package.json — often not at the root (a monorepo
|
||||
// whose frontend/package.json is the single source of truth because the
|
||||
// build injects it). The --version-path / .gstack/version-path pin already
|
||||
// let you point anywhere, but the readers treated the target as raw text,
|
||||
// so a JSON file was whitespace-stripped into `{"name":"frontend",...` and
|
||||
// every version read came back as the 0.0.0.0 fallback — including rival
|
||||
// PRs' claims fetched through the GitHub Contents API, which were then
|
||||
// dropped as "malformed".
|
||||
// 2. The version is 3-digit semver. parseVersion() required exactly four
|
||||
// components, so gstack-next-version exited 2 ("could not parse base
|
||||
// version") on every invocation — and that CLI *is* the queue-collision
|
||||
// check, so /ship fell through to its documented "offline" path of naive
|
||||
// local arithmetic. Two branches cut from the same base then pick the same
|
||||
// version, and git merges that without a conflict because both sides set
|
||||
// one line to identical text. The duplicate slot ships silently.
|
||||
//
|
||||
// Both are handled here rather than in each CLI so the two agree by construction.
|
||||
//
|
||||
// Detection is by shape, not configuration: a version-path ending in .json is
|
||||
// read as JSON (.version), anything else as trimmed text; a version string with
|
||||
// three components stays three components through bumping and formatting. A
|
||||
// repo with a root VERSION file and 4-digit versions sees no behaviour change.
|
||||
//
|
||||
// Re-derived from PR #2501 by @YiftahR.
|
||||
|
||||
export type Version = [number, number, number, number];
|
||||
export type VersionWidth = 3 | 4;
|
||||
export type Bump = "major" | "minor" | "patch" | "micro";
|
||||
|
||||
/** Parse 3- or 4-component versions. 3-digit pads to [a,b,c,0] so comparison stays uniform. */
|
||||
export function parseVersion(s: string): Version | null {
|
||||
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$/);
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4] ?? 0)];
|
||||
}
|
||||
|
||||
/** How many components the string actually had — what to format back out as. */
|
||||
export function versionWidth(s: string): VersionWidth {
|
||||
return /^\d+\.\d+\.\d+\.\d+$/.test(s.trim()) ? 4 : 3;
|
||||
}
|
||||
|
||||
export function fmtVersion(v: Version, width: VersionWidth = 4): string {
|
||||
return v.slice(0, width).join(".");
|
||||
}
|
||||
|
||||
export function cmpVersion(a: Version, b: Version): number {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (a[i] !== b[i]) return a[i] - b[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump one level. In a 3-digit repo there is no MICRO component to move, so
|
||||
* `micro` is carried out as a PATCH: /ship auto-picks MICRO by default, and
|
||||
* erroring there would make it unusable in every 3-digit repo — a silent no-op
|
||||
* would be worse still, since the caller would then write back the version it
|
||||
* started with and claim a taken slot.
|
||||
*/
|
||||
export function bumpVersion(v: Version, level: Bump, width: VersionWidth = 4): Version {
|
||||
const effective: Bump = width === 3 && level === "micro" ? "patch" : level;
|
||||
switch (effective) {
|
||||
case "major":
|
||||
return [v[0] + 1, 0, 0, 0];
|
||||
case "minor":
|
||||
return [v[0], v[1] + 1, 0, 0];
|
||||
case "patch":
|
||||
return [v[0], v[1], v[2] + 1, 0];
|
||||
case "micro":
|
||||
return [v[0], v[1], v[2], v[3] + 1];
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the effective bump differs from the one asked for (so callers can say so). */
|
||||
export function bumpWasCoerced(level: Bump, width: VersionWidth): boolean {
|
||||
return width === 3 && level === "micro";
|
||||
}
|
||||
|
||||
/**
|
||||
* The npm-valid form of a gstack version. npm's semver is 3-component and
|
||||
* rejects a fourth, so the 4-digit MAJOR.MINOR.PATCH.MICRO truncates to
|
||||
* MAJOR.MINOR.PATCH; 3-digit versions pass through unchanged. Per the
|
||||
* version-tooling end-state spec (v1.67 fix-wave plan, decision 11): the
|
||||
* manifest mirror always carries this form, and VERSION stays the 4-digit
|
||||
* source of truth.
|
||||
*/
|
||||
export function npmVersion(version: string): string {
|
||||
return version.trim().split(".").slice(0, 3).join(".");
|
||||
}
|
||||
|
||||
/** A version-path pointing at a .json is read as JSON, not as raw text. */
|
||||
export function isJsonVersionPath(versionPath: string): boolean {
|
||||
return /\.json$/i.test(versionPath.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the version out of whatever the version-path resolves to. `text` is the
|
||||
* file's contents from anywhere — local read, `git show`, or a base64-decoded
|
||||
* API response — so every reader agrees on interpretation. Returns "" when
|
||||
* there is no usable version, which callers map to their own fallback.
|
||||
*/
|
||||
export function extractVersion(text: string, versionPath: string): string {
|
||||
if (!isJsonVersionPath(versionPath)) return text.replace(/[\r\n\s]/g, "");
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { version?: unknown };
|
||||
return typeof parsed?.version === "string" ? parsed.version.trim() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a version back into a JSON file, preserving the rest of it. Deliberately
|
||||
* key-order-preserving (JSON.parse/stringify keeps insertion order) and 2-space
|
||||
* indented with a trailing newline, matching what package managers write.
|
||||
*/
|
||||
export function setVersionInJson(raw: string, version: string): string {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
parsed.version = version;
|
||||
return JSON.stringify(parsed, null, 2) + "\n";
|
||||
}
|
||||
Reference in New Issue
Block a user