offrouter-cli 0.0.0 → 0.2.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.
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +34 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +288 -16
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/route.d.ts.map +1 -1
- package/dist/commands/route.js +22 -2
- package/dist/commands/route.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +23 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/index.js +0 -0
- package/package.json +9 -9
- package/src/cli.test.ts +128 -0
- package/src/commands/doctor.ts +52 -0
- package/src/commands/login.test.ts +247 -0
- package/src/commands/login.ts +397 -17
- package/src/commands/route.ts +31 -1
- package/src/commands/status.ts +30 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"status.js","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "offrouter-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"offrouter": "dist/index.js"
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "vitest run"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"offrouter-adapter-claude": "
|
|
16
|
-
"offrouter-adapter-codex": "
|
|
17
|
-
"offrouter-adapter-gemini": "
|
|
18
|
-
"offrouter-adapter-grok": "
|
|
19
|
-
"offrouter-core": "
|
|
20
|
-
"offrouter-mcp": "
|
|
21
|
-
"commander": "
|
|
22
|
-
"zod": "
|
|
15
|
+
"offrouter-adapter-claude": "0.0.0",
|
|
16
|
+
"offrouter-adapter-codex": "0.0.0",
|
|
17
|
+
"offrouter-adapter-gemini": "0.0.0",
|
|
18
|
+
"offrouter-adapter-grok": "0.0.0",
|
|
19
|
+
"offrouter-core": "0.0.0",
|
|
20
|
+
"offrouter-mcp": "0.0.0",
|
|
21
|
+
"commander": "0.0.0",
|
|
22
|
+
"zod": "0.0.0"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { FileUsageStore } from "offrouter-core";
|
|
6
7
|
import { createProgram, runCli } from "./index.js";
|
|
7
8
|
|
|
8
9
|
async function capture(
|
|
@@ -1574,4 +1575,131 @@ describe("offrouter CLI", () => {
|
|
|
1574
1575
|
const program = createProgram();
|
|
1575
1576
|
expect(program.name()).toBe("offrouter");
|
|
1576
1577
|
});
|
|
1578
|
+
|
|
1579
|
+
it("offrouter status --providers --json shows real quotaRemaining from FileUsageStore", async () => {
|
|
1580
|
+
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-status-usage-"));
|
|
1581
|
+
try {
|
|
1582
|
+
await writeFile(
|
|
1583
|
+
join(home, "config.toml"),
|
|
1584
|
+
[
|
|
1585
|
+
'allowlisted_profiles = ["claude-personal"]',
|
|
1586
|
+
"",
|
|
1587
|
+
"[providers.anthropic]",
|
|
1588
|
+
"enabled = true",
|
|
1589
|
+
'accounts = [{ id = "my-account" }]',
|
|
1590
|
+
"",
|
|
1591
|
+
].join("\n"),
|
|
1592
|
+
"utf8",
|
|
1593
|
+
);
|
|
1594
|
+
const usageStore = new FileUsageStore({
|
|
1595
|
+
filePath: join(home, "state", "usage.json"),
|
|
1596
|
+
});
|
|
1597
|
+
await usageStore.recordUsage("anthropic", "my-account", {
|
|
1598
|
+
used: 85,
|
|
1599
|
+
limit: 100,
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
const { code, stdout } = await capture(
|
|
1603
|
+
["status", "--providers", "--json"],
|
|
1604
|
+
{ ...process.env, OFFROUTER_HOME: home },
|
|
1605
|
+
);
|
|
1606
|
+
expect(code).toBe(0);
|
|
1607
|
+
const json = JSON.parse(stdout) as {
|
|
1608
|
+
providers: Array<{ accounts: Array<{ quotaRemaining: number | null }> }>;
|
|
1609
|
+
};
|
|
1610
|
+
const account = json.providers?.[0]?.accounts?.[0];
|
|
1611
|
+
expect(account).toBeDefined();
|
|
1612
|
+
expect(account!.quotaRemaining).toBe(15);
|
|
1613
|
+
} finally {
|
|
1614
|
+
await rm(home, { recursive: true, force: true });
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
it("offrouter doctor --json includes nearLimitWarnings when accounts are near-limit", async () => {
|
|
1619
|
+
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-doctor-near-"));
|
|
1620
|
+
try {
|
|
1621
|
+
await writeFile(
|
|
1622
|
+
join(home, "config.toml"),
|
|
1623
|
+
[
|
|
1624
|
+
'allowlisted_profiles = ["claude-personal"]',
|
|
1625
|
+
"",
|
|
1626
|
+
"[providers.anthropic]",
|
|
1627
|
+
"enabled = true",
|
|
1628
|
+
'accounts = [{ id = "my-account" }]',
|
|
1629
|
+
"",
|
|
1630
|
+
].join("\n"),
|
|
1631
|
+
"utf8",
|
|
1632
|
+
);
|
|
1633
|
+
const usageStore = new FileUsageStore({
|
|
1634
|
+
filePath: join(home, "state", "usage.json"),
|
|
1635
|
+
});
|
|
1636
|
+
await usageStore.recordUsage("anthropic", "my-account", {
|
|
1637
|
+
used: 90,
|
|
1638
|
+
limit: 100,
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
const { code, stdout } = await capture(
|
|
1642
|
+
["doctor", "--profile", "claude-personal", "--json"],
|
|
1643
|
+
{ ...process.env, OFFROUTER_HOME: home },
|
|
1644
|
+
);
|
|
1645
|
+
expect(code).toBe(0);
|
|
1646
|
+
const json = JSON.parse(stdout) as {
|
|
1647
|
+
nearLimitWarnings?: string[];
|
|
1648
|
+
accounts: Array<{ nearLimit: boolean | null }>;
|
|
1649
|
+
};
|
|
1650
|
+
expect(json.nearLimitWarnings).toBeDefined();
|
|
1651
|
+
expect(json.nearLimitWarnings!.length).toBeGreaterThan(0);
|
|
1652
|
+
expect(json.nearLimitWarnings![0]).toMatch(/near its limit/i);
|
|
1653
|
+
expect(json.accounts?.[0]?.nearLimit).toBe(true);
|
|
1654
|
+
} finally {
|
|
1655
|
+
await rm(home, { recursive: true, force: true });
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
it("offrouter route explain --json includes nearLimitWarning when an account is near-limit", async () => {
|
|
1660
|
+
const home = await mkdtemp(join(tmpdir(), "offrouter-cli-route-near-"));
|
|
1661
|
+
try {
|
|
1662
|
+
await writeFile(
|
|
1663
|
+
join(home, "config.toml"),
|
|
1664
|
+
[
|
|
1665
|
+
'allowlisted_profiles = ["claude-personal"]',
|
|
1666
|
+
"",
|
|
1667
|
+
"[providers.anthropic]",
|
|
1668
|
+
"enabled = true",
|
|
1669
|
+
'accounts = [{ id = "my-account" }]',
|
|
1670
|
+
"",
|
|
1671
|
+
].join("\n"),
|
|
1672
|
+
"utf8",
|
|
1673
|
+
);
|
|
1674
|
+
const usageStore = new FileUsageStore({
|
|
1675
|
+
filePath: join(home, "state", "usage.json"),
|
|
1676
|
+
});
|
|
1677
|
+
await usageStore.recordUsage("anthropic", "my-account", {
|
|
1678
|
+
used: 95,
|
|
1679
|
+
limit: 100,
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
const { code, stdout } = await capture(
|
|
1683
|
+
[
|
|
1684
|
+
"route",
|
|
1685
|
+
"explain",
|
|
1686
|
+
"explain this repo",
|
|
1687
|
+
"--profile",
|
|
1688
|
+
"claude-personal",
|
|
1689
|
+
"--json",
|
|
1690
|
+
],
|
|
1691
|
+
{ ...process.env, OFFROUTER_HOME: home },
|
|
1692
|
+
);
|
|
1693
|
+
expect(code).toBe(0);
|
|
1694
|
+
const json = JSON.parse(stdout) as {
|
|
1695
|
+
nearLimitWarning?: string;
|
|
1696
|
+
blocked: boolean;
|
|
1697
|
+
};
|
|
1698
|
+
// The route is blocked (no candidates), but the warning should be present.
|
|
1699
|
+
expect(json.nearLimitWarning).toBeDefined();
|
|
1700
|
+
expect(json.nearLimitWarning).toMatch(/near its limit/i);
|
|
1701
|
+
} finally {
|
|
1702
|
+
await rm(home, { recursive: true, force: true });
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1577
1705
|
});
|
package/src/commands/doctor.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { delimiter } from "node:path";
|
|
|
4
4
|
import { dirname, join, normalize } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
evaluatePolicy,
|
|
7
|
+
FileUsageStore,
|
|
7
8
|
loadConfig,
|
|
8
9
|
redact,
|
|
10
|
+
resolveOffRouterHome,
|
|
9
11
|
routeRequest,
|
|
10
12
|
type ConfigWarning,
|
|
11
13
|
type HarnessKind,
|
|
@@ -329,6 +331,13 @@ export function registerDoctorCommand(
|
|
|
329
331
|
};
|
|
330
332
|
let configError: CliConfigError | null = null;
|
|
331
333
|
let policyConfig: PolicyConfig;
|
|
334
|
+
let accountReadiness: {
|
|
335
|
+
providerId: string;
|
|
336
|
+
accountId: string;
|
|
337
|
+
label: string;
|
|
338
|
+
nearLimit: boolean | null;
|
|
339
|
+
}[] = [];
|
|
340
|
+
const nearLimitWarnings: string[] = [];
|
|
332
341
|
try {
|
|
333
342
|
const config = await loadConfig({ env, workspaceDir: cwd });
|
|
334
343
|
policyConfig = config.policy;
|
|
@@ -343,6 +352,35 @@ export function registerDoctorCommand(
|
|
|
343
352
|
warnings: config.warnings.length,
|
|
344
353
|
warningDetails: redactConfigWarnings(config.warnings, env),
|
|
345
354
|
};
|
|
355
|
+
|
|
356
|
+
// Read persistent usage data for near-limit detection.
|
|
357
|
+
const usageStore = new FileUsageStore({
|
|
358
|
+
filePath: join(resolveOffRouterHome({ env }), "state", "usage.json"),
|
|
359
|
+
});
|
|
360
|
+
const allUsage = await usageStore.listAccountUsage();
|
|
361
|
+
const usageByKey = new Map(
|
|
362
|
+
allUsage.map((u) => [`${u.providerId}:${u.accountId}`, u]),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
accountReadiness = Object.values(config.providers).flatMap((p) =>
|
|
366
|
+
p.accounts.map((a) => {
|
|
367
|
+
const snap = usageByKey.get(`${p.id}:${a.id}`);
|
|
368
|
+
return {
|
|
369
|
+
providerId: p.id,
|
|
370
|
+
accountId: a.id,
|
|
371
|
+
label: a.label ?? a.id,
|
|
372
|
+
nearLimit: snap?.nearLimit ?? null,
|
|
373
|
+
};
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
for (const a of accountReadiness) {
|
|
378
|
+
if (a.nearLimit) {
|
|
379
|
+
nearLimitWarnings.push(
|
|
380
|
+
`Account ${a.providerId}/${a.accountId} is near its limit (${a.label})`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
346
384
|
} catch (err) {
|
|
347
385
|
configError = formatConfigError(err);
|
|
348
386
|
const safeConfigError = redactConfigError(configError, env);
|
|
@@ -421,6 +459,8 @@ export function registerDoctorCommand(
|
|
|
421
459
|
lastProviderError: detection.lastProviderError,
|
|
422
460
|
config: configSummary,
|
|
423
461
|
configError,
|
|
462
|
+
accounts: accountReadiness,
|
|
463
|
+
...(nearLimitWarnings.length > 0 ? { nearLimitWarnings } : {}),
|
|
424
464
|
};
|
|
425
465
|
|
|
426
466
|
if (opts.json) {
|
|
@@ -443,6 +483,18 @@ export function registerDoctorCommand(
|
|
|
443
483
|
`- Config warning [${warning.path}]: ${warning.message}`,
|
|
444
484
|
);
|
|
445
485
|
}
|
|
486
|
+
writeText(
|
|
487
|
+
stdout,
|
|
488
|
+
`Accounts: ${accountReadiness.length} configured${accountReadiness.length > 0 ? "" : " (none)"}`,
|
|
489
|
+
);
|
|
490
|
+
for (const a of accountReadiness) {
|
|
491
|
+
writeText(stdout, ` account ${a.providerId}/${a.accountId}: ${a.label}`);
|
|
492
|
+
}
|
|
493
|
+
if (nearLimitWarnings.length > 0) {
|
|
494
|
+
for (const warning of nearLimitWarnings) {
|
|
495
|
+
writeText(stdout, ` ! ${warning}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
446
498
|
writeText(
|
|
447
499
|
stdout,
|
|
448
500
|
`Selected profile: ${profile.profile} - ${profile.status}`,
|
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
});
|