metergraph-cli 0.2.2 → 0.2.4

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.
Files changed (66) hide show
  1. package/README.md +121 -13
  2. package/assets/skill/SKILL.md +124 -31
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +31 -31
  5. package/assets/skills/metergraph-analyze/SKILL.md +161 -58
  6. package/assets/skills/metergraph-candidates/SKILL.md +50 -8
  7. package/assets/skills/metergraph-evals/SKILL.md +83 -16
  8. package/assets/skills/metergraph-investigate/SKILL.md +90 -34
  9. package/assets/skills/metergraph-iterate/SKILL.md +82 -12
  10. package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
  11. package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
  12. package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
  13. package/assets/skills/metergraph-report/SKILL.md +60 -15
  14. package/assets/skills/metergraph-workloads/SKILL.md +97 -13
  15. package/bin/metergraph.js +1 -1
  16. package/native/README.md +85 -0
  17. package/native/anchored-fs.c +243 -0
  18. package/native/bin/anchored-fs-darwin-arm64 +0 -0
  19. package/native/bin/anchored-fs-darwin-x64 +0 -0
  20. package/native/bin/anchored-fs-linux-arm64 +0 -0
  21. package/native/bin/anchored-fs-linux-x64 +0 -0
  22. package/native/build.mjs +55 -0
  23. package/native/manifest.json +9 -0
  24. package/native/targets.mjs +24 -0
  25. package/package.json +10 -3
  26. package/src/action-help.js +130 -0
  27. package/src/agent-key.js +22 -12
  28. package/src/anchored-fs.js +163 -0
  29. package/src/args.js +335 -31
  30. package/src/auth-binding.js +12 -7
  31. package/src/auth-callback.js +10 -2
  32. package/src/auth-login.js +111 -28
  33. package/src/auth-oauth.js +133 -36
  34. package/src/auth-session.js +19 -12
  35. package/src/auth-store.js +28 -7
  36. package/src/cli.js +147 -15
  37. package/src/constants.js +70 -1
  38. package/src/experiments-contract.js +462 -0
  39. package/src/experiments-output.js +190 -0
  40. package/src/experiments.js +406 -0
  41. package/src/output.js +374 -68
  42. package/src/pricing-args.js +163 -0
  43. package/src/pricing-output.js +206 -0
  44. package/src/pricing-reference.js +195 -0
  45. package/src/pricing.js +307 -0
  46. package/src/read-contract.js +64 -10
  47. package/src/read-output.js +66 -13
  48. package/src/read.js +105 -16
  49. package/src/repository-identity.js +2 -2
  50. package/src/setup-deployment.js +7 -0
  51. package/src/setup-env-git.js +1 -1
  52. package/src/setup-env.js +16 -9
  53. package/src/setup-interactive.js +356 -0
  54. package/src/setup-refusal.js +2 -2
  55. package/src/setup-state.js +9 -4
  56. package/src/setup.js +15 -3
  57. package/src/skill-bundle.js +2 -2
  58. package/src/skill-trusted.js +56 -0
  59. package/src/skill.js +28 -9
  60. package/src/style.js +33 -7
  61. package/src/tools-output.js +73 -0
  62. package/src/tools.js +249 -0
  63. package/src/trace-contract.js +1 -1
  64. package/src/transport.js +26 -3
  65. package/src/verify-output.js +2 -2
  66. package/src/verify.js +5 -2
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import fs from "node:fs";
2
+ import fs, { withRollback } from "./anchored-fs.js";
3
3
  import path from "node:path";
4
4
 
5
5
  import { Stop, isSlot } from "./auth-store.js";
@@ -8,8 +8,9 @@ import { parseOrigin } from "./origin.js";
8
8
 
9
9
  // The project binding, .metergraph/project.json, names which origin,
10
10
  // workspace and profile a project is signed in to and which credential slot
11
- // in the user's private store holds the grant. It holds no credential, user
12
- // name or absolute path, so it is safe to commit. Other files in .metergraph,
11
+ // in the user's private store holds the grant. The private grant also pins the
12
+ // canonical checkout, so copying this file never adopts or revokes that grant.
13
+ // It holds no credential, user name or absolute path. Other files in .metergraph,
13
14
  // such as skill receipts, are never touched.
14
15
 
15
16
  export const BINDING_PATH = ".metergraph/project.json";
@@ -25,7 +26,8 @@ export function resolveProject(project) {
25
26
  try {
26
27
  const real = fs.realpathSync(path.resolve(project ?? process.cwd()));
27
28
  if (fs.statSync(real).isDirectory()) return real;
28
- } catch {
29
+ } catch (error) {
30
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
29
31
  // Fall through to the fixed error.
30
32
  }
31
33
  throw new Stop("invalid_input", "invalid_project");
@@ -44,6 +46,7 @@ export function readBinding(root) {
44
46
  return { binding, content };
45
47
  } catch (error) {
46
48
  if (error instanceof Stop) throw error;
49
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
47
50
  throw new Stop("filesystem_error", "read_failed");
48
51
  }
49
52
  }
@@ -82,12 +85,13 @@ export function writeBinding(root, binding, previous) {
82
85
  } catch (error) {
83
86
  if (created) {
84
87
  try {
85
- fs.rmdirSync(dir);
88
+ withRollback(() => fs.rmdirSync(dir));
86
89
  } catch {
87
90
  // Not empty. An empty directory is harmless.
88
91
  }
89
92
  }
90
93
  if (error instanceof Stop) throw error;
94
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
91
95
  throw new Stop("filesystem_error", "binding_write_failed");
92
96
  }
93
97
  }
@@ -102,6 +106,7 @@ export function removeBinding(root, previous) {
102
106
  });
103
107
  } catch (error) {
104
108
  if (error instanceof Stop) throw error;
109
+ if (error.code === "ENOTSUP") throw new Stop("unsupported", "secure_writes_unavailable");
105
110
  throw new Stop("filesystem_error", "binding_write_failed");
106
111
  }
107
112
  }
@@ -118,7 +123,7 @@ function withProjectLock(dir, work) {
118
123
  return work();
119
124
  } finally {
120
125
  try {
121
- fs.unlinkSync(lock);
126
+ withRollback(() => fs.unlinkSync(lock));
122
127
  } catch {
123
128
  // Reported by the next run as a held lock.
124
129
  }
@@ -145,7 +150,7 @@ function replace(dir, content) {
145
150
  fs.renameSync(temp, dest);
146
151
  } catch (error) {
147
152
  try {
148
- fs.unlinkSync(temp);
153
+ withRollback(() => fs.unlinkSync(temp));
149
154
  } catch {
150
155
  // Already renamed or never created.
151
156
  }
@@ -19,6 +19,9 @@ const ALLOWED_PARAMS = new Set(["code", "state", "error", "error_description", "
19
19
  // The setup receipt a combined sign-in approval returns next to the code.
20
20
  // Accepted only by a wait that asked for it.
21
21
  export const SETUP_CODE_PARAM = "ingest_setup_code";
22
+ // The fixed error_description values a consent page sends when the workspace
23
+ // the CLI named matches none, or more than one, of the person's workspaces.
24
+ const HINT_REFUSALS = ["workspace_hint_ambiguous", "workspace_hint_unknown"];
22
25
 
23
26
  const PAGE_HEADERS = {
24
27
  "content-type": "text/html; charset=utf-8",
@@ -39,7 +42,11 @@ const PAGE_HEADERS = {
39
42
  // { kind: "code", code[, setupCode] } setupCode, the ingest_setup_code
40
43
  // value, only when wait() was given
41
44
  // acceptSetupCode and it was sent
42
- // { kind: "denied" } the user or server declined
45
+ // { kind: "denied", hint } the user or server declined; hint
46
+ // is workspace_hint_ambiguous or
47
+ // workspace_hint_unknown when the
48
+ // consent page refused the workspace
49
+ // the CLI named, else null
43
50
  // { kind: "error", reason } authorization_error,
44
51
  // callback_issuer_mismatch or
45
52
  // callback_invalid
@@ -163,8 +170,9 @@ function inspect(request, { host, state, issuer, requireIss, acceptSetupCode })
163
170
  if (seen.has("code") || seen.has(SETUP_CODE_PARAM)) {
164
171
  return { status: 400, result: { kind: "error", reason: "callback_invalid" } };
165
172
  }
173
+ const description = params.get("error_description");
166
174
  return params.get("error") === "access_denied"
167
- ? { status: 200, result: { kind: "denied" } }
175
+ ? { status: 200, result: { kind: "denied", hint: HINT_REFUSALS.includes(description) ? description : null } }
168
176
  : { status: 200, result: { kind: "error", reason: "authorization_error" } };
169
177
  }
170
178
  const code = params.get("code");
package/src/auth-login.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  exchangeCode,
17
17
  newPkce,
18
18
  revoke,
19
+ sameScopes,
19
20
  verifyContext,
20
21
  } from "./auth-oauth.js";
21
22
  import { LOCK_WAIT_MS, sessionFor } from "./auth-session.js";
@@ -25,26 +26,35 @@ import {
25
26
  deleteCredential,
26
27
  newSlot,
27
28
  openStore,
28
- readCredential,
29
+ readProjectCredential,
30
+ projectIdentity,
29
31
  resolveConfigDir,
30
32
  withSlotLock,
31
33
  writeCredential,
32
34
  } from "./auth-store.js";
33
- import { AUTH_HTTP_TIMEOUT_MS, METADATA_SCOPE } from "./constants.js";
35
+ import { AUTH_HTTP_TIMEOUT_MS, WORKFLOW_PERMISSIONS, loginScope } from "./constants.js";
34
36
  import { runDoctor } from "./doctor.js";
35
37
  import { agentKeyAction, classifyEnvironment } from "./environment.js";
38
+ import { workspaceLabel, workspacePinned } from "./read-contract.js";
36
39
  import { deadline, trapSignals } from "./transport.js";
37
40
 
38
41
  // login and logout. Each returns { outcome, reason, data }; every string in
39
- // data is a fixed token, the validated origin, a supported profile or a
40
- // server-verified workspace UUID. Token values, the authorization code, the
41
- // PKCE verifier, absolute paths and server text never leave this module.
42
+ // data is a fixed token, the validated origin, a supported profile, a
43
+ // server-verified workspace UUID, or that workspace's slug and name as the
44
+ // service reported them, checked as safe to print and dropped if they contain
45
+ // a credential this run holds. Token values, the authorization code, the PKCE
46
+ // verifier, absolute paths and other server text never leave this module.
42
47
  // progress(text) prints human progress lines; it is a no-op with --json.
43
48
  // options.announce(data), given only with --json --no-browser, receives the
44
49
  // login data with an open_url next action as soon as the authorization URL
45
50
  // exists, while the listener keeps waiting. The caller prints it as the one
46
51
  // JSON line, so an agent can hand the URL to a person; approval-handoff.js
47
52
  // lets the agent's command exit then while this process keeps waiting.
53
+ // options.role is "metadata" (the default) or "debug", and options.permissions
54
+ // the workflow permission scopes --allow-* added to Debug, which a workspace
55
+ // owner approves on the consent page. options.workspaceText is a workspace named by
56
+ // slug or name: sent to a consent page that can match it, and checked against
57
+ // the workspace the service reports.
48
58
  // options.ingestSetup, given only by setup, is { familyId, supported(origin,
49
59
  // profile, signal) }. When the service supports it, the sign-in consent also
50
60
  // approves the first ingest key for that family, and an ok result carries the
@@ -61,8 +71,12 @@ export async function runLogin(options, progress) {
61
71
  status: null,
62
72
  previousGrant: null,
63
73
  environment: null,
74
+ role: options.role === "debug" ? "debug" : "metadata",
75
+ scope: loginScope(options.role, options.permissions ?? []),
76
+ workspaceLabel: { slug: null, name: null },
64
77
  };
65
78
  const end = (outcome, reason, next = null) => ({ outcome, reason, data: loginData(ctx, next) });
79
+ const workspaceText = options.workspaceText ?? null;
66
80
 
67
81
  // Handoffs come first: no listener, request or file write happens for a
68
82
  // session that cannot finish sign in. The next action tells the agent how
@@ -74,6 +88,11 @@ export async function runLogin(options, progress) {
74
88
  return end("unsupported", "runtime_not_supported", agentKeyAction({ kind: "declared_cloud" }, options.origin));
75
89
  }
76
90
  if (environment.remote) return end("unsupported", environment.reason, agentKeyAction(environment, options.origin));
91
+ // Refuse before reading saved grants or creating a callback/manual URL.
92
+ // Browser consent through a host loopback port cannot establish identity.
93
+ if (new URL(options.origin).protocol === "http:") {
94
+ return end("unsupported", "local_docker_transport_required");
95
+ }
77
96
  const trap = trapSignals();
78
97
  try {
79
98
  const root = resolveProject(options.project);
@@ -94,18 +113,25 @@ export async function runLogin(options, progress) {
94
113
  }
95
114
  const store = openStore(configDir, { create: true });
96
115
 
97
- // A rerun with a working grant changes nothing and opens no browser.
98
- if (bound !== null && !options.reconnect) {
99
- const session = await sessionFor(bound, store, trap.signal);
116
+ // A rerun with a working grant for the same role and permissions changes
117
+ // nothing and opens no browser. Asking for others signs in again, for the
118
+ // same workspace.
119
+ const saved = bound === null ? null : readOrNull(store, bound.credential_slot, root);
120
+ if (bound !== null && !options.reconnect && saved?.scope === ctx.scope) {
121
+ const session = await sessionFor(bound, store, trap.signal, root);
100
122
  if (session.ok) {
123
+ if (workspaceText !== null && !workspacePinned(session.documents.workspace?.workspace, workspaceText)) {
124
+ return end("conflict", "bound_to_other_workspace", RECONNECT);
125
+ }
101
126
  Object.assign(ctx, {
102
127
  profile: bound.deployment_profile,
103
128
  workspaceId: bound.workspace_id,
129
+ workspaceLabel: labelWithout(workspaceLabel(session.documents.workspace?.workspace), session.knownCredentials),
104
130
  authenticated: true,
105
131
  configured: true,
106
132
  status: "reused",
107
133
  });
108
- return end("ok", null, CONNECTED);
134
+ return end("ok", null, connected(ctx));
109
135
  }
110
136
  if (session.outcome !== "login_required") return end(session.outcome, session.reason);
111
137
  }
@@ -128,13 +154,14 @@ export async function runLogin(options, progress) {
128
154
  try {
129
155
  writeCredential(store, slot, {
130
156
  schema_version: 1,
157
+ project_id: projectIdentity(root),
131
158
  origin: ctx.origin,
132
159
  issuer: endpoints.issuer,
133
160
  resource: endpoints.resource,
134
161
  deployment_profile: ctx.profile,
135
162
  workspace_id: grant.tenant,
136
163
  client_id: clientId,
137
- scope: METADATA_SCOPE,
164
+ scope: ctx.scope,
138
165
  access_token: grant.accessToken,
139
166
  refresh_token: grant.refreshToken,
140
167
  expires_at: grant.expiresAt,
@@ -162,9 +189,9 @@ export async function runLogin(options, progress) {
162
189
  // The old grant is no longer referenced. Ask the service to revoke it
163
190
  // and remove it locally; neither affects the new binding.
164
191
  if (bound !== null && bound.credential_slot !== slot) {
165
- ctx.previousGrant = await retireSlot(store, bound);
192
+ ctx.previousGrant = await retireSlot(store, bound, root);
166
193
  }
167
- return { ...end("ok", null, CONNECTED), ingestSetup: flow.ingestSetup };
194
+ return { ...end("ok", null, connected(ctx)), ingestSetup: flow.ingestSetup };
168
195
  } catch (error) {
169
196
  if (error instanceof Stop) return end(error.outcome, error.reason);
170
197
  throw error;
@@ -189,9 +216,18 @@ async function authorize(options, ctx, cancel, progress, announce) {
189
216
  if (options.signup && ctx.profile !== "managed") return fail("unsupported", "signup_unsupported");
190
217
 
191
218
  let limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
192
- const discovered = await discover(ctx.origin, limit.signal);
219
+ const discovered = await discover(ctx.origin, limit.signal, ctx.scope.split(" ")[0]);
193
220
  if (!discovered.ok) return httpFailure(discovered, cancel);
194
221
  const { endpoints, requireIss, offeredClientId } = discovered;
222
+ const scopes = ctx.scope.split(" ");
223
+ // The consent page can match a slug or name only where the service says
224
+ // so. Without that, a slug is still checked after sign in, but a name,
225
+ // which may be ambiguous, is refused before the browser opens.
226
+ const workspaceText = options.workspaceText ?? null;
227
+ if (workspaceText !== null && !discovered.workspaceHint && !SLUG.test(workspaceText.toLowerCase())) {
228
+ return fail("unsupported", "workspace_name_unsupported", USE_SLUG);
229
+ }
230
+ const workspaceHint = discovered.workspaceHint ? workspaceText ?? options.workspace ?? null : null;
195
231
 
196
232
  // Setup asks this consent to approve the first ingest key too, but only
197
233
  // when the service says it can. Otherwise sign in is unchanged and setup
@@ -209,7 +245,7 @@ async function authorize(options, ctx, cancel, progress, announce) {
209
245
  let verifier;
210
246
  try {
211
247
  limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
212
- const client = await clientFor(endpoints, offeredClientId, listener.redirectUri, limit.signal);
248
+ const client = await clientFor(endpoints, offeredClientId, listener.redirectUri, limit.signal, scopes);
213
249
  if (!client.ok) return httpFailure(client, cancel);
214
250
  clientId = client.clientId;
215
251
 
@@ -234,9 +270,12 @@ async function authorize(options, ctx, cancel, progress, announce) {
234
270
  challenge: pkce.challenge,
235
271
  signup: options.signup,
236
272
  ingestSetup: setupPkce === null ? null : { familyId: options.ingestSetup.familyId, challenge: setupPkce.challenge },
273
+ scopes,
274
+ workspaceHint,
237
275
  });
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";
276
+ const access = accessText(ctx) + (ctx.scope.includes(" ") ? ", which a workspace owner approves" : "");
277
+ const purpose = setupPkce === null ? `sign in with ${access}`
278
+ : `sign in with ${access} and approve an ingest-only key for this project`;
240
279
  const seconds = Math.round(options.timeoutMs / 1000);
241
280
  // A wait armed after Ctrl+C has already settled; do not show the URL.
242
281
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
@@ -272,6 +311,8 @@ async function authorize(options, ctx, cancel, progress, announce) {
272
311
  }
273
312
 
274
313
  if (callback.kind === "code" && setupPkce !== null && callback.setupCode) options.ingestSetup.onApproved?.();
314
+ // The consent page refused the workspace the CLI named.
315
+ if (callback.kind === "denied" && callback.hint) return fail("invalid_input", callback.hint, USE_SLUG);
275
316
  if (callback.kind === "denied") return fail("authorization_failed", "access_denied");
276
317
  if (callback.kind === "timeout") return fail("authorization_failed", "timeout");
277
318
  if (callback.kind === "cancelled") return fail("authorization_failed", "cancelled");
@@ -280,7 +321,7 @@ async function authorize(options, ctx, cancel, progress, announce) {
280
321
  limit = deadline(AUTH_HTTP_TIMEOUT_MS, cancel);
281
322
  const exchanged = await exchangeCode(
282
323
  endpoints,
283
- { clientId, redirectUri: listener.redirectUri, code: callback.code, verifier },
324
+ { clientId, redirectUri: listener.redirectUri, code: callback.code, verifier, scopes },
284
325
  limit.signal,
285
326
  );
286
327
  if (!exchanged.ok) return httpFailure(exchanged, cancel);
@@ -297,14 +338,22 @@ async function authorize(options, ctx, cancel, progress, announce) {
297
338
  const verified = await verifyContext(
298
339
  ctx.origin,
299
340
  grant.accessToken,
300
- { profile: ctx.profile, workspaceId: grant.tenant },
341
+ { profile: ctx.profile, workspaceId: grant.tenant, access: ctx.role === "debug" ? "read" : "metadata" },
301
342
  limit.signal,
302
343
  );
303
344
  if (!verified.ok) {
304
345
  if (cancel.aborted) return rejectGrant("authorization_failed", "cancelled");
305
346
  return rejectGrant(verified.outcome, verified.reason);
306
347
  }
348
+ if (ctx.role === "debug" && !sameScopes(ctx.scope, verified.scopes)) return rejectGrant("verification_failed", "scope_mismatch");
349
+ const workspace = verified.documents.workspace?.workspace;
350
+ if (workspaceText !== null && !workspacePinned(workspace, workspaceText)) {
351
+ return rejectGrant("verification_failed", "workspace_mismatch");
352
+ }
307
353
  ctx.workspaceId = grant.tenant;
354
+ ctx.workspaceLabel = labelWithout(workspaceLabel(workspace), [
355
+ grant.accessToken, grant.refreshToken, callback.code, verifier, callback.setupCode, setupPkce?.verifier,
356
+ ]);
308
357
  // The receipt is a separate credential: setup redeems it with its own
309
358
  // verifier, for the family it named, after binding this sign in. A service
310
359
  // that showed only the sign in returns no receipt, and setup falls back to
@@ -335,7 +384,8 @@ export async function runLogout(options) {
335
384
  store,
336
385
  bound.credential_slot,
337
386
  async () => {
338
- const record = readOrNull(store, bound.credential_slot);
387
+ const record = readOrNull(store, bound.credential_slot, root);
388
+ if (record === null) return false;
339
389
  // A grant is only ever sent back to the origin it came from.
340
390
  if (record !== null && record.origin === bound.origin) {
341
391
  const limit = deadline(AUTH_HTTP_TIMEOUT_MS, trap.signal);
@@ -372,13 +422,14 @@ export async function runLogout(options) {
372
422
  // Revokes and removes the grant a previous binding pointed to. Returns
373
423
  // "accepted", "unconfirmed" or "not_attempted". Failures here never undo the
374
424
  // new binding.
375
- async function retireSlot(store, bound) {
425
+ async function retireSlot(store, bound, root) {
376
426
  try {
377
427
  return await withSlotLock(
378
428
  store,
379
429
  bound.credential_slot,
380
430
  async () => {
381
- const record = readOrNull(store, bound.credential_slot);
431
+ const record = readOrNull(store, bound.credential_slot, root);
432
+ if (record === null) return "not_attempted";
382
433
  let revocation = "not_attempted";
383
434
  if (record !== null && record.origin === bound.origin) {
384
435
  revocation = await revokeQuietly(endpointsFor(record.origin), record.client_id, record.refresh_token);
@@ -393,9 +444,9 @@ async function retireSlot(store, bound) {
393
444
  }
394
445
  }
395
446
 
396
- function readOrNull(store, slot) {
447
+ function readOrNull(store, slot, root) {
397
448
  try {
398
- return readCredential(store, slot);
449
+ return readProjectCredential(store, slot, root);
399
450
  } catch (error) {
400
451
  if (error instanceof Stop && error.outcome === "login_required") return null;
401
452
  throw error;
@@ -436,10 +487,38 @@ const RECONNECT = {
436
487
  kind: "reconnect",
437
488
  message: "Run login again with --reconnect to switch this project, or run logout first.",
438
489
  };
439
- const CONNECTED = {
440
- kind: "connected",
441
- message: 'This project is signed in with Metadata access. Run "metergraph logout" to sign out.',
490
+ const USE_SLUG = {
491
+ kind: "use_workspace_slug",
492
+ message:
493
+ "Run login again with --workspace and the workspace's slug, which its Settings page and " +
494
+ "\"metergraph context\" show.",
442
495
  };
496
+ const SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
497
+
498
+ // A workspace label is service text. If it holds a credential this run knows,
499
+ // it is not printed; sign in itself is unaffected.
500
+ function labelWithout(label, secrets) {
501
+ const values = [label.slug, label.name].filter((value) => value !== null);
502
+ const echoes = secrets.some((secret) => typeof secret === "string" && secret.length > 0 &&
503
+ values.some((value) => value.includes(secret)));
504
+ return echoes ? { slug: null, name: null } : label;
505
+ }
506
+
507
+ // The role and permissions a sign in asks for, by the Keys page's labels.
508
+ function accessText(ctx) {
509
+ if (ctx.role !== "debug") return "the Metadata role";
510
+ const labels = WORKFLOW_PERMISSIONS.filter(({ scope }) => ctx.scope.split(" ").includes(scope)).map(({ label }) => label);
511
+ if (labels.length === 0) return "the Debug role";
512
+ const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
513
+ return `the Debug role with ${list}`;
514
+ }
515
+
516
+ function connected(ctx) {
517
+ return {
518
+ kind: "connected",
519
+ message: `This project is signed in with ${accessText(ctx)}. Run "metergraph logout" to sign out.`,
520
+ };
521
+ }
443
522
 
444
523
  function loginData(ctx, next) {
445
524
  return {
@@ -447,8 +526,12 @@ function loginData(ctx, next) {
447
526
  runtime: ctx.runtime,
448
527
  environment: ctx.environment,
449
528
  deployment_profile: ctx.profile,
450
- workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
451
- scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
529
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId, ...ctx.workspaceLabel },
530
+ access: {
531
+ role: ctx.role,
532
+ permissions: Object.fromEntries(WORKFLOW_PERMISSIONS.map(({ field, scope }) => [field, ctx.scope.split(" ").includes(scope)])),
533
+ },
534
+ scopes: ctx.authenticated ? ctx.scope.split(" ").sort() : [],
452
535
  authenticated: ctx.authenticated,
453
536
  configured: ctx.configured,
454
537
  status: ctx.status,