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.
- package/LICENSE +21 -0
- package/README.md +580 -0
- package/bin/moshcode.mjs +674 -0
- package/bin/moshscript.mjs +29 -0
- package/examples/alive.mosh +6 -0
- package/examples/scripting-the-cli.mosh +21 -0
- package/examples/team-secrets.mosh +20 -0
- package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
- package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
- package/examples/templates/bun-caddy-sqlite/README.md +97 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
- package/examples/templates/bun-caddy-sqlite/package.json +15 -0
- package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
- package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
- package/examples/templates/bun-caddy-sqlite/template.json +10 -0
- package/examples/templates/caddy-proxy/Caddyfile +36 -0
- package/examples/templates/caddy-proxy/README.md +104 -0
- package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-proxy/template.json +8 -0
- package/examples/templates/caddy-static/Caddyfile +16 -0
- package/examples/templates/caddy-static/README.md +90 -0
- package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-static/site/index.html +11 -0
- package/examples/templates/caddy-static/template.json +8 -0
- package/install.sh +194 -0
- package/package.json +28 -0
- package/prd/0000-template.md +49 -0
- package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
- package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
- package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
- package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
- package/prd/0005-hosted-moshpit-resolver.md +192 -0
- package/prd/0006-help.md +359 -0
- package/prd/0007-profullstack-site-init.md +1183 -0
- package/prd/README.md +26 -0
- package/src/ads.mjs +58 -0
- package/src/auth.mjs +193 -0
- package/src/cli-schema.mjs +533 -0
- package/src/cli.mjs +118 -0
- package/src/commands.mjs +259 -0
- package/src/completion.mjs +594 -0
- package/src/console.mjs +244 -0
- package/src/dns-system.mjs +404 -0
- package/src/dns.mjs +2872 -0
- package/src/doh-server.mjs +256 -0
- package/src/doh.mjs +218 -0
- package/src/engines.mjs +385 -0
- package/src/escalate.mjs +85 -0
- package/src/help.mjs +443 -0
- package/src/integrations.mjs +265 -0
- package/src/mcp-catalog.mjs +50 -0
- package/src/mcp.mjs +155 -0
- package/src/mirror.mjs +187 -0
- package/src/notify.mjs +86 -0
- package/src/open-url.mjs +34 -0
- package/src/parking-http.mjs +65 -0
- package/src/pins.mjs +190 -0
- package/src/pit-url.mjs +13 -0
- package/src/prd.mjs +341 -0
- package/src/pty.mjs +176 -0
- package/src/pwd.mjs +103 -0
- package/src/registry.mjs +37 -0
- package/src/release-install.mjs +191 -0
- package/src/runtime.mjs +161 -0
- package/src/selfupdate.mjs +215 -0
- package/src/serve.mjs +502 -0
- package/src/skills.mjs +93 -0
- package/src/tabs.mjs +144 -0
- package/src/templates.mjs +456 -0
- package/src/tools.mjs +231 -0
- package/src/trade.mjs +137 -0
- package/src/trust.mjs +712 -0
- package/src/tui.mjs +736 -0
- package/src/ui.mjs +49 -0
- package/src/uninstall.mjs +113 -0
- package/src/upgrade.mjs +217 -0
package/src/engines.mjs
ADDED
|
@@ -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;
|
package/src/escalate.mjs
ADDED
|
@@ -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
|
+
}
|