metergraph-cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth-login.js CHANGED
@@ -1,4 +1,4 @@
1
- import { detectRemoteSession, openBrowser } from "./auth-browser.js";
1
+ import { openBrowser } from "./auth-browser.js";
2
2
  import { startCallbackListener } from "./auth-callback.js";
3
3
  import {
4
4
  BINDING_PATH,
@@ -30,8 +30,9 @@ import {
30
30
  withSlotLock,
31
31
  writeCredential,
32
32
  } from "./auth-store.js";
33
- import { AUTH_HTTP_TIMEOUT_MS, CONNECTION_GUIDE_URL, METADATA_SCOPE } from "./constants.js";
33
+ import { AUTH_HTTP_TIMEOUT_MS, METADATA_SCOPE } from "./constants.js";
34
34
  import { runDoctor } from "./doctor.js";
35
+ import { agentKeyAction, classifyEnvironment } from "./environment.js";
35
36
  import { deadline, trapSignals } from "./transport.js";
36
37
 
37
38
  // login and logout. Each returns { outcome, reason, data }; every string in
@@ -44,6 +45,10 @@ import { deadline, trapSignals } from "./transport.js";
44
45
  // exists, while the listener keeps waiting. The caller prints it as the one
45
46
  // JSON line, so an agent can hand the URL to a person; approval-handoff.js
46
47
  // lets the agent's command exit then while this process keeps waiting.
48
+ // options.ingestSetup, given only by setup, is { familyId, supported(origin,
49
+ // profile, signal) }. When the service supports it, the sign-in consent also
50
+ // approves the first ingest key for that family, and an ok result carries the
51
+ // unredeemed receipt as result.ingestSetup (never in data).
47
52
 
48
53
  export async function runLogin(options, progress) {
49
54
  const ctx = {
@@ -55,14 +60,20 @@ export async function runLogin(options, progress) {
55
60
  configured: false,
56
61
  status: null,
57
62
  previousGrant: null,
63
+ environment: null,
58
64
  };
59
65
  const end = (outcome, reason, next = null) => ({ outcome, reason, data: loginData(ctx, next) });
60
66
 
61
67
  // Handoffs come first: no listener, request or file write happens for a
62
- // session that cannot finish sign in.
63
- if (options.runtime !== "local") return end("unsupported", "runtime_not_supported", GUIDE);
64
- const remote = detectRemoteSession();
65
- if (remote !== null) return end("unsupported", remote, GUIDE);
68
+ // session that cannot finish sign in. The next action tells the agent how
69
+ // a person hands it a Metadata key instead.
70
+ const environment = options.environment ?? classifyEnvironment();
71
+ ctx.environment = environment.kind;
72
+ if (options.runtime !== "local") {
73
+ ctx.environment = "declared_cloud";
74
+ return end("unsupported", "runtime_not_supported", agentKeyAction({ kind: "declared_cloud" }, options.origin));
75
+ }
76
+ if (environment.remote) return end("unsupported", environment.reason, agentKeyAction(environment, options.origin));
66
77
  const trap = trapSignals();
67
78
  try {
68
79
  const root = resolveProject(options.project);
@@ -153,7 +164,7 @@ export async function runLogin(options, progress) {
153
164
  if (bound !== null && bound.credential_slot !== slot) {
154
165
  ctx.previousGrant = await retireSlot(store, bound);
155
166
  }
156
- return end("ok", null, CONNECTED);
167
+ return { ...end("ok", null, CONNECTED), ingestSetup: flow.ingestSetup };
157
168
  } catch (error) {
158
169
  if (error instanceof Stop) return end(error.outcome, error.reason);
159
170
  throw error;
@@ -182,6 +193,16 @@ async function authorize(options, ctx, cancel, progress, announce) {
182
193
  if (!discovered.ok) return httpFailure(discovered, cancel);
183
194
  const { endpoints, requireIss, offeredClientId } = discovered;
184
195
 
196
+ // Setup asks this consent to approve the first ingest key too, but only
197
+ // when the service says it can. Otherwise sign in is unchanged and setup
198
+ // asks for its own approval afterwards.
199
+ let setupPkce = null;
200
+ if (options.ingestSetup) {
201
+ limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
202
+ if (await options.ingestSetup.supported(ctx.origin, ctx.profile, limit.signal)) setupPkce = newPkce();
203
+ if (cancel.aborted) return fail("authorization_failed", "cancelled");
204
+ }
205
+
185
206
  const listener = await startCallbackListener();
186
207
  let callback;
187
208
  let clientId;
@@ -204,6 +225,7 @@ async function authorize(options, ctx, cancel, progress, announce) {
204
225
  requireIss,
205
226
  timeoutMs: options.timeoutMs,
206
227
  cancel,
228
+ acceptSetupCode: setupPkce !== null,
207
229
  });
208
230
  const url = authorizationUrl(endpoints, {
209
231
  clientId,
@@ -211,15 +233,18 @@ async function authorize(options, ctx, cancel, progress, announce) {
211
233
  state: pkce.state,
212
234
  challenge: pkce.challenge,
213
235
  signup: options.signup,
236
+ ingestSetup: setupPkce === null ? null : { familyId: options.ingestSetup.familyId, challenge: setupPkce.challenge },
214
237
  });
238
+ const purpose = setupPkce === null ? "sign in with Metadata access"
239
+ : "sign in with Metadata access and approve an ingest-only key for this project";
215
240
  const seconds = Math.round(options.timeoutMs / 1000);
216
241
  // A wait armed after Ctrl+C has already settled; do not show the URL.
217
242
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
218
243
  if (options.noBrowser && announce) {
219
- announce(openUrl(url, seconds, "sign in with Metadata access"));
244
+ announce(openUrl(url, seconds, purpose));
220
245
  } else if (options.noBrowser) {
221
246
  progress(
222
- "Open this URL in a browser on this machine to sign in:\n" +
247
+ `Open this URL in a browser on this machine to ${purpose}:\n` +
223
248
  `${url}\n` +
224
249
  `Waiting up to ${seconds} seconds for the browser. Press Ctrl+C to cancel.`,
225
250
  );
@@ -228,15 +253,25 @@ async function authorize(options, ctx, cancel, progress, announce) {
228
253
  return fail("authorization_failed", "browser_unavailable", {
229
254
  kind: "no_browser",
230
255
  message: "Run login again with --no-browser, without --json, and open the URL it prints.",
256
+ // A local container's loopback callback is reachable only when its
257
+ // port is forwarded to the person's machine. A Metadata key avoids
258
+ // the callback altogether.
259
+ ...(ctx.environment === "local_container"
260
+ ? { alternative: agentKeyAction({ kind: "local_container", remote: false }, ctx.origin) }
261
+ : {}),
231
262
  });
232
263
  }
233
- progress(`Opened your browser to sign in. Waiting up to ${seconds} seconds. Press Ctrl+C to cancel.`);
264
+ progress(setupPkce === null
265
+ ? `Opened your browser to sign in. Waiting up to ${seconds} seconds. Press Ctrl+C to cancel.`
266
+ : "Opened your browser to sign in and approve this project's ingest key in one step. " +
267
+ `Waiting up to ${seconds} seconds. Press Ctrl+C to cancel.`);
234
268
  }
235
269
  callback = await pending;
236
270
  } finally {
237
271
  await listener.close();
238
272
  }
239
273
 
274
+ if (callback.kind === "code" && setupPkce !== null && callback.setupCode) options.ingestSetup.onApproved?.();
240
275
  if (callback.kind === "denied") return fail("authorization_failed", "access_denied");
241
276
  if (callback.kind === "timeout") return fail("authorization_failed", "timeout");
242
277
  if (callback.kind === "cancelled") return fail("authorization_failed", "cancelled");
@@ -270,7 +305,15 @@ async function authorize(options, ctx, cancel, progress, announce) {
270
305
  return rejectGrant(verified.outcome, verified.reason);
271
306
  }
272
307
  ctx.workspaceId = grant.tenant;
273
- return { ok: true, endpoints, clientId, grant };
308
+ // The receipt is a separate credential: setup redeems it with its own
309
+ // verifier, for the family it named, after binding this sign in. A service
310
+ // that showed only the sign in returns no receipt, and setup falls back to
311
+ // its own approval.
312
+ const ingestSetup = setupPkce !== null && callback.setupCode
313
+ ? { familyId: options.ingestSetup.familyId, code: callback.setupCode, verifier: setupPkce.verifier,
314
+ clientId, redirectUri: listener.redirectUri }
315
+ : null;
316
+ return { ok: true, endpoints, clientId, grant, ingestSetup };
274
317
  }
275
318
 
276
319
  export async function runLogout(options) {
@@ -389,7 +432,6 @@ export function openUrl(url, seconds, purpose) {
389
432
  };
390
433
  }
391
434
 
392
- const GUIDE = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
393
435
  const RECONNECT = {
394
436
  kind: "reconnect",
395
437
  message: "Run login again with --reconnect to switch this project, or run logout first.",
@@ -403,6 +445,7 @@ function loginData(ctx, next) {
403
445
  return {
404
446
  origin: ctx.origin,
405
447
  runtime: ctx.runtime,
448
+ environment: ctx.environment,
406
449
  deployment_profile: ctx.profile,
407
450
  workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
408
451
  scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
package/src/auth-oauth.js CHANGED
@@ -225,7 +225,11 @@ export function newPkce() {
225
225
  // The authorization URL the browser opens. With signup, the browser starts
226
226
  // at the service's own signup page, which returns to the same authorization
227
227
  // request on this origin after the account exists.
228
- export function authorizationUrl(endpoints, { clientId, redirectUri, state, challenge, signup }) {
228
+ // ingestSetup, when given as { familyId, challenge }, asks this one consent to
229
+ // also approve the first ingest key for a new setup family. The service
230
+ // returns that receipt next to the code; it is redeemed separately with its
231
+ // own PKCE verifier.
232
+ export function authorizationUrl(endpoints, { clientId, redirectUri, state, challenge, signup, ingestSetup = null }) {
229
233
  const url = new URL(endpoints.authorization);
230
234
  url.search = new URLSearchParams({
231
235
  response_type: "code",
@@ -236,6 +240,11 @@ export function authorizationUrl(endpoints, { clientId, redirectUri, state, chal
236
240
  code_challenge: challenge,
237
241
  code_challenge_method: "S256",
238
242
  resource: endpoints.resource,
243
+ ...(ingestSetup === null ? {} : {
244
+ ingest_setup: "1",
245
+ ingest_setup_family_id: ingestSetup.familyId,
246
+ ingest_setup_code_challenge: ingestSetup.challenge,
247
+ }),
239
248
  }).toString();
240
249
  if (!signup) return url.href;
241
250
  const entry = new URL(`${endpoints.origin}${AUTH_PATHS.signup}`);
@@ -383,9 +392,17 @@ function decodeClaims(token) {
383
392
  // Nothing else is read: no telemetry, content or provider requests.
384
393
  // Returns { ok: true, workspaceId, documents } or { ok: false, outcome, reason, denied }
385
394
  // where denied means the service refused the token (401 or 403).
386
- export async function verifyContext(origin, accessToken, { profile, workspaceId }, signal) {
395
+ // workspaceId is the workspace the credential must be for. Only an agent key
396
+ // with nothing to pin it to passes anyWorkspace: true with workspaceId null,
397
+ // and then the workspace the service reports is checked for consistency and
398
+ // returned as the verified one.
399
+ export async function verifyContext(origin, accessToken, { profile, workspaceId, anyWorkspace = false }, signal) {
387
400
  const workspace = await bearerJson(origin, AUTH_PATHS.workspace, accessToken, WORKSPACE_BYTES, signal, "workspace");
388
401
  if (!workspace.ok) return workspace;
402
+ if (workspaceId === null && anyWorkspace === true) {
403
+ workspaceId = normalizeUuid(workspace.body.workspace?.id);
404
+ if (workspaceId === null) return stop("verification_failed", "workspace_response_invalid");
405
+ }
389
406
  const checked = checkWorkspace(workspace.body, { profile, workspaceId });
390
407
  if (!checked.ok) return checked;
391
408
 
@@ -466,7 +483,7 @@ async function bearerJson(origin, path, accessToken, maxBytes, signal, document)
466
483
  const failure = failureOf(response);
467
484
  if (failure !== null) return { ok: false, ...failure };
468
485
  if (response.status === 401 || response.status === 403) {
469
- return { ok: false, outcome: "verification_failed", reason: "access_rejected", denied: true };
486
+ return { ok: false, outcome: "verification_failed", reason: "access_rejected", denied: true, status: response.status };
470
487
  }
471
488
  if (response.status !== 200) return stop("unsupported", "unexpected_status");
472
489
  const body = parseJsonObject(response);
@@ -0,0 +1,58 @@
1
+ // The static pages the loopback callback listener answers with. They follow
2
+ // the look of the server's consent page (green band with the Metergraph mark,
3
+ // white card) so the hand-off from the browser back to the terminal reads as
4
+ // one flow. Pages are fixed strings: nothing from a request is ever rendered.
5
+ // The stylesheet is inline and allowed by its hash, so the policy stays
6
+ // default-src 'none' with no script and no 'unsafe-inline'.
7
+ import { createHash } from "node:crypto";
8
+
9
+ const LOGO = `<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 134 25" width="134" height="25" role="img" aria-label="Metergraph"><g fill="white" transform="scale(0.03681885)"><circle cx="105" cy="128" r="62"/><circle cx="105" cy="239" r="62"/><circle cx="105" cy="350" r="62"/><circle cx="105" cy="461" r="62"/><circle cx="105" cy="572" r="62"/><circle cx="578" cy="128" r="62"/><circle cx="578" cy="239" r="62"/><circle cx="578" cy="350" r="62"/><circle cx="578" cy="461" r="62"/><circle cx="578" cy="572" r="62"/><circle cx="221" cy="238" r="62"/><circle cx="282.5" cy="346" r="62"/><circle cx="341.5" cy="456" r="62"/><circle cx="400.5" cy="346" r="62"/><circle cx="462" cy="238" r="62"/></g><path d="M34.68 20V6H38.2L42.08 16.38L42.56 17.78C42.7 17.32 42.82 16.9 43.02 16.38L46.82 6H50.28V20H48.12V10.02L48.14 8.16L43.84 20H41.22L36.8 8.16L36.84 10.02V20H34.68ZM56.8091 20.32C53.9091 20.32 51.9691 18.48 51.9691 15.3C51.9691 12.1 53.9691 9.98 56.7091 9.98C59.6491 9.98 61.2891 12.02 61.2891 15.16V15.6H54.0891C54.1091 17.48 55.2491 18.7 56.8091 18.7C57.9291 18.7 58.9291 18.16 59.1291 16.92H61.2091C61.0091 18.76 59.4891 20.32 56.8091 20.32ZM59.1291 14.12C59.1091 12.6 58.2091 11.56 56.7091 11.56C55.2291 11.56 54.2291 12.68 54.1091 14.12H59.1291ZM66.6303 20.16C64.8703 20.16 63.6703 19.3 63.6703 17.14V11.98H61.6703V10.3H63.6703V6.94H65.7503V10.3H67.9503V11.98H65.7503V16.86C65.7503 17.94 66.2303 18.42 67.1903 18.42C67.4303 18.42 67.6503 18.4 67.9103 18.36V20.02C67.6103 20.1 67.1503 20.16 66.6303 20.16ZM73.4997 20.32C70.5997 20.32 68.6597 18.48 68.6597 15.3C68.6597 12.1 70.6597 9.98 73.3997 9.98C76.3397 9.98 77.9797 12.02 77.9797 15.16V15.6H70.7797C70.7997 17.48 71.9397 18.7 73.4997 18.7C74.6197 18.7 75.6197 18.16 75.8197 16.92H77.8997C77.6997 18.76 76.1797 20.32 73.4997 20.32ZM75.8197 14.12C75.7997 12.6 74.8997 11.56 73.3997 11.56C71.9197 11.56 70.9197 12.68 70.7997 14.12H75.8197ZM79.5581 20V10.3H81.6381V10.88L81.5981 11.98C82.0981 10.88 83.0581 9.98 84.5381 9.98C84.6981 9.98 84.8381 9.98 85.0181 10.02V12.04C84.8581 12.02 84.6781 11.98 84.3981 11.98C82.7181 11.98 81.6381 12.88 81.6381 15.02V20H79.5581ZM90.0131 24.44C87.3131 24.44 85.6531 22.96 85.6531 20.8H87.7531C87.7731 22.02 88.6131 22.82 90.0531 22.82C91.5931 22.82 92.6931 21.96 92.6931 20.2V19.08C92.6931 18.76 92.6931 18.46 92.7331 18.16C92.1931 19.08 91.0931 19.76 89.6331 19.76C87.0331 19.76 85.2531 17.92 85.2531 14.88C85.2531 11.88 87.2531 9.98 89.5931 9.98C91.2531 9.98 92.1931 10.74 92.7331 11.64C92.6931 11.36 92.6931 11.08 92.6931 10.78V10.3H94.7731V19.6C94.7731 22.86 92.9931 24.44 90.0131 24.44ZM90.1131 18.12C91.6931 18.12 92.8131 16.8 92.8131 14.88C92.8131 12.98 91.6931 11.62 90.1131 11.62C88.4931 11.62 87.3731 12.96 87.3731 14.88C87.3731 16.82 88.4931 18.12 90.1131 18.12ZM96.9128 20V10.3H98.9928V10.88L98.9528 11.98C99.4528 10.88 100.413 9.98 101.893 9.98C102.053 9.98 102.193 9.98 102.373 10.02V12.04C102.213 12.02 102.033 11.98 101.753 11.98C100.073 11.98 98.9928 12.88 98.9928 15.02V20H96.9128ZM105.929 20.32C104.169 20.32 102.589 19.2 102.589 17.34C102.589 15.3 104.189 14.48 106.089 14.22L107.829 13.96C108.589 13.86 108.849 13.58 108.849 13.04C108.849 12.18 108.169 11.56 107.089 11.56C105.769 11.56 105.029 12.28 104.969 13.38H102.849C102.949 11.5 104.469 9.98 106.949 9.98C109.629 9.98 110.909 11.48 110.909 13.96V20H108.969V19.56C108.969 19.26 108.969 19 109.009 18.68C108.509 19.66 107.489 20.32 105.929 20.32ZM106.389 18.76C107.809 18.76 108.889 17.76 108.889 16.28V15.04C108.729 15.22 108.369 15.36 107.789 15.46L106.689 15.68C105.549 15.9 104.729 16.34 104.729 17.28C104.729 18.22 105.469 18.76 106.389 18.76ZM112.92 24.12V10.3H115V10.86C115 11.12 114.98 11.36 114.96 11.66C115.54 10.7 116.66 9.98 118.14 9.98C120.82 9.98 122.5 12.14 122.5 15.16C122.5 18.28 120.64 20.32 118.08 20.32C116.56 20.32 115.58 19.64 114.96 18.66L115 19.52V24.12H112.92ZM117.64 18.7C119.28 18.7 120.36 17.24 120.36 15.14C120.36 13.08 119.32 11.62 117.64 11.62C116.04 11.62 114.9 12.98 114.9 15.14C114.9 17.26 116.02 18.7 117.64 18.7ZM124.058 20V6H126.138V10.76L126.098 11.64C126.618 10.7 127.658 9.98 129.078 9.98C131.258 9.98 132.578 11.44 132.578 13.88V20H130.498V14.16C130.498 12.54 129.838 11.62 128.478 11.62C127.058 11.62 126.138 12.8 126.138 14.24V20H124.058Z" fill="white"/></svg>`;
10
+
11
+ const STYLE = `
12
+ :root{color-scheme:light}
13
+ *{box-sizing:border-box}
14
+ body{margin:0;background:#edeee9;color:#151b17;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.55}
15
+ .shell{max-width:600px;margin:0 auto;padding:28px 12px 48px}
16
+ .hero{background:linear-gradient(90deg,#0a6146,#0a5944 54%,#073d32);color:#fff;padding:26px 30px 34px}
17
+ .logo{display:block;width:150px;height:auto;margin:0 0 36px}
18
+ .eyebrow{margin:0 0 6px;color:#d7f68e;font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
19
+ h1{margin:0;font-size:30px;font-weight:400;letter-spacing:-.6px;line-height:1.2}
20
+ .card{background:#fff;padding:30px}
21
+ p{margin:0 0 18px}
22
+ p:last-child{margin-bottom:0}
23
+ .note{color:#505b53;font-size:14px}
24
+ @media (max-width:520px){
25
+ .shell{padding:0 0 32px}
26
+ .hero{padding:22px 20px 28px}
27
+ .logo{margin-bottom:28px}
28
+ h1{font-size:25px}
29
+ .card{padding:22px 20px}
30
+ }
31
+ `;
32
+
33
+ export const PAGE_CSP = `default-src 'none'; style-src 'sha256-${createHash("sha256").update(STYLE).digest("base64")}'`;
34
+
35
+ const page = (title, lead, note) =>
36
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
37
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
38
+ `<title>${title} | Metergraph CLI</title><style>${STYLE}</style></head><body>` +
39
+ `<div class="shell"><header class="hero">${LOGO}<p class="eyebrow">Metergraph CLI</p><h1>${title}</h1></header>` +
40
+ `<main class="card"><p>${lead}</p><p class="note">${note}</p></main></div></body></html>\n`;
41
+
42
+ export const PAGES = {
43
+ done: page(
44
+ "Authorization received",
45
+ "Return to the terminal. The command finishes signing in there and reports the result.",
46
+ "You can close this tab.",
47
+ ),
48
+ failed: page(
49
+ "Authorization did not complete",
50
+ "Return to the terminal for details and the next step.",
51
+ "You can close this tab.",
52
+ ),
53
+ invalid: page(
54
+ "Request not accepted",
55
+ "This page only accepts the browser hand-off for a command that is waiting in your terminal.",
56
+ "If you started a sign-in, return to the terminal and try again.",
57
+ ),
58
+ };
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ import { listSkillPack, runSkill, runSkillPack } from "./skill.js";
20
20
  import { runSetup } from "./setup.js";
21
21
  import { containsKnownCredential, preflightNonHostedSetup, prerequisiteGuide, resumeNonHostedRoute } from "./setup-deployment.js";
22
22
  import { VERSION } from "./constants.js";
23
+ import { createStyle, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
23
24
  import { runVerify } from "./verify.js";
24
25
  import { verifyMessage, verifyText } from "./verify-output.js";
25
26
 
@@ -46,6 +47,7 @@ export async function main(argv, { stdout, stderr }) {
46
47
 
47
48
  async function run(argv, { stdout, stderr }) {
48
49
  const parsed = parseArgs(argv);
50
+ const style = createStyle(stdout);
49
51
 
50
52
  if (!parsed.ok) {
51
53
  const result = envelope({
@@ -55,13 +57,13 @@ async function run(argv, { stdout, stderr }) {
55
57
  message: parsed.message,
56
58
  });
57
59
  if (parsed.json) stdout.write(toJsonLine(result));
58
- else stderr.write(`Error: ${parsed.message}\n`);
60
+ else stderr.write(`${createStyle(stderr).red("Error:")} ${parsed.message}\n`);
59
61
  return result;
60
62
  }
61
63
 
62
64
  if (parsed.command === "help") {
63
65
  const result = envelope({ command: "help", outcome: "ok", data: helpData(parsed.topic) });
64
- stdout.write(parsed.json ? toJsonLine(result) : helpText(parsed.topic));
66
+ stdout.write(parsed.json ? toJsonLine(result) : helpText(parsed.topic, style));
65
67
  return result;
66
68
  }
67
69
 
@@ -87,7 +89,7 @@ async function run(argv, { stdout, stderr }) {
87
89
  data,
88
90
  message: outcome === "ok" ? null : message,
89
91
  });
90
- stdout.write(parsed.json ? toJsonLine(result) : authText(result, message));
92
+ stdout.write(parsed.json ? toJsonLine(result) : authText(result, message, style));
91
93
  return result;
92
94
  }
93
95
 
@@ -110,11 +112,7 @@ async function run(argv, { stdout, stderr }) {
110
112
  if (containsKnownCredential(result, routed)) {
111
113
  result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
112
114
  }
113
- const receipt = result.data?.receipt;
114
- const summary = receipt ? `Workspace: ${receipt.workspace_id}\nDeployment: ${receipt.deployment_profile} at ${receipt.origin}\nCompleted: ${receipt.completed_steps.join(", ")}\nPending: ${receipt.pending_steps.join(", ")}\n` : "";
115
- const identity = repositoryLine(result.data?.repository);
116
- const guide = handoffLines(result.data);
117
- const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}${identity}${guide}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
115
+ const human = setupText(result, style);
118
116
  if (containsKnownCredential(human, routed)) {
119
117
  result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
120
118
  stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
@@ -134,7 +132,7 @@ async function run(argv, { stdout, stderr }) {
134
132
  data,
135
133
  message: outcome === "ok" ? null : message,
136
134
  });
137
- stdout.write(parsed.json ? toJsonLine(result) : readText(result, message));
135
+ stdout.write(parsed.json ? toJsonLine(result) : readText(result, message, style));
138
136
  return result;
139
137
  }
140
138
 
@@ -142,7 +140,7 @@ async function run(argv, { stdout, stderr }) {
142
140
  const { outcome, reason, data } = await runVerify(parsed);
143
141
  const message = verifyMessage(outcome, reason);
144
142
  const result = envelope({ command: "verify", outcome, reason, data, message: outcome === "ok" ? null : message });
145
- stdout.write(parsed.json ? toJsonLine(result) : verifyText(result));
143
+ stdout.write(parsed.json ? toJsonLine(result) : verifyText(result, style));
146
144
  return result;
147
145
  }
148
146
 
@@ -155,7 +153,7 @@ async function run(argv, { stdout, stderr }) {
155
153
  data,
156
154
  message: outcome === "ok" ? null : message,
157
155
  });
158
- stdout.write(parsed.json ? toJsonLine(result) : skillText(result, message));
156
+ stdout.write(parsed.json ? toJsonLine(result) : skillText(result, message, style));
159
157
  return result;
160
158
  }
161
159
 
@@ -169,7 +167,7 @@ async function run(argv, { stdout, stderr }) {
169
167
  data,
170
168
  message: outcome === "ok" ? null : message,
171
169
  });
172
- stdout.write(parsed.json ? toJsonLine(result) : skillsText(result, message));
170
+ stdout.write(parsed.json ? toJsonLine(result) : skillsText(result, message, style));
173
171
  return result;
174
172
  }
175
173
 
@@ -184,7 +182,7 @@ async function run(argv, { stdout, stderr }) {
184
182
  data: report,
185
183
  message: doctorMessage(outcome, reason),
186
184
  });
187
- stdout.write(parsed.json ? toJsonLine(result) : doctorText(result));
185
+ stdout.write(parsed.json ? toJsonLine(result) : doctorText(result, style));
188
186
  return result;
189
187
  }
190
188
 
@@ -209,39 +207,98 @@ function announcer(parsed, stdout, screen) {
209
207
  return pending;
210
208
  }
211
209
 
212
- // What an operator handoff is waiting for, in fixed text: the route, each
213
- // unconfirmed prerequisite with its guidance, and the next action.
214
- function handoffLines(data) {
215
- if (data?.status !== "operator_handoff") return "";
216
- let text = data.origin ? `Deployment: ${data.deployment_profile} at ${data.origin}\n` : "";
217
- if (data.workspace_id) text += `Workspace: ${data.workspace_id}\n`;
210
+ const SETUP_TITLES = {
211
+ ready_for_instrumentation: "Setup complete, ready to instrument",
212
+ operator_handoff: "Setup is waiting on the operator",
213
+ };
214
+
215
+ function setupText(result, style) {
216
+ const data = result.data;
217
+ const status = data?.status ?? "not_ready";
218
+ const title = SETUP_TITLES[status] ?? (result.ok ? `Setup: ${status}` : "Setup did not complete");
219
+ const tone = status === "operator_handoff" ? "warn" : toneFor(result.outcome);
220
+ const lines = [headline(style, tone, title, result.outcome), ""];
221
+ const receipt = data?.receipt;
222
+ const rows = [];
223
+ if (receipt) {
224
+ rows.push(
225
+ ["Workspace", receipt.workspace_id],
226
+ ["Deployment", `${receipt.deployment_profile} at ${receipt.origin}`],
227
+ ["Completed", receipt.completed_steps.join(", ") || "none"],
228
+ ["Pending", receipt.pending_steps.join(", ") || "none"],
229
+ );
230
+ }
231
+ const repository = repositoryValue(data?.repository);
232
+ if (repository !== null) rows.push(["Repository", repository]);
233
+ if (status === "operator_handoff") {
234
+ if (data.origin) rows.push(["Deployment", `${data.deployment_profile} at ${data.origin}`]);
235
+ if (data.workspace_id) rows.push(["Workspace", data.workspace_id]);
236
+ }
237
+ rows.push(["Application traffic verified", "no"]);
238
+ lines.push(...fields(style, rows), "");
239
+ if (!result.ok) lines.push(...paragraph(style, result.error.message), ` ${style.dim(`Reason: ${result.error.reason}`)}`, "");
240
+ lines.push(...handoffLines(data, style));
241
+ lines.push(...remoteLines(data, style));
242
+ lines.push(...budgetLines(data, style));
243
+ if (result.ok) lines.push(...steps(style, [["", "Instrument your application and verify an exact trace."]]));
244
+ return render(lines);
245
+ }
246
+
247
+ // What an operator handoff is waiting for, in fixed text: each unconfirmed
248
+ // prerequisite with its guidance, and the next action.
249
+ function handoffLines(data, style) {
250
+ if (data?.status !== "operator_handoff" || data.next_action?.kind === "run_on_local_machine") return [];
251
+ const lines = [];
218
252
  if (data.pending_prerequisites.length > 0) {
219
- text += "Confirm each prerequisite, then rerun with --confirm-prerequisites:\n";
253
+ lines.push("Confirm each prerequisite, then rerun with --confirm-prerequisites:");
220
254
  for (const name of data.pending_prerequisites) {
221
- text += ` - ${name}: ${prerequisiteGuide(name, data.deployment_profile) ?? "see the guide"}\n`;
255
+ lines.push(` ${style.yellow("•")} ${style.bold(name)}`,
256
+ ...paragraph(style, prerequisiteGuide(name, data.deployment_profile) ?? "See the guide.", " "));
222
257
  }
258
+ lines.push("");
223
259
  }
224
260
  const next = data.next_action;
225
- if (next?.message && !(next.kind === "complete_prerequisite" && data.pending_prerequisites.includes(next.prerequisite))) {
226
- text += `Next: ${next.message}\n`;
227
- }
228
- if (next?.url) text += `Guide: ${next.url}\n`;
229
- return text;
261
+ const message = next?.message && !(next.kind === "complete_prerequisite" && data.pending_prerequisites.includes(next.prerequisite))
262
+ ? next.message : null;
263
+ lines.push(...steps(style, [["", message], ["Guide", next?.url ?? null]]), "");
264
+ return lines;
265
+ }
266
+
267
+ // A refused key issuance: wait for the next window, or revoke unused keys.
268
+ function budgetLines(data, style) {
269
+ const next = data?.next_action;
270
+ if (next?.kind !== "retry_later" && next?.kind !== "revoke_keys") return [];
271
+ return [...steps(style, [["", next.message], ["Keys page", next.url ?? null]]), ""];
272
+ }
273
+
274
+ // A remote session's handoff: where setup runs instead, and how this
275
+ // environment can still make Metadata reads with a separate agent key.
276
+ function remoteLines(data, style) {
277
+ const next = data?.next_action;
278
+ if (next?.kind !== "run_on_local_machine") return [];
279
+ const keys = next.metadata_reads?.person_steps.find((step) => step.action === "create_metadata_key")?.url ?? null;
280
+ return [...steps(style, [
281
+ ["", next.message],
282
+ ["On the person's own machine", style.bold(next.command)],
283
+ ["Metadata key for reads here", keys],
284
+ ["Then run here", next.metadata_reads ? style.bold(next.metadata_reads.then_run.join(", then ")) : null],
285
+ ["Guide", next.url],
286
+ ]), ""];
230
287
  }
231
288
 
232
- // One line on the repository identity setup found or recorded. The value is
233
- // owner/name only; a remote URL is never shown.
234
- function repositoryLine(repository) {
235
- if (!repository) return "";
289
+ // The repository identity setup found or recorded. The value is owner/name
290
+ // only; a remote URL is never shown.
291
+ function repositoryValue(repository) {
292
+ if (!repository) return null;
236
293
  const where = repository.path ? ` in ${repository.path}` : repository.source === "env_file" ? " in the env file" : "";
237
294
  switch (repository.status) {
238
- case "written": return `Repository: ${repository.repository} (recorded${where}; commit it)\n`;
295
+ case "written": return `${repository.repository} (recorded${where}; commit it)`;
239
296
  case "existing": return repository.repository === null
240
- ? `Repository: already set${where}\n` : `Repository: ${repository.repository} (already set${where})\n`;
241
- case "mismatch": return `Repository: ${repository.repository} (already set${where}; differs from ${repository.expected}, left unchanged)\n`;
242
- case "invalid_config": return `Repository: not recorded (${repository.path} is not a usable config; left unchanged)\n`;
243
- case "skipped": return "Repository: not recorded (--no-repository)\n";
244
- case "write_failed": return `Repository: not recorded (could not write ${repository.path})\n`;
245
- default: return "Repository: not recorded (no owner/name git remote; pass --repository OWNER/NAME)\n";
297
+ ? `already set${where}` : `${repository.repository} (already set${where})`;
298
+ case "mismatch": return `${repository.repository} (already set${where}; differs from ${repository.expected}, left unchanged)`;
299
+ case "invalid_config": return `not recorded (${repository.path} is not a usable config; left unchanged)`;
300
+ case "skipped": return "not recorded (--no-repository)";
301
+ case "write_failed": return `not recorded (could not write ${repository.path})`;
302
+ default: return "not recorded (no owner/name git remote; pass --repository OWNER/NAME)";
246
303
  }
247
304
  }
package/src/constants.js CHANGED
@@ -95,15 +95,35 @@ export const AUTH_PATHS = Object.freeze({
95
95
  // --limit locally.
96
96
  export const READ_PATHS = Object.freeze({
97
97
  usage: "/v1/agent/usage",
98
+ usageAggregates: "/v1/agent/usage/aggregates",
98
99
  routes: "/v1/agent/routes",
99
100
  traces: "/v1/agent/traces",
100
101
  });
101
102
  export const READ_QUERY_KEYS = Object.freeze({
102
- "/v1/agent/usage": Object.freeze(["days", "limit"]),
103
- "/v1/agent/traces": Object.freeze(["days", "limit", "route", "status", "cursor"]),
103
+ "/v1/agent/usage": Object.freeze(["days", "limit", "metadata"]),
104
+ "/v1/agent/usage/aggregates": Object.freeze([
105
+ "group_by", "days", "since", "until", "limit", "route", "provider", "model", "environment", "metadata",
106
+ ]),
107
+ "/v1/agent/traces": Object.freeze(["days", "limit", "route", "status", "cursor", "metadata"]),
104
108
  "/v1/cli/setup/credential": Object.freeze(["family_id"]),
105
109
  });
106
110
 
111
+ // Query keys that may repeat on a path, one value each, as the service reads
112
+ // list parameters. Every other key may appear at most once.
113
+ export const READ_QUERY_REPEATABLE = Object.freeze({
114
+ // One group_by per dimension, so a metadata key is never split on a comma.
115
+ "/v1/agent/usage/aggregates": Object.freeze(["group_by", "route", "provider", "model", "environment"]),
116
+ });
117
+
118
+ // Grouped usage (GET /v1/agent/usage/aggregates). Dimensions the service can
119
+ // group by (plus at most one "metadata.<key>"), the most values one filter may name, and how far apart the two
120
+ // ends of an explicit window may be.
121
+ export const USAGE_DIMENSIONS = Object.freeze(["provider", "model", "route", "day"]);
122
+ export const USAGE_DEFAULT_GROUP_BY = Object.freeze(["provider", "model"]);
123
+ export const USAGE_MAX_FILTER_VALUES = 20;
124
+ export const USAGE_SORTS = Object.freeze(["cost", "cost-per-call", "calls", "errors", "avg-latency", "p95-latency", "day"]);
125
+ export const USAGE_COMPARISONS = Object.freeze(["previous", "previous-same-time"]);
126
+
107
127
  // Read commands share one total deadline for every request they make,
108
128
  // including a token refresh. It is never reset per request or page.
109
129
  export const READ_DEFAULT_TIMEOUT_MS = 15000;
@@ -119,6 +139,15 @@ export const READ_DEFAULT_LIMIT = 50;
119
139
  export const TRACES_DEFAULT_LIMIT = 20;
120
140
  export const MAX_CURSOR_LENGTH = 512;
121
141
  export const MAX_FILTER_LENGTH = 256;
142
+ // Exact customer-metadata filters, matching the service's bounds. The service
143
+ // also refuses credential-like keys; the CLI checks the same rule first.
144
+ export const MAX_METADATA_FILTERS = 5;
145
+ export const MAX_METADATA_KEY_LENGTH = 200;
146
+ export const MAX_METADATA_VALUE_LENGTH = 1000;
147
+ // The longest query string the transport sends. Read arguments are checked
148
+ // against the exact query they produce, so a long one is an input error
149
+ // before any request rather than a failure after sign in.
150
+ export const MAX_QUERY_LENGTH = 2048;
122
151
 
123
152
  // One exit code per outcome. Documented in README.md; changing a value is a
124
153
  // breaking change for scripts. New outcomes are appended.
@@ -175,7 +204,7 @@ export const EXIT_CODE_MEANINGS = Object.freeze({
175
204
  "The service does not make this read available to the project's Metadata grant. No data was read.",
176
205
  permission_denied:
177
206
  "The service refused this read for the signed in grant, for example for a missing scope or permission.",
178
- rate_limited: "The service asked the CLI to slow down. Nothing was retried. Try again later.",
207
+ rate_limited: "The service asked the CLI to slow down. Try again later. Only setup retries, after a short Retry-After, to keep its approval.",
179
208
  cancelled:
180
209
  "A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry.",
181
210
  action_required: