metergraph-cli 0.0.0-stage → 0.2.0-preview.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.
@@ -0,0 +1,397 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { systemRoot } from "./auth-browser.js";
8
+ import { AUTH_PATHS, METADATA_SCOPE, SUPPORTED_PROFILES } from "./constants.js";
9
+ import { parseOrigin } from "./origin.js";
10
+
11
+ // Per-user credential storage for the delegated Metadata OAuth grant. Only
12
+ // this module reads or writes token values. Layout, below the config
13
+ // directory chosen by --config-dir, METERGRAPH_CONFIG_DIR or the default:
14
+ // credentials/<slot>.json one grant, written atomically
15
+ // credentials/<slot>.lock held while a grant is refreshed or removed
16
+ // On POSIX systems the directories must be owner-only (0700) and owned by the
17
+ // current user, and files are created 0600 and checked the same way. On
18
+ // Windows, file modes do not protect anything, so the grant is encrypted with
19
+ // DPAPI for the current user before it is written. Existing paths with unsafe
20
+ // permissions are refused, never silently changed. Symbolic links are never
21
+ // followed below the config directory.
22
+
23
+ export class Stop extends Error {
24
+ constructor(outcome, reason) {
25
+ super(reason);
26
+ this.outcome = outcome;
27
+ this.reason = reason;
28
+ }
29
+ }
30
+
31
+ const IS_WINDOWS = process.platform === "win32";
32
+ const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
33
+ const MAX_FILE_BYTES = 64 * 1024;
34
+ const SLOT = /^[0-9a-f]{32}$/;
35
+ const RECORD_KEYS = [
36
+ "access_token",
37
+ "client_id",
38
+ "deployment_profile",
39
+ "expires_at",
40
+ "issuer",
41
+ "origin",
42
+ "refresh_pending",
43
+ "refresh_token",
44
+ "resource",
45
+ "schema_version",
46
+ "scope",
47
+ "workspace_id",
48
+ ].join();
49
+
50
+ export const PROTECTION = IS_WINDOWS ? "dpapi" : "owner_only_file";
51
+
52
+ // The config directory, as an absolute path. The default is derived from the
53
+ // operating system's home directory for this user. Other tools' variables
54
+ // such as CODEX_HOME are never used.
55
+ export function resolveConfigDir(flag, env = process.env) {
56
+ if (flag !== null && flag !== undefined) return path.resolve(flag);
57
+ const fromEnv = env.METERGRAPH_CONFIG_DIR;
58
+ if (typeof fromEnv === "string" && fromEnv !== "") {
59
+ if (!path.isAbsolute(fromEnv) || fromEnv.includes("\0")) throw new Stop("invalid_input", "invalid_config_dir");
60
+ return path.resolve(fromEnv);
61
+ }
62
+ const home = os.homedir();
63
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
64
+ throw new Stop("filesystem_error", "credential_store_unavailable");
65
+ }
66
+ return IS_WINDOWS ? path.join(home, "AppData", "Roaming", "Metergraph") : path.join(home, ".config", "metergraph");
67
+ }
68
+
69
+ // Returns { dir, credentials } after checking both directories, creating
70
+ // missing ones with owner-only permissions when create is true. Returns null
71
+ // when the store does not exist and create is false.
72
+ export function openStore(dir, { create }) {
73
+ try {
74
+ checkParent(dir);
75
+ let stat = lstatOrNull(dir);
76
+ if (stat === null) {
77
+ if (!create) return null;
78
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
79
+ stat = fs.lstatSync(dir);
80
+ }
81
+ checkPrivate(stat, "directory");
82
+ const credentials = path.join(dir, "credentials");
83
+ let inner = lstatOrNull(credentials);
84
+ if (inner === null) {
85
+ if (!create) return null;
86
+ fs.mkdirSync(credentials, { mode: 0o700 });
87
+ inner = fs.lstatSync(credentials);
88
+ }
89
+ checkPrivate(inner, "directory");
90
+ return { dir, credentials };
91
+ } catch (error) {
92
+ if (error instanceof Stop) throw error;
93
+ throw new Stop("filesystem_error", "credential_store_unavailable");
94
+ }
95
+ }
96
+
97
+ export function newSlot() {
98
+ return randomBytes(16).toString("hex");
99
+ }
100
+
101
+ export function isSlot(value) {
102
+ return typeof value === "string" && SLOT.test(value);
103
+ }
104
+
105
+ // Returns the stored record, or null when the slot has no file. A file that
106
+ // cannot be decoded is reported as unreadable, never guessed at.
107
+ export function readCredential(store, slot) {
108
+ const file = slotPath(store, slot, "json");
109
+ let content;
110
+ try {
111
+ content = readPrivateFile(file);
112
+ } catch (error) {
113
+ if (error instanceof Stop) throw error;
114
+ throw new Stop("filesystem_error", "credential_store_unavailable");
115
+ }
116
+ if (content === null) return null;
117
+ const record = decode(content);
118
+ if (record === null) throw new Stop("login_required", "credential_unreadable");
119
+ return record;
120
+ }
121
+
122
+ // Atomically replaces the slot file with record.
123
+ export function writeCredential(store, slot, record) {
124
+ if (!validRecord(record)) throw new Error("credential record is not valid");
125
+ try {
126
+ const content = encode(record);
127
+ writePrivateFile(store.credentials, `${slot}.json`, content);
128
+ } catch (error) {
129
+ if (error instanceof Stop) throw error;
130
+ throw new Stop("filesystem_error", "credential_write_failed");
131
+ }
132
+ }
133
+
134
+ // Removes the slot file. Returns true when a file was removed. A symbolic
135
+ // link or other non-regular entry in its place is refused, not removed.
136
+ export function deleteCredential(store, slot) {
137
+ const file = slotPath(store, slot, "json");
138
+ try {
139
+ const stat = lstatOrNull(file);
140
+ if (stat === null) return false;
141
+ if (!stat.isFile()) throw new Stop("conflict", "credential_path_unsafe");
142
+ fs.unlinkSync(file);
143
+ return true;
144
+ } catch (error) {
145
+ if (error instanceof Stop) throw error;
146
+ throw new Stop("filesystem_error", "credential_write_failed");
147
+ }
148
+ }
149
+
150
+ // Runs work while holding the slot's exclusive lock file. Waits up to waitMs
151
+ // for another process to finish, then stops with a conflict. A lock left by
152
+ // a killed process stays until it is deleted, because taking over a lock
153
+ // that may still be held could let two refreshes use one refresh token.
154
+ // An optional signal (a deadline or Ctrl+C) stops the wait, and stops before
155
+ // work starts, with authorization_failed/cancelled. A lock this call never
156
+ // took is never touched.
157
+ export async function withSlotLock(store, slot, work, { waitMs, signal = null }) {
158
+ const lock = slotPath(store, slot, "lock");
159
+ const until = Date.now() + waitMs;
160
+ const cancelled = () => new Stop("authorization_failed", "cancelled");
161
+ for (;;) {
162
+ if (signal?.aborted) throw cancelled();
163
+ try {
164
+ const fd = fs.openSync(lock, "wx", 0o600);
165
+ fs.closeSync(fd);
166
+ break;
167
+ } catch (error) {
168
+ if (error.code !== "EEXIST") throw new Stop("filesystem_error", "credential_store_unavailable");
169
+ if (Date.now() >= until) throw new Stop("conflict", "credential_locked");
170
+ await pause(50, signal);
171
+ }
172
+ }
173
+ try {
174
+ if (signal?.aborted) throw cancelled();
175
+ return await work();
176
+ } finally {
177
+ try {
178
+ fs.unlinkSync(lock);
179
+ } catch {
180
+ // Reported by the next run as a held lock.
181
+ }
182
+ }
183
+ }
184
+
185
+ // Resolves after ms, or as soon as signal aborts.
186
+ function pause(ms, signal) {
187
+ return new Promise((resolve) => {
188
+ const done = () => {
189
+ clearTimeout(timer);
190
+ signal?.removeEventListener("abort", done);
191
+ resolve();
192
+ };
193
+ const timer = setTimeout(done, ms);
194
+ signal?.addEventListener("abort", done, { once: true });
195
+ });
196
+ }
197
+
198
+ export function validRecord(record) {
199
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return false;
200
+ if (Object.keys(record).sort().join() !== RECORD_KEYS) return false;
201
+ const origin = parseOrigin(record.origin);
202
+ return (
203
+ record.schema_version === 1 &&
204
+ origin === record.origin &&
205
+ record.issuer === `${origin}${AUTH_PATHS.issuer}` &&
206
+ record.resource === `${origin}${AUTH_PATHS.resource}` &&
207
+ SUPPORTED_PROFILES.includes(record.deployment_profile) &&
208
+ typeof record.workspace_id === "string" &&
209
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(record.workspace_id) &&
210
+ record.scope === METADATA_SCOPE &&
211
+ printable(record.client_id, 256) &&
212
+ printable(record.access_token, 8192) &&
213
+ printable(record.refresh_token, 4096) &&
214
+ Number.isSafeInteger(record.expires_at) &&
215
+ typeof record.refresh_pending === "boolean"
216
+ );
217
+ }
218
+
219
+ function printable(value, max) {
220
+ return typeof value === "string" && value.length > 0 && value.length <= max && /^[\x21-\x7e]+$/.test(value);
221
+ }
222
+
223
+ function slotPath(store, slot, extension) {
224
+ if (!isSlot(slot)) throw new Error("invalid credential slot");
225
+ return path.join(store.credentials, `${slot}.${extension}`);
226
+ }
227
+
228
+ // File format: { schema_version, protection, credential } where credential
229
+ // is the record itself for owner_only_file, and base64 DPAPI output for dpapi.
230
+ function encode(record) {
231
+ const plain = JSON.stringify(record);
232
+ const credential = IS_WINDOWS ? dpapi("protect", Buffer.from(plain, "utf8")).toString("base64") : record;
233
+ return Buffer.from(`${JSON.stringify({ schema_version: 1, protection: PROTECTION, credential })}\n`);
234
+ }
235
+
236
+ function decode(content) {
237
+ let wrapper;
238
+ try {
239
+ wrapper = JSON.parse(content.toString("utf8"));
240
+ } catch {
241
+ return null;
242
+ }
243
+ if (wrapper === null || typeof wrapper !== "object" || wrapper.schema_version !== 1) return null;
244
+ if (wrapper.protection !== PROTECTION) return null;
245
+ let record = wrapper.credential;
246
+ if (IS_WINDOWS) {
247
+ if (typeof record !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(record)) return null;
248
+ try {
249
+ record = JSON.parse(dpapi("unprotect", Buffer.from(record, "base64")).toString("utf8"));
250
+ } catch (error) {
251
+ if (error instanceof Stop) throw error;
252
+ return null;
253
+ }
254
+ }
255
+ return validRecord(record) ? record : null;
256
+ }
257
+
258
+ // A fixed Windows PowerShell script, passed as an encoded constant. The data
259
+ // travels on stdin and stdout pipes only, never on the command line. The
260
+ // optional entropy ties the blob to this purpose; the scope is the current
261
+ // user, so another account on the machine cannot decrypt it.
262
+ const DPAPI_SCRIPT = [
263
+ "$ErrorActionPreference = 'Stop'",
264
+ "$ProgressPreference = 'SilentlyContinue'",
265
+ "Add-Type -AssemblyName System.Security",
266
+ "$mode = [Console]::In.ReadLine()",
267
+ "$data = [Convert]::FromBase64String([Console]::In.ReadLine())",
268
+ "$entropy = [Text.Encoding]::UTF8.GetBytes('metergraph-cli-credential-v1')",
269
+ "$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser",
270
+ "if ($mode -eq 'protect') { $out = [Security.Cryptography.ProtectedData]::Protect($data, $entropy, $scope) }",
271
+ "elseif ($mode -eq 'unprotect') { $out = [Security.Cryptography.ProtectedData]::Unprotect($data, $entropy, $scope) }",
272
+ "else { exit 2 }",
273
+ "[Console]::Out.Write([Convert]::ToBase64String($out))",
274
+ ].join("\n");
275
+ const DPAPI_COMMAND = Buffer.from(DPAPI_SCRIPT, "utf16le").toString("base64");
276
+
277
+ export function dpapi(mode, data) {
278
+ const powershell = path.win32.join(systemRoot(), "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
279
+ const result = spawnSync(
280
+ powershell,
281
+ ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", DPAPI_COMMAND],
282
+ {
283
+ input: `${mode}\n${data.toString("base64")}\n`,
284
+ encoding: "utf8",
285
+ shell: false,
286
+ windowsHide: true,
287
+ timeout: 30000,
288
+ maxBuffer: 1024 * 1024,
289
+ },
290
+ );
291
+ const out = typeof result.stdout === "string" ? result.stdout.trim() : "";
292
+ if (result.error || result.status !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(out)) {
293
+ throw new Stop("filesystem_error", "credential_protection_failed");
294
+ }
295
+ return Buffer.from(out, "base64");
296
+ }
297
+
298
+ function lstatOrNull(file) {
299
+ try {
300
+ return fs.lstatSync(file);
301
+ } catch (error) {
302
+ if (error.code === "ENOENT") return null;
303
+ throw error;
304
+ }
305
+ }
306
+
307
+ // A world-writable parent without the sticky bit would let another user
308
+ // replace the config directory itself.
309
+ function checkParent(dir) {
310
+ if (IS_WINDOWS) return;
311
+ let stat;
312
+ try {
313
+ stat = fs.statSync(path.dirname(dir));
314
+ } catch {
315
+ return;
316
+ }
317
+ if ((stat.mode & 0o002) !== 0 && (stat.mode & 0o1000) === 0) {
318
+ throw new Stop("conflict", "credential_permissions_unsafe");
319
+ }
320
+ }
321
+
322
+ function checkPrivate(stat, kind) {
323
+ const right = kind === "directory" ? stat.isDirectory() : stat.isFile();
324
+ if (stat.isSymbolicLink() || !right) throw new Stop("conflict", "credential_path_unsafe");
325
+ if (IS_WINDOWS) return;
326
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
327
+ throw new Stop("conflict", "credential_path_not_owned");
328
+ }
329
+ if ((stat.mode & 0o077) !== 0) throw new Stop("conflict", "credential_permissions_unsafe");
330
+ }
331
+
332
+ function readPrivateFile(file) {
333
+ const stat = lstatOrNull(file);
334
+ if (stat === null) return null;
335
+ checkPrivate(stat, "file");
336
+ let fd;
337
+ try {
338
+ fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
339
+ } catch (error) {
340
+ if (error.code === "ELOOP") throw new Stop("conflict", "credential_path_unsafe");
341
+ if (error.code === "ENOENT") return null;
342
+ throw error;
343
+ }
344
+ try {
345
+ const opened = fs.fstatSync(fd);
346
+ checkPrivate(opened, "file");
347
+ if (opened.size > MAX_FILE_BYTES) throw new Stop("login_required", "credential_unreadable");
348
+ return fs.readFileSync(fd);
349
+ } finally {
350
+ fs.closeSync(fd);
351
+ }
352
+ }
353
+
354
+ // Writes an exclusive owner-only temporary file next to the target, flushes
355
+ // it and renames it over the target. A rename replaces a link itself rather
356
+ // than following it, but an unexpected entry at the target is refused first.
357
+ function writePrivateFile(dir, name, content) {
358
+ const dest = path.join(dir, name);
359
+ const existing = lstatOrNull(dest);
360
+ if (existing !== null && (existing.isSymbolicLink() || !existing.isFile())) {
361
+ throw new Stop("conflict", "credential_path_unsafe");
362
+ }
363
+ const temp = path.join(dir, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
364
+ const fd = fs.openSync(temp, "wx", 0o600);
365
+ try {
366
+ try {
367
+ if (!IS_WINDOWS) fs.fchmodSync(fd, 0o600);
368
+ fs.writeFileSync(fd, content);
369
+ fs.fsyncSync(fd);
370
+ } finally {
371
+ fs.closeSync(fd);
372
+ }
373
+ fs.renameSync(temp, dest);
374
+ } catch (error) {
375
+ try {
376
+ fs.unlinkSync(temp);
377
+ } catch {
378
+ // Already renamed or never created.
379
+ }
380
+ throw error;
381
+ }
382
+ syncDir(dir);
383
+ }
384
+
385
+ function syncDir(dir) {
386
+ if (IS_WINDOWS) return;
387
+ try {
388
+ const fd = fs.openSync(dir, "r");
389
+ try {
390
+ fs.fsyncSync(fd);
391
+ } finally {
392
+ fs.closeSync(fd);
393
+ }
394
+ } catch {
395
+ // Not supported everywhere. The rename itself is still atomic.
396
+ }
397
+ }
package/src/cli.js ADDED
@@ -0,0 +1,166 @@
1
+ import { READ_COMMANDS, parseArgs } from "./args.js";
2
+ import { runLogin, runLogout } from "./auth-login.js";
3
+ import { runDoctor } from "./doctor.js";
4
+ import {
5
+ authMessage,
6
+ authText,
7
+ doctorMessage,
8
+ doctorText,
9
+ envelope,
10
+ helpData,
11
+ helpText,
12
+ skillText,
13
+ toJsonLine,
14
+ versionData,
15
+ } from "./output.js";
16
+ import { runRead } from "./read.js";
17
+ import { readMessage, readText } from "./read-output.js";
18
+ import { runSkill } from "./skill.js";
19
+ import { runSetup } from "./setup.js";
20
+ import { containsKnownCredential, preflightNonHostedSetup } from "./setup-deployment.js";
21
+ import { VERSION } from "./constants.js";
22
+ import { runVerify } from "./verify.js";
23
+ import { verifyMessage, verifyText } from "./verify-output.js";
24
+
25
+ // Runs one command and resolves with the exit code. With --json, stdout gets
26
+ // exactly one JSON line and stderr stays empty. Without it, results go to
27
+ // stdout and usage or internal errors go to stderr. Never reads stdin.
28
+ export async function main(argv, { stdout, stderr }) {
29
+ const json = argv.includes("--json");
30
+ let result;
31
+ try {
32
+ result = await run(argv, { stdout, stderr });
33
+ } catch {
34
+ result = envelope({
35
+ command: null,
36
+ outcome: "internal_error",
37
+ reason: "unexpected_failure",
38
+ message: "The CLI failed unexpectedly. No details are shown to avoid exposing sensitive input.",
39
+ });
40
+ if (json) stdout.write(toJsonLine(result));
41
+ else stderr.write(`Error: ${result.error.message}\n`);
42
+ }
43
+ return result.exit_code;
44
+ }
45
+
46
+ async function run(argv, { stdout, stderr }) {
47
+ const parsed = parseArgs(argv);
48
+
49
+ if (!parsed.ok) {
50
+ const result = envelope({
51
+ command: parsed.command,
52
+ outcome: parsed.outcome,
53
+ reason: parsed.code,
54
+ message: parsed.message,
55
+ });
56
+ if (parsed.json) stdout.write(toJsonLine(result));
57
+ else stderr.write(`Error: ${parsed.message}\n`);
58
+ return result;
59
+ }
60
+
61
+ if (parsed.command === "help") {
62
+ const result = envelope({ command: "help", outcome: "ok", data: helpData(parsed.topic) });
63
+ stdout.write(parsed.json ? toJsonLine(result) : helpText(parsed.topic));
64
+ return result;
65
+ }
66
+
67
+ if (parsed.command === "version") {
68
+ const result = envelope({ command: "version", outcome: "ok", data: versionData() });
69
+ stdout.write(parsed.json ? toJsonLine(result) : `${VERSION}\n`);
70
+ return result;
71
+ }
72
+
73
+ if (parsed.command === "login" || parsed.command === "logout") {
74
+ // Human progress goes to stderr, never with --json, so stdout carries
75
+ // only the final result.
76
+ const progress = parsed.json ? () => {} : (text) => stderr.write(`${text}\n`);
77
+ const { outcome, reason, data } =
78
+ parsed.command === "login" ? await runLogin(parsed, progress) : await runLogout(parsed);
79
+ const message = authMessage(outcome, reason);
80
+ const result = envelope({
81
+ command: parsed.command,
82
+ outcome,
83
+ reason,
84
+ data,
85
+ message: outcome === "ok" ? null : message,
86
+ });
87
+ stdout.write(parsed.json ? toJsonLine(result) : authText(result, message));
88
+ return result;
89
+ }
90
+
91
+ if (parsed.command === "setup") {
92
+ const routed = parsed.deployment === "managed"
93
+ ? { proceed: true, profile: "managed" }
94
+ : await preflightNonHostedSetup(parsed);
95
+ const progress = parsed.json ? () => {} : (line) =>
96
+ stderr.write(containsKnownCredential(line, routed) ? "Setup progress.\n" : `${line}\n`);
97
+ const response = routed.proceed
98
+ ? await runSetup({ ...parsed, expectedProfile: routed.profile ?? null }, progress)
99
+ : routed;
100
+ const { outcome, reason, data } = response;
101
+ let result = envelope({ command: "setup", outcome, reason, data,
102
+ message: outcome === "ok" ? null : "Setup did not complete. Review the reason and retry safely." });
103
+ if (containsKnownCredential(result, routed)) {
104
+ result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
105
+ }
106
+ const receipt = result.data?.receipt;
107
+ 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` : "";
108
+ const human = `Metergraph setup: ${result.data?.status ?? "not_ready"}\n${summary}Application traffic verified: no\n${result.ok ? "Next: instrument your application and verify an exact trace.\n" : `Result: ${result.outcome} (${result.error.reason})\n`}`;
109
+ if (containsKnownCredential(human, routed)) {
110
+ result = envelope({ command: "setup", outcome: "unsupported", reason: "credential_echo" });
111
+ stdout.write(parsed.json ? toJsonLine(result) : "Setup stopped.\n");
112
+ } else {
113
+ stdout.write(parsed.json ? toJsonLine(result) : human);
114
+ }
115
+ return result;
116
+ }
117
+
118
+ if (READ_COMMANDS.includes(parsed.command)) {
119
+ const { outcome, reason, data } = await runRead(parsed);
120
+ const message = readMessage(outcome, reason);
121
+ const result = envelope({
122
+ command: parsed.command,
123
+ outcome,
124
+ reason,
125
+ data,
126
+ message: outcome === "ok" ? null : message,
127
+ });
128
+ stdout.write(parsed.json ? toJsonLine(result) : readText(result, message));
129
+ return result;
130
+ }
131
+
132
+ if (parsed.command === "verify") {
133
+ const { outcome, reason, data } = await runVerify(parsed);
134
+ const message = verifyMessage(outcome, reason);
135
+ const result = envelope({ command: "verify", outcome, reason, data, message: outcome === "ok" ? null : message });
136
+ stdout.write(parsed.json ? toJsonLine(result) : verifyText(result));
137
+ return result;
138
+ }
139
+
140
+ if (parsed.command === "skill") {
141
+ const { outcome, reason, message, data } = runSkill(parsed);
142
+ const result = envelope({
143
+ command: `skill ${parsed.action}`,
144
+ outcome,
145
+ reason,
146
+ data,
147
+ message: outcome === "ok" ? null : message,
148
+ });
149
+ stdout.write(parsed.json ? toJsonLine(result) : skillText(result, message));
150
+ return result;
151
+ }
152
+
153
+ const { outcome, reason, report } = await runDoctor({
154
+ origin: parsed.origin,
155
+ timeoutMs: parsed.timeoutMs,
156
+ });
157
+ const result = envelope({
158
+ command: "doctor",
159
+ outcome,
160
+ reason,
161
+ data: report,
162
+ message: doctorMessage(outcome, reason),
163
+ });
164
+ stdout.write(parsed.json ? toJsonLine(result) : doctorText(result));
165
+ return result;
166
+ }