feat: wire gbrain auth subcommand with OAuth register-client

Previously auth.ts was a standalone script invoked via
`bun run src/commands/auth.ts`. CHANGELOG and README documented
`gbrain auth ...` commands that didn't actually work.

- Export `runAuth(args)` from auth.ts (keeps standalone entry intact
  via `import.meta.url === file://${process.argv[1]}` check)
- Add `auth` to CLI_ONLY + dispatch in handleCliOnly
- New subcommand `gbrain auth register-client <name> [--grant-types]
  [--scopes]` wraps GBrainOAuthProvider.registerClientManual
- Lazy DB check: only subcommands that need DATABASE_URL error out

Now the documented CLI flow works end to end:
  gbrain auth register-client perplexity --grant-types client_credentials --scopes "read write"
  gbrain serve --http --port 3131

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-23 06:44:37 -07:00
co-authored by Claude Opus 4.7
parent 5b0e61196a
commit c4a86cec89
2 changed files with 82 additions and 22 deletions
+6 -1
View File
@@ -19,7 +19,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable']);
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable', 'auth']);
async function main() {
// Parse global flags (--quiet / --progress-json / --progress-interval)
@@ -265,6 +265,11 @@ async function handleCliOnly(command: string, args: string[]) {
await runInit(args);
return;
}
if (command === 'auth') {
const { runAuth } = await import('./commands/auth.ts');
await runAuth(args);
return;
}
if (command === 'upgrade') {
const { runUpgrade } = await import('./commands/upgrade.ts');
await runUpgrade(args);
+76 -21
View File
@@ -12,9 +12,15 @@ import postgres from 'postgres';
import { createHash, randomBytes } from 'crypto';
const DATABASE_URL = process.env.DATABASE_URL || process.env.GBRAIN_DATABASE_URL;
if (!DATABASE_URL && process.argv[2] !== 'test') {
console.error('Set DATABASE_URL or GBRAIN_DATABASE_URL environment variable.');
process.exit(1);
/** Subcommands that need a DB connection. `test` (smoke test) does not. */
const NEEDS_DB = new Set(['create', 'list', 'revoke', 'register-client']);
function ensureDatabaseUrl(cmd: string | undefined) {
if (cmd && NEEDS_DB.has(cmd) && !DATABASE_URL) {
console.error('Set DATABASE_URL or GBRAIN_DATABASE_URL environment variable.');
process.exit(1);
}
}
function hashToken(token: string): string {
@@ -216,26 +222,75 @@ async function test(url: string, token: string) {
console.log(`\n🧠 Your brain is live! (${elapsed}s)`);
}
// CLI dispatch
const [cmd, ...args] = process.argv.slice(2);
switch (cmd) {
case 'create': await create(args[0]); break;
case 'list': await list(); break;
case 'revoke': await revoke(args[0]); break;
case 'test': {
const tokenIdx = args.indexOf('--token');
const url = args.find(a => !a.startsWith('--') && a !== args[tokenIdx + 1]);
const token = tokenIdx >= 0 ? args[tokenIdx + 1] : '';
await test(url || '', token || '');
break;
async function registerClient(name: string, args: string[]) {
if (!name) { console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S]'); process.exit(1); }
const grantsIdx = args.indexOf('--grant-types');
const scopesIdx = args.indexOf('--scopes');
const grantTypes = grantsIdx >= 0 && args[grantsIdx + 1]
? args[grantsIdx + 1].split(',').map(s => s.trim()).filter(Boolean)
: ['client_credentials'];
const scopes = scopesIdx >= 0 && args[scopesIdx + 1] ? args[scopesIdx + 1] : 'read';
const sql = postgres(DATABASE_URL!);
try {
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
const provider = new GBrainOAuthProvider({ sql: sql as any });
const { clientId, clientSecret } = await provider.registerClientManual(
name, grantTypes, scopes, [],
);
console.log(`OAuth client registered: "${name}"\n`);
console.log(` Client ID: ${clientId}`);
console.log(` Client Secret: ${clientSecret}\n`);
console.log(` Grant types: ${grantTypes.join(', ')}`);
console.log(` Scopes: ${scopes}\n`);
console.log('Save the client secret — it will not be shown again.');
console.log(`Revoke with: bun run src/commands/auth.ts revoke-client "${clientId}"`);
} catch (e: any) {
console.error('Error:', e.message);
process.exit(1);
} finally {
await sql.end();
}
default:
console.log(`GBrain Token Management
}
function printHelp() {
console.log(`GBrain Token Management
Usage:
bun run src/commands/auth.ts create <name> Create a new access token
bun run src/commands/auth.ts list List all tokens
bun run src/commands/auth.ts revoke <name> Revoke a token
bun run src/commands/auth.ts test <url> --token <token> Smoke test a remote MCP server
gbrain auth create <name> Create a legacy bearer token
gbrain auth list List all tokens
gbrain auth revoke <name> Revoke a legacy token
gbrain auth register-client <name> [options] Register an OAuth 2.1 client
--grant-types <client_credentials,authorization_code> (default: client_credentials)
--scopes "<read write admin>" (default: read)
gbrain auth test <url> --token <token> Smoke test a remote MCP server
`);
}
/**
* Invoked by src/cli.ts for `gbrain auth <subcommand>` and by direct
* `bun run src/commands/auth.ts <subcommand>` (legacy path).
*/
export async function runAuth(args: string[]) {
const [cmd, ...rest] = args;
ensureDatabaseUrl(cmd);
switch (cmd) {
case 'create': await create(rest[0]); break;
case 'list': await list(); break;
case 'revoke': await revoke(rest[0]); break;
case 'register-client': await registerClient(rest[0], rest.slice(1)); break;
case 'test': {
const tokenIdx = rest.indexOf('--token');
const url = rest.find(a => !a.startsWith('--') && a !== rest[tokenIdx + 1]);
const token = tokenIdx >= 0 ? rest[tokenIdx + 1] : '';
await test(url || '', token || '');
break;
}
default: printHelp();
}
}
// Direct invocation (`bun run src/commands/auth.ts ...`). Skip when imported.
if (import.meta.url === `file://${process.argv[1]}`) {
await runAuth(process.argv.slice(2));
}