@getrouter/getrouter-cli 0.1.0 → 0.1.2

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/src/cmd/keys.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Command } from "commander";
2
2
  import { createApiClients } from "../core/api/client";
3
- import { redactSecrets } from "../core/config/redact";
3
+ import { fetchAllPages } from "../core/api/pagination";
4
4
  import {
5
5
  confirmDelete,
6
6
  promptKeyEnabled,
@@ -32,17 +32,16 @@ const consumerRow = (consumer: ConsumerLike) => [
32
32
  ];
33
33
 
34
34
  const outputConsumerTable = (consumer: ConsumerLike) => {
35
- console.log(renderTable(consumerHeaders, [consumerRow(consumer)]));
35
+ console.log(
36
+ renderTable(consumerHeaders, [consumerRow(consumer)], { maxColWidth: 64 }),
37
+ );
36
38
  };
37
39
 
38
40
  const outputConsumers = (consumers: routercommonv1_Consumer[]) => {
39
41
  const rows = consumers.map(consumerRow);
40
- console.log(renderTable(consumerHeaders, rows));
42
+ console.log(renderTable(consumerHeaders, rows, { maxColWidth: 64 }));
41
43
  };
42
44
 
43
- const redactConsumer = (consumer: routercommonv1_Consumer) =>
44
- redactSecrets(consumer);
45
-
46
45
  const requireInteractive = (message: string) => {
47
46
  if (!process.stdin.isTTY) {
48
47
  throw new Error(message);
@@ -83,11 +82,15 @@ const updateConsumer = async (
83
82
  const listConsumers = async (
84
83
  consumerService: Pick<ConsumerService, "ListConsumers">,
85
84
  ) => {
86
- const res = await consumerService.ListConsumers({
87
- pageSize: undefined,
88
- pageToken: undefined,
89
- });
90
- const consumers = (res?.consumers ?? []).map(redactConsumer);
85
+ const consumers = await fetchAllPages(
86
+ (pageToken) =>
87
+ consumerService.ListConsumers({
88
+ pageSize: undefined,
89
+ pageToken,
90
+ }),
91
+ (res) => res?.consumers ?? [],
92
+ (res) => res?.nextPageToken || undefined,
93
+ );
91
94
  outputConsumers(consumers);
92
95
  };
93
96
 
@@ -143,7 +146,7 @@ const updateConsumerById = async (
143
146
  name,
144
147
  enabled,
145
148
  );
146
- outputConsumerTable(redactConsumer(consumer));
149
+ outputConsumerTable(consumer);
147
150
  };
148
151
 
149
152
  const deleteConsumerById = async (
@@ -159,7 +162,7 @@ const deleteConsumerById = async (
159
162
  const confirmed = await confirmDelete(selected);
160
163
  if (!confirmed) return;
161
164
  await consumerService.DeleteConsumer({ id: selected.id });
162
- outputConsumerTable(redactConsumer(selected));
165
+ outputConsumerTable(selected);
163
166
  };
164
167
 
165
168
  export const registerKeysCommands = (program: Command) => {
package/src/cmd/models.ts CHANGED
@@ -3,9 +3,10 @@ import { createApiClients } from "../core/api/client";
3
3
  import { renderTable } from "../core/output/table";
4
4
  import type { routercommonv1_Model } from "../generated/router/dashboard/v1";
5
5
 
6
- const modelHeaders = ["NAME", "AUTHOR", "ENABLED", "UPDATED_AT"];
6
+ const modelHeaders = ["ID", "NAME", "AUTHOR", "ENABLED", "UPDATED_AT"];
7
7
 
8
8
  const modelRow = (model: routercommonv1_Model) => [
9
+ String(model.id ?? ""),
9
10
  String(model.name ?? ""),
10
11
  String(model.author ?? ""),
11
12
  String(model.enabled ?? ""),
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Fetches all pages from a paginated API endpoint.
3
+ *
4
+ * @param fetchPage - Function that fetches a single page given a pageToken
5
+ * @param getItems - Function that extracts items from the response
6
+ * @param getNextToken - Function that extracts the next page token from the response
7
+ * @returns Array of all items across all pages
8
+ */
9
+ export const fetchAllPages = async <TResponse, TItem>(
10
+ fetchPage: (pageToken?: string) => Promise<TResponse>,
11
+ getItems: (response: TResponse) => TItem[],
12
+ getNextToken: (response: TResponse) => string | undefined,
13
+ ): Promise<TItem[]> => {
14
+ const allItems: TItem[] = [];
15
+ let pageToken: string | undefined;
16
+
17
+ do {
18
+ const response = await fetchPage(pageToken);
19
+ const items = getItems(response);
20
+ allItems.push(...items);
21
+ pageToken = getNextToken(response);
22
+ } while (pageToken);
23
+
24
+ return allItems;
25
+ };
@@ -0,0 +1,68 @@
1
+ import { readAuth, writeAuth } from "../config";
2
+ import { buildApiUrl } from "../http/url";
3
+
4
+ type AuthToken = {
5
+ accessToken: string | undefined;
6
+ refreshToken: string | undefined;
7
+ expiresAt: string | undefined;
8
+ };
9
+
10
+ const EXPIRY_BUFFER_MS = 60 * 1000; // Refresh 1 minute before expiry
11
+
12
+ export const isTokenExpiringSoon = (expiresAt: string): boolean => {
13
+ if (!expiresAt) return true;
14
+ const t = Date.parse(expiresAt);
15
+ if (Number.isNaN(t)) return true;
16
+ return t <= Date.now() + EXPIRY_BUFFER_MS;
17
+ };
18
+
19
+ export const refreshAccessToken = async ({
20
+ fetchImpl,
21
+ }: {
22
+ fetchImpl?: typeof fetch;
23
+ }): Promise<AuthToken | null> => {
24
+ const auth = readAuth();
25
+ if (!auth.refreshToken) {
26
+ return null;
27
+ }
28
+
29
+ const res = await (fetchImpl ?? fetch)(
30
+ buildApiUrl("v1/dashboard/auth/token"),
31
+ {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ refreshToken: auth.refreshToken }),
35
+ },
36
+ );
37
+
38
+ if (!res.ok) {
39
+ return null;
40
+ }
41
+
42
+ const token = (await res.json()) as AuthToken;
43
+ if (token.accessToken && token.refreshToken) {
44
+ writeAuth({
45
+ accessToken: token.accessToken,
46
+ refreshToken: token.refreshToken,
47
+ expiresAt: token.expiresAt ?? "",
48
+ tokenType: "Bearer",
49
+ });
50
+ }
51
+ return token;
52
+ };
53
+
54
+ export const ensureValidToken = async ({
55
+ fetchImpl,
56
+ }: {
57
+ fetchImpl?: typeof fetch;
58
+ }): Promise<boolean> => {
59
+ const auth = readAuth();
60
+ if (!auth.accessToken || !auth.refreshToken) {
61
+ return false;
62
+ }
63
+ if (!isTokenExpiringSoon(auth.expiresAt)) {
64
+ return true;
65
+ }
66
+ const refreshed = await refreshAccessToken({ fetchImpl });
67
+ return refreshed !== null && Boolean(refreshed.accessToken);
68
+ };
@@ -1,5 +1,7 @@
1
+ import { refreshAccessToken } from "../auth/refresh";
1
2
  import { readAuth } from "../config";
2
3
  import { createApiError } from "./errors";
4
+ import { isServerError, withRetry } from "./retry";
3
5
  import { buildApiUrl } from "./url";
4
6
 
5
7
  type RequestInput = {
@@ -7,6 +9,9 @@ type RequestInput = {
7
9
  method: string;
8
10
  body?: unknown;
9
11
  fetchImpl?: typeof fetch;
12
+ maxRetries?: number;
13
+ /** For testing: override the sleep function used for retry delays */
14
+ _retrySleep?: (ms: number) => Promise<void>;
10
15
  };
11
16
 
12
17
  const getAuthCookieName = () =>
@@ -14,28 +19,79 @@ const getAuthCookieName = () =>
14
19
  process.env.KRATOS_AUTH_COOKIE ||
15
20
  "access_token";
16
21
 
17
- export const requestJson = async <T = unknown>({
18
- path,
19
- method,
20
- body,
21
- fetchImpl,
22
- }: RequestInput): Promise<T> => {
22
+ const buildHeaders = (accessToken?: string): Record<string, string> => {
23
23
  const headers: Record<string, string> = {
24
24
  "Content-Type": "application/json",
25
25
  };
26
- const auth = readAuth();
27
- if (auth.accessToken) {
28
- headers.Authorization = `Bearer ${auth.accessToken}`;
29
- headers.Cookie = `${getAuthCookieName()}=${auth.accessToken}`;
26
+ if (accessToken) {
27
+ headers.Authorization = `Bearer ${accessToken}`;
28
+ headers.Cookie = `${getAuthCookieName()}=${accessToken}`;
30
29
  }
31
- const res = await (fetchImpl ?? fetch)(buildApiUrl(path), {
30
+ return headers;
31
+ };
32
+
33
+ const doFetch = async (
34
+ url: string,
35
+ method: string,
36
+ headers: Record<string, string>,
37
+ body: unknown,
38
+ fetchImpl?: typeof fetch,
39
+ ): Promise<Response> => {
40
+ return (fetchImpl ?? fetch)(url, {
32
41
  method,
33
42
  headers,
34
43
  body: body == null ? undefined : JSON.stringify(body),
35
44
  });
36
- if (!res.ok) {
37
- const payload = await res.json().catch(() => null);
38
- throw createApiError(payload, res.statusText, res.status);
45
+ };
46
+
47
+ const shouldRetryResponse = (error: unknown): boolean => {
48
+ if (
49
+ typeof error === "object" &&
50
+ error !== null &&
51
+ "status" in error &&
52
+ typeof (error as { status: unknown }).status === "number"
53
+ ) {
54
+ return isServerError((error as { status: number }).status);
39
55
  }
40
- return (await res.json()) as T;
56
+ // Retry on network errors (TypeError from fetch)
57
+ return error instanceof TypeError;
58
+ };
59
+
60
+ export const requestJson = async <T = unknown>({
61
+ path,
62
+ method,
63
+ body,
64
+ fetchImpl,
65
+ maxRetries = 3,
66
+ _retrySleep,
67
+ }: RequestInput): Promise<T> => {
68
+ return withRetry(
69
+ async () => {
70
+ const auth = readAuth();
71
+ const url = buildApiUrl(path);
72
+ const headers = buildHeaders(auth.accessToken);
73
+
74
+ let res = await doFetch(url, method, headers, body, fetchImpl);
75
+
76
+ // On 401, attempt token refresh and retry once
77
+ if (res.status === 401 && auth.refreshToken) {
78
+ const refreshed = await refreshAccessToken({ fetchImpl });
79
+ if (refreshed?.accessToken) {
80
+ const newHeaders = buildHeaders(refreshed.accessToken);
81
+ res = await doFetch(url, method, newHeaders, body, fetchImpl);
82
+ }
83
+ }
84
+
85
+ if (!res.ok) {
86
+ const payload = await res.json().catch(() => null);
87
+ throw createApiError(payload, res.statusText, res.status);
88
+ }
89
+ return (await res.json()) as T;
90
+ },
91
+ {
92
+ maxRetries,
93
+ shouldRetry: shouldRetryResponse,
94
+ sleep: _retrySleep,
95
+ },
96
+ );
41
97
  };
@@ -0,0 +1,68 @@
1
+ export type RetryOptions = {
2
+ maxRetries?: number;
3
+ initialDelayMs?: number;
4
+ maxDelayMs?: number;
5
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
6
+ onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
7
+ sleep?: (ms: number) => Promise<void>;
8
+ };
9
+
10
+ const defaultSleep = (ms: number) =>
11
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
12
+
13
+ const isRetryableError = (error: unknown): boolean => {
14
+ // Network errors (fetch failures)
15
+ if (error instanceof TypeError) {
16
+ return true;
17
+ }
18
+ // Errors with status codes
19
+ if (
20
+ typeof error === "object" &&
21
+ error !== null &&
22
+ "status" in error &&
23
+ typeof (error as { status: unknown }).status === "number"
24
+ ) {
25
+ const status = (error as { status: number }).status;
26
+ // Retry on 5xx server errors, 408 timeout, 429 rate limit
27
+ return status >= 500 || status === 408 || status === 429;
28
+ }
29
+ return false;
30
+ };
31
+
32
+ export const withRetry = async <T>(
33
+ fn: () => Promise<T>,
34
+ options: RetryOptions = {},
35
+ ): Promise<T> => {
36
+ const {
37
+ maxRetries = 3,
38
+ initialDelayMs = 1000,
39
+ maxDelayMs = 10000,
40
+ shouldRetry = isRetryableError,
41
+ onRetry,
42
+ sleep = defaultSleep,
43
+ } = options;
44
+
45
+ let lastError: unknown;
46
+ let delay = initialDelayMs;
47
+
48
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
49
+ try {
50
+ return await fn();
51
+ } catch (error) {
52
+ lastError = error;
53
+
54
+ if (attempt >= maxRetries || !shouldRetry(error, attempt)) {
55
+ throw error;
56
+ }
57
+
58
+ onRetry?.(error, attempt + 1, delay);
59
+ await sleep(delay);
60
+ delay = Math.min(delay * 2, maxDelayMs);
61
+ }
62
+ }
63
+
64
+ throw lastError;
65
+ };
66
+
67
+ export const isServerError = (status: number): boolean =>
68
+ status >= 500 || status === 408 || status === 429;
@@ -3,6 +3,7 @@ import type {
3
3
  ConsumerService as DashboardConsumerService,
4
4
  routercommonv1_Consumer,
5
5
  } from "../../generated/router/dashboard/v1";
6
+ import { fetchAllPages } from "../api/pagination";
6
7
  import { fuzzySelect } from "./fuzzy";
7
8
 
8
9
  type Consumer = routercommonv1_Consumer;
@@ -101,11 +102,15 @@ export const promptKeyEnabled = async (initial: boolean): Promise<boolean> => {
101
102
  export const selectConsumer = async (
102
103
  consumerService: ConsumerService,
103
104
  ): Promise<routercommonv1_Consumer | null> => {
104
- const res = await consumerService.ListConsumers({
105
- pageSize: undefined,
106
- pageToken: undefined,
107
- });
108
- const consumers = res?.consumers ?? [];
105
+ const consumers = await fetchAllPages(
106
+ (pageToken) =>
107
+ consumerService.ListConsumers({
108
+ pageSize: undefined,
109
+ pageToken,
110
+ }),
111
+ (res) => res?.consumers ?? [],
112
+ (res) => res?.nextPageToken || undefined,
113
+ );
109
114
  if (consumers.length === 0) {
110
115
  throw new Error("No available API keys");
111
116
  }
@@ -128,11 +133,15 @@ export const selectConsumerList = async (
128
133
  consumerService: ConsumerService,
129
134
  message: string,
130
135
  ): Promise<routercommonv1_Consumer | null> => {
131
- const res = await consumerService.ListConsumers({
132
- pageSize: undefined,
133
- pageToken: undefined,
134
- });
135
- const consumers = res?.consumers ?? [];
136
+ const consumers = await fetchAllPages(
137
+ (pageToken) =>
138
+ consumerService.ListConsumers({
139
+ pageSize: undefined,
140
+ pageToken,
141
+ }),
142
+ (res) => res?.consumers ?? [],
143
+ (res) => res?.nextPageToken || undefined,
144
+ );
136
145
  if (consumers.length === 0) {
137
146
  throw new Error("No available API keys");
138
147
  }
@@ -1,7 +1,6 @@
1
1
  import type { AggregatedUsage } from "../usages/aggregate";
2
2
 
3
- const INPUT_BLOCK = "█";
4
- const OUTPUT_BLOCK = "▒";
3
+ const TOTAL_BLOCK = "█";
5
4
  const DEFAULT_WIDTH = 24;
6
5
 
7
6
  const formatTokens = (value: number) => {
@@ -35,41 +34,23 @@ export const renderUsageChart = (
35
34
  return `${header}\n\nNo usage data available.`;
36
35
  }
37
36
  const normalized = rows.map((row) => {
38
- const input = Number(row.inputTokens);
39
- const output = Number(row.outputTokens);
40
- const safeInput = Number.isFinite(input) ? input : 0;
41
- const safeOutput = Number.isFinite(output) ? output : 0;
37
+ const total = Number(row.totalTokens);
38
+ const safeTotal = Number.isFinite(total) ? total : 0;
42
39
  return {
43
40
  day: row.day,
44
- input: safeInput,
45
- output: safeOutput,
46
- total: safeInput + safeOutput,
41
+ total: safeTotal,
47
42
  };
48
43
  });
49
44
  const totals = normalized.map((row) => row.total);
50
45
  const maxTotal = Math.max(0, ...totals);
51
46
  const lines = normalized.map((row) => {
52
- const total = row.total;
53
- if (maxTotal === 0 || total === 0) {
54
- return `${row.day} ${"".padEnd(width, " ")} I:0 O:0`;
47
+ if (maxTotal === 0 || row.total === 0) {
48
+ return `${row.day} ${"".padEnd(width, " ")} 0`;
55
49
  }
56
- const scaled = Math.max(1, Math.round((total / maxTotal) * width));
57
- let inputBars = Math.round((row.input / total) * scaled);
58
- let outputBars = Math.max(0, scaled - inputBars);
59
- if (row.input > 0 && row.output > 0) {
60
- if (inputBars === 0) {
61
- inputBars = 1;
62
- outputBars = Math.max(0, scaled - 1);
63
- } else if (outputBars === 0) {
64
- outputBars = 1;
65
- inputBars = Math.max(0, scaled - 1);
66
- }
67
- }
68
- const bar = `${INPUT_BLOCK.repeat(inputBars)}${OUTPUT_BLOCK.repeat(outputBars)}`;
69
- const inputLabel = formatTokens(row.input);
70
- const outputLabel = formatTokens(row.output);
71
- return `${row.day} ${bar.padEnd(width, " ")} I:${inputLabel} O:${outputLabel}`;
50
+ const scaled = Math.max(1, Math.round((row.total / maxTotal) * width));
51
+ const bar = TOTAL_BLOCK.repeat(scaled);
52
+ const totalLabel = formatTokens(row.total);
53
+ return `${row.day} ${bar.padEnd(width, " ")} ${totalLabel}`;
72
54
  });
73
- const legend = `Legend: ${INPUT_BLOCK} input ${OUTPUT_BLOCK} output`;
74
- return [header, "", ...lines, "", legend].join("\n");
55
+ return [header, "", ...lines].join("\n");
75
56
  };
@@ -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/cli.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { describe, expect, it } from "vitest";
3
+ import { describe, expect, it, vi } from "vitest";
4
4
  import { createProgram } from "../src/cli";
5
5
 
6
6
  describe("getrouter cli", () => {
@@ -10,6 +10,25 @@ describe("getrouter cli", () => {
10
10
  expect(program.helpInformation()).toContain("getrouter");
11
11
  });
12
12
 
13
+ it("rejects removed config command", async () => {
14
+ const writeErr = vi
15
+ .spyOn(process.stderr, "write")
16
+ .mockImplementation(() => true);
17
+ const program = createProgram();
18
+ program.exitOverride();
19
+ program.configureOutput({
20
+ writeErr: () => {},
21
+ });
22
+ try {
23
+ await expect(
24
+ program.parseAsync(["node", "getrouter", "config"]),
25
+ ).rejects.toBeTruthy();
26
+ expect(writeErr).not.toHaveBeenCalled();
27
+ } finally {
28
+ writeErr.mockRestore();
29
+ }
30
+ });
31
+
13
32
  it("only ships registered command entrypoints", () => {
14
33
  const cmdDir = path.join(process.cwd(), "src", "cmd");
15
34
  const files = readdirSync(cmdDir).filter((file) => file.endsWith(".ts"));
@@ -17,8 +36,6 @@ describe("getrouter cli", () => {
17
36
  "auth.ts",
18
37
  "claude.ts",
19
38
  "codex.ts",
20
- "config-helpers.ts",
21
- "config.ts",
22
39
  "env.ts",
23
40
  "index.ts",
24
41
  "keys.ts",