mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
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>
305 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|