@sealant/mend 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/dashboard.js +30 -5
- package/dist/dotfiles.js +90 -0
- package/dist/main.js +777 -11
- package/dist/service-init.js +227 -0
- package/dist/shared.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,12 +33,12 @@ mend status active sessions (alias of mend sessions)
|
|
|
33
33
|
|
|
34
34
|
## Configuration
|
|
35
35
|
|
|
36
|
-
| Source
|
|
37
|
-
|
|
|
38
|
-
| `MEND_URL`
|
|
39
|
-
| `MEND_TOKEN`
|
|
40
|
-
| `MEND_DETACH_KEY`
|
|
41
|
-
| `~/.mend/cli.json` | `{ "url": ..., "token": ... }` — env vars win
|
|
36
|
+
| Source | What |
|
|
37
|
+
| ------------------------- | ----------------------------------------------------------------------------------------- |
|
|
38
|
+
| `MEND_URL` | The Mend server (default `http://localhost:3105`) |
|
|
39
|
+
| `MEND_TOKEN` | Bearer token for that server |
|
|
40
|
+
| `MEND_DETACH_KEY` | Set to `none` when an outer multiplexer detaches |
|
|
41
|
+
| `~/.config/mend/cli.json` | `{ "url": ..., "token": ... }` — env vars win; a pre-XDG `~/.mend/cli.json` keeps working |
|
|
42
42
|
|
|
43
43
|
## Herdr
|
|
44
44
|
|
package/dist/dashboard.js
CHANGED
|
@@ -43,8 +43,21 @@ const timeAgo = (iso) => {
|
|
|
43
43
|
const WORKBENCH_KEY = ["workbench"];
|
|
44
44
|
const fetchWorkbench = async (ctx) => {
|
|
45
45
|
const projects = await ctx.api("GET", "/projects");
|
|
46
|
-
const fetched = await Promise.all(
|
|
47
|
-
|
|
46
|
+
const [fetched, services] = await Promise.all([
|
|
47
|
+
Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
|
|
48
|
+
ctx.api("GET", "/services"),
|
|
49
|
+
]);
|
|
50
|
+
const servicesBySession = new Map();
|
|
51
|
+
for (const service of services) {
|
|
52
|
+
const bucket = servicesBySession.get(service.sessionId) ?? [];
|
|
53
|
+
bucket.push(service);
|
|
54
|
+
servicesBySession.set(service.sessionId, bucket);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
projects,
|
|
58
|
+
details: new Map(fetched.map((detail) => [detail.project.id, detail])),
|
|
59
|
+
servicesBySession,
|
|
60
|
+
};
|
|
48
61
|
};
|
|
49
62
|
const itemKey = (item) => item.kind === "session"
|
|
50
63
|
? item.session.id
|
|
@@ -84,6 +97,7 @@ const deriveItems = (view, data) => {
|
|
|
84
97
|
kind: "session",
|
|
85
98
|
session,
|
|
86
99
|
annotation: detail.annotations.find((a) => a.sessionId === session.id),
|
|
100
|
+
services: data.servicesBySession.get(session.id) ?? [],
|
|
87
101
|
}));
|
|
88
102
|
}
|
|
89
103
|
const resuming = view.session;
|
|
@@ -100,7 +114,9 @@ const deriveItems = (view, data) => {
|
|
|
100
114
|
kind: "harness",
|
|
101
115
|
harness,
|
|
102
116
|
label: harness,
|
|
103
|
-
hint:
|
|
117
|
+
hint: harness === "shell"
|
|
118
|
+
? "a bash in the worktree — resume either agent from inside"
|
|
119
|
+
: "the conversation crosses as a distilled prompt",
|
|
104
120
|
})),
|
|
105
121
|
];
|
|
106
122
|
}
|
|
@@ -108,7 +124,9 @@ const deriveItems = (view, data) => {
|
|
|
108
124
|
kind: "harness",
|
|
109
125
|
harness,
|
|
110
126
|
label: harness,
|
|
111
|
-
hint:
|
|
127
|
+
hint: harness === "shell"
|
|
128
|
+
? "a plain bash session — new worktree, recorded"
|
|
129
|
+
: `mend ${harness} — new worktree, recorded session`,
|
|
112
130
|
}));
|
|
113
131
|
};
|
|
114
132
|
// ─── rows: JSX spans instead of StyledText chunks ───────────────────────────
|
|
@@ -122,13 +140,20 @@ const Facts = ({ facts }) => {
|
|
|
122
140
|
_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, fact] }, index))) }));
|
|
123
141
|
};
|
|
124
142
|
const SessionRow = ({ item, selected, }) => {
|
|
125
|
-
const { session, annotation } = item;
|
|
143
|
+
const { session, annotation, services } = item;
|
|
126
144
|
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
127
145
|
const glyph = STATUS_GLYPH[session.status] ?? "·";
|
|
128
146
|
const facts = [];
|
|
129
147
|
const age = timeAgo(session.createdAt);
|
|
130
148
|
if (age !== "")
|
|
131
149
|
facts.push(_jsx("span", { fg: FAINT, children: age }));
|
|
150
|
+
// What runs right now: name :port → host port, observed state colored.
|
|
151
|
+
for (const service of services.slice(0, 2)) {
|
|
152
|
+
facts.push(_jsx("span", { fg: service.status === "reachable" ? GREEN : AMBER, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"} ${service.status}` }));
|
|
153
|
+
}
|
|
154
|
+
if (services.length > 2) {
|
|
155
|
+
facts.push(_jsx("span", { fg: FAINT, children: `+${services.length - 2} services` }));
|
|
156
|
+
}
|
|
132
157
|
if (session.label !== null)
|
|
133
158
|
facts.push(_jsx("span", { fg: MUTED, children: session.label }));
|
|
134
159
|
if (annotation !== undefined && annotation.openComments > 0) {
|
package/dist/dotfiles.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Local dotfiles scanning for `mend dotfiles sync`. This runs on the machine that HAS the
|
|
5
|
+
* files — the whole point: the Mend server may be a VPS whose home directory is a service
|
|
6
|
+
* account's, so contents are captured here and streamed to the server's per-user dotfiles store.
|
|
7
|
+
*
|
|
8
|
+
* The candidate list mirrors the product's curated set (plain configuration only — never keys,
|
|
9
|
+
* credential stores, or histories). The CLI stays dependency-light by design, so the list is
|
|
10
|
+
* duplicated here rather than imported from a workspace package.
|
|
11
|
+
*/
|
|
12
|
+
export const DOTFILE_CANDIDATES = [
|
|
13
|
+
{
|
|
14
|
+
group: "shell",
|
|
15
|
+
paths: [
|
|
16
|
+
".zshrc",
|
|
17
|
+
".zshenv",
|
|
18
|
+
".zprofile",
|
|
19
|
+
".bashrc",
|
|
20
|
+
".bash_profile",
|
|
21
|
+
".profile",
|
|
22
|
+
".aliases",
|
|
23
|
+
".config/fish/config.fish",
|
|
24
|
+
".config/starship.toml",
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
group: "git",
|
|
29
|
+
paths: [".gitconfig", ".gitignore_global", ".config/git/config", ".config/git/ignore"],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
group: "editors",
|
|
33
|
+
paths: [".vimrc", ".ideavimrc", ".editorconfig", ".config/helix/config.toml"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
group: "terminal",
|
|
37
|
+
paths: [".tmux.conf", ".config/tmux/tmux.conf", ".inputrc", ".dir_colors"],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
group: "tools",
|
|
41
|
+
paths: [
|
|
42
|
+
".ripgreprc",
|
|
43
|
+
".config/bat/config",
|
|
44
|
+
".config/lazygit/config.yml",
|
|
45
|
+
".config/mise/config.toml",
|
|
46
|
+
".config/atuin/config.toml",
|
|
47
|
+
".config/zellij/config.kdl",
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
/** The per-file cap the server enforces; checked here so a mistake fails before the upload. */
|
|
52
|
+
export const MAX_FILE_BYTES = 1024 * 1024;
|
|
53
|
+
const statFile = (home, relative) => {
|
|
54
|
+
try {
|
|
55
|
+
const stat = fs.statSync(path.join(home, relative));
|
|
56
|
+
return stat.isFile() ? stat : null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** Probe the curated candidates under `home`; only existing regular files appear. */
|
|
63
|
+
export const scanDotfileCandidates = (home) => DOTFILE_CANDIDATES.flatMap(({ group, paths }) => paths.flatMap((relative) => {
|
|
64
|
+
const stat = statFile(home, relative);
|
|
65
|
+
return stat === null ? [] : [{ path: relative, group, bytes: stat.size }];
|
|
66
|
+
}));
|
|
67
|
+
/**
|
|
68
|
+
* Read the selected paths for upload. Explicitly requested paths must exist (a typo should fail,
|
|
69
|
+
* not silently sync nothing); oversized files fail with the server's own rule. Modes ride along
|
|
70
|
+
* so an executable script stays executable.
|
|
71
|
+
*/
|
|
72
|
+
export const readSyncFiles = (home, paths_) => {
|
|
73
|
+
const files = [];
|
|
74
|
+
for (const relative of paths_) {
|
|
75
|
+
const stat = statFile(home, relative);
|
|
76
|
+
if (stat === null) {
|
|
77
|
+
return { error: `${relative} is not a file under ${home}` };
|
|
78
|
+
}
|
|
79
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
80
|
+
return { error: `${relative} is over 1MB — dotfiles are text; trim the selection` };
|
|
81
|
+
}
|
|
82
|
+
files.push({
|
|
83
|
+
path: relative,
|
|
84
|
+
contentsBase64: fs.readFileSync(path.join(home, relative)).toString("base64"),
|
|
85
|
+
// eslint-disable-next-line no-bitwise -- permission bits are the point
|
|
86
|
+
mode: (stat.mode & 0o777).toString(8),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { files };
|
|
90
|
+
};
|
package/dist/main.js
CHANGED
|
@@ -2,10 +2,21 @@
|
|
|
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 { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
7
10
|
import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
|
|
8
|
-
|
|
11
|
+
// `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
|
|
12
|
+
// when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
|
|
13
|
+
const mendCliHome = () => {
|
|
14
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
15
|
+
const preferred = path.join(xdg === undefined || xdg === "" ? path.join(os.homedir(), ".config") : xdg, "mend");
|
|
16
|
+
const legacy = path.join(os.homedir(), ".mend");
|
|
17
|
+
return !fs.existsSync(preferred) && fs.existsSync(legacy) ? legacy : preferred;
|
|
18
|
+
};
|
|
19
|
+
const CONFIG_PATH = path.join(mendCliHome(), "cli.json");
|
|
9
20
|
const loadConfig = () => {
|
|
10
21
|
let fileConfig = {};
|
|
11
22
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
@@ -132,18 +143,35 @@ const withSpinner = async (label, work) => {
|
|
|
132
143
|
const adopt = async (config, args) => {
|
|
133
144
|
const nameFlag = args.indexOf("--name");
|
|
134
145
|
const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
|
|
135
|
-
const
|
|
146
|
+
const authFlagIndex = args.indexOf("--auth");
|
|
147
|
+
const positional = args.filter((a, i) => !a.startsWith("--") &&
|
|
148
|
+
(nameFlag === -1 || i !== nameFlag + 1) &&
|
|
149
|
+
(authFlagIndex === -1 || i !== authFlagIndex + 1));
|
|
136
150
|
// Bare `mend adopt` adopts the repo the cwd is inside, not the subdirectory.
|
|
137
151
|
const rawSource = positional[0] ?? gitTopLevel(process.cwd()) ?? ".";
|
|
138
152
|
const source = /^(https?|git|ssh):|^git@/.test(rawSource) ? rawSource : path.resolve(rawSource);
|
|
139
153
|
// Derived defaults are normalized ("Mend" → "mend"); explicit --name is sent as typed.
|
|
140
154
|
const projectName = name ?? normalizeProjectName(path.basename(source, ".git"));
|
|
155
|
+
const auth = authFlagIndex !== -1 && args[authFlagIndex + 1] !== undefined
|
|
156
|
+
? String(args[authFlagIndex + 1])
|
|
157
|
+
: null;
|
|
158
|
+
if (auth !== null && auth !== "ambient" && auth !== "mend-key" && auth !== "bridge") {
|
|
159
|
+
return fail(`--auth takes "ambient", "mend-key", or "bridge", not "${auth}"`);
|
|
160
|
+
}
|
|
141
161
|
const project = await api(config, "POST", "/projects", {
|
|
142
162
|
name: projectName,
|
|
143
163
|
source,
|
|
164
|
+
...(auth === null ? {} : { gitAuthMode: auth }),
|
|
144
165
|
});
|
|
145
166
|
say(`${green("✓")} adopted · ${project.name} · ${dim(project.storePath)}`);
|
|
146
167
|
say(`${dim(" default branch")} ${project.defaultBranch}`);
|
|
168
|
+
// Say which signer did the work — the clone already proved it answers.
|
|
169
|
+
if (project.gitAuthMode === "mend-key") {
|
|
170
|
+
say(`${dim(" git auth")} mend key ${dim("(the machine's deploy key signed this clone)")}`);
|
|
171
|
+
}
|
|
172
|
+
else if (project.gitAuthMode === "bridge") {
|
|
173
|
+
say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
|
|
174
|
+
}
|
|
147
175
|
say(`${dim(" sessions start with:")} mend codex ${dim("(from anywhere —")} --project ${project.name}${dim(")")}`);
|
|
148
176
|
};
|
|
149
177
|
// ─── session launch ─────────────────────────────────────────────────────────
|
|
@@ -218,10 +246,15 @@ const launch = async (config, harness, args) => {
|
|
|
218
246
|
* caller decides what each outcome means (commands exit, the dashboard
|
|
219
247
|
* resumes).
|
|
220
248
|
*/
|
|
221
|
-
const attachTty = async (config, sessionId, harness, from) => {
|
|
249
|
+
const attachTty = async (config, sessionId, harness, from, processId, options) => {
|
|
222
250
|
const url = new URL(`${config.url}/api/tty`);
|
|
223
251
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
224
|
-
|
|
252
|
+
// Process addressing reaches any PTY in the workspace (a shell); the
|
|
253
|
+
// session form remains the agent's PTY.
|
|
254
|
+
if (processId !== undefined)
|
|
255
|
+
url.searchParams.set("process", processId);
|
|
256
|
+
else
|
|
257
|
+
url.searchParams.set("session", sessionId);
|
|
225
258
|
url.searchParams.set("from", from.toString());
|
|
226
259
|
if (config.token !== null)
|
|
227
260
|
url.searchParams.set("token", config.token);
|
|
@@ -294,12 +327,16 @@ const attachTty = async (config, sessionId, harness, from) => {
|
|
|
294
327
|
try {
|
|
295
328
|
sendResize();
|
|
296
329
|
process.stdout.on("resize", onWinch);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
330
|
+
// Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
|
|
331
|
+
// socket drops, and the process inside keeps running untouched.
|
|
332
|
+
if (options?.readOnly !== true) {
|
|
333
|
+
if (rawTty) {
|
|
334
|
+
process.stdin.setRawMode(true);
|
|
335
|
+
rawModeEnabled = true;
|
|
336
|
+
}
|
|
337
|
+
process.stdin.resume();
|
|
338
|
+
process.stdin.on("data", onKeys);
|
|
300
339
|
}
|
|
301
|
-
process.stdin.resume();
|
|
302
|
-
process.stdin.on("data", onKeys);
|
|
303
340
|
ws.addEventListener("message", onTtyFrame);
|
|
304
341
|
await finished;
|
|
305
342
|
return detached ? "detached" : "ended";
|
|
@@ -350,6 +387,702 @@ const attach = async (config, args) => {
|
|
|
350
387
|
await attachOrExit(config, match.id, match.harness);
|
|
351
388
|
exitAfterSessionEnd(config, match.id);
|
|
352
389
|
};
|
|
390
|
+
/** Compact picker: numbered live sessions, one keystroke of typing. */
|
|
391
|
+
const pickSessionInteractively = async (rows) => {
|
|
392
|
+
say(dim("more than one live session — pick one:"));
|
|
393
|
+
rows.forEach((row, index) => {
|
|
394
|
+
say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
|
|
395
|
+
});
|
|
396
|
+
const readline = await import("node:readline/promises");
|
|
397
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
398
|
+
const answer = await rl.question(" session #: ");
|
|
399
|
+
rl.close();
|
|
400
|
+
const chosen = rows[Number(answer.trim()) - 1];
|
|
401
|
+
if (chosen === undefined)
|
|
402
|
+
return fail(`"${answer.trim()}" is not one of the choices`);
|
|
403
|
+
return chosen.session;
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* The resolution order (docs/SESSION-SERVICES.md): an explicit id wins, then
|
|
407
|
+
* the cwd's project narrows, one candidate is taken, several go to the
|
|
408
|
+
* picker, and a non-interactive caller never gets a silent guess.
|
|
409
|
+
*/
|
|
410
|
+
const resolveLiveSession = async (config, prefix) => {
|
|
411
|
+
const sessions = await api(config, "GET", "/sessions");
|
|
412
|
+
if (prefix !== undefined) {
|
|
413
|
+
const matches = sessions.filter((s) => s.id.startsWith(prefix));
|
|
414
|
+
if (matches.length === 0)
|
|
415
|
+
return fail(`no live session matches "${prefix}"`);
|
|
416
|
+
const exact = matches[0];
|
|
417
|
+
if (matches.length > 1 || exact === undefined) {
|
|
418
|
+
return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
|
|
419
|
+
}
|
|
420
|
+
return exact;
|
|
421
|
+
}
|
|
422
|
+
const projects = await api(config, "GET", "/projects");
|
|
423
|
+
const project = matchProjectByCwd(projects, process.cwd());
|
|
424
|
+
const candidates = project === undefined ? sessions : sessions.filter((s) => s.projectId === project.id);
|
|
425
|
+
const only = candidates[0];
|
|
426
|
+
if (only === undefined) {
|
|
427
|
+
return fail("no live session — start one with mend codex|claude|opencode");
|
|
428
|
+
}
|
|
429
|
+
if (candidates.length === 1)
|
|
430
|
+
return only;
|
|
431
|
+
if (process.stdin.isTTY !== true) {
|
|
432
|
+
return fail("several live sessions — name one: mend shell <session-id-prefix>");
|
|
433
|
+
}
|
|
434
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
435
|
+
return pickSessionInteractively(candidates.map((session) => ({
|
|
436
|
+
session,
|
|
437
|
+
projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
|
|
438
|
+
})));
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
441
|
+
* A real interactive shell in the session's CURRENT workspace — same
|
|
442
|
+
* /workspace/repo the agent is editing, same dependencies, same network. Not
|
|
443
|
+
* a shell in the host checkout. The shell holds a workspace lease, so the
|
|
444
|
+
* container survives the agent settling while the shell lives.
|
|
445
|
+
*/
|
|
446
|
+
const shellCommand = async (config, args) => {
|
|
447
|
+
const prefix = args.find((a) => !a.startsWith("--"));
|
|
448
|
+
const session = await resolveLiveSession(config, prefix);
|
|
449
|
+
say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
|
|
450
|
+
const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
|
|
451
|
+
say("");
|
|
452
|
+
const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id);
|
|
453
|
+
if (outcome === "unavailable") {
|
|
454
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
455
|
+
}
|
|
456
|
+
say("");
|
|
457
|
+
if (outcome === "detached") {
|
|
458
|
+
say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
|
|
459
|
+
process.exit(0);
|
|
460
|
+
}
|
|
461
|
+
say(`${green("✓")} shell ended`);
|
|
462
|
+
process.exit(0);
|
|
463
|
+
};
|
|
464
|
+
const serviceUrl = (config, service) => {
|
|
465
|
+
const host = new URL(config.url).hostname || "localhost";
|
|
466
|
+
// A UDP Service has no page to open — the endpoint is the whole fact.
|
|
467
|
+
return service.protocol === "udp"
|
|
468
|
+
? `${host}:${service.hostPort ?? "?"} (udp)`
|
|
469
|
+
: `http://${host}:${service.hostPort ?? "?"}`;
|
|
470
|
+
};
|
|
471
|
+
const printService = (config, service) => {
|
|
472
|
+
const status = service.status === "reachable" ? green(service.status) : amber(service.status);
|
|
473
|
+
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))}`);
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* Adopt an already-listening workspace port as a Service: Mend binds a host
|
|
477
|
+
* port on its private interfaces and pumps every connection into the
|
|
478
|
+
* session's workspace. No supervision — reachability is the observation.
|
|
479
|
+
*/
|
|
480
|
+
const serviceAdd = async (config, args) => {
|
|
481
|
+
const nameFlag = args.indexOf("--name");
|
|
482
|
+
const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
|
|
483
|
+
const protocol = args.includes("--udp") ? "udp" : "tcp";
|
|
484
|
+
const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
|
|
485
|
+
const portRaw = positional.find((a) => /^\d+$/.test(a));
|
|
486
|
+
if (portRaw === undefined) {
|
|
487
|
+
return fail("usage: mend service add [session] <port> [--name <n>] [--udp]");
|
|
488
|
+
}
|
|
489
|
+
const port = Number(portRaw);
|
|
490
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
491
|
+
return fail(`"${portRaw}" is not a port`);
|
|
492
|
+
}
|
|
493
|
+
const prefix = positional.find((a) => a !== portRaw);
|
|
494
|
+
const session = await resolveLiveSession(config, prefix);
|
|
495
|
+
const service = await api(config, "POST", `/sessions/${session.id}/services`, {
|
|
496
|
+
port,
|
|
497
|
+
name,
|
|
498
|
+
protocol,
|
|
499
|
+
});
|
|
500
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
501
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
502
|
+
if (protocol === "udp") {
|
|
503
|
+
say(dim(` udp — a reply is the only reachability signal; silence just relays`));
|
|
504
|
+
}
|
|
505
|
+
else if (service.status !== "reachable") {
|
|
506
|
+
say(dim(` nothing answered on :${port} yet — the URL goes live when something listens`));
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const serviceList = async (config) => {
|
|
510
|
+
const services = await api(config, "GET", "/services");
|
|
511
|
+
if (services.length === 0) {
|
|
512
|
+
say(dim("no live services — mend service add <port> adopts a listening one"));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
for (const service of services)
|
|
516
|
+
printService(config, service);
|
|
517
|
+
};
|
|
518
|
+
const serviceStop = async (config, args) => {
|
|
519
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
520
|
+
if (needle === undefined)
|
|
521
|
+
return fail("usage: mend service stop <name-or-id-prefix>");
|
|
522
|
+
const services = await api(config, "GET", "/services");
|
|
523
|
+
const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
524
|
+
if (matches.length === 0)
|
|
525
|
+
return fail(`no live service matches "${needle}"`);
|
|
526
|
+
const match = matches[0];
|
|
527
|
+
if (matches.length > 1 || match === undefined) {
|
|
528
|
+
return fail(`"${needle}" is ambiguous — use more of the id`);
|
|
529
|
+
}
|
|
530
|
+
const stoppedService = await api(config, "POST", `/services/${match.id}/stop`);
|
|
531
|
+
say(`${green("✓")} stopped · ${stoppedService.label ?? stoppedService.id.slice(0, 8)}`);
|
|
532
|
+
};
|
|
533
|
+
const findLiveService = async (config, needle) => {
|
|
534
|
+
const services = await api(config, "GET", "/services");
|
|
535
|
+
const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
536
|
+
if (matches.length === 0)
|
|
537
|
+
return fail(`no live service matches "${needle}"`);
|
|
538
|
+
const match = matches[0];
|
|
539
|
+
if (matches.length > 1 || match === undefined) {
|
|
540
|
+
return fail(`"${needle}" is ambiguous — use more of the id`);
|
|
541
|
+
}
|
|
542
|
+
return match;
|
|
543
|
+
};
|
|
544
|
+
/**
|
|
545
|
+
* Start and supervise a Service: the command runs as its own PTY process in
|
|
546
|
+
* the session's workspace (own record = its logs), Mend waits for the
|
|
547
|
+
* declared port to answer, then exposes it like any Service. The command
|
|
548
|
+
* never occupies the agent's terminal or a tool call.
|
|
549
|
+
*/
|
|
550
|
+
const serviceRun = async (config, args) => {
|
|
551
|
+
const dashdash = args.indexOf("--");
|
|
552
|
+
const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] -- <command...>\n" +
|
|
553
|
+
" mend service run [session] <name> (a declared recipe)";
|
|
554
|
+
// No explicit command = a DECLARED Service: resolve the name against the
|
|
555
|
+
// session worktree's mend.toml and start (or adopt) its recipe.
|
|
556
|
+
if (dashdash === -1) {
|
|
557
|
+
const positionals = args.filter((a) => !a.startsWith("--"));
|
|
558
|
+
const name = positionals.at(-1);
|
|
559
|
+
if (name === undefined)
|
|
560
|
+
return fail(usage);
|
|
561
|
+
const prefix = positionals.length > 1 ? positionals[0] : undefined;
|
|
562
|
+
const session = await resolveLiveSession(config, prefix);
|
|
563
|
+
const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
|
|
564
|
+
const recipe = recipes.find((entry) => entry.name === name);
|
|
565
|
+
if (recipe === undefined) {
|
|
566
|
+
const known = recipes.map((entry) => entry.name).join(", ");
|
|
567
|
+
return fail(recipes.length === 0
|
|
568
|
+
? `no mend.toml recipes in this worktree — declare [service.${name}] first`
|
|
569
|
+
: `no recipe named "${name}" — declared: ${known}`);
|
|
570
|
+
}
|
|
571
|
+
if (recipe.command === null) {
|
|
572
|
+
// A port-only recipe is an adopt: something else starts the listener.
|
|
573
|
+
const service = await api(config, "POST", `/sessions/${session.id}/services`, {
|
|
574
|
+
port: recipe.port,
|
|
575
|
+
name: recipe.name,
|
|
576
|
+
protocol: recipe.protocol,
|
|
577
|
+
});
|
|
578
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
579
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const service = await withSpinner(recipe.protocol === "udp"
|
|
583
|
+
? `starting ${recipe.name} (udp :${recipe.port})…`
|
|
584
|
+
: `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
|
|
585
|
+
argv: ["sh", "-c", recipe.command],
|
|
586
|
+
port: recipe.port,
|
|
587
|
+
name: recipe.name,
|
|
588
|
+
protocol: recipe.protocol,
|
|
589
|
+
}));
|
|
590
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
591
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
592
|
+
say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const argv = args.slice(dashdash + 1);
|
|
596
|
+
if (argv.length === 0)
|
|
597
|
+
return fail(usage);
|
|
598
|
+
const head = args.slice(0, dashdash);
|
|
599
|
+
const portFlag = head.indexOf("--port");
|
|
600
|
+
const port = portFlag !== -1 ? Number(head[portFlag + 1]) : Number.NaN;
|
|
601
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
602
|
+
return fail(usage);
|
|
603
|
+
const nameFlag = head.indexOf("--name");
|
|
604
|
+
const name = nameFlag !== -1 && head[nameFlag + 1] !== undefined ? String(head[nameFlag + 1]) : null;
|
|
605
|
+
const protocol = head.includes("--udp") ? "udp" : "tcp";
|
|
606
|
+
const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
|
|
607
|
+
const session = await resolveLiveSession(config, prefix);
|
|
608
|
+
const service = await withSpinner(protocol === "udp"
|
|
609
|
+
? `starting ${name ?? argv[0]} (udp :${port})…`
|
|
610
|
+
: `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
|
|
611
|
+
argv,
|
|
612
|
+
port,
|
|
613
|
+
name,
|
|
614
|
+
protocol,
|
|
615
|
+
}));
|
|
616
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
617
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
618
|
+
say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
|
|
619
|
+
};
|
|
620
|
+
/**
|
|
621
|
+
* Watch a supervised Service's output: record replay, then live tail. A DEAD
|
|
622
|
+
* Service still answers — its record outlives the process — printed once
|
|
623
|
+
* instead of followed.
|
|
624
|
+
*/
|
|
625
|
+
const serviceLogs = async (config, args) => {
|
|
626
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
627
|
+
if (needle === undefined)
|
|
628
|
+
return fail("usage: mend service logs <name-or-id-prefix>");
|
|
629
|
+
const everything = await api(config, "GET", "/services?all=1");
|
|
630
|
+
const matches = everything.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
631
|
+
if (matches.length === 0)
|
|
632
|
+
return fail(`no service matches "${needle}"`);
|
|
633
|
+
// Prefer the live one; otherwise the newest ended one (list is newest-first).
|
|
634
|
+
const service = matches.find((m) => m.status !== "exited" && m.status !== "stopped") ?? matches[0];
|
|
635
|
+
if (service === undefined)
|
|
636
|
+
return fail(`no service matches "${needle}"`);
|
|
637
|
+
if (service.sealantSessionId === null) {
|
|
638
|
+
return fail(`"${needle}" is an adopted port — no process of Mend's, no logs. mend service run supervises.`);
|
|
639
|
+
}
|
|
640
|
+
if (service.status === "exited" || service.status === "stopped") {
|
|
641
|
+
// Post-mortem: print the record, don't attach.
|
|
642
|
+
const output = await api(config, "GET", `/processes/${service.id}/output`);
|
|
643
|
+
say(dim(`${service.label ?? service.id.slice(0, 8)} · ${service.status} — recorded output:`));
|
|
644
|
+
say("");
|
|
645
|
+
process.stdout.write(output.text.endsWith("\n") ? output.text : `${output.text}\n`);
|
|
646
|
+
process.exit(0);
|
|
647
|
+
}
|
|
648
|
+
say(dim(`following ${service.label ?? service.id.slice(0, 8)} — Ctrl+C detaches, the service keeps running`));
|
|
649
|
+
say("");
|
|
650
|
+
const outcome = await attachTty(config, service.sessionId, "service", 0n, service.id, {
|
|
651
|
+
readOnly: true,
|
|
652
|
+
});
|
|
653
|
+
if (outcome === "unavailable") {
|
|
654
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
655
|
+
}
|
|
656
|
+
say("");
|
|
657
|
+
say(dim("stream ended"));
|
|
658
|
+
process.exit(0);
|
|
659
|
+
};
|
|
660
|
+
const serviceRestart = async (config, args) => {
|
|
661
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
662
|
+
if (needle === undefined)
|
|
663
|
+
return fail("usage: mend service restart <name-or-id-prefix>");
|
|
664
|
+
const service = await findLiveService(config, needle);
|
|
665
|
+
const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, api(config, "POST", `/services/${service.id}/restart`));
|
|
666
|
+
say(`${green("✓")} restarted · ${restarted.status}`);
|
|
667
|
+
say(` ${cobalt(serviceUrl(config, restarted))}`);
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* Scaffold mend.toml from the project's own manifests. Static suggestion,
|
|
671
|
+
* not detection: package.json scripts and compose port mappings become
|
|
672
|
+
* recipe proposals the user confirms and commits. Nothing runs.
|
|
673
|
+
*/
|
|
674
|
+
const serviceInit = async (args) => {
|
|
675
|
+
const root = gitTopLevel(process.cwd()) ?? process.cwd();
|
|
676
|
+
const target = path.join(root, "mend.toml");
|
|
677
|
+
if (fs.existsSync(target)) {
|
|
678
|
+
return fail(`${target} already exists — edit it directly (init never merges)`);
|
|
679
|
+
}
|
|
680
|
+
const rootFiles = fs.readdirSync(root);
|
|
681
|
+
const proposals = [];
|
|
682
|
+
const readRoot = (name) => fs.readFileSync(path.join(root, name), "utf8");
|
|
683
|
+
if (rootFiles.includes("package.json")) {
|
|
684
|
+
proposals.push(...proposeFromPackageJson(readRoot("package.json"), rootFiles));
|
|
685
|
+
}
|
|
686
|
+
// Monorepo sweep: every workspace package, named after its folder.
|
|
687
|
+
const globs = workspaceGlobs(rootFiles.includes("pnpm-workspace.yaml") ? readRoot("pnpm-workspace.yaml") : null, rootFiles.includes("package.json") ? readRoot("package.json") : null);
|
|
688
|
+
for (const glob of globs) {
|
|
689
|
+
const parent = glob.replace(/\/?\*+$/, "");
|
|
690
|
+
const dirs = glob.endsWith("*")
|
|
691
|
+
? fs.existsSync(path.join(root, parent))
|
|
692
|
+
? fs.readdirSync(path.join(root, parent)).map((dir) => path.join(parent, dir))
|
|
693
|
+
: []
|
|
694
|
+
: [glob];
|
|
695
|
+
for (const dir of dirs) {
|
|
696
|
+
const manifest = path.join(root, dir, "package.json");
|
|
697
|
+
if (!fs.existsSync(manifest))
|
|
698
|
+
continue;
|
|
699
|
+
for (const proposal of proposeFromWorkspacePackage(path.basename(dir), fs.readFileSync(manifest, "utf8"), rootFiles)) {
|
|
700
|
+
if (!proposals.some((existing) => existing.name === proposal.name)) {
|
|
701
|
+
proposals.push(proposal);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
// Every compose flavor in the root, aggregated; non-default files need -f.
|
|
707
|
+
for (const composeName of rootFiles.filter(isComposeFile).toSorted()) {
|
|
708
|
+
const isDefault = composeName === "compose.yaml" || composeName === "docker-compose.yml";
|
|
709
|
+
for (const proposal of proposeFromCompose(readRoot(composeName))) {
|
|
710
|
+
if (!proposals.some((existing) => existing.name === proposal.name)) {
|
|
711
|
+
proposals.push({
|
|
712
|
+
...proposal,
|
|
713
|
+
command: isDefault
|
|
714
|
+
? proposal.command
|
|
715
|
+
: proposal.command.replace("docker compose ", `docker compose -f ${composeName} `),
|
|
716
|
+
source: `${composeName}: ${proposal.source}`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (proposals.length === 0) {
|
|
722
|
+
return fail("nothing to propose — no server-ish package.json script with a nameable port, no compose ports");
|
|
723
|
+
}
|
|
724
|
+
const toml = renderMendToml(proposals);
|
|
725
|
+
say(dim(`proposed ${target}:`));
|
|
726
|
+
say("");
|
|
727
|
+
process.stdout.write(toml);
|
|
728
|
+
say("");
|
|
729
|
+
if (!args.includes("--yes")) {
|
|
730
|
+
if (process.stdin.isTTY !== true) {
|
|
731
|
+
return fail("non-interactive — pass --yes to write the file");
|
|
732
|
+
}
|
|
733
|
+
const readline = await import("node:readline/promises");
|
|
734
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
735
|
+
const answer = await rl.question("write it? [y/N] ");
|
|
736
|
+
rl.close();
|
|
737
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
738
|
+
say(dim("nothing written"));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
fs.writeFileSync(target, toml);
|
|
743
|
+
say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
|
|
744
|
+
};
|
|
745
|
+
const serviceCommand = async (config, args) => {
|
|
746
|
+
const [verb, ...rest] = args;
|
|
747
|
+
switch (verb) {
|
|
748
|
+
case "run":
|
|
749
|
+
return serviceRun(config, rest);
|
|
750
|
+
case "add":
|
|
751
|
+
return serviceAdd(config, rest);
|
|
752
|
+
case "init":
|
|
753
|
+
return serviceInit(rest);
|
|
754
|
+
case "list":
|
|
755
|
+
case undefined:
|
|
756
|
+
return serviceList(config);
|
|
757
|
+
case "logs":
|
|
758
|
+
return serviceLogs(config, rest);
|
|
759
|
+
case "restart":
|
|
760
|
+
return serviceRestart(config, rest);
|
|
761
|
+
case "stop":
|
|
762
|
+
return serviceStop(config, rest);
|
|
763
|
+
default:
|
|
764
|
+
// Sugar: `mend service mysql` reads as `mend service run mysql` — a
|
|
765
|
+
// bare word that isn't a verb is a recipe name. A miss still explains
|
|
766
|
+
// itself (declared recipes are listed in the failure).
|
|
767
|
+
return serviceRun(config, [verb, ...rest]);
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
// ─── keys: the machine's Mend deploy key (docs/GIT-ACCESS.md) ───────────────
|
|
771
|
+
/** Print the public key with the one instruction that makes it useful. */
|
|
772
|
+
const printGitKey = (key) => {
|
|
773
|
+
if (key.publicKey === null)
|
|
774
|
+
return;
|
|
775
|
+
say(key.publicKey);
|
|
776
|
+
if (key.fingerprint !== null)
|
|
777
|
+
say(dim(` ${key.fingerprint}`));
|
|
778
|
+
say(dim(" add this as a deploy key on your git host (GitHub/GitLab/Gitea: repo"));
|
|
779
|
+
say(dim(" settings → deploy keys; grant write access if sessions should push),"));
|
|
780
|
+
say(dim(" then adopt with: mend adopt <ssh-url> --auth mend-key"));
|
|
781
|
+
};
|
|
782
|
+
const keysShow = async (config) => {
|
|
783
|
+
const key = await api(config, "GET", "/keys/git");
|
|
784
|
+
if (!key.exists) {
|
|
785
|
+
say(`no Mend key yet ${dim("— mend keys init generates one (ed25519, stays on the server host)")}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
printGitKey(key);
|
|
789
|
+
};
|
|
790
|
+
const keysInit = async (config) => {
|
|
791
|
+
const key = await api(config, "POST", "/keys/git");
|
|
792
|
+
say(`${green("✓")} Mend key ready ${dim("(private half stays on the server host)")}`);
|
|
793
|
+
printGitKey(key);
|
|
794
|
+
};
|
|
795
|
+
/**
|
|
796
|
+
* The ssh-agent bridge, client half (docs/GIT-ACCESS.md decision 2): relay
|
|
797
|
+
* the LOCAL ssh-agent to the Mend server over one standing WebSocket, so
|
|
798
|
+
* bridge-mode git ops sign with a key that never leaves this machine — a
|
|
799
|
+
* hardware key blinks here, not on the server. Agent-protocol messages are
|
|
800
|
+
* relayed verbatim (length-prefixed frames, base64 over JSON); the only
|
|
801
|
+
* inspection is each message's type byte, to say what is being asked.
|
|
802
|
+
*/
|
|
803
|
+
const keysShare = async (config) => {
|
|
804
|
+
const agentSock = process.env["SSH_AUTH_SOCK"];
|
|
805
|
+
if (agentSock === undefined || agentSock === "") {
|
|
806
|
+
return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
|
|
807
|
+
}
|
|
808
|
+
const url = new URL(`${config.url}/api/keys/bridge/ws`);
|
|
809
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
810
|
+
url.searchParams.set("host", os.hostname());
|
|
811
|
+
if (config.token !== null)
|
|
812
|
+
url.searchParams.set("token", config.token);
|
|
813
|
+
/**
|
|
814
|
+
* One complete agent exchange against the local agent: fresh connection,
|
|
815
|
+
* write the framed request verbatim, read one framed response. A hardware
|
|
816
|
+
* key blocks here until the touch — hence the generous timeout.
|
|
817
|
+
*/
|
|
818
|
+
const askLocalAgent = (payload) => new Promise((resolve, reject) => {
|
|
819
|
+
const connection = net.connect(agentSock, () => {
|
|
820
|
+
connection.write(payload);
|
|
821
|
+
});
|
|
822
|
+
const timer = setTimeout(() => {
|
|
823
|
+
connection.destroy();
|
|
824
|
+
reject(new Error("the agent did not answer within 60s — touch missed?"));
|
|
825
|
+
}, 60_000);
|
|
826
|
+
let pending = Buffer.alloc(0);
|
|
827
|
+
connection.on("data", (chunk) => {
|
|
828
|
+
pending = Buffer.concat([pending, chunk]);
|
|
829
|
+
if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
|
|
830
|
+
clearTimeout(timer);
|
|
831
|
+
connection.end();
|
|
832
|
+
resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
connection.on("error", (error) => {
|
|
836
|
+
clearTimeout(timer);
|
|
837
|
+
reject(error);
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
// Strictly one request at a time, in arrival order — the agent protocol is
|
|
841
|
+
// request/response and a hardware key cannot answer two touches at once.
|
|
842
|
+
let chain = Promise.resolve();
|
|
843
|
+
const SSH_AGENTC_SIGN_REQUEST = 13;
|
|
844
|
+
const SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
845
|
+
let attempt = 0;
|
|
846
|
+
for (;;) {
|
|
847
|
+
const ws = new WebSocket(url);
|
|
848
|
+
const closed = new Promise((resolve) => {
|
|
849
|
+
ws.addEventListener("close", () => resolve(), { once: true });
|
|
850
|
+
});
|
|
851
|
+
const opened = await new Promise((resolve) => {
|
|
852
|
+
ws.addEventListener("open", () => resolve(true), { once: true });
|
|
853
|
+
ws.addEventListener("error", () => resolve(false), { once: true });
|
|
854
|
+
});
|
|
855
|
+
if (opened) {
|
|
856
|
+
attempt = 0;
|
|
857
|
+
say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
|
|
858
|
+
say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
|
|
859
|
+
ws.addEventListener("message", (event) => {
|
|
860
|
+
const text = typeof event.data === "string"
|
|
861
|
+
? event.data
|
|
862
|
+
: Buffer.from(event.data).toString("utf8");
|
|
863
|
+
let frame;
|
|
864
|
+
try {
|
|
865
|
+
frame = JSON.parse(text);
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (frame.t !== "req" ||
|
|
871
|
+
typeof frame.id !== "number" ||
|
|
872
|
+
typeof frame.payload !== "string") {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const id = frame.id;
|
|
876
|
+
const payload = Buffer.from(frame.payload, "base64");
|
|
877
|
+
const type = payload[4];
|
|
878
|
+
const context = typeof frame.context === "string" ? frame.context : "mend";
|
|
879
|
+
chain = chain.then(async () => {
|
|
880
|
+
const started = Date.now();
|
|
881
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
882
|
+
say(`${amber("✎")} signature requested by mend ${dim(`(${context})`)}`);
|
|
883
|
+
say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
|
|
884
|
+
}
|
|
885
|
+
else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
|
|
886
|
+
say(dim(` identities requested (${context})`));
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
const response = await askLocalAgent(payload);
|
|
890
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
891
|
+
return null;
|
|
892
|
+
ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
|
|
893
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
894
|
+
say(`${green("✓")} signed ${dim(`(${((Date.now() - started) / 1000).toFixed(1)}s)`)}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
899
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
900
|
+
ws.send(JSON.stringify({ t: "err", id, message }));
|
|
901
|
+
}
|
|
902
|
+
if (type === SSH_AGENTC_SIGN_REQUEST)
|
|
903
|
+
say(`${amber("✗")} not signed — ${message}`);
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
await closed;
|
|
910
|
+
attempt += 1;
|
|
911
|
+
const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt - 1, 5));
|
|
912
|
+
say(dim(`not connected — retrying in ${Math.round(delay / 1000)}s (Ctrl-C stops)`));
|
|
913
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
const keysCommand = async (config, args) => {
|
|
917
|
+
const [verb] = args;
|
|
918
|
+
switch (verb) {
|
|
919
|
+
case "init":
|
|
920
|
+
return keysInit(config);
|
|
921
|
+
case "share":
|
|
922
|
+
return keysShare(config);
|
|
923
|
+
case "show":
|
|
924
|
+
case undefined:
|
|
925
|
+
return keysShow(config);
|
|
926
|
+
default:
|
|
927
|
+
return fail(`unknown keys command "${verb}" — try: mend keys init | show | share`);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
const formatDotfileBytes = (bytes) => bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
931
|
+
const dotfilesShow = async (config) => {
|
|
932
|
+
const dotfiles = await api(config, "GET", "/dotfiles");
|
|
933
|
+
if (dotfiles.repository === null) {
|
|
934
|
+
say(`repo ${dim("none")}`);
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
const branch = dotfiles.repository.ref ?? "default branch";
|
|
938
|
+
const bootstrap = dotfiles.repository.bootstrap ? "install.sh on" : "install.sh off";
|
|
939
|
+
say(`repo ${dotfiles.repository.url} ${dim(`(${branch} · ${bootstrap})`)}`);
|
|
940
|
+
}
|
|
941
|
+
if (dotfiles.snapshot === null) {
|
|
942
|
+
say(`snapshot ${dim("none — sync from this machine: mend dotfiles sync --all")}`);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const snapshot = dotfiles.snapshot;
|
|
946
|
+
say(`snapshot ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} · from ${snapshot.source} · ${dim(snapshot.sha.slice(0, 7))}`);
|
|
947
|
+
for (const file of snapshot.files) {
|
|
948
|
+
say(` ${file.path.padEnd(36)} ${dim(formatDotfileBytes(file.bytes))}`);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
/**
|
|
952
|
+
* `mend dotfiles sync` — capture home files ON THIS MACHINE and stream them into the server's
|
|
953
|
+
* per-user dotfiles store. This is the whole point of the store: the server may be a VPS whose
|
|
954
|
+
* home directory belongs to a service account, so contents are read here, where they live.
|
|
955
|
+
*/
|
|
956
|
+
const dotfilesSync = async (config, args) => {
|
|
957
|
+
const all = args.includes("--all");
|
|
958
|
+
const requestedPaths = args.filter((arg) => !arg.startsWith("--"));
|
|
959
|
+
const home = os.homedir();
|
|
960
|
+
if (!all && requestedPaths.length === 0) {
|
|
961
|
+
const found = scanDotfileCandidates(home);
|
|
962
|
+
if (found.length === 0) {
|
|
963
|
+
say(dim("no known config files found under ~"));
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
let group = "";
|
|
967
|
+
for (const entry of found) {
|
|
968
|
+
if (entry.group !== group) {
|
|
969
|
+
group = entry.group;
|
|
970
|
+
say(dim(group));
|
|
971
|
+
}
|
|
972
|
+
say(` ${entry.path.padEnd(36)} ${dim(formatDotfileBytes(entry.bytes))}`);
|
|
973
|
+
}
|
|
974
|
+
say("");
|
|
975
|
+
say(`sync everything with ${cobalt("mend dotfiles sync --all")}, or pick: ${cobalt("mend dotfiles sync .zshrc .gitconfig")}`);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const selected = all ? scanDotfileCandidates(home).map((entry) => entry.path) : requestedPaths;
|
|
979
|
+
if (selected.length === 0)
|
|
980
|
+
return fail("nothing to sync — no known config files found under ~");
|
|
981
|
+
const read = readSyncFiles(home, selected);
|
|
982
|
+
if ("error" in read)
|
|
983
|
+
return fail(read.error);
|
|
984
|
+
const result = await api(config, "POST", "/dotfiles/snapshot", {
|
|
985
|
+
files: read.files,
|
|
986
|
+
source: os.hostname(),
|
|
987
|
+
merge: false,
|
|
988
|
+
});
|
|
989
|
+
const snapshot = result.snapshot;
|
|
990
|
+
if (snapshot === null)
|
|
991
|
+
return fail("the server accepted the sync but reports no snapshot");
|
|
992
|
+
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")}`);
|
|
993
|
+
};
|
|
994
|
+
const dotfilesCommand = async (config, args) => {
|
|
995
|
+
const [verb, ...rest] = args;
|
|
996
|
+
switch (verb) {
|
|
997
|
+
case "sync":
|
|
998
|
+
return dotfilesSync(config, rest);
|
|
999
|
+
case "show":
|
|
1000
|
+
case undefined:
|
|
1001
|
+
return dotfilesShow(config);
|
|
1002
|
+
default:
|
|
1003
|
+
return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
// ─── completions: live sessions under TAB ───────────────────────────────────
|
|
1007
|
+
/**
|
|
1008
|
+
* The data half of shell completion: one live session per line as
|
|
1009
|
+
* `id<TAB>description`. Scripts adapt the shape (zsh wants `id:desc`, bash
|
|
1010
|
+
* wants bare ids). Never fails — a dead server just completes nothing.
|
|
1011
|
+
*/
|
|
1012
|
+
const completeCommand = async (config, args) => {
|
|
1013
|
+
if (args[0] !== "session")
|
|
1014
|
+
return;
|
|
1015
|
+
try {
|
|
1016
|
+
const [sessions, projects] = await Promise.all([
|
|
1017
|
+
request(config, "GET", "/sessions"),
|
|
1018
|
+
request(config, "GET", "/projects"),
|
|
1019
|
+
]);
|
|
1020
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
1021
|
+
for (const session of sessions) {
|
|
1022
|
+
const project = nameById.get(session.projectId) ?? "";
|
|
1023
|
+
process.stdout.write(`${session.id}\t${session.harness} · ${project} · ${session.branch}\n`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// Completion must never surface an error into the user's TAB press.
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
const ZSH_COMPLETIONS = `#compdef mend
|
|
1031
|
+
_mend() {
|
|
1032
|
+
local -a commands
|
|
1033
|
+
commands=(
|
|
1034
|
+
'adopt:adopt a repository into the store'
|
|
1035
|
+
'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
|
|
1036
|
+
'run:new session + arbitrary command'
|
|
1037
|
+
'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
|
|
1038
|
+
'service:reachable ports — add, list, stop'
|
|
1039
|
+
'keys:the machine Mend deploy key — init, show, share'
|
|
1040
|
+
'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
|
|
1041
|
+
'rejoin:attach if live, otherwise resume'
|
|
1042
|
+
'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
|
|
1043
|
+
'ui:the dashboard' 'help:help'
|
|
1044
|
+
)
|
|
1045
|
+
if (( CURRENT == 2 )); then
|
|
1046
|
+
_describe 'command' commands
|
|
1047
|
+
return
|
|
1048
|
+
fi
|
|
1049
|
+
case $words[2] in
|
|
1050
|
+
shell|attach|continue|resume|rejoin)
|
|
1051
|
+
local -a sessions
|
|
1052
|
+
sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
|
|
1053
|
+
(( \${#sessions} )) && _describe 'session' sessions
|
|
1054
|
+
;;
|
|
1055
|
+
esac
|
|
1056
|
+
}
|
|
1057
|
+
_mend "$@"
|
|
1058
|
+
`;
|
|
1059
|
+
const BASH_COMPLETIONS = `_mend() {
|
|
1060
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1061
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1062
|
+
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys continue resume rejoin projects sessions status ui help" -- "$cur") )
|
|
1063
|
+
return
|
|
1064
|
+
fi
|
|
1065
|
+
case \${COMP_WORDS[1]} in
|
|
1066
|
+
shell|attach|continue|resume|rejoin)
|
|
1067
|
+
COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
|
|
1068
|
+
;;
|
|
1069
|
+
esac
|
|
1070
|
+
}
|
|
1071
|
+
complete -F _mend mend
|
|
1072
|
+
`;
|
|
1073
|
+
/** Print the hook for the named shell; the user wires it into their rc file. */
|
|
1074
|
+
const completionsCommand = (args) => {
|
|
1075
|
+
switch (args[0]) {
|
|
1076
|
+
case "zsh":
|
|
1077
|
+
process.stdout.write(ZSH_COMPLETIONS);
|
|
1078
|
+
return;
|
|
1079
|
+
case "bash":
|
|
1080
|
+
process.stdout.write(BASH_COMPLETIONS);
|
|
1081
|
+
return;
|
|
1082
|
+
default:
|
|
1083
|
+
return fail('usage: mend completions zsh|bash — e.g. mend completions zsh > "$fpath[1]/_mend"');
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
353
1086
|
// ─── supervised run: platform workspace + PTY + record ──────────────────────
|
|
354
1087
|
const supervisedRun = async (config, session, argv) => {
|
|
355
1088
|
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 +1464,30 @@ const dashboard = async (config) => {
|
|
|
731
1464
|
const HELP = `mend — the agent workbench
|
|
732
1465
|
|
|
733
1466
|
mend the dashboard: every project and session, live
|
|
734
|
-
mend adopt [source] [--name <name>]
|
|
1467
|
+
mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
|
|
1468
|
+
adopt a repository into the store (default: cwd; any git
|
|
1469
|
+
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
1470
|
+
mend keys init generate the machine's Mend deploy key (ed25519)
|
|
1471
|
+
mend keys show print the public key — add it as a deploy key on your git host
|
|
1472
|
+
mend dotfiles your dotfiles on the server: repo + synced home files
|
|
1473
|
+
mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
|
|
1474
|
+
mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
|
|
1475
|
+
hardware keys sign here; Ctrl-C stops sharing)
|
|
735
1476
|
mend codex|claude|opencode new session worktree + launch the harness in it
|
|
736
1477
|
mend run -- <command...> same, with an arbitrary command
|
|
737
1478
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
1479
|
+
mend shell [session-id-prefix] open a shell in a live session's workspace
|
|
1480
|
+
mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
|
|
1481
|
+
start + supervise a server in the session workspace
|
|
1482
|
+
mend service run [session] <name> start a declared Service (mend.toml recipe)
|
|
1483
|
+
mend service <name> shorthand for the above
|
|
1484
|
+
mend service init [--yes] scaffold mend.toml from package.json + compose ports
|
|
1485
|
+
mend service add [session] <port> [--name <n>] [--udp]
|
|
1486
|
+
adopt a listening workspace port — reachable on this machine
|
|
1487
|
+
mend service list every live service and its observed state
|
|
1488
|
+
mend service logs <name-or-id> follow a supervised service's output (replay, then live)
|
|
1489
|
+
mend service restart <name-or-id> re-run its recorded command — same URL
|
|
1490
|
+
mend service stop <name-or-id> stop a service (closes its host port)
|
|
738
1491
|
mend continue [session-id] resume a session with its pending review follow-up
|
|
739
1492
|
mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
|
|
740
1493
|
mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
|
|
@@ -742,10 +1495,11 @@ const HELP = `mend — the agent workbench
|
|
|
742
1495
|
mend sessions [--all] [--project p] [--json]
|
|
743
1496
|
sessions with review facts; JSON is stable for integrations
|
|
744
1497
|
mend status active sessions (alias of mend sessions)
|
|
1498
|
+
mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
|
|
745
1499
|
|
|
746
1500
|
server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
|
|
747
1501
|
detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
|
|
748
|
-
config file: ~/.mend/cli.json { "url": ..., "token": ... }
|
|
1502
|
+
config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
|
|
749
1503
|
`;
|
|
750
1504
|
const main = async () => {
|
|
751
1505
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -760,6 +1514,18 @@ const main = async () => {
|
|
|
760
1514
|
return launch(config, command, rest);
|
|
761
1515
|
case "attach":
|
|
762
1516
|
return attach(config, rest);
|
|
1517
|
+
case "shell":
|
|
1518
|
+
return shellCommand(config, rest);
|
|
1519
|
+
case "service":
|
|
1520
|
+
return serviceCommand(config, rest);
|
|
1521
|
+
case "keys":
|
|
1522
|
+
return keysCommand(config, rest);
|
|
1523
|
+
case "dotfiles":
|
|
1524
|
+
return dotfilesCommand(config, rest);
|
|
1525
|
+
case "completions":
|
|
1526
|
+
return completionsCommand(rest);
|
|
1527
|
+
case "__complete":
|
|
1528
|
+
return completeCommand(config, rest);
|
|
763
1529
|
case "continue":
|
|
764
1530
|
return continueSession(config, rest);
|
|
765
1531
|
case "resume":
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mend service init` — the mend.toml scaffolder (docs/SESSION-SERVICES.md).
|
|
3
|
+
* Static suggestion, not detection: an offline read of manifests the project
|
|
4
|
+
* already ships (package.json scripts, compose files), producing a proposal
|
|
5
|
+
* the user confirms and commits. Nothing runs; nothing is observed.
|
|
6
|
+
*
|
|
7
|
+
* Pure functions here; the command in main.ts does the I/O.
|
|
8
|
+
*/
|
|
9
|
+
/** Scripts that mean "a server" in practice; dev first, the rest as extras. */
|
|
10
|
+
const SERVER_SCRIPTS = ["dev", "start", "serve", "preview"];
|
|
11
|
+
/** Tool defaults for when the script names no port. */
|
|
12
|
+
const TOOL_PORTS = [
|
|
13
|
+
[/\bvite\b(?!.*build)/, 5173],
|
|
14
|
+
[/\bnext dev\b/, 3000],
|
|
15
|
+
[/\bnuxt\b/, 3000],
|
|
16
|
+
[/\bastro dev\b/, 4321],
|
|
17
|
+
[/\bremix\b/, 3000],
|
|
18
|
+
];
|
|
19
|
+
const portFromScript = (script) => {
|
|
20
|
+
const explicit = /(?:--port[= ]|(?<![\w-])-p[= ])(\d{2,5})/.exec(script) ?? /\bPORT=(\d{2,5})/.exec(script);
|
|
21
|
+
if (explicit?.[1] !== undefined) {
|
|
22
|
+
const port = Number(explicit[1]);
|
|
23
|
+
if (port >= 1 && port <= 65535)
|
|
24
|
+
return { port, guessed: false };
|
|
25
|
+
}
|
|
26
|
+
for (const [pattern, port] of TOOL_PORTS) {
|
|
27
|
+
if (pattern.test(script))
|
|
28
|
+
return { port, guessed: true };
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
const packageManagerOf = (lockfiles) => {
|
|
33
|
+
if (lockfiles.includes("pnpm-lock.yaml"))
|
|
34
|
+
return "pnpm";
|
|
35
|
+
if (lockfiles.includes("yarn.lock"))
|
|
36
|
+
return "yarn";
|
|
37
|
+
if (lockfiles.includes("bun.lockb") || lockfiles.includes("bun.lock"))
|
|
38
|
+
return "bun";
|
|
39
|
+
return "npm";
|
|
40
|
+
};
|
|
41
|
+
/** Proposals from package.json scripts. `files` = names present in the repo root. */
|
|
42
|
+
export const proposeFromPackageJson = (packageJsonText, rootFiles) => {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(packageJsonText);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
51
|
+
return [];
|
|
52
|
+
const scripts = parsed.scripts;
|
|
53
|
+
if (typeof scripts !== "object" || scripts === null)
|
|
54
|
+
return [];
|
|
55
|
+
const pm = packageManagerOf(rootFiles);
|
|
56
|
+
const proposals = [];
|
|
57
|
+
for (const scriptName of SERVER_SCRIPTS) {
|
|
58
|
+
const script = scripts[scriptName];
|
|
59
|
+
if (typeof script !== "string")
|
|
60
|
+
continue;
|
|
61
|
+
const port = portFromScript(script);
|
|
62
|
+
if (port === null)
|
|
63
|
+
continue; // no port, no Service — the user can add one by hand
|
|
64
|
+
proposals.push({
|
|
65
|
+
name: scriptName === "dev" ? "web" : scriptName,
|
|
66
|
+
command: `${pm} run ${scriptName}`,
|
|
67
|
+
port: port.port,
|
|
68
|
+
protocol: "tcp",
|
|
69
|
+
guessed: port.guessed,
|
|
70
|
+
source: `package.json "${scriptName}": ${script}`,
|
|
71
|
+
});
|
|
72
|
+
break; // one entry from scripts — the first server-ish one wins
|
|
73
|
+
}
|
|
74
|
+
return proposals;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Proposals from a compose file: every service with a published port becomes
|
|
78
|
+
* `docker compose up <name>` at the HOST side of the first mapping — that is
|
|
79
|
+
* where the workspace's docker sidecar publishes it.
|
|
80
|
+
*/
|
|
81
|
+
export const proposeFromCompose = (composeText) => {
|
|
82
|
+
// Deliberately narrow YAML reading: top-level `services:`, two-space
|
|
83
|
+
// indented names, `ports:` entries as "- host:container". A full YAML
|
|
84
|
+
// parser is not worth a dependency for a suggestion.
|
|
85
|
+
const proposals = [];
|
|
86
|
+
const lines = composeText.split("\n");
|
|
87
|
+
let inServices = false;
|
|
88
|
+
let current = null;
|
|
89
|
+
let inPorts = false;
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
if (/^services:\s*$/.test(line)) {
|
|
92
|
+
inServices = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (inServices && /^\S/.test(line)) {
|
|
96
|
+
inServices = false; // left the services block
|
|
97
|
+
}
|
|
98
|
+
if (!inServices)
|
|
99
|
+
continue;
|
|
100
|
+
const serviceHead = /^ {2}([A-Za-z0-9._-]+):\s*$/.exec(line);
|
|
101
|
+
if (serviceHead?.[1] !== undefined) {
|
|
102
|
+
current = serviceHead[1];
|
|
103
|
+
inPorts = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (current === null)
|
|
107
|
+
continue;
|
|
108
|
+
if (/^\s+ports:\s*$/.test(line)) {
|
|
109
|
+
inPorts = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (inPorts) {
|
|
113
|
+
const mapping = /^\s+-\s+["']?(\d{2,5}):/.exec(line) ??
|
|
114
|
+
/^\s+-\s+["']?\$\{[A-Z_a-z0-9]+:-(\d{2,5})\}:/.exec(line) ??
|
|
115
|
+
/^\s+-\s+["']?(\d{2,5})["']?\s*$/.exec(line);
|
|
116
|
+
if (mapping?.[1] !== undefined) {
|
|
117
|
+
const port = Number(mapping[1]);
|
|
118
|
+
const protocol = /\/udp["']?\s*$/.test(line) ? "udp" : "tcp";
|
|
119
|
+
if (port >= 1 && port <= 65535 && !proposals.some((p) => p.name === current)) {
|
|
120
|
+
proposals.push({
|
|
121
|
+
name: current,
|
|
122
|
+
command: `docker compose up ${current}`,
|
|
123
|
+
port,
|
|
124
|
+
protocol,
|
|
125
|
+
guessed: false,
|
|
126
|
+
source: `compose service "${current}" publishes :${mapping[1]}${protocol === "udp" ? "/udp" : ""}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
inPorts = false; // first mapping wins
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!/^\s+-/.test(line))
|
|
133
|
+
inPorts = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return proposals;
|
|
137
|
+
};
|
|
138
|
+
/** Any compose flavor counts: compose.yaml, docker-compose.yml, compose.dev.yaml… */
|
|
139
|
+
export const isComposeFile = (name) => /^(docker-)?compose(\.[\w.-]+)?\.ya?ml$/.test(name);
|
|
140
|
+
/** Workspace dir globs from pnpm-workspace.yaml / package.json "workspaces". Single-level only. */
|
|
141
|
+
export const workspaceGlobs = (pnpmWorkspaceText, packageJsonText) => {
|
|
142
|
+
const globs = [];
|
|
143
|
+
if (pnpmWorkspaceText !== null) {
|
|
144
|
+
let inPackages = false;
|
|
145
|
+
for (const line of pnpmWorkspaceText.split("\n")) {
|
|
146
|
+
if (/^packages:\s*$/.test(line)) {
|
|
147
|
+
inPackages = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (inPackages && /^\S/.test(line))
|
|
151
|
+
inPackages = false;
|
|
152
|
+
if (!inPackages)
|
|
153
|
+
continue;
|
|
154
|
+
const entry = /^\s+-\s+["']?([^"'#]+?)["']?\s*$/.exec(line);
|
|
155
|
+
if (entry?.[1] !== undefined && !entry[1].startsWith("!"))
|
|
156
|
+
globs.push(entry[1].trim());
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (packageJsonText !== null) {
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(packageJsonText);
|
|
162
|
+
if (Array.isArray(parsed.workspaces)) {
|
|
163
|
+
globs.push(...parsed.workspaces.filter((g) => typeof g === "string"));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// no workspaces to read
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return globs;
|
|
171
|
+
};
|
|
172
|
+
/** A workspace package's proposal: named after its folder, run through the package manager. */
|
|
173
|
+
export const proposeFromWorkspacePackage = (dirName, packageJsonText, rootFiles) => {
|
|
174
|
+
let parsed;
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(packageJsonText);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
182
|
+
return [];
|
|
183
|
+
const pkg = parsed;
|
|
184
|
+
const scripts = pkg.scripts;
|
|
185
|
+
if (typeof scripts !== "object" || scripts === null)
|
|
186
|
+
return [];
|
|
187
|
+
const pkgName = typeof pkg.name === "string" ? pkg.name : dirName;
|
|
188
|
+
const pm = packageManagerOf(rootFiles);
|
|
189
|
+
const runner = pm === "pnpm"
|
|
190
|
+
? (script) => `pnpm --filter ${pkgName} ${script}`
|
|
191
|
+
: pm === "yarn"
|
|
192
|
+
? (script) => `yarn workspace ${pkgName} ${script}`
|
|
193
|
+
: (script) => `${pm} run ${script} --workspace ${pkgName}`;
|
|
194
|
+
for (const scriptName of SERVER_SCRIPTS) {
|
|
195
|
+
const script = scripts[scriptName];
|
|
196
|
+
if (typeof script !== "string")
|
|
197
|
+
continue;
|
|
198
|
+
const port = portFromScript(script);
|
|
199
|
+
if (port === null)
|
|
200
|
+
continue;
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
name: dirName,
|
|
204
|
+
command: runner(scriptName),
|
|
205
|
+
port: port.port,
|
|
206
|
+
protocol: "tcp",
|
|
207
|
+
guessed: port.guessed,
|
|
208
|
+
source: `${dirName}/package.json "${scriptName}": ${script}`,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
return [];
|
|
213
|
+
};
|
|
214
|
+
/** The proposal as the file the user will commit. */
|
|
215
|
+
export const renderMendToml = (proposals) => {
|
|
216
|
+
const blocks = proposals.map((proposal) => {
|
|
217
|
+
const lines = [
|
|
218
|
+
`# ${proposal.source}`,
|
|
219
|
+
`[service.${proposal.name}]`,
|
|
220
|
+
`command = ${JSON.stringify(proposal.command)}`,
|
|
221
|
+
proposal.guessed ? `port = ${proposal.port} # guessed — verify` : `port = ${proposal.port}`,
|
|
222
|
+
...(proposal.protocol === "udp" ? [`protocol = "udp"`] : []),
|
|
223
|
+
];
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
});
|
|
226
|
+
return `${blocks.join("\n\n")}\n`;
|
|
227
|
+
};
|
package/dist/shared.js
CHANGED
|
@@ -9,6 +9,8 @@ export const HARNESS_COMMANDS = {
|
|
|
9
9
|
codex: ["codex"],
|
|
10
10
|
claude: ["claude"],
|
|
11
11
|
opencode: ["opencode"],
|
|
12
|
+
// Not a coding agent: a plain bash session in its own recorded worktree.
|
|
13
|
+
shell: ["bash"],
|
|
12
14
|
};
|
|
13
15
|
/** How each harness takes an instruction as its opening prompt. */
|
|
14
16
|
export const CONTINUE_COMMANDS = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|