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
package/src/notify.mjs ADDED
@@ -0,0 +1,86 @@
1
+ // notify + human-in-the-loop approvals — talks to the approvals app (app.moshcode.sh).
2
+ //
3
+ // notify()/ask() POST the approval to the app's ingest endpoint with the user's
4
+ // API key; the app fans it out to the operator's channels (email/SMS/Slack/
5
+ // Telegram/push) and returns the approval id + link. ask() then long-polls the
6
+ // app until the human opens the link, reads the context, and submits a reply.
7
+ //
8
+ // Config (env): MOSHCODE_API (default https://app.moshcode.sh), MOSHCODE_API_KEY
9
+ // (from the app's Settings → API keys). MOSHCODE_WEBHOOK_SECRET optionally signs
10
+ // the ingest for defense in depth. The HTTP layer and API key are injectable for tests.
11
+ import crypto from "node:crypto";
12
+ import { loadCreds } from "./auth.mjs";
13
+
14
+ // Prefer explicit env; otherwise fall back to `moshcode login` credentials, so a
15
+ // script Just Works after login without exporting anything.
16
+ const API = () => (process.env.MOSHCODE_API || loadCreds()?.api || "https://app.moshcode.sh").replace(/\/+$/, "");
17
+ const KEY = () => process.env.MOSHCODE_API_KEY || loadCreds()?.token || "";
18
+ const SECRET = () => process.env.MOSHCODE_WEBHOOK_SECRET || "";
19
+
20
+ function signHeaders(body) {
21
+ const secret = SECRET();
22
+ if (!secret) return {};
23
+ const ts = Math.floor(Date.now() / 1000);
24
+ const sig = crypto.createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
25
+ return { "x-moshcode-signature": `t=${ts},v1=${sig}` };
26
+ }
27
+
28
+ /** POST an approval to the app. Returns { ok, id, url, delivered, charged, warning } or { ok:false }. */
29
+ export async function ingestApproval(payload, { fetchImpl = fetch, key } = {}) {
30
+ const apiKey = key ?? KEY();
31
+ if (!apiKey) return { ok: false, error: "not logged in" };
32
+ const body = JSON.stringify(payload);
33
+ let res;
34
+ try {
35
+ res = await fetchImpl(`${API()}/api/approvals`, {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}`, ...signHeaders(body) },
38
+ body,
39
+ });
40
+ } catch (e) {
41
+ return { ok: false, error: String(e.message || e) };
42
+ }
43
+ if (!res.ok) return { ok: false, status: res.status };
44
+ // A 200 is not a promise of JSON: a proxy, captive portal, or HTML error page
45
+ // still answers 200. Parsing outside the guard threw the raw SyntaxError all
46
+ // the way out of notify()/ask() and killed the script, instead of the
47
+ // documented { ok:false } the callers already print a friendly line for.
48
+ let data;
49
+ try {
50
+ data = await res.json();
51
+ } catch (e) {
52
+ return { ok: false, error: `bad response from ${API()} (${String(e.message || e)})` };
53
+ }
54
+ return { ok: true, ...data };
55
+ }
56
+
57
+ /**
58
+ * Long-poll the app for the human's submission to approval `id`.
59
+ * Resolves with their response string once submitted, null on timeout/kill.
60
+ */
61
+ export async function pollApproval(id, opts = {}) {
62
+ const {
63
+ fetchImpl = fetch,
64
+ intervalMs = 3000,
65
+ timeoutMs = 0, // 0 = wait forever
66
+ now = () => Date.now(),
67
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
68
+ } = opts;
69
+
70
+ const url = `${API()}/api/approvals/${id}`;
71
+ const headers = KEY() ? { authorization: `Bearer ${KEY()}` } : {};
72
+ const start = now();
73
+ for (;;) {
74
+ let body = null;
75
+ try {
76
+ const res = await fetchImpl(url, { headers });
77
+ if (res && res.ok) body = await res.json();
78
+ } catch {
79
+ body = null; // network hiccup — keep polling
80
+ }
81
+ if (body && body.status === "submitted") return body.response ?? "";
82
+ if (body && body.status === "killed") return null;
83
+ if (timeoutMs && now() - start >= timeoutMs) return null;
84
+ await sleep(intervalMs);
85
+ }
86
+ }
@@ -0,0 +1,34 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Opening a URL in the user's browser.
5
+ *
6
+ * auth.mjs and commands.mjs each carry a private copy of this; new callers get
7
+ * this one rather than making a third. Folding those two in is a separate
8
+ * change — they sit on the login path and are not worth disturbing here.
9
+ */
10
+
11
+ /** Is there plausibly a browser to open? False on headless boxes, CI and SSH. */
12
+ export function canOpenBrowser() {
13
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return false;
14
+ if (process.platform === "darwin" || process.platform === "win32") return true;
15
+ // Linux/BSD: only with a display server. Spawning xdg-open on a server does
16
+ // nothing useful and can hang.
17
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
18
+ }
19
+
20
+ /** Fire-and-forget open of a URL. Never throws; returns whether it was attempted. */
21
+ export function openBrowser(url, { spawnImpl = spawn } = {}) {
22
+ const [cmd, args] =
23
+ process.platform === "darwin" ? ["open", [url]]
24
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
25
+ : ["xdg-open", [url]];
26
+ try {
27
+ const child = spawnImpl(cmd, args, { stdio: "ignore", detached: true });
28
+ child.on?.("error", () => {}); // no opener installed — stay quiet
29
+ child.unref?.();
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,65 @@
1
+ import http from "node:http";
2
+ import { DEFAULT_REGISTRY_BASE, pitNameUrl } from "./pit-url.mjs";
3
+
4
+ /**
5
+ * The other half of parking: something that actually answers.
6
+ *
7
+ * DNS can only hand back an IP, so a parked name has always resolved *somewhere*
8
+ * — but the address it pointed at was a platform that routes by Host header and
9
+ * returns "Application not found" for a name it has never heard of. `curl
10
+ * scrambled.eggs` resolved and then died one layer up.
11
+ *
12
+ * The bridge is already running on this machine for the name to resolve at all,
13
+ * so it can serve the answer too: parked names point at loopback, and this
14
+ * redirects whatever Host arrives to that name's page in the Pit. No public
15
+ * host, and no certificate for an ending that no CA will ever sign.
16
+ */
17
+
18
+ /** Port 80, because `curl <name>` has no way to say otherwise. */
19
+ export const DEFAULT_PARKING_HTTP_PORT = 80;
20
+
21
+ export function parkingRedirect(hostHeader, registryBase = DEFAULT_REGISTRY_BASE) {
22
+ const name = String(hostHeader || "").split(":")[0].trim().toLowerCase();
23
+ // One label and one ending is the only shape the registry holds; anything
24
+ // else (an IP, a bare word, a subdomain) belongs at the Pit's front door.
25
+ const isName = /^[a-z0-9-]+\.[a-z0-9-]+$/.test(name);
26
+ return isName ? pitNameUrl(name, registryBase) : `${String(registryBase).replace(/\/+$/, "")}/pit`;
27
+ }
28
+
29
+ /**
30
+ * Start the parking responder. Resolves { port, address, close() }, or rejects
31
+ * when the port cannot be bound — port 80 needs privileges, and the caller
32
+ * decides whether that is fatal.
33
+ */
34
+ export function createParkingServer(options = {}) {
35
+ const {
36
+ port = DEFAULT_PARKING_HTTP_PORT,
37
+ host = "127.0.0.1",
38
+ registryBase = DEFAULT_REGISTRY_BASE,
39
+ onRequest = () => {},
40
+ } = options;
41
+
42
+ const server = http.createServer((req, res) => {
43
+ const target = parkingRedirect(req.headers.host, registryBase);
44
+ onRequest({ host: req.headers.host || null, target });
45
+ // 302, not 301: the owner can point this name at a real target at any
46
+ // moment, and a cached permanent redirect would outlive that.
47
+ res.writeHead(302, { location: target, "content-type": "text/plain; charset=utf-8" });
48
+ // A body as well as the header, so `curl` without -L still says something
49
+ // useful instead of printing nothing at all.
50
+ res.end(`parked → ${target}\n`);
51
+ });
52
+
53
+ return new Promise((resolve, reject) => {
54
+ const onError = (err) => { server.close(); reject(err); };
55
+ server.once("error", onError);
56
+ server.listen(port, host, () => {
57
+ server.removeListener("error", onError);
58
+ resolve({
59
+ port: server.address().port,
60
+ address: host,
61
+ close: () => new Promise((done) => server.close(done)),
62
+ });
63
+ });
64
+ });
65
+ }
package/src/pins.mjs ADDED
@@ -0,0 +1,190 @@
1
+ // TLS for a Moshpit name, without anybody having to learn what a pin is.
2
+ //
3
+ // Names outside the DNS root cannot have CA-issued certificates: a CA validates
4
+ // control through the public hierarchy, and `.hacker` is not in it. So Moshpit
5
+ // verifies the other way round — the registry publishes the key a name's
6
+ // certificate must present, and clients check the certificate against that
7
+ // instead of against a chain of issuers.
8
+ //
9
+ // That is a good trade. A CA attests that somebody proved control to some
10
+ // issuer; a pin says this is exactly the key the registry has on record. There
11
+ // is no third party to mis-issue.
12
+ //
13
+ // It only works if the pin actually gets published, which is the part that was
14
+ // failing. Publishing meant running a script, reading a base64 hash out of it,
15
+ // and pasting that into a web form — three steps where the interesting one is
16
+ // invisible, so the honest outcome is that most names never get a pin at all
17
+ // and their TLS is unverifiable.
18
+ //
19
+ // The key is created here, so the pin is known here with certainty. Nothing is
20
+ // probed, nothing is trusted on first use, and the hash never has to be seen
21
+ // by a person.
22
+
23
+ import crypto from "node:crypto";
24
+
25
+ /** Pins hang off the ending, not the name — the registry has no per-name record. */
26
+ export function tldOf(name) {
27
+ const clean = String(name ?? "").trim().toLowerCase().replace(/\.$/, "");
28
+ const parts = clean.split(".").filter(Boolean);
29
+ return parts.length >= 2 ? parts[parts.length - 1] : "";
30
+ }
31
+
32
+ /**
33
+ * The pin for a public key: SHA-256 over the SubjectPublicKeyInfo, base64.
34
+ *
35
+ * Over the SPKI rather than the certificate, so re-issuing a certificate for
36
+ * the same key — a longer expiry, an added name — does not invalidate every
37
+ * client's pin. The key is the identity; the certificate is just its current
38
+ * wrapper.
39
+ */
40
+ export function spkiPin(publicKeyPem) {
41
+ const key = crypto.createPublicKey(publicKeyPem);
42
+ const der = key.export({ type: "spki", format: "der" });
43
+ return crypto.createHash("sha256").update(der).digest("base64");
44
+ }
45
+
46
+ /** The same, from a certificate rather than a bare key. */
47
+ export function pinFromCertificate(certPem) {
48
+ return spkiPin(new crypto.X509Certificate(certPem).publicKey.export({ type: "spki", format: "pem" }));
49
+ }
50
+
51
+ /**
52
+ * Where a name's key lives.
53
+ *
54
+ * One key per name, not per ending. This used to key off the TLD, on the
55
+ * belief that "that is the granularity the registry stores" — which is the
56
+ * opposite of true. Migration 009 is explicit about it, and about why:
57
+ *
58
+ * Per name rather than per TLD, and that is forced by 008: names under a TLD
59
+ * are sold, so `blue.eggs` can belong to someone who does not own `.eggs`.
60
+ * Hanging keys off the TLD would let its operator publish a key for a name
61
+ * they already sold — impersonating a buyer inside the namespace they bought
62
+ * into.
63
+ *
64
+ * A shared per-ending key is that hole in private-key form: the ending's
65
+ * operator holds the key for every name they have sold, and every buyer holds
66
+ * a key that signs for every other buyer. Per-name keys make a compromise stop
67
+ * at one site.
68
+ *
69
+ * The dot is kept — `chovy.hacker.crt`, not `chovyhacker.crt` — which also
70
+ * matches the certificates setup-origin.sh has been writing all along.
71
+ * Separators that could climb out of `dir` are dropped rather than escaped:
72
+ * `../../etc/passwd` collapses to `etcpasswd`, which is a harmless filename
73
+ * inside the directory rather than a path anywhere else.
74
+ */
75
+ export function keyPaths(name, dir = "/etc/ssl/moshpit") {
76
+ const safe = String(name ?? "")
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9.-]/g, "")
79
+ // Any run of dots becomes one, so no `..` survives to mean "parent".
80
+ .replace(/\.{2,}/g, ".")
81
+ .replace(/^[.-]+|[.-]+$/g, "");
82
+ if (!safe) return null;
83
+ return { key: `${dir}/${safe}.key`, cert: `${dir}/${safe}.crt`, dir };
84
+ }
85
+
86
+ /**
87
+ * The openssl invocation that mints a key and a self-signed certificate.
88
+ *
89
+ * P-256 to match what is already deployed, and a long expiry on purpose: the
90
+ * pin is what makes this certificate trustworthy, and rotating it means
91
+ * republishing the pin. An annual scramble to re-pin every name would be a
92
+ * reliability problem invented to satisfy a CA convention that does not apply
93
+ * here.
94
+ */
95
+ export function certificateCommand({ name, tld, paths, days = 3650 }) {
96
+ const subject = `/CN=${name}`;
97
+ return {
98
+ cmd: "openssl",
99
+ args: [
100
+ "req", "-x509", "-nodes",
101
+ "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
102
+ "-keyout", paths.key, "-out", paths.cert,
103
+ "-days", String(days), "-subj", subject,
104
+ // This name and nothing else. It used to carry `DNS:*.${tld}` as well,
105
+ // on the assumption that every name under the ending shared one key —
106
+ // which would have each buyer's certificate assert authority over every
107
+ // other name in a namespace they merely bought into. Browsers and
108
+ // pin-checking clients both read SAN, not CN.
109
+ "-addext", `subjectAltName=DNS:${name}`,
110
+ ],
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Publish a pin to the registry.
116
+ *
117
+ * Additive rather than replacing, matching the API: rotation wants a window
118
+ * where both keys are valid, so the new one can be published, deployed, and
119
+ * only then the old one withdrawn. Replacing outright breaks every client
120
+ * between the write and the deploy.
121
+ *
122
+ * A 409 means this exact pin is already published, which is success as far as
123
+ * the caller is concerned — running `site --install` twice should not be an
124
+ * error.
125
+ */
126
+ export async function publishPin({
127
+ tld,
128
+ pin,
129
+ kind = "tls",
130
+ note = "moshcode site",
131
+ registryBase = "https://pit.moshcode.sh",
132
+ token,
133
+ fetchImpl = fetch,
134
+ } = {}) {
135
+ if (!tld) return { ok: false, error: "no ending to publish under" };
136
+ if (!pin) return { ok: false, error: "no pin to publish" };
137
+ if (!token) {
138
+ return {
139
+ ok: false,
140
+ needsAuth: true,
141
+ error: "not logged in — run `moshcode login`, or set MOSHCODE_API_KEY",
142
+ };
143
+ }
144
+
145
+ const url = `${String(registryBase).replace(/\/+$/, "")}/api/moshpit/tlds/${encodeURIComponent(tld)}/pins`;
146
+ let response;
147
+ try {
148
+ response = await fetchImpl(url, {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
151
+ body: JSON.stringify({ pin, kind, note }),
152
+ });
153
+ } catch (error) {
154
+ return { ok: false, error: `registry unreachable: ${error.message}` };
155
+ }
156
+
157
+ if (response.status === 201) return { ok: true, published: true };
158
+ // Already there — the desired state, reached earlier.
159
+ if (response.status === 409) return { ok: true, published: false, already: true };
160
+ if (response.status === 401) {
161
+ return { ok: false, needsAuth: true, error: "the registry rejected the credentials" };
162
+ }
163
+ const body = await response.text().catch(() => "");
164
+ return { ok: false, error: `registry said ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}` };
165
+ }
166
+
167
+ /**
168
+ * Whether this ending already publishes this pin.
169
+ *
170
+ * Checked before writing so a re-run is silent rather than a 409, and so the
171
+ * common case — the key is already deployed and published — costs one GET and
172
+ * no credentials at all.
173
+ */
174
+ export async function pinPublished({
175
+ tld,
176
+ pin,
177
+ registryBase = "https://pit.moshcode.sh",
178
+ fetchImpl = fetch,
179
+ } = {}) {
180
+ if (!tld || !pin) return false;
181
+ const url = `${String(registryBase).replace(/\/+$/, "")}/api/moshpit/tlds/${encodeURIComponent(tld)}/pins`;
182
+ try {
183
+ const response = await fetchImpl(url);
184
+ if (!response.ok) return false;
185
+ const body = await response.json();
186
+ return (body?.pins ?? []).some((entry) => (entry?.pin ?? entry) === pin);
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh";
2
+
3
+ /**
4
+ * The Pit's page for a name.
5
+ *
6
+ * Its own module because both halves of parking need it — the resolver, when it
7
+ * explains where a name went, and the parking responder, when it sends a
8
+ * browser there — and dns.mjs importing the responder that imports dns.mjs
9
+ * would be a cycle.
10
+ */
11
+ export function pitNameUrl(name, registryBase = DEFAULT_REGISTRY_BASE) {
12
+ return `${String(registryBase).replace(/\/+$/, "")}/n/${encodeURIComponent(name)}`;
13
+ }