metergraph-cli 0.2.3 → 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.
- package/README.md +78 -14
- package/assets/skill/SKILL.md +124 -31
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +31 -31
- package/assets/skills/metergraph-analyze/SKILL.md +161 -58
- package/assets/skills/metergraph-candidates/SKILL.md +50 -8
- package/assets/skills/metergraph-evals/SKILL.md +83 -16
- package/assets/skills/metergraph-investigate/SKILL.md +90 -34
- package/assets/skills/metergraph-iterate/SKILL.md +82 -12
- package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
- package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
- package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
- package/assets/skills/metergraph-report/SKILL.md +60 -15
- package/assets/skills/metergraph-workloads/SKILL.md +97 -13
- package/native/README.md +85 -0
- package/native/anchored-fs.c +243 -0
- package/native/bin/anchored-fs-darwin-arm64 +0 -0
- package/native/bin/anchored-fs-darwin-x64 +0 -0
- package/native/bin/anchored-fs-linux-arm64 +0 -0
- package/native/bin/anchored-fs-linux-x64 +0 -0
- package/native/build.mjs +55 -0
- package/native/manifest.json +9 -0
- package/native/targets.mjs +24 -0
- package/package.json +10 -3
- package/src/action-help.js +130 -0
- package/src/agent-key.js +21 -14
- package/src/anchored-fs.js +163 -0
- package/src/args.js +125 -17
- package/src/auth-binding.js +12 -7
- package/src/auth-callback.js +10 -2
- package/src/auth-login.js +111 -28
- package/src/auth-oauth.js +89 -45
- package/src/auth-session.js +19 -12
- package/src/auth-store.js +28 -7
- package/src/cli.js +52 -8
- package/src/constants.js +37 -9
- package/src/experiments-output.js +7 -6
- package/src/experiments.js +26 -17
- package/src/output.js +274 -79
- package/src/read-contract.js +42 -8
- package/src/read-output.js +66 -13
- package/src/read.js +90 -15
- package/src/repository-identity.js +2 -2
- package/src/setup-env-git.js +1 -1
- package/src/setup-env.js +16 -9
- package/src/setup-interactive.js +19 -4
- package/src/setup-refusal.js +2 -2
- package/src/setup-state.js +9 -4
- package/src/setup.js +15 -3
- package/src/skill-bundle.js +2 -2
- package/src/skill-trusted.js +56 -0
- package/src/skill.js +28 -9
- package/src/style.js +33 -7
- package/src/tools-output.js +73 -0
- package/src/tools.js +249 -0
- package/src/trace-contract.js +1 -1
- package/src/transport.js +21 -0
- package/src/verify-output.js +2 -2
- package/src/verify.js +5 -2
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
|
-
|
|
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,
|
|
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
|
|
40
|
-
// server-verified workspace UUID
|
|
41
|
-
//
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
|
239
|
-
|
|
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
|
|
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
|
|
440
|
-
kind: "
|
|
441
|
-
message:
|
|
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
|
-
|
|
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,
|