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/README.md +34 -23
- package/assets/skill/SKILL.md +10 -1
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +10 -4
- package/assets/skills/metergraph-local-bundle/SKILL.md +283 -0
- package/assets/skills/metergraph-onboarding/SKILL.md +3 -2
- package/bin/metergraph.js +4 -4
- package/package.json +2 -2
- package/src/approval-handoff.js +91 -0
- package/src/args.js +7 -8
- package/src/auth-login.js +27 -13
- package/src/cli.js +54 -6
- package/src/constants.js +5 -0
- package/src/deployment-route.js +1 -1
- package/src/output.js +22 -11
- package/src/setup-deployment.js +103 -7
- package/src/setup.js +33 -16
- package/src/skill-bundle.js +2 -2
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
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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,
|
|
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
|
|
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
|
|
95
|
+
const options = resumeNonHostedRoute(parsed);
|
|
96
|
+
const routed = options.deployment === "managed"
|
|
94
97
|
? { proceed: true, profile: "managed" }
|
|
95
|
-
: await preflightNonHostedSetup(
|
|
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({ ...
|
|
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
|
|
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
|
});
|
package/src/deployment-route.js
CHANGED
|
@@ -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: "
|
|
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,
|
|
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,
|
|
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
|
-
|
|
723
|
-
"
|
|
724
|
-
"
|
|
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
|
|
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.",
|
package/src/setup-deployment.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
150
|
-
|
|
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
|
|
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;
|