@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.
Files changed (41) hide show
  1. package/.serena/project.yml +84 -0
  2. package/CLAUDE.md +52 -0
  3. package/biome.json +1 -1
  4. package/bun.lock +10 -10
  5. package/dist/bin.mjs +245 -94
  6. package/package.json +2 -2
  7. package/src/cli.ts +2 -1
  8. package/src/cmd/codex.ts +17 -7
  9. package/src/cmd/env.ts +1 -1
  10. package/src/cmd/keys.ts +46 -28
  11. package/src/cmd/models.ts +2 -1
  12. package/src/core/api/pagination.ts +25 -0
  13. package/src/core/api/providerModels.ts +32 -0
  14. package/src/core/auth/refresh.ts +68 -0
  15. package/src/core/config/fs.ts +33 -2
  16. package/src/core/config/index.ts +2 -8
  17. package/src/core/config/paths.ts +6 -3
  18. package/src/core/http/request.ts +71 -15
  19. package/src/core/http/retry.ts +68 -0
  20. package/src/core/interactive/codex.ts +21 -0
  21. package/src/core/interactive/keys.ts +19 -10
  22. package/src/core/output/usages.ts +11 -30
  23. package/src/core/setup/codex.ts +4 -0
  24. package/src/core/setup/env.ts +14 -6
  25. package/tests/auth/refresh.test.ts +149 -0
  26. package/tests/cmd/codex.test.ts +87 -1
  27. package/tests/cmd/keys.test.ts +48 -14
  28. package/tests/cmd/models.test.ts +5 -2
  29. package/tests/cmd/usages.test.ts +5 -5
  30. package/tests/config/fs.test.ts +22 -1
  31. package/tests/config/index.test.ts +16 -1
  32. package/tests/config/paths.test.ts +23 -0
  33. package/tests/core/api/pagination.test.ts +87 -0
  34. package/tests/core/interactive/codex.test.ts +25 -1
  35. package/tests/core/setup/env.test.ts +18 -4
  36. package/tests/http/request.test.ts +157 -0
  37. package/tests/http/retry.test.ts +152 -0
  38. package/tests/output/usages.test.ts +11 -12
  39. package/tsconfig.json +3 -2
  40. package/src/core/paths.ts +0 -4
  41. package/tests/paths.test.ts +0 -9
@@ -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] ?? "")) {
@@ -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}="${value}"`;
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
+ });
@@ -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
  });
@@ -38,7 +38,7 @@ afterEach(() => {
38
38
  });
39
39
 
40
40
  describe("keys command", () => {
41
- it("lists keys and redacts apiKey", async () => {
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 writeErr = vi
85
- .spyOn(process.stderr, "write")
86
- .mockImplementation(() => true);
87
- program.configureOutput({
120
+ const silentOutput = {
88
121
  writeErr: () => {},
89
- });
90
- try {
91
- await expect(
92
- program.parseAsync(["node", "getrouter", "keys", "get", "c1"]),
93
- ).rejects.toBeTruthy();
94
- expect(writeErr).not.toHaveBeenCalled();
95
- } finally {
96
- writeErr.mockRestore();
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 () => {
@@ -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).not.toContain("ID");
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).not.toContain("ID");
62
+ expect(output).toContain("gpt-5");
60
63
  expect(output).toContain("GPT-5");
61
64
  log.mockRestore();
62
65
  });
@@ -7,7 +7,7 @@ vi.mock("../../src/core/api/client", () => ({
7
7
  }));
8
8
 
9
9
  describe("usages command", () => {
10
- it("prints chart and table", async () => {
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("I:1K");
36
- expect(output).toContain("O:1K");
37
- expect(output).toContain("Legend:");
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
  });
@@ -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
+ });