@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/.serena/project.yml +84 -0
- package/CLAUDE.md +52 -0
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/README.zh-cn.md +2 -2
- package/biome.json +1 -1
- package/bun.lock +70 -98
- package/dist/bin.mjs +141 -127
- package/docs/plans/2026-01-04-remove-config-command-design.md +32 -0
- package/docs/plans/2026-01-04-remove-config-command-plan.md +129 -0
- package/package.json +6 -6
- package/src/cli.ts +2 -1
- package/src/cmd/index.ts +0 -2
- package/src/cmd/keys.ts +16 -13
- package/src/cmd/models.ts +2 -1
- package/src/core/api/pagination.ts +25 -0
- package/src/core/auth/refresh.ts +68 -0
- package/src/core/http/request.ts +71 -15
- package/src/core/http/retry.ts +68 -0
- package/src/core/interactive/keys.ts +19 -10
- package/src/core/output/usages.ts +11 -30
- package/tests/auth/refresh.test.ts +149 -0
- package/tests/cli.test.ts +20 -3
- package/tests/cmd/keys.test.ts +7 -3
- package/tests/cmd/models.test.ts +5 -2
- package/tests/cmd/usages.test.ts +5 -5
- package/tests/core/api/pagination.test.ts +87 -0
- 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 +2 -1
- package/src/cmd/config-helpers.ts +0 -16
- package/src/cmd/config.ts +0 -31
- package/tests/cmd/config-helpers.test.ts +0 -18
- package/tests/cmd/config.test.ts +0 -56
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 {
|
|
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(
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
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(
|
|
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
|
+
};
|
package/src/core/http/request.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
headers.
|
|
29
|
-
headers.Cookie = `${getAuthCookieName()}=${auth.accessToken}`;
|
|
26
|
+
if (accessToken) {
|
|
27
|
+
headers.Authorization = `Bearer ${accessToken}`;
|
|
28
|
+
headers.Cookie = `${getAuthCookieName()}=${accessToken}`;
|
|
30
29
|
}
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
39
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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",
|