feat(cli): replace offload subcommands with browser device-authorization login / logout
The old `offload` command family required users to paste a token manually (`offload login --token `) and exposed bring-your-own-endpoint plumbing (`set-endpoint`, `status`, `disable`) as top-level CLI surface. This replaces it with a standard OAuth device flow (RFC 8628 shape) against the CodeGraph dashboard. `codegraph login` calls `/api/cli/device/start`, opens the browser to the returned URL, polls `/api/cli/device/token` until the user approves, then stores the minted token and enables managed reasoning. `codegraph logout` clears it. BYO-endpoint configuration moves entirely to env vars (`CODEGRAPH_OFFLOAD_URL` / `CODEGRAPH_OFFLOAD_KEY` / `CODEGRAPH_OFFLOAD_MODEL`), keeping the CLI surface minimal.
This commit is contained in:
+42
-89
@@ -36,8 +36,9 @@ import { installFatalHandlers } from './fatal-handler';
|
|||||||
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
|
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
|
||||||
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
|
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
|
||||||
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
|
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
|
||||||
import { writeOffloadConfig, resolveOffload } from '../reasoning/config';
|
import { writeOffloadConfig } from '../reasoning/config';
|
||||||
import { writeOffloadToken } from '../reasoning/credentials';
|
import { writeOffloadToken } from '../reasoning/credentials';
|
||||||
|
import { startDeviceLogin, pollForToken, openBrowser } from '../reasoning/login';
|
||||||
import { fetchUsage } from '../reasoning/reasoner';
|
import { fetchUsage } from '../reasoning/reasoner';
|
||||||
|
|
||||||
// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
|
// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
|
||||||
@@ -1352,103 +1353,55 @@ program
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* codegraph offload — configure the reasoning offload (bring-your-own endpoint).
|
* codegraph login / logout — managed reasoning (CodeGraph AI).
|
||||||
*
|
*
|
||||||
* When set, codegraph_explore reasons over its assembled source with a remote
|
* `login` runs a browser device-authorization flow against the CodeGraph dashboard,
|
||||||
* model and returns the synthesized answer instead of the raw source dump.
|
* mints the account's metered org token, and stores it (managed offload on). When
|
||||||
|
* signed in, codegraph_explore reasons over its assembled source via the managed
|
||||||
|
* gateway instead of returning the raw source dump. `logout` clears it.
|
||||||
|
*
|
||||||
|
* Bring-your-own endpoint is configured via the CODEGRAPH_OFFLOAD_URL /
|
||||||
|
* CODEGRAPH_OFFLOAD_KEY / CODEGRAPH_OFFLOAD_MODEL env vars (see ../reasoning/config).
|
||||||
*/
|
*/
|
||||||
const offloadCmd = program
|
program
|
||||||
.command('offload')
|
|
||||||
.description('Configure the reasoning offload — let codegraph_explore answer via your own reasoning model');
|
|
||||||
|
|
||||||
offloadCmd
|
|
||||||
.command('set-endpoint <url>')
|
|
||||||
.description('Send explore output to an OpenAI-compatible reasoning endpoint (URL ends in /v1)')
|
|
||||||
.option('--model <model>', 'Model id to request', 'gpt-oss-120b')
|
|
||||||
.option('--key-env <ENV>', 'Name of the env var holding the API key (the key is never written to disk)')
|
|
||||||
.option('--effort <effort>', 'reasoning_effort: low | medium | high')
|
|
||||||
.option('--style <style>', 'Output style: plain | report')
|
|
||||||
.action((url: string, opts: { model?: string; keyEnv?: string; effort?: string; style?: string }) => {
|
|
||||||
writeOffloadConfig({
|
|
||||||
url,
|
|
||||||
model: opts.model,
|
|
||||||
keyEnv: opts.keyEnv,
|
|
||||||
effort: opts.effort,
|
|
||||||
style: opts.style,
|
|
||||||
});
|
|
||||||
success(`Reasoning offload enabled → ${url}`);
|
|
||||||
info(` model: ${opts.model || 'gpt-oss-120b'}`);
|
|
||||||
if (opts.keyEnv) info(` key: read from $${opts.keyEnv} at call time`);
|
|
||||||
else warn(' no API key configured — pass --key-env <ENV> (or set CODEGRAPH_OFFLOAD_KEY) if your endpoint needs auth.');
|
|
||||||
info(' Restart your editor/agent session for running MCP servers to pick it up.');
|
|
||||||
});
|
|
||||||
|
|
||||||
offloadCmd
|
|
||||||
.command('login')
|
.command('login')
|
||||||
.description('Use the managed CodeGraph AI tier (metered) with your account token')
|
.description('Sign in to CodeGraph AI for managed reasoning — opens your browser to authorize')
|
||||||
.requiredOption('--token <token>', 'Your CodeGraph AI org token')
|
.option('--no-browser', "Don't auto-open the browser; just print the URL to visit")
|
||||||
.option('--url <url>', 'Override the managed gateway URL (advanced/testing)')
|
.action(async (opts: { browser?: boolean }) => {
|
||||||
.option('--model <model>', 'Override the model id')
|
try {
|
||||||
.action((opts: { token: string; url?: string; model?: string }) => {
|
const start = await startDeviceLogin();
|
||||||
// Phase 2: the token is pasted in. A future `codegraph login` device flow will
|
const url = start.verification_uri_complete ?? start.verification_uri;
|
||||||
// mint and store it automatically.
|
info('To authorize, open this URL in your browser:');
|
||||||
writeOffloadConfig({ managed: true, url: opts.url, model: opts.model });
|
info(` ${url}`);
|
||||||
writeOffloadToken(opts.token);
|
info(`and confirm the code: ${start.user_code}`);
|
||||||
success('Reasoning offload: signed in to CodeGraph AI (managed).');
|
if (opts.browser !== false) await openBrowser(url);
|
||||||
info(' Credits burn from your account. Check the balance with `codegraph offload status`.');
|
info('Waiting for authorization… (Ctrl-C to cancel)');
|
||||||
info(' Restart your editor/agent session for running MCP servers to pick it up.');
|
const token = await pollForToken(start.device_code, start.interval ?? 5, start.expires_in ?? 600);
|
||||||
|
writeOffloadConfig({ managed: true });
|
||||||
|
writeOffloadToken(token);
|
||||||
|
success('Signed in to CodeGraph AI — managed reasoning is on.');
|
||||||
|
try {
|
||||||
|
const usage = await fetchUsage();
|
||||||
|
if (usage && typeof usage.remaining === 'number') {
|
||||||
|
info(` credits: ${usage.remaining.toLocaleString()} remaining`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* balance is best-effort */
|
||||||
|
}
|
||||||
|
info(' Restart your editor/agent session for running MCP servers to pick it up.');
|
||||||
|
} catch (err) {
|
||||||
|
error(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
offloadCmd
|
program
|
||||||
.command('logout')
|
.command('logout')
|
||||||
.description('Sign out of CodeGraph AI and clear the stored token')
|
.description('Sign out of CodeGraph AI (clears the saved token and turns off managed reasoning)')
|
||||||
.action(() => {
|
.action(() => {
|
||||||
writeOffloadToken(null);
|
writeOffloadToken(null);
|
||||||
writeOffloadConfig(null);
|
writeOffloadConfig(null);
|
||||||
success('Signed out of CodeGraph AI; offload turned off.');
|
success('Signed out of CodeGraph AI.');
|
||||||
});
|
|
||||||
|
|
||||||
offloadCmd
|
|
||||||
.command('status')
|
|
||||||
.description('Show the current reasoning-offload configuration (and managed balance)')
|
|
||||||
.action(async () => {
|
|
||||||
const c = resolveOffload();
|
|
||||||
if (!c.enabled) {
|
|
||||||
if (c.managed) info('Reasoning offload: managed, but signed out. Run `codegraph offload login --token <token>`.');
|
|
||||||
else info('Reasoning offload: off. Enable with `codegraph offload set-endpoint <url>` or `codegraph offload login`.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (c.managed) {
|
|
||||||
success(`Reasoning offload: on — CodeGraph AI (managed)`);
|
|
||||||
info(` endpoint: ${c.url}`);
|
|
||||||
info(` model: ${c.model}`);
|
|
||||||
info(` token: present (from ${c.keySource})`);
|
|
||||||
const usage = await fetchUsage();
|
|
||||||
if (usage && typeof usage.remaining === 'number') {
|
|
||||||
const reset = usage.periodEnd ? ` · allowance resets ${new Date(usage.periodEnd).toISOString().slice(0, 10)}` : '';
|
|
||||||
info(` credits: ${usage.remaining.toLocaleString()} remaining (plan ${usage.plan ?? '—'})${reset}`);
|
|
||||||
} else {
|
|
||||||
warn(' credits: could not reach CodeGraph AI to read your balance (the offload still degrades gracefully).');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
success(`Reasoning offload: on (${c.origin === 'env' ? 'from environment' : 'configured'})`);
|
|
||||||
info(` endpoint: ${c.url}`);
|
|
||||||
info(` model: ${c.model}`);
|
|
||||||
info(` key: ${c.apiKey ? `present (from $${c.keySource})` : 'none'}`);
|
|
||||||
info(` effort: ${c.effort} style: ${c.style}`);
|
|
||||||
if (!c.apiKey) warn(' no API key resolved — set --key-env <ENV> or CODEGRAPH_OFFLOAD_KEY if your endpoint requires auth.');
|
|
||||||
});
|
|
||||||
|
|
||||||
offloadCmd
|
|
||||||
.command('disable')
|
|
||||||
.description('Turn off the reasoning offload (keeps any saved login token)')
|
|
||||||
.action(() => {
|
|
||||||
writeOffloadConfig(null);
|
|
||||||
success('Reasoning offload disabled.');
|
|
||||||
if (process.env.CODEGRAPH_OFFLOAD_URL) {
|
|
||||||
warn('Note: CODEGRAPH_OFFLOAD_URL is still set in your environment, which keeps it on. Unset it to fully disable.');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Managed-login device flow for `codegraph login`.
|
||||||
|
*
|
||||||
|
* Opens the user's browser to the CodeGraph dashboard, where they authorize with
|
||||||
|
* their account; the CLI meanwhile polls for the minted, org-scoped token and
|
||||||
|
* stores it (see ./credentials + ./config) to turn on managed reasoning.
|
||||||
|
*
|
||||||
|
* This talks to the DASHBOARD (app.getcodegraph.com), not the metered gateway —
|
||||||
|
* it's a plain OAuth-style device handshake (RFC 8628 shape), nothing proprietary.
|
||||||
|
* The resulting token is what authenticates the managed reasoning calls (./reasoner).
|
||||||
|
*/
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
const DEFAULT_BASE = 'https://app.getcodegraph.com';
|
||||||
|
|
||||||
|
/** Dashboard base for the device-login endpoints; override for testing via CODEGRAPH_LOGIN_URL. */
|
||||||
|
export function loginBaseUrl(): string {
|
||||||
|
const raw = process.env.CODEGRAPH_LOGIN_URL?.trim() || DEFAULT_BASE;
|
||||||
|
return raw.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard's response to a device-authorization start request. */
|
||||||
|
export interface DeviceStart {
|
||||||
|
device_code: string;
|
||||||
|
user_code: string;
|
||||||
|
verification_uri: string;
|
||||||
|
/** Same URL with the code prefilled, for one-click open. */
|
||||||
|
verification_uri_complete?: string;
|
||||||
|
/** Seconds the CLI should wait between polls. */
|
||||||
|
interval?: number;
|
||||||
|
/** Seconds until the request expires. */
|
||||||
|
expires_in?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin a device-authorization request. */
|
||||||
|
export async function startDeviceLogin(): Promise<DeviceStart> {
|
||||||
|
const base = loginBaseUrl();
|
||||||
|
const res = await fetch(`${base}/api/cli/device/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!res) throw new Error(`couldn't reach ${base} — check your connection`);
|
||||||
|
if (!res.ok) throw new Error(`couldn't start login (HTTP ${res.status})`);
|
||||||
|
const j = (await res.json().catch(() => null)) as DeviceStart | null;
|
||||||
|
if (!j?.device_code || !j.user_code) throw new Error('login start returned an unexpected response');
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll until the user approves in the browser; resolves with the org token. */
|
||||||
|
export async function pollForToken(deviceCode: string, intervalSec: number, expiresInSec: number): Promise<string> {
|
||||||
|
const deadline = Date.now() + Math.max(30, expiresInSec || 600) * 1000;
|
||||||
|
let waitMs = Math.max(2, intervalSec || 5) * 1000;
|
||||||
|
const base = loginBaseUrl();
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, waitMs));
|
||||||
|
const res = await fetch(`${base}/api/cli/device/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device_code: deviceCode }),
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!res) continue; // transient network blip — keep polling until the deadline
|
||||||
|
if (res.status === 200) {
|
||||||
|
const j = (await res.json().catch(() => null)) as { token?: string } | null;
|
||||||
|
if (j?.token) return j.token;
|
||||||
|
} else if (res.status === 429) {
|
||||||
|
waitMs += 2000; // server asked us to slow down
|
||||||
|
} else if (res.status === 404 || res.status === 410) {
|
||||||
|
throw new Error('the login request expired — run `codegraph login` again');
|
||||||
|
}
|
||||||
|
// 202 (authorization pending) → keep waiting
|
||||||
|
}
|
||||||
|
throw new Error('login timed out before you approved — run `codegraph login` again');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort: open a URL in the default browser. Never throws — the URL is also printed. */
|
||||||
|
export async function openBrowser(url: string): Promise<void> {
|
||||||
|
const [cmd, args] =
|
||||||
|
process.platform === 'darwin' ? ['open', [url]]
|
||||||
|
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
||||||
|
: ['xdg-open', [url]];
|
||||||
|
try {
|
||||||
|
const child = spawn(cmd as string, args as string[], { stdio: 'ignore', detached: true });
|
||||||
|
child.on('error', () => {});
|
||||||
|
child.unref();
|
||||||
|
} catch {
|
||||||
|
/* the URL is printed for manual open */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user