offrouter-cli 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +34 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +288 -16
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/route.d.ts.map +1 -1
- package/dist/commands/route.js +22 -2
- package/dist/commands/route.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +23 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/index.js +0 -0
- package/package.json +9 -9
- package/src/cli.test.ts +128 -0
- package/src/commands/doctor.ts +52 -0
- package/src/commands/login.test.ts +247 -0
- package/src/commands/login.ts +397 -17
- package/src/commands/route.ts +31 -1
- package/src/commands/status.ts +30 -2
package/src/commands/login.ts
CHANGED
|
@@ -1,33 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `offrouter login` command: real OAuth PKCE login with browser open.
|
|
3
|
+
*
|
|
4
|
+
* Supports anthropic and openai (bring-your-own-client-id, documented as fragile).
|
|
5
|
+
* Tokens are stored in the OffRouter secret store (keychain or file fallback).
|
|
6
|
+
* Never scrapes or copies credentials from other CLIs.
|
|
7
|
+
*/
|
|
1
8
|
import type { Command } from "commander";
|
|
2
|
-
import {
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
FileSecretStore,
|
|
13
|
+
PKCE_CONFIGS,
|
|
14
|
+
getOAuthTokens,
|
|
15
|
+
isOAuthProvider,
|
|
16
|
+
isOAuthTokenExpired,
|
|
17
|
+
refreshTokens,
|
|
18
|
+
resolveClientId,
|
|
19
|
+
resolveOffRouterHome,
|
|
20
|
+
runOAuthLogin,
|
|
21
|
+
} from "offrouter-core";
|
|
22
|
+
import type { OAuthStoredTokens } from "offrouter-core";
|
|
3
23
|
import { writeJson, writeText } from "./output.js";
|
|
4
24
|
import type { CommandContext } from "./init.js";
|
|
5
25
|
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Browser open (macOS, best-effort)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function openBrowser(url: string): boolean {
|
|
31
|
+
try {
|
|
32
|
+
execSync(`open "${url}"`, { timeout: 5000, stdio: "ignore" });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Secret store helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function createStore(env: NodeJS.ProcessEnv): FileSecretStore {
|
|
44
|
+
const home = resolveOffRouterHome({ env });
|
|
45
|
+
return new FileSecretStore({ filePath: join(home, "secrets.json") });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Provider display helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
interface OAuthProviderInfo {
|
|
53
|
+
id: string;
|
|
54
|
+
clientId: string | null;
|
|
55
|
+
tokensStored: boolean;
|
|
56
|
+
hasRefreshToken: boolean;
|
|
57
|
+
expired: boolean | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function listOAuthProviders(
|
|
61
|
+
env: NodeJS.ProcessEnv,
|
|
62
|
+
): Promise<OAuthProviderInfo[]> {
|
|
63
|
+
const providers = Object.keys(PKCE_CONFIGS);
|
|
64
|
+
const store = createStore(env);
|
|
65
|
+
const results: OAuthProviderInfo[] = [];
|
|
66
|
+
|
|
67
|
+
for (const id of providers) {
|
|
68
|
+
const clientId = resolveClientId(id, env) ?? null;
|
|
69
|
+
let tokens: OAuthStoredTokens | undefined;
|
|
70
|
+
try {
|
|
71
|
+
tokens = await getOAuthTokens(id, store);
|
|
72
|
+
} catch {
|
|
73
|
+
tokens = undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
results.push({
|
|
77
|
+
id,
|
|
78
|
+
clientId,
|
|
79
|
+
tokensStored: !!tokens,
|
|
80
|
+
hasRefreshToken: !!tokens?.refreshToken,
|
|
81
|
+
expired: tokens ? isOAuthTokenExpired(tokens) : null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function statusForProvider(
|
|
89
|
+
providerId: string,
|
|
90
|
+
env: NodeJS.ProcessEnv,
|
|
91
|
+
): Promise<{
|
|
92
|
+
provider: string;
|
|
93
|
+
valid: boolean;
|
|
94
|
+
expired: boolean;
|
|
95
|
+
hasRefreshToken: boolean;
|
|
96
|
+
refreshed: boolean;
|
|
97
|
+
error?: string;
|
|
98
|
+
}> {
|
|
99
|
+
const store = createStore(env);
|
|
100
|
+
const tokens = await getOAuthTokens(providerId, store);
|
|
101
|
+
|
|
102
|
+
if (!tokens) {
|
|
103
|
+
return {
|
|
104
|
+
provider: providerId,
|
|
105
|
+
valid: false,
|
|
106
|
+
expired: false,
|
|
107
|
+
hasRefreshToken: false,
|
|
108
|
+
refreshed: false,
|
|
109
|
+
error: "No OAuth tokens stored. Run `offrouter login <provider>` first.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const expired = isOAuthTokenExpired(tokens);
|
|
114
|
+
|
|
115
|
+
if (!expired) {
|
|
116
|
+
return {
|
|
117
|
+
provider: providerId,
|
|
118
|
+
valid: true,
|
|
119
|
+
expired: false,
|
|
120
|
+
hasRefreshToken: !!tokens.refreshToken,
|
|
121
|
+
refreshed: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Expired: try refresh if we have a refresh token.
|
|
126
|
+
if (!tokens.refreshToken) {
|
|
127
|
+
return {
|
|
128
|
+
provider: providerId,
|
|
129
|
+
valid: false,
|
|
130
|
+
expired: true,
|
|
131
|
+
hasRefreshToken: false,
|
|
132
|
+
refreshed: false,
|
|
133
|
+
error:
|
|
134
|
+
"Token is expired and no refresh token is available. Run `offrouter login` again.",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await refreshTokens(providerId, tokens.refreshToken, store);
|
|
140
|
+
return {
|
|
141
|
+
provider: providerId,
|
|
142
|
+
valid: true,
|
|
143
|
+
expired: false,
|
|
144
|
+
hasRefreshToken: true,
|
|
145
|
+
refreshed: true,
|
|
146
|
+
};
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
provider: providerId,
|
|
150
|
+
valid: false,
|
|
151
|
+
expired: true,
|
|
152
|
+
hasRefreshToken: true,
|
|
153
|
+
refreshed: false,
|
|
154
|
+
error: err instanceof Error ? err.message : "Token refresh failed",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Command registration
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
6
163
|
export function registerLoginCommand(
|
|
7
164
|
program: Command,
|
|
8
165
|
ctx: () => CommandContext,
|
|
9
166
|
): void {
|
|
10
|
-
program
|
|
167
|
+
const cmd = program
|
|
11
168
|
.command("login")
|
|
12
169
|
.description(
|
|
13
|
-
"
|
|
170
|
+
"Authenticate with an OAuth provider (Anthropic or OpenAI). Tokens stored in the OffRouter secret store.",
|
|
14
171
|
)
|
|
15
|
-
.argument("[provider]", "Provider id (
|
|
172
|
+
.argument("[provider]", "Provider id (anthropic, openai)")
|
|
173
|
+
.option("--list", "List providers with stored tokens")
|
|
174
|
+
.option("--status <provider>", "Check token status for a provider")
|
|
16
175
|
.option("--json", "Emit JSON", false)
|
|
17
|
-
.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
176
|
+
.option("--timeout <ms>", "OAuth callback timeout in milliseconds")
|
|
177
|
+
.option(
|
|
178
|
+
"--client-id <id>",
|
|
179
|
+
"Override client ID (default from OFFROUTER_*_CLIENT_ID env)",
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
cmd.action(
|
|
183
|
+
async (
|
|
184
|
+
provider: string | undefined,
|
|
185
|
+
opts: {
|
|
186
|
+
json?: boolean;
|
|
187
|
+
list?: boolean;
|
|
188
|
+
status?: string;
|
|
189
|
+
timeout?: string;
|
|
190
|
+
clientId?: string;
|
|
191
|
+
},
|
|
192
|
+
) => {
|
|
193
|
+
const { env, stdout } = ctx();
|
|
194
|
+
|
|
195
|
+
// --list
|
|
196
|
+
if (opts.list) {
|
|
197
|
+
await handleList(stdout, env, !!opts.json);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --status <provider>
|
|
202
|
+
if (opts.status) {
|
|
203
|
+
await handleStatus(stdout, env, opts.status, !!opts.json);
|
|
28
204
|
return;
|
|
29
205
|
}
|
|
30
|
-
|
|
31
|
-
|
|
206
|
+
|
|
207
|
+
// login <provider> (full flow)
|
|
208
|
+
if (!provider) {
|
|
209
|
+
if (opts.json) {
|
|
210
|
+
writeJson(stdout, {
|
|
211
|
+
error: "Missing provider argument. Run `offrouter login --help` for usage.",
|
|
212
|
+
});
|
|
213
|
+
} else {
|
|
214
|
+
writeText(
|
|
215
|
+
stdout,
|
|
216
|
+
"Missing provider. Usage: offrouter login <provider> [options]",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await handleLogin(stdout, env, provider, opts);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Handlers
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
async function handleList(
|
|
232
|
+
stdout: { write(chunk: string): boolean | void },
|
|
233
|
+
env: NodeJS.ProcessEnv,
|
|
234
|
+
json: boolean,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const providers = await listOAuthProviders(env);
|
|
237
|
+
|
|
238
|
+
if (json) {
|
|
239
|
+
writeJson(stdout, { providers });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (providers.length === 0) {
|
|
244
|
+
writeText(stdout, "No OAuth providers configured.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
writeText(stdout, "OAuth providers:");
|
|
249
|
+
for (const p of providers) {
|
|
250
|
+
const tokenStatus = p.tokensStored
|
|
251
|
+
? p.expired
|
|
252
|
+
? "expired"
|
|
253
|
+
: "active"
|
|
254
|
+
: "no tokens";
|
|
255
|
+
const refreshStatus = p.hasRefreshToken ? " (refresh token)" : "";
|
|
256
|
+
const clientStatus = p.clientId ? `client-id: ${p.clientId}` : "no client-id configured";
|
|
257
|
+
writeText(stdout, ` ${p.id}: ${tokenStatus}${refreshStatus}, ${clientStatus}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function handleStatus(
|
|
262
|
+
stdout: { write(chunk: string): boolean | void },
|
|
263
|
+
env: NodeJS.ProcessEnv,
|
|
264
|
+
providerId: string,
|
|
265
|
+
json: boolean,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
if (!isOAuthProvider(providerId)) {
|
|
268
|
+
if (json) {
|
|
269
|
+
writeJson(stdout, {
|
|
270
|
+
error: `OAuth not supported for provider: ${providerId}`,
|
|
271
|
+
});
|
|
272
|
+
} else {
|
|
273
|
+
writeText(
|
|
274
|
+
stdout,
|
|
275
|
+
`OAuth not supported for provider "${providerId}". Supported providers: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const status = await statusForProvider(providerId, env);
|
|
283
|
+
|
|
284
|
+
if (json) {
|
|
285
|
+
writeJson(stdout, status);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (status.error) {
|
|
290
|
+
writeText(stdout, ` ${providerId}: ${status.error}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (status.refreshed) {
|
|
295
|
+
writeText(stdout, ` ${providerId}: token was expired, refreshed successfully`);
|
|
296
|
+
} else {
|
|
297
|
+
writeText(stdout, ` ${providerId}: token valid`);
|
|
298
|
+
}
|
|
299
|
+
} catch (err) {
|
|
300
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
301
|
+
if (json) {
|
|
302
|
+
writeJson(stdout, { error: msg, provider: providerId });
|
|
303
|
+
} else {
|
|
304
|
+
writeText(stdout, ` ${providerId}: error checking status: ${msg}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function handleLogin(
|
|
310
|
+
stdout: { write(chunk: string): boolean | void },
|
|
311
|
+
env: NodeJS.ProcessEnv,
|
|
312
|
+
provider: string,
|
|
313
|
+
opts: {
|
|
314
|
+
json?: boolean;
|
|
315
|
+
timeout?: string;
|
|
316
|
+
clientId?: string;
|
|
317
|
+
},
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
if (!isOAuthProvider(provider)) {
|
|
320
|
+
if (opts.json) {
|
|
321
|
+
writeJson(stdout, {
|
|
322
|
+
error: `OAuth not supported for provider: ${provider}`,
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
writeText(
|
|
326
|
+
stdout,
|
|
327
|
+
`OAuth not supported for "${provider}". Use one of: ${Object.keys(PKCE_CONFIGS).join(", ")}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const config = PKCE_CONFIGS[provider];
|
|
334
|
+
if (!config) return; // unreachable
|
|
335
|
+
|
|
336
|
+
// Resolve client ID
|
|
337
|
+
const clientId = opts.clientId ?? resolveClientId(provider, env);
|
|
338
|
+
|
|
339
|
+
if (!clientId) {
|
|
340
|
+
const hint = config.clientIdEnvVar
|
|
341
|
+
? `Set the ${config.clientIdEnvVar} environment variable.`
|
|
342
|
+
: "A client ID is required. Register an OAuth app with the provider.";
|
|
343
|
+
if (opts.json) {
|
|
344
|
+
writeJson(stdout, {
|
|
345
|
+
error: `Client ID required for ${provider} OAuth. ${hint}`,
|
|
346
|
+
});
|
|
347
|
+
} else {
|
|
348
|
+
writeText(stdout, `Error: Client ID required for ${provider} OAuth.`);
|
|
349
|
+
writeText(stdout, hint);
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const store = createStore(env);
|
|
355
|
+
const timeoutMs = opts.timeout ? parseInt(opts.timeout, 10) : 5 * 60 * 1000;
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const result = await runOAuthLogin({
|
|
359
|
+
provider,
|
|
360
|
+
store,
|
|
361
|
+
clientId,
|
|
362
|
+
timeoutMs,
|
|
363
|
+
openBrowser,
|
|
364
|
+
onAuthorizeUrl(url) {
|
|
365
|
+
writeText(stdout, "");
|
|
366
|
+
writeText(stdout, `Opening browser to authorize OffRouter for ${provider}...`);
|
|
367
|
+
writeText(stdout, "");
|
|
368
|
+
writeText(stdout, `If the browser does not open, visit:`);
|
|
369
|
+
writeText(stdout, ` ${url}`);
|
|
370
|
+
writeText(stdout, "");
|
|
371
|
+
|
|
372
|
+
const opened = openBrowser(url);
|
|
373
|
+
if (!opened) {
|
|
374
|
+
writeText(stdout, "(Could not open browser automatically. Copy and paste the URL above.)");
|
|
375
|
+
}
|
|
376
|
+
writeText(stdout, "Waiting for authorization...");
|
|
377
|
+
},
|
|
32
378
|
});
|
|
379
|
+
|
|
380
|
+
if (opts.json) {
|
|
381
|
+
writeJson(stdout, {
|
|
382
|
+
provider: result.provider,
|
|
383
|
+
ok: true,
|
|
384
|
+
redirectUri: result.redirectUri,
|
|
385
|
+
expiresAt: result.expiresAt,
|
|
386
|
+
scope: result.scope,
|
|
387
|
+
});
|
|
388
|
+
} else {
|
|
389
|
+
writeText(stdout, "");
|
|
390
|
+
writeText(stdout, `✓ Login successful for ${provider}.`);
|
|
391
|
+
writeText(stdout, ` Tokens stored in OffRouter secret store.`);
|
|
392
|
+
if (result.expiresAt) {
|
|
393
|
+
const expiresIn = Math.round((result.expiresAt - Date.now()) / 1000 / 60);
|
|
394
|
+
writeText(stdout, ` Token expires in ${expiresIn} minutes.`);
|
|
395
|
+
}
|
|
396
|
+
writeText(stdout, "");
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
400
|
+
|
|
401
|
+
if (opts.json) {
|
|
402
|
+
writeJson(stdout, {
|
|
403
|
+
error: msg,
|
|
404
|
+
provider,
|
|
405
|
+
ok: false,
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
writeText(stdout, "");
|
|
409
|
+
writeText(stdout, `✗ Login failed for ${provider}: ${msg}`);
|
|
410
|
+
writeText(stdout, "");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
33
413
|
}
|
package/src/commands/route.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import type { Command } from "commander";
|
|
3
4
|
import {
|
|
5
|
+
FileUsageStore,
|
|
4
6
|
loadConfig,
|
|
7
|
+
resolveOffRouterHome,
|
|
5
8
|
routeRequest,
|
|
6
9
|
type HarnessKind,
|
|
7
10
|
type RouteRequest,
|
|
@@ -79,8 +82,9 @@ export function registerRouteCommand(
|
|
|
79
82
|
const request = buildRequest(prompt, opts.profile, cwd);
|
|
80
83
|
|
|
81
84
|
let policyConfig;
|
|
85
|
+
let config;
|
|
82
86
|
try {
|
|
83
|
-
|
|
87
|
+
config = await loadConfig({ env, workspaceDir: cwd });
|
|
84
88
|
policyConfig = config.policy;
|
|
85
89
|
} catch (err) {
|
|
86
90
|
const configError = formatConfigError(err);
|
|
@@ -117,6 +121,28 @@ export function registerRouteCommand(
|
|
|
117
121
|
const decision = routeRequest(request, [], policyConfig);
|
|
118
122
|
const setupIncomplete = decision.needsConfiguration === true;
|
|
119
123
|
|
|
124
|
+
// Check near-limit status for configured accounts.
|
|
125
|
+
let nearLimitWarning: string | null = null;
|
|
126
|
+
try {
|
|
127
|
+
const usageStore = new FileUsageStore({
|
|
128
|
+
filePath: join(
|
|
129
|
+
resolveOffRouterHome({ env }),
|
|
130
|
+
"state",
|
|
131
|
+
"usage.json",
|
|
132
|
+
),
|
|
133
|
+
});
|
|
134
|
+
const nearAccounts = await usageStore.getNearLimitAccounts(
|
|
135
|
+
policyConfig.nearLimitThreshold,
|
|
136
|
+
);
|
|
137
|
+
if (nearAccounts.length > 0) {
|
|
138
|
+
nearLimitWarning = `Account ${nearAccounts[0]!.providerId}/${nearAccounts[0]!.accountId} is near its limit (${
|
|
139
|
+
nearAccounts[0]!.percentRemaining ?? 0
|
|
140
|
+
}% remaining)`;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Non-fatal: if the usage store cannot be read, skip the warning.
|
|
144
|
+
}
|
|
145
|
+
|
|
120
146
|
if (opts.json) {
|
|
121
147
|
writeJson(stdout, {
|
|
122
148
|
...decision,
|
|
@@ -124,6 +150,7 @@ export function registerRouteCommand(
|
|
|
124
150
|
setupMessage: setupIncomplete
|
|
125
151
|
? "Setup incomplete: configure a subscription, local runtime, or API-key provider, and allowlist a personal profile."
|
|
126
152
|
: null,
|
|
153
|
+
...(nearLimitWarning !== null ? { nearLimitWarning } : {}),
|
|
127
154
|
});
|
|
128
155
|
return;
|
|
129
156
|
}
|
|
@@ -148,6 +175,9 @@ export function registerRouteCommand(
|
|
|
148
175
|
"Setup incomplete: configure a subscription, local runtime, or API-key provider, and allowlist a personal profile.",
|
|
149
176
|
);
|
|
150
177
|
}
|
|
178
|
+
if (nearLimitWarning !== null) {
|
|
179
|
+
writeText(stdout, `! ${nearLimitWarning}`);
|
|
180
|
+
}
|
|
151
181
|
return;
|
|
152
182
|
}
|
|
153
183
|
|
package/src/commands/status.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
FileUsageStore,
|
|
5
|
+
loadConfig,
|
|
6
|
+
resolveOffRouterHome,
|
|
7
|
+
} from "offrouter-core";
|
|
3
8
|
import { formatConfigError, type CliConfigError } from "./config-error.js";
|
|
4
9
|
import { detectEnvironment, V1_HONESTY_LINE } from "./detection.js";
|
|
5
10
|
import { writeJson, writeText } from "./output.js";
|
|
@@ -27,11 +32,19 @@ export function registerStatusCommand(
|
|
|
27
32
|
localReady: boolean;
|
|
28
33
|
limitStatus: string;
|
|
29
34
|
lastError: string | null;
|
|
35
|
+
accounts: { accountId: string; label: string; quotaRemaining: number | null; nearLimit: boolean | null }[];
|
|
30
36
|
}> = [];
|
|
31
37
|
let configError: CliConfigError | null = null;
|
|
32
38
|
|
|
33
39
|
try {
|
|
34
40
|
const config = await loadConfig({ env, workspaceDir: cwd });
|
|
41
|
+
const usageStore = new FileUsageStore({
|
|
42
|
+
filePath: join(resolveOffRouterHome({ env }), "state", "usage.json"),
|
|
43
|
+
});
|
|
44
|
+
const allUsage = await usageStore.listAccountUsage();
|
|
45
|
+
const usageByKey = new Map(
|
|
46
|
+
allUsage.map((u) => [`${u.providerId}:${u.accountId}`, u]),
|
|
47
|
+
);
|
|
35
48
|
providers = Object.values(config.providers)
|
|
36
49
|
.map((p) => ({
|
|
37
50
|
id: p.id,
|
|
@@ -41,13 +54,24 @@ export function registerStatusCommand(
|
|
|
41
54
|
? (["subscription"] as const)
|
|
42
55
|
: []),
|
|
43
56
|
...(detection.authTiers.local ? (["local"] as const) : []),
|
|
44
|
-
...(detection.authTiers["api-key"]
|
|
57
|
+
...(detection.authTiers["api-key"]
|
|
58
|
+
? (["api-key"] as const)
|
|
59
|
+
: []),
|
|
45
60
|
],
|
|
46
61
|
subscriptionStatus: detection.subscription.status,
|
|
47
62
|
apiKeyReady: detection.apiKeyReadiness.anyReady,
|
|
48
63
|
localReady: detection.localRuntime.health !== "unconfigured",
|
|
49
64
|
limitStatus: detection.limits.status,
|
|
50
65
|
lastError: detection.lastProviderError,
|
|
66
|
+
accounts: p.accounts.map((a) => {
|
|
67
|
+
const snap = usageByKey.get(`${p.id}:${a.id}`);
|
|
68
|
+
return {
|
|
69
|
+
accountId: a.id,
|
|
70
|
+
label: a.label ?? a.id,
|
|
71
|
+
quotaRemaining: snap?.remaining ?? null,
|
|
72
|
+
nearLimit: snap?.nearLimit ?? null,
|
|
73
|
+
};
|
|
74
|
+
}),
|
|
51
75
|
}))
|
|
52
76
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
53
77
|
} catch (err) {
|
|
@@ -69,6 +93,7 @@ export function registerStatusCommand(
|
|
|
69
93
|
localReady: detection.localRuntime.health !== "unconfigured",
|
|
70
94
|
limitStatus: detection.limits.status,
|
|
71
95
|
lastError: detection.lastProviderError,
|
|
96
|
+
accounts: [],
|
|
72
97
|
},
|
|
73
98
|
];
|
|
74
99
|
}
|
|
@@ -102,6 +127,9 @@ export function registerStatusCommand(
|
|
|
102
127
|
stdout,
|
|
103
128
|
`provider ${p.id}: enabled=${p.enabled} subscription=${p.subscriptionStatus} apiKeyReady=${p.apiKeyReady} localReady=${p.localReady} limits=${p.limitStatus}`,
|
|
104
129
|
);
|
|
130
|
+
for (const a of p.accounts) {
|
|
131
|
+
writeText(stdout, ` account ${a.accountId}: ${a.label}`);
|
|
132
|
+
}
|
|
105
133
|
}
|
|
106
134
|
}
|
|
107
135
|
if (configError) {
|