offrouter-cli 0.1.0 → 0.2.1
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 +28 -7
- 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 +19 -8
- package/dist/commands/status.js.map +1 -1
- package/package.json +9 -9
- package/src/cli.test.ts +128 -0
- package/src/commands/doctor.ts +36 -6
- 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 +26 -9
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "offrouter-cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": "0.
|
|
16
|
-
"offrouter-adapter-codex": "0.
|
|
17
|
-
"offrouter-adapter-gemini": "0.
|
|
18
|
-
"offrouter-adapter-grok": "0.
|
|
19
|
-
"offrouter-core": "0.
|
|
20
|
-
"offrouter-mcp": "0.
|
|
21
|
-
"commander": "
|
|
22
|
-
"zod": "
|
|
15
|
+
"offrouter-adapter-claude": "0.2.1",
|
|
16
|
+
"offrouter-adapter-codex": "0.2.1",
|
|
17
|
+
"offrouter-adapter-gemini": "0.2.1",
|
|
18
|
+
"offrouter-adapter-grok": "0.2.1",
|
|
19
|
+
"offrouter-core": "0.2.1",
|
|
20
|
+
"offrouter-mcp": "0.2.1",
|
|
21
|
+
"commander": "^12.0.0",
|
|
22
|
+
"zod": "^3.23.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,
|
|
@@ -335,6 +337,7 @@ export function registerDoctorCommand(
|
|
|
335
337
|
label: string;
|
|
336
338
|
nearLimit: boolean | null;
|
|
337
339
|
}[] = [];
|
|
340
|
+
const nearLimitWarnings: string[] = [];
|
|
338
341
|
try {
|
|
339
342
|
const config = await loadConfig({ env, workspaceDir: cwd });
|
|
340
343
|
policyConfig = config.policy;
|
|
@@ -349,14 +352,35 @@ export function registerDoctorCommand(
|
|
|
349
352
|
warnings: config.warnings.length,
|
|
350
353
|
warningDetails: redactConfigWarnings(config.warnings, env),
|
|
351
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
|
+
|
|
352
365
|
accountReadiness = Object.values(config.providers).flatMap((p) =>
|
|
353
|
-
p.accounts.map((a) =>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
+
}),
|
|
359
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
|
+
}
|
|
360
384
|
} catch (err) {
|
|
361
385
|
configError = formatConfigError(err);
|
|
362
386
|
const safeConfigError = redactConfigError(configError, env);
|
|
@@ -436,6 +460,7 @@ export function registerDoctorCommand(
|
|
|
436
460
|
config: configSummary,
|
|
437
461
|
configError,
|
|
438
462
|
accounts: accountReadiness,
|
|
463
|
+
...(nearLimitWarnings.length > 0 ? { nearLimitWarnings } : {}),
|
|
439
464
|
};
|
|
440
465
|
|
|
441
466
|
if (opts.json) {
|
|
@@ -465,6 +490,11 @@ export function registerDoctorCommand(
|
|
|
465
490
|
for (const a of accountReadiness) {
|
|
466
491
|
writeText(stdout, ` account ${a.providerId}/${a.accountId}: ${a.label}`);
|
|
467
492
|
}
|
|
493
|
+
if (nearLimitWarnings.length > 0) {
|
|
494
|
+
for (const warning of nearLimitWarnings) {
|
|
495
|
+
writeText(stdout, ` ! ${warning}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
468
498
|
writeText(
|
|
469
499
|
stdout,
|
|
470
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
|
+
});
|