metergraph-cli 0.2.1 → 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/README.md +12 -7
- 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/package.json +1 -1
- package/src/agent-key.js +157 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +373 -14
- 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 +20 -3
- package/src/callback-page.js +58 -0
- package/src/cli.js +94 -37
- package/src/constants.js +32 -3
- package/src/environment.js +169 -0
- package/src/origin.js +6 -0
- package/src/output.js +325 -130
- package/src/read-contract.js +953 -13
- package/src/read-output.js +449 -30
- package/src/read.js +297 -59
- package/src/setup-deployment.js +4 -3
- 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 +8 -4
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
package/src/auth-login.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
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,
|
|
244
|
+
announce(openUrl(url, seconds, purpose));
|
|
220
245
|
} else if (options.noBrowser) {
|
|
221
246
|
progress(
|
|
222
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
253
|
+
lines.push("Confirm each prerequisite, then rerun with --confirm-prerequisites:");
|
|
220
254
|
for (const name of data.pending_prerequisites) {
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
//
|
|
233
|
-
//
|
|
234
|
-
function
|
|
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
|
|
295
|
+
case "written": return `${repository.repository} (recorded${where}; commit it)`;
|
|
239
296
|
case "existing": return repository.repository === null
|
|
240
|
-
? `
|
|
241
|
-
case "mismatch": return
|
|
242
|
-
case "invalid_config": return `
|
|
243
|
-
case "skipped": return "
|
|
244
|
-
case "write_failed": return `
|
|
245
|
-
default: return "
|
|
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/
|
|
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.
|
|
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:
|