@getrouter/getrouter-cli 0.1.1 → 0.1.3
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/.serena/project.yml +84 -0
- package/CLAUDE.md +52 -0
- package/biome.json +1 -1
- package/bun.lock +10 -10
- package/dist/bin.mjs +245 -94
- package/package.json +2 -2
- package/src/cli.ts +2 -1
- package/src/cmd/codex.ts +17 -7
- package/src/cmd/env.ts +1 -1
- package/src/cmd/keys.ts +46 -28
- package/src/cmd/models.ts +2 -1
- package/src/core/api/pagination.ts +25 -0
- package/src/core/api/providerModels.ts +32 -0
- package/src/core/auth/refresh.ts +68 -0
- package/src/core/config/fs.ts +33 -2
- package/src/core/config/index.ts +2 -8
- package/src/core/config/paths.ts +6 -3
- package/src/core/http/request.ts +71 -15
- package/src/core/http/retry.ts +68 -0
- package/src/core/interactive/codex.ts +21 -0
- package/src/core/interactive/keys.ts +19 -10
- package/src/core/output/usages.ts +11 -30
- package/src/core/setup/codex.ts +4 -0
- package/src/core/setup/env.ts +14 -6
- package/tests/auth/refresh.test.ts +149 -0
- package/tests/cmd/codex.test.ts +87 -1
- package/tests/cmd/keys.test.ts +48 -14
- package/tests/cmd/models.test.ts +5 -2
- package/tests/cmd/usages.test.ts +5 -5
- package/tests/config/fs.test.ts +22 -1
- package/tests/config/index.test.ts +16 -1
- package/tests/config/paths.test.ts +23 -0
- package/tests/core/api/pagination.test.ts +87 -0
- package/tests/core/interactive/codex.test.ts +25 -1
- package/tests/core/setup/env.test.ts +18 -4
- package/tests/http/request.test.ts +157 -0
- package/tests/http/retry.test.ts +152 -0
- package/tests/output/usages.test.ts +11 -12
- package/tsconfig.json +3 -2
- package/src/core/paths.ts +0 -4
- package/tests/paths.test.ts +0 -9
package/src/core/setup/codex.ts
CHANGED
|
@@ -45,6 +45,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
45
45
|
let firstHeaderIndex: number | null = null;
|
|
46
46
|
const rootFound = new Set<string>();
|
|
47
47
|
|
|
48
|
+
// Update root keys that appear before any section headers.
|
|
48
49
|
for (let i = 0; i < updated.length; i += 1) {
|
|
49
50
|
const headerMatch = matchHeader(updated[i] ?? "");
|
|
50
51
|
if (headerMatch) {
|
|
@@ -66,6 +67,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
// Insert missing root keys before the first section header (or at EOF).
|
|
69
71
|
const insertIndex = firstHeaderIndex ?? updated.length;
|
|
70
72
|
const missingRoot = ROOT_KEYS.filter((key) => !rootFound.has(key)).map(
|
|
71
73
|
(key) => `${key} = ${rootValueMap[key]}`,
|
|
@@ -76,6 +78,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
76
78
|
updated.splice(insertIndex, 0, ...missingRoot, ...(needsBlank ? [""] : []));
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
// Ensure the provider section exists and keep its keys in sync.
|
|
79
82
|
const providerHeader = `[${PROVIDER_SECTION}]`;
|
|
80
83
|
const providerHeaderIndex = updated.findIndex(
|
|
81
84
|
(line) => line.trim() === providerHeader,
|
|
@@ -91,6 +94,7 @@ export const mergeCodexToml = (content: string, input: CodexConfigInput) => {
|
|
|
91
94
|
return updated.join("\n");
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// Find the provider section bounds for in-place updates.
|
|
94
98
|
let providerEnd = updated.length;
|
|
95
99
|
for (let i = providerHeaderIndex + 1; i < updated.length; i += 1) {
|
|
96
100
|
if (matchHeader(updated[i] ?? "")) {
|
package/src/core/setup/env.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
export type EnvVars = {
|
|
@@ -14,11 +13,21 @@ export type EnvShell = "sh" | "ps1";
|
|
|
14
13
|
|
|
15
14
|
export type RcShell = "zsh" | "bash" | "fish" | "pwsh";
|
|
16
15
|
|
|
16
|
+
const quoteEnvValue = (shell: EnvShell, value: string) => {
|
|
17
|
+
if (shell === "ps1") {
|
|
18
|
+
// PowerShell: single quotes are literal; escape by doubling.
|
|
19
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// POSIX shell: use single quotes; escape embedded single quotes with: '\''.
|
|
23
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
24
|
+
};
|
|
25
|
+
|
|
17
26
|
const renderLine = (shell: EnvShell, key: string, value: string) => {
|
|
18
27
|
if (shell === "ps1") {
|
|
19
|
-
return `$env:${key}
|
|
28
|
+
return `$env:${key}=${quoteEnvValue(shell, value)}`;
|
|
20
29
|
}
|
|
21
|
-
return `export ${key}=${value}`;
|
|
30
|
+
return `export ${key}=${quoteEnvValue(shell, value)}`;
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
export const renderEnv = (shell: EnvShell, vars: EnvVars) => {
|
|
@@ -39,6 +48,7 @@ export const renderEnv = (shell: EnvShell, vars: EnvVars) => {
|
|
|
39
48
|
return lines.join("\n");
|
|
40
49
|
};
|
|
41
50
|
|
|
51
|
+
// Wrap getrouter to source env after successful codex/claude runs.
|
|
42
52
|
export const renderHook = (shell: RcShell) => {
|
|
43
53
|
if (shell === "pwsh") {
|
|
44
54
|
return [
|
|
@@ -126,6 +136,7 @@ export const writeEnvFile = (filePath: string, content: string) => {
|
|
|
126
136
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
127
137
|
fs.writeFileSync(filePath, content, "utf8");
|
|
128
138
|
if (process.platform !== "win32") {
|
|
139
|
+
// Limit env file readability since it can contain API keys.
|
|
129
140
|
fs.chmodSync(filePath, 0o600);
|
|
130
141
|
}
|
|
131
142
|
};
|
|
@@ -215,6 +226,3 @@ export const appendRcIfMissing = (rcPath: string, line: string) => {
|
|
|
215
226
|
fs.writeFileSync(rcPath, `${content}${prefix}${line}\n`, "utf8");
|
|
216
227
|
return true;
|
|
217
228
|
};
|
|
218
|
-
|
|
219
|
-
export const resolveConfigDir = () =>
|
|
220
|
-
process.env.GETROUTER_CONFIG_DIR || path.join(os.homedir(), ".getrouter");
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
ensureValidToken,
|
|
7
|
+
isTokenExpiringSoon,
|
|
8
|
+
refreshAccessToken,
|
|
9
|
+
} from "../../src/core/auth/refresh";
|
|
10
|
+
import { readAuth, writeAuth } from "../../src/core/config";
|
|
11
|
+
|
|
12
|
+
const makeDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
13
|
+
|
|
14
|
+
describe("isTokenExpiringSoon", () => {
|
|
15
|
+
it("returns true for empty string", () => {
|
|
16
|
+
expect(isTokenExpiringSoon("")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns true for invalid date", () => {
|
|
20
|
+
expect(isTokenExpiringSoon("not-a-date")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns true for expired token", () => {
|
|
24
|
+
const past = new Date(Date.now() - 10000).toISOString();
|
|
25
|
+
expect(isTokenExpiringSoon(past)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns true for token expiring within buffer", () => {
|
|
29
|
+
const soon = new Date(Date.now() + 30000).toISOString(); // 30 seconds
|
|
30
|
+
expect(isTokenExpiringSoon(soon)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns false for token with plenty of time", () => {
|
|
34
|
+
const future = new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 minutes
|
|
35
|
+
expect(isTokenExpiringSoon(future)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("refreshAccessToken", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
process.env.GETROUTER_CONFIG_DIR = makeDir();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns null when no refresh token", async () => {
|
|
45
|
+
writeAuth({
|
|
46
|
+
accessToken: "",
|
|
47
|
+
refreshToken: "",
|
|
48
|
+
expiresAt: "",
|
|
49
|
+
tokenType: "",
|
|
50
|
+
});
|
|
51
|
+
const result = await refreshAccessToken({});
|
|
52
|
+
expect(result).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns null when refresh fails", async () => {
|
|
56
|
+
writeAuth({
|
|
57
|
+
accessToken: "old",
|
|
58
|
+
refreshToken: "refresh",
|
|
59
|
+
expiresAt: "",
|
|
60
|
+
tokenType: "Bearer",
|
|
61
|
+
});
|
|
62
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 401,
|
|
65
|
+
});
|
|
66
|
+
const result = await refreshAccessToken({
|
|
67
|
+
fetchImpl: mockFetch as unknown as typeof fetch,
|
|
68
|
+
});
|
|
69
|
+
expect(result).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("refreshes and updates auth on success", async () => {
|
|
73
|
+
writeAuth({
|
|
74
|
+
accessToken: "old",
|
|
75
|
+
refreshToken: "refresh",
|
|
76
|
+
expiresAt: "",
|
|
77
|
+
tokenType: "Bearer",
|
|
78
|
+
});
|
|
79
|
+
const newToken = {
|
|
80
|
+
accessToken: "new-access",
|
|
81
|
+
refreshToken: "new-refresh",
|
|
82
|
+
expiresAt: "2026-12-01T00:00:00Z",
|
|
83
|
+
};
|
|
84
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
85
|
+
ok: true,
|
|
86
|
+
json: async () => newToken,
|
|
87
|
+
});
|
|
88
|
+
const result = await refreshAccessToken({
|
|
89
|
+
fetchImpl: mockFetch as unknown as typeof fetch,
|
|
90
|
+
});
|
|
91
|
+
expect(result).toEqual(newToken);
|
|
92
|
+
const saved = readAuth();
|
|
93
|
+
expect(saved.accessToken).toBe("new-access");
|
|
94
|
+
expect(saved.refreshToken).toBe("new-refresh");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("ensureValidToken", () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
process.env.GETROUTER_CONFIG_DIR = makeDir();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns false when no tokens", async () => {
|
|
104
|
+
writeAuth({
|
|
105
|
+
accessToken: "",
|
|
106
|
+
refreshToken: "",
|
|
107
|
+
expiresAt: "",
|
|
108
|
+
tokenType: "",
|
|
109
|
+
});
|
|
110
|
+
const result = await ensureValidToken({});
|
|
111
|
+
expect(result).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns true when token is still valid", async () => {
|
|
115
|
+
const future = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
116
|
+
writeAuth({
|
|
117
|
+
accessToken: "valid",
|
|
118
|
+
refreshToken: "refresh",
|
|
119
|
+
expiresAt: future,
|
|
120
|
+
tokenType: "Bearer",
|
|
121
|
+
});
|
|
122
|
+
const result = await ensureValidToken({});
|
|
123
|
+
expect(result).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("refreshes when token is expiring soon", async () => {
|
|
127
|
+
const soon = new Date(Date.now() + 10000).toISOString(); // 10 seconds
|
|
128
|
+
writeAuth({
|
|
129
|
+
accessToken: "expiring",
|
|
130
|
+
refreshToken: "refresh",
|
|
131
|
+
expiresAt: soon,
|
|
132
|
+
tokenType: "Bearer",
|
|
133
|
+
});
|
|
134
|
+
const newToken = {
|
|
135
|
+
accessToken: "new-access",
|
|
136
|
+
refreshToken: "new-refresh",
|
|
137
|
+
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
|
138
|
+
};
|
|
139
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
140
|
+
ok: true,
|
|
141
|
+
json: async () => newToken,
|
|
142
|
+
});
|
|
143
|
+
const result = await ensureValidToken({
|
|
144
|
+
fetchImpl: mockFetch as unknown as typeof fetch,
|
|
145
|
+
});
|
|
146
|
+
expect(result).toBe(true);
|
|
147
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
package/tests/cmd/codex.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import prompts from "prompts";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { createProgram } from "../../src/cli";
|
|
7
7
|
import { createApiClients } from "../../src/core/api/client";
|
|
8
8
|
import type { ConsumerService } from "../../src/generated/router/dashboard/v1";
|
|
@@ -32,6 +32,7 @@ const originalEnv = Object.fromEntries(
|
|
|
32
32
|
);
|
|
33
33
|
|
|
34
34
|
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals();
|
|
35
36
|
setStdinTTY(originalIsTTY);
|
|
36
37
|
prompts.inject([]);
|
|
37
38
|
for (const key of ENV_KEYS) {
|
|
@@ -44,6 +45,57 @@ afterEach(() => {
|
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
describe("codex command", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.stubGlobal(
|
|
50
|
+
"fetch",
|
|
51
|
+
vi.fn().mockResolvedValue({
|
|
52
|
+
ok: true,
|
|
53
|
+
status: 200,
|
|
54
|
+
json: vi.fn().mockResolvedValue({ models: [] }),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("fetches codex models from remote endpoint", async () => {
|
|
60
|
+
setStdinTTY(true);
|
|
61
|
+
const dir = makeDir();
|
|
62
|
+
process.env.HOME = dir;
|
|
63
|
+
|
|
64
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
65
|
+
ok: true,
|
|
66
|
+
status: 200,
|
|
67
|
+
json: vi.fn().mockResolvedValue({
|
|
68
|
+
models: ["gpt-5.2-codex"],
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
72
|
+
|
|
73
|
+
prompts.inject(["gpt-5.2-codex", "extra_high", mockConsumer, true]);
|
|
74
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
75
|
+
consumerService: {
|
|
76
|
+
ListConsumers: vi.fn().mockResolvedValue({
|
|
77
|
+
consumers: [
|
|
78
|
+
{
|
|
79
|
+
id: "c1",
|
|
80
|
+
name: "dev",
|
|
81
|
+
enabled: true,
|
|
82
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
|
|
87
|
+
} as unknown as ConsumerService,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const program = createProgram();
|
|
91
|
+
await program.parseAsync(["node", "getrouter", "codex"]);
|
|
92
|
+
|
|
93
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
94
|
+
expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toContain(
|
|
95
|
+
"/v1/dashboard/providers/models?tag=codex",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
47
99
|
it("writes codex config and auth after interactive flow", async () => {
|
|
48
100
|
setStdinTTY(true);
|
|
49
101
|
const dir = makeDir();
|
|
@@ -144,4 +196,38 @@ describe("codex command", () => {
|
|
|
144
196
|
expect(auth.OPENAI_API_KEY).toBe("key-123");
|
|
145
197
|
expect(auth.OTHER).toBe("keep");
|
|
146
198
|
});
|
|
199
|
+
|
|
200
|
+
it("supports -m to set a custom model", async () => {
|
|
201
|
+
setStdinTTY(true);
|
|
202
|
+
const dir = makeDir();
|
|
203
|
+
process.env.HOME = dir;
|
|
204
|
+
prompts.inject(["extra_high", mockConsumer, true]);
|
|
205
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
206
|
+
consumerService: {
|
|
207
|
+
ListConsumers: vi.fn().mockResolvedValue({
|
|
208
|
+
consumers: [
|
|
209
|
+
{
|
|
210
|
+
id: "c1",
|
|
211
|
+
name: "dev",
|
|
212
|
+
enabled: true,
|
|
213
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
}),
|
|
217
|
+
GetConsumer: vi.fn().mockResolvedValue(mockConsumer),
|
|
218
|
+
} as unknown as ConsumerService,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const program = createProgram();
|
|
222
|
+
await program.parseAsync([
|
|
223
|
+
"node",
|
|
224
|
+
"getrouter",
|
|
225
|
+
"codex",
|
|
226
|
+
"-m",
|
|
227
|
+
"legacy-model",
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
const config = fs.readFileSync(codexConfigPath(dir), "utf8");
|
|
231
|
+
expect(config).toContain('model = "legacy-model"');
|
|
232
|
+
});
|
|
147
233
|
});
|
package/tests/cmd/keys.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ afterEach(() => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
describe("keys command", () => {
|
|
41
|
-
it("lists keys
|
|
41
|
+
it("lists keys with redacted apiKey by default", async () => {
|
|
42
42
|
setStdinTTY(false);
|
|
43
43
|
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
44
44
|
consumerService: {
|
|
@@ -55,6 +55,24 @@ describe("keys command", () => {
|
|
|
55
55
|
expect(output).toContain("NAME");
|
|
56
56
|
expect(output).not.toContain("ID");
|
|
57
57
|
expect(output).toContain("abcd...WXYZ");
|
|
58
|
+
expect(output).not.toContain("abcd1234WXYZ");
|
|
59
|
+
log.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("lists keys with full apiKey when --show is provided", async () => {
|
|
63
|
+
setStdinTTY(false);
|
|
64
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
65
|
+
consumerService: {
|
|
66
|
+
ListConsumers: vi.fn().mockResolvedValue({ consumers: [mockConsumer] }),
|
|
67
|
+
} as unknown as ConsumerService,
|
|
68
|
+
subscriptionService: emptySubscriptionService,
|
|
69
|
+
authService: emptyAuthService,
|
|
70
|
+
});
|
|
71
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
72
|
+
const program = createProgram();
|
|
73
|
+
await program.parseAsync(["node", "getrouter", "keys", "list", "--show"]);
|
|
74
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
75
|
+
expect(output).toContain("abcd1234WXYZ");
|
|
58
76
|
log.mockRestore();
|
|
59
77
|
});
|
|
60
78
|
|
|
@@ -74,6 +92,24 @@ describe("keys command", () => {
|
|
|
74
92
|
expect(output).toContain("NAME");
|
|
75
93
|
expect(output).not.toContain("ID");
|
|
76
94
|
expect(output).toContain("abcd...WXYZ");
|
|
95
|
+
expect(output).not.toContain("abcd1234WXYZ");
|
|
96
|
+
log.mockRestore();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("lists keys when no subcommand and --show is provided", async () => {
|
|
100
|
+
setStdinTTY(true);
|
|
101
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
102
|
+
consumerService: {
|
|
103
|
+
ListConsumers: vi.fn().mockResolvedValue({ consumers: [mockConsumer] }),
|
|
104
|
+
} as unknown as ConsumerService,
|
|
105
|
+
subscriptionService: emptySubscriptionService,
|
|
106
|
+
authService: emptyAuthService,
|
|
107
|
+
});
|
|
108
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
109
|
+
const program = createProgram();
|
|
110
|
+
await program.parseAsync(["node", "getrouter", "keys", "--show"]);
|
|
111
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
112
|
+
expect(output).toContain("abcd1234WXYZ");
|
|
77
113
|
log.mockRestore();
|
|
78
114
|
});
|
|
79
115
|
|
|
@@ -81,20 +117,18 @@ describe("keys command", () => {
|
|
|
81
117
|
setStdinTTY(false);
|
|
82
118
|
const program = createProgram();
|
|
83
119
|
program.exitOverride();
|
|
84
|
-
const
|
|
85
|
-
.spyOn(process.stderr, "write")
|
|
86
|
-
.mockImplementation(() => true);
|
|
87
|
-
program.configureOutput({
|
|
120
|
+
const silentOutput = {
|
|
88
121
|
writeErr: () => {},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
122
|
+
writeOut: () => {},
|
|
123
|
+
outputError: () => {},
|
|
124
|
+
};
|
|
125
|
+
program.configureOutput(silentOutput);
|
|
126
|
+
program.commands
|
|
127
|
+
.find((command) => command.name() === "keys")
|
|
128
|
+
?.configureOutput(silentOutput);
|
|
129
|
+
await expect(
|
|
130
|
+
program.parseAsync(["node", "getrouter", "keys", "get", "c1"]),
|
|
131
|
+
).rejects.toBeTruthy();
|
|
98
132
|
});
|
|
99
133
|
|
|
100
134
|
it("creates a key and prints reminder", async () => {
|
package/tests/cmd/models.test.ts
CHANGED
|
@@ -39,8 +39,10 @@ describe("models command", () => {
|
|
|
39
39
|
const program = createProgram();
|
|
40
40
|
await program.parseAsync(["node", "getrouter", "models", "list"]);
|
|
41
41
|
const output = log.mock.calls.map((call) => call[0]).join("\n");
|
|
42
|
+
expect(output).toContain("ID");
|
|
42
43
|
expect(output).toContain("NAME");
|
|
43
|
-
expect(output).
|
|
44
|
+
expect(output).toContain("gpt-5");
|
|
45
|
+
expect(output).toContain("GPT-5");
|
|
44
46
|
log.mockRestore();
|
|
45
47
|
});
|
|
46
48
|
|
|
@@ -55,8 +57,9 @@ describe("models command", () => {
|
|
|
55
57
|
const program = createProgram();
|
|
56
58
|
await program.parseAsync(["node", "getrouter", "models"]);
|
|
57
59
|
const output = log.mock.calls.map((call) => call[0]).join("\n");
|
|
60
|
+
expect(output).toContain("ID");
|
|
58
61
|
expect(output).toContain("NAME");
|
|
59
|
-
expect(output).
|
|
62
|
+
expect(output).toContain("gpt-5");
|
|
60
63
|
expect(output).toContain("GPT-5");
|
|
61
64
|
log.mockRestore();
|
|
62
65
|
});
|
package/tests/cmd/usages.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ vi.mock("../../src/core/api/client", () => ({
|
|
|
7
7
|
}));
|
|
8
8
|
|
|
9
9
|
describe("usages command", () => {
|
|
10
|
-
it("prints chart
|
|
10
|
+
it("prints chart with total tokens only", async () => {
|
|
11
11
|
const listUsage = vi.fn().mockResolvedValue({
|
|
12
12
|
usages: [
|
|
13
13
|
{
|
|
@@ -32,11 +32,11 @@ describe("usages command", () => {
|
|
|
32
32
|
expect(log).toHaveBeenCalledTimes(1);
|
|
33
33
|
const output = String(log.mock.calls[0][0] ?? "");
|
|
34
34
|
expect(output).toContain("📊 Usage (last 7 days)");
|
|
35
|
-
expect(output).toContain("
|
|
36
|
-
expect(output).toContain("
|
|
37
|
-
expect(output).toContain("
|
|
35
|
+
expect(output).toContain("2K");
|
|
36
|
+
expect(output).not.toContain("I:");
|
|
37
|
+
expect(output).not.toContain("O:");
|
|
38
|
+
expect(output).not.toContain("Legend");
|
|
38
39
|
expect(output).not.toContain("DAY");
|
|
39
|
-
expect(output).not.toContain("O:0179");
|
|
40
40
|
log.mockRestore();
|
|
41
41
|
});
|
|
42
42
|
});
|
package/tests/config/fs.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { readJsonFile, writeJsonFile } from "../../src/core/config/fs";
|
|
6
6
|
|
|
7
7
|
describe("config fs", () => {
|
|
@@ -11,4 +11,25 @@ describe("config fs", () => {
|
|
|
11
11
|
writeJsonFile(file, { hello: "world" });
|
|
12
12
|
expect(readJsonFile(file)).toEqual({ hello: "world" });
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
it("tolerates invalid JSON by returning null and backing up the file", () => {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
17
|
+
const file = path.join(dir, "config.json");
|
|
18
|
+
fs.writeFileSync(file, "{", "utf8");
|
|
19
|
+
|
|
20
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
21
|
+
try {
|
|
22
|
+
expect(readJsonFile(file)).toBeNull();
|
|
23
|
+
} finally {
|
|
24
|
+
warnSpy.mockRestore();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
28
|
+
const backups = fs
|
|
29
|
+
.readdirSync(dir)
|
|
30
|
+
.filter(
|
|
31
|
+
(name) => name.startsWith("config.corrupt-") && name.endsWith(".json"),
|
|
32
|
+
);
|
|
33
|
+
expect(backups.length).toBe(1);
|
|
34
|
+
});
|
|
14
35
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import {
|
|
6
6
|
readAuth,
|
|
7
7
|
readConfig,
|
|
@@ -34,6 +34,21 @@ describe("config read/write", () => {
|
|
|
34
34
|
expect(auth.expiresAt).toBe("c");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it("falls back to defaults when config.json is invalid JSON", () => {
|
|
38
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
39
|
+
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
40
|
+
fs.writeFileSync(path.join(dir, "config.json"), "{", "utf8");
|
|
41
|
+
|
|
42
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
43
|
+
try {
|
|
44
|
+
const cfg = readConfig();
|
|
45
|
+
expect(cfg.apiBase).toBe("https://getrouter.dev");
|
|
46
|
+
expect(cfg.json).toBe(false);
|
|
47
|
+
} finally {
|
|
48
|
+
warnSpy.mockRestore();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
37
52
|
it("defaults tokenType to Bearer", () => {
|
|
38
53
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
39
54
|
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
@@ -1,10 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { describe, expect, it } from "vitest";
|
|
2
5
|
import { getAuthPath, getConfigPath } from "../../src/core/config/paths";
|
|
3
6
|
|
|
4
7
|
describe("config paths", () => {
|
|
8
|
+
const originalConfigDir = process.env.GETROUTER_CONFIG_DIR;
|
|
9
|
+
|
|
10
|
+
const restore = () => {
|
|
11
|
+
if (originalConfigDir === undefined) {
|
|
12
|
+
delete process.env.GETROUTER_CONFIG_DIR;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
process.env.GETROUTER_CONFIG_DIR = originalConfigDir;
|
|
16
|
+
};
|
|
17
|
+
|
|
5
18
|
it("returns ~/.getrouter paths", () => {
|
|
19
|
+
delete process.env.GETROUTER_CONFIG_DIR;
|
|
6
20
|
expect(getConfigPath()).toContain(".getrouter");
|
|
7
21
|
expect(getConfigPath()).toContain("config.json");
|
|
8
22
|
expect(getAuthPath()).toContain("auth.json");
|
|
23
|
+
restore();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("uses GETROUTER_CONFIG_DIR when set", () => {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "getrouter-"));
|
|
28
|
+
process.env.GETROUTER_CONFIG_DIR = dir;
|
|
29
|
+
expect(getConfigPath()).toBe(path.join(dir, "config.json"));
|
|
30
|
+
expect(getAuthPath()).toBe(path.join(dir, "auth.json"));
|
|
31
|
+
restore();
|
|
9
32
|
});
|
|
10
33
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fetchAllPages } from "../../../src/core/api/pagination";
|
|
3
|
+
|
|
4
|
+
type PageResponse = {
|
|
5
|
+
items: number[];
|
|
6
|
+
nextPageToken: string | undefined;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("fetchAllPages", () => {
|
|
10
|
+
it("fetches single page when no next token", async () => {
|
|
11
|
+
const fetchPage = vi.fn().mockResolvedValue({
|
|
12
|
+
items: [1, 2, 3],
|
|
13
|
+
nextPageToken: undefined,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const result = await fetchAllPages<PageResponse, number>(
|
|
17
|
+
fetchPage,
|
|
18
|
+
(res) => res.items,
|
|
19
|
+
(res) => res.nextPageToken,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(result).toEqual([1, 2, 3]);
|
|
23
|
+
expect(fetchPage).toHaveBeenCalledTimes(1);
|
|
24
|
+
expect(fetchPage).toHaveBeenCalledWith(undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("fetches multiple pages", async () => {
|
|
28
|
+
const fetchPage = vi
|
|
29
|
+
.fn()
|
|
30
|
+
.mockResolvedValueOnce({
|
|
31
|
+
items: [1, 2],
|
|
32
|
+
nextPageToken: "page2",
|
|
33
|
+
})
|
|
34
|
+
.mockResolvedValueOnce({
|
|
35
|
+
items: [3, 4],
|
|
36
|
+
nextPageToken: "page3",
|
|
37
|
+
})
|
|
38
|
+
.mockResolvedValueOnce({
|
|
39
|
+
items: [5],
|
|
40
|
+
nextPageToken: undefined,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const result = await fetchAllPages<PageResponse, number>(
|
|
44
|
+
fetchPage,
|
|
45
|
+
(res) => res.items,
|
|
46
|
+
(res) => res.nextPageToken,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual([1, 2, 3, 4, 5]);
|
|
50
|
+
expect(fetchPage).toHaveBeenCalledTimes(3);
|
|
51
|
+
expect(fetchPage).toHaveBeenNthCalledWith(1, undefined);
|
|
52
|
+
expect(fetchPage).toHaveBeenNthCalledWith(2, "page2");
|
|
53
|
+
expect(fetchPage).toHaveBeenNthCalledWith(3, "page3");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns empty array when first page is empty", async () => {
|
|
57
|
+
const fetchPage = vi.fn().mockResolvedValue({
|
|
58
|
+
items: [],
|
|
59
|
+
nextPageToken: undefined,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await fetchAllPages<PageResponse, number>(
|
|
63
|
+
fetchPage,
|
|
64
|
+
(res) => res.items,
|
|
65
|
+
(res) => res.nextPageToken,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result).toEqual([]);
|
|
69
|
+
expect(fetchPage).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stops when nextPageToken is empty string", async () => {
|
|
73
|
+
const fetchPage = vi.fn().mockResolvedValueOnce({
|
|
74
|
+
items: [1],
|
|
75
|
+
nextPageToken: "",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const result = await fetchAllPages<PageResponse, number>(
|
|
79
|
+
fetchPage,
|
|
80
|
+
(res) => res.items,
|
|
81
|
+
(res) => res.nextPageToken || undefined,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual([1]);
|
|
85
|
+
expect(fetchPage).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
});
|