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/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
|
+
}
|