metergraph-cli 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -11
- package/assets/skill/SKILL.md +52 -3
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +13 -13
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/bin/metergraph.js +1 -1
- package/package.json +1 -1
- package/src/agent-key.js +160 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +572 -17
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +77 -7
- package/src/callback-page.js +58 -0
- package/src/cli.js +190 -45
- package/src/constants.js +73 -3
- package/src/environment.js +169 -0
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +189 -0
- package/src/experiments.js +397 -0
- package/src/origin.js +6 -0
- package/src/output.js +437 -131
- package/src/pricing-args.js +163 -0
- package/src/pricing-output.js +206 -0
- package/src/pricing-reference.js +195 -0
- package/src/pricing.js +307 -0
- package/src/read-contract.js +976 -16
- package/src/read-output.js +449 -30
- package/src/read.js +312 -60
- package/src/setup-deployment.js +11 -3
- package/src/setup-interactive.js +341 -0
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +13 -7
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
package/src/setup.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { openBrowser } from "./auth-browser.js";
|
|
4
|
+
import { classifyEnvironment, remoteSetupAction } from "./environment.js";
|
|
2
5
|
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
3
6
|
import { startCallbackListener } from "./auth-callback.js";
|
|
4
7
|
import { openUrl, runLogin } from "./auth-login.js";
|
|
@@ -9,6 +12,9 @@ import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
|
|
|
9
12
|
import { preflightEnv, currentEnvValues, commitEnv, isAppToken } from "./setup-env.js";
|
|
10
13
|
import { newSetupState, readSetupState, setupReceipt, withSetupState, writeSetupState } from "./setup-state.js";
|
|
11
14
|
import { runSkill } from "./skill.js";
|
|
15
|
+
import { handoffPages } from "./environment.js";
|
|
16
|
+
import { REFUSAL_REASONS, clearSetupRefusal, pendingRefusal, waiterRecord } from "./setup-refusal.js";
|
|
17
|
+
import { isApprovalWaiter } from "./approval-handoff.js";
|
|
12
18
|
import { ensureRepositoryIdentity, envRepository } from "./repository-identity.js";
|
|
13
19
|
import { parseJsonObject } from "./http.js";
|
|
14
20
|
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
@@ -37,8 +43,20 @@ const fail = (outcome, reason, status = "not_ready", receipt = null, next = null
|
|
|
37
43
|
// keeps waiting; stdout has room for one line, so a second approval in the
|
|
38
44
|
// same run is not started. The person approves, the run saves what it
|
|
39
45
|
// finished, and the agent reruns setup, which resumes from the saved state.
|
|
46
|
+
//
|
|
47
|
+
// A first setup that has to sign in asks the sign-in consent to approve the
|
|
48
|
+
// first ingest key as well, when the service advertises it, so the person
|
|
49
|
+
// approves once. The sign-in grant and the ingest key stay separate
|
|
50
|
+
// credentials: the receipt is redeemed here with its own PKCE verifier,
|
|
51
|
+
// exactly as one from the ingest-only approval is. A service without that
|
|
52
|
+
// support, a repair, a replacement or a project already signed in keeps the
|
|
53
|
+
// separate ingest-only approval.
|
|
40
54
|
export async function runSetup(options, progress = () => {}) {
|
|
41
|
-
|
|
55
|
+
const environment = options.environment ?? classifyEnvironment();
|
|
56
|
+
if (options.runtime !== "local" || environment.remote) {
|
|
57
|
+
const where = options.runtime !== "local" ? { kind: "declared_cloud" } : environment;
|
|
58
|
+
return fail("unsupported", "run_on_local_machine", "not_ready", null, remoteSetupAction(where, options.origin, options));
|
|
59
|
+
}
|
|
42
60
|
const trap = trapSignals();
|
|
43
61
|
let receipt = null;
|
|
44
62
|
let loginVerified = false;
|
|
@@ -54,8 +72,14 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
54
72
|
}
|
|
55
73
|
return current;
|
|
56
74
|
};
|
|
57
|
-
const stop = (outcome, reason, status = "not_ready"
|
|
75
|
+
const stop = (outcome, reason, status = "not_ready", next = null) =>
|
|
76
|
+
fail(outcome, reason, status, currentReceipt(), next);
|
|
58
77
|
let announced = false;
|
|
78
|
+
// Only the --json --no-browser background waiter records what it does with
|
|
79
|
+
// an approval; a foreground run reports directly.
|
|
80
|
+
const waiter = Boolean(options.announce) && isApprovalWaiter();
|
|
81
|
+
let record = null;
|
|
82
|
+
let refusalRecorded = false;
|
|
59
83
|
const announce = options.announce ? (status, next) => {
|
|
60
84
|
announced = true;
|
|
61
85
|
options.announce(fail("action_required", "browser_approval_required", status, currentReceipt(), next).data);
|
|
@@ -73,6 +97,18 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
73
97
|
(bound !== null && (bound.origin !== saved.value.origin || bound.workspace_id !== saved.value.workspace_id)))) {
|
|
74
98
|
return stop("conflict", "setup_binding_changed");
|
|
75
99
|
}
|
|
100
|
+
// What an earlier --json --no-browser waiter is doing with an approval it
|
|
101
|
+
// received: still redeeming it, or refused by the service's budget. The
|
|
102
|
+
// rerun reports that instead of asking the person for another approval.
|
|
103
|
+
// It runs after the binding guard, so a rerun aimed at another deployment
|
|
104
|
+
// or workspace is refused as such and leaves the record for a correct
|
|
105
|
+
// rerun; the next action names the saved family's own deployment.
|
|
106
|
+
const pending = pendingRefusal(root, { familyId: saved?.value.family_id ?? null,
|
|
107
|
+
delivered: saved?.value.phase === "delivered" });
|
|
108
|
+
if (pending !== null) {
|
|
109
|
+
return stop(pending.reason === "setup_rate_limited" ? "rate_limited" : "conflict", pending.reason,
|
|
110
|
+
"delivery_pending", redeemNext(pending, saved?.value.origin ?? intendedOrigin));
|
|
111
|
+
}
|
|
76
112
|
// An env key this setup does not own stops before any sign in, so the
|
|
77
113
|
// refusal costs no approval and leaves the binding untouched. The check
|
|
78
114
|
// after sign in stays, against a fresh read.
|
|
@@ -85,11 +121,27 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
85
121
|
if (bindingDiffers && !options.reconnect) return stop("conflict", "bound_to_other_workspace");
|
|
86
122
|
let signedIn = bindingDiffers || options.reconnect ? { ok: false, outcome: "login_required" } :
|
|
87
123
|
await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
124
|
+
// An approved, unredeemed receipt for a new family, from a combined sign in.
|
|
125
|
+
let combined = null;
|
|
88
126
|
if (!signedIn.ok && signedIn.outcome === "login_required") {
|
|
127
|
+
const familyId = randomUUID();
|
|
128
|
+
const ingestSetup = saved || options.repair ? null : { familyId,
|
|
129
|
+
// In the waiter, record the approval before anything else happens,
|
|
130
|
+
// so an immediate rerun waits instead of asking for another one.
|
|
131
|
+
onApproved: () => { if (waiter) record = waiterRecord(root, familyId); record?.redeeming(); },
|
|
132
|
+
supported: async (origin, profile, cancel) => {
|
|
133
|
+
const discovered = await discoverSetup(origin, profile, cancel);
|
|
134
|
+
return discovered.ok && discovered.loginSetup;
|
|
135
|
+
} };
|
|
89
136
|
const login = await runLogin({ ...options, origin: intendedOrigin, workspace: intendedWorkspace,
|
|
90
|
-
project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null,
|
|
137
|
+
project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null, ingestSetup,
|
|
91
138
|
announce: announce && ((data) => announce("login_pending", data.next_action)) }, progress);
|
|
92
|
-
|
|
139
|
+
// Login's own next action (for example no_browser with a Metadata key
|
|
140
|
+
// alternative in a local container) is passed through.
|
|
141
|
+
if (login.outcome !== "ok") return stop(login.outcome, login.reason, "login_pending", login.data?.next_action ?? null);
|
|
142
|
+
if (login.ingestSetup && login.ingestSetup.familyId === familyId && RECEIPT.test(login.ingestSetup.code)) {
|
|
143
|
+
combined = login.ingestSetup;
|
|
144
|
+
}
|
|
93
145
|
signedIn = await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
|
|
94
146
|
}
|
|
95
147
|
if (!signedIn.ok) return stop(signedIn.outcome, signedIn.reason, "login_pending");
|
|
@@ -159,15 +211,21 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
159
211
|
return stop("conflict", "repair_requires_saved_key");
|
|
160
212
|
}
|
|
161
213
|
|
|
214
|
+
// A combined sign in already approved a key for a new family. Anything
|
|
215
|
+
// that makes this anything but a first create drops the receipt unused;
|
|
216
|
+
// it expires without issuing a key.
|
|
217
|
+
if (previous !== null || options.repair) combined = null;
|
|
162
218
|
// A verified rerun returned above without approval. From here a person
|
|
163
|
-
// must open an approval URL
|
|
164
|
-
//
|
|
165
|
-
|
|
219
|
+
// must open an approval URL, unless the sign in approved the key too. A
|
|
220
|
+
// run that already announced its sign in URL has used its one JSON line,
|
|
221
|
+
// so it stops here and the rerun continues.
|
|
222
|
+
if (announced && combined === null) return stop("action_required", "browser_approval_required", "approval_pending");
|
|
166
223
|
const setup = await discoverSetup(origin, profile, trap.signal);
|
|
167
224
|
if (!setup.ok) return stop(setup.outcome, setup.reason);
|
|
168
225
|
|
|
169
226
|
if (previous === null) {
|
|
170
|
-
writeSetupState(root, newSetupState(origin, workspaceId, profile, options.client)
|
|
227
|
+
writeSetupState(root, combined === null ? newSetupState(origin, workspaceId, profile, options.client)
|
|
228
|
+
: newSetupState(origin, workspaceId, profile, options.client, combined.familyId), null);
|
|
171
229
|
previous = readSetupState(root);
|
|
172
230
|
receipt = setupReceipt(previous.value);
|
|
173
231
|
}
|
|
@@ -177,40 +235,19 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
177
235
|
// An acknowledged family is never implicitly replaced. Repair requires
|
|
178
236
|
// the explicit flag and the key ID saved from its original issuance.
|
|
179
237
|
if (intent === "replace_pending" && state.phase === "delivered") return stop("conflict", "repair_required");
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (!
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const url = new URL(`${origin}${SETUP_PATHS.authorize}`);
|
|
193
|
-
const params = {
|
|
194
|
-
client_id: clientId, redirect_uri: listener.redirectUri, state: pkce.state,
|
|
195
|
-
code_challenge: pkce.challenge, code_challenge_method: "S256",
|
|
196
|
-
workspace_id: workspaceId, family_id: state.family_id, intent,
|
|
197
|
-
};
|
|
198
|
-
if (intent === "repair") params.expected_key_id = state.key_id;
|
|
199
|
-
url.search = new URLSearchParams(params).toString();
|
|
200
|
-
if (trap.signal.aborted) return stop("authorization_failed", "cancelled");
|
|
201
|
-
if (options.noBrowser && announce) {
|
|
202
|
-
announce("approval_pending", openUrl(url.href, Math.round(options.timeoutMs / 1000), "approve ingest-only setup"));
|
|
203
|
-
} else if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
|
|
204
|
-
else if (!(await openBrowser(url.href))) return stop("authorization_failed", "browser_unavailable");
|
|
205
|
-
else progress("Opened your browser for ingest-only setup approval.");
|
|
206
|
-
callback = await pending;
|
|
207
|
-
} finally {
|
|
208
|
-
await listener.close();
|
|
238
|
+
let approval;
|
|
239
|
+
if (combined !== null && intent === "create" && state.family_id === combined.familyId) {
|
|
240
|
+
approval = combined;
|
|
241
|
+
progress("Your sign-in approval also approved this project's ingest key.");
|
|
242
|
+
} else {
|
|
243
|
+
approval = await approveInBrowser({ origin, setup, workspaceId, state, intent, options, announce, progress,
|
|
244
|
+
cancel: trap.signal });
|
|
245
|
+
if (!approval.ok) return stop(approval.outcome, approval.reason);
|
|
246
|
+
if (waiter) {
|
|
247
|
+
record = waiterRecord(root, state.family_id);
|
|
248
|
+
record.redeeming();
|
|
249
|
+
}
|
|
209
250
|
}
|
|
210
|
-
if (callback.kind === "denied") return stop("authorization_failed", "access_denied");
|
|
211
|
-
if (callback.kind === "timeout") return stop("authorization_failed", "timeout");
|
|
212
|
-
if (callback.kind === "cancelled") return stop("authorization_failed", "cancelled");
|
|
213
|
-
if (callback.kind !== "code" || !RECEIPT.test(callback.code)) return stop("authorization_failed", "callback_invalid");
|
|
214
251
|
|
|
215
252
|
// Persist the ambiguity boundary before a single-use receipt is sent.
|
|
216
253
|
// If the response is lost, a later run asks the browser to replace only
|
|
@@ -220,11 +257,22 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
220
257
|
return stop("conflict", "setup_state_changed");
|
|
221
258
|
}
|
|
222
259
|
writeSetupState(root, withSetupState(previous.value, { phase: "redeem_attempted" }), previous);
|
|
260
|
+
// This waiter won the state for its redemption: the record is now its own,
|
|
261
|
+
// even if another waiter's approval arrived first and wrote it.
|
|
262
|
+
if (waiter) {
|
|
263
|
+
record ??= waiterRecord(root, state.family_id);
|
|
264
|
+
record.redeeming({ force: true });
|
|
265
|
+
}
|
|
223
266
|
previous = readSetupState(root);
|
|
224
|
-
const redeemed = await redeem(origin, { code:
|
|
225
|
-
redirect_uri:
|
|
226
|
-
family_id: state.family_id, workspace_id: workspaceId }, profile, trap.signal);
|
|
227
|
-
if (!redeemed.ok)
|
|
267
|
+
const redeemed = await redeem(origin, { code: approval.code, client_id: approval.clientId,
|
|
268
|
+
redirect_uri: approval.redirectUri, code_verifier: approval.verifier,
|
|
269
|
+
family_id: state.family_id, workspace_id: workspaceId }, profile, trap.signal, progress);
|
|
270
|
+
if (!redeemed.ok) {
|
|
271
|
+
if (record !== null && REFUSAL_REASONS.includes(redeemed.reason)) {
|
|
272
|
+
refusalRecorded = record.refused(redeemed.reason, redeemed.retryAfter ?? null);
|
|
273
|
+
}
|
|
274
|
+
return stop(redeemed.outcome, redeemed.reason, "delivery_pending", redeemNext(redeemed, origin));
|
|
275
|
+
}
|
|
228
276
|
const written = await commitEnv(plan, { token: redeemed.token, ingestUrl: origin, signal: trap.signal });
|
|
229
277
|
const checked = await checkCredential(origin, redeemed.token, state, profile, trap.signal);
|
|
230
278
|
if (!checked.ok || checked.keyId !== redeemed.keyId) return stop("verification_failed", "issued_key_unverified", "delivery_pending");
|
|
@@ -236,10 +284,59 @@ export async function runSetup(options, progress = () => {}) {
|
|
|
236
284
|
} catch (error) {
|
|
237
285
|
if (error instanceof Stop) return stop(error.outcome, error.reason);
|
|
238
286
|
throw error;
|
|
239
|
-
} finally {
|
|
287
|
+
} finally {
|
|
288
|
+
if (record !== null && !refusalRecorded) record.settle();
|
|
289
|
+
trap.release();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The ingest-only approval at the setup authorization endpoint: the only
|
|
294
|
+
// browser step for a repair or replacement, and the second one for a service
|
|
295
|
+
// without the combined sign-in approval. Returns the receipt with the client,
|
|
296
|
+
// redirect and verifier it must be redeemed with, or a stop reason.
|
|
297
|
+
async function approveInBrowser({ origin, setup, workspaceId, state, intent, options, announce, progress, cancel }) {
|
|
298
|
+
const failed = (reason, outcome = "authorization_failed") => ({ ok: false, outcome, reason });
|
|
299
|
+
const listener = await startCallbackListener();
|
|
300
|
+
let callback;
|
|
301
|
+
let clientId;
|
|
302
|
+
let pkce;
|
|
303
|
+
try {
|
|
304
|
+
const client = await clientFor(endpointsFor(origin), setup.offeredClientId, listener.redirectUri,
|
|
305
|
+
deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal);
|
|
306
|
+
if (!client.ok) return failed(client.reason, client.outcome);
|
|
307
|
+
clientId = client.clientId;
|
|
308
|
+
pkce = newPkce();
|
|
309
|
+
const pending = listener.wait({ state: pkce.state, issuer: `${origin}/v1/oauth`, requireIss: false,
|
|
310
|
+
timeoutMs: options.timeoutMs, cancel });
|
|
311
|
+
const url = new URL(`${origin}${SETUP_PATHS.authorize}`);
|
|
312
|
+
const params = {
|
|
313
|
+
client_id: clientId, redirect_uri: listener.redirectUri, state: pkce.state,
|
|
314
|
+
code_challenge: pkce.challenge, code_challenge_method: "S256",
|
|
315
|
+
workspace_id: workspaceId, family_id: state.family_id, intent,
|
|
316
|
+
};
|
|
317
|
+
if (intent === "repair") params.expected_key_id = state.key_id;
|
|
318
|
+
url.search = new URLSearchParams(params).toString();
|
|
319
|
+
if (cancel.aborted) return failed("cancelled");
|
|
320
|
+
if (options.noBrowser && announce) {
|
|
321
|
+
announce("approval_pending", openUrl(url.href, Math.round(options.timeoutMs / 1000), "approve ingest-only setup"));
|
|
322
|
+
} else if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
|
|
323
|
+
else if (!(await openBrowser(url.href))) return failed("browser_unavailable");
|
|
324
|
+
else progress("Opened your browser for ingest-only setup approval.");
|
|
325
|
+
callback = await pending;
|
|
326
|
+
} finally {
|
|
327
|
+
await listener.close();
|
|
328
|
+
}
|
|
329
|
+
if (callback.kind === "denied") return failed("access_denied");
|
|
330
|
+
if (callback.kind === "timeout") return failed("timeout");
|
|
331
|
+
if (callback.kind === "cancelled") return failed("cancelled");
|
|
332
|
+
if (callback.kind !== "code" || !RECEIPT.test(callback.code)) return failed("callback_invalid");
|
|
333
|
+
return { ok: true, code: callback.code, clientId, redirectUri: listener.redirectUri, verifier: pkce.verifier };
|
|
240
334
|
}
|
|
241
335
|
|
|
242
336
|
function finishSkill(root, options, envStatus) {
|
|
337
|
+
// Reached only with a verified, delivered credential, whether just issued
|
|
338
|
+
// or reused: no earlier refusal or redemption still applies.
|
|
339
|
+
clearSetupRefusal(root);
|
|
243
340
|
let previous = readSetupState(root);
|
|
244
341
|
if (previous === null || previous.value.phase !== "delivered") return fail("conflict", "setup_state_changed");
|
|
245
342
|
if (previous.value.selected_client !== options.client) {
|
|
@@ -313,14 +410,91 @@ async function discoverSetup(origin, profile, cancel) {
|
|
|
313
410
|
}
|
|
314
411
|
const offered = offeredClientId(doc);
|
|
315
412
|
if (offered === undefined) return { ok: false, outcome: "unsupported", reason: "setup_contract_mismatch" };
|
|
316
|
-
|
|
413
|
+
// Optional: a service that names it lets the sign-in consent approve the
|
|
414
|
+
// first ingest key too. Anything but true means the separate approval.
|
|
415
|
+
return { ok: true, offeredClientId: offered, loginSetup: doc.login_ingest_setup_supported === true };
|
|
317
416
|
}
|
|
318
417
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
418
|
+
// The service budgets how many ingest keys setup may issue per person and per
|
|
419
|
+
// workspace each minute. A refusal leaves the receipt unredeemed, and only
|
|
420
|
+
// this process holds its PKCE verifier, so setup waits out a short
|
|
421
|
+
// Retry-After and redeems the same receipt rather than asking for another
|
|
422
|
+
// approval. It gives up after REDEEM_RETRIES waits.
|
|
423
|
+
const REDEEM_RETRIES = 2;
|
|
424
|
+
const MAX_RETRY_AFTER_SECONDS = 60;
|
|
425
|
+
|
|
426
|
+
async function redeem(origin, form, profile, cancel, progress = () => {}) {
|
|
427
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
428
|
+
const response = await send(origin, SETUP_PATHS.redeem, { method: "POST", form,
|
|
429
|
+
signal: deadline(AUTH_HTTP_TIMEOUT_MS, cancel).signal, maxBytes: 32768 });
|
|
430
|
+
const failed = failureOf(response);
|
|
431
|
+
if (failed) return { ok: false, ...failed };
|
|
432
|
+
if (response.status === 429) {
|
|
433
|
+
const wait = retryAfterSeconds(response);
|
|
434
|
+
if (wait === null || attempt >= REDEEM_RETRIES) {
|
|
435
|
+
return { ok: false, outcome: "rate_limited", reason: "setup_rate_limited", retryAfter: wait };
|
|
436
|
+
}
|
|
437
|
+
progress(`The service is pacing new ingest keys. Retrying in ${wait} seconds.`);
|
|
438
|
+
if (!(await pause(wait * 1000, cancel))) return { ok: false, outcome: "authorization_failed", reason: "cancelled" };
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (response.status === 409 && parseJsonObject(response)?.error === "setup_key_limit") {
|
|
442
|
+
return { ok: false, outcome: "conflict", reason: "setup_key_limit" };
|
|
443
|
+
}
|
|
444
|
+
return redeemResult(response, origin, form, profile);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Seconds from Retry-After (or the body's retry_after_seconds), 1 to 60, or
|
|
449
|
+
// null when the service gave no usable delay. The header must be whole
|
|
450
|
+
// digits: "60junk" or "1.5" is unusable, as on the read path.
|
|
451
|
+
const RETRY_AFTER = /^[0-9]{1,6}$/;
|
|
452
|
+
|
|
453
|
+
function retryAfterSeconds(response) {
|
|
454
|
+
const raw = response.headers?.["retry-after"];
|
|
455
|
+
const header = typeof raw === "string" && RETRY_AFTER.test(raw.trim()) ? Number(raw.trim()) : null;
|
|
456
|
+
if (raw !== undefined && header === null) return null;
|
|
457
|
+
const body = parseJsonObject(response)?.retry_after_seconds;
|
|
458
|
+
const value = header !== null ? header : Number.isInteger(body) ? body : null;
|
|
459
|
+
return value !== null && value >= 1 && value <= MAX_RETRY_AFTER_SECONDS ? value : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function pause(ms, cancel) {
|
|
463
|
+
return new Promise((resolve) => {
|
|
464
|
+
if (cancel.aborted) return resolve(false);
|
|
465
|
+
const done = (value) => {
|
|
466
|
+
clearTimeout(timer);
|
|
467
|
+
cancel.removeEventListener("abort", onAbort);
|
|
468
|
+
resolve(value);
|
|
469
|
+
};
|
|
470
|
+
const onAbort = () => done(false);
|
|
471
|
+
const timer = setTimeout(() => done(true), ms);
|
|
472
|
+
cancel.addEventListener("abort", onAbort, { once: true });
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The next action for a budget refusal. Nothing was issued, and a rerun asks
|
|
477
|
+
// for a new approval.
|
|
478
|
+
function redeemNext(result, origin) {
|
|
479
|
+
if (result.reason === "setup_in_progress") {
|
|
480
|
+
return { kind: "retry_later", retry_after_seconds: result.retryAfter ?? null,
|
|
481
|
+
message: "An earlier setup run for this project is still finishing an approval it received. Wait a " +
|
|
482
|
+
"few seconds, then run setup again; do not ask for another approval." };
|
|
483
|
+
}
|
|
484
|
+
if (result.reason === "setup_rate_limited") {
|
|
485
|
+
return { kind: "retry_later", retry_after_seconds: result.retryAfter ?? null,
|
|
486
|
+
message: "The service limits how many ingest keys setup can issue each minute for one person and one " +
|
|
487
|
+
"workspace. Nothing was issued. Wait a minute, then run setup again; it asks for a new approval." };
|
|
488
|
+
}
|
|
489
|
+
if (result.reason === "setup_key_limit") {
|
|
490
|
+
return { kind: "revoke_keys", url: handoffPages(origin).keys_url,
|
|
491
|
+
message: "This workspace has reached its limit of active CLI setup keys. Nothing was issued. Revoke " +
|
|
492
|
+
"unused CLI setup keys on the Keys page, then run setup again." };
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function redeemResult(response, origin, form, profile) {
|
|
324
498
|
if (response.status !== 200) return { ok: false, outcome: "authorization_failed", reason: "setup_redeem_rejected" };
|
|
325
499
|
const body = parseJsonObject(response);
|
|
326
500
|
if (body?.schema_version !== SCHEMA || body.token_type !== "ingest" || body.scope !== "ingest" ||
|
package/src/skill-bundle.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
// public skill at the manifest's source_url. The source has no version of its
|
|
6
6
|
// own, so the revision is derived from the content hash. The hash is pinned
|
|
7
7
|
// here as well as in the manifest, so editing either file alone is detected.
|
|
8
|
-
const PINNED_SHA256 = "
|
|
8
|
+
const PINNED_SHA256 = "ebf784673eff0280d85974504f93c683a1dabfb54156c9d27e091131462e9d5f";
|
|
9
9
|
|
|
10
10
|
const ASSET_DIR = new URL("../assets/skill/", import.meta.url);
|
|
11
11
|
const MANIFEST_KEYS = ["file", "manifest_version", "name", "revision", "sha256", "size", "source_url"];
|
|
@@ -55,7 +55,7 @@ export function loadBundledSkill() {
|
|
|
55
55
|
// The workflow skills that "skills install" writes, vendored from the public
|
|
56
56
|
// skills repository at one commit. The manifest's own hash is pinned here, and
|
|
57
57
|
// the manifest pins each skill's hash, so changing any file alone is detected.
|
|
58
|
-
const PINNED_PACK_SHA256 = "
|
|
58
|
+
const PINNED_PACK_SHA256 = "11ab983875911c4c2ad03d2a3b7b9e9213c410e77f4ff2d27b4059354b00fe0c";
|
|
59
59
|
|
|
60
60
|
const PACK_DIR = new URL("../assets/skills/", import.meta.url);
|
|
61
61
|
const PACK_KEYS = ["commit", "manifest_version", "repository", "skills"];
|
package/src/style.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { EXIT_CODES } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
// Presentation for human (non --json) output: a status headline, aligned
|
|
4
|
+
// fields and next steps. Color is used only when the stream is a terminal
|
|
5
|
+
// and NO_COLOR is unset (https://no-color.org); FORCE_COLOR turns it on.
|
|
6
|
+
// Symbols are plain Unicode and are printed with or without color.
|
|
7
|
+
|
|
8
|
+
const CODES = { bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39], cyan: [36, 39] };
|
|
9
|
+
|
|
10
|
+
function makeStyle(color, width = Infinity) {
|
|
11
|
+
const style = { color, width };
|
|
12
|
+
for (const [name, [open, close]] of Object.entries(CODES)) {
|
|
13
|
+
style[name] = color ? (text) => `\u001b[${open}m${text}\u001b[${close}m` : (text) => String(text);
|
|
14
|
+
}
|
|
15
|
+
return style;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PLAIN = makeStyle(false);
|
|
19
|
+
|
|
20
|
+
// Prose wraps to the terminal width only on a terminal, so piped output keeps
|
|
21
|
+
// one line per message.
|
|
22
|
+
export function createStyle(stream, env = process.env) {
|
|
23
|
+
const tty = Boolean(stream?.isTTY);
|
|
24
|
+
const width = tty ? Math.min(Math.max(stream.columns || 80, 40), 100) - 2 : Infinity;
|
|
25
|
+
if (env.NO_COLOR) return makeStyle(false, width);
|
|
26
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return makeStyle(true, width);
|
|
27
|
+
return makeStyle(tty && env.TERM !== "dumb", width);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TONES = {
|
|
31
|
+
ok: ["✓", "green"],
|
|
32
|
+
warn: ["!", "yellow"],
|
|
33
|
+
fail: ["✗", "red"],
|
|
34
|
+
info: ["•", "cyan"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ok for success; warn when the command stopped for a person to act;
|
|
38
|
+
// fail for everything else.
|
|
39
|
+
export function toneFor(outcome) {
|
|
40
|
+
if (outcome === "ok") return "ok";
|
|
41
|
+
if (outcome === "action_required" || outcome === "authentication_required") return "warn";
|
|
42
|
+
return "fail";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function headline(style, tone, title, outcome = null) {
|
|
46
|
+
const [icon, color] = TONES[tone];
|
|
47
|
+
let line = `${style[color](icon)} ${style.bold(title)}`;
|
|
48
|
+
if (outcome !== null && outcome !== "ok") line += ` ${style.dim(`(${outcome}, exit ${EXIT_CODES[outcome]})`)}`;
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Aligned "label value" rows. Rows whose value is null or undefined are left out.
|
|
53
|
+
export function fields(style, rows, indent = " ") {
|
|
54
|
+
const shown = rows.filter(([, value]) => value !== null && value !== undefined);
|
|
55
|
+
const width = Math.max(0, ...shown.map(([label]) => label.length));
|
|
56
|
+
return shown.map(([label, value]) => `${indent}${style.dim(label.padEnd(width))} ${value}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Indents prose and wraps it to style.width.
|
|
60
|
+
export function paragraph(style, text, indent = " ") {
|
|
61
|
+
const width = style.width;
|
|
62
|
+
const lines = [];
|
|
63
|
+
let line = "";
|
|
64
|
+
for (const word of String(text).split(/\s+/).filter(Boolean)) {
|
|
65
|
+
if (line && indent.length + line.length + 1 + word.length > width) {
|
|
66
|
+
lines.push(indent + line);
|
|
67
|
+
line = word;
|
|
68
|
+
} else {
|
|
69
|
+
line = line ? `${line} ${word}` : word;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (line) lines.push(indent + line);
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// "→ label value" rows for what to do next, aligned like fields.
|
|
77
|
+
export function steps(style, rows) {
|
|
78
|
+
const shown = rows.filter(([, value]) => value !== null && value !== undefined);
|
|
79
|
+
const width = Math.max(0, ...shown.map(([label]) => label.length));
|
|
80
|
+
return shown.flatMap(([label, value]) => {
|
|
81
|
+
if (label) return [`${style.cyan("→")} ${label.padEnd(width)} ${value}`];
|
|
82
|
+
// Unlabeled steps are prose: wrap them with a hanging indent.
|
|
83
|
+
const [first, ...rest] = paragraph(style, value, " ");
|
|
84
|
+
return [`${style.cyan("→")} ${first.trimStart()}`, ...rest];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function section(style, title) {
|
|
89
|
+
return style.bold(title);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function render(lines) {
|
|
93
|
+
// Collapse runs of blank lines and trim blank edges.
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
if (line === "" && (out.length === 0 || out[out.length - 1] === "")) continue;
|
|
97
|
+
out.push(line);
|
|
98
|
+
}
|
|
99
|
+
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
100
|
+
return `${out.join("\n")}\n`;
|
|
101
|
+
}
|
package/src/transport.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
3
|
|
|
4
|
-
import { PACKAGE_NAME, READ_QUERY_KEYS, VERSION } from "./constants.js";
|
|
4
|
+
import { MAX_QUERY_LENGTH, PACKAGE_NAME, READ_QUERY_KEYS, READ_QUERY_REPEATABLE, VERSION } from "./constants.js";
|
|
5
5
|
import { classifyError } from "./http.js";
|
|
6
6
|
|
|
7
7
|
const USER_AGENT = `${PACKAGE_NAME}/${VERSION}`;
|
|
8
|
-
|
|
8
|
+
// MAX_QUERY_LENGTH is shared with argument validation (constants.js).
|
|
9
9
|
|
|
10
10
|
// Builds the request URL. Without a query the path must be a fixed path on
|
|
11
11
|
// the origin with no search part. A query is accepted only as a
|
|
12
12
|
// URLSearchParams the caller built, only on a path in READ_QUERY_KEYS, only
|
|
13
|
-
// with that path's keys, each at most once
|
|
13
|
+
// with that path's keys, each at most once unless READ_QUERY_REPEATABLE lists
|
|
14
|
+
// it for that path. Anything else is a programming
|
|
14
15
|
// error, so no input can choose another path, origin or header.
|
|
15
16
|
function requestUrl(origin, pathname, query) {
|
|
16
17
|
const url = new URL(pathname, origin);
|
|
@@ -23,9 +24,12 @@ function requestUrl(origin, pathname, query) {
|
|
|
23
24
|
if (!(query instanceof URLSearchParams) || allowed === null) {
|
|
24
25
|
throw new Error("a query is not allowed on this path");
|
|
25
26
|
}
|
|
27
|
+
const repeatable = READ_QUERY_REPEATABLE[pathname] ?? [];
|
|
26
28
|
const seen = new Set();
|
|
27
29
|
for (const key of query.keys()) {
|
|
28
|
-
if (!allowed.includes(key) || seen.has(key)
|
|
30
|
+
if (!allowed.includes(key) || (seen.has(key) && !repeatable.includes(key))) {
|
|
31
|
+
throw new Error("query key is not allowed");
|
|
32
|
+
}
|
|
29
33
|
seen.add(key);
|
|
30
34
|
}
|
|
31
35
|
const search = query.toString();
|
|
@@ -48,11 +52,13 @@ function requestUrl(origin, pathname, query) {
|
|
|
48
52
|
// other status are read up to maxBytes; a larger body sets tooLarge and is
|
|
49
53
|
// discarded. The only credential ever sent is the bearer argument, and only
|
|
50
54
|
// to this origin. Nothing is taken from the environment, and no cookies are
|
|
51
|
-
// sent or kept.
|
|
55
|
+
// sent or kept. useGlobalAgent is opt-in for public pricing so Node can use its
|
|
56
|
+
// configured environment proxy (Node 24+ with --use-env-proxy). Other callers
|
|
57
|
+
// retain their existing isolated-agent behavior. query is null or a URLSearchParams accepted by requestUrl.
|
|
52
58
|
export function send(
|
|
53
59
|
origin,
|
|
54
60
|
pathname,
|
|
55
|
-
{ method = "GET", signal, maxBytes, bearer = null, form = null, json = null, query = null },
|
|
61
|
+
{ method = "GET", signal, maxBytes, bearer = null, form = null, json = null, query = null, useGlobalAgent = false },
|
|
56
62
|
) {
|
|
57
63
|
const url = requestUrl(origin, pathname, query);
|
|
58
64
|
|
|
@@ -87,7 +93,7 @@ export function send(
|
|
|
87
93
|
const transport = url.protocol === "https:" ? https : http;
|
|
88
94
|
let request;
|
|
89
95
|
try {
|
|
90
|
-
request = transport.request(url, { method, agent: false, signal, headers });
|
|
96
|
+
request = transport.request(url, { method, agent: useGlobalAgent ? undefined : false, signal, headers });
|
|
91
97
|
} catch (error) {
|
|
92
98
|
fail(error);
|
|
93
99
|
return;
|