offrouter-cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,247 +0,0 @@
1
- /**
2
- * Tests for the `offrouter login` command.
3
- *
4
- * Mocks `offrouter-core` functions so the login command runs without real OAuth.
5
- * FileSecretStore is NOT mocked; it runs against a real temp directory.
6
- */
7
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
- import { mkdtempSync, rmSync, existsSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
-
12
- // ---------------------------------------------------------------------------
13
- // Mock offrouter-core for login tests
14
- // ---------------------------------------------------------------------------
15
- const mocks = vi.hoisted(() => ({
16
- mockRunOAuthLogin: vi.fn(),
17
- mockGetOAuthTokens: vi.fn(),
18
- mockIsOAuthTokenExpired: vi.fn(),
19
- mockRefreshTokens: vi.fn(),
20
- mockResolveClientId: vi.fn(),
21
- mockResolveOffRouterHome: vi.fn(),
22
- }));
23
-
24
- // Keep FileSecretStore real but mock the core functions.
25
- vi.mock("offrouter-core", async () => {
26
- const actual = await vi.importActual<typeof import("offrouter-core")>("offrouter-core");
27
- return {
28
- ...actual,
29
- runOAuthLogin: mocks.mockRunOAuthLogin,
30
- getOAuthTokens: mocks.mockGetOAuthTokens,
31
- isOAuthTokenExpired: mocks.mockIsOAuthTokenExpired,
32
- refreshTokens: mocks.mockRefreshTokens,
33
- resolveClientId: mocks.mockResolveClientId,
34
- resolveOffRouterHome: mocks.mockResolveOffRouterHome,
35
- };
36
- });
37
-
38
- import { createProgram } from "../index.js";
39
-
40
- function capture(
41
- argv: string[],
42
- env: NodeJS.ProcessEnv = { ...process.env },
43
- ): Promise<{ code: number; stdout: string; stderr: string }> {
44
- return new Promise((resolve) => {
45
- let stdout = "";
46
- let stderr = "";
47
- const program = createProgram({
48
- env,
49
- stdout: {
50
- write(chunk: string) {
51
- stdout += chunk;
52
- return true;
53
- },
54
- },
55
- stderr: {
56
- write(chunk: string) {
57
- stderr += chunk;
58
- return true;
59
- },
60
- },
61
- });
62
-
63
- program
64
- .parseAsync(argv, { from: "user" })
65
- .then(() => {
66
- resolve({ code: 0, stdout, stderr });
67
- })
68
- .catch((err) => {
69
- const code = err?.exitCode ?? 1;
70
- resolve({ code, stdout, stderr });
71
- });
72
- });
73
- }
74
-
75
- describe("offrouter login command", () => {
76
- let tmpHome: string;
77
-
78
- beforeEach(() => {
79
- vi.resetAllMocks();
80
- tmpHome = mkdtempSync(join(tmpdir(), "offrouter-login-test-"));
81
- mocks.mockResolveOffRouterHome.mockReturnValue(tmpHome);
82
- });
83
-
84
- afterEach(() => {
85
- if (tmpHome && existsSync(tmpHome)) {
86
- rmSync(tmpHome, { recursive: true, force: true });
87
- }
88
- });
89
-
90
- describe("help", () => {
91
- it("offrouter login --help shows login command", async () => {
92
- const { code, stdout, stderr } = await capture(["login", "--help"]);
93
- expect(code).toBe(0);
94
- const output = stdout + stderr;
95
- expect(output).toMatch(/login/i);
96
- expect(output).toMatch(/provider/i);
97
- expect(output).toMatch(/anthropic/i);
98
- expect(output).toMatch(/openai/i);
99
- });
100
- });
101
-
102
- describe("login <provider>", () => {
103
- it("calls runOAuthLogin with the provider and stores tokens", async () => {
104
- mocks.mockResolveClientId.mockReturnValue("test-client-id");
105
- mocks.mockRunOAuthLogin.mockResolvedValue({
106
- provider: "anthropic",
107
- redirectUri: "http://127.0.0.1:1456/callback",
108
- expiresAt: Date.now() + 3600_000,
109
- scope: "api offline_access",
110
- });
111
-
112
- const { code, stdout, stderr } = await capture(
113
- ["login", "anthropic"],
114
- {
115
- ...process.env,
116
- OFFROUTER_HOME: tmpHome,
117
- },
118
- );
119
-
120
- expect(code).toBe(0);
121
- expect(stderr).toBe("");
122
-
123
- expect(mocks.mockRunOAuthLogin).toHaveBeenCalledWith(
124
- expect.objectContaining({
125
- provider: "anthropic",
126
- clientId: "test-client-id",
127
- }),
128
- );
129
-
130
- expect(stdout).toMatch(/successful|Login/);
131
- expect(stdout).toContain("anthropic");
132
- });
133
-
134
- it("prints error for unsupported provider", async () => {
135
- const { code, stdout } = await capture(["login", "gemini"]);
136
- expect(code).toBe(0);
137
- expect(stdout).toMatch(/not supported/i);
138
- expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
139
- });
140
-
141
- it("prints error when no client id is configured", async () => {
142
- const { code, stdout } = await capture(
143
- ["login", "anthropic"],
144
- { ...process.env, OFFROUTER_HOME: tmpHome },
145
- );
146
- expect(code).toBe(0);
147
- expect(stdout).toMatch(/client id required|client.*id/i);
148
- expect(mocks.mockRunOAuthLogin).not.toHaveBeenCalled();
149
- });
150
- });
151
-
152
- describe("login --list", () => {
153
- it("shows providers with no tokens when none stored", async () => {
154
- mocks.mockGetOAuthTokens.mockResolvedValue(undefined);
155
-
156
- const { code, stdout } = await capture(
157
- ["login", "--list"],
158
- { ...process.env, OFFROUTER_HOME: tmpHome },
159
- );
160
- expect(code).toBe(0);
161
- expect(stdout).toMatch(/OAuth providers/i);
162
- expect(stdout).toContain("anthropic");
163
- expect(stdout).toContain("openai");
164
- });
165
-
166
- it("shows active token status", async () => {
167
- mocks.mockGetOAuthTokens.mockResolvedValue({
168
- accessToken: "tok",
169
- refreshToken: "refresh",
170
- expiresAt: Date.now() + 3600_000,
171
- obtainedAt: Date.now(),
172
- });
173
- mocks.mockIsOAuthTokenExpired.mockReturnValue(false);
174
-
175
- const { code, stdout } = await capture(
176
- ["login", "--list"],
177
- { ...process.env, OFFROUTER_HOME: tmpHome },
178
- );
179
- expect(code).toBe(0);
180
- expect(stdout).toContain("active");
181
- });
182
-
183
- it("shows expired token status", async () => {
184
- mocks.mockGetOAuthTokens.mockResolvedValue({
185
- accessToken: "tok",
186
- refreshToken: "refresh",
187
- expiresAt: Date.now() - 1000,
188
- obtainedAt: Date.now() - 7200_000,
189
- });
190
- mocks.mockIsOAuthTokenExpired.mockReturnValue(true);
191
-
192
- const { code, stdout } = await capture(
193
- ["login", "--list"],
194
- { ...process.env, OFFROUTER_HOME: tmpHome },
195
- );
196
- expect(code).toBe(0);
197
- expect(stdout).toContain("expired");
198
- });
199
- });
200
-
201
- describe("login --status <provider>", () => {
202
- it("shows status for unsupported provider", async () => {
203
- const { code, stdout } = await capture(
204
- ["login", "--status", "gemini"],
205
- );
206
- expect(code).toBe(0);
207
- expect(stdout).toMatch(/not supported/i);
208
- });
209
- });
210
-
211
- describe("login --json", () => {
212
- it("returns JSON for successful login", async () => {
213
- mocks.mockResolveClientId.mockReturnValue("test-oa-id");
214
- mocks.mockRunOAuthLogin.mockResolvedValue({
215
- provider: "openai",
216
- redirectUri: "http://127.0.0.1:1455/callback",
217
- expiresAt: 9999999999000,
218
- scope: "offline_access",
219
- });
220
-
221
- const { code, stdout } = await capture(
222
- ["login", "openai", "--json"],
223
- {
224
- ...process.env,
225
- OFFROUTER_HOME: tmpHome,
226
- },
227
- );
228
-
229
- expect(code).toBe(0);
230
- const json = JSON.parse(stdout) as Record<string, unknown>;
231
- expect(json.ok).toBe(true);
232
- expect(json.provider).toBe("openai");
233
- expect(json.redirectUri).toContain("127.0.0.1");
234
- });
235
-
236
- it("returns JSON error for unsupported provider", async () => {
237
- const { code, stdout } = await capture(
238
- ["login", "gemini", "--json"],
239
- { ...process.env, OFFROUTER_HOME: tmpHome },
240
- );
241
-
242
- expect(code).toBe(0);
243
- const json = JSON.parse(stdout) as Record<string, unknown>;
244
- expect(json.error).toMatch(/not supported/i);
245
- });
246
- });
247
- });
@@ -1,413 +0,0 @@
1
- /**
2
- * `offrouter login` command: real OAuth PKCE login with browser open.
3
- *
4
- * Supports anthropic and openai (bring-your-own-client-id, documented as fragile).
5
- * Tokens are stored in the OffRouter secret store (keychain or file fallback).
6
- * Never scrapes or copies credentials from other CLIs.
7
- */
8
- import type { Command } from "commander";
9
- import { execSync } from "node:child_process";
10
- import { join } from "node:path";
11
- import {
12
- FileSecretStore,
13
- PKCE_CONFIGS,
14
- getOAuthTokens,
15
- isOAuthProvider,
16
- isOAuthTokenExpired,
17
- refreshTokens,
18
- resolveClientId,
19
- resolveOffRouterHome,
20
- runOAuthLogin,
21
- } from "offrouter-core";
22
- import type { OAuthStoredTokens } from "offrouter-core";
23
- import { writeJson, writeText } from "./output.js";
24
- import type { CommandContext } from "./init.js";
25
-
26
- // ---------------------------------------------------------------------------
27
- // Browser open (macOS, best-effort)
28
- // ---------------------------------------------------------------------------
29
-
30
- function openBrowser(url: string): boolean {
31
- try {
32
- execSync(`open "${url}"`, { timeout: 5000, stdio: "ignore" });
33
- return true;
34
- } catch {
35
- return false;
36
- }
37
- }
38
-
39
- // ---------------------------------------------------------------------------
40
- // Secret store helpers
41
- // ---------------------------------------------------------------------------
42
-
43
- function createStore(env: NodeJS.ProcessEnv): FileSecretStore {
44
- const home = resolveOffRouterHome({ env });
45
- return new FileSecretStore({ filePath: join(home, "secrets.json") });
46
- }
47
-
48
- // ---------------------------------------------------------------------------
49
- // Provider display helpers
50
- // ---------------------------------------------------------------------------
51
-
52
- interface OAuthProviderInfo {
53
- id: string;
54
- clientId: string | null;
55
- tokensStored: boolean;
56
- hasRefreshToken: boolean;
57
- expired: boolean | null;
58
- }
59
-
60
- async function listOAuthProviders(
61
- env: NodeJS.ProcessEnv,
62
- ): Promise<OAuthProviderInfo[]> {
63
- const providers = Object.keys(PKCE_CONFIGS);
64
- const store = createStore(env);
65
- const results: OAuthProviderInfo[] = [];
66
-
67
- for (const id of providers) {
68
- const clientId = resolveClientId(id, env) ?? null;
69
- let tokens: OAuthStoredTokens | undefined;
70
- try {
71
- tokens = await getOAuthTokens(id, store);
72
- } catch {
73
- tokens = undefined;
74
- }
75
-
76
- results.push({
77
- id,
78
- clientId,
79
- tokensStored: !!tokens,
80
- hasRefreshToken: !!tokens?.refreshToken,
81
- expired: tokens ? isOAuthTokenExpired(tokens) : null,
82
- });
83
- }
84
-
85
- return results;
86
- }
87
-
88
- async function statusForProvider(
89
- providerId: string,
90
- env: NodeJS.ProcessEnv,
91
- ): Promise<{
92
- provider: string;
93
- valid: boolean;
94
- expired: boolean;
95
- hasRefreshToken: boolean;
96
- refreshed: boolean;
97
- error?: string;
98
- }> {
99
- const store = createStore(env);
100
- const tokens = await getOAuthTokens(providerId, store);
101
-
102
- if (!tokens) {
103
- return {
104
- provider: providerId,
105
- valid: false,
106
- expired: false,
107
- hasRefreshToken: false,
108
- refreshed: false,
109
- error: "No OAuth tokens stored. Run `offrouter login <provider>` first.",
110
- };
111
- }
112
-
113
- const expired = isOAuthTokenExpired(tokens);
114
-
115
- if (!expired) {
116
- return {
117
- provider: providerId,
118
- valid: true,
119
- expired: false,
120
- hasRefreshToken: !!tokens.refreshToken,
121
- refreshed: false,
122
- };
123
- }
124
-
125
- // Expired: try refresh if we have a refresh token.
126
- if (!tokens.refreshToken) {
127
- return {
128
- provider: providerId,
129
- valid: false,
130
- expired: true,
131
- hasRefreshToken: false,
132
- refreshed: false,
133
- error:
134
- "Token is expired and no refresh token is available. Run `offrouter login` again.",
135
- };
136
- }
137
-
138
- try {
139
- await refreshTokens(providerId, tokens.refreshToken, store);
140
- return {
141
- provider: providerId,
142
- valid: true,
143
- expired: false,
144
- hasRefreshToken: true,
145
- refreshed: true,
146
- };
147
- } catch (err) {
148
- return {
149
- provider: providerId,
150
- valid: false,
151
- expired: true,
152
- hasRefreshToken: true,
153
- refreshed: false,
154
- error: err instanceof Error ? err.message : "Token refresh failed",
155
- };
156
- }
157
- }
158
-
159
- // ---------------------------------------------------------------------------
160
- // Command registration
161
- // ---------------------------------------------------------------------------
162
-
163
- export function registerLoginCommand(
164
- program: Command,
165
- ctx: () => CommandContext,
166
- ): void {
167
- const cmd = program
168
- .command("login")
169
- .description(
170
- "Authenticate with an OAuth provider (Anthropic or OpenAI). Tokens stored in the OffRouter secret store.",
171
- )
172
- .argument("[provider]", "Provider id (anthropic, openai)")
173
- .option("--list", "List providers with stored tokens")
174
- .option("--status <provider>", "Check token status for a provider")
175
- .option("--json", "Emit JSON", false)
176
- .option("--timeout <ms>", "OAuth callback timeout in milliseconds")
177
- .option(
178
- "--client-id <id>",
179
- "Override client ID (default from OFFROUTER_*_CLIENT_ID env)",
180
- );
181
-
182
- cmd.action(
183
- async (
184
- provider: string | undefined,
185
- opts: {
186
- json?: boolean;
187
- list?: boolean;
188
- status?: string;
189
- timeout?: string;
190
- clientId?: string;
191
- },
192
- ) => {
193
- const { env, stdout } = ctx();
194
-
195
- // --list
196
- if (opts.list) {
197
- await handleList(stdout, env, !!opts.json);
198
- return;
199
- }
200
-
201
- // --status <provider>
202
- if (opts.status) {
203
- await handleStatus(stdout, env, opts.status, !!opts.json);
204
- return;
205
- }
206
-
207
- // login <provider> (full flow)
208
- if (!provider) {
209
- if (opts.json) {
210
- writeJson(stdout, {
211
- error: "Missing provider argument. Run `offrouter login --help` for usage.",
212
- });
213
- } else {
214
- writeText(
215
- stdout,
216
- "Missing provider. Usage: offrouter login <provider> [options]",
217
- );
218
- }
219
- return;
220
- }
221
-
222
- await handleLogin(stdout, env, provider, opts);
223
- },
224
- );
225
- }
226
-
227
- // ---------------------------------------------------------------------------
228
- // Handlers
229
- // ---------------------------------------------------------------------------
230
-
231
- async function handleList(
232
- stdout: { write(chunk: string): boolean | void },
233
- env: NodeJS.ProcessEnv,
234
- json: boolean,
235
- ): Promise<void> {
236
- const providers = await listOAuthProviders(env);
237
-
238
- if (json) {
239
- writeJson(stdout, { providers });
240
- return;
241
- }
242
-
243
- if (providers.length === 0) {
244
- writeText(stdout, "No OAuth providers configured.");
245
- return;
246
- }
247
-
248
- writeText(stdout, "OAuth providers:");
249
- for (const p of providers) {
250
- const tokenStatus = p.tokensStored
251
- ? p.expired
252
- ? "expired"
253
- : "active"
254
- : "no tokens";
255
- const refreshStatus = p.hasRefreshToken ? " (refresh token)" : "";
256
- const clientStatus = p.clientId ? `client-id: ${p.clientId}` : "no client-id configured";
257
- writeText(stdout, ` ${p.id}: ${tokenStatus}${refreshStatus}, ${clientStatus}`);
258
- }
259
- }
260
-
261
- async function handleStatus(
262
- stdout: { write(chunk: string): boolean | void },
263
- env: NodeJS.ProcessEnv,
264
- providerId: string,
265
- json: boolean,
266
- ): Promise<void> {
267
- if (!isOAuthProvider(providerId)) {
268
- if (json) {
269
- writeJson(stdout, {
270
- error: `OAuth not supported for provider: ${providerId}`,
271
- });
272
- } else {
273
- writeText(
274
- stdout,
275
- `OAuth not supported for provider "${providerId}". Supported providers: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
276
- );
277
- }
278
- return;
279
- }
280
-
281
- try {
282
- const status = await statusForProvider(providerId, env);
283
-
284
- if (json) {
285
- writeJson(stdout, status);
286
- return;
287
- }
288
-
289
- if (status.error) {
290
- writeText(stdout, ` ${providerId}: ${status.error}`);
291
- return;
292
- }
293
-
294
- if (status.refreshed) {
295
- writeText(stdout, ` ${providerId}: token was expired, refreshed successfully`);
296
- } else {
297
- writeText(stdout, ` ${providerId}: token valid`);
298
- }
299
- } catch (err) {
300
- const msg = err instanceof Error ? err.message : String(err);
301
- if (json) {
302
- writeJson(stdout, { error: msg, provider: providerId });
303
- } else {
304
- writeText(stdout, ` ${providerId}: error checking status: ${msg}`);
305
- }
306
- }
307
- }
308
-
309
- async function handleLogin(
310
- stdout: { write(chunk: string): boolean | void },
311
- env: NodeJS.ProcessEnv,
312
- provider: string,
313
- opts: {
314
- json?: boolean;
315
- timeout?: string;
316
- clientId?: string;
317
- },
318
- ): Promise<void> {
319
- if (!isOAuthProvider(provider)) {
320
- if (opts.json) {
321
- writeJson(stdout, {
322
- error: `OAuth not supported for provider: ${provider}`,
323
- });
324
- } else {
325
- writeText(
326
- stdout,
327
- `OAuth not supported for "${provider}". Use one of: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
328
- );
329
- }
330
- return;
331
- }
332
-
333
- const config = PKCE_CONFIGS[provider];
334
- if (!config) return; // unreachable
335
-
336
- // Resolve client ID
337
- const clientId = opts.clientId ?? resolveClientId(provider, env);
338
-
339
- if (!clientId) {
340
- const hint = config.clientIdEnvVar
341
- ? `Set the ${config.clientIdEnvVar} environment variable.`
342
- : "A client ID is required. Register an OAuth app with the provider.";
343
- if (opts.json) {
344
- writeJson(stdout, {
345
- error: `Client ID required for ${provider} OAuth. ${hint}`,
346
- });
347
- } else {
348
- writeText(stdout, `Error: Client ID required for ${provider} OAuth.`);
349
- writeText(stdout, hint);
350
- }
351
- return;
352
- }
353
-
354
- const store = createStore(env);
355
- const timeoutMs = opts.timeout ? parseInt(opts.timeout, 10) : 5 * 60 * 1000;
356
-
357
- try {
358
- const result = await runOAuthLogin({
359
- provider,
360
- store,
361
- clientId,
362
- timeoutMs,
363
- openBrowser,
364
- onAuthorizeUrl(url) {
365
- writeText(stdout, "");
366
- writeText(stdout, `Opening browser to authorize OffRouter for ${provider}...`);
367
- writeText(stdout, "");
368
- writeText(stdout, `If the browser does not open, visit:`);
369
- writeText(stdout, ` ${url}`);
370
- writeText(stdout, "");
371
-
372
- const opened = openBrowser(url);
373
- if (!opened) {
374
- writeText(stdout, "(Could not open browser automatically. Copy and paste the URL above.)");
375
- }
376
- writeText(stdout, "Waiting for authorization...");
377
- },
378
- });
379
-
380
- if (opts.json) {
381
- writeJson(stdout, {
382
- provider: result.provider,
383
- ok: true,
384
- redirectUri: result.redirectUri,
385
- expiresAt: result.expiresAt,
386
- scope: result.scope,
387
- });
388
- } else {
389
- writeText(stdout, "");
390
- writeText(stdout, `✓ Login successful for ${provider}.`);
391
- writeText(stdout, ` Tokens stored in OffRouter secret store.`);
392
- if (result.expiresAt) {
393
- const expiresIn = Math.round((result.expiresAt - Date.now()) / 1000 / 60);
394
- writeText(stdout, ` Token expires in ${expiresIn} minutes.`);
395
- }
396
- writeText(stdout, "");
397
- }
398
- } catch (err) {
399
- const msg = err instanceof Error ? err.message : String(err);
400
-
401
- if (opts.json) {
402
- writeJson(stdout, {
403
- error: msg,
404
- provider,
405
- ok: false,
406
- });
407
- } else {
408
- writeText(stdout, "");
409
- writeText(stdout, `✗ Login failed for ${provider}: ${msg}`);
410
- writeText(stdout, "");
411
- }
412
- }
413
- }
@@ -1,35 +0,0 @@
1
- import type { Command } from "commander";
2
- import { runMcpCli } from "offrouter-mcp";
3
- import type { CommandContext } from "./init.js";
4
-
5
- export function registerMcpCommand(
6
- program: Command,
7
- ctx: () => CommandContext,
8
- ): void {
9
- const mcp = program
10
- .command("mcp")
11
- .description("OffRouter MCP stdio server bridges");
12
-
13
- mcp
14
- .command("serve")
15
- .description(
16
- "Start the OffRouter MCP stdio server (route/delegate/status tools)",
17
- )
18
- .action(async (_opts: unknown, command: Command) => {
19
- const { env, cwd, stdout, stderr } = ctx();
20
- // Forward only unknown/passthrough args after `serve`; Commander already
21
- // handled --help before invoking this action.
22
- const forwarded = command.args ?? [];
23
- const code = await runMcpCli(forwarded, {
24
- env,
25
- cwd,
26
- stdout,
27
- stderr,
28
- });
29
- if (code !== 0) {
30
- const err = new Error(`MCP server exited with code ${code}`);
31
- (err as { exitCode?: number }).exitCode = code;
32
- throw err;
33
- }
34
- });
35
- }