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/tabs.mjs ADDED
@@ -0,0 +1,144 @@
1
+ // Tmux-backed tabs for the interactive mosh pit.
2
+ //
3
+ // The pit deliberately hands a provider CLI the whole terminal with inherited
4
+ // stdio. Keeping that contract matters: full-screen TUIs, mouse handling,
5
+ // colours, signals, and provider-specific shortcuts should remain native. A
6
+ // tab therefore cannot be an in-process readline view. It is another moshcode
7
+ // process in another tmux window, with tmux owning the terminal multiplexing.
8
+ import { spawn, spawnSync } from "node:child_process";
9
+
10
+ /** POSIX-shell quoting for tmux's single `shell-command` argument. */
11
+ export function tabShellQuote(value) {
12
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
13
+ }
14
+
15
+ /** Command run in every tab. Always opens a fresh pit, never repeats argv. */
16
+ export function tabCommand({ execPath = process.execPath, entry = process.argv[1] } = {}) {
17
+ if (!entry) throw new Error("can't locate the moshcode entrypoint");
18
+ return `exec ${tabShellQuote(execPath)} ${tabShellQuote(entry)}`;
19
+ }
20
+
21
+ /**
22
+ * Pure tmux command plan, split out so the safety-sensitive argv is testable
23
+ * without opening real windows in the test runner.
24
+ */
25
+ export function tabPlan({
26
+ cwd = process.cwd(),
27
+ command = tabCommand(),
28
+ tmux = process.env.TMUX,
29
+ pid = process.pid,
30
+ stamp = Date.now(),
31
+ } = {}) {
32
+ if (tmux) {
33
+ return {
34
+ dedicated: false,
35
+ session: null,
36
+ socket: null,
37
+ required: [["new-window", "-c", cwd, "-n", "mosh", command]],
38
+ optional: [],
39
+ attach: null,
40
+ };
41
+ }
42
+
43
+ // A private server gets the current environment at creation time. Reusing a
44
+ // detached default server here could give provider CLIs stale PATH/API vars.
45
+ const suffix = `${pid}-${stamp}`.replace(/[^a-zA-Z0-9_-]/g, "-");
46
+ const socket = `moshcode-${suffix}`;
47
+ const session = `moshcode-${suffix}`;
48
+ const server = ["-L", socket];
49
+ return {
50
+ dedicated: true,
51
+ session,
52
+ socket,
53
+ required: [
54
+ // This server is only for moshcode. Start it without the user's tmux
55
+ // config so the advertised Ctrl-b n/p/number bindings stay true even
56
+ // when their normal tmux remaps or unbinds those keys. Existing tmux
57
+ // sessions take the branch above and keep the user's configuration.
58
+ [...server, "-f", "/dev/null", "new-session", "-d", "-s", session, "-c", cwd, "-n", "mosh 1", command],
59
+ // Match the visible names to Ctrl-b 1/2. The clean tmux default starts
60
+ // at zero, so change the base and renumber the first window before
61
+ // adding its sibling.
62
+ [...server, "set-option", "-t", session, "base-index", "1"],
63
+ [...server, "move-window", "-r", "-t", session],
64
+ // Do not use -d: the new tab should be selected when we attach.
65
+ [...server, "new-window", "-t", session, "-c", cwd, "-n", "mosh 2", command],
66
+ ],
67
+ // Presentation is best-effort: an older tmux should still open the tabs.
68
+ optional: [
69
+ [...server, "set-option", "-t", session, "status", "on"],
70
+ [...server, "set-option", "-t", session, "status-position", "bottom"],
71
+ [...server, "set-option", "-t", session, "status-right", " Ctrl-b n/p · /new "],
72
+ ],
73
+ attach: [...server, "attach-session", "-t", session],
74
+ };
75
+ }
76
+
77
+ function resultError(result) {
78
+ if (result?.error?.code === "ENOENT") return "tmux is not installed";
79
+ if (result?.error) return result.error.message || String(result.error);
80
+ const detail = String(result?.stderr || result?.stdout || "").trim();
81
+ return detail || `tmux exited ${result?.status ?? "without a status"}`;
82
+ }
83
+
84
+ function runAttached(args, { spawner = spawn, env = process.env } = {}) {
85
+ return new Promise((resolve) => {
86
+ let child;
87
+ try { child = spawner("tmux", args, { stdio: "inherit", env }); }
88
+ catch (error) { resolve({ ok: false, error }); return; }
89
+ child.on("error", (error) => resolve({ ok: false, error }));
90
+ child.on("exit", (code, signal) => resolve({ ok: code === 0, code, signal }));
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Open and switch to a new pit tab.
96
+ *
97
+ * Inside tmux this adds one window to the current session. Outside tmux it
98
+ * starts a private two-window workspace and attaches to it; this is the only
99
+ * way the already-running, non-tmux pit can gain a sibling without replacing
100
+ * the provider-friendly inherited-stdio architecture.
101
+ */
102
+ export async function openNewTab({
103
+ cwd = process.cwd(),
104
+ env = process.env,
105
+ isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
106
+ runner = spawnSync,
107
+ spawner = spawn,
108
+ execPath = process.execPath,
109
+ entry = process.argv[1],
110
+ pid = process.pid,
111
+ stamp = Date.now(),
112
+ } = {}) {
113
+ if (!isTTY) return { ok: false, error: new Error("/new needs an interactive terminal") };
114
+
115
+ let command;
116
+ try { command = tabCommand({ execPath, entry }); }
117
+ catch (error) { return { ok: false, error }; }
118
+ const plan = tabPlan({ cwd, command, tmux: env.TMUX, pid, stamp });
119
+
120
+ for (const args of plan.required) {
121
+ const result = runner("tmux", args, { encoding: "utf8", env });
122
+ if (result?.status !== 0) {
123
+ // Only a private server created by this call is eligible for cleanup.
124
+ if (plan.dedicated) {
125
+ runner("tmux", ["-L", plan.socket, "kill-server"], { stdio: "ignore", env });
126
+ }
127
+ return { ok: false, error: new Error(resultError(result)) };
128
+ }
129
+ }
130
+ for (const args of plan.optional) runner("tmux", args, { stdio: "ignore", env });
131
+
132
+ if (!plan.attach) return { ok: true, dedicated: false };
133
+ const attached = await runAttached(plan.attach, { spawner, env });
134
+ if (!attached.ok) {
135
+ // Attaching is the last required step, but the private server and its two
136
+ // pit processes already exist by then. Do not strand them in the
137
+ // background when the terminal cannot attach (for example TERM=dumb or a
138
+ // client-side tmux error). As above, only a server created by this call is
139
+ // ever eligible for cleanup.
140
+ runner("tmux", ["-L", plan.socket, "kill-server"], { stdio: "ignore", env });
141
+ return { ok: false, error: attached.error || new Error(`tmux attach exited ${attached.code ?? attached.signal ?? "unknown"}`) };
142
+ }
143
+ return { ok: true, dedicated: true, session: plan.session, socket: plan.socket };
144
+ }
@@ -0,0 +1,456 @@
1
+ // Starting stacks for services published at a Moshpit name.
2
+ //
3
+ // Hosting at a Moshpit ending is four files and one non-obvious fact — that
4
+ // the machine serving the name never resolves it, and every visitor's machine
5
+ // must. People get that wrong in the same way every time, so the fix is a
6
+ // template that already has it right rather than a paragraph they read after
7
+ // the site did not come up.
8
+ //
9
+ // A template is a directory of files and nothing else. Nothing here runs,
10
+ // evaluates, or sources anything out of one, including the bundled ones: the
11
+ // whole point of `install <url>` is that the URL is a stranger's, and a
12
+ // template that could execute on install would be a remote code execution
13
+ // feature wearing a scaffold's clothes. Files are copied, and the reader
14
+ // decides what to run.
15
+
16
+ import { promises as fs } from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { spawn } from "node:child_process";
20
+ import os from "node:os";
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+
24
+ /** Where the templates that ship with moshcode live. */
25
+ export const BUNDLED_DIR = path.join(HERE, "..", "examples", "templates");
26
+
27
+ /** The manifest is metadata about the copy, not part of it. */
28
+ const MANIFEST = "template.json";
29
+
30
+ /**
31
+ * Is this the template's own manifest?
32
+ *
33
+ * Only the one at the root describes the copy. A `template.json` deeper in the
34
+ * tree is one of the template's own files — a collection laid out the way the
35
+ * bundled ones are, `<name>/template.json`, is the ordinary case — and dropping
36
+ * it because the basename matched would quietly install a broken tree.
37
+ */
38
+ function isOwnManifest(relative) {
39
+ return relative === MANIFEST;
40
+ }
41
+
42
+ /* ------------------------------------------------------------------ listing */
43
+
44
+ /** The bundled templates, each with whatever its manifest says about it. */
45
+ export async function listTemplates(dir = BUNDLED_DIR) {
46
+ let entries;
47
+ try {
48
+ entries = await fs.readdir(dir, { withFileTypes: true });
49
+ } catch {
50
+ return [];
51
+ }
52
+
53
+ const found = [];
54
+ for (const entry of entries) {
55
+ if (!entry.isDirectory()) continue;
56
+ let description = "";
57
+ try {
58
+ const raw = await fs.readFile(path.join(dir, entry.name, MANIFEST), "utf8");
59
+ const parsed = JSON.parse(raw);
60
+ description = typeof parsed?.description === "string" ? parsed.description : "";
61
+ } catch {
62
+ // A directory without a readable manifest is still a template. Listing it
63
+ // without a description beats hiding it because its metadata is wrong.
64
+ }
65
+ found.push({ name: entry.name, description });
66
+ }
67
+ return found.sort((a, b) => a.name.localeCompare(b.name));
68
+ }
69
+
70
+ /* ----------------------------------------------------------------- sourcing */
71
+
72
+ /**
73
+ * What kind of thing is this, and can we fetch it?
74
+ *
75
+ * Deliberately conservative about what counts as a bundled name: anything with
76
+ * a slash, a colon, or a dot is treated as remote, so a name can never be
77
+ * coaxed into reading a path outside the bundled directory.
78
+ */
79
+ export function classifySource(spec) {
80
+ const raw = String(spec ?? "").trim();
81
+ if (!raw) return { kind: "none" };
82
+
83
+ if (/^git@|\.git$|^git\+/i.test(raw)) return { kind: "git", url: raw.replace(/^git\+/i, "") };
84
+ if (/^https?:\/\//i.test(raw)) {
85
+ return /\.(tar\.gz|tgz)$/i.test(raw) ? { kind: "tarball", url: raw } : { kind: "git", url: raw };
86
+ }
87
+ if (/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i.test(raw)) {
88
+ // owner/repo, the shorthand everyone types.
89
+ return { kind: "git", url: `https://github.com/${raw}.git` };
90
+ }
91
+ if (/^[a-z0-9][a-z0-9-]*$/i.test(raw)) return { kind: "bundled", name: raw };
92
+ return { kind: "unusable", spec: raw };
93
+ }
94
+
95
+ /**
96
+ * Is this archive member safe to write?
97
+ *
98
+ * An absolute path or one that climbs out of the extraction directory writes
99
+ * wherever the attacker chose — `/etc/systemd/system/anything.service` is a
100
+ * root shell on the next boot. tar itself refuses most of these, but the check
101
+ * is cheap and this is not a place to rely on someone else's default.
102
+ */
103
+ export function safeEntry(entry) {
104
+ const name = String(entry ?? "");
105
+ if (!name || name.startsWith("/") || /^[a-z]:/i.test(name)) return false;
106
+ if (name.split(/[/\\]/).some((part) => part === "..")) return false;
107
+ return true;
108
+ }
109
+
110
+ /* ----------------------------------------------------------------- copying */
111
+
112
+ /** Every file under `dir`, relative to it. */
113
+ async function walk(dir, base = dir) {
114
+ const files = [];
115
+ const unsafe = [];
116
+ let entries;
117
+ try {
118
+ entries = await fs.readdir(dir, { withFileTypes: true });
119
+ } catch {
120
+ return { files, unsafe };
121
+ }
122
+ for (const entry of entries) {
123
+ const full = path.join(dir, entry.name);
124
+ const relative = path.relative(base, full);
125
+ if (entry.isSymbolicLink()) {
126
+ unsafe.push(relative);
127
+ } else if (entry.isDirectory()) {
128
+ const nested = await walk(full, base);
129
+ files.push(...nested.files);
130
+ unsafe.push(...nested.unsafe);
131
+ } else if (entry.isFile()) {
132
+ files.push(relative);
133
+ } else {
134
+ unsafe.push(relative);
135
+ }
136
+ }
137
+ return { files, unsafe };
138
+ }
139
+
140
+ /**
141
+ * What installing would write, and what it would land on top of.
142
+ *
143
+ * Separated from the writing so a conflict can be reported before anything has
144
+ * been changed. Half-installing a template over someone's Caddyfile and then
145
+ * stopping is worse than not starting.
146
+ */
147
+ export async function installPlan(from, into) {
148
+ const walked = await walk(from);
149
+ const files = walked.files.filter((f) => !isOwnManifest(f));
150
+ const conflicts = [];
151
+ for (const file of files) {
152
+ try {
153
+ await fs.access(path.join(into, file));
154
+ conflicts.push(file);
155
+ } catch {
156
+ /* absent, which is what we want */
157
+ }
158
+ }
159
+ return { files: files.sort(), conflicts: conflicts.sort(), unsafe: walked.unsafe.sort() };
160
+ }
161
+
162
+ /** Copy the planned files. Directories are created as needed. */
163
+ export async function applyInstall(from, into, files) {
164
+ for (const file of files) {
165
+ const stat = await fs.lstat(path.join(from, file));
166
+ if (!stat.isFile()) throw new Error(`template entry is not a regular file: ${file}`);
167
+ }
168
+ for (const file of files) {
169
+ const target = path.join(into, file);
170
+ await fs.mkdir(path.dirname(target), { recursive: true });
171
+ await fs.copyFile(path.join(from, file), target);
172
+ }
173
+ }
174
+
175
+ /* -------------------------------------------------------------- fetching */
176
+
177
+ function run(cmd, args, { capture = false } = {}) {
178
+ return new Promise((resolve) => {
179
+ const child = spawn(cmd, args, { stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore" });
180
+ let stdout = "";
181
+ if (capture) child.stdout.on("data", (chunk) => { stdout += chunk; });
182
+ child.on("error", () => resolve({ ok: false, code: null, stdout }));
183
+ child.on("close", (code) => resolve({ ok: code === 0, code, stdout }));
184
+ });
185
+ }
186
+
187
+ /**
188
+ * How many leading path components should the extraction drop?
189
+ *
190
+ * A release tarball wraps everything in one directory — `repo-1.2.3/` from a
191
+ * GitHub URL, `./` from `tar -czf t.tgz .` — and the template is what sits
192
+ * inside that wrapper, not the wrapper itself. So it is dropped.
193
+ *
194
+ * An archive built the other obvious way, `tar -czf t.tgz *`, has no wrapper.
195
+ * Dropping a component there does not unwrap anything: tar silently discards
196
+ * every top-level file and moves everything else up a level, so README.md and
197
+ * template.json disappear and src/app.js installs as app.js. That happens with
198
+ * no warning and a zero exit, which is the worst way for it to happen.
199
+ *
200
+ * So strip only when there is genuinely one root holding everything.
201
+ */
202
+ function stripDepth(entries) {
203
+ const roots = new Set();
204
+ let nested = false;
205
+ for (const entry of entries) {
206
+ const parts = entry.split("/").filter(Boolean);
207
+ if (!parts.length) continue;
208
+ roots.add(parts[0]);
209
+ if (parts.length > 1) nested = true;
210
+ if (roots.size > 1) return 0;
211
+ }
212
+ // One root that nothing lives under is a lone file, not a wrapper.
213
+ return roots.size === 1 && nested ? 1 : 0;
214
+ }
215
+
216
+ /**
217
+ * Put a remote template on disk and return the directory holding it.
218
+ *
219
+ * Both paths shell out rather than reimplementing git or tar. The cost is a
220
+ * dependency on tools that are already on any machine that could plausibly
221
+ * deploy a service; the alternative is a tar parser in a CLI that does not
222
+ * otherwise need one.
223
+ */
224
+ export async function fetchRemote(
225
+ source,
226
+ { tmpRoot = os.tmpdir(), fetchImpl = fetch, runImpl = run } = {},
227
+ ) {
228
+ const dir = await fs.mkdtemp(path.join(tmpRoot, "moshcode-template-"));
229
+
230
+ if (source.kind === "git") {
231
+ const cloned = await runImpl("git", ["clone", "--depth", "1", "--quiet", source.url, dir]);
232
+ if (!cloned.ok) return { ok: false, error: `could not clone ${source.url}`, dir, cleanupDir: dir };
233
+ // The clone's own history is not part of the template.
234
+ await fs.rm(path.join(dir, ".git"), { recursive: true, force: true });
235
+ return { ok: true, dir, cleanupDir: dir };
236
+ }
237
+
238
+ if (source.kind === "tarball") {
239
+ const archive = path.join(dir, "template.tar.gz");
240
+ let body;
241
+ try {
242
+ const res = await fetchImpl(source.url);
243
+ if (!res.ok) return { ok: false, error: `${source.url} answered ${res.status}`, dir, cleanupDir: dir };
244
+ body = Buffer.from(await res.arrayBuffer());
245
+ } catch {
246
+ return { ok: false, error: `could not fetch ${source.url}`, dir, cleanupDir: dir };
247
+ }
248
+ await fs.writeFile(archive, body);
249
+
250
+ // Read the manifest of members before unpacking, not after. Checking the
251
+ // extracted tree would be theatre: by then tar has already written
252
+ // wherever the entry said, and /etc/systemd/system/anything.service is a
253
+ // root shell on the next boot.
254
+ const listed = await runImpl("tar", ["-tzf", archive], { capture: true });
255
+ if (!listed.ok) return { ok: false, error: "could not read the archive", dir, cleanupDir: dir };
256
+ const members = listed.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
257
+ const unsafe = members.find((e) => !safeEntry(e));
258
+ if (unsafe) return { ok: false, error: `archive writes outside the target: ${unsafe}`, dir, cleanupDir: dir };
259
+
260
+ const out = path.join(dir, "unpacked");
261
+ await fs.mkdir(out, { recursive: true });
262
+ const extracted = await runImpl("tar", [
263
+ "-xzf", archive, "-C", out,
264
+ `--strip-components=${stripDepth(members)}`,
265
+ "--no-same-owner", "--no-same-permissions",
266
+ ]);
267
+ if (!extracted.ok) return { ok: false, error: "could not unpack the archive", dir, cleanupDir: dir };
268
+ return { ok: true, dir: out, cleanupDir: dir };
269
+ }
270
+
271
+ return { ok: false, error: "not a template source", dir, cleanupDir: dir };
272
+ }
273
+
274
+ /* ------------------------------------------------------------------- verb */
275
+
276
+ const USAGE = `moshcode template — starting stacks for Moshpit-hosted services
277
+
278
+ moshcode template list [--json] the templates that ship with moshcode
279
+ moshcode template install <name> copy a bundled one into this directory
280
+ moshcode template install <url> copy one from a git repo or a .tar.gz
281
+ moshcode template install <owner/repo> the GitHub shorthand
282
+
283
+ --into <dir> write somewhere other than the current directory
284
+ --force overwrite files that are already there
285
+ --dry-run show every create/overwrite without writing anything
286
+ --json print the template list as JSON
287
+
288
+ Nothing in a template is executed on install — the files are copied and what to
289
+ run is yours to decide. Read them before you do.`;
290
+
291
+ /**
292
+ * Split `install` arguments into the source, the destination, and the flags.
293
+ *
294
+ * Written as a scan rather than an indexOf-per-flag because `--into` consumes
295
+ * the token after it: a filter that only drops things starting with `--` would
296
+ * happily treat that directory as the template name.
297
+ */
298
+ export function parseInstallArgs(args = []) {
299
+ let spec = null;
300
+ let into = null;
301
+ let force = false;
302
+ let dryRun = false;
303
+
304
+ for (let i = 0; i < args.length; i++) {
305
+ const arg = args[i];
306
+ if (arg === "--force") { force = true; continue; }
307
+ if (arg === "--dry-run") { dryRun = true; continue; }
308
+ if (arg === "--into") {
309
+ const value = args[i + 1];
310
+ if (!value || value.startsWith("-")) {
311
+ return { spec, into, force, dryRun, error: "--into requires a directory" };
312
+ }
313
+ into = value;
314
+ i += 1;
315
+ continue;
316
+ }
317
+ if (arg.startsWith("--into=")) {
318
+ into = arg.slice("--into=".length) || null;
319
+ if (!into) return { spec, into, force, dryRun, error: "--into requires a directory" };
320
+ continue;
321
+ }
322
+ if (arg.startsWith("-")) {
323
+ return { spec, into, force, dryRun, error: `unknown option ${JSON.stringify(arg)}` };
324
+ }
325
+ if (spec === null) spec = arg;
326
+ }
327
+ return { spec, into, force, dryRun };
328
+ }
329
+
330
+ export async function templateCommand(
331
+ args = [],
332
+ out = console.log,
333
+ { fetchRemoteImpl = fetchRemote, cwd = process.cwd() } = {},
334
+ ) {
335
+ const [sub, ...rest] = args;
336
+
337
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
338
+ out(USAGE);
339
+ return 0;
340
+ }
341
+
342
+ if (sub === "list") {
343
+ const unknown = rest.filter((arg) => arg !== "--json");
344
+ if (unknown.length) {
345
+ out(`moshcode template list: unknown option ${JSON.stringify(unknown[0])}`);
346
+ return 1;
347
+ }
348
+ const templates = await listTemplates();
349
+ if (rest.includes("--json")) {
350
+ out(JSON.stringify(templates, null, 2));
351
+ return 0;
352
+ }
353
+ if (!templates.length) {
354
+ out("no templates bundled with this install");
355
+ return 0;
356
+ }
357
+ const width = Math.max(...templates.map((t) => t.name.length));
358
+ for (const { name, description } of templates) {
359
+ out(` ${name.padEnd(width)} ${description}`);
360
+ }
361
+ out("");
362
+ out("install one with: moshcode template install <name>");
363
+ return 0;
364
+ }
365
+
366
+ if (sub !== "install") {
367
+ out(`moshcode template: unknown subcommand ${JSON.stringify(sub)}`);
368
+ out(USAGE);
369
+ return 1;
370
+ }
371
+
372
+ const parsed = parseInstallArgs(rest);
373
+ if (parsed.error) {
374
+ out(`moshcode template install: ${parsed.error}`);
375
+ return 1;
376
+ }
377
+ const { spec, into: intoArg, force, dryRun } = parsed;
378
+ const source = classifySource(spec);
379
+ if (source.kind === "none") {
380
+ out("moshcode template install: name a template, a git URL, or a .tar.gz");
381
+ return 1;
382
+ }
383
+ if (source.kind === "unusable") {
384
+ out(`moshcode template install: ${JSON.stringify(source.spec)} is not a template name or a URL`);
385
+ return 1;
386
+ }
387
+
388
+ const into = path.resolve(cwd, intoArg || ".");
389
+
390
+ let from = null;
391
+ let cleanup = null;
392
+ if (source.kind === "bundled") {
393
+ from = path.join(BUNDLED_DIR, source.name);
394
+ try {
395
+ await fs.access(from);
396
+ } catch {
397
+ out(`moshcode template install: no bundled template named ${JSON.stringify(source.name)}`);
398
+ out("see what there is with: moshcode template list");
399
+ return 1;
400
+ }
401
+ } else {
402
+ out(`fetching ${source.url} …`);
403
+ const fetched = await fetchRemoteImpl(source);
404
+ if (!fetched.ok) {
405
+ await fs.rm(fetched.cleanupDir || fetched.dir, { recursive: true, force: true }).catch(() => {});
406
+ out(`moshcode template install: ${fetched.error}`);
407
+ return 1;
408
+ }
409
+ from = fetched.dir;
410
+ cleanup = fetched.cleanupDir || fetched.dir;
411
+ }
412
+
413
+ try {
414
+ const { files, conflicts, unsafe } = await installPlan(from, into);
415
+ if (unsafe.length) {
416
+ out(`moshcode template install: links and special files are not allowed (${unsafe.length} found):`);
417
+ for (const file of unsafe.slice(0, 10)) out(` ${file}`);
418
+ if (unsafe.length > 10) out(` … and ${unsafe.length - 10} more`);
419
+ out("nothing was written. Replace them with ordinary files and try again.");
420
+ return 1;
421
+ }
422
+ if (!files.length) {
423
+ out("moshcode template install: that template has no files in it");
424
+ return 1;
425
+ }
426
+ if (dryRun) {
427
+ const conflictSet = new Set(conflicts);
428
+ for (const file of files) out(` ${conflictSet.has(file) ? "overwrite" : "create"} ${file}`);
429
+ out("");
430
+ if (conflicts.length && !force) {
431
+ out(`${conflicts.length} existing file${conflicts.length === 1 ? "" : "s"} would block this install.`);
432
+ out("Nothing was written. Re-run with --force --dry-run to preview overwrites.");
433
+ return 1;
434
+ }
435
+ out(`${files.length} file${files.length === 1 ? "" : "s"} would be written to ${into}`);
436
+ out("dry run; nothing was written");
437
+ return 0;
438
+ }
439
+ if (conflicts.length && !force) {
440
+ out(`moshcode template install: ${conflicts.length} file${conflicts.length === 1 ? "" : "s"} already exist here:`);
441
+ for (const file of conflicts.slice(0, 10)) out(` ${file}`);
442
+ if (conflicts.length > 10) out(` … and ${conflicts.length - 10} more`);
443
+ out("nothing was written. Re-run with --force to overwrite, or --into <dir>.");
444
+ return 1;
445
+ }
446
+
447
+ await applyInstall(from, into, files);
448
+ for (const file of files) out(` ${file}`);
449
+ out("");
450
+ out(`${files.length} file${files.length === 1 ? "" : "s"} written to ${into}`);
451
+ out("read them before running anything — start with README.md");
452
+ return 0;
453
+ } finally {
454
+ if (cleanup) await fs.rm(cleanup, { recursive: true, force: true }).catch(() => {});
455
+ }
456
+ }