metergraph-cli 0.2.0-preview.5 → 0.2.0

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/args.js CHANGED
@@ -279,9 +279,10 @@ function parseVerify(values, flags, json, fail) {
279
279
  open: flags.has("--open"), noBrowser: flags.has("--no-browser"), json };
280
280
  }
281
281
 
282
- // Client and runtime are required, so a script states where the skill is
283
- // used instead of the CLI guessing. Values for clients and runtimes that
284
- // cannot use project skill files are accepted here and handed off later.
282
+ // The client is required, so a script states which client loads the skill.
283
+ // The runtime defaults to local, the runtime setup installs the skill for;
284
+ // pass --runtime cloud for a cloud checkout. Values for clients and runtimes
285
+ // that cannot use project skill files are accepted here and handed off later.
285
286
  function parseSkill(action, values, json, fail) {
286
287
  if (action === null) {
287
288
  return fail(
@@ -296,10 +297,7 @@ function parseSkill(action, values, json, fail) {
296
297
  if (!Object.hasOwn(SKILL_CLIENTS, client) && !Object.hasOwn(HANDOFF_SKILL_CLIENTS, client)) {
297
298
  return fail("invalid_client", "--client must be codex, claude or cursor.");
298
299
  }
299
- const runtime = values["--runtime"];
300
- if (runtime === undefined) {
301
- return fail("missing_runtime", "--runtime is required. Use local or cloud.");
302
- }
300
+ const runtime = values["--runtime"] ?? "local";
303
301
  if (!SKILL_RUNTIMES.includes(runtime) && !HANDOFF_SKILL_RUNTIMES.includes(runtime)) {
304
302
  return fail("invalid_runtime", "--runtime must be local or cloud.");
305
303
  }
@@ -442,7 +440,8 @@ function parseSetup(values, flags, json, fail) {
442
440
  parseTimeout(rawTimeout, LOGIN_MIN_TIMEOUT_MS, LOGIN_MAX_TIMEOUT_MS);
443
441
  if (timeoutMs === null) return fail("invalid_timeout", `--timeout-ms must be a whole number from ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}.`);
444
442
  return { ok: true, command: "setup", runtime, origin, originExplicit: values["--url"] !== undefined,
445
- deployment, confirmPrerequisites: flags.has("--confirm-prerequisites"), agentTokenFile,
443
+ deployment, deploymentExplicit: values["--deployment"] !== undefined,
444
+ confirmPrerequisites: flags.has("--confirm-prerequisites"), agentTokenFile,
446
445
  workspace: rawWorkspace === undefined ? null : rawWorkspace.toLowerCase(),
447
446
  project: paths.project, configDir: paths.configDir, envFile, client, skipSkill: flags.has("--skip-skill"),
448
447
  timeoutMs, noBrowser: flags.has("--no-browser"), repair: flags.has("--repair"),
package/src/auth-login.js CHANGED
@@ -39,6 +39,11 @@ import { deadline, trapSignals } from "./transport.js";
39
39
  // server-verified workspace UUID. Token values, the authorization code, the
40
40
  // PKCE verifier, absolute paths and server text never leave this module.
41
41
  // progress(text) prints human progress lines; it is a no-op with --json.
42
+ // options.announce(data), given only with --json --no-browser, receives the
43
+ // login data with an open_url next action as soon as the authorization URL
44
+ // exists, while the listener keeps waiting. The caller prints it as the one
45
+ // JSON line, so an agent can hand the URL to a person; approval-handoff.js
46
+ // lets the agent's command exit then while this process keeps waiting.
42
47
 
43
48
  export async function runLogin(options, progress) {
44
49
  const ctx = {
@@ -58,21 +63,12 @@ export async function runLogin(options, progress) {
58
63
  if (options.runtime !== "local") return end("unsupported", "runtime_not_supported", GUIDE);
59
64
  const remote = detectRemoteSession();
60
65
  if (remote !== null) return end("unsupported", remote, GUIDE);
61
- // --json cannot show a person the --no-browser URL. A rerun with a saved,
62
- // working grant needs no URL, so only refuse once approval is required.
63
- const noBrowserHandoff = () => end("unsupported", "no_browser_requires_terminal", {
64
- kind: "run_in_terminal",
65
- message: "Run the same login command in a terminal without --json to see the sign in URL.",
66
- });
67
- const urlUnshowable = options.json && options.noBrowser;
68
-
69
66
  const trap = trapSignals();
70
67
  try {
71
68
  const root = resolveProject(options.project);
72
69
  const configDir = resolveConfigDir(options.configDir);
73
70
  const existing = readBinding(root);
74
71
  const bound = existing?.binding ?? null;
75
- if (urlUnshowable && (bound === null || options.reconnect)) return noBrowserHandoff();
76
72
  // Setup pins a deployment model before sign in. A stale binding for a
77
73
  // different profile must never be silently reused or reconnected.
78
74
  if (options.expectedProfile && bound !== null && !options.reconnect &&
@@ -102,9 +98,11 @@ export async function runLogin(options, progress) {
102
98
  }
103
99
  if (session.outcome !== "login_required") return end(session.outcome, session.reason);
104
100
  }
105
- if (urlUnshowable) return noBrowserHandoff();
106
101
 
107
- const flow = await authorize(options, ctx, trap.signal, progress);
102
+ const announce = options.announce
103
+ ? (next) => options.announce(loginData({ ...ctx, status: "approval_pending" }, next))
104
+ : null;
105
+ const flow = await authorize(options, ctx, trap.signal, progress, announce);
108
106
  if (!flow.ok) return end(flow.outcome, flow.reason, flow.next ?? null);
109
107
  const { endpoints, clientId, grant } = flow;
110
108
 
@@ -169,7 +167,7 @@ export async function runLogin(options, progress) {
169
167
  // written here. A validated grant that is not accepted is sent for revocation
170
168
  // before returning; that is best effort, and a token response that fails
171
169
  // validation cannot be revoked.
172
- async function authorize(options, ctx, cancel, progress) {
170
+ async function authorize(options, ctx, cancel, progress, announce) {
173
171
  const preflight = await runDoctor({ origin: ctx.origin, timeoutMs: AUTH_HTTP_TIMEOUT_MS });
174
172
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
175
173
  if (preflight.outcome !== "authentication_required") return fail(preflight.outcome, preflight.reason);
@@ -217,7 +215,9 @@ async function authorize(options, ctx, cancel, progress) {
217
215
  const seconds = Math.round(options.timeoutMs / 1000);
218
216
  // A wait armed after Ctrl+C has already settled; do not show the URL.
219
217
  if (cancel.aborted) return fail("authorization_failed", "cancelled");
220
- if (options.noBrowser) {
218
+ if (options.noBrowser && announce) {
219
+ announce(openUrl(url, seconds, "sign in with Metadata access"));
220
+ } else if (options.noBrowser) {
221
221
  progress(
222
222
  "Open this URL in a browser on this machine to sign in:\n" +
223
223
  `${url}\n` +
@@ -375,6 +375,20 @@ function fail(outcome, reason, next = null) {
375
375
  return { ok: false, outcome, reason, next };
376
376
  }
377
377
 
378
+ // The next action a --json --no-browser run prints while it waits. The URL is
379
+ // the authorization request: public client ID, loopback redirect, state and
380
+ // PKCE challenge. It carries no token, code or verifier.
381
+ export function openUrl(url, seconds, purpose) {
382
+ return {
383
+ kind: "open_url",
384
+ url,
385
+ timeout_seconds: seconds,
386
+ message: `Show this URL to the person now and ask them to open it in a browser on this machine to ` +
387
+ `${purpose}. A background process waits up to ${seconds} seconds for their approval. ` +
388
+ "After they approve, run the same command again to continue.",
389
+ };
390
+ }
391
+
378
392
  const GUIDE = { kind: "connection_guide", url: CONNECTION_GUIDE_URL };
379
393
  const RECONNECT = {
380
394
  kind: "reconnect",
package/src/cli.js CHANGED
@@ -18,7 +18,7 @@ import { runRead } from "./read.js";
18
18
  import { readMessage, readText } from "./read-output.js";
19
19
  import { listSkillPack, runSkill, runSkillPack } from "./skill.js";
20
20
  import { runSetup } from "./setup.js";
21
- import { containsKnownCredential, preflightNonHostedSetup } from "./setup-deployment.js";
21
+ import { containsKnownCredential, preflightNonHostedSetup, prerequisiteGuide, resumeNonHostedRoute } from "./setup-deployment.js";
22
22
  import { VERSION } from "./constants.js";
23
23
  import { runVerify } from "./verify.js";
24
24
  import { verifyMessage, verifyText } from "./verify-output.js";
@@ -75,8 +75,10 @@ async function run(argv, { stdout, stderr }) {
75
75
  // Human progress goes to stderr, never with --json, so stdout carries
76
76
  // only the final result.
77
77
  const progress = parsed.json ? () => {} : (text) => stderr.write(`${text}\n`);
78
+ const pending = announcer(parsed, stdout, (data) => data);
78
79
  const { outcome, reason, data } =
79
- parsed.command === "login" ? await runLogin(parsed, progress) : await runLogout(parsed);
80
+ parsed.command === "login" ? await runLogin({ ...parsed, announce: pending.announce }, progress) : await runLogout(parsed);
81
+ if (pending.result) return pending.result;
80
82
  const message = authMessage(outcome, reason);
81
83
  const result = envelope({
82
84
  command: parsed.command,
@@ -90,14 +92,18 @@ async function run(argv, { stdout, stderr }) {
90
92
  }
91
93
 
92
94
  if (parsed.command === "setup") {
93
- const routed = parsed.deployment === "managed"
95
+ const options = resumeNonHostedRoute(parsed);
96
+ const routed = options.deployment === "managed"
94
97
  ? { proceed: true, profile: "managed" }
95
- : await preflightNonHostedSetup(parsed);
98
+ : await preflightNonHostedSetup(options);
96
99
  const progress = parsed.json ? () => {} : (line) =>
97
100
  stderr.write(containsKnownCredential(line, routed) ? "Setup progress.\n" : `${line}\n`);
101
+ // The announced line is checked for known credentials like the final one.
102
+ const pending = announcer(parsed, stdout, (data) => containsKnownCredential(data, routed) ? null : data);
98
103
  const response = routed.proceed
99
- ? await runSetup({ ...parsed, expectedProfile: routed.profile ?? null }, progress)
104
+ ? await runSetup({ ...options, expectedProfile: routed.profile ?? null, announce: pending.announce }, progress)
100
105
  : routed;
106
+ if (pending.result) return pending.result;
101
107
  const { outcome, reason, data } = response;
102
108
  let result = envelope({ command: "setup", outcome, reason, data,
103
109
  message: outcome === "ok" ? null : "Setup did not complete. Review the reason and retry safely." });
@@ -107,7 +113,8 @@ async function run(argv, { stdout, stderr }) {
107
113
  const receipt = result.data?.receipt;
108
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` : "";
109
115
  const identity = repositoryLine(result.data?.repository);
110
- const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}${identity}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
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`}`;
111
118
  if (containsKnownCredential(human, routed)) {
112
119
  result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
113
120
  stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
@@ -181,6 +188,47 @@ async function run(argv, { stdout, stderr }) {
181
188
  return result;
182
189
  }
183
190
 
191
+ // With --json --no-browser, login and setup print their one JSON line as
192
+ // soon as a person must open an approval URL, then keep waiting for the
193
+ // callback; bin/metergraph.js has already moved this process to the
194
+ // background (approval-handoff.js). Whatever the run does afterwards, that
195
+ // line is its result; the agent reruns the command to continue. screen(data) returns
196
+ // the data to print, or null to print a credential_echo refusal instead.
197
+ function announcer(parsed, stdout, screen) {
198
+ const pending = { announce: null, result: null };
199
+ if (!parsed.json || !parsed.noBrowser) return pending;
200
+ pending.announce = (data) => {
201
+ if (pending.result) return;
202
+ const safe = screen(data);
203
+ pending.result = safe === null
204
+ ? envelope({ command: parsed.command, outcome: "unsupported", reason: "credential_echo" })
205
+ : envelope({ command: parsed.command, outcome: "action_required", reason: "browser_approval_required",
206
+ data: safe, message: authMessage("action_required", "browser_approval_required") });
207
+ stdout.write(toJsonLine(pending.result));
208
+ };
209
+ return pending;
210
+ }
211
+
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`;
218
+ if (data.pending_prerequisites.length > 0) {
219
+ text += "Confirm each prerequisite, then rerun with --confirm-prerequisites:\n";
220
+ for (const name of data.pending_prerequisites) {
221
+ text += ` - ${name}: ${prerequisiteGuide(name, data.deployment_profile) ?? "see the guide"}\n`;
222
+ }
223
+ }
224
+ 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;
230
+ }
231
+
184
232
  // One line on the repository identity setup found or recorded. The value is
185
233
  // owner/name only; a remote URL is never shown.
186
234
  function repositoryLine(repository) {
package/src/constants.js CHANGED
@@ -141,6 +141,7 @@ export const EXIT_CODES = Object.freeze({
141
141
  permission_denied: 15,
142
142
  rate_limited: 16,
143
143
  cancelled: 17,
144
+ action_required: 18,
144
145
  });
145
146
 
146
147
  export const EXIT_CODE_MEANINGS = Object.freeze({
@@ -177,4 +178,8 @@ export const EXIT_CODE_MEANINGS = Object.freeze({
177
178
  rate_limited: "The service asked the CLI to slow down. Nothing was retried. Try again later.",
178
179
  cancelled:
179
180
  "A read command was interrupted before it finished. Read commands never change workspace configuration or telemetry.",
181
+ action_required:
182
+ "A person must act before this command can finish; data.next_action says what to do. " +
183
+ "With --json --no-browser the command exits as soon as the approval URL exists, and a background " +
184
+ "process waits for approval until --timeout-ms. After approval, run the same command again to continue.",
180
185
  });
@@ -117,7 +117,7 @@ const DEPLOYMENT_PATH = "/v1/deployment";
117
117
  const MIN_TIMEOUT_MS = 100;
118
118
  const MAX_TIMEOUT_MS = 60000;
119
119
 
120
- function action(kind, prerequisite = null) {
120
+ export function action(kind, prerequisite = null) {
121
121
  // Kinds are constants from this module, so this is a programming error.
122
122
  if (!ACTION_KINDS.includes(kind)) throw new Error("unknown next action kind");
123
123
  return { kind, prerequisite, url: CONNECTION_GUIDE_URL };
package/src/output.js CHANGED
@@ -73,20 +73,20 @@ const SKILL_OPTIONS = [
73
73
  {
74
74
  name: "--runtime",
75
75
  value: "RUNTIME",
76
- summary: "Required. local or cloud: where the client runs. Recorded, not detected.",
76
+ summary: "local (default) or cloud: where the client runs. Recorded, not detected.",
77
77
  },
78
78
  { name: "--project", value: "DIR", summary: "Existing project directory. Default: the current directory." },
79
79
  { name: "--json", value: null, summary: "Print one JSON line on stdout." },
80
80
  ];
81
81
 
82
82
  const SKILL_USAGE = [
83
- "metergraph skill install --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
84
- "metergraph skill update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
83
+ "metergraph skill install --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
84
+ "metergraph skill update --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
85
85
  ];
86
86
 
87
87
  const SKILLS_USAGE = [
88
- "metergraph skills install --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
89
- "metergraph skills update --client CLIENT --runtime RUNTIME [--project DIR] [--json]",
88
+ "metergraph skills install --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
89
+ "metergraph skills update --client CLIENT [--runtime RUNTIME] [--project DIR] [--json]",
90
90
  "metergraph skills list [--project DIR] [--json]",
91
91
  ];
92
92
 
@@ -106,6 +106,15 @@ const SKILLS_STATE_LABELS = {
106
106
  unsafe_path: "unsafe path",
107
107
  };
108
108
 
109
+ // How a coding agent gets an approval URL from login or setup. Many agent
110
+ // shells show output only after the command exits, which is too late.
111
+ const AGENT_APPROVAL_HELP = [
112
+ "From a coding agent: with --json --no-browser, a step that needs approval exits at once",
113
+ "with exit 18 action_required and the URL in data.next_action.url, while a background",
114
+ "process keeps waiting for the approval until --timeout-ms. Show the URL to the person,",
115
+ "and after they approve, run the same command again to continue.",
116
+ ];
117
+
109
118
  const CONFIG_DIR_OPTION = {
110
119
  name: "--config-dir",
111
120
  value: "DIR",
@@ -134,7 +143,7 @@ const LOGIN_OPTIONS = [
134
143
  summary: `Time to wait for the browser, ${LOGIN_MIN_TIMEOUT_MS} to ${LOGIN_MAX_TIMEOUT_MS}. Default ${LOGIN_DEFAULT_TIMEOUT_MS}.`,
135
144
  },
136
145
  { name: "--signup", value: null, summary: "Start at the hosted sign up page. Managed service only." },
137
- { name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser. With --json, only a rerun that needs no sign in succeeds." },
146
+ { name: "--no-browser", value: null, summary: "Print the sign in URL on stderr instead of opening a browser. With --json, exit 18 with it in data.next_action (open_url)." },
138
147
  { name: "--reconnect", value: null, summary: "Allow switching a bound project to another origin or workspace." },
139
148
  { name: "--json", value: null, summary: "Print one JSON line on stdout." },
140
149
  ];
@@ -353,7 +362,7 @@ export function helpData(topic) {
353
362
  { name: "--timeout-ms", value: "N", summary: "Time to wait for browser approval." },
354
363
  { name: "--signup", value: null, summary: "Start at hosted sign up when the project needs login." },
355
364
  { name: "--reconnect", value: null, summary: "Permit switching an existing project binding." },
356
- { name: "--no-browser", value: null, summary: "Print approval URL on stderr. With --json, only a rerun that needs no approval succeeds." },
365
+ { name: "--no-browser", value: null, summary: "Print approval URL on stderr. With --json, exit 18 with it in data.next_action (open_url); rerun after approval." },
357
366
  { name: "--repair", value: null, summary: "Explicitly approve replacement of an acknowledged key that no longer verifies." },
358
367
  { name: "--deployment", value: "MODEL", summary: "managed, customer-local, byoc or oss. Default managed." },
359
368
  { name: "--confirm-prerequisites", value: null, summary: "Attest deployment prerequisites are met; it does not verify bundle publication." },
@@ -417,6 +426,7 @@ export function helpText(topic) {
417
426
  const flag = option.value ? `${option.name} ${option.value}` : option.name;
418
427
  lines.push(` ${flag.padEnd(25)}${option.summary}`);
419
428
  }
429
+ lines.push("", ...AGENT_APPROVAL_HELP);
420
430
  } else if (topic === "login" || topic === "logout") {
421
431
  const login = topic === "login";
422
432
  lines.push(`Usage: ${login ? LOGIN_USAGE : LOGOUT_USAGE}`, "");
@@ -449,6 +459,7 @@ export function helpText(topic) {
449
459
  const flag = option.value ? `${option.name} ${option.value}` : option.name;
450
460
  lines.push(` ${flag.padEnd(18)}${option.summary}`);
451
461
  }
462
+ if (login) lines.push("", ...AGENT_APPROVAL_HELP);
452
463
  } else if (topic === "verify") {
453
464
  lines.push(`Usage: ${VERIFY_USAGE}`, "", "Uses the saved Metadata grant to poll for one exact, processed trace.",
454
465
  "The source label is supplied by the caller. A Metadata match alone does not prove application traffic.",
@@ -719,9 +730,9 @@ const AUTH_MESSAGES = {
719
730
  "so sign in cannot finish here. Follow the connection guide instead. Nothing was written.",
720
731
  ci_environment:
721
732
  "This is a CI environment, where no person can approve sign in in a browser. Nothing was written.",
722
- no_browser_requires_terminal:
723
- "Approval is needed, and --no-browser prints its URL for a person to open, which --json cannot do. " +
724
- "Run the same command without --json in a terminal. Nothing was done.",
733
+ browser_approval_required:
734
+ "A person must approve in a browser. Open data.next_action.url on this machine; a background process " +
735
+ "waits for the approval until the timeout. After approval, run the same command again to continue.",
725
736
  bound_to_other_origin:
726
737
  "This project is bound to a different origin. Nothing was changed. Use --reconnect to switch it.",
727
738
  bound_to_other_workspace:
@@ -737,7 +748,7 @@ const AUTH_MESSAGES = {
737
748
  timeout: "The operation did not finish within the time limit. Nothing was saved.",
738
749
  cancelled: "Sign in was cancelled. Nothing was saved.",
739
750
  browser_unavailable:
740
- "The browser could not be opened. Run login again with --no-browser, without --json, and open the URL it prints.",
751
+ "The browser could not be opened. Run login again with --no-browser and open the URL it prints.",
741
752
  oauth_metadata_missing: "This service does not support CLI sign in yet. Nothing was written.",
742
753
  metadata_scope_unsupported:
743
754
  "This service does not offer Metadata-only access for the CLI yet. The CLI does not fall back to broader access.",
@@ -1,6 +1,9 @@
1
+ import { detectRemoteSession } from "./auth-browser.js";
1
2
  import { runDoctor } from "./doctor.js";
2
- import { planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
3
+ import { action, planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
3
4
  import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
5
+ import { resolveProject } from "./auth-binding.js";
6
+ import { readSetupState } from "./setup-state.js";
4
7
 
5
8
  // Setup routing uses the same deployment model and Metadata credential checks
6
9
  // as the standalone routing contract. Operator prerequisites are assertions,
@@ -24,6 +27,55 @@ const PREREQUISITES = Object.freeze({
24
27
 
25
28
  const EXPECTED_PROFILE = Object.freeze({ "customer-local": "local", byoc: "byoc-core", oss: "oss" });
26
29
 
30
+ const DEPLOYMENT_FOR_PROFILE = Object.freeze({ local: "customer-local", "byoc-core": "byoc" });
31
+
32
+ // Fixed guidance for each prerequisite, so a person or agent can act on a
33
+ // handoff without the docs. None of it names a credential value, and none of
34
+ // it asks for a credential to be passed to this CLI.
35
+ const PREREQUISITE_GUIDE = Object.freeze({
36
+ released_signed_bundle: "Download the released customer-local bundle and its signed manifest, and verify the signature before starting it. A bundle built from source is not a release.",
37
+ registry_invitation: "Accept the workspace's registry invitation so the bundle can pull its images. The registry pull credential is only for pulling images: never give it to this CLI or put it in the project env file.",
38
+ bundle_started_verified: "Start the bundle with bin/start and wait until bin/status reports it healthy. Pass its origin as --url and the workspace ID that bin/status shows as --workspace.",
39
+ local_admin_configured: "Sign in to the installation once as its local admin, in a browser on this machine. Setup asks that account to approve sign in and an ingest-only key.",
40
+ operator_provisioning: "The operator must already have provisioned the customer-owned AWS deployment. Setup connects to it and never provisions anything.",
41
+ private_network_reachability: "Run setup from a machine that reaches the deployment's private HTTPS origin, for example over the customer VPN. Setup never opens a tunnel or a public endpoint.",
42
+ identity_membership_configured: "The operator must already have given your identity membership of the workspace through the deployment's configured identity provider.",
43
+ server_distribution_installed: "The open source server must already be installed and running under the operator's management.",
44
+ deployment_discovery_supported: "The open source server must answer GET /v1/deployment with the oss profile. If it does not, ask the operator to upgrade it.",
45
+ metadata_scope_supported: "The open source server must advertise the agent:metadata scope in its OAuth metadata. If it does not, ask the operator to upgrade it.",
46
+ ingestion_tokens_configured: "The operator configures ingestion tokens in MG_TOKENS and gives the application one as METERGRAPH_APP_TOKEN. Setup never issues or writes open source ingest tokens.",
47
+ agent_read_tokens_configured: "The operator configures agent read tokens in MG_AGENT_TOKENS, separately from MG_TOKENS. Never reuse an ingestion token for agent reads.",
48
+ });
49
+ const METADATA_CREDENTIAL_GUIDE = Object.freeze({
50
+ local: "Setup obtains its own Metadata-only sign in through the browser. --agent-token-file is optional and must hold a Metadata-only agent token.",
51
+ "byoc-core": "Setup obtains its own Metadata-only sign in through the browser. --agent-token-file is optional and must hold a Metadata-only agent token.",
52
+ oss: "Put one MG_AGENT_TOKENS value in a private file (mode 0600) and pass its absolute path as --agent-token-file.",
53
+ });
54
+ const ACTION_GUIDE = Object.freeze({
55
+ complete_prerequisite: "Complete the named prerequisite, then rerun setup with --confirm-prerequisites.",
56
+ oss_operator_handoff: "Ask the open source server operator to complete this step. This CLI does not create open source credentials or change the server.",
57
+ run_on_customer_machine: "Run setup in a terminal on the machine that reaches the deployment. Setup does not tunnel or forward credentials from a remote or cloud session.",
58
+ use_https_origin: "Pass the deployment's HTTPS origin. A customer-owned AWS deployment is never reached over plain HTTP.",
59
+ connection_guide: "Check that --url is the installation's own origin and that it reports the expected deployment profile.",
60
+ check_private_network: "This machine cannot reach the private origin. Connect to the deployment's network, for example over the customer VPN, and rerun. Do not expose the deployment publicly.",
61
+ platform_credential_handoff: "This platform cannot prove the agent token file is private. Rerun without --agent-token-file, or on macOS or Linux.",
62
+ fix_credential_file: "Make --agent-token-file an absolute path to a regular file you own, with mode 0600, holding one token.",
63
+ use_metadata_only_credential: "The agent token was refused or has more than Metadata access. Use a Metadata-only agent token for this workspace.",
64
+ retry: "Rerun the same command.",
65
+ });
66
+
67
+ export function prerequisiteGuide(name, profile) {
68
+ if (name === "metadata_agent_credential") return METADATA_CREDENTIAL_GUIDE[profile] ?? null;
69
+ return PREREQUISITE_GUIDE[name] ?? null;
70
+ }
71
+
72
+ function guided(nextAction, profile) {
73
+ if (nextAction === null) return null;
74
+ const message = nextAction.kind === "complete_prerequisite" && nextAction.prerequisite !== null
75
+ ? prerequisiteGuide(nextAction.prerequisite, profile) : ACTION_GUIDE[nextAction.kind] ?? null;
76
+ return { ...nextAction, message };
77
+ }
78
+
27
79
  const handoff = (outcome, reason, nextAction = null, details = {}) => ({
28
80
  proceed: false,
29
81
  outcome,
@@ -38,10 +90,21 @@ const handoff = (outcome, reason, nextAction = null, details = {}) => ({
38
90
  pending_prerequisites: details.pendingPrerequisites ?? [],
39
91
  metadata_access: details.metadataAccess ?? "not_checked",
40
92
  ingest_credential: "not_checked",
41
- next_action: nextAction,
93
+ next_action: guided(nextAction, details.profile ?? null),
42
94
  },
43
95
  });
44
96
 
97
+ // The next action when the service did not answer as a healthy deployment.
98
+ // An unreachable customer-local origin usually means the bundle is not
99
+ // running; an unreachable BYOC origin is private networking. Neither is
100
+ // tunnelled or relaxed.
101
+ function unreachedAction(deployment, outcome) {
102
+ if (deployment === "oss") return action("oss_operator_handoff");
103
+ if (outcome !== "connection_failed") return action("connection_guide");
104
+ if (deployment === "byoc") return action("check_private_network");
105
+ return action("complete_prerequisite", "bundle_started_verified");
106
+ }
107
+
45
108
  // The static agent token can be any printable string of at least 16 bytes.
46
109
  // Even a fixed status such as "operator_handoff" might equal that token. Keep
47
110
  // the verifier's non-enumerable knownCredentials on every later result and
@@ -94,13 +157,21 @@ export function planNonHostedSetup(options) {
94
157
  return { proceed: true, plan: planned.plan, prerequisites };
95
158
  }
96
159
 
97
- export async function preflightNonHostedSetup(options) {
160
+ export async function preflightNonHostedSetup(options, { env = process.env } = {}) {
98
161
  const planned = planNonHostedSetup(options);
99
162
  if (!planned.proceed) return planned;
100
163
  const { plan, prerequisites } = planned;
164
+ // Setup itself refuses a remote session, but only after this preflight.
165
+ // Refuse here first, so a remote shell sends no request and never reads the
166
+ // separate agent token file.
167
+ if (detectRemoteSession(env) !== null) {
168
+ return handoff("unsupported", "run_on_local_machine", action("run_on_customer_machine"), {
169
+ profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
170
+ });
171
+ }
101
172
  const timeoutMs = Math.min(options.timeoutMs, AUTH_HTTP_TIMEOUT_MS);
102
173
  if (options.deployment === "oss" && !options.agentTokenFile) {
103
- return handoff("unsupported", "oss_agent_token_file_required", "oss_operator_handoff", {
174
+ return handoff("unsupported", "oss_agent_token_file_required", action("oss_operator_handoff"), {
104
175
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
105
176
  prerequisites: "operator_confirmed",
106
177
  });
@@ -123,7 +194,7 @@ export async function preflightNonHostedSetup(options) {
123
194
  // The OSS operator provisions MG_TOKENS and MG_AGENT_TOKENS separately.
124
195
  // No released OSS server implements the purpose-bound ingest bootstrap,
125
196
  // and a Metadata agent token cannot issue a key. Keep this a handoff.
126
- return guardKnownCredentials(handoff("unsupported", "oss_ingest_operator_handoff", "oss_operator_handoff", {
197
+ return guardKnownCredentials(handoff("unsupported", "oss_ingest_operator_handoff", action("oss_operator_handoff"), {
127
198
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
128
199
  prerequisites: "operator_confirmed", metadataAccess: "verified",
129
200
  }), verified);
@@ -134,13 +205,13 @@ export async function preflightNonHostedSetup(options) {
134
205
  // and unsafe network responses and reports only a fixed profile token.
135
206
  const doctor = await runDoctor({ origin: plan.origin, timeoutMs });
136
207
  if (doctor.report.deployment_profile !== null && doctor.report.deployment_profile !== plan.deployment_profile) {
137
- return handoff("unsupported", "deployment_profile_mismatch", "connection_guide", {
208
+ return handoff("unsupported", "deployment_profile_mismatch", action("connection_guide"), {
138
209
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
139
210
  prerequisites: "operator_confirmed",
140
211
  });
141
212
  }
142
213
  if (doctor.outcome !== "authentication_required") {
143
- return handoff(doctor.outcome, doctor.reason, options.deployment === "oss" ? "oss_operator_handoff" : "connection_guide", {
214
+ return handoff(doctor.outcome, doctor.reason, unreachedAction(options.deployment, doctor.outcome), {
144
215
  profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
145
216
  prerequisites: "operator_confirmed",
146
217
  });
@@ -176,3 +247,28 @@ export async function preflightNonHostedSetup(options) {
176
247
  metadataAccess: "pending_login",
177
248
  };
178
249
  }
250
+
251
+ // A rerun of non-hosted setup may omit the route it was first given, as a
252
+ // hosted rerun does. Only a saved setup state for a customer-local or BYOC
253
+ // route is resumed, and only to that same origin and workspace. The earlier
254
+ // run's operator confirmation stands for that route; every live check still
255
+ // runs again. Anything else is left for setup's own conflict checks.
256
+ export function resumeNonHostedRoute(options) {
257
+ let saved;
258
+ try {
259
+ saved = readSetupState(resolveProject(options.project));
260
+ } catch {
261
+ return options;
262
+ }
263
+ const deployment = DEPLOYMENT_FOR_PROFILE[saved?.value.deployment_profile];
264
+ if (deployment === undefined) return options;
265
+ const { origin, workspace_id: workspaceId } = saved.value;
266
+ const sameRoute = (!options.originExplicit || options.origin === origin) &&
267
+ (options.workspace === null || options.workspace === workspaceId);
268
+ if (!sameRoute) return options;
269
+ if (!options.deploymentExplicit) {
270
+ return { ...options, deployment, origin, originExplicit: true, workspace: workspaceId, confirmPrerequisites: true };
271
+ }
272
+ if (options.deployment === deployment && options.originExplicit) return { ...options, confirmPrerequisites: true };
273
+ return options;
274
+ }
package/src/setup.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { detectRemoteSession, openBrowser } from "./auth-browser.js";
2
2
  import { readBinding, resolveProject } from "./auth-binding.js";
3
3
  import { startCallbackListener } from "./auth-callback.js";
4
- import { runLogin } from "./auth-login.js";
4
+ import { openUrl, runLogin } from "./auth-login.js";
5
5
  import { clientFor, endpointsFor, newPkce, offeredClientId } from "./auth-oauth.js";
6
6
  import { Stop } from "./auth-store.js";
7
7
  import { verifiedSession } from "./auth-session.js";
@@ -24,26 +24,26 @@ const PURPOSE = "ingest-bootstrap-v1";
24
24
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
25
25
  const RECEIPT = /^mgbs_[A-Za-z0-9_-]{32,256}$/;
26
26
  const KEY = (value) => typeof value === "string" && UUID.test(value);
27
- // --json cannot show a person the --no-browser approval URL. The refusal names
28
- // a fixed, secret-free next step instead of only a reason token.
29
- const RUN_IN_TERMINAL = Object.freeze({
30
- kind: "run_in_terminal",
31
- message: "Run the same setup command in a terminal without --json to see the approval URL.",
32
- });
33
- const fail = (outcome, reason, status = "not_ready", receipt = null) => ({ outcome, reason,
34
- data: { status, application_traffic_verified: false, receipt,
35
- next_action: reason === "no_browser_requires_terminal" ? RUN_IN_TERMINAL : null } });
27
+ const fail = (outcome, reason, status = "not_ready", receipt = null, next = null) => ({ outcome, reason,
28
+ data: { status, application_traffic_verified: false, receipt, next_action: next } });
36
29
 
37
30
  // This command never receives an ingest key from argv, stdin, an environment
38
31
  // variable or a Metadata OAuth grant. Only the purpose-bound redemption
39
32
  // response can supply it. Every returned string is fixed and non-secret.
33
+ //
34
+ // With --json --no-browser the caller passes options.announce(data). The
35
+ // first approval this run needs (sign in, or ingest-only setup approval) is
36
+ // announced as setup data with an open_url next action while the listener
37
+ // keeps waiting; stdout has room for one line, so a second approval in the
38
+ // same run is not started. The person approves, the run saves what it
39
+ // finished, and the agent reruns setup, which resumes from the saved state.
40
40
  export async function runSetup(options, progress = () => {}) {
41
41
  if (options.runtime !== "local" || detectRemoteSession() !== null) return fail("unsupported", "run_on_local_machine");
42
42
  const trap = trapSignals();
43
43
  let receipt = null;
44
44
  let loginVerified = false;
45
45
  let credentialVerified = false;
46
- const stop = (outcome, reason, status = "not_ready") => {
46
+ const currentReceipt = () => {
47
47
  let current = receipt;
48
48
  if (current !== null && (!loginVerified || !credentialVerified)) {
49
49
  const completed = loginVerified ? ["login"] : [];
@@ -52,8 +52,14 @@ export async function runSetup(options, progress = () => {}) {
52
52
  ...(!loginVerified ? ["login"] : []), "credential", "skill", "instrument", "verify", "view",
53
53
  ] };
54
54
  }
55
- return fail(outcome, reason, status, current);
55
+ return current;
56
56
  };
57
+ const stop = (outcome, reason, status = "not_ready") => fail(outcome, reason, status, currentReceipt());
58
+ let announced = false;
59
+ const announce = options.announce ? (status, next) => {
60
+ announced = true;
61
+ options.announce(fail("action_required", "browser_approval_required", status, currentReceipt(), next).data);
62
+ } : null;
57
63
  try {
58
64
  const root = resolveProject(options.project);
59
65
  const saved = readSetupState(root);
@@ -67,6 +73,13 @@ export async function runSetup(options, progress = () => {}) {
67
73
  (bound !== null && (bound.origin !== saved.value.origin || bound.workspace_id !== saved.value.workspace_id)))) {
68
74
  return stop("conflict", "setup_binding_changed");
69
75
  }
76
+ // An env key this setup does not own stops before any sign in, so the
77
+ // refusal costs no approval and leaves the binding untouched. The check
78
+ // after sign in stays, against a fresh read.
79
+ if (!saved) {
80
+ const early = currentEnvValues(preflightEnv({ project: root, envFile: options.envFile, signal: trap.signal }));
81
+ if (early.token !== null) return stop("conflict", "existing_ingest_key_unowned");
82
+ }
70
83
  const bindingDiffers = bound !== null && (bound.origin !== intendedOrigin ||
71
84
  (intendedWorkspace !== null && bound.workspace_id !== intendedWorkspace));
72
85
  if (bindingDiffers && !options.reconnect) return stop("conflict", "bound_to_other_workspace");
@@ -74,7 +87,8 @@ export async function runSetup(options, progress = () => {}) {
74
87
  await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
75
88
  if (!signedIn.ok && signedIn.outcome === "login_required") {
76
89
  const login = await runLogin({ ...options, origin: intendedOrigin, workspace: intendedWorkspace,
77
- project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null }, progress);
90
+ project: root, reconnect: options.reconnect, expectedProfile: options.expectedProfile ?? null,
91
+ announce: announce && ((data) => announce("login_pending", data.next_action)) }, progress);
78
92
  if (login.outcome !== "ok") return stop(login.outcome, login.reason, "login_pending");
79
93
  signedIn = await verifiedSession({ project: root, configDir: options.configDir, cancel: trap.signal });
80
94
  }
@@ -146,8 +160,9 @@ export async function runSetup(options, progress = () => {}) {
146
160
  }
147
161
 
148
162
  // A verified rerun returned above without approval. From here a person
149
- // must open an approval URL, which --json cannot show with --no-browser.
150
- if (options.json && options.noBrowser) return stop("unsupported", "no_browser_requires_terminal");
163
+ // must open an approval URL. A run that already announced its sign in URL
164
+ // has used its one JSON line, so it stops here and the rerun continues.
165
+ if (announced) return stop("action_required", "browser_approval_required", "approval_pending");
151
166
  const setup = await discoverSetup(origin, profile, trap.signal);
152
167
  if (!setup.ok) return stop(setup.outcome, setup.reason);
153
168
 
@@ -183,7 +198,9 @@ export async function runSetup(options, progress = () => {}) {
183
198
  if (intent === "repair") params.expected_key_id = state.key_id;
184
199
  url.search = new URLSearchParams(params).toString();
185
200
  if (trap.signal.aborted) return stop("authorization_failed", "cancelled");
186
- if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
201
+ if (options.noBrowser && announce) {
202
+ announce("approval_pending", openUrl(url.href, Math.round(options.timeoutMs / 1000), "approve ingest-only setup"));
203
+ } else if (options.noBrowser) progress(`Open this URL on this machine to approve ingest-only setup:\n${url.href}`);
187
204
  else if (!(await openBrowser(url.href))) return stop("authorization_failed", "browser_unavailable");
188
205
  else progress("Opened your browser for ingest-only setup approval.");
189
206
  callback = await pending;