@melra/cli 0.3.0-alpha.11 → 0.3.0-alpha.13
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 +14 -7
- package/dist/activity.d.ts +26 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +404 -0
- package/dist/activity.js.map +1 -0
- package/dist/approval-screen.d.ts +35 -0
- package/dist/approval-screen.d.ts.map +1 -0
- package/dist/approval-screen.js +130 -0
- package/dist/approval-screen.js.map +1 -0
- package/dist/client-status.d.ts +24 -0
- package/dist/client-status.d.ts.map +1 -0
- package/dist/client-status.js +273 -0
- package/dist/client-status.js.map +1 -0
- package/dist/clients.d.ts +32 -0
- package/dist/clients.d.ts.map +1 -0
- package/dist/clients.js +588 -0
- package/dist/clients.js.map +1 -0
- package/dist/conformance.d.ts +7 -0
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +134 -30
- package/dist/conformance.js.map +1 -1
- package/dist/demo-workflow.d.ts +46 -0
- package/dist/demo-workflow.d.ts.map +1 -0
- package/dist/demo-workflow.js +58 -0
- package/dist/demo-workflow.js.map +1 -0
- package/dist/environment.d.ts +15 -1
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js +30 -4
- package/dist/environment.js.map +1 -1
- package/dist/human.d.ts +67 -0
- package/dist/human.d.ts.map +1 -0
- package/dist/human.js +266 -0
- package/dist/human.js.map +1 -0
- package/dist/index.js +432 -77
- package/dist/index.js.map +1 -1
- package/dist/isolation.d.ts +35 -0
- package/dist/isolation.d.ts.map +1 -0
- package/dist/isolation.js +66 -0
- package/dist/isolation.js.map +1 -0
- package/dist/jsonc.d.ts +24 -0
- package/dist/jsonc.d.ts.map +1 -0
- package/dist/jsonc.js +238 -0
- package/dist/jsonc.js.map +1 -0
- package/dist/output.d.ts +28 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +84 -0
- package/dist/output.js.map +1 -0
- package/package.json +11 -10
package/dist/index.js
CHANGED
|
@@ -1,27 +1,73 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Copyright 2026 XAGI Labs Private Limited
|
|
3
3
|
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { access, lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { constants } from "node:fs";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
-
import { basename, join, resolve } from "node:path";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
8
|
import { createInterface } from "node:readline/promises";
|
|
9
9
|
import { DatabaseSync } from "node:sqlite";
|
|
10
|
-
import { ApprovalResponseSchema, TaskRequestSchema, WorkflowDefinitionSchema, WorkflowInputSchema, PRODUCT_VERSION, deploymentMode, principalRef, } from "@melra/protocol";
|
|
11
|
-
import { assertEnforceable, createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, } from "@melra/server";
|
|
10
|
+
import { ApprovalResponseSchema, TaskRequestSchema, TaskStatusSchema, WorkflowDefinitionSchema, WorkflowInputSchema, PRODUCT_VERSION, deploymentMode, principalRef, } from "@melra/protocol";
|
|
11
|
+
import { assertEnforceable, confinedRoot, createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, workspaceHoldsData, } from "@melra/server";
|
|
12
12
|
import { detectBrowserExecutable } from "@melra/browser-runtime";
|
|
13
13
|
import { createSystemComputerAdapter } from "@melra/computer-runtime";
|
|
14
14
|
import { capabilityUsageReader } from "@melra/runtime-core";
|
|
15
15
|
import { SqliteStore } from "@melra/storage-sqlite";
|
|
16
16
|
import { classifyOperation, createDefaultPolicy, evaluatePolicy, loadPolicy, } from "@melra/policy-core";
|
|
17
|
-
import { parseCliEnvironment, serverLaunch, } from "./environment.js";
|
|
17
|
+
import { approverLabel, parseCliEnvironment, serverLaunch, } from "./environment.js";
|
|
18
|
+
import { approvalScreen, printable } from "./approval-screen.js";
|
|
18
19
|
import { runConformance } from "./conformance.js";
|
|
20
|
+
import { statusCommand, tasksCommand, watchCommand } from "./activity.js";
|
|
21
|
+
import { clientStatuses, clientsCheck, detectClients } from "./client-status.js";
|
|
22
|
+
import { RESTART_SAFE_WORKFLOW } from "./demo-workflow.js";
|
|
23
|
+
import { detectIsolation, isolationCheck } from "./isolation.js";
|
|
24
|
+
import { renderConformance, renderDoctor, renderInit, renderSetup, setupWizard, } from "./human.js";
|
|
25
|
+
import { isHuman, paint, printJsonOrHuman } from "./output.js";
|
|
26
|
+
import { CLIENTS, clientConfig, clientConfigPath, resolveClient, writeClientConfig, } from "./clients.js";
|
|
27
|
+
/**
|
|
28
|
+
* Fold in what the policy file says, so every command describes the server it
|
|
29
|
+
* would actually start: `unhinged` if either asks for it, and the stricter
|
|
30
|
+
* `mode`, exactly as the server combines them.
|
|
31
|
+
*
|
|
32
|
+
* The workspace is MELRA_WORKSPACE when it is set, which every config `setup`
|
|
33
|
+
* writes does, and the working directory otherwise. A file the operator names
|
|
34
|
+
* in MELRA_POLICY may pin it when MELRA_WORKSPACE is not set; any policy file
|
|
35
|
+
* may narrow it to a directory inside, never move it outside. Older `setup`
|
|
36
|
+
* runs wrote their own workspace into the shared ~/.melra/policy.json, and a
|
|
37
|
+
* server for any later project was then confined to the first one instead.
|
|
38
|
+
*
|
|
39
|
+
* A file that does not parse changes nothing here; `doctor` reports it and the
|
|
40
|
+
* server refuses to start on it.
|
|
41
|
+
*/
|
|
42
|
+
async function settle(env) {
|
|
43
|
+
const path = await existingPolicyPath(env);
|
|
44
|
+
if (path === undefined)
|
|
45
|
+
return env;
|
|
46
|
+
let file;
|
|
47
|
+
try {
|
|
48
|
+
file = await loadPolicy(path, env.workspaceRoot);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return env;
|
|
52
|
+
}
|
|
53
|
+
const shared = resolve(path) === resolve(env.dataDirectory, "policy.json");
|
|
54
|
+
return {
|
|
55
|
+
...env,
|
|
56
|
+
workspaceRoot: env.workspaceSet !== true && !shared
|
|
57
|
+
? file.workspaceRoot
|
|
58
|
+
: confinedRoot(env.workspaceRoot, file.workspaceRoot),
|
|
59
|
+
unhinged: env.unhinged || file.unhinged,
|
|
60
|
+
mode: env.mode === "enforced" || file.mode === "enforced" ? "enforced" : "developer",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
19
63
|
async function existingPolicyPath(env) {
|
|
20
64
|
if (env.policyPath !== undefined)
|
|
21
65
|
return env.policyPath;
|
|
22
66
|
const candidate = join(env.dataDirectory, "policy.json");
|
|
23
67
|
try {
|
|
24
|
-
|
|
68
|
+
// Exists, not readable: an unreadable file has to fail loudly, not be
|
|
69
|
+
// skipped for the built-in defaults along with its enforced mode.
|
|
70
|
+
await access(candidate, constants.F_OK);
|
|
25
71
|
return candidate;
|
|
26
72
|
}
|
|
27
73
|
catch {
|
|
@@ -89,7 +135,10 @@ async function runtime(env) {
|
|
|
89
135
|
});
|
|
90
136
|
}
|
|
91
137
|
function output(value) {
|
|
92
|
-
|
|
138
|
+
// JSON.stringify escapes C0 controls but leaves C1, DEL and the invisible
|
|
139
|
+
// and reordering characters raw, and much of what is printed came from an
|
|
140
|
+
// agent. Spelled out, it is still valid JSON for the same values.
|
|
141
|
+
process.stdout.write(`${printable(JSON.stringify(value, null, 2), true)}\n`);
|
|
93
142
|
}
|
|
94
143
|
function argument(name, args) {
|
|
95
144
|
const index = args.indexOf(name);
|
|
@@ -111,7 +160,8 @@ function rejectUnknownFlags(args, known) {
|
|
|
111
160
|
]);
|
|
112
161
|
// A flag's own value can look like a flag (`--input node=--x`), so skip the
|
|
113
162
|
// slot after any known flag that takes one.
|
|
114
|
-
const
|
|
163
|
+
const boolean = new Set(["--http", "--json", "--yes", "--deny"]);
|
|
164
|
+
const valued = new Set(known.filter((flag) => !boolean.has(flag)));
|
|
115
165
|
for (let index = 0; index < args.length; index += 1) {
|
|
116
166
|
const argument_ = args[index];
|
|
117
167
|
if (argument_ === undefined || !argument_.startsWith("--"))
|
|
@@ -283,14 +333,13 @@ async function workflowCommand(args, env) {
|
|
|
283
333
|
}
|
|
284
334
|
}
|
|
285
335
|
async function durableCoreDemo(env) {
|
|
286
|
-
const examplePath = resolve(import.meta.dirname, "../../../examples/workflows/restart-safe.json");
|
|
287
336
|
const melra = await runtime(env);
|
|
288
337
|
try {
|
|
289
|
-
const definition = WorkflowDefinitionSchema.parse(
|
|
338
|
+
const definition = WorkflowDefinitionSchema.parse(RESTART_SAFE_WORKFLOW);
|
|
290
339
|
const planned = melra.workflows.plan(definition);
|
|
291
340
|
const advanced = await melra.workflows.advance(planned.id);
|
|
292
341
|
output({
|
|
293
|
-
|
|
342
|
+
example: definition.name,
|
|
294
343
|
workflow: advanced.run,
|
|
295
344
|
next: advanced.run.status === "verified_complete"
|
|
296
345
|
? "complete"
|
|
@@ -302,6 +351,54 @@ async function durableCoreDemo(env) {
|
|
|
302
351
|
await melra.close();
|
|
303
352
|
}
|
|
304
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Whether `MELRA_SOCKET` names somewhere `serve` can listen, and whether a
|
|
356
|
+
* socket already there is one only this user can open. The mode is the whole
|
|
357
|
+
* point of the transport, so a wider one is a `fail`, not a `warn`.
|
|
358
|
+
*/
|
|
359
|
+
async function socketCheck(path) {
|
|
360
|
+
const name = "socket";
|
|
361
|
+
if (process.platform === "win32") {
|
|
362
|
+
return {
|
|
363
|
+
name,
|
|
364
|
+
status: "fail",
|
|
365
|
+
detail: "MELRA_SOCKET is not supported on Windows; use stdio or loopback HTTP",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
if (!(await stat(dirname(path))).isDirectory())
|
|
370
|
+
throw new Error("not a directory");
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return { name, status: "fail", detail: `parent directory missing: ${dirname(path)}` };
|
|
374
|
+
}
|
|
375
|
+
let stats;
|
|
376
|
+
try {
|
|
377
|
+
stats = await lstat(path);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return {
|
|
381
|
+
name,
|
|
382
|
+
status: "pass",
|
|
383
|
+
detail: `${path}: not listening; 'melra serve' creates it mode 0600`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (!stats.isSocket()) {
|
|
387
|
+
return {
|
|
388
|
+
name,
|
|
389
|
+
status: "fail",
|
|
390
|
+
detail: `${path} exists and is not a socket; 'melra serve' will refuse it`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const mode = stats.mode & 0o777;
|
|
394
|
+
return (mode & 0o077) === 0
|
|
395
|
+
? { name, status: "pass", detail: `${path}: socket, mode 0${mode.toString(8)}` }
|
|
396
|
+
: {
|
|
397
|
+
name,
|
|
398
|
+
status: "fail",
|
|
399
|
+
detail: `${path}: socket, mode 0${mode.toString(8)}; other users can connect`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
305
402
|
async function doctor(env) {
|
|
306
403
|
const checks = [];
|
|
307
404
|
const major = Number(process.versions.node.split(".")[0]);
|
|
@@ -319,17 +416,33 @@ async function doctor(env) {
|
|
|
319
416
|
status: "warn",
|
|
320
417
|
detail: "UNSAFE-LOCAL: no policy, approval, evidence, confinement, or destination check is applied",
|
|
321
418
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
419
|
+
// Not "enforced": the next check uses that word for the deployment mode,
|
|
420
|
+
// and one JSON blob saying `guardrails: enforced` beside `mode: developer`
|
|
421
|
+
// reads as a contradiction that is not there.
|
|
422
|
+
: { name: "guardrails", status: "pass", detail: "all applied" });
|
|
423
|
+
// Neither mode is a `fail` on its own. Developer mode is a legitimate choice and the
|
|
424
|
+
// default; what is not legitimate is a machine that cannot say which one it is,
|
|
425
|
+
// or one asking for enforced mode and unsafe-local at once, which a policy file
|
|
426
|
+
// can do past the flag check and which the server then refuses to start.
|
|
427
|
+
let refusal;
|
|
428
|
+
try {
|
|
429
|
+
assertEnforceable(env.mode, env.unhinged);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
refusal = error instanceof Error ? error.message : String(error);
|
|
433
|
+
}
|
|
325
434
|
checks.push({
|
|
326
435
|
name: "mode",
|
|
327
|
-
status: "pass",
|
|
328
|
-
detail:
|
|
329
|
-
|
|
330
|
-
|
|
436
|
+
status: refusal === undefined ? "pass" : "fail",
|
|
437
|
+
detail: refusal ??
|
|
438
|
+
(env.mode === "enforced"
|
|
439
|
+
? "enforced: no unsafe-local, loopback or Unix socket only, no client self-registration"
|
|
440
|
+
: "developer: MELRA governs what it is asked for; the harness may have another path"),
|
|
331
441
|
});
|
|
332
442
|
try {
|
|
443
|
+
// A file passes access() too, and the runtime then fails to start on it.
|
|
444
|
+
if (!(await stat(env.workspaceRoot)).isDirectory())
|
|
445
|
+
throw new Error("not a directory");
|
|
333
446
|
await access(env.workspaceRoot, constants.R_OK | constants.W_OK);
|
|
334
447
|
checks.push({
|
|
335
448
|
name: "workspace",
|
|
@@ -360,6 +473,30 @@ async function doctor(env) {
|
|
|
360
473
|
detail: env.dataDirectory,
|
|
361
474
|
});
|
|
362
475
|
}
|
|
476
|
+
// Only once both exist as directories: the check would create a missing
|
|
477
|
+
// workspace. Not in unsafe-local, which reaches every file on the machine and
|
|
478
|
+
// whose guardrails warning already says so.
|
|
479
|
+
if (!env.unhinged &&
|
|
480
|
+
checks.every((check) => !["workspace", "data-directory"].includes(check.name) || check.status === "pass")) {
|
|
481
|
+
try {
|
|
482
|
+
const inside = await workspaceHoldsData(env.workspaceRoot, env.dataDirectory);
|
|
483
|
+
checks.push(inside
|
|
484
|
+
? {
|
|
485
|
+
name: "data-location",
|
|
486
|
+
// The server refuses to start this way in enforced mode.
|
|
487
|
+
status: env.mode === "enforced" ? "fail" : "warn",
|
|
488
|
+
detail: `${env.dataDirectory} is inside the workspace, where a recursive read reaches payload.key without naming it`,
|
|
489
|
+
}
|
|
490
|
+
: { name: "data-location", status: "pass", detail: "outside the workspace" });
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
checks.push({
|
|
494
|
+
name: "data-location",
|
|
495
|
+
status: "fail",
|
|
496
|
+
detail: `could not resolve the workspace: ${error instanceof Error ? error.message : String(error)}`,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
363
500
|
try {
|
|
364
501
|
const database = new DatabaseSync(":memory:");
|
|
365
502
|
database.exec("CREATE TABLE readiness(value TEXT)");
|
|
@@ -390,8 +527,9 @@ async function doctor(env) {
|
|
|
390
527
|
});
|
|
391
528
|
try {
|
|
392
529
|
const path = await existingPolicyPath(env);
|
|
530
|
+
// The server's own parser, so a bad `mode` or grant fails here too.
|
|
393
531
|
if (path !== undefined)
|
|
394
|
-
|
|
532
|
+
await loadPolicy(path, env.workspaceRoot);
|
|
395
533
|
checks.push({
|
|
396
534
|
name: "policy",
|
|
397
535
|
status: "pass",
|
|
@@ -405,6 +543,11 @@ async function doctor(env) {
|
|
|
405
543
|
detail: error instanceof Error ? error.message : String(error),
|
|
406
544
|
});
|
|
407
545
|
}
|
|
546
|
+
if (env.socketPath !== undefined)
|
|
547
|
+
checks.push(await socketCheck(env.socketPath));
|
|
548
|
+
checks.push(isolationCheck(await detectIsolation(), env.mode));
|
|
549
|
+
const clients = await clientStatuses(env.workspaceRoot);
|
|
550
|
+
checks.push(clientsCheck(clients));
|
|
408
551
|
const failed = checks.some((check) => check.status === "fail");
|
|
409
552
|
return {
|
|
410
553
|
report: {
|
|
@@ -416,46 +559,92 @@ async function doctor(env) {
|
|
|
416
559
|
unhinged: env.unhinged,
|
|
417
560
|
mode: env.mode,
|
|
418
561
|
checks,
|
|
562
|
+
// Per client, so a script can find a stale pin without parsing `detail`.
|
|
563
|
+
clients,
|
|
419
564
|
},
|
|
420
565
|
failed,
|
|
421
566
|
};
|
|
422
567
|
}
|
|
423
568
|
async function init(args, env) {
|
|
424
569
|
await mkdir(env.dataDirectory, { recursive: true });
|
|
425
|
-
|
|
570
|
+
// The operator's MELRA_POLICY when there is one: writing the default path
|
|
571
|
+
// into the client config instead quietly swapped their policy for ours.
|
|
572
|
+
const policyPath = env.policyPath ?? join(env.dataDirectory, "policy.json");
|
|
426
573
|
try {
|
|
427
574
|
await access(policyPath, constants.F_OK);
|
|
428
575
|
}
|
|
429
576
|
catch {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
577
|
+
if (env.policyPath !== undefined) {
|
|
578
|
+
throw new Error(`MELRA_POLICY names ${policyPath}, which does not exist`);
|
|
579
|
+
}
|
|
580
|
+
// No `workspaceRoot`: this file is shared by every project's server, and
|
|
581
|
+
// each config `setup` writes names its own workspace in MELRA_WORKSPACE.
|
|
582
|
+
const { workspaceRoot: _shared, ...policy } = createDefaultPolicy(env.workspaceRoot);
|
|
583
|
+
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`, { flag: "wx" });
|
|
584
|
+
}
|
|
585
|
+
const client = resolveClient(argument("--client", args) ?? "generic");
|
|
586
|
+
const label = CLIENTS[client]?.label ?? client;
|
|
587
|
+
const config = clientConfig(client, {
|
|
588
|
+
...serverLaunch(import.meta.dirname, PRODUCT_VERSION),
|
|
589
|
+
env: {
|
|
590
|
+
MELRA_WORKSPACE: env.workspaceRoot,
|
|
591
|
+
MELRA_HOME: env.dataDirectory,
|
|
592
|
+
MELRA_POLICY: policyPath,
|
|
443
593
|
},
|
|
444
|
-
};
|
|
594
|
+
});
|
|
595
|
+
const configPath = clientConfigPath(client, env.workspaceRoot);
|
|
596
|
+
if (args.includes("--write")) {
|
|
597
|
+
if (configPath === undefined) {
|
|
598
|
+
throw new Error("--write needs a named --client; see 'melra help' for the list");
|
|
599
|
+
}
|
|
600
|
+
const { backup, unchanged } = await writeClientConfig(configPath, config, client);
|
|
601
|
+
const next = CLIENTS[client]?.next ?? `Restart ${label} to load it.`;
|
|
602
|
+
return {
|
|
603
|
+
initialized: true,
|
|
604
|
+
client,
|
|
605
|
+
policyPath,
|
|
606
|
+
configPath,
|
|
607
|
+
written: true,
|
|
608
|
+
...(backup === undefined ? {} : { backup }),
|
|
609
|
+
...(unchanged === undefined ? {} : { unchanged }),
|
|
610
|
+
config,
|
|
611
|
+
note: unchanged
|
|
612
|
+
? `${configPath} already has this melra entry; nothing changed. ${next}`
|
|
613
|
+
: `Added melra to ${configPath}. ${next}`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
445
616
|
return {
|
|
446
617
|
initialized: true,
|
|
447
618
|
client,
|
|
448
619
|
policyPath,
|
|
620
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
449
621
|
config,
|
|
450
|
-
note:
|
|
622
|
+
note: configPath === undefined
|
|
623
|
+
? "Add the mcpServers.melra entry to your client's MCP configuration, " +
|
|
624
|
+
"or rerun with --client <name> for that client's own format."
|
|
625
|
+
: `Add this to ${configPath}, or rerun with --write to merge it there for you.`,
|
|
451
626
|
};
|
|
452
627
|
}
|
|
453
628
|
// One command for the whole local setup: write the policy, emit a working
|
|
454
629
|
// client config, and verify the machine can actually run it.
|
|
455
630
|
async function setup(args, env) {
|
|
456
|
-
|
|
631
|
+
let chosen = args;
|
|
632
|
+
// Only a person at a terminal on both ends is asked. Anything else — a pipe,
|
|
633
|
+
// a test, `--json` — gets today's non-interactive generic setup unchanged.
|
|
634
|
+
if (argument("--client", args) === undefined && isHuman(args) && process.stdin.isTTY) {
|
|
635
|
+
const answer = await setupWizard({ input: process.stdin, output: process.stdout }, detectClients(env.workspaceRoot));
|
|
636
|
+
if (answer === undefined) {
|
|
637
|
+
process.stdout.write("\nCancelled; nothing was written.\n");
|
|
638
|
+
return 130;
|
|
639
|
+
}
|
|
640
|
+
process.stdout.write("\n");
|
|
641
|
+
// The same arguments the person could have typed, so the wizard cannot
|
|
642
|
+
// drift from what `--client X --write` does.
|
|
643
|
+
chosen = ["--client", answer.client, ...(answer.write ? ["--write"] : [])];
|
|
644
|
+
}
|
|
645
|
+
const initialized = await init(chosen, env);
|
|
457
646
|
const { report, failed } = await doctor(env);
|
|
458
|
-
|
|
647
|
+
printJsonOrHuman({ ...initialized, ...report }, () => renderSetup(initialized, report), args);
|
|
459
648
|
return failed ? 1 : 0;
|
|
460
649
|
}
|
|
461
650
|
async function runTask(args, env) {
|
|
@@ -471,13 +660,14 @@ async function runTask(args, env) {
|
|
|
471
660
|
output({ task, next: "rerun interactively to approve this scoped action" });
|
|
472
661
|
return 3;
|
|
473
662
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
663
|
+
// Shown the effect and asked, rather than made to retype a phrase that
|
|
664
|
+
// proves only that someone typed it.
|
|
665
|
+
const approver = approverLabel(env);
|
|
666
|
+
const view = melra.controller.approvalView(task.id);
|
|
667
|
+
if (!(await confirmInTerminal(view)))
|
|
668
|
+
return 1;
|
|
669
|
+
melra.controller.decide(task.id, view.renderDigest, approver);
|
|
670
|
+
const execution = await melra.controller.execute(task.id);
|
|
481
671
|
output(execution);
|
|
482
672
|
return execution.task.status === "verified_success" ? 0 : 2;
|
|
483
673
|
}
|
|
@@ -489,6 +679,68 @@ async function runTask(args, env) {
|
|
|
489
679
|
await melra.close();
|
|
490
680
|
}
|
|
491
681
|
}
|
|
682
|
+
/** The effect as an approver needs to see it, on stderr, then y/N. */
|
|
683
|
+
async function confirmInTerminal(view) {
|
|
684
|
+
process.stderr.write(approvalScreen(view));
|
|
685
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
686
|
+
const answer = (await prompt.question("Approve this effect? [y/N] ")).trim().toLowerCase();
|
|
687
|
+
prompt.close();
|
|
688
|
+
return answer === "y" || answer === "yes";
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Records an approval for a task another process planned, without running it:
|
|
692
|
+
* the agent that planned it runs it, and the decision lets it.
|
|
693
|
+
*/
|
|
694
|
+
async function approveCommand(args, env) {
|
|
695
|
+
// `--mode` is the one flag here that takes a value.
|
|
696
|
+
const given = args.find((value, index) => !value.startsWith("--") && args[index - 1] !== "--mode");
|
|
697
|
+
if (given === undefined)
|
|
698
|
+
throw new Error("approve requires a task ID");
|
|
699
|
+
const approver = approverLabel(env);
|
|
700
|
+
const melra = await runtime(env);
|
|
701
|
+
try {
|
|
702
|
+
// `melra tasks` shows the first eight characters, so a prefix naming one
|
|
703
|
+
// waiting task is enough.
|
|
704
|
+
const waiting = melra.store
|
|
705
|
+
.listTasks(500, "awaiting_approval")
|
|
706
|
+
.filter((task) => task.id.startsWith(given));
|
|
707
|
+
if (given.length < 36 && waiting.length > 1)
|
|
708
|
+
throw new Error(`task_id_ambiguous:${given}`);
|
|
709
|
+
const taskId = given.length < 36 && waiting.length === 1 ? waiting[0].id : given;
|
|
710
|
+
const view = melra.controller.approvalView(taskId);
|
|
711
|
+
if (args.includes("--deny")) {
|
|
712
|
+
melra.controller.cancel(taskId);
|
|
713
|
+
// Read back: another process may have started it in the meantime.
|
|
714
|
+
const task = melra.controller.status(taskId);
|
|
715
|
+
output({ task });
|
|
716
|
+
return task.status === "cancelled" ? 0 : 1;
|
|
717
|
+
}
|
|
718
|
+
if (!args.includes("--yes")) {
|
|
719
|
+
if (!process.stdin.isTTY || args.includes("--json")) {
|
|
720
|
+
output({
|
|
721
|
+
taskId,
|
|
722
|
+
renderDigest: view.renderDigest,
|
|
723
|
+
contract: view.contract,
|
|
724
|
+
next: "rerun with --yes to approve, or in a terminal to be asked",
|
|
725
|
+
});
|
|
726
|
+
return 3;
|
|
727
|
+
}
|
|
728
|
+
if (!(await confirmInTerminal(view))) {
|
|
729
|
+
process.stderr.write("Not approved.\n");
|
|
730
|
+
return 1;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const decision = melra.controller.decide(taskId, view.renderDigest, approver);
|
|
734
|
+
if (decision.channel === "in_band") {
|
|
735
|
+
process.stderr.write("Recorded in band: this process is in developer mode. Set \"mode\": \"enforced\" in the policy file (or MELRA_MODE=enforced) for the server and for this command. An enforced server will not run on this decision: a workflow plans the step again once the challenge expires; a single task needs --deny and planning again.\n");
|
|
736
|
+
}
|
|
737
|
+
output({ decision });
|
|
738
|
+
return 0;
|
|
739
|
+
}
|
|
740
|
+
finally {
|
|
741
|
+
await melra.close();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
492
744
|
async function inspectTask(args, env) {
|
|
493
745
|
const taskId = args[0];
|
|
494
746
|
if (taskId === undefined)
|
|
@@ -513,6 +765,7 @@ async function policyTest(args, env) {
|
|
|
513
765
|
// answer for a policy nobody is running.
|
|
514
766
|
const policy = {
|
|
515
767
|
...(await loadPolicy(await existingPolicyPath(env), env.workspaceRoot)),
|
|
768
|
+
workspaceRoot: env.workspaceRoot,
|
|
516
769
|
unhinged: env.unhinged,
|
|
517
770
|
mode: env.mode,
|
|
518
771
|
};
|
|
@@ -556,7 +809,7 @@ function clientsCommand(args, env) {
|
|
|
556
809
|
}
|
|
557
810
|
for (const client of clients) {
|
|
558
811
|
process.stdout.write(`${client.approvedAt === undefined ? "pending " : "approved"} ` +
|
|
559
|
-
`${client.id} ${client.name}\n` +
|
|
812
|
+
`${client.id} ${printable(client.name)}\n` +
|
|
560
813
|
` ${principalRef(clientPrincipal(client))}\n`);
|
|
561
814
|
}
|
|
562
815
|
process.stdout.write(`\nRevoke one with 'melra clients --revoke <client-id>', or all of them\n` +
|
|
@@ -588,25 +841,45 @@ function help() {
|
|
|
588
841
|
process.stdout.write(`MELRA ${PRODUCT_VERSION}
|
|
589
842
|
|
|
590
843
|
Usage:
|
|
591
|
-
melra setup [--client <
|
|
592
|
-
melra doctor
|
|
844
|
+
melra setup [--client <name>] [--write] [--json]
|
|
845
|
+
melra doctor [--json]
|
|
593
846
|
melra conformance [--url <mcp-url> --token <token>] [--level <1|2|3>]
|
|
594
|
-
melra init [--client <
|
|
847
|
+
melra init [--client <name>] [--write] [--json]
|
|
595
848
|
melra serve [--http] [--port <port>] [--open]
|
|
849
|
+
melra serve --socket <path>
|
|
596
850
|
melra run --request <task.json>
|
|
851
|
+
melra approve <task-id> [--yes] [--deny] [--json]
|
|
597
852
|
melra inspect <task-id>
|
|
853
|
+
melra tasks [--limit <n>] [--status <status>] [--json]
|
|
854
|
+
melra watch [--json]
|
|
855
|
+
melra status [--json]
|
|
598
856
|
melra workflow plan --definition <workflow.json>
|
|
599
857
|
melra workflow advance <workflow-id> [--approval <id>:<exact-phrase>]
|
|
600
858
|
[--input <node-id>=<value>]
|
|
859
|
+
(--approval: developer mode; enforced:
|
|
860
|
+
melra approve <task-id>, then advance)
|
|
601
861
|
melra workflow inspect <workflow-id>
|
|
602
862
|
melra workflow cancel <workflow-id>
|
|
603
863
|
melra workflow pause|resume|suspend <workflow-id>
|
|
604
864
|
melra demo durable-core
|
|
605
865
|
melra clients [--revoke <client-id|all>]
|
|
606
866
|
melra policy test --request <task.json>
|
|
607
|
-
melra version
|
|
867
|
+
melra version [--json]
|
|
868
|
+
|
|
869
|
+
In a terminal, doctor, setup, init, and version print a readable report, and
|
|
870
|
+
'melra setup' with no --client asks which detected client to set up. Piped or
|
|
871
|
+
captured, they print JSON (version: the bare number), exactly as before.
|
|
608
872
|
|
|
609
873
|
Flags:
|
|
874
|
+
--json Print JSON even in a terminal, and never ask a question.
|
|
875
|
+
--client <name> Emit the MCP config in this client's own format and say
|
|
876
|
+
where it goes: claude-code (or claude), claude-desktop,
|
|
877
|
+
cursor, vscode, windsurf, codex, gemini, zed, cline,
|
|
878
|
+
opencode, kiro, goose, continue, amp, openclaw, hermes,
|
|
879
|
+
copilot, qwen, auggie, warp, junie, droid, kimi, kilo,
|
|
880
|
+
lmstudio, or generic (default).
|
|
881
|
+
--write With --client, merge the config into that client's file,
|
|
882
|
+
keeping everything else and saving the old file as .bak.
|
|
610
883
|
--unsafe-local Run with no policy and no guardrails. Everything the OS user
|
|
611
884
|
can do, any caller can now do. Same as MELRA_UNHINGED=1.
|
|
612
885
|
Refused outright when --mode enforced. Was --unhinged, which
|
|
@@ -619,20 +892,30 @@ Flags:
|
|
|
619
892
|
read-only REST API, a workflow event stream, and the console.
|
|
620
893
|
Prints a bearer token; every request must carry it.
|
|
621
894
|
--port <port> HTTP port (default: 7457, or MELRA_HTTP_PORT).
|
|
895
|
+
--socket <path> Serve what --http serves on a Unix socket created mode 0600,
|
|
896
|
+
so only this OS user can connect; the token is still
|
|
897
|
+
required. Not on Windows. Same as MELRA_SOCKET.
|
|
622
898
|
--open Open the console in your browser once it is listening.
|
|
623
899
|
--url <mcp-url> Endpoint for 'conformance'. Default: spawn 'melra serve'.
|
|
624
900
|
--level <1|2|3> Conformance level being claimed (default: 3). The run exits
|
|
625
901
|
non-zero if the endpoint falls short of it.
|
|
902
|
+
--json For tasks, watch, and status: JSON (NDJSON for watch) even
|
|
903
|
+
in a terminal. Output that is not a terminal is JSON anyway.
|
|
904
|
+
--limit <n> How many recent tasks 'tasks' lists (default: 20, max 500).
|
|
905
|
+
--status <s> Only tasks in this status, e.g. awaiting_approval.
|
|
626
906
|
|
|
627
907
|
Environment:
|
|
628
|
-
MELRA_WORKSPACE Workspace boundary (default:
|
|
908
|
+
MELRA_WORKSPACE Workspace boundary (default: a MELRA_POLICY file's
|
|
909
|
+
workspaceRoot, else the current directory)
|
|
629
910
|
MELRA_HOME Local database and artifact directory
|
|
630
911
|
MELRA_POLICY Optional local policy JSON
|
|
631
912
|
MELRA_UNHINGED Set to 1 to disable every guardrail (see --unsafe-local)
|
|
632
913
|
MELRA_MODE developer (default) or enforced (see --mode)
|
|
914
|
+
MELRA_APPROVER Name 'melra approve' records as the approver (default: operator)
|
|
633
915
|
MELRA_HTTP_PORT Port for 'serve --http' (default: 7457)
|
|
634
916
|
MELRA_HTTP_TOKEN Fixed bearer token for 'serve --http' (default: random)
|
|
635
917
|
MELRA_HTTP_OAUTH Set to 0 so only that token gets in; no client can register
|
|
918
|
+
MELRA_SOCKET Unix socket path for 'serve' instead of stdio (see --socket)
|
|
636
919
|
MELRA_BROWSER Optional Chrome/Chromium/Edge executable
|
|
637
920
|
MELRA_BROWSER_CDP_ENDPOINT Optional HTTP(S) CDP endpoint
|
|
638
921
|
MELRA_BROWSER_CDP_CONTEXT_INDEX External context index (-1 is last)
|
|
@@ -654,15 +937,21 @@ async function main() {
|
|
|
654
937
|
"it is an opt-out of the entire safety model.\n");
|
|
655
938
|
}
|
|
656
939
|
const modeFlag = argument("--mode", args);
|
|
657
|
-
const
|
|
940
|
+
const requested = {
|
|
658
941
|
...parsed,
|
|
659
942
|
unhinged: parsed.unhinged || deprecated || args.includes("--unsafe-local"),
|
|
660
|
-
|
|
943
|
+
// The strictest of the flag and the variable, as for the policy file below:
|
|
944
|
+
// `--mode developer` does not undo MELRA_MODE=enforced.
|
|
945
|
+
...(modeFlag === undefined || deploymentMode(modeFlag) === "developer"
|
|
946
|
+
? {}
|
|
947
|
+
: { mode: "enforced" }),
|
|
661
948
|
};
|
|
662
949
|
// Before the banner: a process that refuses to start has no guardrails to warn
|
|
663
950
|
// about, and printing the unsafe-local warning first would read as if it had
|
|
664
|
-
// taken effect.
|
|
665
|
-
|
|
951
|
+
// taken effect. On the flags and variables only: a policy file that asks for
|
|
952
|
+
// both is the server's to refuse, naming the file, and `doctor` still runs.
|
|
953
|
+
assertEnforceable(requested.mode, requested.unhinged);
|
|
954
|
+
const env = await settle(requested);
|
|
666
955
|
// Before the command runs, and for every command including `help` — the mode
|
|
667
956
|
// is a property of the process, so there is no invocation where staying quiet
|
|
668
957
|
// about it is right.
|
|
@@ -671,14 +960,14 @@ async function main() {
|
|
|
671
960
|
}
|
|
672
961
|
switch (command) {
|
|
673
962
|
case "doctor": {
|
|
674
|
-
rejectUnknownFlags(args, []);
|
|
963
|
+
rejectUnknownFlags(args, ["--json"]);
|
|
675
964
|
const { report, failed } = await doctor(env);
|
|
676
|
-
|
|
965
|
+
printJsonOrHuman(report, renderDoctor, args);
|
|
677
966
|
process.exitCode = failed ? 1 : 0;
|
|
678
967
|
return;
|
|
679
968
|
}
|
|
680
969
|
case "conformance": {
|
|
681
|
-
rejectUnknownFlags(args, ["--url", "--token", "--level"]);
|
|
970
|
+
rejectUnknownFlags(args, ["--url", "--token", "--level", "--json"]);
|
|
682
971
|
const level = argument("--level", args);
|
|
683
972
|
if (level !== undefined && !["1", "2", "3"].includes(level)) {
|
|
684
973
|
throw new Error("--level must be 1, 2, or 3");
|
|
@@ -690,33 +979,63 @@ async function main() {
|
|
|
690
979
|
...(token === undefined ? {} : { token }),
|
|
691
980
|
...(level === undefined ? {} : { claim: Number(level) }),
|
|
692
981
|
});
|
|
693
|
-
|
|
982
|
+
printJsonOrHuman(report, renderConformance, args);
|
|
694
983
|
process.exitCode = failed ? 1 : 0;
|
|
695
984
|
return;
|
|
696
985
|
}
|
|
697
986
|
case "init":
|
|
698
|
-
rejectUnknownFlags(args, ["--client"]);
|
|
699
|
-
|
|
987
|
+
rejectUnknownFlags(args, ["--client", "--write", "--json"]);
|
|
988
|
+
printJsonOrHuman(await init(args, env), renderInit, args);
|
|
700
989
|
return;
|
|
701
990
|
case "setup":
|
|
702
|
-
rejectUnknownFlags(args, ["--client"]);
|
|
991
|
+
rejectUnknownFlags(args, ["--client", "--write", "--json"]);
|
|
703
992
|
process.exitCode = await setup(args, env);
|
|
704
993
|
return;
|
|
705
994
|
case "serve": {
|
|
706
|
-
rejectUnknownFlags(args, ["--http", "--port", "--open"]);
|
|
707
|
-
|
|
995
|
+
rejectUnknownFlags(args, ["--http", "--port", "--open", "--socket"]);
|
|
996
|
+
if (args.includes("--socket") && argument("--socket", args) === undefined) {
|
|
997
|
+
throw new Error("--socket requires a path");
|
|
998
|
+
}
|
|
999
|
+
const socketPath = argument("--socket", args) ?? env.socketPath;
|
|
708
1000
|
const http = args.includes("--http");
|
|
709
1001
|
const port = argument("--port", args);
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1002
|
+
// One transport per process. Quietly preferring one would leave an
|
|
1003
|
+
// operator believing they had the other — and the difference between a
|
|
1004
|
+
// socket only they can open and a port anyone local can is the point.
|
|
1005
|
+
if (socketPath !== undefined &&
|
|
1006
|
+
(http || port !== undefined || args.includes("--open"))) {
|
|
1007
|
+
throw new Error("--socket (or MELRA_SOCKET) replaces --http, --port, and --open; pass one transport");
|
|
1008
|
+
}
|
|
1009
|
+
const melra = await runtime(env);
|
|
1010
|
+
const server = http || socketPath !== undefined ? undefined : await serveStdio(melra);
|
|
1011
|
+
const endpoint = socketPath !== undefined
|
|
1012
|
+
? await serveHttp({ runtime: melra, socketPath })
|
|
1013
|
+
: http
|
|
1014
|
+
? await serveHttp({
|
|
1015
|
+
runtime: melra,
|
|
1016
|
+
...(port === undefined ? {} : { port: Number(port) }),
|
|
1017
|
+
})
|
|
1018
|
+
: undefined;
|
|
1019
|
+
if (endpoint?.socketPath !== undefined) {
|
|
1020
|
+
// An exported MELRA_SOCKET also reaches every stdio client spawned from
|
|
1021
|
+
// that shell, which then waits forever for JSON-RPC on stdout. Name the
|
|
1022
|
+
// variable where those clients log stderr, so the hang has a cause.
|
|
1023
|
+
if (argument("--socket", args) === undefined) {
|
|
1024
|
+
process.stderr.write(`melra: MELRA_SOCKET is set, so 'serve' is on the socket below, not stdio.\n` +
|
|
1025
|
+
` An MCP client that started this process will get no reply; unset\n` +
|
|
1026
|
+
` MELRA_SOCKET for it, or pass --socket explicitly when you mean it.\n`);
|
|
1027
|
+
}
|
|
1028
|
+
process.stderr.write(`MELRA server listening on Unix socket ${endpoint.socketPath} (mode 0600)\n` +
|
|
1029
|
+
` MCP: ${endpoint.mcpUrl}, connecting to that socket\n` +
|
|
1030
|
+
` Token: ${endpoint.token}\n` +
|
|
1031
|
+
` Try: curl --unix-socket ${endpoint.socketPath} -H 'Authorization: Bearer <token>' http://localhost/api/capabilities\n` +
|
|
1032
|
+
`Only this OS user can connect; the token is still required on every\n` +
|
|
1033
|
+
`request. OAuth is off: no browser can reach a socket to approve a client.\n` +
|
|
1034
|
+
(env.mode === "enforced"
|
|
1035
|
+
? `Enforced mode: --unsafe-local is refused. Every receipt says 'enforced'.\n`
|
|
1036
|
+
: ""));
|
|
1037
|
+
}
|
|
1038
|
+
else if (endpoint !== undefined) {
|
|
720
1039
|
// stdout stays clean for the stdio transport's sake even here, so the
|
|
721
1040
|
// one command an operator has to copy goes where they can read it.
|
|
722
1041
|
process.stderr.write(`MELRA HTTP server listening on http://${endpoint.host}:${endpoint.port}\n` +
|
|
@@ -725,8 +1044,8 @@ async function main() {
|
|
|
725
1044
|
` Token: ${endpoint.token}\n` +
|
|
726
1045
|
(endpoint.oauth
|
|
727
1046
|
? `A client that cannot be given the token can register itself and ask\n` +
|
|
728
|
-
`you to approve it in a browser
|
|
729
|
-
`receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token
|
|
1047
|
+
`you to approve it in a browser, with the token above; approved clients\n` +
|
|
1048
|
+
`are named on every receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token.\n`
|
|
730
1049
|
: `OAuth is off, so the token above is the only way in.\n`) +
|
|
731
1050
|
(env.mode === "enforced"
|
|
732
1051
|
? `Enforced mode: loopback only, no client can register itself, and\n` +
|
|
@@ -749,18 +1068,50 @@ async function main() {
|
|
|
749
1068
|
rejectUnknownFlags(args, ["--request"]);
|
|
750
1069
|
process.exitCode = await runTask(args, env);
|
|
751
1070
|
return;
|
|
1071
|
+
case "approve":
|
|
1072
|
+
rejectUnknownFlags(args, ["--yes", "--deny", "--json"]);
|
|
1073
|
+
process.exitCode = await approveCommand(args, env);
|
|
1074
|
+
return;
|
|
752
1075
|
case "inspect":
|
|
753
1076
|
case "export":
|
|
754
1077
|
rejectUnknownFlags(args, []);
|
|
755
1078
|
await inspectTask(args, env);
|
|
756
1079
|
return;
|
|
1080
|
+
case "tasks": {
|
|
1081
|
+
rejectUnknownFlags(args, ["--limit", "--status", "--json"]);
|
|
1082
|
+
const limit = Number(argument("--limit", args) ?? "20");
|
|
1083
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
1084
|
+
throw new Error("--limit must be a whole number from 1 to 500");
|
|
1085
|
+
}
|
|
1086
|
+
const status = argument("--status", args);
|
|
1087
|
+
process.exitCode = tasksCommand(env, {
|
|
1088
|
+
limit,
|
|
1089
|
+
json: args.includes("--json"),
|
|
1090
|
+
...(status === undefined ? {} : { status: TaskStatusSchema.parse(status) }),
|
|
1091
|
+
});
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
case "watch":
|
|
1095
|
+
rejectUnknownFlags(args, ["--json"]);
|
|
1096
|
+
process.exitCode = await watchCommand(env, { json: args.includes("--json") });
|
|
1097
|
+
return;
|
|
1098
|
+
case "status": {
|
|
1099
|
+
rejectUnknownFlags(args, ["--json"]);
|
|
1100
|
+
const policyPath = await existingPolicyPath(env);
|
|
1101
|
+
process.exitCode = await statusCommand(env, {
|
|
1102
|
+
json: args.includes("--json"),
|
|
1103
|
+
...(policyPath === undefined ? {} : { policyPath }),
|
|
1104
|
+
});
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
757
1107
|
case "workflow":
|
|
758
1108
|
rejectUnknownFlags(args, ["--definition", "--approval", "--input"]);
|
|
759
1109
|
process.exitCode = await workflowCommand(args, env);
|
|
760
1110
|
return;
|
|
761
1111
|
case "demo":
|
|
762
1112
|
rejectUnknownFlags(args, []);
|
|
763
|
-
|
|
1113
|
+
// One demo today, so naming it is optional.
|
|
1114
|
+
if ((args[0] ?? "durable-core") !== "durable-core") {
|
|
764
1115
|
throw new Error("demo supports only 'durable-core'");
|
|
765
1116
|
}
|
|
766
1117
|
process.exitCode = await durableCoreDemo(env);
|
|
@@ -778,7 +1129,11 @@ async function main() {
|
|
|
778
1129
|
case "version":
|
|
779
1130
|
case "--version":
|
|
780
1131
|
case "-v":
|
|
781
|
-
|
|
1132
|
+
// `--json` keeps the bare version scripts already parse; a JSON object
|
|
1133
|
+
// here would break every `$(melra version)` in the wild.
|
|
1134
|
+
process.stdout.write(isHuman(args)
|
|
1135
|
+
? `MELRA ${paint.bold(PRODUCT_VERSION)} ${paint.dim(`(Node ${process.version}, ${process.platform}-${process.arch})`)}\n`
|
|
1136
|
+
: `${PRODUCT_VERSION}\n`);
|
|
782
1137
|
return;
|
|
783
1138
|
case "help":
|
|
784
1139
|
case "--help":
|