mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
fix(doctor): resolve MSYS executable paths (#3865)
This commit is contained in:
@@ -56,6 +56,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
|
||||
- `src/core/disk-walk.ts` — `walkBrainRepo(repoPath)` returns `Map<slug, {size, mtimeMs}>` from one recursive `readdirSync`. Skips dot-dirs, `node_modules`, non-`.md` files. Used by `gbrain storage status` to replace per-page `existsSync + statSync` (~400K syscalls on 200K-page brains → tens).
|
||||
- `src/core/git-head.ts` — local git HEAD freshness probe for `gbrain doctor`. `isSourceUnchangedSinceSync(localPath, lastCommit, opts?)` returns true iff `localPath` is a git repo whose current HEAD matches `lastCommit`; when `opts.requireCleanWorkingTree` is true also requires a clean working tree (mirrors `gbrain sync`'s force-walk gate at `sync.ts:1075` so doctor and sync agree on "is there work to do?"). `requireCleanWorkingTree` is `boolean | 'ignore-untracked'` — in `'ignore-untracked'` mode the clean probe runs `git status --porcelain --untracked-files=no` so a quiet repo with stray untracked dirs (`?? companies/`, `?? media/`) is still "unchanged" (sync's incremental path keys off the commit diff and never imports untracked files); `GitCleanProbe` gains an `ignoreUntracked?` second arg. Two probe seams (`_setGitHeadProbeForTests`, `_setGitCleanProbeForTests`) keep unit tests R2-compliant (no `mock.module`). Uses `execFileSync` with array args so shell metachars in `local_path` cannot escape to a shell (the regression test runs real `execFileSync` against `'/nonexistent/$(touch <sentinel>)/repo'` and asserts the sentinel is never created). Fail-open on every error (missing path, not a git repo, git not installed, timeout, NULL inputs, dirty-probe errored → false) preserving the caller's prior time-based behavior. The chunker-version-match check lives in the caller (doctor.ts) because it depends on engine state (`sources.chunker_version` vs `CHUNKER_VERSION` from `src/core/chunkers/code.ts`). Pinned by `test/core/git-head.test.ts` (incl. the shell-injection regression guard).
|
||||
- `src/core/source-health.ts` — per-source health metrics for `gbrain sources status` + doctor's `federation_health`. Commit-relative staleness: `newestCommitMs(localPath)` = HEAD committer time via `git log -1 --format=%ct` (fail-open null; NO working-tree mtime parsing — committed content only, robust against the porcelain-mtime bug farm); pure `lagFromContentMs(contentMs|null, lastSyncMs|null, nowMs)` = remote/column comparator (null lastSync → null; negative wall-clock → skew passthrough; `contentMs <= lastSync` → 0; else/null-content → wall-clock). `computeAllSourceMetrics(engine, sources, {probeContent?})`: LOCAL (`probeContent:true`, `gbrain sources status`) → `isSourceUnchangedSinceSync(..., {requireCleanWorkingTree:'ignore-untracked'}) ? 0 : wall-clock` (live commit-hash catches HEAD moving to an old-dated commit a timestamp compare would miss); REMOTE (default, `federation_health` on the HTTP MCP path) → `lagFromContentMs(row.newest_content_at, ...)`, NO git subprocess (trust boundary). `commitTimeMs(localPath, sha)` is the `newestCommitMs` sibling pinned to an arbitrary commit (committer time via `git show -s --format=%ct <sha>`, fail-open null, execFileSync array args) — the resumable sync stamps `newest_content_at` against its pinned target commit, not whatever HEAD raced to. Pinned by `test/source-health.test.ts`.
|
||||
- `src/core/npm-squat-check.ts` — classifies `gbrain` PATH entries as real, foreign npm package, broken, or unknown for doctor's `npm_squat` check. On Windows it normalizes Git Bash/MSYS drive paths (`/c/...` → `C:/...`) and tries the native `.exe` suffix before reporting a broken entry; non-Windows classification keeps the original single-candidate behavior. Pinned by `test/npm-squat-check.test.ts`.
|
||||
- `src/core/git-remote.ts` — SSRF-hardened git invocations for remote-source `cloneRepo`, `pullRepo`, and `fetchRemote(repoPath, branch)` (the last added for the sync cost-estimator's fetch-first path, #2139, so a cost preview / dry-run fetches through the same hardened flags + `GIT_TERMINAL_PROMPT=0` as real sync rather than a less-protected route). Exports two distinct flag constants because `git`'s argv grammar treats them differently: `GIT_SSRF_FLAGS` (3 `-c` config flags — `protocol.allow=user`, `protocol.file.allow=never`, `http.allowRedirects=false`) is global config, spread BEFORE the subcommand verb; `GIT_SSRF_SUBCOMMAND_FLAGS = ['--no-recurse-submodules']` is subcommand-scoped, spread AFTER the verb (a combined array would spread `--no-recurse-submodules` before the verb where real git rejects it exit 129). `cloneRepo` argv: `git <GIT_SSRF_FLAGS> clone <GIT_SSRF_SUBCOMMAND_FLAGS> --depth=1 [--branch X] -- <url> <dir>`. `pullRepo` argv: `git <GIT_SSRF_FLAGS> -C <dir> pull <GIT_SSRF_SUBCOMMAND_FLAGS> --ff-only`. Pinned by `test/git-remote.test.ts` position-anchored regression guard (`argv.indexOf('--no-recurse-submodules') > argv.indexOf(verb)`). Also exports the durability-side helpers that power `gbrain sources harden/pull`: `GIT_ENV_AUTH` (the no-prompt env minus the askpass `/bin/false` overrides, so an auth'd push/fetch can consult the repo's configured credential helper while `GIT_TERMINAL_PROMPT=0` still fails fast on a missing credential), `divergenceSafePull(repoPath, branch)` (fetch + `pull --rebase`; returns `skipped_dirty` on a dirty tree, `conflict_aborted` on a rebase conflict after `rebase --abort` so the tree is never left mid-rebase, else `up_to_date`/`advanced`), `detectDefaultBranch` (origin/HEAD → current branch → `main`), `pushProbe(repoPath, branch)` (authenticated `push --dry-run` that proves push access and classifies `auth`/`protected`/`unreachable`), and `isWorkingTreeDirty`. These auth'd paths route their `protocol.file.allow` through `GBRAIN_GIT_ALLOW_FILE_TRANSPORT` (default `never`; set `=1` for self-hosted filesystem remotes), unlike clone/pull which stay strict.
|
||||
- `src/core/brain-repo-durability.ts` + `src/commands/sources-harden.ts` — brain-repo git durability. `hardenBrainRepo(opts)` makes a brain's working tree durable, idempotently: divergence-safe pull, a LOCAL untracked `.git/hooks/post-commit` auto-push safety net (never committed — a pulled commit can't rewrite executed code next to the token; installed into the active `core.hooksPath` dir and excluded via `.git/info/exclude` when that dir is tracked), a committed `scripts/brain-commit-push.sh` that refuses to exit 0 without a confirmed push and stages+commits BEFORE any pull so a dirty tree of modified pages (the write-through shape) can still be committed — the push-retry's rebase-on-reject handles a remote that advanced (#2426; hook + helper render from ONE bash push-retry template — DRY at the TS source, not by the hook sourcing a repo-controlled script), durability rules patched into the active resolver file (`findResolverFile` → RESOLVER.md > AGENTS.md; taxonomy rendered from the bundled `_brain-filing-rules.json`), a minimal DB-free pull cron (launchd/crontab running `gbrain sources pull --path <dir>` so it never opens the PGLite single-writer lock), and a push-probe verify (no heartbeat commit). Credential is REPO-scoped (`acceptPat` from `--pat-file`/`GBRAIN_GITHUB_PAT`, warns on loose perms; reuses an existing repo-local `credential.helper`, else a `0600` store wired via repo-local config); the token is redacted everywhere via `redactSecretsInText` and never enters the repo, remote URL, logs, or `DurabilityReport`. `unhardenBrainRepo` removes the cron/hook/credential wiring (ownership-fingerprinted) and runs before `sources remove`. CLI: `gbrain sources harden <id|--all>` / `pull <id>|--path <dir>` / `unharden <id>`; auto-harden fires on `sources add --url ... --pat-file` for managed clones (`--no-harden` opts out). `sources pull --path` is dispatched in `src/cli.ts` BEFORE `connectEngine` so the cron stays DB-free. CLI-only (writes executables + an OS cron + a credential helper on the host); never exposed over MCP. Tests: `test/brain-repo-durability.serial.test.ts`, `test/git-remote-durable.serial.test.ts`, `test/brain-durability-hook.serial.test.ts`, `test/durability-cron.test.ts`.
|
||||
- `src/commands/storage.ts` — `gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time.
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface NpmSquatAssessment {
|
||||
binaries: ClassifiedGbrainBinary[];
|
||||
}
|
||||
|
||||
export interface ClassifyGbrainBinaryOptions {
|
||||
platform?: typeof process.platform;
|
||||
realpath?: (path: string) => string;
|
||||
}
|
||||
|
||||
/** Repository marker identifying this project's package.json. */
|
||||
const REAL_REPO_MARKER = 'garrytan/gbrain';
|
||||
|
||||
@@ -94,6 +99,25 @@ function isRealGbrainPackage(pkg: Record<string, any>): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate paths Node can resolve on the current platform. Git Bash emits
|
||||
* `/c/.../gbrain` while the Windows filesystem exposes
|
||||
* `C:/.../gbrain.exe`, so normalize the drive prefix and honor the native
|
||||
* executable suffix before declaring the PATH entry broken.
|
||||
*/
|
||||
function executableCandidates(path: string, platform: typeof process.platform): string[] {
|
||||
if (platform !== 'win32') return [path];
|
||||
|
||||
const normalized = path.replace(
|
||||
/^\/([a-zA-Z])(?=\/)/,
|
||||
(_match, drive: string) => `${drive.toUpperCase()}:`,
|
||||
);
|
||||
const candidates = [normalized];
|
||||
const basename = normalized.split(/[\\/]/).at(-1) ?? '';
|
||||
if (!basename.includes('.')) candidates.push(`${normalized}.exe`);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one candidate `gbrain` path:
|
||||
* - 'broken' : symlink that doesn't resolve / unreadable path.
|
||||
@@ -104,11 +128,22 @@ function isRealGbrainPackage(pkg: Record<string, any>): boolean {
|
||||
* project — an unrelated registry install.
|
||||
* - 'unknown' : can't tell (no gbrain package.json above the resolved file).
|
||||
*/
|
||||
export function classifyGbrainBinary(path: string): ClassifiedGbrainBinary {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = realpathSync(path);
|
||||
} catch {
|
||||
export function classifyGbrainBinary(
|
||||
path: string,
|
||||
opts: ClassifyGbrainBinaryOptions = {},
|
||||
): ClassifiedGbrainBinary {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const resolve = opts.realpath ?? realpathSync;
|
||||
let resolved: string | undefined;
|
||||
for (const candidate of executableCandidates(path, platform)) {
|
||||
try {
|
||||
resolved = resolve(candidate);
|
||||
break;
|
||||
} catch {
|
||||
// Try the next platform-specific candidate.
|
||||
}
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
return { path, kind: 'broken', detail: 'broken symlink or unreadable path' };
|
||||
}
|
||||
if (isNativeExecutable(resolved)) {
|
||||
@@ -144,7 +179,7 @@ export function assessGbrainBinaries(candidates: string[]): NpmSquatAssessment {
|
||||
if (unique.length === 0) {
|
||||
return { status: 'skip', message: 'gbrain not found on PATH', binaries: [] };
|
||||
}
|
||||
const binaries = unique.map(classifyGbrainBinary);
|
||||
const binaries = unique.map((candidate) => classifyGbrainBinary(candidate));
|
||||
const first = binaries[0]!;
|
||||
const realIdx = binaries.findIndex((b) => b.kind === 'real');
|
||||
const foreignIdx = binaries.findIndex((b) => b.kind === 'foreign');
|
||||
|
||||
@@ -112,6 +112,53 @@ describe('classifyGbrainBinary', () => {
|
||||
test('script with no gbrain package.json above → unknown', () => {
|
||||
expect(classifyGbrainBinary(orphanScript).kind).toBe('unknown');
|
||||
});
|
||||
|
||||
test('Windows/MSYS drive path resolves the executable with an .exe suffix', () => {
|
||||
const seen: string[] = [];
|
||||
const c = classifyGbrainBinary('/c/Users/chris/.bun/bin/gbrain', {
|
||||
platform: 'win32',
|
||||
realpath: (candidate) => {
|
||||
seen.push(candidate);
|
||||
if (candidate === 'C:/Users/chris/.bun/bin/gbrain.exe') return nativeBin;
|
||||
throw new Error('ENOENT');
|
||||
},
|
||||
});
|
||||
expect(seen).toEqual([
|
||||
'C:/Users/chris/.bun/bin/gbrain',
|
||||
'C:/Users/chris/.bun/bin/gbrain.exe',
|
||||
]);
|
||||
expect(c.kind).toBe('real');
|
||||
});
|
||||
|
||||
test('Windows native drive path also tries the .exe suffix', () => {
|
||||
const seen: string[] = [];
|
||||
const c = classifyGbrainBinary('C:\\Users\\chris\\.bun\\bin\\gbrain', {
|
||||
platform: 'win32',
|
||||
realpath: (candidate) => {
|
||||
seen.push(candidate);
|
||||
if (candidate.endsWith('gbrain.exe')) return nativeBin;
|
||||
throw new Error('ENOENT');
|
||||
},
|
||||
});
|
||||
expect(seen).toEqual([
|
||||
'C:\\Users\\chris\\.bun\\bin\\gbrain',
|
||||
'C:\\Users\\chris\\.bun\\bin\\gbrain.exe',
|
||||
]);
|
||||
expect(c.kind).toBe('real');
|
||||
});
|
||||
|
||||
test('non-Windows classification does not synthesize executable suffixes', () => {
|
||||
const seen: string[] = [];
|
||||
const c = classifyGbrainBinary('/opt/homebrew/bin/gbrain', {
|
||||
platform: 'darwin',
|
||||
realpath: (candidate) => {
|
||||
seen.push(candidate);
|
||||
throw new Error('ENOENT');
|
||||
},
|
||||
});
|
||||
expect(seen).toEqual(['/opt/homebrew/bin/gbrain']);
|
||||
expect(c.kind).toBe('broken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assessGbrainBinaries', () => {
|
||||
|
||||
Reference in New Issue
Block a user