@runtypelabs/cli 2.59.0 → 2.59.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/dist/chunk-0-T9NWjH.js +3899 -0
- package/dist/chunk-0x7UKC1U.js +17 -0
- package/dist/chunk-2ySxXtcg.js +418 -0
- package/dist/chunk-3An_BRHv.js +501 -0
- package/dist/chunk-6uTxgdiY.js +16218 -0
- package/dist/chunk-87nRnNRG.js +1363 -0
- package/dist/chunk-B3wo8uRx.js +322 -0
- package/dist/chunk-BGp3ZdBy.js +560 -0
- package/dist/chunk-BJQv9Elp.js +107 -0
- package/dist/chunk-BOpO6xE-.js +6053 -0
- package/dist/chunk-BQXXCL8B.js +102 -0
- package/dist/chunk-BV-8isOd.js +34 -0
- package/dist/chunk-BVMhjGVe.js +826 -0
- package/dist/chunk-BW2fg46T.js +141 -0
- package/dist/chunk-BWJdY5Ag.js +36 -0
- package/dist/chunk-BYJOrLhh.js +302 -0
- package/dist/chunk-BYab8EVL.js +127 -0
- package/dist/chunk-BfEKvp7h.js +82 -0
- package/dist/chunk-BhgLSfH6.js +1123 -0
- package/dist/chunk-BirUxTtH.js +296 -0
- package/dist/chunk-BmB6UVZ9.js +1559 -0
- package/dist/chunk-BnWhIqW1.js +109 -0
- package/dist/chunk-Bpfnfk0t.js +123 -0
- package/dist/chunk-BrhgdnM3.js +108 -0
- package/dist/chunk-Bt0XAgX5.js +327 -0
- package/dist/chunk-Bu0FJr6S.js +84 -0
- package/dist/chunk-BvEoKpN3.js +255 -0
- package/dist/chunk-BxNJgYbE.js +28 -0
- package/dist/chunk-C1OFhd7F.js +6131 -0
- package/dist/chunk-C7e7WwbU.js +331 -0
- package/dist/chunk-CDTYP6bN.js +558 -0
- package/dist/chunk-CGDQoqDC.js +24 -0
- package/dist/chunk-CJraEBVd.js +127 -0
- package/dist/chunk-CQrNhOtv.js +40 -0
- package/dist/chunk-CW6AzBH6.js +128 -0
- package/dist/chunk-CXgL4w-9.js +317 -0
- package/dist/chunk-CZrqoPyf.js +233 -0
- package/dist/chunk-CcFCyqbB.js +83 -0
- package/dist/chunk-CdaIvO-y.js +485 -0
- package/dist/chunk-CehSXtgK.js +624 -0
- package/dist/chunk-Ciqkop65.js +218 -0
- package/dist/chunk-CoAWdSiE.js +43 -0
- package/dist/chunk-Crb4y7t4.js +830 -0
- package/dist/chunk-CrlqIfmb.js +337 -0
- package/dist/chunk-Cs4pwCQ-.js +324 -0
- package/dist/chunk-CwM_9y01.js +519 -0
- package/dist/chunk-CyRbIJg8.js +440 -0
- package/dist/chunk-D0OHsyMy.js +212 -0
- package/dist/chunk-D3y2bURs.js +1347 -0
- package/dist/chunk-D4_mJk03.js +2338 -0
- package/dist/chunk-D6C8rned.js +578 -0
- package/dist/chunk-D9ZbUnks.js +11 -0
- package/dist/chunk-DEPmLshX.js +834 -0
- package/dist/chunk-DFj1UPB2.js +330 -0
- package/dist/chunk-DGdFdZVf.js +197 -0
- package/dist/chunk-DGv7X9S_.js +63 -0
- package/dist/chunk-DJQ47WYZ.js +664 -0
- package/dist/chunk-DJw3O61j.js +1974 -0
- package/dist/chunk-DK4FbWVK.js +100 -0
- package/dist/chunk-DSRYhUus.js +993 -0
- package/dist/chunk-DajZ9Eg4.js +299 -0
- package/dist/chunk-Df4dunvU.js +195 -0
- package/dist/chunk-DjNTPhfO.js +481 -0
- package/dist/chunk-Dn8eaJuu.js +1042 -0
- package/dist/chunk-DwDWqSxM.js +637 -0
- package/dist/chunk-Dz7cEtxl.js +84 -0
- package/dist/chunk-E_0bgjrS.js +359 -0
- package/dist/chunk-KPYN2vUp.js +776 -0
- package/dist/chunk-KcqPQJBi.js +86 -0
- package/dist/chunk-N9H0JnoG.js +349 -0
- package/dist/chunk-NONFcXkn.js +728 -0
- package/dist/chunk-PrFjcpmH.js +256 -0
- package/dist/chunk-QSfNYiw9.js +11 -0
- package/dist/chunk-R265bzj9.js +226 -0
- package/dist/chunk-X9V_r6jM.js +166 -0
- package/dist/chunk-ZBtPUtyA.js +55 -0
- package/dist/chunk-ZCTfmOs7.js +4939 -0
- package/dist/chunk-h6oavj0t.js +97 -0
- package/dist/chunk-i7DwkvvD.js +42 -0
- package/dist/chunk-mG-4eZ6S.js +203 -0
- package/dist/chunk-o0ygUbCf.js +313 -0
- package/dist/chunk-ucu5lrDZ.js +5715 -0
- package/dist/index.js +11367 -73764
- package/package.json +6 -6
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { P as getRuntypeHomeDir, b as CredentialStore, t as isTTY } from "./index.js";
|
|
3
|
+
import { ConfirmPrompt, DataList, EntityCard, MutationResult, React, Text, mutationResultProps, render, useEffect, useMutationState, useState } from "./chunk-CoAWdSiE.js";
|
|
4
|
+
import { b as pkceCodeChallengeSchema, d as apiKeyFileSlug, f as apiKeyFileUsageHint, m as isForbiddenRequestedPermission, n as API_KEY_REQUEST_MAX_TTL_SECONDS, r as API_KEY_REQUEST_POLL_INTERVAL_MS, t as API_KEY_FILE_FORMAT, u as apiKeyFileSchema, x as pkceCodeVerifierSchema } from "./chunk-ucu5lrDZ.js";
|
|
5
|
+
import { i as getApiUrl, r as createCliClient } from "./chunk-BW2fg46T.js";
|
|
6
|
+
import { n as ensureAuth } from "./chunk-CJraEBVd.js";
|
|
7
|
+
import { a as printJson, i as printDetail, r as getTotalCount } from "./chunk-CcFCyqbB.js";
|
|
8
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
12
|
+
import open from "open";
|
|
13
|
+
import { chmod, mkdir, open as open$1, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
14
|
+
var ApiKeyFileExistsError = class extends Error {
|
|
15
|
+
path;
|
|
16
|
+
constructor(path) {
|
|
17
|
+
super(`A key file already exists at ${path}. Pass --force to overwrite it, or --out <path> to write somewhere else.`);
|
|
18
|
+
this.path = path;
|
|
19
|
+
this.name = "ApiKeyFileExistsError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/** Directory key files live in: `$RUNTYPE_STATE_DIR/keys` (default `~/.runtype/keys`). */
|
|
23
|
+
function getApiKeyFileDir() {
|
|
24
|
+
return path.join(getRuntypeHomeDir(), "keys");
|
|
25
|
+
}
|
|
26
|
+
/** Default destination for a key named `name`: `<keys dir>/<slug>.json`. */
|
|
27
|
+
function defaultApiKeyFilePath(name) {
|
|
28
|
+
return path.join(getApiKeyFileDir(), `${apiKeyFileSlug(name)}.json`);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Throw `ApiKeyFileExistsError` if a key file already sits at the destination.
|
|
32
|
+
* A read-only pre-flight for callers that must not spend something irreversible only to collide afterwards.
|
|
33
|
+
* Unlike the write path this reserves nothing; it's advisory, and `writeApiKeyFile`'s `open(..., 'wx')` is still authoritative.
|
|
34
|
+
*/
|
|
35
|
+
async function assertApiKeyFilePathFree(outPath, name) {
|
|
36
|
+
if (!outPath && !name) return;
|
|
37
|
+
const targetPath = path.resolve(outPath ?? defaultApiKeyFilePath(name));
|
|
38
|
+
try {
|
|
39
|
+
await stat(targetPath);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code === "ENOENT") return;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
throw new ApiKeyFileExistsError(targetPath);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validate and write a key file. The content is parsed through
|
|
48
|
+
* `apiKeyFileSchema` first so a malformed record never reaches disk under the
|
|
49
|
+
* canonical format name.
|
|
50
|
+
*/
|
|
51
|
+
async function writeApiKeyFile(file, options = {}) {
|
|
52
|
+
const parsed = apiKeyFileSchema.parse(file);
|
|
53
|
+
const targetPath = path.resolve(options.outPath ?? defaultApiKeyFilePath(parsed.name));
|
|
54
|
+
const directory = path.dirname(targetPath);
|
|
55
|
+
if (await mkdir(directory, {
|
|
56
|
+
recursive: true,
|
|
57
|
+
mode: 448
|
|
58
|
+
}) !== void 0 || directory === getApiKeyFileDir()) await chmod(directory, 448);
|
|
59
|
+
if (!options.force) try {
|
|
60
|
+
await (await open$1(targetPath, "wx", 384)).close();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === "EEXIST") throw new ApiKeyFileExistsError(targetPath);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const temporaryPath = path.join(directory, `.runtype-key-${randomUUID()}.tmp`);
|
|
66
|
+
try {
|
|
67
|
+
await writeFile(temporaryPath, `${JSON.stringify(parsed, null, 2)}\n`, {
|
|
68
|
+
mode: 384,
|
|
69
|
+
flag: "wx"
|
|
70
|
+
});
|
|
71
|
+
await chmod(temporaryPath, 384);
|
|
72
|
+
await rename(temporaryPath, targetPath);
|
|
73
|
+
} finally {
|
|
74
|
+
await rm(temporaryPath, { force: true });
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
path: targetPath,
|
|
78
|
+
format: API_KEY_FILE_FORMAT,
|
|
79
|
+
usageHint: apiKeyFileUsageHint(targetPath)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// src/lib/api-key-request-flow.ts
|
|
83
|
+
var ApiKeyRequestError = class extends Error {
|
|
84
|
+
code;
|
|
85
|
+
resumeCommand;
|
|
86
|
+
constructor(message, code, resumeCommand) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.code = code;
|
|
89
|
+
this.resumeCommand = resumeCommand;
|
|
90
|
+
this.name = "ApiKeyRequestError";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
function base64Url(bytes) {
|
|
94
|
+
return Buffer.from(bytes).toString("base64url");
|
|
95
|
+
}
|
|
96
|
+
/** S256 challenge for a verifier, matching the api's `oauth-code-store` derivation. */
|
|
97
|
+
function deriveCodeChallenge(codeVerifier) {
|
|
98
|
+
return base64Url(createHash("sha256").update(codeVerifier).digest());
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 32 random bytes base64url-encoded → 43 characters, inside both the verifier
|
|
102
|
+
* grammar (43–128 unreserved chars) and the challenge grammar the api pins.
|
|
103
|
+
*/
|
|
104
|
+
function generatePkcePair() {
|
|
105
|
+
const codeVerifier = base64Url(randomBytes(32));
|
|
106
|
+
const codeChallenge = deriveCodeChallenge(codeVerifier);
|
|
107
|
+
pkceCodeVerifierSchema.parse(codeVerifier);
|
|
108
|
+
pkceCodeChallengeSchema.parse(codeChallenge);
|
|
109
|
+
return {
|
|
110
|
+
codeVerifier,
|
|
111
|
+
codeChallenge
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const DURATION_UNIT_SECONDS = {
|
|
115
|
+
s: 1,
|
|
116
|
+
m: 60,
|
|
117
|
+
h: 3600,
|
|
118
|
+
d: 86400,
|
|
119
|
+
w: 604800
|
|
120
|
+
};
|
|
121
|
+
/** `30d`, `12h`, `90m`, `3600s`, or a bare number of seconds. */
|
|
122
|
+
function parseDurationSeconds(input) {
|
|
123
|
+
const trimmed = input.trim().toLowerCase();
|
|
124
|
+
const match = /^(\d+)([smhdw]?)$/.exec(trimmed);
|
|
125
|
+
if (!match) throw new ApiKeyRequestError(`Could not read "${input}" as a duration. Use a number of seconds or a suffixed value like 30d, 12h, 90m.`, "invalid_ttl");
|
|
126
|
+
const seconds = Number(match[1]) * DURATION_UNIT_SECONDS[match[2] || "s"];
|
|
127
|
+
if (seconds <= 0) throw new ApiKeyRequestError("TTL must be greater than zero.", "invalid_ttl");
|
|
128
|
+
if (seconds > 7776e3) throw new ApiKeyRequestError(`TTL ${input} exceeds the maximum requestable lifetime of ${API_KEY_REQUEST_MAX_TTL_SECONDS / 86400} days.`, "invalid_ttl");
|
|
129
|
+
return seconds;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Comma (or whitespace) separated scope list, upper-cased because the api's
|
|
133
|
+
* permission registry is upper-case. Scopes an agent may never ask for are
|
|
134
|
+
* rejected here so the user sees the reason before a human is paged.
|
|
135
|
+
*/
|
|
136
|
+
function parseScopes(input) {
|
|
137
|
+
const scopes = input.split(/[,\s]+/).map((scope) => scope.trim().toUpperCase()).filter((scope) => scope.length > 0);
|
|
138
|
+
if (scopes.length === 0) throw new ApiKeyRequestError("At least one scope is required, e.g. --scopes FLOWS:READ,RECORDS:READ", "invalid_scopes");
|
|
139
|
+
const forbidden = scopes.filter((scope) => isForbiddenRequestedPermission(scope));
|
|
140
|
+
if (forbidden.length > 0) throw new ApiKeyRequestError(`These scopes can never be requested by an agent: ${forbidden.join(", ")}. Ask for the narrowest scopes the task needs.`, "forbidden_scopes");
|
|
141
|
+
return [...new Set(scopes)];
|
|
142
|
+
}
|
|
143
|
+
function wireDeliveryFor(delivery) {
|
|
144
|
+
return delivery.mode === "secret" ? "secret" : "inline";
|
|
145
|
+
}
|
|
146
|
+
async function createApiKeyRequest(client, input) {
|
|
147
|
+
return client.create({
|
|
148
|
+
name: input.name,
|
|
149
|
+
permissions: input.permissions,
|
|
150
|
+
reason: input.reason,
|
|
151
|
+
environment: input.environment,
|
|
152
|
+
...input.ttlSeconds !== void 0 ? { ttlSeconds: input.ttlSeconds } : {},
|
|
153
|
+
preferredDelivery: input.preferredDelivery,
|
|
154
|
+
codeChallenge: input.codeChallenge,
|
|
155
|
+
codeChallengeMethod: "S256",
|
|
156
|
+
...input.returnUrl ? { returnUrl: input.returnUrl } : {},
|
|
157
|
+
allowUnauthenticatedClaim: input.allowUnauthenticatedClaim === true
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function getApiKeyRequest(client, requestId) {
|
|
161
|
+
return client.get(requestId);
|
|
162
|
+
}
|
|
163
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 5;
|
|
164
|
+
function retryAfterMs(error) {
|
|
165
|
+
const raw = (error?.headers)?.get?.("retry-after");
|
|
166
|
+
if (!raw) return null;
|
|
167
|
+
const seconds = Number(raw);
|
|
168
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
169
|
+
const date = Date.parse(raw);
|
|
170
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
function statusCodeOf(error) {
|
|
174
|
+
const code = error?.statusCode;
|
|
175
|
+
return typeof code === "number" ? code : void 0;
|
|
176
|
+
}
|
|
177
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
178
|
+
/**
|
|
179
|
+
* Poll a pending request until it leaves `pending`.
|
|
180
|
+
* Returns whatever terminal request the api reports; a decline comes back as data, not an exception.
|
|
181
|
+
* Transient failures (429 w/ Retry-After, 5xx, network) are retried; a non-rate-limit 4xx is fatal.
|
|
182
|
+
*/
|
|
183
|
+
async function waitForDecision(client, requestId, options = {}) {
|
|
184
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
185
|
+
const now = options.now ?? (() => Date.now());
|
|
186
|
+
const interval = options.pollIntervalMs ?? 5e3;
|
|
187
|
+
const startedAt = now();
|
|
188
|
+
let consecutiveFailures = 0;
|
|
189
|
+
let deadlineMs = options.deadlineMs;
|
|
190
|
+
for (;;) {
|
|
191
|
+
let request;
|
|
192
|
+
try {
|
|
193
|
+
request = await getApiKeyRequest(client, requestId);
|
|
194
|
+
consecutiveFailures = 0;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const status = statusCodeOf(error);
|
|
197
|
+
const wait = retryAfterMs(error);
|
|
198
|
+
const transient = status === 429 || status === void 0 || status >= 500;
|
|
199
|
+
consecutiveFailures += 1;
|
|
200
|
+
if (!transient || consecutiveFailures > MAX_CONSECUTIVE_POLL_FAILURES) throw error;
|
|
201
|
+
await sleep(wait ?? interval);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (request.status !== "pending") return request;
|
|
205
|
+
if (deadlineMs === void 0) {
|
|
206
|
+
const expiresAt = Date.parse(request.expiresAt);
|
|
207
|
+
deadlineMs = Number.isFinite(expiresAt) ? expiresAt : void 0;
|
|
208
|
+
}
|
|
209
|
+
options.onPoll?.(request, now() - startedAt);
|
|
210
|
+
if (deadlineMs !== void 0 && now() + interval > deadlineMs) throw new ApiKeyRequestError(`Request ${requestId} is still pending and its approval window closes at ${request.expiresAt}.`, "pending_timeout");
|
|
211
|
+
await sleep(interval);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Human-readable explanation for a request that ended somewhere other than `approved`. */
|
|
215
|
+
function describeUnapprovedRequest(request) {
|
|
216
|
+
switch (request.status) {
|
|
217
|
+
case "declined": return new ApiKeyRequestError(`Request ${request.id} was declined${request.declineReason ? `: ${request.declineReason}` : "."}`, "declined");
|
|
218
|
+
case "claimed": return new ApiKeyRequestError(`Request ${request.id} was already claimed at ${request.claimedAt}. A request can only be redeemed once; file a new one.`, "already_claimed");
|
|
219
|
+
case "expired": return new ApiKeyRequestError(`Request ${request.id} expired before it was redeemed. File a new request.`, "expired");
|
|
220
|
+
case "cancelled": return new ApiKeyRequestError(`Request ${request.id} was cancelled.`, "cancelled");
|
|
221
|
+
case "pending": return new ApiKeyRequestError(`Request ${request.id} has not been decided yet. Run: RUNTYPE_KEY_REQUEST_VERIFIER=<verifier> runtype api-keys claim ${request.id}`, "pending");
|
|
222
|
+
default: return new ApiKeyRequestError(`Request ${request.id} is in an unexpected state: ${request.status}`, "unexpected_status");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Fail BEFORE the claim if the key file's destination is already taken.
|
|
227
|
+
* Claiming is one-shot and irreversible: a write failure after claiming discards the only copy of a live key.
|
|
228
|
+
* A retry can only answer ALREADY_CLAIMED; re-running a rotation with the same name is exactly this collision.
|
|
229
|
+
* Checking here turns it into an ordinary pre-flight error naming `--force`, before anything is spent.
|
|
230
|
+
*/
|
|
231
|
+
async function assertKeyFileDestinationFree(delivery) {
|
|
232
|
+
if (delivery.mode !== "keyfile" || delivery.force) return;
|
|
233
|
+
if (!delivery.outPath && !delivery.name) return;
|
|
234
|
+
try {
|
|
235
|
+
await assertApiKeyFilePathFree(delivery.outPath, delivery.name);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof ApiKeyFileExistsError) throw new ApiKeyRequestError(error.message, "key_file_exists");
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function errorCodeOf(error) {
|
|
242
|
+
const code = error?.data?.code;
|
|
243
|
+
return typeof code === "string" ? code : void 0;
|
|
244
|
+
}
|
|
245
|
+
function verifierClaimFailure(error, requestId) {
|
|
246
|
+
const status = statusCodeOf(error);
|
|
247
|
+
const code = errorCodeOf(error);
|
|
248
|
+
if (status === 404) return new ApiKeyRequestError(`Request ${requestId} could not be redeemed without a login. Either the verifier is wrong, or the request was not filed with unauthenticated claim allowed. Log in with \`runtype auth login\` and run this command again.`, "not_found_unauthenticated");
|
|
249
|
+
if (status === 409) return new ApiKeyRequestError(`Request ${requestId} was already claimed or its claim window closed. A request can only be redeemed once; file a new one.`, "already_claimed");
|
|
250
|
+
if (status === 410) {
|
|
251
|
+
const reason = code === "DECLINED" ? "declined" : code === "CANCELLED" ? "cancelled" : "expired";
|
|
252
|
+
return new ApiKeyRequestError(`Request ${requestId} was ${reason}. File a new request.`, reason);
|
|
253
|
+
}
|
|
254
|
+
if (status === 403) return new ApiKeyRequestError(error instanceof Error ? error.message : `Request ${requestId} cannot be redeemed without a login.`, "grant_too_broad");
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
async function claimWithVerifierUntilDecided(client, requestId, codeVerifier, options) {
|
|
258
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
259
|
+
const now = options.now ?? (() => Date.now());
|
|
260
|
+
const startedAt = now();
|
|
261
|
+
let consecutiveFailures = 0;
|
|
262
|
+
for (;;) try {
|
|
263
|
+
return await client.claimWithVerifier(requestId, { codeVerifier });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const status = statusCodeOf(error);
|
|
266
|
+
if (status === 425) {
|
|
267
|
+
consecutiveFailures = 0;
|
|
268
|
+
if (!options.wait) throw new ApiKeyRequestError(`Request ${requestId} has not been approved yet. Ask the approver to open its request page, then re-run this command (or add --wait to poll until they decide).`, "pending");
|
|
269
|
+
options.onPending?.(now() - startedAt);
|
|
270
|
+
await sleep(retryAfterMs(error) ?? 5e3);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const mapped = verifierClaimFailure(error, requestId);
|
|
274
|
+
if (mapped) throw mapped;
|
|
275
|
+
const transient = status === 429 || status === void 0 || status >= 500;
|
|
276
|
+
consecutiveFailures += 1;
|
|
277
|
+
if (!transient || consecutiveFailures > MAX_CONSECUTIVE_POLL_FAILURES) throw error;
|
|
278
|
+
await sleep(retryAfterMs(error) ?? 5e3);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function claimApiKeyRequest(client, requestId, codeVerifier, delivery) {
|
|
282
|
+
await assertKeyFileDestinationFree(delivery);
|
|
283
|
+
return client.claim(requestId, {
|
|
284
|
+
codeVerifier,
|
|
285
|
+
delivery: wireDeliveryFor(delivery),
|
|
286
|
+
...delivery.mode === "secret" && delivery.secretKey ? { secretKey: delivery.secretKey } : {}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function buildApiKeyFileContent(input) {
|
|
290
|
+
return apiKeyFileSchema.parse({
|
|
291
|
+
format: API_KEY_FILE_FORMAT,
|
|
292
|
+
apiKey: input.apiKey,
|
|
293
|
+
keyId: input.keyId,
|
|
294
|
+
name: input.name,
|
|
295
|
+
environment: input.environment,
|
|
296
|
+
permissions: input.permissions,
|
|
297
|
+
apiUrl: input.apiUrl ?? getApiUrl(),
|
|
298
|
+
organizationId: input.organizationId,
|
|
299
|
+
expiresAt: input.expiresAt,
|
|
300
|
+
createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
301
|
+
requestId: input.requestId
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Turn a claim response into the artifact the caller asked for. The plaintext
|
|
306
|
+
* key is carried on the result ONLY for `inline` — every other mode leaves it
|
|
307
|
+
* on disk or in the secret plane, so `--json` output cannot leak it.
|
|
308
|
+
*/
|
|
309
|
+
async function deliverClaimedKey(response, delivery) {
|
|
310
|
+
const base = {
|
|
311
|
+
keyId: response.keyId,
|
|
312
|
+
name: response.name,
|
|
313
|
+
environment: response.environment,
|
|
314
|
+
permissions: response.permissions,
|
|
315
|
+
expiresAt: response.expiresAt
|
|
316
|
+
};
|
|
317
|
+
if (delivery.mode === "secret") {
|
|
318
|
+
if (!response.secretRef) throw new ApiKeyRequestError("The claim succeeded but the API returned no secret reference. Re-run with --inline or --out to recover the key.", "missing_secret_ref");
|
|
319
|
+
return {
|
|
320
|
+
...base,
|
|
321
|
+
delivery: "secret",
|
|
322
|
+
secretRef: response.secretRef,
|
|
323
|
+
secretKey: response.secretKey,
|
|
324
|
+
nextSteps: response.nextSteps
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (!response.apiKey) throw new ApiKeyRequestError("The claim succeeded but the API returned no key. It cannot be re-issued — file a new request.", "missing_plaintext");
|
|
328
|
+
if (delivery.mode === "inline") return {
|
|
329
|
+
...base,
|
|
330
|
+
delivery: "inline",
|
|
331
|
+
apiKey: response.apiKey,
|
|
332
|
+
nextSteps: response.nextSteps
|
|
333
|
+
};
|
|
334
|
+
let file;
|
|
335
|
+
try {
|
|
336
|
+
file = await writeApiKeyFile(buildApiKeyFileContent({
|
|
337
|
+
apiKey: response.apiKey,
|
|
338
|
+
keyId: response.keyId,
|
|
339
|
+
name: response.name,
|
|
340
|
+
environment: response.environment,
|
|
341
|
+
permissions: response.permissions,
|
|
342
|
+
expiresAt: response.expiresAt,
|
|
343
|
+
organizationId: response.request?.organizationId ?? null,
|
|
344
|
+
requestId: response.request?.id ?? null
|
|
345
|
+
}), {
|
|
346
|
+
outPath: delivery.outPath,
|
|
347
|
+
force: delivery.force
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
throw new ApiKeyRequestError(`The key was minted but could not be written to disk (${error instanceof Error ? error.message : String(error)}). It cannot be re-issued: this request is already claimed. Revoke key ${response.keyId} and file a new request.`, "key_file_write_failed");
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
...base,
|
|
354
|
+
delivery: "keyfile",
|
|
355
|
+
file,
|
|
356
|
+
nextSteps: file.usageHint
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* `--json` projection. The plaintext key appears only when the caller asked
|
|
361
|
+
* for `--inline`; a key file or secret delivery emits its pointer instead.
|
|
362
|
+
*/
|
|
363
|
+
function deliveredKeyJson(delivered) {
|
|
364
|
+
return {
|
|
365
|
+
keyId: delivered.keyId,
|
|
366
|
+
name: delivered.name,
|
|
367
|
+
environment: delivered.environment,
|
|
368
|
+
permissions: delivered.permissions,
|
|
369
|
+
expiresAt: delivered.expiresAt,
|
|
370
|
+
delivery: delivered.delivery,
|
|
371
|
+
...delivered.file ? {
|
|
372
|
+
path: delivered.file.path,
|
|
373
|
+
format: delivered.file.format,
|
|
374
|
+
usageHint: delivered.file.usageHint
|
|
375
|
+
} : {},
|
|
376
|
+
...delivered.apiKey ? { apiKey: delivered.apiKey } : {},
|
|
377
|
+
...delivered.secretRef ? { secretRef: delivered.secretRef } : {},
|
|
378
|
+
...delivered.secretKey ? { secretKey: delivered.secretKey } : {},
|
|
379
|
+
nextSteps: delivered.nextSteps
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// src/commands/api-keys-requests.ts
|
|
383
|
+
/**
|
|
384
|
+
* `runtype api-keys request` and `runtype api-keys claim` are the CLI half of the agent key-request ceremony.
|
|
385
|
+
* `request` files the request, hands a human the approval URL, polls, and redeems.
|
|
386
|
+
* `claim` is the resume/handoff entry point for an MCP-driven agent that cannot hold a file.
|
|
387
|
+
* The ceremony itself lives in lib/api-key-request-flow.ts; this module is flags, prompts and output.
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* Resolve the mutually exclusive delivery flags shared by both commands.
|
|
391
|
+
* Default is a key file, because the point of the ceremony is that a local
|
|
392
|
+
* agent can USE the key without it ever appearing in a transcript.
|
|
393
|
+
*/
|
|
394
|
+
function resolveDelivery(options, keyName) {
|
|
395
|
+
const chosen = [];
|
|
396
|
+
if (options.out !== void 0) chosen.push("--out");
|
|
397
|
+
if (options.secret !== void 0 && options.secret !== false) chosen.push("--secret");
|
|
398
|
+
if (options.inline) chosen.push("--inline");
|
|
399
|
+
if (chosen.length > 1) throw new ApiKeyRequestError(`Choose one delivery: ${chosen.join(", ")} cannot be combined.`, "conflicting_delivery");
|
|
400
|
+
if (options.secret !== void 0 && options.secret !== false) {
|
|
401
|
+
if (typeof options.secret === "string") {
|
|
402
|
+
if (!/^[A-Z][A-Z0-9_]{1,99}$/.test(options.secret)) throw new ApiKeyRequestError(`"${options.secret}" is not a valid secret key. Use uppercase letters, digits and underscores, starting with a letter (for example RUNTYPE_API_KEY_DEPLOY_BOT).`, "invalid_secret_key");
|
|
403
|
+
return {
|
|
404
|
+
mode: "secret",
|
|
405
|
+
secretKey: options.secret
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { mode: "secret" };
|
|
409
|
+
}
|
|
410
|
+
if (options.inline) return { mode: "inline" };
|
|
411
|
+
return {
|
|
412
|
+
mode: "keyfile",
|
|
413
|
+
outPath: options.out,
|
|
414
|
+
force: options.force,
|
|
415
|
+
...keyName ? { name: keyName } : {}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function formatElapsed(ms) {
|
|
419
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
420
|
+
const minutes = Math.floor(total / 60);
|
|
421
|
+
const seconds = total % 60;
|
|
422
|
+
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
423
|
+
}
|
|
424
|
+
/** Run `work` under an ink spinner when the terminal can show one. */
|
|
425
|
+
async function withSpinner(useInk, initialLabel, work) {
|
|
426
|
+
if (!useInk) {
|
|
427
|
+
console.log(chalk.gray(initialLabel));
|
|
428
|
+
return work(() => {});
|
|
429
|
+
}
|
|
430
|
+
const { Spinner } = await import("@runtypelabs/ink-components");
|
|
431
|
+
const instance = render(React.createElement(Spinner, { label: initialLabel }));
|
|
432
|
+
try {
|
|
433
|
+
return await work((label) => {
|
|
434
|
+
instance.rerender(React.createElement(Spinner, { label }));
|
|
435
|
+
});
|
|
436
|
+
} finally {
|
|
437
|
+
instance.unmount();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function printDeliveredKey(delivered) {
|
|
441
|
+
console.log(chalk.green("API key claimed"));
|
|
442
|
+
console.log(` Key ID: ${chalk.green(delivered.keyId)}`);
|
|
443
|
+
console.log(` Name: ${delivered.name}`);
|
|
444
|
+
console.log(` Environment: ${delivered.environment}`);
|
|
445
|
+
console.log(` Scopes: ${delivered.permissions.join(", ")}`);
|
|
446
|
+
console.log(` Expires: ${delivered.expiresAt ?? "never"}`);
|
|
447
|
+
if (delivered.file) console.log(` Key file: ${chalk.cyan(delivered.file.path)}`);
|
|
448
|
+
if (delivered.secretRef) console.log(` Secret: ${chalk.cyan(delivered.secretRef)}`);
|
|
449
|
+
if (delivered.apiKey) {
|
|
450
|
+
console.log(` Key: ${chalk.yellow(delivered.apiKey)}`);
|
|
451
|
+
console.log(chalk.yellow(" Shown once. Store it now, and do not paste it into a chat transcript or commit it."));
|
|
452
|
+
}
|
|
453
|
+
console.log("");
|
|
454
|
+
console.log(delivered.nextSteps);
|
|
455
|
+
}
|
|
456
|
+
/** States where re-running `claim` cannot help, so no resume hint is offered. */
|
|
457
|
+
const UNRESUMABLE_CODES = /* @__PURE__ */ new Set([
|
|
458
|
+
"declined",
|
|
459
|
+
"expired",
|
|
460
|
+
"cancelled",
|
|
461
|
+
"already_claimed",
|
|
462
|
+
"missing_plaintext",
|
|
463
|
+
"key_file_write_failed"
|
|
464
|
+
]);
|
|
465
|
+
function failRequest(error, resumeCommand) {
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
467
|
+
const code = error instanceof ApiKeyRequestError ? error.code : void 0;
|
|
468
|
+
console.error(chalk.red(message));
|
|
469
|
+
if (resumeCommand && (!code || !UNRESUMABLE_CODES.has(code))) console.error(chalk.gray(`Resume with: ${resumeCommand}`));
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Poll for the decision, then redeem. Shared by `request` (which just filed
|
|
474
|
+
* the request) and `claim --wait`.
|
|
475
|
+
*/
|
|
476
|
+
async function awaitDecisionAndClaim(args) {
|
|
477
|
+
const decided = await withSpinner(args.useInk, "Waiting for a human to approve this request...", async (setLabel) => waitForDecision(args.client, args.requestId, {
|
|
478
|
+
pollIntervalMs: args.pollIntervalMs,
|
|
479
|
+
onPoll: (_request, elapsedMs) => {
|
|
480
|
+
setLabel(`Waiting for a human to approve this request... (${formatElapsed(elapsedMs)})`);
|
|
481
|
+
}
|
|
482
|
+
}));
|
|
483
|
+
if (decided.status !== "approved") throw describeUnapprovedRequest(decided);
|
|
484
|
+
return deliverClaimedKey(await claimApiKeyRequest(args.client, args.requestId, args.codeVerifier, args.delivery), args.delivery);
|
|
485
|
+
}
|
|
486
|
+
function registerApiKeyRequestCommands(apiKeysCommand) {
|
|
487
|
+
apiKeysCommand.command("request").description("Ask a human to approve a new, narrower API key, then redeem it").requiredOption("-n, --name <name>", "Name for the requested key").requiredOption("--scopes <list>", "Comma-separated scopes, e.g. FLOWS:READ,RECORDS:READ").requiredOption("--reason <text>", "Why the key is needed (shown to the approver)").option("--ttl <duration>", "Requested lifetime, e.g. 30d, 12h, 3600").option("--env <environment>", "Key environment: live or test", "live").option("--out <path>", "Write the key file here instead of ~/.runtype/keys/<name>.json").option("--secret [key]", "Deliver as a Runtype secret instead of a key file").option("--inline", "Print the key to stdout instead of writing a key file").option("--force", "Overwrite an existing key file").option("--no-open", "Do not launch a browser for the approval page").option("--allow-unauthenticated-claim", "Let the verifier alone redeem the key, on a machine with no Runtype login (own scopes only, at most 7 days)").option("--json", "Output as JSON").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").action(async (options) => {
|
|
488
|
+
let delivery;
|
|
489
|
+
let permissions;
|
|
490
|
+
let ttlSeconds;
|
|
491
|
+
try {
|
|
492
|
+
delivery = resolveDelivery(options, options.name);
|
|
493
|
+
permissions = parseScopes(options.scopes);
|
|
494
|
+
ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl) : void 0;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
failRequest(error);
|
|
497
|
+
}
|
|
498
|
+
if (options.env !== "live" && options.env !== "test") failRequest(new ApiKeyRequestError(`--env must be "live" or "test" (got "${options.env}").`, "invalid_environment"));
|
|
499
|
+
const environment = options.env;
|
|
500
|
+
const apiKey = await ensureAuth();
|
|
501
|
+
if (!apiKey) return;
|
|
502
|
+
const client = createCliClient(apiKey).apiKeys.requests;
|
|
503
|
+
const { codeVerifier, codeChallenge } = generatePkcePair();
|
|
504
|
+
const useInk = isTTY(options) && !options.json;
|
|
505
|
+
let created;
|
|
506
|
+
try {
|
|
507
|
+
created = await createApiKeyRequest(client, {
|
|
508
|
+
name: options.name,
|
|
509
|
+
permissions,
|
|
510
|
+
reason: options.reason,
|
|
511
|
+
environment,
|
|
512
|
+
ttlSeconds,
|
|
513
|
+
preferredDelivery: delivery.mode,
|
|
514
|
+
codeChallenge,
|
|
515
|
+
allowUnauthenticatedClaim: options.allowUnauthenticatedClaim
|
|
516
|
+
});
|
|
517
|
+
} catch (error) {
|
|
518
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
519
|
+
console.error(chalk.red("Failed to file the API key request"));
|
|
520
|
+
console.error(chalk.red(message));
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
const requestId = created.request.id;
|
|
524
|
+
const resumeCommand = `RUNTYPE_KEY_REQUEST_VERIFIER=${codeVerifier} runtype api-keys claim ${requestId}`;
|
|
525
|
+
if (!options.json) {
|
|
526
|
+
console.log(chalk.green("API key request filed"));
|
|
527
|
+
console.log(` Request: ${chalk.green(requestId)}`);
|
|
528
|
+
console.log(` Name: ${created.request.name}`);
|
|
529
|
+
console.log(` Scopes: ${created.request.requestedPermissions.join(", ")}`);
|
|
530
|
+
console.log(` Approve at: ${chalk.cyan(created.handoff.url)}`);
|
|
531
|
+
console.log("");
|
|
532
|
+
console.log(created.nextSteps);
|
|
533
|
+
if (delivery.mode === "inline") console.log(chalk.yellow("Warning: --inline prints the key to stdout. Anything reading this terminal, including a chat transcript, will see it."));
|
|
534
|
+
console.log("");
|
|
535
|
+
}
|
|
536
|
+
if (options.open) try {
|
|
537
|
+
await open(created.handoff.url);
|
|
538
|
+
} catch {
|
|
539
|
+
if (!options.json) console.log(chalk.gray("Could not open a browser. Open the approval URL above."));
|
|
540
|
+
}
|
|
541
|
+
const onInterrupt = () => {
|
|
542
|
+
console.error(chalk.gray(`\nStill pending. Resume with: ${resumeCommand}`));
|
|
543
|
+
process.exit(130);
|
|
544
|
+
};
|
|
545
|
+
process.once("SIGINT", onInterrupt);
|
|
546
|
+
try {
|
|
547
|
+
const delivered = await awaitDecisionAndClaim({
|
|
548
|
+
client,
|
|
549
|
+
requestId,
|
|
550
|
+
codeVerifier,
|
|
551
|
+
delivery,
|
|
552
|
+
pollIntervalMs: created.pollIntervalMs || 5e3,
|
|
553
|
+
useInk
|
|
554
|
+
});
|
|
555
|
+
if (options.json) printJson({
|
|
556
|
+
requestId,
|
|
557
|
+
...deliveredKeyJson(delivered)
|
|
558
|
+
});
|
|
559
|
+
else printDeliveredKey(delivered);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
failRequest(error, resumeCommand);
|
|
562
|
+
} finally {
|
|
563
|
+
process.removeListener("SIGINT", onInterrupt);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
apiKeysCommand.command("claim <requestId>").description("Redeem an approved API key request with its PKCE verifier").option("--verifier <verifier>", `The code verifier (prefer ${VERIFIER_ENV} or --verifier-stdin: argv is visible to other processes)`).option("--verifier-stdin", "Read the code verifier from stdin").option("--out <path>", "Write the key file here (default: key name when logged in, request ID otherwise)").option("--secret [key]", "Deliver as a Runtype secret instead of a key file").option("--inline", "Print the key to stdout instead of writing a key file").option("--force", "Overwrite an existing key file").option("--wait", "Keep polling while the request is still pending").option("--unauthenticated", "Redeem with the verifier alone even when logged in (the request must allow it)").option("--json", "Output as JSON").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").action(async (requestId, options) => {
|
|
567
|
+
let delivery;
|
|
568
|
+
let codeVerifier;
|
|
569
|
+
try {
|
|
570
|
+
delivery = resolveDelivery(options);
|
|
571
|
+
codeVerifier = await resolveVerifier(options);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
failRequest(error);
|
|
574
|
+
}
|
|
575
|
+
const resumeCommand = `${VERIFIER_ENV}=<verifier> runtype api-keys claim ${requestId}`;
|
|
576
|
+
const useInk = isTTY(options) && !options.json;
|
|
577
|
+
const storedKey = options.unauthenticated ? null : await ensureAuth({
|
|
578
|
+
interactive: false,
|
|
579
|
+
exitOnFailure: false
|
|
580
|
+
});
|
|
581
|
+
const claimLoggedIn = (apiKey) => claimWithLogin({
|
|
582
|
+
apiKey,
|
|
583
|
+
requestId,
|
|
584
|
+
codeVerifier,
|
|
585
|
+
options,
|
|
586
|
+
useInk
|
|
587
|
+
});
|
|
588
|
+
try {
|
|
589
|
+
let delivered;
|
|
590
|
+
if (storedKey) delivered = await claimLoggedIn(storedKey);
|
|
591
|
+
else try {
|
|
592
|
+
delivered = await claimWithoutLogin({
|
|
593
|
+
requestId,
|
|
594
|
+
codeVerifier,
|
|
595
|
+
delivery,
|
|
596
|
+
wait: options.wait === true,
|
|
597
|
+
useInk,
|
|
598
|
+
json: options.json === true
|
|
599
|
+
});
|
|
600
|
+
} catch (error) {
|
|
601
|
+
const apiKey = shouldOfferLogin(options, error) ? await ensureAuth({ exitOnFailure: false }) : null;
|
|
602
|
+
if (!apiKey) throw error;
|
|
603
|
+
delivered = await claimLoggedIn(apiKey);
|
|
604
|
+
}
|
|
605
|
+
if (options.json) printJson({
|
|
606
|
+
requestId,
|
|
607
|
+
...deliveredKeyJson(delivered)
|
|
608
|
+
});
|
|
609
|
+
else printDeliveredKey(delivered);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
failRequest(error, resumeCommand);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
const VERIFIER_ENV = "RUNTYPE_KEY_REQUEST_VERIFIER";
|
|
616
|
+
const LOGIN_RECOVERABLE_CODES = /* @__PURE__ */ new Set(["not_found_unauthenticated", "secret_requires_login"]);
|
|
617
|
+
function shouldOfferLogin(options, error) {
|
|
618
|
+
return !options.unauthenticated && !options.json && process.stdin.isTTY === true && error instanceof ApiKeyRequestError && LOGIN_RECOVERABLE_CODES.has(error.code);
|
|
619
|
+
}
|
|
620
|
+
/** Logged-in redemption: read the request, re-resolve delivery against its name, then claim. */
|
|
621
|
+
async function claimWithLogin(args) {
|
|
622
|
+
const { requestId, codeVerifier, options, useInk } = args;
|
|
623
|
+
const client = createCliClient(args.apiKey).apiKeys.requests;
|
|
624
|
+
const current = await getApiKeyRequest(client, requestId);
|
|
625
|
+
if (current.status !== "pending" && current.status !== "approved") throw describeUnapprovedRequest(current);
|
|
626
|
+
const delivery = resolveDelivery(options, current.name);
|
|
627
|
+
if (current.status === "pending" && !options.wait) throw new ApiKeyRequestError(`Request ${requestId} has not been approved yet. Ask the approver to open its request page, then re-run this command (or add --wait to poll until they decide).`, "pending");
|
|
628
|
+
return current.status === "pending" ? awaitDecisionAndClaim({
|
|
629
|
+
client,
|
|
630
|
+
requestId,
|
|
631
|
+
codeVerifier,
|
|
632
|
+
delivery,
|
|
633
|
+
pollIntervalMs: API_KEY_REQUEST_POLL_INTERVAL_MS,
|
|
634
|
+
useInk
|
|
635
|
+
}) : deliverClaimedKey(await claimApiKeyRequest(client, requestId, codeVerifier, delivery), delivery);
|
|
636
|
+
}
|
|
637
|
+
async function readStdin() {
|
|
638
|
+
const chunks = [];
|
|
639
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
640
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Exactly one verifier source: `--verifier`, `--verifier-stdin`, or the environment.
|
|
644
|
+
* The flag is kept for compatibility; stdin and the environment keep it out of `ps` and shell history.
|
|
645
|
+
*/
|
|
646
|
+
async function resolveVerifier(options, io = {}) {
|
|
647
|
+
const fromEnv = (io.env ?? process.env)[VERIFIER_ENV]?.trim() || void 0;
|
|
648
|
+
const sources = [options.verifier !== void 0 ? "--verifier" : null, options.verifierStdin ? "--verifier-stdin" : null].filter((source) => source !== null);
|
|
649
|
+
if (sources.length > 1) throw new ApiKeyRequestError(`Choose one verifier source: ${sources.join(", ")} cannot be combined.`, "conflicting_verifier");
|
|
650
|
+
const verifier = options.verifierStdin ? (await (io.readStdin ?? readStdin)()).trim() : options.verifier ?? fromEnv;
|
|
651
|
+
if (!verifier) throw new ApiKeyRequestError(`Provide the code verifier via ${VERIFIER_ENV}, --verifier-stdin, or --verifier.`, "missing_verifier");
|
|
652
|
+
if (!pkceCodeVerifierSchema.safeParse(verifier).success) throw new ApiKeyRequestError("The verifier is not a valid PKCE code verifier (43-128 unreserved characters).", "invalid_verifier");
|
|
653
|
+
return verifier;
|
|
654
|
+
}
|
|
655
|
+
/** No stored login: redeem through the public verifier-only route, which mints inline only. */
|
|
656
|
+
async function claimWithoutLogin(args) {
|
|
657
|
+
if (args.delivery.mode === "secret") throw new ApiKeyRequestError("Secret delivery needs a Runtype login. Use the default key file or --inline, or log in with `runtype auth login` first.", "secret_requires_login");
|
|
658
|
+
const delivery = args.delivery.mode === "keyfile" ? {
|
|
659
|
+
...args.delivery,
|
|
660
|
+
outPath: args.delivery.outPath ?? defaultApiKeyFilePath(args.requestId)
|
|
661
|
+
} : args.delivery;
|
|
662
|
+
await assertKeyFileDestinationFree(delivery);
|
|
663
|
+
const client = createCliClient().apiKeys.requests;
|
|
664
|
+
const redeem = (setLabel) => claimWithVerifierUntilDecided(client, args.requestId, args.codeVerifier, {
|
|
665
|
+
wait: args.wait,
|
|
666
|
+
onPending: (elapsedMs) => setLabel(`Waiting for a human to approve this request... (${formatElapsed(elapsedMs)})`)
|
|
667
|
+
});
|
|
668
|
+
return deliverClaimedKey(args.json ? await redeem(() => {}) : await withSpinner(args.useInk && args.wait, "Redeeming without a login...", redeem), delivery);
|
|
669
|
+
}
|
|
670
|
+
// src/commands/api-keys.ts
|
|
671
|
+
/** Best-effort org id for the key file; provenance only, never authorization. */
|
|
672
|
+
async function storedOrganizationId() {
|
|
673
|
+
try {
|
|
674
|
+
return (await new CredentialStore().getCredentials())?.orgId ?? null;
|
|
675
|
+
} catch {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/** Parses `--permissions FLOWS:*,EVALS:WRITE`; the API has no default scopes, so an empty list is refused. */
|
|
680
|
+
function parsePermissionList(value) {
|
|
681
|
+
const scopes = [...new Set(value.split(",").map((scope) => scope.trim()).filter(Boolean))];
|
|
682
|
+
if (scopes.length === 0) throw new InvalidArgumentError("pass at least one scope, e.g. --permissions FLOWS:*,EVALS:WRITE");
|
|
683
|
+
return scopes;
|
|
684
|
+
}
|
|
685
|
+
const apiKeysCommand = new Command("api-keys").alias("keys").description("Manage API keys");
|
|
686
|
+
apiKeysCommand.command("list").description("List your API keys").option("--json", "Output as JSON").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").action(async (options) => {
|
|
687
|
+
const apiKey = await ensureAuth();
|
|
688
|
+
if (!apiKey) return;
|
|
689
|
+
const client = createCliClient(apiKey);
|
|
690
|
+
if (!isTTY(options) || options.json) {
|
|
691
|
+
try {
|
|
692
|
+
const data = await client.get("/api-keys");
|
|
693
|
+
if (options.json) printJson(data);
|
|
694
|
+
else {
|
|
695
|
+
const keys = data.data ?? [];
|
|
696
|
+
if (keys.length === 0) {
|
|
697
|
+
console.log(chalk.gray("No API keys found"));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
console.log(chalk.cyan("Your API Keys:"));
|
|
701
|
+
for (const key of keys) {
|
|
702
|
+
const prefix = key.prefix ? chalk.gray(` (${key.prefix}...)`) : "";
|
|
703
|
+
const lastUsed = key.lastUsedAt ? chalk.gray(` last used: ${key.lastUsedAt}`) : "";
|
|
704
|
+
console.log(` ${chalk.green(key.id)} ${key.name}${prefix}${lastUsed}`);
|
|
705
|
+
}
|
|
706
|
+
const total = getTotalCount(data.pagination);
|
|
707
|
+
if (total !== void 0) console.log(chalk.dim(`\n Total: ${total} keys`));
|
|
708
|
+
}
|
|
709
|
+
} catch (error) {
|
|
710
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
711
|
+
console.error(chalk.red("Failed to fetch API keys"));
|
|
712
|
+
console.error(chalk.red(message));
|
|
713
|
+
process.exit(1);
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const App = () => {
|
|
718
|
+
const [loading, setLoading] = useState(true);
|
|
719
|
+
const [items, setItems] = useState(null);
|
|
720
|
+
const [total, setTotal] = useState(void 0);
|
|
721
|
+
const [error, setError] = useState(null);
|
|
722
|
+
useEffect(() => {
|
|
723
|
+
const run = async () => {
|
|
724
|
+
try {
|
|
725
|
+
const data = await client.get("/api-keys");
|
|
726
|
+
setItems(data.data ?? []);
|
|
727
|
+
setTotal(getTotalCount(data.pagination));
|
|
728
|
+
setLoading(false);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
731
|
+
setLoading(false);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
run();
|
|
735
|
+
}, []);
|
|
736
|
+
return React.createElement(DataList, {
|
|
737
|
+
title: "Your API Keys",
|
|
738
|
+
items,
|
|
739
|
+
error,
|
|
740
|
+
loading,
|
|
741
|
+
total,
|
|
742
|
+
emptyMessage: "No API keys found",
|
|
743
|
+
renderCard: (item) => {
|
|
744
|
+
const k = item;
|
|
745
|
+
const prefix = k.prefix ? ` (${k.prefix}...)` : "";
|
|
746
|
+
const lastUsed = k.lastUsedAt ? ` last used: ${k.lastUsedAt}` : "";
|
|
747
|
+
return React.createElement(Text, null, ` ${k.id} ${k.name}${prefix}${lastUsed}`);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
};
|
|
751
|
+
const { waitUntilExit } = render(React.createElement(App));
|
|
752
|
+
await waitUntilExit();
|
|
753
|
+
});
|
|
754
|
+
apiKeysCommand.command("get <id>").description("Get API key details").option("--json", "Output as JSON").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").action(async (id, options) => {
|
|
755
|
+
const apiKey = await ensureAuth();
|
|
756
|
+
if (!apiKey) return;
|
|
757
|
+
const client = createCliClient(apiKey);
|
|
758
|
+
if (!isTTY(options) || options.json) {
|
|
759
|
+
try {
|
|
760
|
+
const data = await client.get(`/api-keys/${id}`);
|
|
761
|
+
if (options.json) printJson(data);
|
|
762
|
+
else printDetail("API Key", [
|
|
763
|
+
{
|
|
764
|
+
label: "ID",
|
|
765
|
+
value: data.id
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
label: "Name",
|
|
769
|
+
value: data.name
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
label: "Prefix",
|
|
773
|
+
value: data.prefix
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
label: "Scopes",
|
|
777
|
+
value: data.scopes?.join(", ")
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
label: "Last used",
|
|
781
|
+
value: data.lastUsedAt
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
label: "Created",
|
|
785
|
+
value: data.createdAt
|
|
786
|
+
}
|
|
787
|
+
]);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
790
|
+
console.error(chalk.red("Failed to fetch API key"));
|
|
791
|
+
console.error(chalk.red(message));
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const App = () => {
|
|
797
|
+
const mutation = useMutationState();
|
|
798
|
+
useEffect(() => {
|
|
799
|
+
const run = async () => {
|
|
800
|
+
try {
|
|
801
|
+
const data = await client.get(`/api-keys/${id}`);
|
|
802
|
+
mutation.succeed(React.createElement(EntityCard, { fields: [
|
|
803
|
+
{
|
|
804
|
+
label: "ID",
|
|
805
|
+
value: data.id
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
label: "Name",
|
|
809
|
+
value: data.name
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
label: "Prefix",
|
|
813
|
+
value: data.prefix
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
label: "Scopes",
|
|
817
|
+
value: data.scopes?.join(", ")
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
label: "Last used",
|
|
821
|
+
value: data.lastUsedAt
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
label: "Created",
|
|
825
|
+
value: data.createdAt
|
|
826
|
+
}
|
|
827
|
+
] }));
|
|
828
|
+
} catch (err) {
|
|
829
|
+
mutation.fail(err);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
run();
|
|
833
|
+
}, []);
|
|
834
|
+
return React.createElement(MutationResult, {
|
|
835
|
+
...mutationResultProps(mutation.state),
|
|
836
|
+
loadingLabel: "Fetching API key...",
|
|
837
|
+
successMessage: "API Key"
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
const { waitUntilExit } = render(React.createElement(App));
|
|
841
|
+
await waitUntilExit();
|
|
842
|
+
});
|
|
843
|
+
apiKeysCommand.command("create").description("Create a new API key").requiredOption("-n, --name <name>", "Key name").requiredOption("-p, --permissions <scopes>", "Comma-separated scopes, e.g. FLOWS:*,EVALS:WRITE (required; list them with the API GET /v1/api-keys/options)", parsePermissionList).option("--out <path>", "Write the key to a runtype.api-key/v1 key file (mode 0600)").option("--force", "Overwrite an existing key file at --out").option("--json", "Output as JSON").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").action(async (options) => {
|
|
844
|
+
const apiKey = await ensureAuth();
|
|
845
|
+
if (!apiKey) return;
|
|
846
|
+
const client = createCliClient(apiKey);
|
|
847
|
+
/**
|
|
848
|
+
* Write the freshly minted plaintext to the canonical key file. Same
|
|
849
|
+
* writer, same format, and the same 0600 guarantees as the agent
|
|
850
|
+
* key-request claim path, so there is exactly one on-disk key format.
|
|
851
|
+
*/
|
|
852
|
+
const writeKeyFile = async (created) => {
|
|
853
|
+
if (!options.out || !created.plainKey) return void 0;
|
|
854
|
+
return writeApiKeyFile(buildApiKeyFileContent({
|
|
855
|
+
apiKey: created.plainKey,
|
|
856
|
+
keyId: created.apiKey.id,
|
|
857
|
+
name: created.apiKey.name ?? options.name,
|
|
858
|
+
environment: created.apiKey.environment === "test" ? "test" : "live",
|
|
859
|
+
permissions: created.apiKey.permissions ?? created.apiKey.scopes ?? [],
|
|
860
|
+
expiresAt: created.apiKey.expiresAt ?? null,
|
|
861
|
+
organizationId: created.apiKey.organizationId ?? await storedOrganizationId(),
|
|
862
|
+
requestId: null
|
|
863
|
+
}), {
|
|
864
|
+
outPath: options.out,
|
|
865
|
+
force: options.force
|
|
866
|
+
});
|
|
867
|
+
};
|
|
868
|
+
if (!isTTY(options) || options.json) {
|
|
869
|
+
try {
|
|
870
|
+
const created = await client.post("/api-keys", {
|
|
871
|
+
name: options.name,
|
|
872
|
+
permissions: options.permissions
|
|
873
|
+
});
|
|
874
|
+
const file = await writeKeyFile(created);
|
|
875
|
+
if (options.json) printJson(file ? {
|
|
876
|
+
...created,
|
|
877
|
+
keyFile: {
|
|
878
|
+
path: file.path,
|
|
879
|
+
format: file.format,
|
|
880
|
+
usageHint: file.usageHint
|
|
881
|
+
}
|
|
882
|
+
} : created);
|
|
883
|
+
else {
|
|
884
|
+
console.log(chalk.green("API key created"));
|
|
885
|
+
console.log(` ID: ${chalk.green(created.apiKey.id)}`);
|
|
886
|
+
console.log(` Name: ${created.apiKey.name}`);
|
|
887
|
+
if (file) {
|
|
888
|
+
console.log(` Key file: ${chalk.cyan(file.path)}`);
|
|
889
|
+
console.log("");
|
|
890
|
+
console.log(file.usageHint);
|
|
891
|
+
} else if (created.plainKey) console.log(chalk.gray(" Key not shown. Re-run with --out <path> to write a key file, or --json."));
|
|
892
|
+
}
|
|
893
|
+
} catch (error) {
|
|
894
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
895
|
+
console.error(chalk.red("Failed to create API key"));
|
|
896
|
+
console.error(chalk.red(message));
|
|
897
|
+
process.exit(1);
|
|
898
|
+
}
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const App = () => {
|
|
902
|
+
const mutation = useMutationState();
|
|
903
|
+
useEffect(() => {
|
|
904
|
+
const run = async () => {
|
|
905
|
+
try {
|
|
906
|
+
const created = await client.post("/api-keys", {
|
|
907
|
+
name: options.name,
|
|
908
|
+
permissions: options.permissions
|
|
909
|
+
});
|
|
910
|
+
const file = await writeKeyFile(created);
|
|
911
|
+
const fields = [{
|
|
912
|
+
label: "ID",
|
|
913
|
+
value: created.apiKey.id,
|
|
914
|
+
color: "green"
|
|
915
|
+
}, {
|
|
916
|
+
label: "Name",
|
|
917
|
+
value: created.apiKey.name
|
|
918
|
+
}];
|
|
919
|
+
if (file) {
|
|
920
|
+
fields.push({
|
|
921
|
+
label: "Key file",
|
|
922
|
+
value: file.path,
|
|
923
|
+
color: "cyan"
|
|
924
|
+
});
|
|
925
|
+
fields.push({
|
|
926
|
+
label: "Usage",
|
|
927
|
+
value: file.usageHint
|
|
928
|
+
});
|
|
929
|
+
} else if (created.plainKey) {
|
|
930
|
+
fields.push({
|
|
931
|
+
label: "Key",
|
|
932
|
+
value: created.plainKey,
|
|
933
|
+
color: "yellow"
|
|
934
|
+
});
|
|
935
|
+
fields.push({
|
|
936
|
+
label: "Note",
|
|
937
|
+
value: "Save this key - it will not be shown again"
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
mutation.succeed(React.createElement(EntityCard, { fields }));
|
|
941
|
+
} catch (err) {
|
|
942
|
+
mutation.fail(err);
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
run();
|
|
946
|
+
}, []);
|
|
947
|
+
return React.createElement(MutationResult, {
|
|
948
|
+
...mutationResultProps(mutation.state),
|
|
949
|
+
loadingLabel: "Creating API key...",
|
|
950
|
+
successMessage: "API key created"
|
|
951
|
+
});
|
|
952
|
+
};
|
|
953
|
+
const { waitUntilExit } = render(React.createElement(App));
|
|
954
|
+
await waitUntilExit();
|
|
955
|
+
});
|
|
956
|
+
apiKeysCommand.command("delete <id>").description("Delete an API key").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").option("--yes", "Skip confirmation").action(async (id, options) => {
|
|
957
|
+
const apiKey = await ensureAuth();
|
|
958
|
+
if (!apiKey) return;
|
|
959
|
+
const client = createCliClient(apiKey);
|
|
960
|
+
if (!isTTY(options)) {
|
|
961
|
+
try {
|
|
962
|
+
await client.apiKeys.delete(id);
|
|
963
|
+
console.log(chalk.green("API key deleted"));
|
|
964
|
+
} catch (error) {
|
|
965
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
966
|
+
console.error(chalk.red("Failed to delete API key"));
|
|
967
|
+
console.error(chalk.red(message));
|
|
968
|
+
process.exit(1);
|
|
969
|
+
}
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (options.yes) {
|
|
973
|
+
const App = () => {
|
|
974
|
+
const mutation = useMutationState();
|
|
975
|
+
useEffect(() => {
|
|
976
|
+
const run = async () => {
|
|
977
|
+
try {
|
|
978
|
+
await client.apiKeys.delete(id);
|
|
979
|
+
mutation.succeed();
|
|
980
|
+
} catch (err) {
|
|
981
|
+
mutation.fail(err);
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
run();
|
|
985
|
+
}, []);
|
|
986
|
+
return React.createElement(MutationResult, {
|
|
987
|
+
...mutationResultProps(mutation.state),
|
|
988
|
+
loadingLabel: "Deleting API key...",
|
|
989
|
+
successMessage: "API key deleted"
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
const { waitUntilExit } = render(React.createElement(App));
|
|
993
|
+
await waitUntilExit();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (!await new Promise((resolve) => {
|
|
997
|
+
const { unmount } = render(React.createElement(ConfirmPrompt, {
|
|
998
|
+
message: `Delete API key ${id}?`,
|
|
999
|
+
defaultValue: false,
|
|
1000
|
+
onConfirm: (result) => {
|
|
1001
|
+
resolve(result);
|
|
1002
|
+
unmount();
|
|
1003
|
+
}
|
|
1004
|
+
}));
|
|
1005
|
+
})) return;
|
|
1006
|
+
const DeleteApp = () => {
|
|
1007
|
+
const mutation = useMutationState();
|
|
1008
|
+
useEffect(() => {
|
|
1009
|
+
const run = async () => {
|
|
1010
|
+
try {
|
|
1011
|
+
await client.apiKeys.delete(id);
|
|
1012
|
+
mutation.succeed();
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
mutation.fail(err);
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
run();
|
|
1018
|
+
}, []);
|
|
1019
|
+
return React.createElement(MutationResult, {
|
|
1020
|
+
...mutationResultProps(mutation.state),
|
|
1021
|
+
loadingLabel: "Deleting API key...",
|
|
1022
|
+
successMessage: "API key deleted"
|
|
1023
|
+
});
|
|
1024
|
+
};
|
|
1025
|
+
const { waitUntilExit } = render(React.createElement(DeleteApp));
|
|
1026
|
+
await waitUntilExit();
|
|
1027
|
+
});
|
|
1028
|
+
apiKeysCommand.command("regenerate <id>").description("Regenerate an API key").option("--json", "Output as JSON").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").action(async (id, options) => {
|
|
1029
|
+
const apiKey = await ensureAuth();
|
|
1030
|
+
if (!apiKey) return;
|
|
1031
|
+
const client = createCliClient(apiKey);
|
|
1032
|
+
if (!isTTY(options) || options.json) {
|
|
1033
|
+
try {
|
|
1034
|
+
const data = await client.post(`/api-keys/${id}/regenerate`);
|
|
1035
|
+
if (options.json) printJson(data);
|
|
1036
|
+
else {
|
|
1037
|
+
console.log(chalk.green("API key regenerated"));
|
|
1038
|
+
if (data.key) {
|
|
1039
|
+
console.log(` New Key: ${chalk.yellow(data.key)}`);
|
|
1040
|
+
console.log(chalk.gray(" Save this key - it will not be shown again"));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1045
|
+
console.error(chalk.red("Failed to regenerate API key"));
|
|
1046
|
+
console.error(chalk.red(message));
|
|
1047
|
+
process.exit(1);
|
|
1048
|
+
}
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const App = () => {
|
|
1052
|
+
const mutation = useMutationState();
|
|
1053
|
+
useEffect(() => {
|
|
1054
|
+
const run = async () => {
|
|
1055
|
+
try {
|
|
1056
|
+
const data = await client.post(`/api-keys/${id}/regenerate`);
|
|
1057
|
+
mutation.succeed(data.key ? React.createElement(EntityCard, { fields: [{
|
|
1058
|
+
label: "New Key",
|
|
1059
|
+
value: data.key,
|
|
1060
|
+
color: "yellow"
|
|
1061
|
+
}, {
|
|
1062
|
+
label: "Note",
|
|
1063
|
+
value: "Save this key - it will not be shown again"
|
|
1064
|
+
}] }) : void 0);
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
mutation.fail(err);
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
run();
|
|
1070
|
+
}, []);
|
|
1071
|
+
return React.createElement(MutationResult, {
|
|
1072
|
+
...mutationResultProps(mutation.state),
|
|
1073
|
+
loadingLabel: "Regenerating API key...",
|
|
1074
|
+
successMessage: "API key regenerated"
|
|
1075
|
+
});
|
|
1076
|
+
};
|
|
1077
|
+
const { waitUntilExit } = render(React.createElement(App));
|
|
1078
|
+
await waitUntilExit();
|
|
1079
|
+
});
|
|
1080
|
+
apiKeysCommand.command("analytics").description("Show API key usage analytics").option("--key <id>", "Specific key ID (defaults to all keys)").option("--json", "Output as JSON").option("--tty", "Force TTY mode").option("--no-tty", "Force non-TTY mode").action(async (options) => {
|
|
1081
|
+
const apiKey = await ensureAuth();
|
|
1082
|
+
if (!apiKey) return;
|
|
1083
|
+
const client = createCliClient(apiKey);
|
|
1084
|
+
if (!isTTY(options) || options.json) {
|
|
1085
|
+
try {
|
|
1086
|
+
const path = options.key ? `/api-keys/${options.key}/analytics` : "/api-keys/analytics";
|
|
1087
|
+
const data = await client.get(path);
|
|
1088
|
+
printJson(data);
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1091
|
+
console.error(chalk.red("Failed to fetch analytics"));
|
|
1092
|
+
console.error(chalk.red(message));
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const App = () => {
|
|
1098
|
+
const mutation = useMutationState();
|
|
1099
|
+
useEffect(() => {
|
|
1100
|
+
const run = async () => {
|
|
1101
|
+
try {
|
|
1102
|
+
const path = options.key ? `/api-keys/${options.key}/analytics` : "/api-keys/analytics";
|
|
1103
|
+
const data = await client.get(path);
|
|
1104
|
+
printJson(data);
|
|
1105
|
+
mutation.succeed();
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
mutation.fail(err);
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
run();
|
|
1111
|
+
}, []);
|
|
1112
|
+
return React.createElement(MutationResult, {
|
|
1113
|
+
...mutationResultProps(mutation.state),
|
|
1114
|
+
loadingLabel: "Fetching analytics...",
|
|
1115
|
+
successMessage: "Analytics"
|
|
1116
|
+
});
|
|
1117
|
+
};
|
|
1118
|
+
const { waitUntilExit } = render(React.createElement(App));
|
|
1119
|
+
await waitUntilExit();
|
|
1120
|
+
});
|
|
1121
|
+
registerApiKeyRequestCommands(apiKeysCommand);
|
|
1122
|
+
export { apiKeysCommand };
|
|
1123
|
+
|