Files
gbrain/test/cli.test.ts
T
04a2a1f7bf fix(cli): put --file, reject unknown op flags, list pagination (#380, #856, #2876)
Three CLI ergonomics fixes on the shared-op dispatch path:

- parseOpArgs now hard-errors on undeclared flags instead of silently
  swallowing them into params (#380). A pass-through allowlist keeps the
  undeclared-but-honored flags working: --source (makeContext's source
  resolver), --brain (mount axis), --dry-run (ctx.dryRun), --json.
  Value-taking flags with no value also error instead of being dropped.

- `gbrain put SLUG --file PATH` reads content from a file (#380, takeover
  of #856). Driven by the op's cliHints.stdin declaration rather than
  put_page hard-coding, so volunteer-context gets it too. Mutually
  exclusive with --content/stdin in either flag order; 5MB cap shared
  with stdin; unreadable path is a loud error instead of an empty page.
  put --help documents the flag.

- `--json` on shared ops now actually emits raw JSON (docs already
  promised it); same seam on local-engine and thin-client routed paths.

- list_pages declares `offset` (both engines already supported it on
  PageFilters) and the limit description discloses the 100-row cap
  (#2876), so `gbrain list` can paginate past 100 instead of silently
  truncating.

Co-authored-by: Kage18 <Kage18@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:40:45 -07:00

305 lines
11 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Read cli.ts source for structural checks
const cliSource = readFileSync(new URL('../src/cli.ts', import.meta.url), 'utf-8');
const repoRoot = new URL('..', import.meta.url).pathname;
function isolatedEnv(home: string): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) env[key] = value;
}
delete env.GBRAIN_DATABASE_URL;
delete env.DATABASE_URL;
env.GBRAIN_HOME = home;
return env;
}
describe('CLI structure', () => {
test('imports operations from operations.ts', () => {
expect(cliSource).toContain("from './core/operations.ts'");
});
test('builds cliOps map from operations', () => {
expect(cliSource).toContain('cliOps');
});
test('CLI_ONLY set contains expected commands', () => {
expect(cliSource).toContain("'init'");
expect(cliSource).toContain("'upgrade'");
expect(cliSource).toContain("'import'");
expect(cliSource).toContain("'export'");
expect(cliSource).toContain("'embed'");
expect(cliSource).toContain("'files'");
});
// v0.41.11 #1451 regression — `reindex` had a `case 'reindex':` handler
// at src/cli.ts:1334 but was missing from CLI_ONLY, so the dispatcher
// rejected `gbrain reindex` with "Unknown command: reindex" before the
// handler ever ran. Cherry-picked from kylma-code-adjacent PR #1354.
test('reindex is in CLI_ONLY (does not get "Unknown command")', () => {
const onlyMatch = cliSource.match(/const CLI_ONLY = new Set\(\[([\s\S]*?)\]\)/);
expect(onlyMatch).not.toBeNull();
expect(onlyMatch![1]).toContain(`'reindex'`);
});
test('has formatResult function for CLI output', () => {
expect(cliSource).toContain('function formatResult');
});
});
describe('CLI version', () => {
test('VERSION matches package.json', async () => {
const { VERSION } = await import('../src/version.ts');
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
expect(VERSION).toBe(pkg.version);
});
test('VERSION is a valid semver string', async () => {
const { VERSION } = await import('../src/version.ts');
expect(VERSION).toMatch(/^\d+\.\d+\.\d+/);
});
});
describe('ask alias', () => {
test('ask alias maps to query in source', () => {
expect(cliSource).toContain("if (command === 'ask')");
expect(cliSource).toContain("command = 'query'");
});
test('ask does NOT appear in --tools-json output', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', '--tools-json'], {
cwd: new URL('..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
await proc.exited;
const tools = JSON.parse(stdout);
const names = tools.map((t: any) => t.name);
expect(names).not.toContain('ask');
});
});
describe('CLI dispatch integration', () => {
test('--version outputs version', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', '--version'], {
cwd: new URL('..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
await proc.exited;
expect(stdout.trim()).toMatch(/^gbrain \d+\.\d+\.\d+/);
});
test('unknown command prints error and exits 1', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'notacommand'], {
cwd: new URL('..', import.meta.url).pathname,
stdout: 'pipe',
stderr: 'pipe',
});
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('Unknown command: notacommand');
expect(exitCode).toBe(1);
});
test('per-command --help prints usage without DB connection', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'get', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain get');
expect(exitCode).toBe(0);
});
// #380 / PR #856: put --help documents the --file input path.
test('put --help documents --file input', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'put', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain put');
expect(stdout).toContain('--file <path>');
expect(exitCode).toBe(0);
});
// #380: unknown flags on shared ops are a hard error (previously silently
// swallowed into params — `put --file` created empty pages). parseOpArgs
// runs BEFORE engine connect, so the error must fire without a brain.
test('unknown shared-op flags fail before DB connection', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-unknown-flag-'));
try {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'get', 'people/alice', '--bogus'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
env: isolatedEnv(home),
});
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('Unknown option for gbrain get: --bogus');
expect(stderr).not.toContain('No brain configured');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('put rejects combining --file and --content', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-conflict-'));
try {
const pagePath = join(home, 'page.md');
writeFileSync(pagePath, 'file body\n');
const proc = Bun.spawn(
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--content', 'inline', '--file', pagePath],
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
);
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('use only one of --file, --content, or stdin');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('put --file with a missing path errors instead of writing an empty page', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-missing-file-'));
try {
const proc = Bun.spawn(
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--file', join(home, 'nope.md')],
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
);
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('cannot read --file');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('upgrade --help prints usage without running upgrade', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'upgrade', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain upgrade');
expect(exitCode).toBe(0);
});
test('sync --help prints sync-specific usage block without running sync (v0.37 D.4)', async () => {
// v0.37 fix wave (Lane D.4 + CDX2-12): sync was added to
// CLI_ONLY_SELF_HELP so `gbrain sync --help` reaches runSync's own
// usage block (which lists --no-embed, the flag that didn't surface
// anywhere pre-fix). Pre-fix the generic CLI-only short-circuit
// printed a header but never mentioned --no-embed.
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-help-'));
try {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'sync', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
env: isolatedEnv(home),
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain sync');
// D.4 regression: the user-visible flag that the bug report wanted
// surfaced. Pre-v0.37 this string was unreachable.
expect(stdout).toContain('--no-embed');
// Sync must NOT actually run (no engine bind, no init).
expect(stdout).not.toContain('Already up to date.');
expect(stderr).not.toContain('Already up to date.');
expect(existsSync(join(home, '.gbrain', 'config.json'))).toBe(false);
expect(exitCode).toBe(0);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('doctor --help short-circuits CLI-only dispatch without diagnostics', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-help-'));
try {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'doctor', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
env: isolatedEnv(home),
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain doctor');
expect(stdout).not.toContain('resolver_health');
expect(stderr).not.toContain('No brain configured');
expect(exitCode).toBe(0);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('init --help short-circuits CLI-only dispatch without writing config', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-help-'));
try {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'init', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
env: isolatedEnv(home),
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain init');
expect(existsSync(join(home, '.gbrain', 'config.json'))).toBe(false);
expect(exitCode).toBe(0);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('--help prints global help', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('USAGE');
expect(stdout).toContain('gbrain <command>');
expect(exitCode).toBe(0);
});
test('--tools-json outputs valid JSON with operations', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', '--tools-json'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
await proc.exited;
const tools = JSON.parse(stdout);
expect(Array.isArray(tools)).toBe(true);
expect(tools.length).toBeGreaterThanOrEqual(30);
expect(tools[0]).toHaveProperty('name');
expect(tools[0]).toHaveProperty('description');
expect(tools[0]).toHaveProperty('parameters');
});
});