@melra/cli 0.3.0-alpha.10 → 0.3.0-alpha.12
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 +11 -6
- package/dist/activity.d.ts +26 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +401 -0
- package/dist/activity.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 +584 -0
- package/dist/clients.js.map +1 -0
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +47 -7
- 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 +5 -1
- package/dist/environment.d.ts.map +1 -1
- package/dist/environment.js +15 -1
- 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 +257 -0
- package/dist/human.js.map +1 -0
- package/dist/index.js +316 -72
- 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 +77 -0
- package/dist/output.js.map +1 -0
- package/package.json +10 -9
package/dist/index.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
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, principalRef, } from "@melra/protocol";
|
|
11
|
-
import { 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, createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, } from "@melra/server";
|
|
12
12
|
import { detectBrowserExecutable } from "@melra/browser-runtime";
|
|
13
13
|
import { createSystemComputerAdapter } from "@melra/computer-runtime";
|
|
14
|
+
import { capabilityUsageReader } from "@melra/runtime-core";
|
|
15
|
+
import { SqliteStore } from "@melra/storage-sqlite";
|
|
14
16
|
import { classifyOperation, createDefaultPolicy, evaluatePolicy, loadPolicy, } from "@melra/policy-core";
|
|
15
17
|
import { parseCliEnvironment, serverLaunch, } from "./environment.js";
|
|
16
18
|
import { runConformance } from "./conformance.js";
|
|
19
|
+
import { statusCommand, tasksCommand, watchCommand } from "./activity.js";
|
|
20
|
+
import { clientStatuses, clientsCheck, detectClients } from "./client-status.js";
|
|
21
|
+
import { RESTART_SAFE_WORKFLOW } from "./demo-workflow.js";
|
|
22
|
+
import { detectIsolation, isolationCheck } from "./isolation.js";
|
|
23
|
+
import { renderConformance, renderDoctor, renderInit, renderSetup, setupWizard, } from "./human.js";
|
|
24
|
+
import { isHuman, paint, printJsonOrHuman } from "./output.js";
|
|
25
|
+
import { CLIENTS, clientConfig, clientConfigPath, resolveClient, writeClientConfig, } from "./clients.js";
|
|
17
26
|
async function existingPolicyPath(env) {
|
|
18
27
|
if (env.policyPath !== undefined)
|
|
19
28
|
return env.policyPath;
|
|
@@ -27,7 +36,7 @@ async function existingPolicyPath(env) {
|
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
38
|
/**
|
|
30
|
-
* The banner
|
|
39
|
+
* The banner unsafe-local mode prints before it does anything.
|
|
31
40
|
*
|
|
32
41
|
* It goes to stderr, which is where it has to go: in `serve` the stdout stream
|
|
33
42
|
* is the MCP transport and any prose written there corrupts the protocol. stderr
|
|
@@ -36,11 +45,11 @@ async function existingPolicyPath(env) {
|
|
|
36
45
|
* developer who exported the variable in one shell and forgot deserves the
|
|
37
46
|
* reminder on every invocation rather than only at startup.
|
|
38
47
|
*/
|
|
39
|
-
function
|
|
48
|
+
function unsafeLocalBanner(root) {
|
|
40
49
|
return [
|
|
41
50
|
"",
|
|
42
51
|
" ############################################################",
|
|
43
|
-
" # MELRA IS RUNNING
|
|
52
|
+
" # MELRA IS RUNNING UNSAFE-LOCAL. NO GUARDRAILS APPLY. #",
|
|
44
53
|
" ############################################################",
|
|
45
54
|
"",
|
|
46
55
|
" Disabled for every task this process runs:",
|
|
@@ -54,7 +63,7 @@ function unhingedBanner(root) {
|
|
|
54
63
|
" OS user can. Receipts are still written, so you will be able to read what",
|
|
55
64
|
" happened — after it has happened.",
|
|
56
65
|
"",
|
|
57
|
-
" Turn it off by unsetting MELRA_UNHINGED and dropping --
|
|
66
|
+
" Turn it off by unsetting MELRA_UNHINGED and dropping --unsafe-local.",
|
|
58
67
|
"",
|
|
59
68
|
].join("\n");
|
|
60
69
|
}
|
|
@@ -64,6 +73,7 @@ async function runtime(env) {
|
|
|
64
73
|
workspaceRoot: env.workspaceRoot,
|
|
65
74
|
dataDirectory: env.dataDirectory,
|
|
66
75
|
unhinged: env.unhinged,
|
|
76
|
+
mode: env.mode,
|
|
67
77
|
...(policyPath === undefined ? {} : { policyPath }),
|
|
68
78
|
...(env.browserExecutablePath === undefined
|
|
69
79
|
? {}
|
|
@@ -98,10 +108,17 @@ function argument(name, args) {
|
|
|
98
108
|
* a terminal prints nothing and in a pipeline that never closes hangs forever.
|
|
99
109
|
*/
|
|
100
110
|
function rejectUnknownFlags(args, known) {
|
|
101
|
-
|
|
111
|
+
// Process-wide rather than per-command: these three describe the process, not
|
|
112
|
+
// what a command does, so every command accepts them.
|
|
113
|
+
const allowed = new Set([
|
|
114
|
+
...known,
|
|
115
|
+
"--unsafe-local",
|
|
116
|
+
"--unhinged",
|
|
117
|
+
"--mode",
|
|
118
|
+
]);
|
|
102
119
|
// A flag's own value can look like a flag (`--input node=--x`), so skip the
|
|
103
120
|
// slot after any known flag that takes one.
|
|
104
|
-
const valued = new Set(known.filter((flag) => flag !== "--http"));
|
|
121
|
+
const valued = new Set(known.filter((flag) => flag !== "--http" && flag !== "--json"));
|
|
105
122
|
for (let index = 0; index < args.length; index += 1) {
|
|
106
123
|
const argument_ = args[index];
|
|
107
124
|
if (argument_ === undefined || !argument_.startsWith("--"))
|
|
@@ -273,14 +290,13 @@ async function workflowCommand(args, env) {
|
|
|
273
290
|
}
|
|
274
291
|
}
|
|
275
292
|
async function durableCoreDemo(env) {
|
|
276
|
-
const examplePath = resolve(import.meta.dirname, "../../../examples/workflows/restart-safe.json");
|
|
277
293
|
const melra = await runtime(env);
|
|
278
294
|
try {
|
|
279
|
-
const definition = WorkflowDefinitionSchema.parse(
|
|
295
|
+
const definition = WorkflowDefinitionSchema.parse(RESTART_SAFE_WORKFLOW);
|
|
280
296
|
const planned = melra.workflows.plan(definition);
|
|
281
297
|
const advanced = await melra.workflows.advance(planned.id);
|
|
282
298
|
output({
|
|
283
|
-
|
|
299
|
+
example: definition.name,
|
|
284
300
|
workflow: advanced.run,
|
|
285
301
|
next: advanced.run.status === "verified_complete"
|
|
286
302
|
? "complete"
|
|
@@ -292,6 +308,54 @@ async function durableCoreDemo(env) {
|
|
|
292
308
|
await melra.close();
|
|
293
309
|
}
|
|
294
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Whether `MELRA_SOCKET` names somewhere `serve` can listen, and whether a
|
|
313
|
+
* socket already there is one only this user can open. The mode is the whole
|
|
314
|
+
* point of the transport, so a wider one is a `fail`, not a `warn`.
|
|
315
|
+
*/
|
|
316
|
+
async function socketCheck(path) {
|
|
317
|
+
const name = "socket";
|
|
318
|
+
if (process.platform === "win32") {
|
|
319
|
+
return {
|
|
320
|
+
name,
|
|
321
|
+
status: "fail",
|
|
322
|
+
detail: "MELRA_SOCKET is not supported on Windows; use stdio or loopback HTTP",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
if (!(await stat(dirname(path))).isDirectory())
|
|
327
|
+
throw new Error("not a directory");
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return { name, status: "fail", detail: `parent directory missing: ${dirname(path)}` };
|
|
331
|
+
}
|
|
332
|
+
let stats;
|
|
333
|
+
try {
|
|
334
|
+
stats = await lstat(path);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return {
|
|
338
|
+
name,
|
|
339
|
+
status: "pass",
|
|
340
|
+
detail: `${path}: not listening; 'melra serve' creates it mode 0600`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (!stats.isSocket()) {
|
|
344
|
+
return {
|
|
345
|
+
name,
|
|
346
|
+
status: "fail",
|
|
347
|
+
detail: `${path} exists and is not a socket; 'melra serve' will refuse it`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const mode = stats.mode & 0o777;
|
|
351
|
+
return (mode & 0o077) === 0
|
|
352
|
+
? { name, status: "pass", detail: `${path}: socket, mode 0${mode.toString(8)}` }
|
|
353
|
+
: {
|
|
354
|
+
name,
|
|
355
|
+
status: "fail",
|
|
356
|
+
detail: `${path}: socket, mode 0${mode.toString(8)}; other users can connect`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
295
359
|
async function doctor(env) {
|
|
296
360
|
const checks = [];
|
|
297
361
|
const major = Number(process.versions.node.split(".")[0]);
|
|
@@ -307,9 +371,21 @@ async function doctor(env) {
|
|
|
307
371
|
? {
|
|
308
372
|
name: "guardrails",
|
|
309
373
|
status: "warn",
|
|
310
|
-
detail: "
|
|
374
|
+
detail: "UNSAFE-LOCAL: no policy, approval, evidence, confinement, or destination check is applied",
|
|
311
375
|
}
|
|
312
|
-
|
|
376
|
+
// Not "enforced": the next check uses that word for the deployment mode,
|
|
377
|
+
// and one JSON blob saying `guardrails: enforced` beside `mode: developer`
|
|
378
|
+
// reads as a contradiction that is not there.
|
|
379
|
+
: { name: "guardrails", status: "pass", detail: "all applied" });
|
|
380
|
+
// Not a `fail` either way. Developer mode is a legitimate choice and the
|
|
381
|
+
// default; what is not legitimate is a machine that cannot say which one it is.
|
|
382
|
+
checks.push({
|
|
383
|
+
name: "mode",
|
|
384
|
+
status: "pass",
|
|
385
|
+
detail: env.mode === "enforced"
|
|
386
|
+
? "enforced: no unsafe-local, loopback or Unix socket only, no client self-registration"
|
|
387
|
+
: "developer: MELRA governs what it is asked for; the harness may have another path",
|
|
388
|
+
});
|
|
313
389
|
try {
|
|
314
390
|
await access(env.workspaceRoot, constants.R_OK | constants.W_OK);
|
|
315
391
|
checks.push({
|
|
@@ -386,6 +462,11 @@ async function doctor(env) {
|
|
|
386
462
|
detail: error instanceof Error ? error.message : String(error),
|
|
387
463
|
});
|
|
388
464
|
}
|
|
465
|
+
if (env.socketPath !== undefined)
|
|
466
|
+
checks.push(await socketCheck(env.socketPath));
|
|
467
|
+
checks.push(isolationCheck(await detectIsolation(), env.mode));
|
|
468
|
+
const clients = await clientStatuses(env.workspaceRoot);
|
|
469
|
+
checks.push(clientsCheck(clients));
|
|
389
470
|
const failed = checks.some((check) => check.status === "fail");
|
|
390
471
|
return {
|
|
391
472
|
report: {
|
|
@@ -395,7 +476,10 @@ async function doctor(env) {
|
|
|
395
476
|
// Alongside the `guardrails` check so a script does not have to read a
|
|
396
477
|
// detail string to find out whether this machine has any.
|
|
397
478
|
unhinged: env.unhinged,
|
|
479
|
+
mode: env.mode,
|
|
398
480
|
checks,
|
|
481
|
+
// Per client, so a script can find a stale pin without parsing `detail`.
|
|
482
|
+
clients,
|
|
399
483
|
},
|
|
400
484
|
failed,
|
|
401
485
|
};
|
|
@@ -409,33 +493,69 @@ async function init(args, env) {
|
|
|
409
493
|
catch {
|
|
410
494
|
await writeFile(policyPath, `${JSON.stringify(createDefaultPolicy(env.workspaceRoot), null, 2)}\n`, { flag: "wx" });
|
|
411
495
|
}
|
|
412
|
-
const client = argument("--client", args) ?? "generic";
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
MELRA_POLICY: policyPath,
|
|
421
|
-
},
|
|
422
|
-
},
|
|
496
|
+
const client = resolveClient(argument("--client", args) ?? "generic");
|
|
497
|
+
const label = CLIENTS[client]?.label ?? client;
|
|
498
|
+
const config = clientConfig(client, {
|
|
499
|
+
...serverLaunch(import.meta.dirname, PRODUCT_VERSION),
|
|
500
|
+
env: {
|
|
501
|
+
MELRA_WORKSPACE: env.workspaceRoot,
|
|
502
|
+
MELRA_HOME: env.dataDirectory,
|
|
503
|
+
MELRA_POLICY: policyPath,
|
|
423
504
|
},
|
|
424
|
-
};
|
|
505
|
+
});
|
|
506
|
+
const configPath = clientConfigPath(client, env.workspaceRoot);
|
|
507
|
+
if (args.includes("--write")) {
|
|
508
|
+
if (configPath === undefined) {
|
|
509
|
+
throw new Error("--write needs a named --client; see 'melra help' for the list");
|
|
510
|
+
}
|
|
511
|
+
const { backup, unchanged } = await writeClientConfig(configPath, config, client);
|
|
512
|
+
const next = CLIENTS[client]?.next ?? `Restart ${label} to load it.`;
|
|
513
|
+
return {
|
|
514
|
+
initialized: true,
|
|
515
|
+
client,
|
|
516
|
+
policyPath,
|
|
517
|
+
configPath,
|
|
518
|
+
written: true,
|
|
519
|
+
...(backup === undefined ? {} : { backup }),
|
|
520
|
+
...(unchanged === undefined ? {} : { unchanged }),
|
|
521
|
+
config,
|
|
522
|
+
note: unchanged
|
|
523
|
+
? `${configPath} already has this melra entry; nothing changed. ${next}`
|
|
524
|
+
: `Added melra to ${configPath}. ${next}`,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
425
527
|
return {
|
|
426
528
|
initialized: true,
|
|
427
529
|
client,
|
|
428
530
|
policyPath,
|
|
531
|
+
...(configPath === undefined ? {} : { configPath }),
|
|
429
532
|
config,
|
|
430
|
-
note:
|
|
533
|
+
note: configPath === undefined
|
|
534
|
+
? "Add the mcpServers.melra entry to your client's MCP configuration, " +
|
|
535
|
+
"or rerun with --client <name> for that client's own format."
|
|
536
|
+
: `Add this to ${configPath}, or rerun with --write to merge it there for you.`,
|
|
431
537
|
};
|
|
432
538
|
}
|
|
433
539
|
// One command for the whole local setup: write the policy, emit a working
|
|
434
540
|
// client config, and verify the machine can actually run it.
|
|
435
541
|
async function setup(args, env) {
|
|
436
|
-
|
|
542
|
+
let chosen = args;
|
|
543
|
+
// Only a person at a terminal on both ends is asked. Anything else — a pipe,
|
|
544
|
+
// a test, `--json` — gets today's non-interactive generic setup unchanged.
|
|
545
|
+
if (argument("--client", args) === undefined && isHuman(args) && process.stdin.isTTY) {
|
|
546
|
+
const answer = await setupWizard({ input: process.stdin, output: process.stdout }, detectClients(env.workspaceRoot));
|
|
547
|
+
if (answer === undefined) {
|
|
548
|
+
process.stdout.write("\nCancelled; nothing was written.\n");
|
|
549
|
+
return 130;
|
|
550
|
+
}
|
|
551
|
+
process.stdout.write("\n");
|
|
552
|
+
// The same arguments the person could have typed, so the wizard cannot
|
|
553
|
+
// drift from what `--client X --write` does.
|
|
554
|
+
chosen = ["--client", answer.client, ...(answer.write ? ["--write"] : [])];
|
|
555
|
+
}
|
|
556
|
+
const initialized = await init(chosen, env);
|
|
437
557
|
const { report, failed } = await doctor(env);
|
|
438
|
-
|
|
558
|
+
printJsonOrHuman({ ...initialized, ...report }, () => renderSetup(initialized, report), args);
|
|
439
559
|
return failed ? 1 : 0;
|
|
440
560
|
}
|
|
441
561
|
async function runTask(args, env) {
|
|
@@ -494,16 +614,26 @@ async function policyTest(args, env) {
|
|
|
494
614
|
const policy = {
|
|
495
615
|
...(await loadPolicy(await existingPolicyPath(env), env.workspaceRoot)),
|
|
496
616
|
unhinged: env.unhinged,
|
|
617
|
+
mode: env.mode,
|
|
497
618
|
};
|
|
498
619
|
const taskId = "00000000-0000-4000-8000-000000000000";
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
620
|
+
// Metered grants are counted from the same database the server draws them
|
|
621
|
+
// down in, so a preview cannot report a budget as full when it is spent — and
|
|
622
|
+
// policy refuses a metered grant outright when nothing can count it.
|
|
623
|
+
const store = new SqliteStore(join(env.dataDirectory, "melra.sqlite"));
|
|
624
|
+
try {
|
|
625
|
+
// The decision carries effect, risk, and traits; the classification adds the
|
|
626
|
+
// capability and target it was made against, so a surprising verdict can be
|
|
627
|
+
// traced to what MELRA thought the request was touching.
|
|
628
|
+
const { capability, target } = classifyOperation(request.operation);
|
|
629
|
+
output({
|
|
630
|
+
...evaluatePolicy(taskId, request, policy, capabilityUsageReader(store)),
|
|
631
|
+
classified: { capability, target },
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
store.close();
|
|
636
|
+
}
|
|
507
637
|
}
|
|
508
638
|
/**
|
|
509
639
|
* The clients an operator has approved over OAuth, and the way to take that
|
|
@@ -558,13 +688,17 @@ function help() {
|
|
|
558
688
|
process.stdout.write(`MELRA ${PRODUCT_VERSION}
|
|
559
689
|
|
|
560
690
|
Usage:
|
|
561
|
-
melra setup [--client <
|
|
562
|
-
melra doctor
|
|
691
|
+
melra setup [--client <name>] [--write] [--json]
|
|
692
|
+
melra doctor [--json]
|
|
563
693
|
melra conformance [--url <mcp-url> --token <token>] [--level <1|2|3>]
|
|
564
|
-
melra init --client <
|
|
694
|
+
melra init [--client <name>] [--write] [--json]
|
|
565
695
|
melra serve [--http] [--port <port>] [--open]
|
|
696
|
+
melra serve --socket <path>
|
|
566
697
|
melra run --request <task.json>
|
|
567
698
|
melra inspect <task-id>
|
|
699
|
+
melra tasks [--limit <n>] [--status <status>] [--json]
|
|
700
|
+
melra watch [--json]
|
|
701
|
+
melra status [--json]
|
|
568
702
|
melra workflow plan --definition <workflow.json>
|
|
569
703
|
melra workflow advance <workflow-id> [--approval <id>:<exact-phrase>]
|
|
570
704
|
[--input <node-id>=<value>]
|
|
@@ -574,28 +708,56 @@ Usage:
|
|
|
574
708
|
melra demo durable-core
|
|
575
709
|
melra clients [--revoke <client-id|all>]
|
|
576
710
|
melra policy test --request <task.json>
|
|
577
|
-
melra version
|
|
711
|
+
melra version [--json]
|
|
712
|
+
|
|
713
|
+
In a terminal, doctor, setup, init, and version print a readable report, and
|
|
714
|
+
'melra setup' with no --client asks which detected client to set up. Piped or
|
|
715
|
+
captured, they print JSON (version: the bare number), exactly as before.
|
|
578
716
|
|
|
579
717
|
Flags:
|
|
580
|
-
--
|
|
718
|
+
--json Print JSON even in a terminal, and never ask a question.
|
|
719
|
+
--client <name> Emit the MCP config in this client's own format and say
|
|
720
|
+
where it goes: claude-code (or claude), claude-desktop,
|
|
721
|
+
cursor, vscode, windsurf, codex, gemini, zed, cline,
|
|
722
|
+
opencode, kiro, goose, continue, amp, openclaw, hermes,
|
|
723
|
+
copilot, qwen, auggie, warp, junie, droid, kimi, kilo,
|
|
724
|
+
lmstudio, or generic (default).
|
|
725
|
+
--write With --client, merge the config into that client's file,
|
|
726
|
+
keeping everything else and saving the old file as .bak.
|
|
727
|
+
--unsafe-local Run with no policy and no guardrails. Everything the OS user
|
|
581
728
|
can do, any caller can now do. Same as MELRA_UNHINGED=1.
|
|
729
|
+
Refused outright when --mode enforced. Was --unhinged, which
|
|
730
|
+
still works and warns.
|
|
731
|
+
--mode <m> developer (default) or enforced. Enforced is you asserting
|
|
732
|
+
the harness has no other path to these systems: MELRA then
|
|
733
|
+
refuses --unsafe-local, refuses any bind off loopback, and
|
|
734
|
+
admits no self-registering client. Stamped on every receipt.
|
|
582
735
|
--http Serve MCP over loopback HTTP instead of stdio, alongside a
|
|
583
736
|
read-only REST API, a workflow event stream, and the console.
|
|
584
737
|
Prints a bearer token; every request must carry it.
|
|
585
738
|
--port <port> HTTP port (default: 7457, or MELRA_HTTP_PORT).
|
|
739
|
+
--socket <path> Serve what --http serves on a Unix socket created mode 0600,
|
|
740
|
+
so only this OS user can connect; the token is still
|
|
741
|
+
required. Not on Windows. Same as MELRA_SOCKET.
|
|
586
742
|
--open Open the console in your browser once it is listening.
|
|
587
743
|
--url <mcp-url> Endpoint for 'conformance'. Default: spawn 'melra serve'.
|
|
588
744
|
--level <1|2|3> Conformance level being claimed (default: 3). The run exits
|
|
589
745
|
non-zero if the endpoint falls short of it.
|
|
746
|
+
--json For tasks, watch, and status: JSON (NDJSON for watch) even
|
|
747
|
+
in a terminal. Output that is not a terminal is JSON anyway.
|
|
748
|
+
--limit <n> How many recent tasks 'tasks' lists (default: 20, max 500).
|
|
749
|
+
--status <s> Only tasks in this status, e.g. awaiting_approval.
|
|
590
750
|
|
|
591
751
|
Environment:
|
|
592
752
|
MELRA_WORKSPACE Workspace boundary (default: current directory)
|
|
593
753
|
MELRA_HOME Local database and artifact directory
|
|
594
754
|
MELRA_POLICY Optional local policy JSON
|
|
595
|
-
MELRA_UNHINGED Set to 1 to disable every guardrail (see --
|
|
755
|
+
MELRA_UNHINGED Set to 1 to disable every guardrail (see --unsafe-local)
|
|
756
|
+
MELRA_MODE developer (default) or enforced (see --mode)
|
|
596
757
|
MELRA_HTTP_PORT Port for 'serve --http' (default: 7457)
|
|
597
758
|
MELRA_HTTP_TOKEN Fixed bearer token for 'serve --http' (default: random)
|
|
598
759
|
MELRA_HTTP_OAUTH Set to 0 so only that token gets in; no client can register
|
|
760
|
+
MELRA_SOCKET Unix socket path for 'serve' instead of stdio (see --socket)
|
|
599
761
|
MELRA_BROWSER Optional Chrome/Chromium/Edge executable
|
|
600
762
|
MELRA_BROWSER_CDP_ENDPOINT Optional HTTP(S) CDP endpoint
|
|
601
763
|
MELRA_BROWSER_CDP_CONTEXT_INDEX External context index (-1 is last)
|
|
@@ -607,27 +769,41 @@ Environment:
|
|
|
607
769
|
async function main() {
|
|
608
770
|
const [command = "help", ...args] = process.argv.slice(2);
|
|
609
771
|
const parsed = parseCliEnvironment(process.env);
|
|
610
|
-
// `--
|
|
611
|
-
// carries the answer so no code path can consult the weaker of the two.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
772
|
+
// `--unsafe-local` is an alias for the variable, not a second setting: one
|
|
773
|
+
// field carries the answer so no code path can consult the weaker of the two.
|
|
774
|
+
// `--unhinged` was the original name and still works — it undersold what it
|
|
775
|
+
// does, being a mood rather than a description, so it says so and moves on.
|
|
776
|
+
const deprecated = args.includes("--unhinged");
|
|
777
|
+
if (deprecated) {
|
|
778
|
+
process.stderr.write("melra: --unhinged is deprecated; use --unsafe-local. It is not a mood, " +
|
|
779
|
+
"it is an opt-out of the entire safety model.\n");
|
|
780
|
+
}
|
|
781
|
+
const modeFlag = argument("--mode", args);
|
|
782
|
+
const env = {
|
|
783
|
+
...parsed,
|
|
784
|
+
unhinged: parsed.unhinged || deprecated || args.includes("--unsafe-local"),
|
|
785
|
+
...(modeFlag === undefined ? {} : { mode: deploymentMode(modeFlag) }),
|
|
786
|
+
};
|
|
787
|
+
// Before the banner: a process that refuses to start has no guardrails to warn
|
|
788
|
+
// about, and printing the unsafe-local warning first would read as if it had
|
|
789
|
+
// taken effect.
|
|
790
|
+
assertEnforceable(env.mode, env.unhinged);
|
|
615
791
|
// Before the command runs, and for every command including `help` — the mode
|
|
616
792
|
// is a property of the process, so there is no invocation where staying quiet
|
|
617
793
|
// about it is right.
|
|
618
794
|
if (env.unhinged) {
|
|
619
|
-
process.stderr.write(`${
|
|
795
|
+
process.stderr.write(`${unsafeLocalBanner(unconfinedRoot(env.workspaceRoot))}\n`);
|
|
620
796
|
}
|
|
621
797
|
switch (command) {
|
|
622
798
|
case "doctor": {
|
|
623
|
-
rejectUnknownFlags(args, []);
|
|
799
|
+
rejectUnknownFlags(args, ["--json"]);
|
|
624
800
|
const { report, failed } = await doctor(env);
|
|
625
|
-
|
|
801
|
+
printJsonOrHuman(report, renderDoctor, args);
|
|
626
802
|
process.exitCode = failed ? 1 : 0;
|
|
627
803
|
return;
|
|
628
804
|
}
|
|
629
805
|
case "conformance": {
|
|
630
|
-
rejectUnknownFlags(args, ["--url", "--token", "--level"]);
|
|
806
|
+
rejectUnknownFlags(args, ["--url", "--token", "--level", "--json"]);
|
|
631
807
|
const level = argument("--level", args);
|
|
632
808
|
if (level !== undefined && !["1", "2", "3"].includes(level)) {
|
|
633
809
|
throw new Error("--level must be 1, 2, or 3");
|
|
@@ -639,33 +815,63 @@ async function main() {
|
|
|
639
815
|
...(token === undefined ? {} : { token }),
|
|
640
816
|
...(level === undefined ? {} : { claim: Number(level) }),
|
|
641
817
|
});
|
|
642
|
-
|
|
818
|
+
printJsonOrHuman(report, renderConformance, args);
|
|
643
819
|
process.exitCode = failed ? 1 : 0;
|
|
644
820
|
return;
|
|
645
821
|
}
|
|
646
822
|
case "init":
|
|
647
|
-
rejectUnknownFlags(args, ["--client"]);
|
|
648
|
-
|
|
823
|
+
rejectUnknownFlags(args, ["--client", "--write", "--json"]);
|
|
824
|
+
printJsonOrHuman(await init(args, env), renderInit, args);
|
|
649
825
|
return;
|
|
650
826
|
case "setup":
|
|
651
|
-
rejectUnknownFlags(args, ["--client"]);
|
|
827
|
+
rejectUnknownFlags(args, ["--client", "--write", "--json"]);
|
|
652
828
|
process.exitCode = await setup(args, env);
|
|
653
829
|
return;
|
|
654
830
|
case "serve": {
|
|
655
|
-
rejectUnknownFlags(args, ["--http", "--port", "--open"]);
|
|
656
|
-
|
|
831
|
+
rejectUnknownFlags(args, ["--http", "--port", "--open", "--socket"]);
|
|
832
|
+
if (args.includes("--socket") && argument("--socket", args) === undefined) {
|
|
833
|
+
throw new Error("--socket requires a path");
|
|
834
|
+
}
|
|
835
|
+
const socketPath = argument("--socket", args) ?? env.socketPath;
|
|
657
836
|
const http = args.includes("--http");
|
|
658
837
|
const port = argument("--port", args);
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
838
|
+
// One transport per process. Quietly preferring one would leave an
|
|
839
|
+
// operator believing they had the other — and the difference between a
|
|
840
|
+
// socket only they can open and a port anyone local can is the point.
|
|
841
|
+
if (socketPath !== undefined &&
|
|
842
|
+
(http || port !== undefined || args.includes("--open"))) {
|
|
843
|
+
throw new Error("--socket (or MELRA_SOCKET) replaces --http, --port, and --open; pass one transport");
|
|
844
|
+
}
|
|
845
|
+
const melra = await runtime(env);
|
|
846
|
+
const server = http || socketPath !== undefined ? undefined : await serveStdio(melra);
|
|
847
|
+
const endpoint = socketPath !== undefined
|
|
848
|
+
? await serveHttp({ runtime: melra, socketPath })
|
|
849
|
+
: http
|
|
850
|
+
? await serveHttp({
|
|
851
|
+
runtime: melra,
|
|
852
|
+
...(port === undefined ? {} : { port: Number(port) }),
|
|
853
|
+
})
|
|
854
|
+
: undefined;
|
|
855
|
+
if (endpoint?.socketPath !== undefined) {
|
|
856
|
+
// An exported MELRA_SOCKET also reaches every stdio client spawned from
|
|
857
|
+
// that shell, which then waits forever for JSON-RPC on stdout. Name the
|
|
858
|
+
// variable where those clients log stderr, so the hang has a cause.
|
|
859
|
+
if (argument("--socket", args) === undefined) {
|
|
860
|
+
process.stderr.write(`melra: MELRA_SOCKET is set, so 'serve' is on the socket below, not stdio.\n` +
|
|
861
|
+
` An MCP client that started this process will get no reply; unset\n` +
|
|
862
|
+
` MELRA_SOCKET for it, or pass --socket explicitly when you mean it.\n`);
|
|
863
|
+
}
|
|
864
|
+
process.stderr.write(`MELRA server listening on Unix socket ${endpoint.socketPath} (mode 0600)\n` +
|
|
865
|
+
` MCP: ${endpoint.mcpUrl}, connecting to that socket\n` +
|
|
866
|
+
` Token: ${endpoint.token}\n` +
|
|
867
|
+
` Try: curl --unix-socket ${endpoint.socketPath} -H 'Authorization: Bearer <token>' http://localhost/api/capabilities\n` +
|
|
868
|
+
`Only this OS user can connect; the token is still required on every\n` +
|
|
869
|
+
`request. OAuth is off: no browser can reach a socket to approve a client.\n` +
|
|
870
|
+
(env.mode === "enforced"
|
|
871
|
+
? `Enforced mode: --unsafe-local is refused. Every receipt says 'enforced'.\n`
|
|
872
|
+
: ""));
|
|
873
|
+
}
|
|
874
|
+
else if (endpoint !== undefined) {
|
|
669
875
|
// stdout stays clean for the stdio transport's sake even here, so the
|
|
670
876
|
// one command an operator has to copy goes where they can read it.
|
|
671
877
|
process.stderr.write(`MELRA HTTP server listening on http://${endpoint.host}:${endpoint.port}\n` +
|
|
@@ -677,6 +883,10 @@ async function main() {
|
|
|
677
883
|
`you to approve it in a browser; approved clients are named on every\n` +
|
|
678
884
|
`receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token above.\n`
|
|
679
885
|
: `OAuth is off, so the token above is the only way in.\n`) +
|
|
886
|
+
(env.mode === "enforced"
|
|
887
|
+
? `Enforced mode: loopback only, no client can register itself, and\n` +
|
|
888
|
+
`--unsafe-local is refused. Every receipt says 'enforced'.\n`
|
|
889
|
+
: "") +
|
|
680
890
|
`Loopback only. Anyone who can read this token can drive this machine.\n`);
|
|
681
891
|
if (args.includes("--open"))
|
|
682
892
|
openInBrowser(endpoint.url);
|
|
@@ -699,13 +909,41 @@ async function main() {
|
|
|
699
909
|
rejectUnknownFlags(args, []);
|
|
700
910
|
await inspectTask(args, env);
|
|
701
911
|
return;
|
|
912
|
+
case "tasks": {
|
|
913
|
+
rejectUnknownFlags(args, ["--limit", "--status", "--json"]);
|
|
914
|
+
const limit = Number(argument("--limit", args) ?? "20");
|
|
915
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
916
|
+
throw new Error("--limit must be a whole number from 1 to 500");
|
|
917
|
+
}
|
|
918
|
+
const status = argument("--status", args);
|
|
919
|
+
process.exitCode = tasksCommand(env, {
|
|
920
|
+
limit,
|
|
921
|
+
json: args.includes("--json"),
|
|
922
|
+
...(status === undefined ? {} : { status: TaskStatusSchema.parse(status) }),
|
|
923
|
+
});
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
case "watch":
|
|
927
|
+
rejectUnknownFlags(args, ["--json"]);
|
|
928
|
+
process.exitCode = await watchCommand(env, { json: args.includes("--json") });
|
|
929
|
+
return;
|
|
930
|
+
case "status": {
|
|
931
|
+
rejectUnknownFlags(args, ["--json"]);
|
|
932
|
+
const policyPath = await existingPolicyPath(env);
|
|
933
|
+
process.exitCode = await statusCommand(env, {
|
|
934
|
+
json: args.includes("--json"),
|
|
935
|
+
...(policyPath === undefined ? {} : { policyPath }),
|
|
936
|
+
});
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
702
939
|
case "workflow":
|
|
703
940
|
rejectUnknownFlags(args, ["--definition", "--approval", "--input"]);
|
|
704
941
|
process.exitCode = await workflowCommand(args, env);
|
|
705
942
|
return;
|
|
706
943
|
case "demo":
|
|
707
944
|
rejectUnknownFlags(args, []);
|
|
708
|
-
|
|
945
|
+
// One demo today, so naming it is optional.
|
|
946
|
+
if ((args[0] ?? "durable-core") !== "durable-core") {
|
|
709
947
|
throw new Error("demo supports only 'durable-core'");
|
|
710
948
|
}
|
|
711
949
|
process.exitCode = await durableCoreDemo(env);
|
|
@@ -723,7 +961,11 @@ async function main() {
|
|
|
723
961
|
case "version":
|
|
724
962
|
case "--version":
|
|
725
963
|
case "-v":
|
|
726
|
-
|
|
964
|
+
// `--json` keeps the bare version scripts already parse; a JSON object
|
|
965
|
+
// here would break every `$(melra version)` in the wild.
|
|
966
|
+
process.stdout.write(isHuman(args)
|
|
967
|
+
? `MELRA ${paint.bold(PRODUCT_VERSION)} ${paint.dim(`(Node ${process.version}, ${process.platform}-${process.arch})`)}\n`
|
|
968
|
+
: `${PRODUCT_VERSION}\n`);
|
|
727
969
|
return;
|
|
728
970
|
case "help":
|
|
729
971
|
case "--help":
|
|
@@ -731,7 +973,9 @@ async function main() {
|
|
|
731
973
|
help();
|
|
732
974
|
return;
|
|
733
975
|
default:
|
|
734
|
-
|
|
976
|
+
// No inline command list: the switch above is the only truth about what
|
|
977
|
+
// exists, and a second copy here would rot the first time one is added.
|
|
978
|
+
throw new Error(`unknown command: ${basename(command)}. Run 'melra --help' for the command list.`);
|
|
735
979
|
}
|
|
736
980
|
}
|
|
737
981
|
/**
|