moshcode 0.24.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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +580 -0
  3. package/bin/moshcode.mjs +674 -0
  4. package/bin/moshscript.mjs +29 -0
  5. package/examples/alive.mosh +6 -0
  6. package/examples/scripting-the-cli.mosh +21 -0
  7. package/examples/team-secrets.mosh +20 -0
  8. package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
  9. package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
  10. package/examples/templates/bun-caddy-sqlite/README.md +97 -0
  11. package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
  12. package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
  13. package/examples/templates/bun-caddy-sqlite/package.json +15 -0
  14. package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
  15. package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
  16. package/examples/templates/bun-caddy-sqlite/template.json +10 -0
  17. package/examples/templates/caddy-proxy/Caddyfile +36 -0
  18. package/examples/templates/caddy-proxy/README.md +104 -0
  19. package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
  20. package/examples/templates/caddy-proxy/template.json +8 -0
  21. package/examples/templates/caddy-static/Caddyfile +16 -0
  22. package/examples/templates/caddy-static/README.md +90 -0
  23. package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
  24. package/examples/templates/caddy-static/site/index.html +11 -0
  25. package/examples/templates/caddy-static/template.json +8 -0
  26. package/install.sh +194 -0
  27. package/package.json +28 -0
  28. package/prd/0000-template.md +49 -0
  29. package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
  30. package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
  31. package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
  32. package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
  33. package/prd/0005-hosted-moshpit-resolver.md +192 -0
  34. package/prd/0006-help.md +359 -0
  35. package/prd/0007-profullstack-site-init.md +1183 -0
  36. package/prd/README.md +26 -0
  37. package/src/ads.mjs +58 -0
  38. package/src/auth.mjs +193 -0
  39. package/src/cli-schema.mjs +533 -0
  40. package/src/cli.mjs +118 -0
  41. package/src/commands.mjs +259 -0
  42. package/src/completion.mjs +594 -0
  43. package/src/console.mjs +244 -0
  44. package/src/dns-system.mjs +404 -0
  45. package/src/dns.mjs +2872 -0
  46. package/src/doh-server.mjs +256 -0
  47. package/src/doh.mjs +218 -0
  48. package/src/engines.mjs +385 -0
  49. package/src/escalate.mjs +85 -0
  50. package/src/help.mjs +443 -0
  51. package/src/integrations.mjs +265 -0
  52. package/src/mcp-catalog.mjs +50 -0
  53. package/src/mcp.mjs +155 -0
  54. package/src/mirror.mjs +187 -0
  55. package/src/notify.mjs +86 -0
  56. package/src/open-url.mjs +34 -0
  57. package/src/parking-http.mjs +65 -0
  58. package/src/pins.mjs +190 -0
  59. package/src/pit-url.mjs +13 -0
  60. package/src/prd.mjs +341 -0
  61. package/src/pty.mjs +176 -0
  62. package/src/pwd.mjs +103 -0
  63. package/src/registry.mjs +37 -0
  64. package/src/release-install.mjs +191 -0
  65. package/src/runtime.mjs +161 -0
  66. package/src/selfupdate.mjs +215 -0
  67. package/src/serve.mjs +502 -0
  68. package/src/skills.mjs +93 -0
  69. package/src/tabs.mjs +144 -0
  70. package/src/templates.mjs +456 -0
  71. package/src/tools.mjs +231 -0
  72. package/src/trade.mjs +137 -0
  73. package/src/trust.mjs +712 -0
  74. package/src/tui.mjs +736 -0
  75. package/src/ui.mjs +49 -0
  76. package/src/uninstall.mjs +113 -0
  77. package/src/upgrade.mjs +217 -0
@@ -0,0 +1,385 @@
1
+ // Agentic-coding engines moshcode can install + wrap. `moshcode install <name>`
2
+ // runs the engine's official installer; `/agents <name>` (or `moshcode <name>`)
3
+ // opens a passthrough session on it. moshcode itself stays lean (no vendored
4
+ // fork). Add engines here.
5
+ //
6
+ // `agentsView` (optional) is the exact argv that opens the engine's native
7
+ // agent list/view — used by `/agents <name>` when the engine actually has one
8
+ // (claude, opencode). It's the FULL leading args (subcommand + any flags that
9
+ // subcommand accepts), because not every agents-subcommand takes the engine's
10
+ // bypass flag (e.g. `opencode agent list` takes none). Engines without an
11
+ // `agentsView` fall back to `agentArgs` — an autonomous session with native
12
+ // approvals bypassed/auto-approved.
13
+ import { spawn } from "node:child_process";
14
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
+ import { homedir, tmpdir } from "node:os";
16
+ import path from "node:path";
17
+
18
+ import { followFile, ptyEnabled, ptySpec, scriptFlavor, stripScriptBanner } from "./pty.mjs";
19
+
20
+ export const ENGINES = {
21
+ opencode: {
22
+ desc: "opencode — the open-source coding agent (SST/anomalyco)",
23
+ bin: "opencode",
24
+ agentArgs: ["--auto"],
25
+ agentsView: ["agent", "list"], // `opencode agent list` — lists agents; the `agent` subcommand takes no bypass flag
26
+ install: { cmd: "bash", args: ["-c", "curl -fsSL https://opencode.ai/install | bash"] },
27
+ upgrade: { cmd: "opencode", args: ["upgrade"] },
28
+ },
29
+ privacycode: {
30
+ desc: "privacycode — privacy-first coding agent (profullstack)",
31
+ bin: "privacycode",
32
+ // An opencode derivative, so it speaks the same flags/subcommands.
33
+ agentArgs: ["--auto"],
34
+ agentsView: ["agent", "list"],
35
+ install: { cmd: "sh", args: ["-c", "curl -fsSL https://getprivacycode.com/install | sh"] },
36
+ // Deliberately no native updater. `privacycode upgrade` is opencode's, and
37
+ // it works out how to update itself by recognising where it was installed —
38
+ // it knows opencode's own locations, not this fork's ~/.privacycode/bin. It
39
+ // reports `Using method: unknown` and aborts with "Unknown installation
40
+ // method", every time, so it can never upgrade an install we made. Falling
41
+ // through to the installer above is what actually moves the version.
42
+ },
43
+ claude: {
44
+ desc: "Claude Code — Anthropic's agentic CLI",
45
+ bin: "claude",
46
+ agentArgs: ["--dangerously-skip-permissions"],
47
+ agentsView: ["agents", "--dangerously-skip-permissions"], // `claude agents …` — the background-agents view; accepts the skip flag
48
+ install: { cmd: "npm", args: ["install", "-g", "@anthropic-ai/claude-code"] },
49
+ // Claude Code authenticates via its own stored login (~/.claude). An
50
+ // inherited ANTHROPIC_API_KEY hijacks that subscription auth — and if the
51
+ // key can't serve the models, Claude Code shows an "enable models" screen
52
+ // and exits straight back to the mosh prompt. Nested-session markers make a
53
+ // fresh launch think it's running inside another Claude. Drop both so the
54
+ // passthrough session starts clean on its own auth. (opencode/aider legitimately
55
+ // use ANTHROPIC_API_KEY as a provider key, so we only scrub it for claude.)
56
+ stripEnv: [
57
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN",
58
+ "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION",
59
+ ],
60
+ },
61
+ codex: {
62
+ desc: "Codex — OpenAI's coding CLI",
63
+ bin: "codex",
64
+ agentArgs: ["--dangerously-bypass-approvals-and-sandbox"],
65
+ install: { cmd: "npm", args: ["install", "-g", "@openai/codex"] },
66
+ },
67
+ gemini: {
68
+ desc: "Gemini CLI — Google's agentic CLI",
69
+ bin: "gemini",
70
+ agentArgs: ["--approval-mode=yolo"],
71
+ install: { cmd: "npm", args: ["install", "-g", "@google/gemini-cli"] },
72
+ },
73
+ kimi: {
74
+ desc: "Kimi Code — Moonshot AI's agentic CLI",
75
+ bin: "kimi",
76
+ // `--yolo` auto-approves regular tool calls while the agent can still ask a
77
+ // question — the same shape as gemini's yolo and aider's --yes-always. Kimi
78
+ // also has `--auto`, which additionally suppresses the questions; that is a
79
+ // step past what /agents means for every other engine here.
80
+ agentArgs: ["--yolo"],
81
+ // No agentsView: Kimi Code has no agent list to land on. `--agent <name>`
82
+ // picks a profile for the session it is starting, and there is no `kimi
83
+ // agents` subcommand, so agent mode is the autonomous session above.
84
+ //
85
+ // Install the kimi-code installer directly rather than the code.kimi.com
86
+ // /install.sh wrapper the older docs point at. That wrapper now installs the
87
+ // deprecated Python kimi-cli, and it *prompts* — Enter, or a 30s timeout,
88
+ // silently redirects to this same script. A vendor installer that blocks on
89
+ // a human for half a minute is not something `moshcode install` can drive.
90
+ install: { cmd: "bash", args: ["-c", "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"] },
91
+ upgrade: { cmd: "kimi", args: ["upgrade"] },
92
+ // The installer drops the binary in ~/.kimi-code/bin and only appends that
93
+ // to your shell rc, so PATH won't see it until the next shell — including in
94
+ // the moshcode session that just installed it. (A custom KIMI_INSTALL_DIR
95
+ // lands in the rc the same way; only the default needs bridging here.)
96
+ binDirs: [path.join(homedir(), ".kimi-code", "bin")],
97
+ },
98
+ qwen: {
99
+ desc: "Qwen Code — Alibaba's agentic coding CLI",
100
+ bin: "qwen",
101
+ // A Gemini CLI fork, so it takes gemini's approval-mode flag. `--yolo`/`-y`
102
+ // selects the same mode by another name, and passing both is a hard error
103
+ // ("Cannot use --yolo (-y) and --approval-mode together"), so this stays on
104
+ // the one form — the gemini-shaped one, matching the entry above.
105
+ agentArgs: ["--approval-mode=yolo"],
106
+ install: { cmd: "npm", args: ["install", "-g", "@qwen-code/qwen-code"] },
107
+ },
108
+ deepseek: {
109
+ desc: "DeepSeek Code — terminal coding agent on DeepSeek models",
110
+ // Community-built (SerjMihashin), not a DeepSeek-published CLI — DeepSeek
111
+ // ships no first-party agentic CLI. The two other names that get suggested
112
+ // are dead ends: `deepseek-tui` is now a stub whose own description says it
113
+ // was renamed to `codewhale`, and `deepseek-cli` has not been touched since
114
+ // January 2025.
115
+ //
116
+ // The package installs two identical bins, `dsc` and `deepseek-code`. Take
117
+ // the long one: `dsc` is also Microsoft's Desired State Configuration
118
+ // binary, so on a machine that has both, the short name would silently
119
+ // launch the wrong program.
120
+ bin: "deepseek-code",
121
+ // `--turbo` is its "auto-approve all actions" mode (equivalently
122
+ // `--approval-mode turbo`, alongside plan/default/auto-edit).
123
+ agentArgs: ["--turbo"],
124
+ install: { cmd: "npm", args: ["install", "-g", "@serjm/deepseek-code"] },
125
+ },
126
+ aider: {
127
+ desc: "Aider — pair-programming in your terminal",
128
+ bin: "aider",
129
+ agentArgs: ["--yes-always"],
130
+ install: { cmd: "bash", args: ["-c", "curl -LsSf https://aider.chat/install.sh | sh"] },
131
+ upgrade: { cmd: "aider", args: ["--upgrade"] },
132
+ },
133
+ };
134
+
135
+ /**
136
+ * The command that upgrades an already-installed engine in place: its native
137
+ * updater if it has one, else re-run the installer (they're idempotent and
138
+ * fetch the latest — claude/codex/gemini are `npm i -g` which upgrades).
139
+ */
140
+ export function upgradeSpec(engine) {
141
+ return engine.upgrade || engine.install;
142
+ }
143
+
144
+ /** Aliases so `/agents cc` etc. resolve. */
145
+ export const ENGINE_ALIASES = {
146
+ cc: "claude", "claude-code": "claude", openai: "codex", gpt: "codex", google: "gemini",
147
+ pc: "privacycode", getprivacycode: "privacycode", privacy: "privacycode",
148
+ "kimi-cli": "kimi", "kimi-code": "kimi", moonshot: "kimi",
149
+ "qwen-code": "qwen", qwencode: "qwen", alibaba: "qwen",
150
+ // `dsc` and `deepseek-code` are the binary names the package installs; accept
151
+ // both as engine names so whichever one someone has seen resolves.
152
+ ds: "deepseek", dsc: "deepseek", "deepseek-code": "deepseek",
153
+ };
154
+
155
+ /** Resolve a name/alias to `[key, engine]`, or null. */
156
+ export function resolveEngine(token) {
157
+ if (!token) return null;
158
+ const t = String(token).trim().toLowerCase();
159
+ // Own properties only: ENGINES/ALIASES are plain object literals, so a name
160
+ // like `constructor` or `__proto__` would otherwise resolve to something off
161
+ // Object.prototype and be handed on as an engine with no bin/install.
162
+ const key = Object.hasOwn(ENGINES, t) ? t : Object.hasOwn(ENGINE_ALIASES, t) ? ENGINE_ALIASES[t] : null;
163
+ return key ? [key, ENGINES[key]] : null;
164
+ }
165
+
166
+ // `extraDirs` are directories a vendor installer drops a binary into without
167
+ // putting it on PATH for the session that ran the install (turso's official
168
+ // script unpacks to $HOME/.turso and only appends it to your shell profile).
169
+ // Searching them after PATH keeps a real `turso` on PATH winning, while still
170
+ // finding the one we just installed.
171
+ function executableCandidates(bin, extraDirs = []) {
172
+ const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
173
+ const dirs = path.isAbsolute(bin) || bin.includes(path.sep)
174
+ ? [""]
175
+ : [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)];
176
+ const seen = new Set();
177
+ const candidates = [];
178
+ for (const dir of dirs) {
179
+ for (const ext of exts) {
180
+ const candidate = dir ? path.join(dir, bin + ext) : bin + ext;
181
+ const key = candidate.toLowerCase();
182
+ if (!seen.has(key)) {
183
+ seen.add(key);
184
+ candidates.push(candidate);
185
+ }
186
+ }
187
+ }
188
+ return candidates;
189
+ }
190
+
191
+ export function resolveExecutable(bin, extraDirs = []) {
192
+ for (const candidate of executableCandidates(bin, extraDirs)) {
193
+ try {
194
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
195
+ } catch { /* keep looking */ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function nodeShebang(file) {
201
+ try {
202
+ const head = readFileSync(file, "utf8").slice(0, 80);
203
+ return /^#!.*\bnode(?:\.exe)?\b/.test(head);
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ function spawnSpec(bin, args = [], extraDirs = []) {
210
+ const resolved = resolveExecutable(bin, extraDirs);
211
+ if (!resolved) return { cmd: bin, args };
212
+ if (process.platform === "win32" && path.extname(resolved) === "" && nodeShebang(resolved)) {
213
+ return { cmd: process.execPath, args: [resolved, ...args] };
214
+ }
215
+ return { cmd: resolved, args };
216
+ }
217
+
218
+ /** Is `bin` an executable on PATH (or in one of `extraDirs`)? (cross-platform-ish) */
219
+ export function isInstalled(bin, extraDirs = []) {
220
+ return Boolean(resolveExecutable(bin, extraDirs));
221
+ }
222
+
223
+ // Headless "run one prompt, print the answer, exit" invocation per engine — the
224
+ // non-interactive mode the ai() moshscript shortcut captures stdout from. Kept
225
+ // as a pure map so it's unit-tested without spawning engines.
226
+ const AI_EXEC = {
227
+ claude: (p) => ["-p", p], // claude print mode
228
+ codex: (p) => ["exec", p], // codex non-interactive
229
+ gemini: (p) => ["-p", p], // gemini prompt mode
230
+ opencode: (p) => ["run", p], // opencode one-shot
231
+ privacycode: (p) => ["run", p], // privacycode one-shot (opencode-derived)
232
+ aider: (p) => ["--message", p, "--yes", "--no-auto-commits"], // aider single message
233
+ kimi: (p) => ["-p", p], // kimi prompt mode (prints the response, text by default)
234
+ qwen: (p) => ["-p", p], // qwen prompt mode (gemini-derived)
235
+ deepseek: (p) => ["--headless", "-p", p], // deepseek: -p runs one prompt and exits; --headless drops the TUI so stdout is pipe-clean
236
+ };
237
+
238
+ /** argv that runs `prompt` headlessly on `engine` (throws if it has no headless mode). */
239
+ export function aiExecArgs(engine, prompt) {
240
+ const fn = Object.hasOwn(AI_EXEC, engine) ? AI_EXEC[engine] : null;
241
+ if (!fn) throw new Error(`moshscript: ai() has no headless mode for "${engine}"`);
242
+ return fn(String(prompt));
243
+ }
244
+
245
+ /**
246
+ * First installed engine that supports headless ai(), honoring a preference.
247
+ *
248
+ * A preference names an engine the same way every other engine surface does
249
+ * (`/agents cc`, `moshcode start cc`, `moshcode upgrade cc` — README: "name
250
+ * any; alias ok"), so resolve ALIASES here too. Matching raw ENGINES keys only
251
+ * made `ai(prompt, { engine: "cc" })` read as "no such engine" and fail with
252
+ * "needs an installed engine" even when Claude was installed. An unknown name
253
+ * still yields null.
254
+ */
255
+ export function pickAiEngine(preferred) {
256
+ const wanted = preferred ? resolveEngine(preferred)?.[0] : null;
257
+ const order = preferred ? (wanted ? [wanted] : []) : ["claude", "codex", "opencode", "privacycode", "gemini", "kimi", "qwen", "deepseek", "aider"];
258
+ for (const key of order) {
259
+ if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin, ENGINES[key].binDirs)) return key;
260
+ }
261
+ return null;
262
+ }
263
+
264
+ /** Engine entries annotated with install status. */
265
+ export function engineStatus() {
266
+ // Search each engine's own install dir as well as PATH — kimi's installer only
267
+ // adds ~/.kimi-code/bin to your shell rc, so PATH alone reports it missing in
268
+ // the very session that installed it. (Inert for engines without binDirs.)
269
+ return Object.entries(ENGINES).map(([key, e]) => ({ key, ...e, installed: isInstalled(e.bin, e.binDirs) }));
270
+ }
271
+
272
+ export function engineList() {
273
+ return Object.entries(ENGINES).map(([k, v]) => ` ${k.padEnd(10)} ${v.desc}`).join("\n");
274
+ }
275
+
276
+ /**
277
+ * Args for an agent-mode launch (`/agents <engine>` / `moshcode agents <engine>`):
278
+ * the engine's native agents-view invocation when it has one (so you land on your
279
+ * agent list), else its autonomous bypass flags. Caller-supplied args follow.
280
+ */
281
+ export function agentLaunchArgs(engine, args = []) {
282
+ const lead = engine.agentsView || engine.agentArgs || [];
283
+ return [...lead, ...args];
284
+ }
285
+
286
+ /**
287
+ * Spawn an arbitrary command with stdio inherited (so its own progress/prompts
288
+ * own the terminal). Resolves { ok, code, signal } on exit. Used by install +
289
+ * upgrade to run engine installers/updaters.
290
+ */
291
+ export function runCmd(cmd, args = []) {
292
+ return new Promise((resolve) => {
293
+ let child;
294
+ const spec = spawnSpec(cmd, args);
295
+ try { child = spawn(spec.cmd, spec.args, { stdio: "inherit" }); }
296
+ catch (e) { resolve({ ok: false, error: e }); return; }
297
+ child.on("error", (e) => resolve({ ok: false, error: e }));
298
+ child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
299
+ });
300
+ }
301
+
302
+ /**
303
+ * True only when a child actually ran and exited 0. Node reports a signal death
304
+ * as `code === null` (the signal name lands in `signal` instead), so a killed
305
+ * child — OOM, a timeout wrapper's SIGTERM, Ctrl-C — must not read as success
306
+ * just because it has no exit code.
307
+ */
308
+ export function ranOk(r) {
309
+ return Boolean(r?.ok) && r.code === 0;
310
+ }
311
+
312
+ /**
313
+ * Short human reason a `runCmd` result failed: "code 128", "SIGKILL", or the
314
+ * spawn error message. Null when it succeeded.
315
+ */
316
+ export function exitReason(r) {
317
+ if (ranOk(r)) return null;
318
+ if (r?.error) return r.error.message || String(r.error);
319
+ if (r?.code != null) return `code ${r.code}`;
320
+ if (r?.signal) return r.signal;
321
+ return "unknown error";
322
+ }
323
+
324
+ /**
325
+ * Hand the current process streams to an external CLI. Arguments, cwd, and the
326
+ * environment are inherited unchanged unless that target explicitly asks for
327
+ * environment keys to be stripped (Claude uses this to avoid nested-session
328
+ * markers). `target.binDirs` extends the executable search past PATH for tools
329
+ * whose installer drops the binary somewhere PATH won't see until the next
330
+ * shell. Resolves { ok, code, signal } when the child exits.
331
+ */
332
+ export function openPassthrough(target, args = [], { onOutput } = {}) {
333
+ return new Promise((resolve) => {
334
+ let env = process.env;
335
+ if (target.stripEnv?.length) {
336
+ env = { ...process.env };
337
+ for (const k of target.stripEnv) delete env[k];
338
+ }
339
+ const spec = spawnSpec(target.bin, args, target.binDirs || []);
340
+
341
+ // With a mirror attached, run the child under a pseudo-terminal so a copy
342
+ // of its output can be streamed to the session page. `inherit` alone hands
343
+ // the child the tty's own file descriptors, so none of its bytes ever pass
344
+ // through this process. See src/pty.mjs for why this is script(1) and not
345
+ // a pipe or node-pty.
346
+ let transcript = null;
347
+ let workDir = null;
348
+ let stopFollow = null;
349
+ let launch = { ...spec, stdio: "inherit" };
350
+ if (ptyEnabled(onOutput)) {
351
+ try {
352
+ workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
353
+ transcript = path.join(workDir, "transcript");
354
+ writeFileSync(transcript, "");
355
+ const wrapped = ptySpec(spec.cmd, spec.args, transcript, scriptFlavor());
356
+ if (wrapped) {
357
+ launch = { ...wrapped, stdio: "inherit" };
358
+ let first = true;
359
+ stopFollow = followFile(transcript, (chunk) => {
360
+ const clean = stripScriptBanner(chunk, first);
361
+ first = false;
362
+ if (clean) onOutput(clean);
363
+ });
364
+ }
365
+ } catch {
366
+ // Capture is a nicety; never let it stop the session from opening.
367
+ transcript = null;
368
+ }
369
+ }
370
+
371
+ const cleanup = () => {
372
+ try { stopFollow?.(); } catch { /* nothing left to drain */ }
373
+ if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
374
+ };
375
+
376
+ let child;
377
+ try { child = spawn(launch.cmd, launch.args, { stdio: "inherit", env }); }
378
+ catch (e) { cleanup(); resolve({ ok: false, error: e }); return; }
379
+ child.on("error", (e) => { cleanup(); resolve({ ok: false, error: e }); });
380
+ child.on("exit", (code, signal) => { cleanup(); resolve({ ok: true, code, signal }); });
381
+ });
382
+ }
383
+
384
+ // Backwards-compatible engine-oriented name used by the existing CLI/TUI.
385
+ export const openSession = openPassthrough;
@@ -0,0 +1,85 @@
1
+ // Escalating one command, instead of asking the operator to escalate the CLI.
2
+ //
3
+ // `dns enable` genuinely needs root: it writes /etc/resolver/<tld>, an
4
+ // /etc/systemd/resolved.conf.d drop-in, or /etc/dnsmasq.d, and binds :53. The
5
+ // old advice was to re-run the whole CLI — `sudo moshcode dns enable`. That
6
+ // works, but it teaches a habit that has a sharp edge elsewhere in this same
7
+ // CLI: `moshcode update` self-updates by re-running the installer, and every
8
+ // path the installer uses comes from $HOME. Under sudo that is /root, so
9
+ // `sudo moshcode update` reinstalls moshcode into root's home and leaves the
10
+ // operator with a `moshcode` on PATH they cannot execute.
11
+ //
12
+ // So: never ask for a privileged CLI. Ask for a privileged *step*, and let
13
+ // sudo do what it is for — prompt for a password, raise one command.
14
+ //
15
+ // Every input is injectable because the interesting cases (no tty, no sudo,
16
+ // user cancels at the prompt) are ones you cannot reach from a test suite
17
+ // otherwise.
18
+
19
+ import { spawnSync } from "node:child_process";
20
+
21
+ /** Set on the re-executed child so a misconfigured escalator cannot loop. */
22
+ export const ESCALATION_MARKER = "MOSHCODE_ESCALATED";
23
+
24
+ const CANDIDATES = ["sudo", "doas"];
25
+
26
+ function defaultProbe(tool) {
27
+ return spawnSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }).status === 0;
28
+ }
29
+
30
+ /**
31
+ * Which escalation helper this machine has, honouring an explicit override.
32
+ * Returns null when there is none — a container running as a non-root user
33
+ * with no sudo is a normal place to end up, and it should get advice rather
34
+ * than a crash.
35
+ */
36
+ export function findEscalator({ env = process.env, probe = defaultProbe } = {}) {
37
+ const override = env.MOSHCODE_ESCALATOR;
38
+ if (override) return probe(override) ? override : null;
39
+ for (const tool of CANDIDATES) {
40
+ if (probe(tool)) return tool;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Re-run this CLI's own argv under the escalation helper.
47
+ *
48
+ * Returns `{ ran: false, reason }` when escalation is not possible, so the
49
+ * caller can fall back to printing the manual command. It never throws: a
50
+ * failure to escalate has to degrade into advice, not a stack trace.
51
+ *
52
+ * `{ ran: true, code }` means the privileged child ran to completion — code 1
53
+ * covers the operator cancelling at the password prompt, which is a refusal,
54
+ * not an error to retry.
55
+ */
56
+ export function escalateSelf({
57
+ args,
58
+ what = args.join(" "),
59
+ env = process.env,
60
+ argv = process.argv,
61
+ isTTY = Boolean(process.stdin?.isTTY && process.stdout?.isTTY),
62
+ spawn = spawnSync,
63
+ probe = defaultProbe,
64
+ out = console.log,
65
+ } = {}) {
66
+ if (env[ESCALATION_MARKER]) return { ran: false, reason: "already-escalated" };
67
+ // Without a terminal there is nowhere to type a password. sudo would either
68
+ // fail or, worse, sit waiting in a CI log until the job times out.
69
+ if (!isTTY) return { ran: false, reason: "no-tty" };
70
+
71
+ const tool = findEscalator({ env, probe });
72
+ if (!tool) return { ran: false, reason: "no-escalator" };
73
+
74
+ const [runtime, script] = argv;
75
+ if (!runtime || !script) return { ran: false, reason: "no-argv" };
76
+
77
+ out(`· ${what} needs root — re-running it with ${tool}. You may be prompted for your password.`);
78
+ const result = spawn(tool, [runtime, script, ...args], {
79
+ stdio: "inherit",
80
+ env: { ...env, [ESCALATION_MARKER]: "1" },
81
+ });
82
+
83
+ if (result?.error) return { ran: false, reason: "spawn-failed" };
84
+ return { ran: true, code: typeof result?.status === "number" ? result.status : 1 };
85
+ }