moshcode 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +580 -0
  3. package/bin/moshcode.mjs +674 -0
  4. package/bin/moshscript.mjs +29 -0
  5. package/examples/alive.mosh +6 -0
  6. package/examples/scripting-the-cli.mosh +21 -0
  7. package/examples/team-secrets.mosh +20 -0
  8. package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
  9. package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
  10. package/examples/templates/bun-caddy-sqlite/README.md +97 -0
  11. package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
  12. package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
  13. package/examples/templates/bun-caddy-sqlite/package.json +15 -0
  14. package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
  15. package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
  16. package/examples/templates/bun-caddy-sqlite/template.json +10 -0
  17. package/examples/templates/caddy-proxy/Caddyfile +36 -0
  18. package/examples/templates/caddy-proxy/README.md +104 -0
  19. package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
  20. package/examples/templates/caddy-proxy/template.json +8 -0
  21. package/examples/templates/caddy-static/Caddyfile +16 -0
  22. package/examples/templates/caddy-static/README.md +90 -0
  23. package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
  24. package/examples/templates/caddy-static/site/index.html +11 -0
  25. package/examples/templates/caddy-static/template.json +8 -0
  26. package/install.sh +194 -0
  27. package/package.json +28 -0
  28. package/prd/0000-template.md +49 -0
  29. package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
  30. package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
  31. package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
  32. package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
  33. package/prd/0005-hosted-moshpit-resolver.md +192 -0
  34. package/prd/0006-help.md +359 -0
  35. package/prd/0007-profullstack-site-init.md +1183 -0
  36. package/prd/README.md +26 -0
  37. package/src/ads.mjs +58 -0
  38. package/src/auth.mjs +193 -0
  39. package/src/cli-schema.mjs +533 -0
  40. package/src/cli.mjs +118 -0
  41. package/src/commands.mjs +259 -0
  42. package/src/completion.mjs +594 -0
  43. package/src/console.mjs +244 -0
  44. package/src/dns-system.mjs +404 -0
  45. package/src/dns.mjs +2872 -0
  46. package/src/doh-server.mjs +256 -0
  47. package/src/doh.mjs +218 -0
  48. package/src/engines.mjs +385 -0
  49. package/src/escalate.mjs +85 -0
  50. package/src/help.mjs +443 -0
  51. package/src/integrations.mjs +265 -0
  52. package/src/mcp-catalog.mjs +50 -0
  53. package/src/mcp.mjs +155 -0
  54. package/src/mirror.mjs +187 -0
  55. package/src/notify.mjs +86 -0
  56. package/src/open-url.mjs +34 -0
  57. package/src/parking-http.mjs +65 -0
  58. package/src/pins.mjs +190 -0
  59. package/src/pit-url.mjs +13 -0
  60. package/src/prd.mjs +341 -0
  61. package/src/pty.mjs +176 -0
  62. package/src/pwd.mjs +103 -0
  63. package/src/registry.mjs +37 -0
  64. package/src/release-install.mjs +191 -0
  65. package/src/runtime.mjs +161 -0
  66. package/src/selfupdate.mjs +215 -0
  67. package/src/serve.mjs +502 -0
  68. package/src/skills.mjs +93 -0
  69. package/src/tabs.mjs +144 -0
  70. package/src/templates.mjs +456 -0
  71. package/src/tools.mjs +231 -0
  72. package/src/trade.mjs +137 -0
  73. package/src/trust.mjs +712 -0
  74. package/src/tui.mjs +736 -0
  75. package/src/ui.mjs +49 -0
  76. package/src/uninstall.mjs +113 -0
  77. package/src/upgrade.mjs +217 -0
@@ -0,0 +1,191 @@
1
+ // Installer for the workflow CLIs that ship ONLY as GitHub release binaries.
2
+ //
3
+ // gh, supabase, and doctl have no official cross-platform `curl … | sh`
4
+ // installer: each publishes per-platform static binaries on every release, and
5
+ // Supabase explicitly does not support a global npm install ("there is no global
6
+ // `supabase` command with this method"). Rather than guess which of
7
+ // brew/apt/dnf/snap/scoop exists on the box — and rather than ask for sudo — we
8
+ // do what moshcode's own install.sh does: resolve the latest release, download
9
+ // the asset for this OS/arch, and drop the binary in the user's bin dir.
10
+ //
11
+ // `moshcode install gh` runs this file directly; see the install specs in
12
+ // tools.mjs. The descriptors and URL builders are pure so the asset-naming
13
+ // rules (which differ per vendor, in annoying ways) are unit-tested offline.
14
+ import { spawnSync } from "node:child_process";
15
+ import {
16
+ chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync,
17
+ rmSync, writeFileSync,
18
+ } from "node:fs";
19
+ import { homedir, tmpdir } from "node:os";
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ /**
24
+ * How each vendor names its release assets, and where the binary sits inside
25
+ * the archive. Verified against real releases — every field here is a fact
26
+ * about someone else's naming scheme, not a preference:
27
+ * - gh spells darwin "macOS", ships darwin as .zip and linux as .tar.gz, and
28
+ * nests the binary under a versioned dir + bin/.
29
+ * - supabase also publishes version-less asset aliases, so `latest/download`
30
+ * resolves without asking the API for a tag first.
31
+ * - doctl separates its asset fields with "-" instead of "_".
32
+ */
33
+ export const RELEASES = {
34
+ gh: {
35
+ repo: "cli/cli",
36
+ binary: "gh",
37
+ asset: ({ version, platform, arch }) =>
38
+ platform === "darwin"
39
+ ? `gh_${version}_macOS_${arch}.zip`
40
+ : `gh_${version}_linux_${arch}.tar.gz`,
41
+ binPath: ({ version, platform, arch }) =>
42
+ `gh_${version}_${platform === "darwin" ? "macOS" : "linux"}_${arch}/bin/gh`,
43
+ },
44
+ supabase: {
45
+ repo: "supabase/cli",
46
+ binary: "supabase",
47
+ unversioned: true,
48
+ asset: ({ platform, arch }) => `supabase_${platform}_${arch}.tar.gz`,
49
+ binPath: () => "supabase",
50
+ },
51
+ doctl: {
52
+ repo: "digitalocean/doctl",
53
+ binary: "doctl",
54
+ asset: ({ version, platform, arch }) => `doctl-${version}-${platform}-${arch}.tar.gz`,
55
+ binPath: () => "doctl",
56
+ },
57
+ };
58
+
59
+ // Node's process.arch names differ from the ones release assets use.
60
+ const ARCHES = { x64: "amd64", arm64: "arm64" };
61
+
62
+ /**
63
+ * This machine's release-asset platform/arch, or a thrown error naming the
64
+ * escape hatch. Windows is deliberately out of scope: moshcode installs itself
65
+ * with a POSIX shell script, and every one of these vendors ships a Windows
66
+ * package manager (scoop/winget) that does the job better than we would.
67
+ */
68
+ export function targetTriple(platform = process.platform, arch = process.arch) {
69
+ if (platform !== "linux" && platform !== "darwin") {
70
+ throw new Error(
71
+ `${platform} isn't supported by this installer — install the CLI with your system package manager (brew/scoop/winget)`,
72
+ );
73
+ }
74
+ const mapped = ARCHES[arch];
75
+ if (!mapped) {
76
+ throw new Error(`unsupported architecture ${arch} — expected one of ${Object.keys(ARCHES).join(", ")}`);
77
+ }
78
+ return { platform, arch: mapped };
79
+ }
80
+
81
+ /** Resolve a tool name to its release descriptor, or throw. Own properties only. */
82
+ export function resolveRelease(tool) {
83
+ const key = String(tool ?? "").trim().toLowerCase();
84
+ if (!Object.hasOwn(RELEASES, key)) {
85
+ throw new Error(
86
+ `unknown release tool ${JSON.stringify(tool)} — expected one of ${Object.keys(RELEASES).join(", ")}`,
87
+ );
88
+ }
89
+ return [key, RELEASES[key]];
90
+ }
91
+
92
+ /** The newest release tag for `repo`, with any leading "v" stripped. */
93
+ export async function latestVersion(repo, fetchImpl = fetch) {
94
+ const res = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, {
95
+ headers: { accept: "application/vnd.github+json", "user-agent": "moshcode" },
96
+ });
97
+ if (!res.ok) throw new Error(`could not read the latest ${repo} release (HTTP ${res.status})`);
98
+ const tag = (await res.json())?.tag_name;
99
+ if (!tag) throw new Error(`the latest ${repo} release has no tag_name`);
100
+ return String(tag).replace(/^v/, "");
101
+ }
102
+
103
+ /**
104
+ * The download URL for a release asset. Version-less vendors go through
105
+ * GitHub's `/releases/latest/download/` redirect; the rest need the real tag.
106
+ */
107
+ export function assetUrl(spec, target) {
108
+ const asset = spec.asset(target);
109
+ return spec.unversioned
110
+ ? `https://github.com/${spec.repo}/releases/latest/download/${asset}`
111
+ : `https://github.com/${spec.repo}/releases/download/v${target.version}/${asset}`;
112
+ }
113
+
114
+ /** Where binaries land — the same default install.sh uses for the moshcode wrapper. */
115
+ export function installDir() {
116
+ return process.env.MOSHCODE_BIN || path.join(homedir(), ".local", "bin");
117
+ }
118
+
119
+ /** Unpack a .tar.gz or .zip into `dir` using the system tar/unzip. */
120
+ function extract(archive, dir) {
121
+ const [cmd, args] = archive.endsWith(".zip")
122
+ ? ["unzip", ["-q", archive, "-d", dir]]
123
+ : ["tar", ["-xzf", archive, "-C", dir]];
124
+ const r = spawnSync(cmd, args, { stdio: "inherit" });
125
+ if (r.error || r.status !== 0) {
126
+ const why = r.error ? ` (${r.error.message})` : ` (exit ${r.status})`;
127
+ throw new Error(`${cmd} could not unpack ${path.basename(archive)}${why}`);
128
+ }
129
+ }
130
+
131
+ /** A PATH warning is the difference between "installed" and "command not found". */
132
+ function warnIfNotOnPath(dir) {
133
+ const parts = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
134
+ if (parts.includes(dir)) return;
135
+ console.log(`! ${dir} is not on your PATH — add it:\n export PATH="${dir}:$PATH"`);
136
+ }
137
+
138
+ /** Download, unpack, and install one release binary. Returns its final path. */
139
+ export async function installRelease(tool, { fetchImpl = fetch } = {}) {
140
+ const [key, spec] = resolveRelease(tool);
141
+ const { platform, arch } = targetTriple();
142
+ const version = spec.unversioned ? "" : await latestVersion(spec.repo, fetchImpl);
143
+ const target = { version, platform, arch };
144
+ const url = assetUrl(spec, target);
145
+
146
+ console.log(`↓ ${url}`);
147
+ const res = await fetchImpl(url, { headers: { "user-agent": "moshcode" } });
148
+ if (!res.ok) throw new Error(`download failed (HTTP ${res.status}) — ${url}`);
149
+
150
+ const work = mkdtempSync(path.join(tmpdir(), `moshcode-${key}-`));
151
+ try {
152
+ const archive = path.join(work, path.posix.basename(new URL(url).pathname));
153
+ writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
154
+ const unpacked = path.join(work, "unpacked");
155
+ mkdirSync(unpacked);
156
+ extract(archive, unpacked);
157
+
158
+ const relative = spec.binPath(target);
159
+ const from = path.join(unpacked, relative);
160
+ if (!existsSync(from)) {
161
+ throw new Error(`${spec.binary} was not at ${relative} inside ${path.basename(archive)} — the vendor's archive layout changed`);
162
+ }
163
+
164
+ const dir = installDir();
165
+ mkdirSync(dir, { recursive: true });
166
+ const to = path.join(dir, spec.binary);
167
+ copyFileSync(from, to);
168
+ chmodSync(to, 0o755);
169
+ console.log(`✓ ${spec.binary}${version ? ` ${version}` : ""} → ${to}`);
170
+ warnIfNotOnPath(dir);
171
+ return to;
172
+ } finally {
173
+ rmSync(work, { recursive: true, force: true });
174
+ }
175
+ }
176
+
177
+ /** True when this file was executed directly rather than imported. */
178
+ function invokedDirectly() {
179
+ try {
180
+ return realpathSync(process.argv[1] || "") === realpathSync(fileURLToPath(import.meta.url));
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ if (invokedDirectly()) {
187
+ installRelease(process.argv[2]).catch((e) => {
188
+ console.error(`install failed: ${e.message}`);
189
+ process.exit(1);
190
+ });
191
+ }
@@ -0,0 +1,161 @@
1
+ // moshscript runtime — runs a .mosh file as JavaScript with the moshcode command
2
+ // vocabulary injected as globals. "secretly all js is legal."
3
+ //
4
+ // How it works:
5
+ // - The script source is executed inside `with (scope) { … }` in an async
6
+ // function. `scope` is a Proxy that resolves the command vocabulary
7
+ // (mosh(), notify(), … — see registry.mjs) and the live `alive` flag.
8
+ // Everything the proxy doesn't own (const/let locals, console, Math, real
9
+ // JS) falls through to normal scoping, so full JavaScript works.
10
+ // - `alive` is a getter. Each read counts one iteration against the --max
11
+ // budget, so an unbounded `while (alive) { … }` loop terminates on its own;
12
+ // `stop()` ends it early by flipping the flag. Straight-line scripts and
13
+ // non-`alive` loops (a plain `for`) are not bounded by --max.
14
+ //
15
+ // This replaces the old custom-DSL interpreter as the execution path. The old
16
+ // grammar (`while (alive) { code(); … }`) is a strict subset of JS, so those
17
+ // scripts run unchanged.
18
+
19
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
20
+
21
+ // Shared default iteration budget for both entrypoints (CLI `run` + TUI /run).
22
+ export const DEFAULT_MAX = 3;
23
+
24
+ /** Strip a leading `#!…` shebang line so `#!/usr/bin/env moshscript` files parse. */
25
+ export function stripShebang(src) {
26
+ return src.replace(/^\uFEFF?#![^\r\n]*(?:\r?\n|$)/, "");
27
+ }
28
+
29
+ // The loop governor: owns `alive` truthiness and the iteration budget.
30
+ function makeControl(max, out) {
31
+ return {
32
+ ticks: 0,
33
+ stopped: false,
34
+ warned: false,
35
+ // Called on every read of `alive`. Returns whether the loop may continue.
36
+ tick() {
37
+ if (this.stopped) return false;
38
+ if (this.ticks >= max) {
39
+ if (!this.warned) {
40
+ this.warned = true;
41
+ out(` ⏹ hit --max ${max} — stopping the pit (pass --max to go longer)`);
42
+ }
43
+ return false;
44
+ }
45
+ this.ticks++;
46
+ return true;
47
+ },
48
+ stop() {
49
+ this.stopped = true;
50
+ },
51
+ };
52
+ }
53
+
54
+ // The `with` target: a Proxy that owns exactly the vocabulary + a few specials,
55
+ // and lets every other identifier resolve through normal JS scoping.
56
+ //
57
+ // `pending` collects promises returned by async verbs the script did NOT await
58
+ // (e.g. a bare `notify("done")`), so runScript can drain them before returning —
59
+ // otherwise the process could exit before a fire-and-forget notification lands.
60
+ // Blocking verbs (the CLI verbs via spawnSync, sleep) return synchronously and
61
+ // need no draining, which is what keeps the simple no-`await` style correct.
62
+ //
63
+ // What gets queued is `Promise.allSettled([result])`, not the bare `result`. The
64
+ // settled wrapper subscribes immediately, so a fire-and-forget verb that rejects
65
+ // is never seen as an unhandled rejection while the script runs on — queueing the
66
+ // bare promise let Node kill the process at the next tick, before the drain below
67
+ // could ever observe it. The script still gets the original promise back, so an
68
+ // `await`ed call fails exactly as before.
69
+ function makeScope(registry, ctx, control, pending) {
70
+ const bound = new Map();
71
+ for (const cmd of registry.all()) {
72
+ bound.set(cmd.name, (...args) => {
73
+ const result = cmd.run(ctx, ...args);
74
+ if (result && typeof result.then === "function") pending.push(Promise.allSettled([result]));
75
+ return result;
76
+ });
77
+ }
78
+ const owns = (key) =>
79
+ key === "alive" || key === "argv" || key === "env" || bound.has(key);
80
+
81
+ return new Proxy(Object.create(null), {
82
+ has(_t, key) {
83
+ // Symbols (incl. Symbol.unscopables) must fall through, or `with` breaks.
84
+ if (typeof key === "symbol") return false;
85
+ return owns(key);
86
+ },
87
+ get(_t, key) {
88
+ if (key === "alive") return control.tick();
89
+ if (key === "argv") return ctx.argv;
90
+ if (key === "env") return ctx.env;
91
+ return bound.get(key);
92
+ },
93
+ set(_t, key, value) {
94
+ // Allow `alive = false` as an alias for stop(); protect the vocabulary.
95
+ if (key === "alive") {
96
+ control.stopped = !value;
97
+ return true;
98
+ }
99
+ return false;
100
+ },
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Execute moshscript `source` as JavaScript.
106
+ *
107
+ * opts:
108
+ * commands a registry (createRegistry) supplying the vocabulary [required]
109
+ * max iteration budget for `alive` loops (default DEFAULT_MAX)
110
+ * dryRun narrate side effects instead of performing them
111
+ * argv positional args exposed to the script as `argv`
112
+ * env env exposed as `env` (defaults to process.env)
113
+ * out sink for command output (defaults to console.log)
114
+ *
115
+ * Returns { iterations, stopped }.
116
+ */
117
+ export async function runScript(source, opts = {}) {
118
+ const registry = opts.commands;
119
+ if (!registry || typeof registry.all !== "function") {
120
+ throw new Error("moshscript: runScript needs a { commands } registry");
121
+ }
122
+ const out = opts.out || ((s) => console.log(s));
123
+ const max = Number.isFinite(opts.max) ? opts.max : DEFAULT_MAX;
124
+ if (!Number.isInteger(max) || max < 1) {
125
+ throw new Error(`moshscript: max must be a positive integer, got ${JSON.stringify(opts.max)}`);
126
+ }
127
+ const control = makeControl(max, out);
128
+
129
+ const ctx = {
130
+ out,
131
+ dryRun: Boolean(opts.dryRun),
132
+ argv: opts.argv || [],
133
+ env: opts.env || process.env,
134
+ control,
135
+ get iter() {
136
+ return control.ticks;
137
+ },
138
+ stop() {
139
+ control.stop();
140
+ },
141
+ };
142
+
143
+ const pending = [];
144
+ const scope = makeScope(registry, ctx, control, pending);
145
+ const body = `with (__scope__) {\n${stripShebang(source)}\n}`;
146
+ const fn = new AsyncFunction("__scope__", body);
147
+ try {
148
+ await fn(scope);
149
+ } finally {
150
+ // Let any un-awaited async verbs (fire-and-forget notify) finish delivering.
151
+ //
152
+ // Drained in `finally`, not after the call: a script that throws has already
153
+ // queued its notify(), and the CLI's catch calls process.exit(1) — which kills
154
+ // the in-flight POST. Draining only on the success path silently dropped the
155
+ // failure ping, i.e. exactly the notification the operator most wants.
156
+ // allSettled never rejects, so this cannot mask the script's own error.
157
+ if (pending.length) await Promise.allSettled(pending);
158
+ }
159
+
160
+ return { iterations: control.ticks, stopped: control.stopped };
161
+ }
@@ -0,0 +1,215 @@
1
+ // Keeping moshcode current without reinstalling it every time.
2
+ //
3
+ // `moshcode update` re-fetches Node, bun and the release tarball on every run,
4
+ // which is fine as a thing you type and wrong as a thing a timer runs every
5
+ // fifteen minutes: it is minutes of network and disk to discover that nothing
6
+ // changed. So the version is checked first and the install only happens when
7
+ // the answer is yes.
8
+ //
9
+ // Automatic updates carry a real cost that is worth stating where the code
10
+ // lives rather than only in a changelog: they propagate a bad release with no
11
+ // one in the loop. A release that breaks DNS reaches every machine on the
12
+ // timer within the interval. That is the trade for not having to think about
13
+ // upgrading, and it is why the timer logs what it did and why `--check` exists
14
+ // as a way to look before leaping.
15
+
16
+ import { moshcodeVersion } from "./ui.mjs";
17
+
18
+ export const DEFAULT_INTERVAL = "15min";
19
+ const RELEASE_API = "https://api.github.com/repos/moshcoder/moshcode/releases/latest";
20
+
21
+ /** The time units systemd.time(7) accepts, longest spelling first so `sec` wins over `s`. */
22
+ const TIME_UNITS = "usec|us|msec|ms|seconds|second|sec|s|minutes|minute|min|m"
23
+ + "|hours|hour|hr|h|days|day|d|weeks|week|w|months|month|M|years|year|y";
24
+ const TIME_SPAN = new RegExp(`^(\\d+(\\.\\d+)?\\s*(${TIME_UNITS})?\\s*)+$`);
25
+
26
+ /**
27
+ * Would systemd accept this as a timer interval?
28
+ *
29
+ * Checked here because the value is written into a unit file, and systemd's
30
+ * reaction to one it cannot parse is to ignore the setting and refuse the whole
31
+ * timer — leaving it enabled but dead. Case matters: `M` is months and `m` is
32
+ * minutes.
33
+ */
34
+ export function validInterval(value) {
35
+ const text = String(value ?? "").trim();
36
+ return text === "infinity" || TIME_SPAN.test(text);
37
+ }
38
+
39
+ /** Strip the `v` and anything after the patch, so `v1.2.3` and `1.2.3` compare. */
40
+ export function normalizeVersion(input) {
41
+ const match = String(input ?? "").trim().match(/(\d+)\.(\d+)\.(\d+)/);
42
+ return match ? match.slice(1, 4).map(Number) : null;
43
+ }
44
+
45
+ /**
46
+ * Is `candidate` newer than `installed`?
47
+ *
48
+ * Ordered comparison rather than string inequality, so a rolled-back release
49
+ * does not read as an upgrade — 0.16.4 against a published 0.16.3 means the
50
+ * machine is ahead, not behind, and reinstalling would be a downgrade nobody
51
+ * asked for.
52
+ */
53
+ export function isNewer(candidate, installed) {
54
+ const a = normalizeVersion(candidate);
55
+ const b = normalizeVersion(installed);
56
+ if (!a || !b) return false;
57
+ for (let i = 0; i < 3; i++) {
58
+ if (a[i] > b[i]) return true;
59
+ if (a[i] < b[i]) return false;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** The published version, or null when the question cannot be answered. */
65
+ export async function latestRelease({ fetchImpl = fetch, timeoutMs = 8000 } = {}) {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
68
+ try {
69
+ const res = await fetchImpl(RELEASE_API, {
70
+ signal: controller.signal,
71
+ headers: { accept: "application/vnd.github+json" },
72
+ });
73
+ if (!res.ok) return null;
74
+ const json = await res.json();
75
+ return typeof json?.tag_name === "string" ? json.tag_name : null;
76
+ } catch {
77
+ // Unreachable registry, rate limit, offline. All the same answer: we do not
78
+ // know, so we do nothing. A timer that reinstalls on every failed check
79
+ // would hammer a machine that is merely offline.
80
+ return null;
81
+ } finally {
82
+ clearTimeout(timer);
83
+ }
84
+ }
85
+
86
+ /** What an update run should do, without doing any of it. */
87
+ export async function updatePlan({ installed = moshcodeVersion(), fetchImpl = fetch } = {}) {
88
+ const latest = await latestRelease({ fetchImpl });
89
+ if (!latest) return { act: false, installed, latest: null, why: "could not reach the release feed" };
90
+ if (!isNewer(latest, installed)) {
91
+ return { act: false, installed, latest, why: `already on ${installed}` };
92
+ }
93
+ return { act: true, installed, latest, why: `${installed} → ${latest}` };
94
+ }
95
+
96
+ /**
97
+ * A systemd timer that checks on an interval.
98
+ *
99
+ * `Persistent=true` so a laptop that was asleep at the scheduled moment checks
100
+ * once when it wakes, rather than skipping until the next one. The service is
101
+ * oneshot and the timer owns the schedule, which is what makes the interval
102
+ * editable without touching the command.
103
+ */
104
+ export function timerUnits({ interval = DEFAULT_INTERVAL, bin = "moshcode" } = {}) {
105
+ return {
106
+ "moshcode-update.service": [
107
+ "[Unit]",
108
+ "Description=Check for a newer moshcode and install it if there is one",
109
+ "After=network-online.target",
110
+ "Wants=network-online.target",
111
+ "",
112
+ "[Service]",
113
+ "Type=oneshot",
114
+ `ExecStart=/usr/bin/env ${bin} update --if-newer`,
115
+ "",
116
+ ].join("\n"),
117
+ "moshcode-update.timer": [
118
+ "[Unit]",
119
+ "Description=Check for a newer moshcode on a schedule",
120
+ "",
121
+ "[Timer]",
122
+ `OnBootSec=${interval}`,
123
+ `OnUnitActiveSec=${interval}`,
124
+ // A machine asleep at the scheduled moment checks once on waking rather
125
+ // than waiting for the next interval.
126
+ "Persistent=true",
127
+ "",
128
+ "[Install]",
129
+ "WantedBy=timers.target",
130
+ "",
131
+ ].join("\n"),
132
+ };
133
+ }
134
+
135
+ const USAGE = `moshcode update --if-newer — install only when a newer release exists
136
+
137
+ moshcode update --check say what would happen; change nothing
138
+ moshcode update --if-newer install only if the published version is newer
139
+ moshcode update --timer print the systemd units for a scheduled check
140
+ moshcode update --timer --install write and enable them (needs root)
141
+ moshcode update --timer --interval 1h
142
+
143
+ Automatic updates hand a bad release to every machine on the timer within the
144
+ interval, with nobody in the loop. That is the trade.`;
145
+
146
+ /** The `--check` / `--if-newer` / `--timer` half of `moshcode update`. */
147
+ export async function selfUpdateCommand(args = [], out = console.log, deps = {}) {
148
+ const { plan = updatePlan, upgrade = null, write = null, runner = null } = deps;
149
+
150
+ if (args.includes("--help")) {
151
+ out(USAGE);
152
+ return 0;
153
+ }
154
+
155
+ if (args.includes("--timer")) {
156
+ const at = args.indexOf("--interval");
157
+ let interval = DEFAULT_INTERVAL;
158
+ if (at >= 0) {
159
+ const value = args[at + 1];
160
+ // `--interval` took whatever followed it, including the next flag. With
161
+ // `--timer --interval --install` that is the word `--install`, which does
162
+ // not stop the install — `args.includes("--install")` is still true — so
163
+ // the units get written with `OnBootSec=--install`, systemd refuses the
164
+ // timer, and the command still says it is checking on a schedule.
165
+ if (value === undefined || value.startsWith("-")) {
166
+ out(`moshcode update: --interval needs a time span, not ${value === undefined ? "nothing" : JSON.stringify(value)} — try --interval 1h`);
167
+ return 1;
168
+ }
169
+ if (!validInterval(value)) {
170
+ out(`moshcode update: --interval ${JSON.stringify(value)} is not a systemd time span — try 15min, 1h, 2d or 1h30min`);
171
+ return 1;
172
+ }
173
+ interval = value;
174
+ }
175
+ const units = timerUnits({ interval });
176
+ if (!args.includes("--install")) {
177
+ for (const [name, body] of Object.entries(units)) {
178
+ out(`--- /etc/systemd/system/${name} ---`);
179
+ out(body);
180
+ }
181
+ out("nothing written. re-run with --install (as root).");
182
+ return 0;
183
+ }
184
+ if (!write || !runner) {
185
+ out("moshcode update: cannot write units here");
186
+ return 1;
187
+ }
188
+ for (const [name, body] of Object.entries(units)) {
189
+ await write(`/etc/systemd/system/${name}`, body);
190
+ out(` wrote /etc/systemd/system/${name}`);
191
+ }
192
+ const reload = await runner("systemctl", ["daemon-reload"]);
193
+ const enable = await runner("systemctl", ["enable", "--now", "moshcode-update.timer"]);
194
+ // The units are on disk, but they only run if systemd actually took them.
195
+ // On a host without systemd (a container, WSL, macOS) or without root, the
196
+ // enable fails — and saying "checking on a schedule now" then would promise
197
+ // an auto-update that will never fire. Report the failure instead.
198
+ if (reload?.ok === false || enable?.ok === false) {
199
+ out("moshcode update: wrote the units but systemctl could not start the timer — it is not checking on a schedule yet. run `systemctl enable --now moshcode-update.timer` as root.");
200
+ return 1;
201
+ }
202
+ out("checking on a schedule now. `systemctl list-timers moshcode-update` to see when.");
203
+ return 0;
204
+ }
205
+
206
+ const decision = await plan();
207
+ out(decision.act
208
+ ? `update available: ${decision.why}`
209
+ : `no update: ${decision.why}`);
210
+
211
+ // --check reports and stops. Without it, acting is the point.
212
+ if (args.includes("--check") || !decision.act) return 0;
213
+ if (!upgrade) return 0;
214
+ return (await upgrade()) ?? 0;
215
+ }