Files
gbrain/test/cli-help-without-brain.serial.test.ts
T
MasaandClaude Opus 5 ea17f5911c fix(cli): --help required a configured brain for five self-help commands (#3954)
* fix(cli): --help required a configured brain for five commands

`gbrain models --help` on a machine that has not run `gbrain init` exits 1
with "No brain configured. Run: gbrain init". So do watch, skillopt, maintain
and extract-conversation-facts. That is the state a reader is most likely to
be in when they reach for --help.

Mechanism: cli.ts's per-command --help block prints the generic usage stub
only for CLI_ONLY commands NOT in CLI_ONLY_SELF_HELP. Members of that set are
meant to print their own help, so they fall through to the normal dispatch --
which connects the engine before the handler runs. Each of these five already
opens with a --help branch (models honours it first by explicit comment,
watch and skillopt on line 1 of the handler); the connect gate made that
branch unreachable.

Dispatched pre-connect now, through a small table keyed by command. The engine
is never read on the help path, so a placeholder is passed rather than
widening five signatures -- skillopt already declares `BrainEngine | null` for
this reason.

Measured on 130d321d with an empty GBRAIN_HOME:

  before: models/watch/skillopt/maintain/extract-conversation-facts -> exit 1, 0 lines
  after:  exit 0, 15 / 21 / 94 / 11 / 43 lines of their own help

Seven other members of the set (brainstorm, config, embed, lsd, migrate,
pages, retrieval-upgrade) still need a brain, because their handlers have no
--help branch to reach. Fixing those means either writing help or dropping
them from CLI_ONLY_SELF_HELP so the generic stub answers -- a call for the
maintainer, not something to guess at here. They are pinned in the test as a
tripwire rather than omitted, so the coverage list cannot drift in either
direction: fixing one fails the test until its entry moves.

The guard runs the CLI with an empty GBRAIN_HOME and asserts exit 0 plus real
help output. Behaviour, not a declaration -- membership in CLI_ONLY_SELF_HELP
was itself unverified, which is how five commands sat in a set whose whole
meaning is "prints its own help" while printing none.

Verification on 130d321d (v0.42.76.0):
- bun test cli + cli-flag-validation + cli-help-without-brain -> 54 pass / 0 fail
- red check: with upstream/master's cli.ts -> 5 fail / 1 pass
- bun run typecheck -> clean
- bun run verify -> 34/34 green
- bun run build:flag-registry -> no diff

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cli): stop the no-brain guard inheriting a database URL

Codex: an empty GBRAIN_HOME is not sufficient. loadConfig also honours
GBRAIN_DATABASE_URL and DATABASE_URL (config.ts:550-551), so a developer or CI
runner exporting either would let the CLI connect, the positive assertions
would pass on master, and the guard would be inert.

Both are now unset for the spawned process. Verified the guard still regresses
under the leak it was vulnerable to: with DATABASE_URL exported and
upstream/master's cli.ts, the run is 5 fail / 1 pass.

Verification on 130d321d: 6 pass / 0 fail, typecheck clean, verify 34/34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): type the spawn env so the delete compiles

process.env is narrowly typed here, so deleting GBRAIN_DATABASE_URL /
DATABASE_URL off a spread of it fails tsc with TS2339. Declared as
Record<string, string | undefined>.

My previous commit was pushed with this error present — typecheck and verify
were run after the push, not before. Verified now:
- bun run typecheck -> clean
- bun run verify -> 34/34 green
- bun test cli + cli-flag-validation + cli-help-without-brain -> 54 pass / 0 fail

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cli): spawn with --no-env-file so a local .env cannot re-inject the URL

Codex: unsetting GBRAIN_DATABASE_URL / DATABASE_URL on the spawn env is not
enough, because bun auto-loads .env from cwd and GBRAIN_DATABASE_URL is
honored unconditionally (config.ts:550). A developer's local .env would put
back exactly what the deletes removed, and the guard would go inert. Flag
semantics confirmed from `bun --help`: '--no-env-file  Disable automatic
loading of .env files'.

Verified: typecheck clean, verify 34/34, guard 6 pass / 0 fail, and still
5 fail / 1 pass against upstream/master's cli.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cli): run the tripwire spawns concurrently

Seven sequential CLI spawns were most of this file's wall clock and each is
independent (own temp GBRAIN_HOME). 4.05s -> 2.87s locally; the saving is
larger on a loaded CI runner.

Gates run BEFORE this push, unlike twice earlier on this branch: typecheck
clean, verify 34/34, 6 pass / 0 fail, and still 5 fail / 1 pass against
upstream/master's cli.ts.

Note on the shard 9 failure on the previous run: it exited 123 with no
reported test failure, the last output being an unrelated capture test hitting
a placeholder API key. Shard 9 passes locally with this change, and passes on
three sibling PRs off the same base, so I have not been able to attribute it.
Pushing this change re-runs it; if it reproduces in the same place the cause is
here and I will chase it rather than re-run again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cli): make the guard serial so it does not re-shard the suite

Root cause of the shard 9 failure on the two previous runs, chased rather than
re-run as promised.

scripts/run-unit-shard.sh documents 'hash-disjoint' sharding but assigns by
sorted-index modulo: `i % shard_m + 1`. Adding ONE test file shifts every
index after it, so 933 of 1103 files change shard. Shard 9 kept
capture-runcapture.test.ts but got a different set of neighbours, and that file
does a real embed attempt — under CI's placeholder OPENAI_API_KEY it reached
the network, failed, and took the shard process down with exit 123 and no
reported test failure.

Not my code's behaviour: capture-runcapture passes in isolation with my cli.ts
AND with master's (26 pass each), and shard 9 passed on four sibling PRs off
the same base that add no test file. Any PR adding a sharded test file can
surface this.

Serial files are excluded from the partition (`-not -name '*.serial.test.ts'`),
so the sharded set is back to master's 1103 files and shard 9 to its original
composition. Serial is also the honest classification: this guard spawns twelve
CLI subprocesses.

Gates before push: typecheck clean, verify 34/34, 6 pass / 0 fail, still
5 fail / 1 pass against upstream/master's cli.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:44:52 +07:00

100 lines
3.8 KiB
TypeScript

/**
* `--help` must be answerable with no brain configured.
*
* A reader runs `--help` most often right after install, before `gbrain init`.
* CLI_ONLY_SELF_HELP members skip the dispatcher's generic usage stub (that is
* the point — they print their own), but they were then reached through the
* normal dispatch, which connects the engine first. So on a machine with no
* brain they exited 1 with "No brain configured" and their own help block was
* unreachable code.
*
* The oracle here is behaviour, not a declaration: each command is actually
* run with an empty GBRAIN_HOME.
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const REPO = new URL('..', import.meta.url).pathname;
/** Answer `--help` before touching the engine. */
const HELP_WITHOUT_BRAIN = [
'models',
'watch',
'skillopt',
'maintain',
'extract-conversation-facts',
];
/**
* Self-help members that still need a brain for `--help`, because their handler
* has no `--help` branch to reach. Pinned rather than omitted so the list can
* only shrink deliberately: writing help for one of these, or dropping it from
* CLI_ONLY_SELF_HELP so the generic stub answers, fails this test until the
* entry moves.
*/
const STILL_NEEDS_A_BRAIN = [
'brainstorm',
'config',
'embed',
'lsd',
'migrate',
'pages',
'retrieval-upgrade',
];
async function runHelp(command: string): Promise<{ code: number; out: string }> {
const home = mkdtempSync(join(tmpdir(), 'gbrain-nobrain-'));
// An empty GBRAIN_HOME is not enough: loadConfig also honours
// GBRAIN_DATABASE_URL and DATABASE_URL (config.ts:550-551), so a developer
// or CI runner that exports either would let the CLI connect anyway — the
// positive assertions would pass on master and this guard would be inert.
const env: Record<string, string | undefined> = { ...process.env, GBRAIN_HOME: home };
delete env.GBRAIN_DATABASE_URL;
delete env.DATABASE_URL;
// --no-env-file: bun auto-loads .env from cwd, and GBRAIN_DATABASE_URL is
// honored unconditionally, so a developer's local .env would put back
// exactly what the deletes above removed.
const proc = Bun.spawn(['bun', '--no-env-file', 'run', 'src/cli.ts', command, '--help'], {
cwd: REPO,
env,
stdout: 'pipe',
stderr: 'pipe',
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const code = await proc.exited;
return { code, out: stdout + stderr };
}
describe('--help without a configured brain', () => {
for (const command of HELP_WITHOUT_BRAIN) {
test(`${command} --help answers`, async () => {
const { code, out } = await runHelp(command);
expect(code).toBe(0);
expect(out).not.toContain('No brain configured');
// Real help, not a one-line stub or an empty exit.
expect(out.split('\n').filter(l => l.trim()).length).toBeGreaterThan(3);
expect(out.toLowerCase()).toContain(command.split('-')[0]);
}, 30_000);
}
test('the known-unfixed set is exactly what it claims', async () => {
// Concurrently: seven sequential CLI spawns is most of this file's wall
// clock, and each one is independent (its own temp GBRAIN_HOME).
const results = await Promise.all(
STILL_NEEDS_A_BRAIN.map(async command => ({ command, ...(await runHelp(command)) })),
);
const unexpectedlyWorking = results
.filter(r => !r.out.includes('No brain configured'))
.map(r => r.command);
// Not a wish that they stay broken — a tripwire. Fixing one is good and
// should move it to HELP_WITHOUT_BRAIN in the same change, so the coverage
// list never drifts away from reality in either direction.
expect(unexpectedlyWorking).toEqual([]);
}, 90_000);
});