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/serve.mjs ADDED
@@ -0,0 +1,502 @@
1
+ // Serve a Moshpit name from this machine.
2
+ //
3
+ // The work is four lines of web-server config, and every one of them is a line
4
+ // people get wrong in the same way:
5
+ //
6
+ // - no redirect to HTTPS. A box's default vhost usually sends everything to
7
+ // https, and for a Moshpit ending that is a redirect to a page that can
8
+ // never load, because no CA will issue for an ending outside the DNS root.
9
+ // Through the gateway it is worse: pit.moshcode.sh forwards the status and
10
+ // not the Location, so a visitor gets a 301 pointing nowhere at all.
11
+ // - an exact server_name, which is what beats the default vhost.
12
+ // - both address families. A visitor running the resolver arrives over IPv6,
13
+ // because that is what the name points at; a visitor without one arrives
14
+ // via the gateway, which fetches server-side. Drop either listen line and
15
+ // one of those audiences loses the site.
16
+ // - port 80 only. A DNS record carries an address and has nowhere to put a
17
+ // port, so the resolver path cannot reach anything else.
18
+ //
19
+ // Config is written, never executed, and nothing is applied without --write.
20
+
21
+ import { promises as fs } from "node:fs";
22
+ import path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { classifySource, listTemplates } from "./templates.mjs";
26
+ import { tldOf, keyPaths, certificateCommand, pinFromCertificate, publishPin } from "./pins.mjs";
27
+ import { loadCreds } from "./auth.mjs";
28
+
29
+ /** The registry credential: explicit env first, then `moshcode login`. */
30
+ const token = () => process.env.MOSHCODE_API_KEY || loadCreds()?.token || "";
31
+
32
+ /**
33
+ * What a freshly installed site contains before anyone has written anything.
34
+ *
35
+ * A new root with nothing in it serves 404, which is indistinguishable from a
36
+ * broken install at exactly the moment someone is trying to tell those apart.
37
+ * So the default is a page that says the name resolved and the server answered
38
+ * — the two facts a first visit is a test of — and says what to replace.
39
+ *
40
+ * caddy-static is the starter because it is the one with no runtime: seeding a
41
+ * Bun service would leave a root whose index.html is a lie about what is
42
+ * running. Pick another with --template, or --empty to seed nothing.
43
+ */
44
+ export const DEFAULT_TEMPLATE = "caddy-static";
45
+
46
+ /**
47
+ * Which starter to seed, and whether that answer is usable at all.
48
+ *
49
+ * `--template` is checked rather than trusted, because every way of getting it
50
+ * wrong lands in the same place: the directory does not exist, the seed step is
51
+ * quietly dropped, and the root is left empty — which serves the 404 the
52
+ * seeding exists to prevent, after reporting that everything worked. A typo has
53
+ * to be louder than that.
54
+ *
55
+ * The shape check is templates.mjs's existing rule rather than a new one: a
56
+ * bundled name is one label, so anything carrying a slash or a dot is not a
57
+ * name and must not be joined into a path underneath examples/templates.
58
+ */
59
+ export async function chooseTemplate(rest = [], { list = listTemplates } = {}) {
60
+ if (rest.includes("--empty")) return { ok: true, template: null };
61
+
62
+ const at = rest.indexOf("--template");
63
+ if (at < 0) return { ok: true, template: DEFAULT_TEMPLATE };
64
+
65
+ const asked = rest[at + 1];
66
+ // `--template --install` reads the next flag as the starter, and the flag it
67
+ // ate still takes effect, so the site installs with nothing in its root.
68
+ if (asked === undefined || asked.startsWith("-")) {
69
+ return { ok: false, error: "--template takes the name of a starter" };
70
+ }
71
+ if (classifySource(asked).kind !== "bundled") {
72
+ return { ok: false, error: `${JSON.stringify(asked)} is not a starter name` };
73
+ }
74
+
75
+ const available = (await list()).map((t) => t.name);
76
+ if (!available.includes(asked)) {
77
+ return { ok: false, error: `there is no starter called ${JSON.stringify(asked)}`, available };
78
+ }
79
+ return { ok: true, template: asked };
80
+ }
81
+
82
+ /** Where each server keeps drop-in site config. */
83
+ const SERVERS = {
84
+ nginx: { dir: "/etc/nginx/conf.d", ext: ".conf", reload: ["systemctl", "reload", "nginx"], check: ["nginx", "-t"] },
85
+ caddy: { dir: "/etc/caddy/conf.d", ext: ".caddy", reload: ["systemctl", "reload", "caddy"], check: null },
86
+ };
87
+
88
+ /**
89
+ * Who is actually listening on port 80.
90
+ *
91
+ * Asked of the running system rather than of the filesystem, because the
92
+ * filesystem lies: a box can carry /etc/nginx from a package installed years
93
+ * ago while Caddy is the thing answering, and writing nginx config there
94
+ * succeeds at every step and serves nothing. That is the worst outcome
95
+ * available — everything reports success and the site is missing.
96
+ *
97
+ * Falls back to config directories when the port cannot be inspected, which is
98
+ * what happens without privileges: `ss` will show the socket but not who owns
99
+ * it. A guess is still better than refusing to act, as long as it is the
100
+ * second answer rather than the first.
101
+ */
102
+ export async function detectServer({ listeners = defaultListeners, exists = defaultExists } = {}) {
103
+ const holder = await listeners().catch(() => null);
104
+ if (holder) {
105
+ for (const server of Object.keys(SERVERS)) {
106
+ if (holder.includes(server)) return server;
107
+ }
108
+ // Only claim an unknown holder when a process name was actually visible.
109
+ // Without privileges `ss` prints the socket and no owner, and treating
110
+ // that as "something unrecognised" would refuse to act on the common case
111
+ // — a plain user asking what would be installed.
112
+ const named = holder.match(/users:\(\("([^"]+)"/);
113
+ if (named) return { unknown: named[1] };
114
+ }
115
+ if (await exists("/etc/nginx")) return "nginx";
116
+ if (await exists("/etc/caddy")) return "caddy";
117
+ return null;
118
+ }
119
+
120
+ const defaultExists = async (p) => {
121
+ const { promises } = await import("node:fs");
122
+ return !!(await promises.stat(p).catch(() => null));
123
+ };
124
+
125
+ /** The process names bound to port 80, as one lowercase string. */
126
+ const defaultListeners = async () => {
127
+ const { execFile } = await import("node:child_process");
128
+ const out = await new Promise((resolve) => {
129
+ execFile("ss", ["-tlnp"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : String(stdout)));
130
+ });
131
+ return out
132
+ .split("\n")
133
+ .filter((line) => /:80\s/.test(line))
134
+ .join(" ")
135
+ .toLowerCase();
136
+ };
137
+
138
+ /** An nginx server block for one Moshpit name. */
139
+ export function nginxSite({ name, root, proxy, tls = null }) {
140
+ const body = proxy
141
+ ? [`\tlocation / {`, `\t\tproxy_pass http://127.0.0.1:${proxy};`, `\t\tproxy_set_header Host $host;`, `\t\tproxy_set_header X-Moshpit-Name $host;`, `\t}`]
142
+ : [`\troot ${root};`, `\tindex index.html;`, ``, `\tlocation / {`, `\t\ttry_files $uri $uri/ =404;`, `\t}`];
143
+
144
+ const plain = [
145
+ "server {",
146
+ "\tlisten 80;",
147
+ "\tlisten [::]:80;",
148
+ "",
149
+ `\tserver_name ${name};`,
150
+ "",
151
+ ...body,
152
+ "",
153
+ "\tlocation ~ /\\. {",
154
+ "\t\tdeny all;",
155
+ "\t}",
156
+ "}",
157
+ ];
158
+
159
+ // The TLS half, when this ending has a key. Both halves serve the site;
160
+ // neither redirects to the other. That is the whole point — a client takes
161
+ // whichever it can verify, and there is no arrangement where one of them
162
+ // sends a client somewhere it cannot follow.
163
+ const secure = tls
164
+ ? [
165
+ "",
166
+ "# The same site over TLS, for clients that can verify it.",
167
+ "#",
168
+ "# Verified against the registry's published pin rather than a CA chain,",
169
+ "# because no CA will issue for an ending outside the DNS root. The pin is",
170
+ "# a stronger statement than a CA's: it names the exact key, where a CA",
171
+ "# only attests that somebody proved control to some issuer.",
172
+ "#",
173
+ `# pin: ${tls.pin}`,
174
+ "server {",
175
+ "\tlisten 443 ssl;",
176
+ "\tlisten [::]:443 ssl;",
177
+ "\thttp2 on;",
178
+ "",
179
+ `\tserver_name ${name};`,
180
+ "",
181
+ `\tssl_certificate ${tls.cert};`,
182
+ `\tssl_certificate_key ${tls.key};`,
183
+ "\tssl_protocols TLSv1.3;",
184
+ "\tssl_prefer_server_ciphers off;",
185
+ // Hybrid post-quantum, matching what is already deployed. A DNS-adjacent
186
+ // request names every site someone is about to visit, which is worth as
187
+ // much to an attacker recording now to decrypt later as the pages are.
188
+ "\tssl_conf_command Groups X25519MLKEM768:X25519:P-256;",
189
+ "\tssl_session_tickets off;",
190
+ "",
191
+ ...body,
192
+ "",
193
+ "\tlocation ~ /\\. {",
194
+ "\t\tdeny all;",
195
+ "\t}",
196
+ "}",
197
+ ]
198
+ : [];
199
+
200
+ return [
201
+ `# ${name} — written by \`moshcode site\`.`,
202
+ "#",
203
+ "# Port 80 never redirects to 443, deliberately. No CA will issue for an",
204
+ "# ending outside the DNS root, so a stock browser or curl cannot verify the",
205
+ "# certificate here however good it is — and a redirect would send them to a",
206
+ "# page they can never load. The gateway forwards the status without the",
207
+ "# Location header, which turns it into a 301 to nowhere.",
208
+ "#",
209
+ tls
210
+ ? "# So both ports serve the site: plain HTTP for anything, pin-verified TLS"
211
+ : "# TLS is absent because this ending has no key yet — `--tls` creates one",
212
+ tls
213
+ ? "# for clients that check the registry's pin. The client picks."
214
+ : "# and publishes its pin, after which this file gains a 443 block.",
215
+ "#",
216
+ "# Both listen lines matter. Resolver users arrive over IPv6 because that is",
217
+ "# what the name points at; everyone else arrives via pit.moshcode.sh, which",
218
+ "# fetches this server-side.",
219
+ ...plain,
220
+ ...secure,
221
+ "",
222
+ ].join("\n");
223
+ }
224
+
225
+ /** The Caddy equivalent. */
226
+ export function caddySite({ name, root, proxy }) {
227
+ return [
228
+ `# ${name} — written by \`moshcode serve\`.`,
229
+ "#",
230
+ "# The http:// is required and is not a style choice. Leave it off and Caddy",
231
+ "# tries to provision a certificate for an ending no CA will issue for, fails,",
232
+ "# and the site never comes up.",
233
+ `http://${name} {`,
234
+ proxy ? `\treverse_proxy 127.0.0.1:${proxy}` : `\troot * ${root}`,
235
+ proxy ? "" : "\tfile_server",
236
+ "}",
237
+ "",
238
+ ].filter((line) => line !== "").join("\n") + "\n";
239
+ }
240
+
241
+ /**
242
+ * The TLS story for a name: reuse the ending's key, or mint one.
243
+ *
244
+ * One key per *ending*, not per name, because that is what the registry pins —
245
+ * you claim `.eggs`, not `scrambled.eggs`. A key per name would mean a pin
246
+ * published per site and every name under the ending accepting all of them:
247
+ * strictly more keys able to impersonate each other, for no isolation gained.
248
+ *
249
+ * When the key already exists its pin is read back off disk rather than
250
+ * assumed, so a second name under the same ending publishes the pin that is
251
+ * actually being served rather than one we believe should be.
252
+ */
253
+ export async function tlsFor(name, { dir = "/etc/ssl/moshpit", readFile = fs.readFile } = {}) {
254
+ const tld = tldOf(name);
255
+ if (!tld) return null;
256
+ const paths = keyPaths(name, dir);
257
+ if (!paths) return null;
258
+
259
+ try {
260
+ const existing = await readFile(paths.cert, "utf8");
261
+ return { tld, dir, key: paths.key, cert: paths.cert, pin: pinFromCertificate(existing), create: null };
262
+ } catch {
263
+ // No key yet. The command is returned rather than run, so `--install`
264
+ // stays the only thing that touches the machine.
265
+ //
266
+ // The pin is null here and read back from the certificate after openssl
267
+ // has run: openssl mints the key itself, so predicting the pin would mean
268
+ // publishing a value we hoped for rather than the one being served.
269
+ return { tld, dir, key: paths.key, cert: paths.cert, pin: null, create: certificateCommand({ name, tld, paths }) };
270
+ }
271
+ }
272
+
273
+ /**
274
+ * What serving this name would change.
275
+ *
276
+ * Returned rather than done, so `--write` is the only thing that touches the
277
+ * machine and everything before it can be read first.
278
+ */
279
+ export function servePlan({ name, server, root, proxy, reload = false, seed = null, tls = null }) {
280
+ const target = SERVERS[server];
281
+ if (!target) return { ok: false, error: `no supported web server found — install nginx or caddy` };
282
+
283
+ const file = path.join(target.dir, `${name}${target.ext}`);
284
+ // Caddy has no pinned-TLS story here: it wants to provision a certificate
285
+ // from a CA, which is exactly what cannot happen for these endings.
286
+ const useTls = server === "nginx" ? tls : null;
287
+ const content = server === "nginx" ? nginxSite({ name, root, proxy, tls: useTls }) : caddySite({ name, root, proxy });
288
+
289
+ const steps = [];
290
+ if (useTls?.create) {
291
+ // Before the config that references them, so a failed key never leaves
292
+ // nginx pointing at a certificate that does not exist — which it refuses
293
+ // to start with, taking every other site on the box down with it.
294
+ steps.push({ kind: "mkdir", path: useTls.dir, why: "somewhere for the ending's key to live" });
295
+ steps.push({
296
+ kind: "run",
297
+ command: useTls.create.cmd,
298
+ args: useTls.create.args,
299
+ why: `a key for .${useTls.tld} — one per ending, which is what the registry pins`,
300
+ });
301
+ }
302
+ steps.push({ kind: "write", path: file, content, why: `answer to ${name} without redirecting it` });
303
+ if (!proxy) {
304
+ steps.push({ kind: "mkdir", path: root, why: "somewhere for the files to live" });
305
+ // Only when the root is new. Seeding over someone's site would be the
306
+ // worst kind of helpful.
307
+ if (seed) steps.push({ kind: "seed", path: root, from: seed, why: "a page that says it worked, rather than a 404" });
308
+ }
309
+ // Validation runs whether or not we reload: a config that does not parse is
310
+ // worth knowing about now rather than the next time anything touches nginx,
311
+ // which may be a reboot and may be someone else.
312
+ if (target.check) steps.push({ kind: "run", command: target.check[0], args: target.check.slice(1), why: "a config that does not parse is a problem now, not later" });
313
+ // Reloading is what makes the site live, so it is opt-in. Installing config
314
+ // and activating it are different decisions: the first is reversible by
315
+ // deleting a file, the second changes what a running server does to traffic.
316
+ if (reload) steps.push({ kind: "run", command: target.reload[0], args: target.reload.slice(1), why: "make it live now" });
317
+
318
+ // Last, and only once the key exists on disk. Publishing a pin for a key
319
+ // that failed to generate would tell every client to expect a certificate
320
+ // this machine cannot present — worse than no pin, because a pin that does
321
+ // not match is a hard failure rather than a missing one.
322
+ if (useTls) {
323
+ steps.push({
324
+ kind: "publish-pin",
325
+ tld: useTls.tld,
326
+ pin: useTls.pin,
327
+ why: `so clients can verify .${useTls.tld} without a CA`,
328
+ });
329
+ }
330
+
331
+ return { ok: true, server, file, root: proxy ? null : root, proxy: proxy ?? null, tls: useTls, steps };
332
+ }
333
+
334
+ const USAGE = `moshcode site — install web-server config for a Moshpit name
335
+
336
+ moshcode site <name> show what would be installed
337
+ moshcode site <name> --install write the config (needs root)
338
+ moshcode site <name> --install --reload ...and make it live now
339
+ moshcode site <name> --proxy 3000 reverse-proxy a local port instead
340
+ moshcode site <name> --root <dir> where the files live
341
+ moshcode site <name> --template <name> which starter page to seed (see
342
+ \`moshcode template list\`)
343
+ moshcode site <name> --empty seed nothing; serve a 404 until you
344
+ put something there
345
+ moshcode site <name> --tls also serve TLS, and publish the key
346
+ pin so clients can verify it
347
+
348
+ moshcode does not serve anything itself. This writes a config file for the web
349
+ server already on this machine; nginx or Caddy does the serving.
350
+
351
+ The name still has to point at this machine — set that in the Pit, and check
352
+ it with \`moshcode dns resolve <name>\`. This only makes the box answer to it.`;
353
+
354
+ /**
355
+ * Run one of the plan's commands.
356
+ *
357
+ * A real default, because the alternative was worse than it looked: with no
358
+ * runner the execution loop skipped every `run` step in silence and then
359
+ * printed "is live on this machine". `nginx -t` never validated, `systemctl
360
+ * reload` never reloaded, and the config that had just been written was not
361
+ * the config being served — a lie that is invisible until someone reloads
362
+ * nginx for an unrelated reason, hours or reboots later.
363
+ */
364
+ async function defaultRunner(command, args) {
365
+ const { execFile } = await import("node:child_process");
366
+ return new Promise((resolve) => {
367
+ execFile(command, args, { timeout: 60_000 }, (error, _stdout, stderr) => {
368
+ resolve({ ok: !error, code: error?.code ?? 0, stderr: String(stderr || "").trim() });
369
+ });
370
+ });
371
+ }
372
+
373
+ export async function serveCommand(args = [], out = console.log, deps = {}) {
374
+ const { detect = detectServer, write = fs.writeFile, mkdir = fs.mkdir, copy = fs.cp, runner = defaultRunner } = deps;
375
+ const [name, ...rest] = args;
376
+
377
+ if (!name || name === "help" || name === "--help" || name === "-h") {
378
+ out(USAGE);
379
+ return name ? 0 : 1;
380
+ }
381
+ if (!/^[a-z0-9]{1,63}\.[a-z0-9]{1,63}$/i.test(name)) {
382
+ out(`moshcode site: ${JSON.stringify(name)} is not a Moshpit name (one label, one ending)`);
383
+ return 1;
384
+ }
385
+
386
+ const flag = (f) => { const at = rest.indexOf(f); return at >= 0 ? rest[at + 1] : undefined; };
387
+ const proxy = flag("--proxy");
388
+ if (proxy !== undefined) {
389
+ // `^\d+$` alone let 0 and out-of-range numbers through: --proxy 0 is
390
+ // falsy downstream, so the site silently drops to a static root, and
391
+ // --proxy 99999 writes a proxy_pass to a port that cannot exist. Both
392
+ // were reported as live. Ports are 1-65535 everywhere else here.
393
+ const port = /^\d+$/.test(proxy) ? Number(proxy) : NaN;
394
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
395
+ out(`moshcode site: --proxy needs a decimal integer from 1 to 65535, got ${JSON.stringify(proxy)}`);
396
+ return 1;
397
+ }
398
+ }
399
+ const root = flag("--root") || `/srv/${name}`;
400
+ const choice = await chooseTemplate(rest);
401
+ if (!choice.ok) {
402
+ out(`moshcode site: ${choice.error}`);
403
+ if (choice.available) out(` bundled: ${choice.available.join(", ")}`);
404
+ out(" `moshcode template list` shows them all, or use --empty to seed nothing.");
405
+ return 1;
406
+ }
407
+ const template = choice.template;
408
+ const here = path.dirname(fileURLToPath(import.meta.url));
409
+ const seedFrom = template ? path.join(here, "..", "examples", "templates", template, "site") : null;
410
+ // An empty root serves 404, which reads as a broken install. Seed only when
411
+ // there is nothing there to overwrite.
412
+ const rootEmpty = !(await fs.readdir(root).catch(() => []))?.length;
413
+ const seed = seedFrom && rootEmpty && (await fs.stat(seedFrom).catch(() => null)) ? seedFrom : null;
414
+
415
+ const server = await detect();
416
+ if (server && typeof server === "object" && server.unknown) {
417
+ out(`moshcode site: port 80 is held by something this cannot configure`);
418
+ out(` ${server.unknown}`);
419
+ out(" install nginx or caddy, or add the equivalent block by hand.");
420
+ return 1;
421
+ }
422
+ // Opt-in, because it writes a private key and publishes to the registry —
423
+ // two things that should not happen because someone ran the default.
424
+ const tls = rest.includes("--tls") ? await tlsFor(name) : null;
425
+ const plan = servePlan({ name, server, root, proxy: proxy ? Number(proxy) : null, reload: rest.includes("--reload"), seed, tls });
426
+ if (!plan.ok) {
427
+ out(`moshcode site: ${plan.error}`);
428
+ return 1;
429
+ }
430
+
431
+ const ports = plan.tls ? "ports 80 + 443, pin-verified TLS" : "port 80, plain HTTP";
432
+ out(`${name} → ${plan.proxy ? `127.0.0.1:${plan.proxy}` : plan.root} (${plan.server}, ${ports})`);
433
+ out("");
434
+ for (const step of plan.steps) {
435
+ const what = step.kind === "run"
436
+ ? `${step.command} ${step.args.join(" ")}`
437
+ : step.kind === "publish-pin"
438
+ ? `.${step.tld}`
439
+ : step.path;
440
+ out(` ${step.kind.padEnd(6)} ${what}`);
441
+ out(` ${step.why}`);
442
+ }
443
+ out("");
444
+
445
+ if (!rest.includes("--install")) {
446
+ out(plan.server === "nginx" ? "--- the config ---" : "--- the Caddyfile ---");
447
+ out(plan.steps[0].content);
448
+ out("nothing written. re-run with --install (as root) to write it.");
449
+ return 0;
450
+ }
451
+
452
+ for (const step of plan.steps) {
453
+ try {
454
+ if (step.kind === "write") await write(step.path, step.content);
455
+ else if (step.kind === "mkdir") await mkdir(step.path, { recursive: true });
456
+ else if (step.kind === "seed") await copy(step.from, step.path, { recursive: true, force: false });
457
+ else if (step.kind === "run") {
458
+ if (!runner) {
459
+ // Silence here is how a config gets written referencing a key that
460
+ // was never generated, after reporting success.
461
+ out(`! cannot run ${step.command} — nothing would validate or take effect`);
462
+ return 1;
463
+ }
464
+ const result = await runner(step.command, step.args);
465
+ if (!result?.ok) {
466
+ out(`! ${step.command} failed — stopping before anything else changes`);
467
+ if (result?.stderr) out(` ${result.stderr.split("\n")[0]}`);
468
+ return 1;
469
+ }
470
+ } else if (step.kind === "publish-pin") {
471
+ // Read back off the certificate rather than using the value planned:
472
+ // openssl minted the key, so this is the only way to publish what is
473
+ // actually being served instead of what we expected to be.
474
+ const served = await fs.readFile(plan.tls.cert, "utf8").then(pinFromCertificate).catch(() => step.pin);
475
+ const result = await publishPin({ tld: step.tld, pin: served, token: token(), note: `moshcode site ${name}` });
476
+ if (result.ok) {
477
+ out(result.already ? ` pin already published for .${step.tld}` : ` pin published for .${step.tld}`);
478
+ } else {
479
+ // Not fatal. The site works; only its TLS is unverifiable, and
480
+ // failing the whole install here would leave a machine configured
481
+ // and a user told nothing worked.
482
+ out(` ! pin not published: ${result.error}`);
483
+ out(result.needsAuth
484
+ ? ` the site is up, but TLS cannot be verified until it is. run \`moshcode login\`, then:`
485
+ : ` the site is up, but TLS cannot be verified until it is. retry with:`);
486
+ out(` moshcode site ${name} --tls --install`);
487
+ }
488
+ }
489
+ } catch (err) {
490
+ out(`! ${step.kind} ${step.path || step.command} failed: ${err.message}`);
491
+ out(err.code === "EACCES" ? " (needs root — rerun with sudo)" : "");
492
+ return 1;
493
+ }
494
+ }
495
+
496
+ out(rest.includes("--reload")
497
+ ? `${name} is live on this machine.`
498
+ : `config installed for ${name} — not live until the server reloads:\n ${SERVERS[plan.server].reload.join(" ")}`);
499
+ out(` check it here: curl -I -H "Host: ${name}" http://127.0.0.1/`);
500
+ out(` and from anywhere: curl -I https://pit.moshcode.sh/n/${name}`);
501
+ return 0;
502
+ }
package/src/skills.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // Install Agent Skills across every engine that has a skills primitive, from one
2
+ // source (a git URL or local path). Gemini installs natively; Claude clones the
3
+ // source into its personal skills dir. See prd/0003.
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
7
+
8
+ // Coding engines with a skills primitive. Codex/OpenCode/Aider have none.
9
+ export const SKILL_ENGINES = ["claude", "gemini", "kimi"];
10
+
11
+ /** Claude's global personal skills directory (~/.claude/skills). */
12
+ export function claudeSkillsDir() {
13
+ return path.join(os.homedir(), ".claude", "skills");
14
+ }
15
+
16
+ /**
17
+ * Kimi Code's global skills directory ($KIMI_CODE_HOME/skills, default
18
+ * ~/.kimi-code/skills). Kimi's own user-level skill dir moves with that
19
+ * variable, so read it rather than hardcoding the default away.
20
+ */
21
+ export function kimiSkillsDir(env = process.env) {
22
+ return path.join(env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code"), "skills");
23
+ }
24
+
25
+ /** Derive a skill name from a git URL or path (basename minus `.git`), or use the override. */
26
+ export function skillName(source, override) {
27
+ const sanitize = (s) => String(s).toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
28
+ // `.` and `..` are directory references, not names: path.join would collapse
29
+ // them and land the clone on the skills dir itself (or its parent).
30
+ const named = (s) => (s === "." || s === ".." ? "" : s);
31
+ if (override) return named(sanitize(override)) || "skill";
32
+ const raw = String(source).replace(/[/\\]+$/, "");
33
+ const base = raw.split(/[/\\]/).pop() || "skill";
34
+ const derived = named(sanitize(base.replace(/\.git$/i, "")));
35
+ if (derived) return derived;
36
+ // A `.` / `..` source means "this directory", so name the skill after the
37
+ // directory it resolves to: `skill install .` installs the repo you are in.
38
+ return named(sanitize(path.basename(path.resolve(raw)))) || "skill";
39
+ }
40
+
41
+ /**
42
+ * The install action for one engine: a spawnable { cmd, args } or a { skip }
43
+ * reason. `spec: { source, name }`.
44
+ */
45
+ export function skillInstallAction(key, spec) {
46
+ const { source, name } = spec;
47
+ switch (key) {
48
+ case "gemini":
49
+ return { cmd: "gemini", args: ["skills", "install", source, "--scope", "user"] };
50
+ case "claude":
51
+ // Claude has no `skill install`; clone the source into its skills dir.
52
+ return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(claudeSkillsDir(), name)] };
53
+ case "kimi":
54
+ // Kimi Code discovers skills by scanning directories, with no install
55
+ // command of its own — so clone into the one it scans, as Claude does.
56
+ return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(kimiSkillsDir(), name)] };
57
+ default:
58
+ return { skip: "no skills primitive" };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Plan the fan-out: one entry per engine with its action or skip reason.
64
+ * Every engine, not just SKILL_ENGINES — prd/0003 R8 requires the engines with
65
+ * no skills primitive to be *reported* as skipped, and an engine missing from
66
+ * the plan is missing from the summary. Derived from ENGINES the same way the
67
+ * /skill list matrix derives it, so an engine added later cannot quietly fall
68
+ * out of the fan-out while still showing up in the matrix.
69
+ */
70
+ export function planSkillInstall(spec, { installedSet } = {}) {
71
+ const rest = Object.keys(ENGINES).filter((key) => !SKILL_ENGINES.includes(key));
72
+ return [...SKILL_ENGINES, ...rest].map((key) => {
73
+ const bin = ENGINES[key].bin;
74
+ const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs);
75
+ return { key, bin, installed, ...skillInstallAction(key, spec) };
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Execute a skill-install plan. Returns results
81
+ * [{ key, status: "installed"|"skipped"|"failed"|"not-installed", reason? }].
82
+ * `run` is injectable for tests.
83
+ */
84
+ export async function runSkillInstall(plan, { run = runCmd } = {}) {
85
+ const results = [];
86
+ for (const item of plan) {
87
+ if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
88
+ if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
89
+ const r = await run(item.cmd, item.args);
90
+ results.push({ key: item.key, status: ranOk(r) ? "installed" : "failed", code: r.code, signal: r.signal ?? null });
91
+ }
92
+ return results;
93
+ }