@sealant/mend 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/dist/dashboard.js +30 -5
- package/dist/dotfiles.js +90 -0
- package/dist/env.js +17 -0
- package/dist/main.js +978 -12
- package/dist/service-init.js +227 -0
- package/dist/shared.js +2 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -2,10 +2,22 @@
|
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import * as net from "node:net";
|
|
5
6
|
import * as os from "node:os";
|
|
6
7
|
import * as path from "node:path";
|
|
8
|
+
import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
|
|
9
|
+
import { formatLoadReport } from "./env.js";
|
|
10
|
+
import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
7
11
|
import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
|
|
8
|
-
|
|
12
|
+
// `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
|
|
13
|
+
// when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
|
|
14
|
+
const mendCliHome = () => {
|
|
15
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
16
|
+
const preferred = path.join(xdg === undefined || xdg === "" ? path.join(os.homedir(), ".config") : xdg, "mend");
|
|
17
|
+
const legacy = path.join(os.homedir(), ".mend");
|
|
18
|
+
return !fs.existsSync(preferred) && fs.existsSync(legacy) ? legacy : preferred;
|
|
19
|
+
};
|
|
20
|
+
const CONFIG_PATH = path.join(mendCliHome(), "cli.json");
|
|
9
21
|
const loadConfig = () => {
|
|
10
22
|
let fileConfig = {};
|
|
11
23
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
@@ -82,7 +94,9 @@ const request = async (config, method, route, body) => {
|
|
|
82
94
|
throw new Error(`cannot reach the Mend server at ${config.url} — is it running?`);
|
|
83
95
|
}
|
|
84
96
|
if (response.status === 401) {
|
|
85
|
-
throw new Error(
|
|
97
|
+
throw new Error(config.token === null
|
|
98
|
+
? `not signed in to ${config.url} — run: mend login`
|
|
99
|
+
: `unauthorized at ${config.url} — the saved token was rejected; run: mend login`);
|
|
86
100
|
}
|
|
87
101
|
const text = await response.text();
|
|
88
102
|
if (!response.ok) {
|
|
@@ -132,18 +146,35 @@ const withSpinner = async (label, work) => {
|
|
|
132
146
|
const adopt = async (config, args) => {
|
|
133
147
|
const nameFlag = args.indexOf("--name");
|
|
134
148
|
const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
|
|
135
|
-
const
|
|
149
|
+
const authFlagIndex = args.indexOf("--auth");
|
|
150
|
+
const positional = args.filter((a, i) => !a.startsWith("--") &&
|
|
151
|
+
(nameFlag === -1 || i !== nameFlag + 1) &&
|
|
152
|
+
(authFlagIndex === -1 || i !== authFlagIndex + 1));
|
|
136
153
|
// Bare `mend adopt` adopts the repo the cwd is inside, not the subdirectory.
|
|
137
154
|
const rawSource = positional[0] ?? gitTopLevel(process.cwd()) ?? ".";
|
|
138
155
|
const source = /^(https?|git|ssh):|^git@/.test(rawSource) ? rawSource : path.resolve(rawSource);
|
|
139
156
|
// Derived defaults are normalized ("Mend" → "mend"); explicit --name is sent as typed.
|
|
140
157
|
const projectName = name ?? normalizeProjectName(path.basename(source, ".git"));
|
|
158
|
+
const auth = authFlagIndex !== -1 && args[authFlagIndex + 1] !== undefined
|
|
159
|
+
? String(args[authFlagIndex + 1])
|
|
160
|
+
: null;
|
|
161
|
+
if (auth !== null && auth !== "ambient" && auth !== "mend-key" && auth !== "bridge") {
|
|
162
|
+
return fail(`--auth takes "ambient", "mend-key", or "bridge", not "${auth}"`);
|
|
163
|
+
}
|
|
141
164
|
const project = await api(config, "POST", "/projects", {
|
|
142
165
|
name: projectName,
|
|
143
166
|
source,
|
|
167
|
+
...(auth === null ? {} : { gitAuthMode: auth }),
|
|
144
168
|
});
|
|
145
169
|
say(`${green("✓")} adopted · ${project.name} · ${dim(project.storePath)}`);
|
|
146
170
|
say(`${dim(" default branch")} ${project.defaultBranch}`);
|
|
171
|
+
// Say which signer did the work — the clone already proved it answers.
|
|
172
|
+
if (project.gitAuthMode === "mend-key") {
|
|
173
|
+
say(`${dim(" git auth")} mend key ${dim("(the machine's deploy key signed this clone)")}`);
|
|
174
|
+
}
|
|
175
|
+
else if (project.gitAuthMode === "bridge") {
|
|
176
|
+
say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
|
|
177
|
+
}
|
|
147
178
|
say(`${dim(" sessions start with:")} mend codex ${dim("(from anywhere —")} --project ${project.name}${dim(")")}`);
|
|
148
179
|
};
|
|
149
180
|
// ─── session launch ─────────────────────────────────────────────────────────
|
|
@@ -218,10 +249,15 @@ const launch = async (config, harness, args) => {
|
|
|
218
249
|
* caller decides what each outcome means (commands exit, the dashboard
|
|
219
250
|
* resumes).
|
|
220
251
|
*/
|
|
221
|
-
const attachTty = async (config, sessionId, harness, from) => {
|
|
252
|
+
const attachTty = async (config, sessionId, harness, from, processId, options) => {
|
|
222
253
|
const url = new URL(`${config.url}/api/tty`);
|
|
223
254
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
224
|
-
|
|
255
|
+
// Process addressing reaches any PTY in the workspace (a shell); the
|
|
256
|
+
// session form remains the agent's PTY.
|
|
257
|
+
if (processId !== undefined)
|
|
258
|
+
url.searchParams.set("process", processId);
|
|
259
|
+
else
|
|
260
|
+
url.searchParams.set("session", sessionId);
|
|
225
261
|
url.searchParams.set("from", from.toString());
|
|
226
262
|
if (config.token !== null)
|
|
227
263
|
url.searchParams.set("token", config.token);
|
|
@@ -294,12 +330,16 @@ const attachTty = async (config, sessionId, harness, from) => {
|
|
|
294
330
|
try {
|
|
295
331
|
sendResize();
|
|
296
332
|
process.stdout.on("resize", onWinch);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
333
|
+
// Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
|
|
334
|
+
// socket drops, and the process inside keeps running untouched.
|
|
335
|
+
if (options?.readOnly !== true) {
|
|
336
|
+
if (rawTty) {
|
|
337
|
+
process.stdin.setRawMode(true);
|
|
338
|
+
rawModeEnabled = true;
|
|
339
|
+
}
|
|
340
|
+
process.stdin.resume();
|
|
341
|
+
process.stdin.on("data", onKeys);
|
|
300
342
|
}
|
|
301
|
-
process.stdin.resume();
|
|
302
|
-
process.stdin.on("data", onKeys);
|
|
303
343
|
ws.addEventListener("message", onTtyFrame);
|
|
304
344
|
await finished;
|
|
305
345
|
return detached ? "detached" : "ended";
|
|
@@ -350,6 +390,887 @@ const attach = async (config, args) => {
|
|
|
350
390
|
await attachOrExit(config, match.id, match.harness);
|
|
351
391
|
exitAfterSessionEnd(config, match.id);
|
|
352
392
|
};
|
|
393
|
+
/** Compact picker: numbered live sessions, one keystroke of typing. */
|
|
394
|
+
const pickSessionInteractively = async (rows) => {
|
|
395
|
+
say(dim("more than one live session — pick one:"));
|
|
396
|
+
rows.forEach((row, index) => {
|
|
397
|
+
say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
|
|
398
|
+
});
|
|
399
|
+
const readline = await import("node:readline/promises");
|
|
400
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
401
|
+
const answer = await rl.question(" session #: ");
|
|
402
|
+
rl.close();
|
|
403
|
+
const chosen = rows[Number(answer.trim()) - 1];
|
|
404
|
+
if (chosen === undefined)
|
|
405
|
+
return fail(`"${answer.trim()}" is not one of the choices`);
|
|
406
|
+
return chosen.session;
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* The resolution order (docs/SESSION-SERVICES.md): an explicit id wins, then
|
|
410
|
+
* the cwd's project narrows, one candidate is taken, several go to the
|
|
411
|
+
* picker, and a non-interactive caller never gets a silent guess.
|
|
412
|
+
*/
|
|
413
|
+
const resolveLiveSession = async (config, prefix) => {
|
|
414
|
+
const sessions = await api(config, "GET", "/sessions");
|
|
415
|
+
if (prefix !== undefined) {
|
|
416
|
+
const matches = sessions.filter((s) => s.id.startsWith(prefix));
|
|
417
|
+
if (matches.length === 0)
|
|
418
|
+
return fail(`no live session matches "${prefix}"`);
|
|
419
|
+
const exact = matches[0];
|
|
420
|
+
if (matches.length > 1 || exact === undefined) {
|
|
421
|
+
return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
|
|
422
|
+
}
|
|
423
|
+
return exact;
|
|
424
|
+
}
|
|
425
|
+
const projects = await api(config, "GET", "/projects");
|
|
426
|
+
const project = matchProjectByCwd(projects, process.cwd());
|
|
427
|
+
const candidates = project === undefined ? sessions : sessions.filter((s) => s.projectId === project.id);
|
|
428
|
+
const only = candidates[0];
|
|
429
|
+
if (only === undefined) {
|
|
430
|
+
return fail("no live session — start one with mend codex|claude|opencode");
|
|
431
|
+
}
|
|
432
|
+
if (candidates.length === 1)
|
|
433
|
+
return only;
|
|
434
|
+
if (process.stdin.isTTY !== true) {
|
|
435
|
+
return fail("several live sessions — name one: mend shell <session-id-prefix>");
|
|
436
|
+
}
|
|
437
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
438
|
+
return pickSessionInteractively(candidates.map((session) => ({
|
|
439
|
+
session,
|
|
440
|
+
projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
|
|
441
|
+
})));
|
|
442
|
+
};
|
|
443
|
+
/**
|
|
444
|
+
* A real interactive shell in the session's CURRENT workspace — same
|
|
445
|
+
* /workspace/repo the agent is editing, same dependencies, same network. Not
|
|
446
|
+
* a shell in the host checkout. The shell holds a workspace lease, so the
|
|
447
|
+
* container survives the agent settling while the shell lives.
|
|
448
|
+
*/
|
|
449
|
+
const shellCommand = async (config, args) => {
|
|
450
|
+
const prefix = args.find((a) => !a.startsWith("--"));
|
|
451
|
+
const session = await resolveLiveSession(config, prefix);
|
|
452
|
+
say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
|
|
453
|
+
const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
|
|
454
|
+
say("");
|
|
455
|
+
const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id);
|
|
456
|
+
if (outcome === "unavailable") {
|
|
457
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
458
|
+
}
|
|
459
|
+
say("");
|
|
460
|
+
if (outcome === "detached") {
|
|
461
|
+
say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
|
|
462
|
+
process.exit(0);
|
|
463
|
+
}
|
|
464
|
+
say(`${green("✓")} shell ended`);
|
|
465
|
+
process.exit(0);
|
|
466
|
+
};
|
|
467
|
+
const serviceUrl = (config, service) => {
|
|
468
|
+
const host = new URL(config.url).hostname || "localhost";
|
|
469
|
+
// A UDP Service has no page to open — the endpoint is the whole fact.
|
|
470
|
+
return service.protocol === "udp"
|
|
471
|
+
? `${host}:${service.hostPort ?? "?"} (udp)`
|
|
472
|
+
: `http://${host}:${service.hostPort ?? "?"}`;
|
|
473
|
+
};
|
|
474
|
+
const printService = (config, service) => {
|
|
475
|
+
const status = service.status === "reachable" ? green(service.status) : amber(service.status);
|
|
476
|
+
say(`${(service.label ?? service.id.slice(0, 8)).padEnd(12)} ${dim(`:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""} →`)} ${serviceUrl(config, service)} ${status} ${dim(service.id.slice(0, 8))}`);
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* Adopt an already-listening workspace port as a Service: Mend binds a host
|
|
480
|
+
* port on its private interfaces and pumps every connection into the
|
|
481
|
+
* session's workspace. No supervision — reachability is the observation.
|
|
482
|
+
*/
|
|
483
|
+
const serviceAdd = async (config, args) => {
|
|
484
|
+
const nameFlag = args.indexOf("--name");
|
|
485
|
+
const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
|
|
486
|
+
const protocol = args.includes("--udp") ? "udp" : "tcp";
|
|
487
|
+
const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
|
|
488
|
+
const portRaw = positional.find((a) => /^\d+$/.test(a));
|
|
489
|
+
if (portRaw === undefined) {
|
|
490
|
+
return fail("usage: mend service add [session] <port> [--name <n>] [--udp]");
|
|
491
|
+
}
|
|
492
|
+
const port = Number(portRaw);
|
|
493
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
494
|
+
return fail(`"${portRaw}" is not a port`);
|
|
495
|
+
}
|
|
496
|
+
const prefix = positional.find((a) => a !== portRaw);
|
|
497
|
+
const session = await resolveLiveSession(config, prefix);
|
|
498
|
+
const service = await api(config, "POST", `/sessions/${session.id}/services`, {
|
|
499
|
+
port,
|
|
500
|
+
name,
|
|
501
|
+
protocol,
|
|
502
|
+
});
|
|
503
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
504
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
505
|
+
if (protocol === "udp") {
|
|
506
|
+
say(dim(` udp — a reply is the only reachability signal; silence just relays`));
|
|
507
|
+
}
|
|
508
|
+
else if (service.status !== "reachable") {
|
|
509
|
+
say(dim(` nothing answered on :${port} yet — the URL goes live when something listens`));
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
const serviceList = async (config) => {
|
|
513
|
+
const services = await api(config, "GET", "/services");
|
|
514
|
+
if (services.length === 0) {
|
|
515
|
+
say(dim("no live services — mend service add <port> adopts a listening one"));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
for (const service of services)
|
|
519
|
+
printService(config, service);
|
|
520
|
+
};
|
|
521
|
+
const serviceStop = async (config, args) => {
|
|
522
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
523
|
+
if (needle === undefined)
|
|
524
|
+
return fail("usage: mend service stop <name-or-id-prefix>");
|
|
525
|
+
const services = await api(config, "GET", "/services");
|
|
526
|
+
const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
527
|
+
if (matches.length === 0)
|
|
528
|
+
return fail(`no live service matches "${needle}"`);
|
|
529
|
+
const match = matches[0];
|
|
530
|
+
if (matches.length > 1 || match === undefined) {
|
|
531
|
+
return fail(`"${needle}" is ambiguous — use more of the id`);
|
|
532
|
+
}
|
|
533
|
+
const stoppedService = await api(config, "POST", `/services/${match.id}/stop`);
|
|
534
|
+
say(`${green("✓")} stopped · ${stoppedService.label ?? stoppedService.id.slice(0, 8)}`);
|
|
535
|
+
};
|
|
536
|
+
const findLiveService = async (config, needle) => {
|
|
537
|
+
const services = await api(config, "GET", "/services");
|
|
538
|
+
const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
539
|
+
if (matches.length === 0)
|
|
540
|
+
return fail(`no live service matches "${needle}"`);
|
|
541
|
+
const match = matches[0];
|
|
542
|
+
if (matches.length > 1 || match === undefined) {
|
|
543
|
+
return fail(`"${needle}" is ambiguous — use more of the id`);
|
|
544
|
+
}
|
|
545
|
+
return match;
|
|
546
|
+
};
|
|
547
|
+
/**
|
|
548
|
+
* Start and supervise a Service: the command runs as its own PTY process in
|
|
549
|
+
* the session's workspace (own record = its logs), Mend waits for the
|
|
550
|
+
* declared port to answer, then exposes it like any Service. The command
|
|
551
|
+
* never occupies the agent's terminal or a tool call.
|
|
552
|
+
*/
|
|
553
|
+
const serviceRun = async (config, args) => {
|
|
554
|
+
const dashdash = args.indexOf("--");
|
|
555
|
+
const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] -- <command...>\n" +
|
|
556
|
+
" mend service run [session] <name> (a declared recipe)";
|
|
557
|
+
// No explicit command = a DECLARED Service: resolve the name against the
|
|
558
|
+
// session worktree's mend.toml and start (or adopt) its recipe.
|
|
559
|
+
if (dashdash === -1) {
|
|
560
|
+
const positionals = args.filter((a) => !a.startsWith("--"));
|
|
561
|
+
const name = positionals.at(-1);
|
|
562
|
+
if (name === undefined)
|
|
563
|
+
return fail(usage);
|
|
564
|
+
const prefix = positionals.length > 1 ? positionals[0] : undefined;
|
|
565
|
+
const session = await resolveLiveSession(config, prefix);
|
|
566
|
+
const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
|
|
567
|
+
const recipe = recipes.find((entry) => entry.name === name);
|
|
568
|
+
if (recipe === undefined) {
|
|
569
|
+
const known = recipes.map((entry) => entry.name).join(", ");
|
|
570
|
+
return fail(recipes.length === 0
|
|
571
|
+
? `no mend.toml recipes in this worktree — declare [service.${name}] first`
|
|
572
|
+
: `no recipe named "${name}" — declared: ${known}`);
|
|
573
|
+
}
|
|
574
|
+
if (recipe.command === null) {
|
|
575
|
+
// A port-only recipe is an adopt: something else starts the listener.
|
|
576
|
+
const service = await api(config, "POST", `/sessions/${session.id}/services`, {
|
|
577
|
+
port: recipe.port,
|
|
578
|
+
name: recipe.name,
|
|
579
|
+
protocol: recipe.protocol,
|
|
580
|
+
});
|
|
581
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
582
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const service = await withSpinner(recipe.protocol === "udp"
|
|
586
|
+
? `starting ${recipe.name} (udp :${recipe.port})…`
|
|
587
|
+
: `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
|
|
588
|
+
argv: ["sh", "-c", recipe.command],
|
|
589
|
+
port: recipe.port,
|
|
590
|
+
name: recipe.name,
|
|
591
|
+
protocol: recipe.protocol,
|
|
592
|
+
}));
|
|
593
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
594
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
595
|
+
say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const argv = args.slice(dashdash + 1);
|
|
599
|
+
if (argv.length === 0)
|
|
600
|
+
return fail(usage);
|
|
601
|
+
const head = args.slice(0, dashdash);
|
|
602
|
+
const portFlag = head.indexOf("--port");
|
|
603
|
+
const port = portFlag !== -1 ? Number(head[portFlag + 1]) : Number.NaN;
|
|
604
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
605
|
+
return fail(usage);
|
|
606
|
+
const nameFlag = head.indexOf("--name");
|
|
607
|
+
const name = nameFlag !== -1 && head[nameFlag + 1] !== undefined ? String(head[nameFlag + 1]) : null;
|
|
608
|
+
const protocol = head.includes("--udp") ? "udp" : "tcp";
|
|
609
|
+
const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
|
|
610
|
+
const session = await resolveLiveSession(config, prefix);
|
|
611
|
+
const service = await withSpinner(protocol === "udp"
|
|
612
|
+
? `starting ${name ?? argv[0]} (udp :${port})…`
|
|
613
|
+
: `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
|
|
614
|
+
argv,
|
|
615
|
+
port,
|
|
616
|
+
name,
|
|
617
|
+
protocol,
|
|
618
|
+
}));
|
|
619
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
620
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
621
|
+
say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
|
|
622
|
+
};
|
|
623
|
+
/**
|
|
624
|
+
* Watch a supervised Service's output: record replay, then live tail. A DEAD
|
|
625
|
+
* Service still answers — its record outlives the process — printed once
|
|
626
|
+
* instead of followed.
|
|
627
|
+
*/
|
|
628
|
+
const serviceLogs = async (config, args) => {
|
|
629
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
630
|
+
if (needle === undefined)
|
|
631
|
+
return fail("usage: mend service logs <name-or-id-prefix>");
|
|
632
|
+
const everything = await api(config, "GET", "/services?all=1");
|
|
633
|
+
const matches = everything.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
634
|
+
if (matches.length === 0)
|
|
635
|
+
return fail(`no service matches "${needle}"`);
|
|
636
|
+
// Prefer the live one; otherwise the newest ended one (list is newest-first).
|
|
637
|
+
const service = matches.find((m) => m.status !== "exited" && m.status !== "stopped") ?? matches[0];
|
|
638
|
+
if (service === undefined)
|
|
639
|
+
return fail(`no service matches "${needle}"`);
|
|
640
|
+
if (service.sealantSessionId === null) {
|
|
641
|
+
return fail(`"${needle}" is an adopted port — no process of Mend's, no logs. mend service run supervises.`);
|
|
642
|
+
}
|
|
643
|
+
if (service.status === "exited" || service.status === "stopped") {
|
|
644
|
+
// Post-mortem: print the record, don't attach.
|
|
645
|
+
const output = await api(config, "GET", `/processes/${service.id}/output`);
|
|
646
|
+
say(dim(`${service.label ?? service.id.slice(0, 8)} · ${service.status} — recorded output:`));
|
|
647
|
+
say("");
|
|
648
|
+
process.stdout.write(output.text.endsWith("\n") ? output.text : `${output.text}\n`);
|
|
649
|
+
process.exit(0);
|
|
650
|
+
}
|
|
651
|
+
say(dim(`following ${service.label ?? service.id.slice(0, 8)} — Ctrl+C detaches, the service keeps running`));
|
|
652
|
+
say("");
|
|
653
|
+
const outcome = await attachTty(config, service.sessionId, "service", 0n, service.id, {
|
|
654
|
+
readOnly: true,
|
|
655
|
+
});
|
|
656
|
+
if (outcome === "unavailable") {
|
|
657
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
658
|
+
}
|
|
659
|
+
say("");
|
|
660
|
+
say(dim("stream ended"));
|
|
661
|
+
process.exit(0);
|
|
662
|
+
};
|
|
663
|
+
const serviceRestart = async (config, args) => {
|
|
664
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
665
|
+
if (needle === undefined)
|
|
666
|
+
return fail("usage: mend service restart <name-or-id-prefix>");
|
|
667
|
+
const service = await findLiveService(config, needle);
|
|
668
|
+
const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, api(config, "POST", `/services/${service.id}/restart`));
|
|
669
|
+
say(`${green("✓")} restarted · ${restarted.status}`);
|
|
670
|
+
say(` ${cobalt(serviceUrl(config, restarted))}`);
|
|
671
|
+
};
|
|
672
|
+
/**
|
|
673
|
+
* Scaffold mend.toml from the project's own manifests. Static suggestion,
|
|
674
|
+
* not detection: package.json scripts and compose port mappings become
|
|
675
|
+
* recipe proposals the user confirms and commits. Nothing runs.
|
|
676
|
+
*/
|
|
677
|
+
const serviceInit = async (args) => {
|
|
678
|
+
const root = gitTopLevel(process.cwd()) ?? process.cwd();
|
|
679
|
+
const target = path.join(root, "mend.toml");
|
|
680
|
+
if (fs.existsSync(target)) {
|
|
681
|
+
return fail(`${target} already exists — edit it directly (init never merges)`);
|
|
682
|
+
}
|
|
683
|
+
const rootFiles = fs.readdirSync(root);
|
|
684
|
+
const proposals = [];
|
|
685
|
+
const readRoot = (name) => fs.readFileSync(path.join(root, name), "utf8");
|
|
686
|
+
if (rootFiles.includes("package.json")) {
|
|
687
|
+
proposals.push(...proposeFromPackageJson(readRoot("package.json"), rootFiles));
|
|
688
|
+
}
|
|
689
|
+
// Monorepo sweep: every workspace package, named after its folder.
|
|
690
|
+
const globs = workspaceGlobs(rootFiles.includes("pnpm-workspace.yaml") ? readRoot("pnpm-workspace.yaml") : null, rootFiles.includes("package.json") ? readRoot("package.json") : null);
|
|
691
|
+
for (const glob of globs) {
|
|
692
|
+
const parent = glob.replace(/\/?\*+$/, "");
|
|
693
|
+
const dirs = glob.endsWith("*")
|
|
694
|
+
? fs.existsSync(path.join(root, parent))
|
|
695
|
+
? fs.readdirSync(path.join(root, parent)).map((dir) => path.join(parent, dir))
|
|
696
|
+
: []
|
|
697
|
+
: [glob];
|
|
698
|
+
for (const dir of dirs) {
|
|
699
|
+
const manifest = path.join(root, dir, "package.json");
|
|
700
|
+
if (!fs.existsSync(manifest))
|
|
701
|
+
continue;
|
|
702
|
+
for (const proposal of proposeFromWorkspacePackage(path.basename(dir), fs.readFileSync(manifest, "utf8"), rootFiles)) {
|
|
703
|
+
if (!proposals.some((existing) => existing.name === proposal.name)) {
|
|
704
|
+
proposals.push(proposal);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Every compose flavor in the root, aggregated; non-default files need -f.
|
|
710
|
+
for (const composeName of rootFiles.filter(isComposeFile).toSorted()) {
|
|
711
|
+
const isDefault = composeName === "compose.yaml" || composeName === "docker-compose.yml";
|
|
712
|
+
for (const proposal of proposeFromCompose(readRoot(composeName))) {
|
|
713
|
+
if (!proposals.some((existing) => existing.name === proposal.name)) {
|
|
714
|
+
proposals.push({
|
|
715
|
+
...proposal,
|
|
716
|
+
command: isDefault
|
|
717
|
+
? proposal.command
|
|
718
|
+
: proposal.command.replace("docker compose ", `docker compose -f ${composeName} `),
|
|
719
|
+
source: `${composeName}: ${proposal.source}`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (proposals.length === 0) {
|
|
725
|
+
return fail("nothing to propose — no server-ish package.json script with a nameable port, no compose ports");
|
|
726
|
+
}
|
|
727
|
+
const toml = renderMendToml(proposals);
|
|
728
|
+
say(dim(`proposed ${target}:`));
|
|
729
|
+
say("");
|
|
730
|
+
process.stdout.write(toml);
|
|
731
|
+
say("");
|
|
732
|
+
if (!args.includes("--yes")) {
|
|
733
|
+
if (process.stdin.isTTY !== true) {
|
|
734
|
+
return fail("non-interactive — pass --yes to write the file");
|
|
735
|
+
}
|
|
736
|
+
const readline = await import("node:readline/promises");
|
|
737
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
738
|
+
const answer = await rl.question("write it? [y/N] ");
|
|
739
|
+
rl.close();
|
|
740
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
741
|
+
say(dim("nothing written"));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
fs.writeFileSync(target, toml);
|
|
746
|
+
say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
|
|
747
|
+
};
|
|
748
|
+
const serviceCommand = async (config, args) => {
|
|
749
|
+
const [verb, ...rest] = args;
|
|
750
|
+
switch (verb) {
|
|
751
|
+
case "run":
|
|
752
|
+
return serviceRun(config, rest);
|
|
753
|
+
case "add":
|
|
754
|
+
return serviceAdd(config, rest);
|
|
755
|
+
case "init":
|
|
756
|
+
return serviceInit(rest);
|
|
757
|
+
case "list":
|
|
758
|
+
case undefined:
|
|
759
|
+
return serviceList(config);
|
|
760
|
+
case "logs":
|
|
761
|
+
return serviceLogs(config, rest);
|
|
762
|
+
case "restart":
|
|
763
|
+
return serviceRestart(config, rest);
|
|
764
|
+
case "stop":
|
|
765
|
+
return serviceStop(config, rest);
|
|
766
|
+
default:
|
|
767
|
+
// Sugar: `mend service mysql` reads as `mend service run mysql` — a
|
|
768
|
+
// bare word that isn't a verb is a recipe name. A miss still explains
|
|
769
|
+
// itself (declared recipes are listed in the failure).
|
|
770
|
+
return serviceRun(config, [verb, ...rest]);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
// ─── login: obtain and save the bearer token ────────────────────────────────
|
|
774
|
+
const takeFlagValue = (args, flag) => {
|
|
775
|
+
const at = args.indexOf(flag);
|
|
776
|
+
return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
|
|
777
|
+
};
|
|
778
|
+
/**
|
|
779
|
+
* Read a password: raw-mode without echo on a TTY; on a pipe, one line through the SAME readline
|
|
780
|
+
* that read the email (a second reader would find the buffered pipe already drained).
|
|
781
|
+
*/
|
|
782
|
+
const readSecretLine = async (prompt, rl) => {
|
|
783
|
+
if (process.stdin.isTTY !== true) {
|
|
784
|
+
return (await rl.question(prompt)).trim();
|
|
785
|
+
}
|
|
786
|
+
process.stdout.write(prompt);
|
|
787
|
+
return new Promise((resolve) => {
|
|
788
|
+
const stdin = process.stdin;
|
|
789
|
+
stdin.setRawMode(true);
|
|
790
|
+
stdin.resume();
|
|
791
|
+
stdin.setEncoding("utf8");
|
|
792
|
+
let buffer = "";
|
|
793
|
+
const onData = (chunk) => {
|
|
794
|
+
for (const char of chunk) {
|
|
795
|
+
if (char === "\r" || char === "\n") {
|
|
796
|
+
stdin.setRawMode(false);
|
|
797
|
+
stdin.pause();
|
|
798
|
+
stdin.off("data", onData);
|
|
799
|
+
process.stdout.write("\n");
|
|
800
|
+
resolve(buffer);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (char === "\u0003") {
|
|
804
|
+
stdin.setRawMode(false);
|
|
805
|
+
process.stdout.write("\n");
|
|
806
|
+
process.exit(130);
|
|
807
|
+
}
|
|
808
|
+
if (char === "\u007f" || char === "\b") {
|
|
809
|
+
buffer = buffer.slice(0, -1);
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
buffer += char;
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
stdin.on("data", onData);
|
|
816
|
+
});
|
|
817
|
+
};
|
|
818
|
+
const saveCliConfig = (next) => {
|
|
819
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
820
|
+
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify({ url: next.url, token: next.token }, null, 2)}\n`, { mode: 0o600 });
|
|
821
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
822
|
+
};
|
|
823
|
+
/**
|
|
824
|
+
* `mend login [--url <server>] [--email <address>]`: sign in with email + password against the
|
|
825
|
+
* server's auth endpoint (the bearer plugin answers with a `set-auth-token` header) and save the
|
|
826
|
+
* token 0600 in the CLI config. Nothing else asks for a password again.
|
|
827
|
+
*/
|
|
828
|
+
const login = async (config, args) => {
|
|
829
|
+
const url = takeFlagValue(args, "--url") ?? process.env["MEND_URL"] ?? config.url;
|
|
830
|
+
const emailFlag = takeFlagValue(args, "--email");
|
|
831
|
+
const readline = await import("node:readline/promises");
|
|
832
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
833
|
+
const email = emailFlag ?? (await rl.question(`email for ${url}: `)).trim();
|
|
834
|
+
if (email === "") {
|
|
835
|
+
rl.close();
|
|
836
|
+
return fail("an email is required");
|
|
837
|
+
}
|
|
838
|
+
const password = await readSecretLine("password: ", rl);
|
|
839
|
+
rl.close();
|
|
840
|
+
if (password === "")
|
|
841
|
+
return fail("a password is required");
|
|
842
|
+
let response;
|
|
843
|
+
try {
|
|
844
|
+
response = await fetch(`${url}/api/auth/sign-in/email`, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
// better-auth's CSRF check rejects the `Origin: null` a non-browser fetch sends; the
|
|
847
|
+
// server's own URL is always a trusted origin, so present that.
|
|
848
|
+
headers: { "content-type": "application/json", origin: url },
|
|
849
|
+
body: JSON.stringify({ email, password }),
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
return fail(`cannot reach the Mend server at ${url} — is it running?`);
|
|
854
|
+
}
|
|
855
|
+
if (response.status === 401 || response.status === 403 || response.status === 400) {
|
|
856
|
+
return fail(`sign-in refused for ${email} at ${url}`);
|
|
857
|
+
}
|
|
858
|
+
if (!response.ok)
|
|
859
|
+
return fail(`sign-in failed: ${url} responded ${response.status}`);
|
|
860
|
+
const token = response.headers.get("set-auth-token");
|
|
861
|
+
if (token === null || token === "") {
|
|
862
|
+
return fail("the server signed you in but returned no bearer token (bearer plugin missing?)");
|
|
863
|
+
}
|
|
864
|
+
saveCliConfig({ ...config, url, token });
|
|
865
|
+
say(`${green("✓")} signed in as ${email} · ${dim(`token saved to ${CONFIG_PATH}`)}`);
|
|
866
|
+
};
|
|
867
|
+
const logout = (config) => {
|
|
868
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
869
|
+
say(dim("nothing saved — already signed out"));
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
saveCliConfig({ ...config, token: null });
|
|
873
|
+
say(`${green("✓")} signed out · ${dim(`token removed from ${CONFIG_PATH}`)}`);
|
|
874
|
+
};
|
|
875
|
+
// ─── keys: the machine's Mend deploy key (docs/GIT-ACCESS.md) ───────────────
|
|
876
|
+
/** Print the public key with the one instruction that makes it useful. */
|
|
877
|
+
const printGitKey = (key) => {
|
|
878
|
+
if (key.publicKey === null)
|
|
879
|
+
return;
|
|
880
|
+
say(key.publicKey);
|
|
881
|
+
if (key.fingerprint !== null)
|
|
882
|
+
say(dim(` ${key.fingerprint}`));
|
|
883
|
+
say(dim(" add this as a deploy key on your git host (GitHub/GitLab/Gitea: repo"));
|
|
884
|
+
say(dim(" settings → deploy keys; grant write access if sessions should push),"));
|
|
885
|
+
say(dim(" then adopt with: mend adopt <ssh-url> --auth mend-key"));
|
|
886
|
+
};
|
|
887
|
+
const keysShow = async (config) => {
|
|
888
|
+
const key = await api(config, "GET", "/keys/git");
|
|
889
|
+
if (!key.exists) {
|
|
890
|
+
say(`no Mend key yet ${dim("— mend keys init generates one (ed25519, stays on the server host)")}`);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
printGitKey(key);
|
|
894
|
+
};
|
|
895
|
+
const keysInit = async (config) => {
|
|
896
|
+
const key = await api(config, "POST", "/keys/git");
|
|
897
|
+
say(`${green("✓")} Mend key ready ${dim("(private half stays on the server host)")}`);
|
|
898
|
+
printGitKey(key);
|
|
899
|
+
};
|
|
900
|
+
/**
|
|
901
|
+
* The ssh-agent bridge, client half (docs/GIT-ACCESS.md decision 2): relay
|
|
902
|
+
* the LOCAL ssh-agent to the Mend server over one standing WebSocket, so
|
|
903
|
+
* bridge-mode git ops sign with a key that never leaves this machine — a
|
|
904
|
+
* hardware key blinks here, not on the server. Agent-protocol messages are
|
|
905
|
+
* relayed verbatim (length-prefixed frames, base64 over JSON); the only
|
|
906
|
+
* inspection is each message's type byte, to say what is being asked.
|
|
907
|
+
*/
|
|
908
|
+
const keysShare = async (config) => {
|
|
909
|
+
const agentSock = process.env["SSH_AUTH_SOCK"];
|
|
910
|
+
if (agentSock === undefined || agentSock === "") {
|
|
911
|
+
return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
|
|
912
|
+
}
|
|
913
|
+
const url = new URL(`${config.url}/api/keys/bridge/ws`);
|
|
914
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
915
|
+
url.searchParams.set("host", os.hostname());
|
|
916
|
+
if (config.token !== null)
|
|
917
|
+
url.searchParams.set("token", config.token);
|
|
918
|
+
/**
|
|
919
|
+
* One complete agent exchange against the local agent: fresh connection,
|
|
920
|
+
* write the framed request verbatim, read one framed response. A hardware
|
|
921
|
+
* key blocks here until the touch — hence the generous timeout.
|
|
922
|
+
*/
|
|
923
|
+
const askLocalAgent = (payload) => new Promise((resolve, reject) => {
|
|
924
|
+
const connection = net.connect(agentSock, () => {
|
|
925
|
+
connection.write(payload);
|
|
926
|
+
});
|
|
927
|
+
const timer = setTimeout(() => {
|
|
928
|
+
connection.destroy();
|
|
929
|
+
reject(new Error("the agent did not answer within 60s — touch missed?"));
|
|
930
|
+
}, 60_000);
|
|
931
|
+
let pending = Buffer.alloc(0);
|
|
932
|
+
connection.on("data", (chunk) => {
|
|
933
|
+
pending = Buffer.concat([pending, chunk]);
|
|
934
|
+
if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
|
|
935
|
+
clearTimeout(timer);
|
|
936
|
+
connection.end();
|
|
937
|
+
resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
connection.on("error", (error) => {
|
|
941
|
+
clearTimeout(timer);
|
|
942
|
+
reject(error);
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
// Strictly one request at a time, in arrival order — the agent protocol is
|
|
946
|
+
// request/response and a hardware key cannot answer two touches at once.
|
|
947
|
+
let chain = Promise.resolve();
|
|
948
|
+
const SSH_AGENTC_SIGN_REQUEST = 13;
|
|
949
|
+
const SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
950
|
+
let attempt = 0;
|
|
951
|
+
for (;;) {
|
|
952
|
+
const ws = new WebSocket(url);
|
|
953
|
+
const closed = new Promise((resolve) => {
|
|
954
|
+
ws.addEventListener("close", () => resolve(), { once: true });
|
|
955
|
+
});
|
|
956
|
+
const opened = await new Promise((resolve) => {
|
|
957
|
+
ws.addEventListener("open", () => resolve(true), { once: true });
|
|
958
|
+
ws.addEventListener("error", () => resolve(false), { once: true });
|
|
959
|
+
});
|
|
960
|
+
if (opened) {
|
|
961
|
+
attempt = 0;
|
|
962
|
+
say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
|
|
963
|
+
say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
|
|
964
|
+
ws.addEventListener("message", (event) => {
|
|
965
|
+
const text = typeof event.data === "string"
|
|
966
|
+
? event.data
|
|
967
|
+
: Buffer.from(event.data).toString("utf8");
|
|
968
|
+
let frame;
|
|
969
|
+
try {
|
|
970
|
+
frame = JSON.parse(text);
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (frame.t !== "req" ||
|
|
976
|
+
typeof frame.id !== "number" ||
|
|
977
|
+
typeof frame.payload !== "string") {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const id = frame.id;
|
|
981
|
+
const payload = Buffer.from(frame.payload, "base64");
|
|
982
|
+
const type = payload[4];
|
|
983
|
+
const context = typeof frame.context === "string" ? frame.context : "mend";
|
|
984
|
+
chain = chain.then(async () => {
|
|
985
|
+
const started = Date.now();
|
|
986
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
987
|
+
say(`${amber("✎")} signature requested by mend ${dim(`(${context})`)}`);
|
|
988
|
+
say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
|
|
989
|
+
}
|
|
990
|
+
else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
|
|
991
|
+
say(dim(` identities requested (${context})`));
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
const response = await askLocalAgent(payload);
|
|
995
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
996
|
+
return null;
|
|
997
|
+
ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
|
|
998
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
999
|
+
say(`${green("✓")} signed ${dim(`(${((Date.now() - started) / 1000).toFixed(1)}s)`)}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
catch (error) {
|
|
1003
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1004
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1005
|
+
ws.send(JSON.stringify({ t: "err", id, message }));
|
|
1006
|
+
}
|
|
1007
|
+
if (type === SSH_AGENTC_SIGN_REQUEST)
|
|
1008
|
+
say(`${amber("✗")} not signed — ${message}`);
|
|
1009
|
+
}
|
|
1010
|
+
return null;
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
await closed;
|
|
1015
|
+
attempt += 1;
|
|
1016
|
+
const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt - 1, 5));
|
|
1017
|
+
say(dim(`not connected — retrying in ${Math.round(delay / 1000)}s (Ctrl-C stops)`));
|
|
1018
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
const keysCommand = async (config, args) => {
|
|
1022
|
+
const [verb] = args;
|
|
1023
|
+
switch (verb) {
|
|
1024
|
+
case "init":
|
|
1025
|
+
return keysInit(config);
|
|
1026
|
+
case "share":
|
|
1027
|
+
return keysShare(config);
|
|
1028
|
+
case "show":
|
|
1029
|
+
case undefined:
|
|
1030
|
+
return keysShow(config);
|
|
1031
|
+
default:
|
|
1032
|
+
return fail(`unknown keys command "${verb}" — try: mend keys init | show | share`);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
const formatDotfileBytes = (bytes) => bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
1036
|
+
const dotfilesShow = async (config) => {
|
|
1037
|
+
const dotfiles = await api(config, "GET", "/dotfiles");
|
|
1038
|
+
if (dotfiles.repository === null) {
|
|
1039
|
+
say(`repo ${dim("none")}`);
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
const branch = dotfiles.repository.ref ?? "default branch";
|
|
1043
|
+
const bootstrap = dotfiles.repository.bootstrap ? "install.sh on" : "install.sh off";
|
|
1044
|
+
say(`repo ${dotfiles.repository.url} ${dim(`(${branch} · ${bootstrap})`)}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (dotfiles.snapshot === null) {
|
|
1047
|
+
say(`snapshot ${dim("none — sync from this machine: mend dotfiles sync --all")}`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const snapshot = dotfiles.snapshot;
|
|
1051
|
+
say(`snapshot ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} · from ${snapshot.source} · ${dim(snapshot.sha.slice(0, 7))}`);
|
|
1052
|
+
for (const file of snapshot.files) {
|
|
1053
|
+
say(` ${file.path.padEnd(36)} ${dim(formatDotfileBytes(file.bytes))}`);
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
/**
|
|
1057
|
+
* `mend dotfiles sync` — capture home files ON THIS MACHINE and stream them into the server's
|
|
1058
|
+
* per-user dotfiles store. This is the whole point of the store: the server may be a VPS whose
|
|
1059
|
+
* home directory belongs to a service account, so contents are read here, where they live.
|
|
1060
|
+
*/
|
|
1061
|
+
const dotfilesSync = async (config, args) => {
|
|
1062
|
+
const all = args.includes("--all");
|
|
1063
|
+
const requestedPaths = args.filter((arg) => !arg.startsWith("--"));
|
|
1064
|
+
const home = os.homedir();
|
|
1065
|
+
if (!all && requestedPaths.length === 0) {
|
|
1066
|
+
const found = scanDotfileCandidates(home);
|
|
1067
|
+
if (found.length === 0) {
|
|
1068
|
+
say(dim("no known config files found under ~"));
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
let group = "";
|
|
1072
|
+
for (const entry of found) {
|
|
1073
|
+
if (entry.group !== group) {
|
|
1074
|
+
group = entry.group;
|
|
1075
|
+
say(dim(group));
|
|
1076
|
+
}
|
|
1077
|
+
say(` ${entry.path.padEnd(36)} ${dim(formatDotfileBytes(entry.bytes))}`);
|
|
1078
|
+
}
|
|
1079
|
+
say("");
|
|
1080
|
+
say(`sync everything with ${cobalt("mend dotfiles sync --all")}, or pick: ${cobalt("mend dotfiles sync .zshrc .gitconfig")}`);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const selected = all ? scanDotfileCandidates(home).map((entry) => entry.path) : requestedPaths;
|
|
1084
|
+
if (selected.length === 0)
|
|
1085
|
+
return fail("nothing to sync — no known config files found under ~");
|
|
1086
|
+
const read = readSyncFiles(home, selected);
|
|
1087
|
+
if ("error" in read)
|
|
1088
|
+
return fail(read.error);
|
|
1089
|
+
const result = await api(config, "POST", "/dotfiles/snapshot", {
|
|
1090
|
+
files: read.files,
|
|
1091
|
+
source: os.hostname(),
|
|
1092
|
+
merge: false,
|
|
1093
|
+
});
|
|
1094
|
+
const snapshot = result.snapshot;
|
|
1095
|
+
if (snapshot === null)
|
|
1096
|
+
return fail("the server accepted the sync but reports no snapshot");
|
|
1097
|
+
say(`${green("synced")} ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} from ${os.hostname()} · ${dim(snapshot.sha.slice(0, 7))} ${dim("— applies from the next session launch")}`);
|
|
1098
|
+
};
|
|
1099
|
+
const envLoad = async (config, args) => {
|
|
1100
|
+
const explicitProject = takeFlagValue(args, "--project");
|
|
1101
|
+
// `--secret` alone sends everything to Secrets; `--secret A,B` only those names (for the
|
|
1102
|
+
// ordinary-looking ones that embed credentials, like DATABASE_URL). Routing is by NAME.
|
|
1103
|
+
const secretFlag = args.indexOf("--secret");
|
|
1104
|
+
const secretArg = secretFlag !== -1 ? args[secretFlag + 1] : undefined;
|
|
1105
|
+
const secretNames = secretArg !== undefined &&
|
|
1106
|
+
!secretArg.startsWith("--") &&
|
|
1107
|
+
!secretArg.includes("/") &&
|
|
1108
|
+
!secretArg.includes(".")
|
|
1109
|
+
? secretArg
|
|
1110
|
+
.split(",")
|
|
1111
|
+
.map((s) => s.trim())
|
|
1112
|
+
.filter((s) => s !== "")
|
|
1113
|
+
: [];
|
|
1114
|
+
const allSecret = secretFlag !== -1 && secretNames.length === 0;
|
|
1115
|
+
const consumed = new Set();
|
|
1116
|
+
if (secretFlag !== -1 && secretNames.length > 0)
|
|
1117
|
+
consumed.add(secretFlag + 1);
|
|
1118
|
+
const positional = args.filter((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--project" && !consumed.has(i));
|
|
1119
|
+
const file = path.resolve(positional[0] ?? ".env");
|
|
1120
|
+
let contents;
|
|
1121
|
+
try {
|
|
1122
|
+
contents = fs.readFileSync(file, "utf8");
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return fail(`cannot read ${file} — pass a path: mend env load path/to/.env`);
|
|
1126
|
+
}
|
|
1127
|
+
if (contents.trim() === "") {
|
|
1128
|
+
say(dim(`${file} is empty`));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const project = await findProject(config, explicitProject);
|
|
1132
|
+
const report = await api(config, "POST", `/projects/${project.id}/environment/load`, { contents, allSecret, secretNames });
|
|
1133
|
+
if (report.loaded.length === 0 &&
|
|
1134
|
+
report.rejected.length === 0 &&
|
|
1135
|
+
report.malformedLines.length === 0) {
|
|
1136
|
+
say(dim(`${path.basename(file)} has no variables`));
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
say(`${green("✓")} loaded ${path.basename(file)} into ${project.name}`);
|
|
1140
|
+
for (const line of formatLoadReport(report, { dim, warn: amber }))
|
|
1141
|
+
say(line);
|
|
1142
|
+
const plaintextUrls = report.loaded.filter((entry) => entry.lane === "configuration" && /_(URL|URI|DSN)$/.test(entry.name));
|
|
1143
|
+
if (plaintextUrls.length > 0) {
|
|
1144
|
+
say(amber(` ${plaintextUrls.map((entry) => entry.name).join(", ")} stored as plaintext configuration — if a value embeds a password, store it as a secret: mend env load --secret ${plaintextUrls.map((entry) => entry.name).join(",")}`));
|
|
1145
|
+
}
|
|
1146
|
+
if (report.malformedLines.length > 0) {
|
|
1147
|
+
say(amber(` skipped ${report.malformedLines.length} malformed line${report.malformedLines.length === 1 ? "" : "s"}: ${report.malformedLines.join(", ")}`));
|
|
1148
|
+
}
|
|
1149
|
+
say(dim(` configuration r${report.environmentRevision} · secrets r${report.secretRevision} — applies from the next workspace launch, including resume; running workspaces keep what they started with`));
|
|
1150
|
+
};
|
|
1151
|
+
const envShow = async (config, args) => {
|
|
1152
|
+
const project = await findProject(config, takeFlagValue(args, "--project"));
|
|
1153
|
+
const [environment, secrets] = await Promise.all([
|
|
1154
|
+
api(config, "GET", `/projects/${project.id}/environment`),
|
|
1155
|
+
api(config, "GET", `/projects/${project.id}/secrets`),
|
|
1156
|
+
]);
|
|
1157
|
+
say(`${project.name} ${dim(`· configuration r${environment.revision} · secrets r${secrets.revision}`)}`);
|
|
1158
|
+
if (environment.variables.length === 0 && secrets.secrets.length === 0) {
|
|
1159
|
+
say(dim(` nothing stored — load a file: ${cobalt("mend env load")}`));
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
const width = Math.max(...environment.variables.map((v) => v.name.length), ...secrets.secrets.map((s) => s.name.length));
|
|
1163
|
+
for (const variable of environment.variables) {
|
|
1164
|
+
say(` ${variable.name.padEnd(width)} configuration ${dim("· plaintext")}`);
|
|
1165
|
+
}
|
|
1166
|
+
for (const secret of secrets.secrets) {
|
|
1167
|
+
say(` ${secret.name.padEnd(width)} secret ${dim("· value set, never shown")}`);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
const envCommand = async (config, args) => {
|
|
1171
|
+
const [verb, ...rest] = args;
|
|
1172
|
+
switch (verb) {
|
|
1173
|
+
case "load":
|
|
1174
|
+
return envLoad(config, rest);
|
|
1175
|
+
case "show":
|
|
1176
|
+
case undefined:
|
|
1177
|
+
return envShow(config, rest);
|
|
1178
|
+
default:
|
|
1179
|
+
return fail(`unknown env command "${verb}" — try: mend env load | show`);
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
const dotfilesCommand = async (config, args) => {
|
|
1183
|
+
const [verb, ...rest] = args;
|
|
1184
|
+
switch (verb) {
|
|
1185
|
+
case "sync":
|
|
1186
|
+
return dotfilesSync(config, rest);
|
|
1187
|
+
case "show":
|
|
1188
|
+
case undefined:
|
|
1189
|
+
return dotfilesShow(config);
|
|
1190
|
+
default:
|
|
1191
|
+
return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
// ─── completions: live sessions under TAB ───────────────────────────────────
|
|
1195
|
+
/**
|
|
1196
|
+
* The data half of shell completion: one live session per line as
|
|
1197
|
+
* `id<TAB>description`. Scripts adapt the shape (zsh wants `id:desc`, bash
|
|
1198
|
+
* wants bare ids). Never fails — a dead server just completes nothing.
|
|
1199
|
+
*/
|
|
1200
|
+
const completeCommand = async (config, args) => {
|
|
1201
|
+
if (args[0] !== "session")
|
|
1202
|
+
return;
|
|
1203
|
+
try {
|
|
1204
|
+
const [sessions, projects] = await Promise.all([
|
|
1205
|
+
request(config, "GET", "/sessions"),
|
|
1206
|
+
request(config, "GET", "/projects"),
|
|
1207
|
+
]);
|
|
1208
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
1209
|
+
for (const session of sessions) {
|
|
1210
|
+
const project = nameById.get(session.projectId) ?? "";
|
|
1211
|
+
process.stdout.write(`${session.id}\t${session.harness} · ${project} · ${session.branch}\n`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
// Completion must never surface an error into the user's TAB press.
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
const ZSH_COMPLETIONS = `#compdef mend
|
|
1219
|
+
_mend() {
|
|
1220
|
+
local -a commands
|
|
1221
|
+
commands=(
|
|
1222
|
+
'adopt:adopt a repository into the store'
|
|
1223
|
+
'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
|
|
1224
|
+
'run:new session + arbitrary command'
|
|
1225
|
+
'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
|
|
1226
|
+
'service:reachable ports — add, list, stop'
|
|
1227
|
+
'keys:the machine Mend deploy key — init, show, share'
|
|
1228
|
+
'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
|
|
1229
|
+
'rejoin:attach if live, otherwise resume'
|
|
1230
|
+
'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
|
|
1231
|
+
'ui:the dashboard' 'help:help'
|
|
1232
|
+
)
|
|
1233
|
+
if (( CURRENT == 2 )); then
|
|
1234
|
+
_describe 'command' commands
|
|
1235
|
+
return
|
|
1236
|
+
fi
|
|
1237
|
+
case $words[2] in
|
|
1238
|
+
shell|attach|continue|resume|rejoin)
|
|
1239
|
+
local -a sessions
|
|
1240
|
+
sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
|
|
1241
|
+
(( \${#sessions} )) && _describe 'session' sessions
|
|
1242
|
+
;;
|
|
1243
|
+
esac
|
|
1244
|
+
}
|
|
1245
|
+
_mend "$@"
|
|
1246
|
+
`;
|
|
1247
|
+
const BASH_COMPLETIONS = `_mend() {
|
|
1248
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1249
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1250
|
+
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys continue resume rejoin projects sessions status ui help" -- "$cur") )
|
|
1251
|
+
return
|
|
1252
|
+
fi
|
|
1253
|
+
case \${COMP_WORDS[1]} in
|
|
1254
|
+
shell|attach|continue|resume|rejoin)
|
|
1255
|
+
COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
|
|
1256
|
+
;;
|
|
1257
|
+
esac
|
|
1258
|
+
}
|
|
1259
|
+
complete -F _mend mend
|
|
1260
|
+
`;
|
|
1261
|
+
/** Print the hook for the named shell; the user wires it into their rc file. */
|
|
1262
|
+
const completionsCommand = (args) => {
|
|
1263
|
+
switch (args[0]) {
|
|
1264
|
+
case "zsh":
|
|
1265
|
+
process.stdout.write(ZSH_COMPLETIONS);
|
|
1266
|
+
return;
|
|
1267
|
+
case "bash":
|
|
1268
|
+
process.stdout.write(BASH_COMPLETIONS);
|
|
1269
|
+
return;
|
|
1270
|
+
default:
|
|
1271
|
+
return fail('usage: mend completions zsh|bash — e.g. mend completions zsh > "$fpath[1]/_mend"');
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
353
1274
|
// ─── supervised run: platform workspace + PTY + record ──────────────────────
|
|
354
1275
|
const supervisedRun = async (config, session, argv) => {
|
|
355
1276
|
const launched = await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
|
|
@@ -731,10 +1652,36 @@ const dashboard = async (config) => {
|
|
|
731
1652
|
const HELP = `mend — the agent workbench
|
|
732
1653
|
|
|
733
1654
|
mend the dashboard: every project and session, live
|
|
734
|
-
mend adopt [source] [--name <name>]
|
|
1655
|
+
mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
|
|
1656
|
+
adopt a repository into the store (default: cwd; any git
|
|
1657
|
+
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
1658
|
+
mend login [--url <server>] sign in with email + password; saves the token (0600)
|
|
1659
|
+
mend logout forget the saved token
|
|
1660
|
+
mend keys init generate the machine's Mend deploy key (ed25519)
|
|
1661
|
+
mend keys show print the public key — add it as a deploy key on your git host
|
|
1662
|
+
mend env load [file] [--secret [A,B]] load a .env into the project: ordinary names → configuration,
|
|
1663
|
+
secret-shaped names → secrets; --secret sends all (or the
|
|
1664
|
+
named ones, e.g. DATABASE_URL) to secrets
|
|
1665
|
+
mend env show what the project store holds — names only, never values
|
|
1666
|
+
mend dotfiles your dotfiles on the server: repo + synced home files
|
|
1667
|
+
mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
|
|
1668
|
+
mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
|
|
1669
|
+
hardware keys sign here; Ctrl-C stops sharing)
|
|
735
1670
|
mend codex|claude|opencode new session worktree + launch the harness in it
|
|
736
1671
|
mend run -- <command...> same, with an arbitrary command
|
|
737
1672
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
1673
|
+
mend shell [session-id-prefix] open a shell in a live session's workspace
|
|
1674
|
+
mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
|
|
1675
|
+
start + supervise a server in the session workspace
|
|
1676
|
+
mend service run [session] <name> start a declared Service (mend.toml recipe)
|
|
1677
|
+
mend service <name> shorthand for the above
|
|
1678
|
+
mend service init [--yes] scaffold mend.toml from package.json + compose ports
|
|
1679
|
+
mend service add [session] <port> [--name <n>] [--udp]
|
|
1680
|
+
adopt a listening workspace port — reachable on this machine
|
|
1681
|
+
mend service list every live service and its observed state
|
|
1682
|
+
mend service logs <name-or-id> follow a supervised service's output (replay, then live)
|
|
1683
|
+
mend service restart <name-or-id> re-run its recorded command — same URL
|
|
1684
|
+
mend service stop <name-or-id> stop a service (closes its host port)
|
|
738
1685
|
mend continue [session-id] resume a session with its pending review follow-up
|
|
739
1686
|
mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
|
|
740
1687
|
mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
|
|
@@ -742,10 +1689,11 @@ const HELP = `mend — the agent workbench
|
|
|
742
1689
|
mend sessions [--all] [--project p] [--json]
|
|
743
1690
|
sessions with review facts; JSON is stable for integrations
|
|
744
1691
|
mend status active sessions (alias of mend sessions)
|
|
1692
|
+
mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
|
|
745
1693
|
|
|
746
1694
|
server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
|
|
747
1695
|
detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
|
|
748
|
-
config file: ~/.mend/cli.json { "url": ..., "token": ... }
|
|
1696
|
+
config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
|
|
749
1697
|
`;
|
|
750
1698
|
const main = async () => {
|
|
751
1699
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -760,6 +1708,24 @@ const main = async () => {
|
|
|
760
1708
|
return launch(config, command, rest);
|
|
761
1709
|
case "attach":
|
|
762
1710
|
return attach(config, rest);
|
|
1711
|
+
case "shell":
|
|
1712
|
+
return shellCommand(config, rest);
|
|
1713
|
+
case "service":
|
|
1714
|
+
return serviceCommand(config, rest);
|
|
1715
|
+
case "login":
|
|
1716
|
+
return login(config, rest);
|
|
1717
|
+
case "logout":
|
|
1718
|
+
return logout(config);
|
|
1719
|
+
case "keys":
|
|
1720
|
+
return keysCommand(config, rest);
|
|
1721
|
+
case "dotfiles":
|
|
1722
|
+
return dotfilesCommand(config, rest);
|
|
1723
|
+
case "env":
|
|
1724
|
+
return envCommand(config, rest);
|
|
1725
|
+
case "completions":
|
|
1726
|
+
return completionsCommand(rest);
|
|
1727
|
+
case "__complete":
|
|
1728
|
+
return completeCommand(config, rest);
|
|
763
1729
|
case "continue":
|
|
764
1730
|
return continueSession(config, rest);
|
|
765
1731
|
case "resume":
|