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,244 @@
1
+ // `moshcode console` — a browser terminal on this box, gated by moshcode login.
2
+ //
3
+ // Rather than reimplement a terminal in the session mirror (xterm.js, keystroke
4
+ // relay, cursor addressing), run ttyd — which is already a terminal — and put an
5
+ // authenticating reverse proxy in front of it. You get arrow keys, history, and
6
+ // full-screen TUIs because it is a real pty on the other end, not a log view.
7
+ //
8
+ // WHY THE GATEWAY RUNS HERE, not in apps/pwa: the hosted app is on Railway and
9
+ // the shell you want is on this machine. A proxy inside apps/pwa would have to
10
+ // reach back across the internet to a port on the dev box, which means exposing
11
+ // that port — the exact thing worth avoiding. So the box serves its own gateway
12
+ // and asks app.moshcode.sh only "is this token yours?".
13
+ //
14
+ // SECURITY: everything behind this is a shell. ttyd itself MUST stay bound to
15
+ // loopback (`ttyd -i 127.0.0.1`); the only way in is a valid moshcode token,
16
+ // which is exchanged once for a short-lived HMAC cookie. Binding the gateway to
17
+ // a tailnet address instead of 0.0.0.0 is strictly better and is what the docs
18
+ // recommend.
19
+ import http from "node:http";
20
+ import net from "node:net";
21
+ import crypto from "node:crypto";
22
+
23
+ import { loadCreds } from "./auth.mjs";
24
+
25
+ export const DEFAULT_TTYD = "127.0.0.1:7681";
26
+ const COOKIE = "moshcode_console";
27
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // re-auth twice a day
28
+
29
+ /** Split "host:port" into the pieces net/http want. Defaults to ttyd's port. */
30
+ export function parseTarget(target = DEFAULT_TTYD) {
31
+ const raw = String(target || DEFAULT_TTYD).trim() || DEFAULT_TTYD;
32
+ const url = new URL(raw.includes("://") ? raw : `http://${raw}`);
33
+ const host = url.hostname.replace(/^\[|\]$/g, "");
34
+ return { host, port: Number(url.port || 7681) };
35
+ }
36
+
37
+ export function parseConsolePort(input) {
38
+ const raw = String(input ?? "").trim();
39
+ if (!/^\d+$/.test(raw)) return null;
40
+ const port = Number(raw);
41
+ return Number.isSafeInteger(port) && port >= 1 && port <= 65535 ? port : null;
42
+ }
43
+
44
+ const targetHostHeader = ({ host, port }) =>
45
+ `${host.includes(":") ? `[${host}]` : host}:${port}`;
46
+
47
+ /** Cookies from a raw header — cookie-parser is an express thing and the
48
+ * websocket upgrade never reaches express. */
49
+ export function parseCookies(header = "") {
50
+ const out = {};
51
+ for (const part of String(header).split(";")) {
52
+ const eq = part.indexOf("=");
53
+ if (eq < 0) continue;
54
+ const k = part.slice(0, eq).trim();
55
+ if (!k) continue;
56
+ const raw = part.slice(eq + 1).trim();
57
+ // A cookie value is whatever the client chose to send, and decodeURIComponent
58
+ // throws on a stray "%". This runs before any auth check, in an async request
59
+ // handler and in an `upgrade` listener — a throw in either takes the whole
60
+ // gateway down, so an unauthenticated client must not be able to cause one.
61
+ // Falling back to the raw value is what the `cookie` package does too.
62
+ try { out[k] = decodeURIComponent(raw); } catch { out[k] = raw; }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * A signed, expiring session value. HMAC over the payload with a per-process
69
+ * secret, so a cookie cannot be forged and does not survive a restart.
70
+ */
71
+ export function mintCookie(secret, { user = "", now = Date.now(), ttlMs = SESSION_TTL_MS } = {}) {
72
+ // base64url, not percent-encoding: the field is dot-delimited and an email
73
+ // ("a@profullstack.com") is full of dots that encodeURIComponent leaves alone.
74
+ const payload = `${Buffer.from(String(user)).toString("base64url")}.${now + ttlMs}`;
75
+ const mac = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
76
+ return `${payload}.${mac}`;
77
+ }
78
+
79
+ /** Verify a cookie minted above. Returns the user, or null when invalid/expired. */
80
+ export function readCookie(secret, value, now = Date.now()) {
81
+ const parts = String(value || "").split(".");
82
+ if (parts.length !== 3) return null;
83
+ const [user, expiry, mac] = parts;
84
+ const payload = `${user}.${expiry}`;
85
+ const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
86
+ // timingSafeEqual throws on length mismatch, so compare lengths first.
87
+ const a = Buffer.from(mac);
88
+ const b = Buffer.from(expected);
89
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
90
+ if (!Number(expiry) || Number(expiry) < now) return null;
91
+ return Buffer.from(user, "base64url").toString("utf8");
92
+ }
93
+
94
+ /**
95
+ * Ask the moshcode app whether this token is a real login. The gateway trusts
96
+ * the app for identity rather than keeping its own user list.
97
+ */
98
+ export async function verifyToken(api, token, fetchImpl = fetch) {
99
+ if (!token) return null;
100
+ try {
101
+ const r = await fetchImpl(`${api.replace(/\/+$/, "")}/api/me`, {
102
+ headers: { authorization: `Bearer ${token}` },
103
+ });
104
+ if (!r.ok) return null;
105
+ const body = await r.json().catch(() => ({}));
106
+ return body?.user?.email || body?.email || body?.user?.id || "authenticated";
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ const DENIED = `<!doctype html><meta charset="utf-8"><title>moshcode console</title>
113
+ <body style="background:#070806;color:#edf2e4;font-family:monospace;padding:14vh 24px;text-align:center">
114
+ <h1 style="color:#a6ff1a">🔒 not logged in</h1>
115
+ <p>this terminal is gated by your moshcode login.</p>
116
+ <p style="color:#8a8f80">run <code>moshcode console</code> on a machine where you have run <code>moshcode login</code>.</p>
117
+ </body>`;
118
+
119
+ /** Copy a client request through to ttyd and stream the answer back. */
120
+ function proxyHttp(req, res, target) {
121
+ const upstream = http.request(
122
+ { host: target.host, port: target.port, method: req.method, path: req.url, headers: { ...req.headers, host: targetHostHeader(target) } },
123
+ (up) => { res.writeHead(up.statusCode || 502, up.headers); up.pipe(res); },
124
+ );
125
+ upstream.on("error", () => {
126
+ if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
127
+ res.end("console backend unreachable — is ttyd running?");
128
+ });
129
+ req.pipe(upstream);
130
+ }
131
+
132
+ /**
133
+ * Hand the websocket over at the socket level. ttyd's terminal is a websocket,
134
+ * and an upgrade cannot be proxied with http.request — we replay the handshake
135
+ * onto a raw connection and then just let the two sockets talk.
136
+ */
137
+ function proxyUpgrade(req, socket, head, target) {
138
+ const upstream = net.connect(target.port, target.host, () => {
139
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
140
+ for (const [k, v] of Object.entries(req.headers)) {
141
+ for (const one of Array.isArray(v) ? v : [v]) lines.push(`${k}: ${one}`);
142
+ }
143
+ upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
144
+ if (head?.length) upstream.write(head);
145
+ socket.pipe(upstream);
146
+ upstream.pipe(socket);
147
+ });
148
+ const bail = () => { try { socket.destroy(); } catch { /* already gone */ } };
149
+ upstream.on("error", bail);
150
+ socket.on("error", () => { try { upstream.destroy(); } catch { /* already gone */ } });
151
+ }
152
+
153
+ /**
154
+ * The gateway. `?token=<moshcode token>` authenticates once and is swapped for
155
+ * the cookie, so the token never has to appear again (or sit in browser history
156
+ * for the rest of the session — the handshake redirects it away).
157
+ */
158
+ export function createConsoleServer({
159
+ ttyd = DEFAULT_TTYD,
160
+ api = "https://app.moshcode.sh",
161
+ secret = crypto.randomBytes(32).toString("hex"),
162
+ verify = verifyToken,
163
+ } = {}) {
164
+ const target = parseTarget(ttyd);
165
+
166
+ const server = http.createServer(async (req, res) => {
167
+ const url = new URL(req.url, "http://localhost");
168
+ const cookies = parseCookies(req.headers.cookie);
169
+ if (readCookie(secret, cookies[COOKIE])) return proxyHttp(req, res, target);
170
+
171
+ const token = url.searchParams.get("token");
172
+ const user = token ? await verify(api, token) : null;
173
+ if (!user) {
174
+ res.writeHead(401, { "content-type": "text/html" });
175
+ return res.end(DENIED);
176
+ }
177
+ // Drop the token from the URL so it stops travelling with every request.
178
+ url.searchParams.delete("token");
179
+ res.writeHead(302, {
180
+ "set-cookie": `${COOKIE}=${mintCookie(secret, { user })}; HttpOnly; SameSite=Lax; Path=/`,
181
+ location: `${url.pathname}${url.search}`,
182
+ });
183
+ res.end();
184
+ });
185
+
186
+ server.on("upgrade", (req, socket, head) => {
187
+ // The websocket carries the terminal itself; an unauthenticated upgrade
188
+ // would hand out a shell regardless of how well the page is guarded.
189
+ if (!readCookie(secret, parseCookies(req.headers.cookie)[COOKIE])) {
190
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
191
+ return socket.destroy();
192
+ }
193
+ proxyUpgrade(req, socket, head, target);
194
+ });
195
+
196
+ return server;
197
+ }
198
+
199
+ /** The URL to hand a browser: this box's gateway, carrying a one-time token. */
200
+ export function consoleUrl(base, token) {
201
+ const u = new URL(String(base));
202
+ if (token) u.searchParams.set("token", token);
203
+ return u.toString();
204
+ }
205
+
206
+ /** `moshcode console` — print (and optionally serve) the browser terminal. */
207
+ export async function consoleCommand(args = []) {
208
+ const serve = args.includes("serve");
209
+ const flag = (name, fallback) => {
210
+ const i = args.indexOf(`--${name}`);
211
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
212
+ };
213
+ const creds = loadCreds();
214
+ const api = creds?.api || "https://app.moshcode.sh";
215
+
216
+ if (!serve) {
217
+ const base = flag("url", process.env.MOSHCODE_CONSOLE_URL);
218
+ if (!base) {
219
+ console.error("usage: moshcode console serve [--port 7682] [--ttyd 127.0.0.1:7681] [--bind 127.0.0.1]\n"
220
+ + " moshcode console --url https://dev.example.com/ (open an existing gateway)");
221
+ return 1;
222
+ }
223
+ if (!creds?.token) { console.error("not logged in — run: moshcode login"); return 1; }
224
+ console.log(consoleUrl(base, creds.token));
225
+ return 0;
226
+ }
227
+
228
+ const rawPort = flag("port", "7682");
229
+ const port = parseConsolePort(rawPort);
230
+ if (port === null) {
231
+ console.error(`--port needs a decimal integer from 1 to 65535, got ${JSON.stringify(rawPort)}`);
232
+ return 1;
233
+ }
234
+ const bind = flag("bind", "127.0.0.1");
235
+ const ttyd = flag("ttyd", DEFAULT_TTYD);
236
+ const server = createConsoleServer({ ttyd, api });
237
+ await new Promise((resolve) => server.listen(port, bind, resolve));
238
+ console.log(`🖥 moshcode console gateway on http://${bind}:${port} → ttyd ${ttyd}`);
239
+ console.log(` auth: moshcode login tokens verified against ${api}`);
240
+ if (bind === "0.0.0.0") {
241
+ console.log(" ! bound to every interface — prefer a tailnet address or a reverse proxy with TLS");
242
+ }
243
+ return new Promise(() => {}); // serve until killed
244
+ }
@@ -0,0 +1,404 @@
1
+ // Routing Moshpit TLDs to the local bridge, on whatever OS this is.
2
+ //
3
+ // Every supported system has a way to send *one suffix* to a different
4
+ // nameserver without becoming the resolver for everything else, and this uses
5
+ // that mechanism on each rather than the blunt one. Replacing the machine's
6
+ // nameserver would mean every lookup on the box depends on this bridge being
7
+ // alive; routing only `.moshpit` and friends means the worst failure is that
8
+ // Moshpit names stop working, which is exactly the blast radius it should have.
9
+ //
10
+ // macOS /etc/resolver/<tld> one file per TLD, read per query
11
+ // Linux systemd-resolved drop-in DNS= + Domains=~tld routing-only domains
12
+ // Linux dnsmasq server=/tld/host#port
13
+ // Windows NRPT rule per namespace Add-DnsClientNrptRule -Namespace .tld
14
+ //
15
+ // The functions here return a *plan* — a list of steps as data — instead of
16
+ // running anything. Everything that edits system DNS wants to be inspectable
17
+ // before it runs (`--dry-run` prints the plan verbatim), and a plan is testable
18
+ // on any OS without touching that OS's resolver.
19
+
20
+ /**
21
+ * Windows NRPT rules name a server but have nowhere to put a port, so on
22
+ * Windows the bridge has to be on 53 or the rule cannot point at it. macOS and
23
+ * systemd-resolved both accept a port, which is why the default elsewhere is an
24
+ * unprivileged one.
25
+ */
26
+ export const WINDOWS_REQUIRED_PORT = 53;
27
+
28
+ export function detectPlatform(platform = process.platform) {
29
+ if (platform === "darwin") return "macos";
30
+ if (platform === "win32") return "windows";
31
+ if (platform === "linux") return "linux";
32
+ return null;
33
+ }
34
+
35
+ /** A step is either a file to write, a file to remove, or a command to run. */
36
+ const write = (path, content, why) => ({ kind: "write", path, content, why });
37
+ const remove = (path, why) => ({ kind: "remove", path, why });
38
+ const run = (command, args, why) => ({ kind: "run", command, args, why });
39
+
40
+ /**
41
+ * What it would take to route `tlds` at `host:port` on this platform.
42
+ *
43
+ * `linuxBackend` picks between the two Linux mechanisms. systemd-resolved is
44
+ * the default because it is what Ubuntu ships; dnsmasq is for the machines that
45
+ * do not run it.
46
+ */
47
+ export function enablePlan({
48
+ platform,
49
+ tlds,
50
+ host = "127.0.0.1",
51
+ port = 5354,
52
+ linuxBackend = "systemd-resolved",
53
+ // Catch-all routing is opt-in and conditional, never assumed. Sending every
54
+ // lookup on the machine to the bridge is only safe if the bridge can forward
55
+ // the ones that are not ours — so the caller passes the upstreams it found,
56
+ // and an empty list keeps the per-ending routing that cannot break anything
57
+ // beyond Moshpit names. Getting this backwards takes the whole box offline.
58
+ upstreams = [],
59
+ }) {
60
+ const catchAll = Array.isArray(upstreams) && upstreams.length > 0;
61
+ const clean = [...new Set((tlds || []).map((t) => String(t).replace(/^\.+/, "").toLowerCase()).filter(Boolean))];
62
+ if (!clean.length) throw new Error("no TLDs to route");
63
+
64
+ if (platform === "macos") {
65
+ // One file per TLD. macOS reads /etc/resolver/<name> per query, so there is
66
+ // nothing to restart and nothing else on the machine is affected.
67
+ return {
68
+ platform,
69
+ elevated: true,
70
+ port,
71
+ steps: clean.map((tld) =>
72
+ write(
73
+ `/etc/resolver/${tld}`,
74
+ `# Written by \`moshcode dns enable\`.\nnameserver ${host}\nport ${port}\n`,
75
+ `send .${tld} to the local bridge`,
76
+ ),
77
+ ),
78
+ notes: ["macOS reads /etc/resolver per query — nothing to restart."],
79
+ };
80
+ }
81
+
82
+ if (platform === "linux" && linuxBackend === "dnsmasq") {
83
+ return {
84
+ platform,
85
+ elevated: true,
86
+ port,
87
+ steps: [
88
+ write(
89
+ "/etc/dnsmasq.d/moshpit.conf",
90
+ catchAll
91
+ ? [
92
+ "# Written by `moshcode dns enable`.",
93
+ "# no-resolv so dnsmasq does not also inherit upstreams that point back here.",
94
+ "no-resolv",
95
+ `server=${host}#${port}`,
96
+ "",
97
+ ].join("\n")
98
+ : ["# Written by `moshcode dns enable`.", ...clean.map((t) => `server=/${t}/${host}#${port}`), ""].join("\n"),
99
+ catchAll ? "send every lookup to the bridge, which forwards what is not ours" : "route the Moshpit TLDs",
100
+ ),
101
+ run("systemctl", ["restart", "dnsmasq"], "dnsmasq reads its config at start"),
102
+ ],
103
+ notes: [],
104
+ };
105
+ }
106
+
107
+ if (platform === "linux") {
108
+ // `~tld` is a routing-only domain: it sends that suffix here without making
109
+ // this the default resolver for anything else.
110
+ return {
111
+ platform,
112
+ elevated: true,
113
+ port,
114
+ steps: [
115
+ write(
116
+ "/etc/systemd/resolved.conf.d/moshpit.conf",
117
+ catchAll
118
+ ? [
119
+ "# Written by `moshcode dns enable`. Sends every lookup to the local",
120
+ "# bridge, which answers claimed Moshpit endings and forwards the rest",
121
+ "# upstream untouched.",
122
+ "#",
123
+ "# Naming each ending instead does not survive the registry growing:",
124
+ "# systemd-resolved caps how many search domains it accepts and drops",
125
+ "# the remainder with no error a caller can see.",
126
+ "[Resolve]",
127
+ `DNS=${host}:${port}`,
128
+ "Domains=~.",
129
+ "",
130
+ ].join("\n")
131
+ : [
132
+ "# Written by `moshcode dns enable`. Routes Moshpit TLDs to the local",
133
+ "# bridge; every other name keeps using your normal resolver.",
134
+ "[Resolve]",
135
+ `DNS=${host}:${port}`,
136
+ `Domains=${clean.map((t) => `~${t}`).join(" ")}`,
137
+ "",
138
+ ].join("\n"),
139
+ catchAll
140
+ ? "send every lookup to the bridge, which forwards what is not ours"
141
+ : "route the Moshpit TLDs, and nothing else",
142
+ ),
143
+ run("systemctl", ["restart", "systemd-resolved"], "drop-ins are read at start"),
144
+ ],
145
+ notes: [],
146
+ };
147
+ }
148
+
149
+ if (platform === "windows") {
150
+ // An NRPT rule has no port field, so the bridge must be on 53 for Windows
151
+ // to be able to reach it at all. Caught here rather than at runtime, where
152
+ // the symptom would be every Moshpit name silently failing.
153
+ if (port !== WINDOWS_REQUIRED_PORT) {
154
+ throw new Error(
155
+ `Windows NRPT rules cannot carry a port, so the bridge must listen on ${WINDOWS_REQUIRED_PORT} ` +
156
+ `(asked for ${port}). Re-run with --port ${WINDOWS_REQUIRED_PORT}, as Administrator.`,
157
+ );
158
+ }
159
+ return {
160
+ platform,
161
+ elevated: true,
162
+ port,
163
+ steps: clean.map((tld) =>
164
+ run(
165
+ "powershell",
166
+ ["-NoProfile", "-Command", `Add-DnsClientNrptRule -Namespace ".${tld}" -NameServers "${host}"`],
167
+ `send .${tld} to the local bridge`,
168
+ ),
169
+ ),
170
+ notes: [`NRPT carries no port, so the bridge runs on ${WINDOWS_REQUIRED_PORT} here.`],
171
+ };
172
+ }
173
+
174
+ throw new Error(`unsupported platform: ${platform}`);
175
+ }
176
+
177
+ /**
178
+ * Undo it.
179
+ *
180
+ * Deliberately not derived from the enable plan: a machine may have been
181
+ * enabled with a TLD list that has since changed, and a disable that only
182
+ * removed what it currently knows about would strand the rest. On macOS and
183
+ * Windows the removal is therefore by pattern, not by list.
184
+ */
185
+ export function disablePlan({ platform, tlds = [], linuxBackend = "systemd-resolved" }) {
186
+ const clean = [...new Set((tlds || []).map((t) => String(t).replace(/^\.+/, "").toLowerCase()).filter(Boolean))];
187
+
188
+ if (platform === "macos") {
189
+ return {
190
+ platform,
191
+ elevated: true,
192
+ steps: clean.map((tld) => remove(`/etc/resolver/${tld}`, `stop routing .${tld}`)),
193
+ notes: clean.length
194
+ ? []
195
+ : ["No TLDs known — nothing removed. Delete /etc/resolver/<tld> by hand if any remain."],
196
+ };
197
+ }
198
+
199
+ if (platform === "linux" && linuxBackend === "dnsmasq") {
200
+ return {
201
+ platform,
202
+ elevated: true,
203
+ steps: [
204
+ remove("/etc/dnsmasq.d/moshpit.conf", "stop routing Moshpit TLDs"),
205
+ run("systemctl", ["restart", "dnsmasq"], "pick up the removal"),
206
+ ],
207
+ notes: [],
208
+ };
209
+ }
210
+
211
+ if (platform === "linux") {
212
+ return {
213
+ platform,
214
+ elevated: true,
215
+ steps: [
216
+ remove("/etc/systemd/resolved.conf.d/moshpit.conf", "stop routing Moshpit TLDs"),
217
+ run("systemctl", ["restart", "systemd-resolved"], "pick up the removal"),
218
+ ],
219
+ notes: [],
220
+ };
221
+ }
222
+
223
+ if (platform === "windows") {
224
+ // Matched on the comment we stamp rather than on a TLD list, so a rule
225
+ // survives us forgetting which TLDs were routed.
226
+ return {
227
+ platform,
228
+ elevated: true,
229
+ steps: [
230
+ run(
231
+ "powershell",
232
+ [
233
+ "-NoProfile",
234
+ "-Command",
235
+ "Get-DnsClientNrptRule | Where-Object { $_.NameServers -contains '127.0.0.1' } | Remove-DnsClientNrptRule -Force",
236
+ ],
237
+ "remove every NRPT rule pointing at the local bridge",
238
+ ),
239
+ ],
240
+ notes: [],
241
+ };
242
+ }
243
+
244
+ throw new Error(`unsupported platform: ${platform}`);
245
+ }
246
+
247
+ /** The plan as something a person can read before agreeing to run it. */
248
+ export function describePlan(plan) {
249
+ const lines = [];
250
+ for (const step of plan.steps) {
251
+ if (step.kind === "write") {
252
+ lines.push(`write ${step.path} # ${step.why}`);
253
+ for (const l of step.content.trimEnd().split("\n")) lines.push(` ${l}`);
254
+ } else if (step.kind === "remove") {
255
+ lines.push(`remove ${step.path} # ${step.why}`);
256
+ } else {
257
+ lines.push(`run ${step.command} ${step.args.join(" ")} # ${step.why}`);
258
+ }
259
+ }
260
+ for (const note of plan.notes || []) lines.push(`note ${note}`);
261
+ return lines.join("\n");
262
+ }
263
+
264
+ /**
265
+ * The port the bridge must listen on for this platform's routing to reach it.
266
+ *
267
+ * Only Windows constrains it, but asking here rather than special-casing at
268
+ * every call site keeps the one platform quirk in one place.
269
+ */
270
+ export function requiredPort(platform, preferred = 5354) {
271
+ return platform === "windows" ? WINDOWS_REQUIRED_PORT : preferred;
272
+ }
273
+
274
+ /* ------------------------------------------------------- running the plan */
275
+
276
+ import { spawn } from "node:child_process";
277
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
278
+ import { existsSync } from "node:fs";
279
+ import { dirname, join } from "node:path";
280
+ import { homedir, tmpdir } from "node:os";
281
+
282
+ /**
283
+ * Carry out a plan.
284
+ *
285
+ * Every step is attempted and reported; one failure does not abort the rest.
286
+ * A half-applied routing config is a real state a machine can end up in — the
287
+ * user hit Ctrl-C, or one write needed a directory that did not exist — and
288
+ * telling them which steps landed is what makes it recoverable. Stopping at the
289
+ * first error would leave them guessing.
290
+ */
291
+ export async function applyPlan(plan, { runner = defaultRunner, dryRun = false } = {}) {
292
+ const results = [];
293
+ for (const step of plan.steps) {
294
+ if (dryRun) {
295
+ results.push({ step, ok: true, skipped: true });
296
+ continue;
297
+ }
298
+ try {
299
+ if (step.kind === "write") {
300
+ await mkdir(dirname(step.path), { recursive: true });
301
+ await writeFile(step.path, step.content);
302
+ } else if (step.kind === "remove") {
303
+ await rm(step.path, { force: true });
304
+ } else {
305
+ await runner(step.command, step.args);
306
+ }
307
+ results.push({ step, ok: true });
308
+ } catch (error) {
309
+ results.push({ step, ok: false, error: error.message });
310
+ }
311
+ }
312
+ return { results, ok: results.every((r) => r.ok) };
313
+ }
314
+
315
+ function defaultRunner(command, args) {
316
+ return new Promise((resolve, reject) => {
317
+ const child = spawn(command, args, { stdio: "ignore" });
318
+ child.on("error", reject);
319
+ child.on("exit", (code) =>
320
+ code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`)),
321
+ );
322
+ });
323
+ }
324
+
325
+ /* ------------------------------------------------------------- the daemon */
326
+
327
+ /**
328
+ * Where the running bridge records itself.
329
+ *
330
+ * Under the user's own directory rather than /var/run: the bridge does not need
331
+ * root to listen on 5354, and requiring it to write a pidfile somewhere
332
+ * privileged would make the whole daemon need privileges it otherwise does not.
333
+ */
334
+ export function pidfilePath() {
335
+ const base = process.env.XDG_RUNTIME_DIR || join(homedir(), ".moshcode") || tmpdir();
336
+ return join(base, "moshpit-dns.pid");
337
+ }
338
+
339
+ export async function readPid(path = pidfilePath()) {
340
+ try {
341
+ const pid = Number((await readFile(path, "utf8")).trim());
342
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ /** Is that pid actually ours and alive? A stale pidfile must not read as running. */
349
+ export function isAlive(pid) {
350
+ if (!pid) return false;
351
+ try {
352
+ process.kill(pid, 0);
353
+ return true;
354
+ } catch {
355
+ return false;
356
+ }
357
+ }
358
+
359
+ export async function daemonStatus(path = pidfilePath()) {
360
+ const pid = await readPid(path);
361
+ if (!pid) return { running: false, pid: null, stale: false };
362
+ if (isAlive(pid)) return { running: true, pid, stale: false };
363
+ // The file outlived the process — a crash or a reboot. Reported rather than
364
+ // cleaned up silently, because "it says it is on but it is not" is the state
365
+ // that makes Moshpit names fail with routing still in place.
366
+ return { running: false, pid, stale: true };
367
+ }
368
+
369
+ /**
370
+ * Start the bridge detached, so the shell that launched it can exit.
371
+ *
372
+ * Not a systemd unit / launchd job / Windows service yet, which means it does
373
+ * not survive a reboot. `moshcode dns status` says so plainly rather than
374
+ * letting someone discover it when their names stop resolving.
375
+ */
376
+ export async function startDaemon({ port, registryBase, path = pidfilePath(), entry }) {
377
+ const existing = await daemonStatus(path);
378
+ if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true };
379
+
380
+ await mkdir(dirname(path), { recursive: true });
381
+ const args = [entry, "dns", "start", "--port", String(port)];
382
+ if (registryBase) args.push("--registry", registryBase);
383
+
384
+ const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
385
+ child.unref();
386
+ await writeFile(path, `${child.pid}\n`);
387
+ return { started: true, pid: child.pid, alreadyRunning: false };
388
+ }
389
+
390
+ export async function stopDaemon(path = pidfilePath()) {
391
+ const status = await daemonStatus(path);
392
+ if (!status.pid) return { stopped: false, reason: "not running" };
393
+ if (status.running) {
394
+ try {
395
+ process.kill(status.pid, "SIGTERM");
396
+ } catch (error) {
397
+ return { stopped: false, reason: error.message };
398
+ }
399
+ }
400
+ await rm(path, { force: true });
401
+ return { stopped: status.running, reason: status.stale ? "cleared a stale pidfile" : null };
402
+ }
403
+
404
+ export { existsSync as _existsSync };