moshcode 0.89.0 → 0.91.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 +91 -0
- package/bin/moshcode.mjs +7 -0
- package/package.json +1 -1
- package/prd/0013-persistent-ssh-workspaces.md +1181 -0
- package/prd/README.md +2 -0
- package/src/cli-schema.mjs +109 -0
- package/src/commands.mjs +164 -0
- package/src/dns-service.mjs +167 -10
- package/src/dns.mjs +120 -13
- package/src/ssh.mjs +1228 -0
- package/src/tui.mjs +14 -0
package/prd/README.md
CHANGED
|
@@ -27,4 +27,6 @@ Start one with `moshcode prd "<idea>"` (TUI: `/prd`).
|
|
|
27
27
|
| [0009](0009-persistent-agent-runtime.md) | Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents | Accepted |
|
|
28
28
|
| [0010](0010-cloud-settings-sync.md) | Sync the pit's settings to your moshcode.sh account | Draft |
|
|
29
29
|
| [0011](0011-herd-agent-protocol.md) | Teach the herd the agent protocol — hooks-first state, a task ledger, and an A2A surface for local and remote agents | Draft |
|
|
30
|
+
| [0012](0012-billing-baked-into-the-agent-cli.md) | Bake billing into the agent CLI — timer, clients, teams, rates, invoices, rails | Draft |
|
|
31
|
+
| [0013](0013-persistent-ssh-workspaces.md) | Add persistent SSH workspaces for humans and agents | Draft |
|
|
30
32
|
<!-- PRD-INDEX:END -->
|
package/src/cli-schema.mjs
CHANGED
|
@@ -207,6 +207,36 @@ export const CORE_CLI_COMMANDS = [
|
|
|
207
207
|
seeAlso: ["herd", "ps"],
|
|
208
208
|
note: "this brings back the shape — sessions, directories, engines. the processes are new; work that was in flight is not still running.",
|
|
209
209
|
},
|
|
210
|
+
{
|
|
211
|
+
name: "ssh",
|
|
212
|
+
group: "runtime",
|
|
213
|
+
description: "persistent SSH workspaces — one connection, many clean commands",
|
|
214
|
+
synopsis: [
|
|
215
|
+
["moshcode ssh", "targets, and whether each one is connected"],
|
|
216
|
+
["moshcode ssh <name>", "an interactive shell over the shared connection"],
|
|
217
|
+
["moshcode ssh exec <name> [flags] -- <command…>", "one command over it: stdout, stderr and exit status kept apart"],
|
|
218
|
+
["moshcode ssh <verb> [args…]", "add, open, check, close, put, get, shell, bench"],
|
|
219
|
+
],
|
|
220
|
+
verbs: "SSH_VERBS",
|
|
221
|
+
flags: [
|
|
222
|
+
["--json", "machine-readable, on every verb that does not take the terminal", ""],
|
|
223
|
+
["--persist <dur>", "how long the connection outlives its last client", "10m"],
|
|
224
|
+
],
|
|
225
|
+
examples: [
|
|
226
|
+
["moshcode ssh add dev deploy@example.com --cwd /srv/app", "a name for a box (or an alias from ~/.ssh/config)"],
|
|
227
|
+
["moshcode ssh open dev", "authenticate once"],
|
|
228
|
+
["moshcode ssh exec dev -- git status --short", "…then every command reuses it"],
|
|
229
|
+
["moshcode ssh exec dev --json -- pnpm test", "exit code, stdout and stderr as one object"],
|
|
230
|
+
["git diff | moshcode ssh exec dev --stdin -- git apply -", "a multi-file patch in one round trip"],
|
|
231
|
+
["moshcode ssh dev", "a real shell on the same connection"],
|
|
232
|
+
["moshcode ssh shell dev --name app", "a remote tmux shell that survives your laptop sleeping"],
|
|
233
|
+
["moshcode ssh close dev", "hang up (or let --persist expire)"],
|
|
234
|
+
],
|
|
235
|
+
seeAlso: ["herd", "shell"],
|
|
236
|
+
note: "nothing secret is stored: targets.json holds a host, a port and a directory, and OpenSSH keeps using your ~/.ssh, "
|
|
237
|
+
+ "agent, known_hosts and ProxyJump exactly as it does at the prompt. `exec` gives every call a fresh command channel — "
|
|
238
|
+
+ "no shell state carries between calls; `shell` is for the workflows that need it.",
|
|
239
|
+
},
|
|
210
240
|
{
|
|
211
241
|
name: "install",
|
|
212
242
|
group: "engines",
|
|
@@ -1325,6 +1355,82 @@ export const HERD_VERBS = [
|
|
|
1325
1355
|
// The business layer's verbs. Flatter than the herd's on purpose: these are
|
|
1326
1356
|
// commands somebody types between other work, and a verb that needs a paragraph
|
|
1327
1357
|
// to explain itself is a verb in the wrong place.
|
|
1358
|
+
export const SSH_VERBS = [
|
|
1359
|
+
{ name: "list", description: "every target, and whether its connection is up",
|
|
1360
|
+
synopsis: [["moshcode ssh [list] [--json]", ""]],
|
|
1361
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1362
|
+
{ name: "add", description: "name a box: user@host, or an alias from ~/.ssh/config",
|
|
1363
|
+
synopsis: [["moshcode ssh add <name> <user@host|alias> [--port N] [--cwd PATH] [--persist 10m]", ""]],
|
|
1364
|
+
flags: [
|
|
1365
|
+
["--port <n>", "ssh port, when ~/.ssh/config does not say", "22"],
|
|
1366
|
+
["--cwd <path>", "where exec and the interactive shell start", "the remote login directory"],
|
|
1367
|
+
["--persist <dur>", "how long the connection outlives its last client", "10m"],
|
|
1368
|
+
["--json", "machine-readable", ""],
|
|
1369
|
+
] },
|
|
1370
|
+
{ name: "remove", description: "forget a target (and close its connection)",
|
|
1371
|
+
synopsis: [["moshcode ssh remove <name>", ""]] },
|
|
1372
|
+
{ name: "show", description: "one target in full, with its socket and state",
|
|
1373
|
+
synopsis: [["moshcode ssh show <name> [--json]", ""]],
|
|
1374
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1375
|
+
{ name: "open", description: "authenticate once and keep the connection",
|
|
1376
|
+
synopsis: [["moshcode ssh open <name> [--persist 10m] [--batch] [--json]", ""]],
|
|
1377
|
+
flags: [
|
|
1378
|
+
["--persist <dur>", "how long it outlives its last client", "10m"],
|
|
1379
|
+
["--batch", "never prompt — fail instead (the default when stdin is not a terminal)", ""],
|
|
1380
|
+
["--json", "machine-readable; alreadyOpen says whether it was up", ""],
|
|
1381
|
+
] },
|
|
1382
|
+
{ name: "check", description: "is the connection up? (exit 0 yes, 1 no)",
|
|
1383
|
+
synopsis: [["moshcode ssh check <name> [--json]", ""]],
|
|
1384
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1385
|
+
{ name: "close", description: "hang up, with ssh's own -O exit",
|
|
1386
|
+
synopsis: [["moshcode ssh close <name> [--json]", ""]],
|
|
1387
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1388
|
+
{ name: "exec", description: "one command over the connection — no PTY, no shared shell state",
|
|
1389
|
+
synopsis: [["moshcode ssh exec <name> [--cwd PATH] [--env K=V…] [--stdin] [--tty] [--timeout 2m] [--sh] [--json] -- <command…>", ""]],
|
|
1390
|
+
flags: [
|
|
1391
|
+
["--cwd <path>", "run it here, this once", "the target's cwd"],
|
|
1392
|
+
["--env <K=V>", "an environment variable for this command only (repeatable)", ""],
|
|
1393
|
+
["--stdin", "forward this process's stdin, raw", ""],
|
|
1394
|
+
["--tty", "allocate a terminal (sudo, editors, anything that insists)", ""],
|
|
1395
|
+
["--timeout <dur>", "kill it after this long — exit 124", ""],
|
|
1396
|
+
["--sh", "the one argument is a shell snippet, pipes and all", ""],
|
|
1397
|
+
["--batch", "never prompt for auth — fail instead", ""],
|
|
1398
|
+
["--json", "{ ok, transportOk, code, signal, stdout, stderr, durationMs }", ""],
|
|
1399
|
+
],
|
|
1400
|
+
examples: [
|
|
1401
|
+
["moshcode ssh exec dev -- git status --short", ""],
|
|
1402
|
+
["moshcode ssh exec dev --json -- grep -rn TODO src", "exit 1 is grep's answer, not a transport failure"],
|
|
1403
|
+
["moshcode ssh exec dev --sh 'git log --oneline | head -5'", "a pipeline, on purpose"],
|
|
1404
|
+
] },
|
|
1405
|
+
{ name: "put", description: "copy a file up over the connection, atomically",
|
|
1406
|
+
synopsis: [["moshcode ssh put <name> <local> <remote> [--json]", ""]],
|
|
1407
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1408
|
+
{ name: "get", description: "copy a file down over the connection",
|
|
1409
|
+
synopsis: [["moshcode ssh get <name> <remote> <local> [--json]", ""]],
|
|
1410
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1411
|
+
{ name: "shell", description: "a persistent remote shell in tmux, and the verbs to drive it",
|
|
1412
|
+
synopsis: [
|
|
1413
|
+
["moshcode ssh shell <name> --name <session>", "create or attach — Ctrl-b d leaves it running"],
|
|
1414
|
+
["moshcode ssh shell send <name>/<session> <text>", "type a line into it"],
|
|
1415
|
+
["moshcode ssh shell read <name>/<session> [--lines N]", "its screen, as text"],
|
|
1416
|
+
["moshcode ssh shell kill <name>/<session>", "end it"],
|
|
1417
|
+
["moshcode ssh shell list <name>", "every moshcode shell on the box"],
|
|
1418
|
+
],
|
|
1419
|
+
flags: [
|
|
1420
|
+
["--name <session>", "which shell", "main"],
|
|
1421
|
+
["--lines <n>", "how much screen to read", "60"],
|
|
1422
|
+
["--json", "machine-readable", ""],
|
|
1423
|
+
],
|
|
1424
|
+
examples: [
|
|
1425
|
+
["moshcode ssh shell dev --name app", ""],
|
|
1426
|
+
["moshcode ssh shell send dev/app \"pnpm dev\"", ""],
|
|
1427
|
+
["moshcode ssh shell read dev/app --lines 40", ""],
|
|
1428
|
+
] },
|
|
1429
|
+
{ name: "bench", description: "measure fresh connections against the shared one, on this host",
|
|
1430
|
+
synopsis: [["moshcode ssh bench <name> [--n 20] [--json]", ""]],
|
|
1431
|
+
flags: [["--n <runs>", "how many of each", "20"], ["--json", "machine-readable", ""]] },
|
|
1432
|
+
];
|
|
1433
|
+
|
|
1328
1434
|
export const TIMER_VERBS = [
|
|
1329
1435
|
{ name: "on", description: "start the clock", synopsis: [["moshcode timer on [client] [--task …] [--agents N|auto]", ""]] },
|
|
1330
1436
|
{ name: "off", description: "stop it and write the entry", synopsis: [["moshcode timer off [--note …]", ""]] },
|
|
@@ -1377,6 +1483,7 @@ export const PAYMENT_VERBS = [
|
|
|
1377
1483
|
|
|
1378
1484
|
export const VERB_TABLES = {
|
|
1379
1485
|
HERD_VERBS,
|
|
1486
|
+
SSH_VERBS,
|
|
1380
1487
|
TIMER_VERBS,
|
|
1381
1488
|
CLIENT_VERBS,
|
|
1382
1489
|
TEAM_VERBS,
|
|
@@ -1428,6 +1535,8 @@ export const PIT_COMMANDS = [
|
|
|
1428
1535
|
description: "block until a session is blocked or done" },
|
|
1429
1536
|
{ name: "restore", args: "[--resume]", cli: "restore",
|
|
1430
1537
|
description: "rebuild the herd's sessions after a reboot" },
|
|
1538
|
+
{ name: "ssh", args: "[name|verb] [args…]", cli: "ssh",
|
|
1539
|
+
description: "persistent SSH workspaces — one connection, many clean commands" },
|
|
1431
1540
|
{ name: "tools", args: "[name] [args…]", cli: "tools",
|
|
1432
1541
|
description: "list workflow tools, or run one" },
|
|
1433
1542
|
{ name: "trade", args: "<verb> [args…]", cli: "trade",
|
package/src/commands.mjs
CHANGED
|
@@ -22,6 +22,27 @@ import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs";
|
|
|
22
22
|
import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs";
|
|
23
23
|
import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs";
|
|
24
24
|
import { shellInvocation } from "./shell.mjs";
|
|
25
|
+
import {
|
|
26
|
+
checkMaster as sshCheckMaster, closeMaster as sshCloseMaster, exec as sshRun, get as sshGetFile,
|
|
27
|
+
openMaster as sshOpenMaster, parseSessionRef as sshParseSessionRef, parseTimeout as parseSshTimeout,
|
|
28
|
+
put as sshPutFile, resolveTarget as sshResolve,
|
|
29
|
+
shellKill as sshShellKillRemote, shellRead as sshShellReadRemote, shellSend as sshShellSendRemote,
|
|
30
|
+
} from "./ssh.mjs";
|
|
31
|
+
|
|
32
|
+
/** The registry entry for a named ssh target, or a moshscript error naming the verb. */
|
|
33
|
+
function sshTarget(name, verb) {
|
|
34
|
+
if (!name) throw new Error(`moshscript: ${verb}(name) requires a target name`);
|
|
35
|
+
const found = sshResolve(String(name));
|
|
36
|
+
if (found.error) throw new Error(`moshscript: ${verb}: ${found.error.replace(/^ssh: /, "")}`);
|
|
37
|
+
return found.entry;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `dev/app` → the target entry and the session name, or a moshscript error. */
|
|
41
|
+
function sshShellRef(ref, verb) {
|
|
42
|
+
const parsed = sshParseSessionRef(ref);
|
|
43
|
+
if (parsed.error) throw new Error(`moshscript: ${verb}: ${parsed.error.replace(/^ssh: /, "")}`);
|
|
44
|
+
return { found: sshTarget(parsed.name, verb), session: parsed.session };
|
|
45
|
+
}
|
|
25
46
|
import { captureSpec } from "./pty.mjs";
|
|
26
47
|
import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
|
|
27
48
|
import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
|
|
@@ -654,6 +675,148 @@ const COMMANDS = [
|
|
|
654
675
|
},
|
|
655
676
|
},
|
|
656
677
|
|
|
678
|
+
// SSH workspaces (PRD 0013 R49–R52). Value-returning for the herd's reason:
|
|
679
|
+
// a script that runs a command on a remote box wants its stdout back, and a
|
|
680
|
+
// cliVerb's { ok, code } cannot carry it. Each returns the same object the
|
|
681
|
+
// CLI prints under --json, so a script and a shell pipeline read one shape.
|
|
682
|
+
//
|
|
683
|
+
// sshOpen("dev");
|
|
684
|
+
// const r = sshExec("dev", ["git", "status", "--short"], { cwd: "/srv/app" });
|
|
685
|
+
// if (!r.ok) say(r.stderr);
|
|
686
|
+
// sshExec("dev", ["git", "apply", "-"], { stdin: patch });
|
|
687
|
+
// sshClose("dev");
|
|
688
|
+
{
|
|
689
|
+
name: "sshOpen",
|
|
690
|
+
summary: "authenticate to a named ssh target once and keep the connection",
|
|
691
|
+
usage: "sshOpen(name, { persist })",
|
|
692
|
+
detail: "returns { ok, connected, alreadyOpen }; a live connection is left alone",
|
|
693
|
+
run(ctx, name, opts = {}) {
|
|
694
|
+
const found = sshTarget(name, "sshOpen");
|
|
695
|
+
if (ctx.dryRun) { ctx.out(` 🔌 sshOpen(${name}) → would open a master to ${found.target}`); return { ok: true, target: found.name, connected: true, dryRun: true }; }
|
|
696
|
+
const r = sshOpenMaster(found, { persist: opts.persist, batch: opts.batch });
|
|
697
|
+
ctx.out(r.ok ? ` 🔌 sshOpen(${name}) → ${r.alreadyOpen ? "already connected" : "connected"}` : ` ✗ sshOpen(${name}) → ${r.error}`);
|
|
698
|
+
return r;
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
name: "sshCheck",
|
|
703
|
+
summary: "is the connection to a target up?",
|
|
704
|
+
usage: "sshCheck(name)",
|
|
705
|
+
detail: "returns { connected, stale, pid }; never connects",
|
|
706
|
+
run(ctx, name) {
|
|
707
|
+
const found = sshTarget(name, "sshCheck");
|
|
708
|
+
if (ctx.dryRun) return { target: found.name, connected: false, dryRun: true };
|
|
709
|
+
const s = sshCheckMaster(found);
|
|
710
|
+
return { target: found.name, connected: s.connected, stale: s.stale, pid: s.pid ?? null };
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
name: "sshExec",
|
|
715
|
+
summary: "run one command over the shared connection and RETURN its result",
|
|
716
|
+
usage: "sshExec(name, [cmd, ...args], { cwd, env, stdin, timeout, sh })",
|
|
717
|
+
detail: "returns { ok, transportOk, code, signal, stdout, stderr, durationMs }; opens the connection if it is down. ok is the command's verdict, transportOk is ssh's",
|
|
718
|
+
run(ctx, name, argv, opts = {}) {
|
|
719
|
+
const found = sshTarget(name, "sshExec");
|
|
720
|
+
const command = Array.isArray(argv) ? argv.map(String) : [String(argv ?? "")].filter(Boolean);
|
|
721
|
+
if (!command.length) throw new Error("moshscript: sshExec(name, [command, ...args]) needs a command");
|
|
722
|
+
if (ctx.dryRun) {
|
|
723
|
+
ctx.out(` ▶ sshExec(${name}) → would run on ${found.target}: ${command.join(" ")}`);
|
|
724
|
+
return { ok: true, transportOk: true, target: found.name, connected: true, code: 0, signal: null, stdout: "", stderr: "", durationMs: 0, dryRun: true };
|
|
725
|
+
}
|
|
726
|
+
ctx.out(` ▶ sshExec(${name}) → ${command.join(" ").slice(0, 60)}${command.join(" ").length > 60 ? "…" : ""}`);
|
|
727
|
+
const timeoutMs = opts.timeout === undefined ? undefined
|
|
728
|
+
: (typeof opts.timeout === "number" ? opts.timeout : parseSshTimeout(opts.timeout));
|
|
729
|
+
const r = sshRun(found, command, {
|
|
730
|
+
cwd: opts.cwd, remoteEnv: opts.env || {}, stdin: opts.stdin, sh: Boolean(opts.sh), timeoutMs, persist: opts.persist, batch: opts.batch ?? true,
|
|
731
|
+
});
|
|
732
|
+
if (!r.transportOk) ctx.out(` ✗ sshExec(${name}) → ${r.error}`);
|
|
733
|
+
else if (!r.ok) ctx.out(` ✗ sshExec(${name}) exited ${r.signal || r.code}`);
|
|
734
|
+
return r;
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
name: "sshClose",
|
|
739
|
+
summary: "hang up a target's connection",
|
|
740
|
+
usage: "sshClose(name)",
|
|
741
|
+
detail: "returns { ok, closed, wasOpen }",
|
|
742
|
+
run(ctx, name) {
|
|
743
|
+
const found = sshTarget(name, "sshClose");
|
|
744
|
+
if (ctx.dryRun) { ctx.out(` 🔌 sshClose(${name}) → would send -O exit`); return { ok: true, target: found.name, closed: true, dryRun: true }; }
|
|
745
|
+
const r = sshCloseMaster(found);
|
|
746
|
+
ctx.out(r.ok ? ` 🔌 sshClose(${name}) → ${r.wasOpen ? "closed" : "was not connected"}` : ` ✗ sshClose(${name}) → ${r.error}`);
|
|
747
|
+
return r;
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
name: "sshPut",
|
|
752
|
+
summary: "copy a local file to a target, atomically",
|
|
753
|
+
usage: "sshPut(name, local, remote)",
|
|
754
|
+
detail: "returns { ok, remote }; scp to a temp path over the shared connection, then rename",
|
|
755
|
+
run(ctx, name, local, remote) {
|
|
756
|
+
const found = sshTarget(name, "sshPut");
|
|
757
|
+
if (!local || !remote) throw new Error("moshscript: sshPut(name, local, remote) needs both paths");
|
|
758
|
+
if (ctx.dryRun) { ctx.out(` 📤 sshPut(${name}) → would copy ${local} to ${remote}`); return { ok: true, target: found.name, dryRun: true }; }
|
|
759
|
+
const r = sshPutFile(found, String(local), String(remote));
|
|
760
|
+
ctx.out(r.ok ? ` 📤 sshPut(${name}) → ${r.remote}` : ` ✗ sshPut(${name}) → ${r.error}`);
|
|
761
|
+
return r;
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
name: "sshGet",
|
|
766
|
+
summary: "copy a file down from a target",
|
|
767
|
+
usage: "sshGet(name, remote, local)",
|
|
768
|
+
detail: "returns { ok, local }",
|
|
769
|
+
run(ctx, name, remote, local) {
|
|
770
|
+
const found = sshTarget(name, "sshGet");
|
|
771
|
+
if (!local || !remote) throw new Error("moshscript: sshGet(name, remote, local) needs both paths");
|
|
772
|
+
if (ctx.dryRun) { ctx.out(` 📥 sshGet(${name}) → would copy ${remote} to ${local}`); return { ok: true, target: found.name, dryRun: true }; }
|
|
773
|
+
const r = sshGetFile(found, String(remote), String(local));
|
|
774
|
+
ctx.out(r.ok ? ` 📥 sshGet(${name}) → ${r.local}` : ` ✗ sshGet(${name}) → ${r.error}`);
|
|
775
|
+
return r;
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
name: "sshShellSend",
|
|
780
|
+
summary: "type a line into a persistent remote shell",
|
|
781
|
+
usage: 'sshShellSend("dev/app", text)',
|
|
782
|
+
detail: "returns { ok }; literal text then Enter, into the remote tmux session",
|
|
783
|
+
run(ctx, ref, ...words) {
|
|
784
|
+
const { found, session } = sshShellRef(ref, "sshShellSend");
|
|
785
|
+
const text = words.join(" ");
|
|
786
|
+
if (!text) throw new Error("moshscript: sshShellSend(ref, text) needs text");
|
|
787
|
+
if (ctx.dryRun) { ctx.out(` 💬 sshShellSend(${ref}) → would send: ${text}`); return { ok: true, dryRun: true }; }
|
|
788
|
+
const r = sshShellSendRemote(found, session, text);
|
|
789
|
+
if (!r.ok) ctx.out(` ✗ sshShellSend(${ref}) → ${r.error}`);
|
|
790
|
+
return r;
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
name: "sshShellRead",
|
|
795
|
+
summary: "the screen of a persistent remote shell, as a string",
|
|
796
|
+
usage: 'sshShellRead("dev/app", { lines })',
|
|
797
|
+
detail: "returns the text (empty on failure); the same capture-pane the CLI's read prints",
|
|
798
|
+
run(ctx, ref, opts = {}) {
|
|
799
|
+
const { found, session } = sshShellRef(ref, "sshShellRead");
|
|
800
|
+
if (ctx.dryRun) { ctx.out(` 📖 sshShellRead(${ref}) → would capture the pane`); return ""; }
|
|
801
|
+
const r = sshShellReadRemote(found, session, { lines: opts.lines });
|
|
802
|
+
if (!r.ok) { ctx.out(` ✗ sshShellRead(${ref}) → ${r.error}`); return ""; }
|
|
803
|
+
return r.screen;
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
name: "sshShellKill",
|
|
808
|
+
summary: "end a persistent remote shell",
|
|
809
|
+
usage: 'sshShellKill("dev/app")',
|
|
810
|
+
detail: "returns { ok }",
|
|
811
|
+
run(ctx, ref) {
|
|
812
|
+
const { found, session } = sshShellRef(ref, "sshShellKill");
|
|
813
|
+
if (ctx.dryRun) { ctx.out(` ☠ sshShellKill(${ref}) → would kill the session`); return { ok: true, dryRun: true }; }
|
|
814
|
+
const r = sshShellKillRemote(found, session);
|
|
815
|
+
if (!r.ok) ctx.out(` ✗ sshShellKill(${ref}) → ${r.error}`);
|
|
816
|
+
return r;
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
|
|
657
820
|
// CLI verbs — each is `moshcode <name> ...args`. This is the whole point:
|
|
658
821
|
// scripting the CLI. Add a capability by adding a line here.
|
|
659
822
|
//
|
|
@@ -695,6 +858,7 @@ const COMMANDS = [
|
|
|
695
858
|
cliVerb("elevenlabs", "drive the ElevenLabs CLI (Eleven Agents, voices, TTS, dubbing)"),
|
|
696
859
|
cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
|
|
697
860
|
cliVerb("pwd", "print the current repo/location"),
|
|
861
|
+
cliVerb("ssh", "persistent SSH workspaces (moshcode ssh <verb>) — see sshExec/sshOpen for values"),
|
|
698
862
|
|
|
699
863
|
// Research and feeds. The *Read() verbs above return the data; these are the
|
|
700
864
|
// rendered CLI, for when a script wants the table on the operator's screen.
|
package/src/dns-service.mjs
CHANGED
|
@@ -29,9 +29,8 @@
|
|
|
29
29
|
// it, and the entry is the script this very command was invoked from. Nothing
|
|
30
30
|
// is guessed and nothing depends on PATH.
|
|
31
31
|
import { spawn } from "node:child_process";
|
|
32
|
-
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
33
|
-
import { existsSync } from "node:fs";
|
|
34
|
-
import { homedir } from "node:os";
|
|
32
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
33
|
+
import { existsSync, statSync } from "node:fs";
|
|
35
34
|
import { dirname, join } from "node:path";
|
|
36
35
|
import { operatorHome } from "./trust.mjs";
|
|
37
36
|
|
|
@@ -48,10 +47,49 @@ export const UNIT_NAME = "moshcode-dns.service";
|
|
|
48
47
|
* /run/user/<uid>/moshpit-dns.pid for the daemon and for the person asking
|
|
49
48
|
* after it. Under a system unit those are two different paths.
|
|
50
49
|
*/
|
|
51
|
-
export function servicePaths({ system = false, home =
|
|
50
|
+
export function servicePaths({ system = false, home = operatorHome(), env = process.env } = {}) {
|
|
52
51
|
return system
|
|
53
52
|
? { path: join("/etc/systemd/system", UNIT_NAME), systemctl: ["systemctl"], scope: "system" }
|
|
54
|
-
: { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl:
|
|
53
|
+
: { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl: userSystemctl(env), scope: "user" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How to reach the operator's own systemd from wherever this is running.
|
|
58
|
+
*
|
|
59
|
+
* `systemctl --user` talks to the session of whoever is running it. `dns enable`
|
|
60
|
+
* escalates, so from there it is root's session — which has no bridge in it, has
|
|
61
|
+
* never had one, and reports every query about one as "not loaded". Meanwhile
|
|
62
|
+
* the operator's bridge keeps running with whatever it started with.
|
|
63
|
+
*
|
|
64
|
+
* That is why enabling proxy mode could be detected, written, and still not take
|
|
65
|
+
* effect: the unit that had to change belongs to a session the escalated half of
|
|
66
|
+
* the command cannot see.
|
|
67
|
+
*
|
|
68
|
+
* So an escalated run drops back to the invoking user, and hands them the runtime
|
|
69
|
+
* directory their session bus lives in — deriving it rather than inheriting it,
|
|
70
|
+
* because sudo does not carry XDG_RUNTIME_DIR across and the default under sudo
|
|
71
|
+
* points at root's.
|
|
72
|
+
*/
|
|
73
|
+
export function userSystemctl(env = process.env, { home = operatorHome({ env }), owner = ownerOf } = {}) {
|
|
74
|
+
const user = env.SUDO_USER || env.DOAS_USER;
|
|
75
|
+
// Not escalated, or escalated from root itself: the session in reach is the
|
|
76
|
+
// right one.
|
|
77
|
+
if (!user || user === "root") return ["systemctl", "--user"];
|
|
78
|
+
// sudo publishes the uid; doas publishes only the name. Falling back to the
|
|
79
|
+
// owner of the operator's home covers that, and covers an escalator that
|
|
80
|
+
// publishes neither — without it, a doas machine would quietly address root's
|
|
81
|
+
// session, which has no bridge in it and never will.
|
|
82
|
+
const uid = env.SUDO_UID || env.DOAS_UID || owner(home);
|
|
83
|
+
if (uid === null || uid === undefined) return ["systemctl", "--user"];
|
|
84
|
+
return ["sudo", "-u", user, "env", `XDG_RUNTIME_DIR=/run/user/${uid}`, "systemctl", "--user"];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ownerOf(path) {
|
|
88
|
+
try {
|
|
89
|
+
return statSync(path).uid;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
55
93
|
}
|
|
56
94
|
|
|
57
95
|
/**
|
|
@@ -127,6 +165,8 @@ export function serviceUnit({
|
|
|
127
165
|
return lines.join("\n");
|
|
128
166
|
}
|
|
129
167
|
|
|
168
|
+
const defaultRead = async (path) => readFile(path, "utf8").catch(() => "");
|
|
169
|
+
|
|
130
170
|
function run(command, args) {
|
|
131
171
|
return new Promise((resolve) => {
|
|
132
172
|
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -140,8 +180,8 @@ function run(command, args) {
|
|
|
140
180
|
}
|
|
141
181
|
|
|
142
182
|
/** Write the unit and start it. Returns the steps taken, in order, for printing. */
|
|
143
|
-
export async function installService(unit, { system = false, home =
|
|
144
|
-
const { path, systemctl, scope } = servicePaths({ system, home });
|
|
183
|
+
export async function installService(unit, { system = false, home = operatorHome(), exec = run, env = process.env } = {}) {
|
|
184
|
+
const { path, systemctl, scope } = servicePaths({ system, home, env });
|
|
145
185
|
const steps = [];
|
|
146
186
|
try {
|
|
147
187
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -152,7 +192,14 @@ export async function installService(unit, { system = false, home = homedir(), e
|
|
|
152
192
|
}
|
|
153
193
|
|
|
154
194
|
const [cmd, ...flags] = systemctl;
|
|
155
|
-
|
|
195
|
+
// Enable *and* restart. `enable --now` starts a stopped unit and does nothing
|
|
196
|
+
// to a running one, so rewriting the unit to add `--proxy` would leave the old
|
|
197
|
+
// bridge running without it — the change on disk, no change in behaviour.
|
|
198
|
+
for (const args of [
|
|
199
|
+
[...flags, "daemon-reload"],
|
|
200
|
+
[...flags, "enable", UNIT_NAME],
|
|
201
|
+
[...flags, "restart", UNIT_NAME],
|
|
202
|
+
]) {
|
|
156
203
|
const result = await exec(cmd, args);
|
|
157
204
|
steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
|
|
158
205
|
if (!result.ok) return { ok: false, path, scope, steps };
|
|
@@ -160,9 +207,119 @@ export async function installService(unit, { system = false, home = homedir(), e
|
|
|
160
207
|
return { ok: true, path, scope, steps };
|
|
161
208
|
}
|
|
162
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The `--upstream` servers an installed unit already forwards to.
|
|
212
|
+
*
|
|
213
|
+
* Read back rather than recomputed. A supervised bridge needs upstreams to hand
|
|
214
|
+
* the clearnet to, and the machine may already be routing every lookup at that
|
|
215
|
+
* bridge — so asking the system resolver what its upstreams are can answer
|
|
216
|
+
* "this bridge", and a bridge whose upstream is itself resolves nothing at all.
|
|
217
|
+
* Whatever the unit was working with is the safe answer to keep.
|
|
218
|
+
*/
|
|
219
|
+
export function unitUpstreams(text) {
|
|
220
|
+
const line = String(text ?? "").split("\n").find((l) => l.startsWith("ExecStart="));
|
|
221
|
+
if (!line) return [];
|
|
222
|
+
const parts = line.trim().split(/\s+/);
|
|
223
|
+
const found = [];
|
|
224
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
225
|
+
if (parts[i] !== "--upstream") continue;
|
|
226
|
+
const value = parts[i + 1];
|
|
227
|
+
if (!value || value.startsWith("--")) continue;
|
|
228
|
+
if (!found.includes(value)) found.push(value);
|
|
229
|
+
}
|
|
230
|
+
return found;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Re-describe the bridge unit to match the run happening now, and restart it so
|
|
235
|
+
* the description becomes the truth.
|
|
236
|
+
*
|
|
237
|
+
* This is the step that made proxy mode arrive one reboot late. A supervised
|
|
238
|
+
* bridge is not started by `enable`: it is already up under `Restart=always`,
|
|
239
|
+
* so `startDaemon` finds a live pidfile and reports "already running" — true,
|
|
240
|
+
* and useless, because what a resolver answers with is fixed when it spawns. A
|
|
241
|
+
* bridge that came up before the proxy existed goes on answering origins
|
|
242
|
+
* forever, and stopping it by hand does not help, since systemd brings the same
|
|
243
|
+
* ExecStart straight back.
|
|
244
|
+
*
|
|
245
|
+
* The only thing that changes a supervised bridge's mind is rewriting its unit
|
|
246
|
+
* and restarting it. That is all this is.
|
|
247
|
+
*
|
|
248
|
+
* With no unit installed it does nothing and says so. An unsupervised machine
|
|
249
|
+
* is `startDaemon`'s business, and writing a unit here would be `enable`
|
|
250
|
+
* quietly making the bridge outlive a reboot on a machine that never asked for
|
|
251
|
+
* that — a different decision, and one `dns service --write` exists to make.
|
|
252
|
+
*/
|
|
253
|
+
export async function refreshService({
|
|
254
|
+
entry,
|
|
255
|
+
port,
|
|
256
|
+
registryBase = null,
|
|
257
|
+
proxy = null,
|
|
258
|
+
system = false,
|
|
259
|
+
home = operatorHome(),
|
|
260
|
+
env = process.env,
|
|
261
|
+
exec = run,
|
|
262
|
+
exists = existsSync,
|
|
263
|
+
read = defaultRead,
|
|
264
|
+
} = {}) {
|
|
265
|
+
const { path, scope } = servicePaths({ system, home, env });
|
|
266
|
+
if (!exists(path)) return { refreshed: false, reason: "no unit installed", path, scope, upstreams: [], steps: [] };
|
|
267
|
+
|
|
268
|
+
const current = await read(path);
|
|
269
|
+
const upstreams = unitUpstreams(current);
|
|
270
|
+
const unit = serviceUnit({ system, entry, port, registryBase, upstreams, proxy });
|
|
271
|
+
|
|
272
|
+
// Deliberately not short-circuited on `current === unit`. Matching text says
|
|
273
|
+
// the unit describes the right bridge, not that the bridge is running it: a
|
|
274
|
+
// unit can be installed and stopped, installed and never enabled, or running
|
|
275
|
+
// what it was spawned with before the file last changed. Since the whole
|
|
276
|
+
// point here is to make what is running match what is written, the enable and
|
|
277
|
+
// restart happen either way, and cost a moment of no resolver during a
|
|
278
|
+
// command that is already rewriting the machine's routing.
|
|
279
|
+
|
|
280
|
+
const result = await installService(unit, { system, home, env, exec });
|
|
281
|
+
return {
|
|
282
|
+
refreshed: result.ok,
|
|
283
|
+
reason: result.ok ? null : "systemctl refused the unit",
|
|
284
|
+
path,
|
|
285
|
+
scope,
|
|
286
|
+
upstreams,
|
|
287
|
+
steps: result.steps,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Stop the supervised bridge and leave the unit where it is.
|
|
293
|
+
*
|
|
294
|
+
* `stopDaemon` cannot do this. It reads the pidfile and signals that process,
|
|
295
|
+
* which is right for a bridge started by hand and useless for one systemd owns:
|
|
296
|
+
* the unit is `Restart=always`, so the pid dies and the same ExecStart is back
|
|
297
|
+
* within the second. `disable` printed "bridge stopped" and left a bridge
|
|
298
|
+
* running — on a machine whose routing had just been put back, so the bridge
|
|
299
|
+
* was still up, still answering, and no longer on anybody's path.
|
|
300
|
+
*
|
|
301
|
+
* The unit file stays. Removing it is a different decision than turning
|
|
302
|
+
* resolution off for an afternoon, and `enable` re-enables what it finds — so
|
|
303
|
+
* leaving it costs nothing and deleting it would quietly take away a unit the
|
|
304
|
+
* operator may have written themselves.
|
|
305
|
+
*/
|
|
306
|
+
export async function stopService({ system = false, home = operatorHome(), env = process.env, exec = run, exists = existsSync } = {}) {
|
|
307
|
+
const { path, systemctl, scope } = servicePaths({ system, home, env });
|
|
308
|
+
if (!exists(path)) return { stopped: false, reason: "no unit installed", path, scope, steps: [] };
|
|
309
|
+
const [cmd, ...flags] = systemctl;
|
|
310
|
+
const result = await exec(cmd, [...flags, "disable", "--now", UNIT_NAME]);
|
|
311
|
+
return {
|
|
312
|
+
stopped: result.ok,
|
|
313
|
+
reason: result.ok ? null : (result.error || "systemctl refused"),
|
|
314
|
+
path,
|
|
315
|
+
scope,
|
|
316
|
+
steps: [{ step: `${cmd} ${flags.join(" ")} disable --now ${UNIT_NAME}`, ok: result.ok, error: result.error }],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
163
320
|
/** Stop it and take the unit away. Missing is not a failure — removal is idempotent. */
|
|
164
|
-
export async function removeService({ system = false, home =
|
|
165
|
-
const { path, systemctl, scope } = servicePaths({ system, home });
|
|
321
|
+
export async function removeService({ system = false, home = operatorHome(), exec = run, env = process.env } = {}) {
|
|
322
|
+
const { path, systemctl, scope } = servicePaths({ system, home, env });
|
|
166
323
|
const [cmd, ...flags] = systemctl;
|
|
167
324
|
const steps = [];
|
|
168
325
|
for (const args of [[...flags, "disable", "--now", UNIT_NAME]]) {
|