moshcode 0.39.0 → 0.41.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/README.md CHANGED
@@ -43,11 +43,14 @@ or miss one that does. A test fails the build when it drifts.
43
43
  | `moshcode login` | account | authenticate with app.moshcode.sh |
44
44
  | `moshcode whoami` | account | show the logged-in account |
45
45
  | `moshcode logout` | account | clear the logged-in account |
46
+ | `moshcode save` | account | save this machine's pit settings to your account |
47
+ | `moshcode load` | account | bring your saved pit settings onto this machine |
46
48
  | `moshcode console` | account | serve or connect to the browser terminal |
47
49
  | `moshcode dns` | hosting | resolve Moshpit names on this machine |
48
50
  | `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
49
51
  | `moshcode site` <br>`serve` | hosting | install web-server config for a Moshpit name |
50
52
  | `moshcode template` <br>`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
53
+ | `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade — six games, no menus |
51
54
  | `moshcode pwd` <br>`where` | system | show the current directory and git context |
52
55
  | `moshcode engines` | engines | list engines and installation status |
53
56
  | `moshcode tools` | tools | list workflow tools and installation status |
@@ -579,6 +582,93 @@ event, and publishes it to the displayed relays. Both flows leave the final
579
582
  confirmation in the browser. If the pit is remote or headless, `/post` prints
580
583
  the composer URL instead.
581
584
 
585
+ ## The arcade (`/games`)
586
+
587
+ Six games, in the pit or straight from a shell. There are no menus, no options
588
+ screens and no difficulty prompts — `/games tetris` is already playing.
589
+
590
+ ```sh
591
+ moshcode games # the cabinet (pit: /games)
592
+ moshcode games tetris # play one (pit: /games tetris)
593
+ moshcode games --json # the roster, for a machine
594
+ ```
595
+
596
+ ```
597
+ TETRIS score 1200 · lines 12
598
+ ┌────────────────────────────────┐
599
+ │ · · · · ████· · · · NEXT │
600
+ │ · · · · ████· · · · │
601
+ │ · · · · · · · · · · ████████ │
602
+ │ · · · · · · · · · · │
603
+ │ · · · ████· · · · · LVL 2 │
604
+ │ ████████████· ██████ │
605
+ └────────────────────────────────┘
606
+ ← → move · ↑ rotate · ↓ drop one · space slam · q quit
607
+ ```
608
+
609
+ | game | |
610
+ |---|---|
611
+ | `tetris` | stack the bricks, clear the lines, outrun gravity |
612
+ | `snake` | eat, grow, and try not to eat yourself |
613
+ | `pacman` | eat the dots, dodge the ghosts, `✳` makes them edible |
614
+ | `tictactoe` | three in a row against an opponent that cannot be beaten |
615
+ | `chess` | full rules — castling, en passant, promotion — and it plays back |
616
+ | `hangman` | six wrong letters and you are done for |
617
+
618
+ Every one of them works the same way: arrows move, `q` quits, `r` starts
619
+ another, and the controls are written along the bottom of the game itself. Each
620
+ draws in place rather than on the alternate screen, so the board you finished on
621
+ stays in your scrollback.
622
+
623
+ Playing needs a real terminal, because they read single keypresses — `moshcode
624
+ games` on its own lists them anywhere, including a pipe.
625
+
626
+ ## Settings sync (`/save` and `/load`)
627
+
628
+ Your pit becomes yours by accretion — a dozen aliases, herd rules you tuned until
629
+ the roster stopped lying to you. All of it lives in `~/.moshcode` on one machine,
630
+ which is why every new laptop, container and droplet used to feel like someone
631
+ else's prompt.
632
+
633
+ `/save` pushes that configuration to your `app.moshcode.sh` account. `/load`
634
+ brings it down onto any machine you have run `/login` on.
635
+
636
+ ```sh
637
+ moshcode save # push this machine's settings (pit: /save)
638
+ moshcode save --dry-run # what would go up, and stop
639
+
640
+ # on the new box
641
+ moshcode login
642
+ moshcode load # pull them down (pit: /load)
643
+ moshcode load --dry-run # the per-file plan, changing nothing
644
+ ```
645
+
646
+ What syncs is an allowlist, not a directory walk:
647
+
648
+ | file | what it is |
649
+ |---|---|
650
+ | `~/.moshcode/aliases.json` | your pit aliases (`/alias`) |
651
+ | `~/.moshcode/herd/rules.json` | herd state-detection overrides |
652
+
653
+ What never syncs, by name: `credentials.json` (the account token this very
654
+ feature authenticates with), `herd/sessions.json` (live state pinned to one tmux
655
+ server), `sync.json`, and the `pkg/` binary cache. Engine configuration
656
+ (`~/.claude.json` and friends) is deliberately left alone — those files carry
657
+ provider API keys.
658
+
659
+ Nothing is overwritten quietly:
660
+
661
+ - Each save is a numbered **revision**. `/save` sends the revision it last agreed
662
+ on, and the app refuses the write if another machine has saved since — you get
663
+ told, with `/load` and `/save --force` as the two ways out.
664
+ - `/load` refuses to replace a settings file you edited since this machine last
665
+ synced, and names it. `--force` overrides.
666
+ - The last ten revisions are kept. See them, and which machine each came from, at
667
+ [app.moshcode.sh/settings/sync](https://app.moshcode.sh/settings/sync) — where
668
+ you can also promote an older revision or delete the lot.
669
+
670
+ Both verbs take `--json`, so a provisioning script can act on the result.
671
+
582
672
  ## Browser terminal (`moshcode console`)
583
673
 
584
674
  A real terminal in the browser — arrow keys, history, full-screen TUIs — because
package/bin/moshcode.mjs CHANGED
@@ -22,10 +22,12 @@ import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs";
22
22
  import { mcpCommand, pluginCommand, skillCommand } from "../src/integrations.mjs";
23
23
  import { stocksCommand } from "../src/advisor.mjs";
24
24
  import { cryptoCommand } from "../src/crypto.mjs";
25
+ import { gamesCommand } from "../src/games.mjs";
25
26
  import { canOpenBrowser, openBrowser } from "../src/open-url.mjs";
26
27
  import { locate, tilde } from "../src/pwd.mjs";
27
28
  import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
28
29
  import { loginAuto, whoami, logout } from "../src/auth.mjs";
30
+ import { loadCommand, saveCommand } from "../src/settings-sync.mjs";
29
31
  import { tui } from "../src/tui.mjs";
30
32
  import { consoleCommand } from "../src/console.mjs";
31
33
  import { herdCommand, herdStart, splitDetachArgs } from "../src/herd-cli.mjs";
@@ -397,6 +399,11 @@ async function main() {
397
399
  if (code) process.exitCode = code;
398
400
  return;
399
401
  }
402
+ if (cmd === "games" || cmd === "game" || cmd === "arcade") {
403
+ const code = await gamesCommand(rest);
404
+ if (code) process.exitCode = code;
405
+ return;
406
+ }
400
407
  if (cmd === "console") {
401
408
  const code = await consoleCommand(rest);
402
409
  if (code) process.exitCode = code;
@@ -609,6 +616,8 @@ async function main() {
609
616
  return;
610
617
  }
611
618
  if (cmd === "logout") { logout(); return; }
619
+ if (cmd === "save") { process.exitCode = await saveCommand(rest); return; }
620
+ if (cmd === "load") { process.exitCode = await loadCommand(rest); return; }
612
621
  if (cmd === "run") {
613
622
  let max = 3, dryRun = false;
614
623
  let optionsEnded = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -0,0 +1,132 @@
1
+ ---
2
+ openprd: "0.2"
3
+ id: "0010"
4
+ title: "Sync the pit's settings to your moshcode.sh account"
5
+ status: Draft
6
+ authors:
7
+ - anthony@profullstack.com
8
+ created: 2026-08-11
9
+ updated: 2026-08-11
10
+ repo: https://github.com/moshcoder/moshcode
11
+ discussion:
12
+ implementation: src/settings-sync.mjs · apps/pwa/src/routes/settings-sync.mjs
13
+ tags: account, settings, sync
14
+ supersedes:
15
+ superseded-by:
16
+ ---
17
+
18
+ ## Problem
19
+
20
+ A pit becomes yours by accretion. You add `/alias set gs "git status"`, then a
21
+ dozen more; you tune the herd's rules until it stops calling a working agent
22
+ blocked. None of it is in a repo, none of it is in a dotfile anyone syncs, and
23
+ all of it lives in `~/.moshcode` on exactly one machine.
24
+
25
+ So the second machine is a stranger. A new laptop, a fresh container, a droplet
26
+ you SSH into to babysit an agent, a reinstall after a disk swap — each one starts
27
+ from nothing, and the muscle memory built at the first prompt does not work at
28
+ the second. People already log in to `app.moshcode.sh` (`/login`) for approvals,
29
+ notifications and the session mirror, so the account that could hold this
30
+ configuration is already there and already paired with every machine.
31
+
32
+ ## Goals
33
+
34
+ - Moving to a new machine costs `/login` and `/load`, not an afternoon of
35
+ remembering what you had.
36
+ - A person can see what is stored on their account, and delete it, from the web.
37
+ - Nobody is ever surprised by a settings overwrite — not from another machine,
38
+ and not over their own uncommitted edits.
39
+ - No credential, key or token is ever part of what syncs, and that fact is
40
+ enforced by a test rather than by care.
41
+
42
+ ## Non-Goals
43
+
44
+ - Continuous or background sync. Settings are edited by a person at a moment they
45
+ can name; a daemon that pushes silently is a daemon that overwrites silently.
46
+ - Syncing engine configuration (`~/.claude.json`, `~/.codex`, MCP registrations).
47
+ Those files carry provider API keys and are owned by other tools' schemas.
48
+ - Syncing machine state: live herd sessions, the package cache, shell history.
49
+ None of it means anything on a different box.
50
+ - Merging. Two divergent settings files are resolved by a person choosing one,
51
+ not by a three-way merge of someone's aliases.
52
+
53
+ ## Users
54
+
55
+ - **The multi-machine moshcoder** — laptop, desktop, a dev box, and a container
56
+ per project. Wants the same prompt everywhere.
57
+ - **The reinstaller** — new OS, same person. Wants their aliases back.
58
+ - **The team lead** — one account, several machines, and a strong preference for
59
+ never explaining to a colleague why their aliases disappeared.
60
+
61
+ ## Requirements
62
+
63
+ - R1 [P0] `/save` (and `moshcode save`) uploads this machine's pit settings to the
64
+ logged-in account. `/load` (`moshcode load`) brings them back down.
65
+ - R2 [P0] What syncs is an allowlist, not a directory walk: `aliases.json` and
66
+ `herd/rules.json` today. `credentials.json`, `herd/sessions.json`, `sync.json`
67
+ and `pkg/` are named as never-synced and asserted in tests.
68
+ - R3 [P0] Each save is a numbered revision. `/save` sends the revision it last
69
+ agreed on and the app refuses the write if the account has moved past it, so
70
+ two machines cannot silently erase one another.
71
+ - R4 [P0] `/load` refuses to overwrite a settings file that changed locally since
72
+ the last sync, and names the file. `--force` overrides; `--dry-run` shows the
73
+ per-file plan and writes nothing.
74
+ - R5 [P0] Every path in a downloaded snapshot is re-checked against the allowlist
75
+ before anything is written. A snapshot is data from the network, and an
76
+ unchecked path in it makes `/load` a remote write primitive.
77
+ - R6 [P1] The app keeps the last ten revisions, shows them at
78
+ `/settings/sync` with the machine and time each came from, and can promote an
79
+ older one to current.
80
+ - R7 [P1] `--json` on both verbs, so a provisioning script can act on the result.
81
+ - R8 [P1] A snapshot records which engines and tools the source machine had
82
+ installed. `/load` names the missing ones as a suggestion; it never installs.
83
+ - R9 [P2] Not logged in, session expired, nothing saved yet, conflict: each is a
84
+ sentence naming the command that resolves it (`/login`, `/save`, `/load`,
85
+ `--force`).
86
+
87
+ ## UX Notes
88
+
89
+ ```
90
+ mosh ▸ /save
91
+ ✓ saved 2 files to you@example.com (revision 3)
92
+ aliases.json pit aliases
93
+ herd/rules.json herd state rules
94
+ on another machine: `/login` then `/load`
95
+
96
+ mosh ▸ /load # on the new box
97
+ loaded revision 3 from dev — 2 files written
98
+ added aliases.json
99
+ added herd/rules.json
100
+ that machine also had codex, gh — `/install <name>` to match it
101
+
102
+ mosh ▸ /load # after editing aliases locally
103
+ 1 local file changed since this machine last synced:
104
+ aliases.json
105
+ `/save` to keep them, `/load --force` to replace them, `/load --dry-run` to see the difference
106
+ ```
107
+
108
+ The pit never blocks on this: both verbs are one request and some printing, so
109
+ readline keeps the prompt. `~/.moshcode/sync.json` remembers the revision and a
110
+ per-file digest — that digest is what separates "someone else saved" from "you
111
+ edited this five minutes ago", which want opposite answers.
112
+
113
+ ## Success Metrics
114
+
115
+ - A fresh machine reaches a familiar prompt in two commands (`/login`, `/load`).
116
+ - Zero settings-loss reports: every destructive path is either refused or
117
+ recoverable from `/settings/sync`.
118
+ - No credential ever appears in a stored snapshot (asserted, not audited).
119
+
120
+ ## Risks & Open Questions
121
+
122
+ - **Scope creep into secrets.** The most-requested next file will be an engine
123
+ config that holds an API key. Holding the line — settings, never credentials —
124
+ is what keeps `/load` safe to run on a machine you share.
125
+ - **Ten revisions is a guess.** Cheap to raise; it exists so a bad `/save` from
126
+ the wrong machine is recoverable at all.
127
+ - **A snapshot version bump.** Handled by refusing to read a newer snapshot and
128
+ naming `moshcode upgrade`, rather than by guessing at a shape this build has
129
+ never seen.
130
+ - **Should `/load` be able to pick a revision?** The app stores ten and the web
131
+ page can promote one, which covers recovery without adding a flag that takes a
132
+ number. Open if people ask for `--revision`.
package/prd/README.md CHANGED
@@ -25,4 +25,5 @@ Start one with `moshcode prd "<idea>"` (TUI: `/prd`).
25
25
  | [0007](0007-profullstack-site-init.md) | Generate batteries-included Profullstack sites for Moshpit names | Draft |
26
26
  | [0008](0008-ticker-research-and-plugin-marketplace.md) | Bring equity research into the pit, and ship the pit's slash commands as a plugin | Draft |
27
27
  | [0009](0009-persistent-agent-runtime.md) | Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents | Accepted |
28
+ | [0010](0010-cloud-settings-sync.md) | Sync the pit's settings to your moshcode.sh account | Draft |
28
29
  <!-- PRD-INDEX:END -->
@@ -24,6 +24,7 @@ export const COMMAND_GROUPS = [
24
24
  { key: "tools", title: "tools" },
25
25
  { key: "extend", title: "extend" },
26
26
  { key: "script", title: "script" },
27
+ { key: "arcade", title: "arcade" },
27
28
  { key: "account", title: "account" },
28
29
  { key: "hosting", title: "hosting" },
29
30
  { key: "system", title: "system" },
@@ -278,6 +279,43 @@ export const CORE_CLI_COMMANDS = [
278
279
  synopsis: [["moshcode logout", ""]],
279
280
  seeAlso: ["login"],
280
281
  },
282
+ {
283
+ name: "save",
284
+ group: "account",
285
+ description: "save this machine's pit settings to your account",
286
+ synopsis: [["moshcode save [--dry-run] [--force] [--json]", ""]],
287
+ flags: [
288
+ ["--dry-run", "list what would be saved and stop", ""],
289
+ ["--force", "save even if another machine saved after this one last synced", ""],
290
+ ["--json", "machine-readable result", ""],
291
+ ],
292
+ examples: [
293
+ ["moshcode save", "push aliases + herd rules to app.moshcode.sh"],
294
+ ["moshcode save --dry-run", "what would go up"],
295
+ ],
296
+ seeAlso: ["load", "login", "alias"],
297
+ note: "aliases (~/.moshcode/aliases.json) and herd rules (~/.moshcode/herd/rules.json). "
298
+ + "credentials, live herd state and the package cache are never included. "
299
+ + "each save is a numbered revision; the last ten are kept at app.moshcode.sh/settings/sync.",
300
+ },
301
+ {
302
+ name: "load",
303
+ group: "account",
304
+ description: "bring your saved pit settings onto this machine",
305
+ synopsis: [["moshcode load [--dry-run] [--force] [--json]", ""]],
306
+ flags: [
307
+ ["--dry-run", "show the per-file plan and change nothing", ""],
308
+ ["--force", "overwrite local settings that changed since the last sync", ""],
309
+ ["--json", "machine-readable result", ""],
310
+ ],
311
+ examples: [
312
+ ["moshcode load", "on a new machine, right after moshcode login"],
313
+ ["moshcode load --dry-run", "which files would change"],
314
+ ],
315
+ seeAlso: ["save", "login", "alias"],
316
+ note: "refuses rather than overwriting a local file you edited since the last sync — "
317
+ + "`moshcode save` to keep it, or --force to replace it.",
318
+ },
281
319
  {
282
320
  name: "console",
283
321
  group: "account",
@@ -355,6 +393,27 @@ export const CORE_CLI_COMMANDS = [
355
393
  seeAlso: ["site"],
356
394
  },
357
395
  { name: "templates", aliasOf: "template", description: "alias for template" },
396
+ {
397
+ name: "games",
398
+ group: "arcade",
399
+ description: "the moshcode arcade — six games, no menus",
400
+ synopsis: [
401
+ ["moshcode games", "the cabinet, and what each one is"],
402
+ ["moshcode games <game>", "play it, right here in the terminal"],
403
+ ],
404
+ flags: [["--json", "the roster, machine-readable", ""]],
405
+ examples: [
406
+ ["moshcode games", "what is in the arcade"],
407
+ ["moshcode games tetris", ""],
408
+ ["moshcode games chess", "real rules, and it plays back"],
409
+ ["moshcode games pacman", "dots, ghosts, three lives"],
410
+ ],
411
+ seeAlso: ["help"],
412
+ note: "every game works the same way: arrows move, q quits, r starts another. "
413
+ + "Playing needs a real terminal because they read single keypresses — `moshcode games` on its own lists them anywhere.",
414
+ },
415
+ { name: "game", aliasOf: "games", description: "alias for games" },
416
+ { name: "arcade", aliasOf: "games", description: "alias for games" },
358
417
  {
359
418
  name: "pwd",
360
419
  group: "system",
@@ -870,6 +929,8 @@ export const PIT_COMMANDS = [
870
929
  description: "crypto market data from advis0r.com" },
871
930
  { name: "plugin", aliases: ["plugins"], args: "<verb> [name]", cli: "plugin",
872
931
  description: "install moshcode's slash commands into Claude Code" },
932
+ { name: "games", aliases: ["game", "arcade", "play"], args: "[game]", cli: "games",
933
+ description: "the arcade — tetris, snake, pac-man, tic-tac-toe, chess, hangman" },
873
934
  { name: "socials", aliases: ["social"], pitOnly: true,
874
935
  description: "list social networks available for posting" },
875
936
  { name: "post", args: '<social> "message"', pitOnly: true,
@@ -891,6 +952,10 @@ export const PIT_COMMANDS = [
891
952
  { name: "whoami", cli: "whoami", description: "who this machine is logged in as" },
892
953
  // Dispatched since forever and missing from /help until now.
893
954
  { name: "logout", cli: "logout", description: "clear the logged-in account" },
955
+ { name: "save", args: "[--dry-run] [--force]", cli: "save",
956
+ description: "save this pit's settings to your moshcode.sh account" },
957
+ { name: "load", args: "[--dry-run] [--force]", cli: "load",
958
+ description: "bring your saved settings onto this machine" },
894
959
  { name: "pwd", aliases: ["where"], cli: "pwd",
895
960
  description: "show the current dir + git repo/branch/origin" },
896
961
  { name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
@@ -274,7 +274,8 @@ export function requiredPort(platform, preferred = 5354) {
274
274
  /* ------------------------------------------------------- running the plan */
275
275
 
276
276
  import { spawn } from "node:child_process";
277
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
277
+ import dgram from "node:dgram";
278
+ import { mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
278
279
  import { existsSync } from "node:fs";
279
280
  import { dirname, join } from "node:path";
280
281
  import { homedir, tmpdir } from "node:os";
@@ -367,17 +368,132 @@ export async function daemonStatus(path = pidfilePath()) {
367
368
  }
368
369
 
369
370
  /**
370
- * Start the bridge detached, so the shell that launched it can exit.
371
+ * Where a daemon that died on startup left its reason.
371
372
  *
372
- * Not a systemd unit / launchd job / Windows service yet, which means it does
373
+ * Next to the pidfile, because the two answer halves of the same question and
374
+ * a person debugging one wants the other in the same directory.
375
+ */
376
+ export function daemonLogPath(path = pidfilePath()) {
377
+ return join(dirname(path), "moshpit-dns.log");
378
+ }
379
+
380
+ /**
381
+ * How long to wait for the bridge to answer before reporting it unproven.
382
+ *
383
+ * Generous on purpose, and it costs nothing in the case that matters: a daemon
384
+ * that dies resolves the race on its `exit` event immediately, so this bounds
385
+ * only the "alive but has not answered yet" case. The bridge binds *after* it
386
+ * fetches the ending list, which against the live registry is ~3s on a fast
387
+ * link — a tighter deadline would print a warning about healthy bridges on
388
+ * every slow connection.
389
+ */
390
+ export const READY_TIMEOUT_MS = 8000;
391
+ const POLL_MS = 150;
392
+ const LOG_TAIL_LINES = 20;
393
+
394
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
395
+
396
+ /** A minimal A query. Only the reply matters here, never what it says. */
397
+ function encodeQuery(name, id) {
398
+ const labels = String(name).split(".").filter(Boolean);
399
+ const head = Buffer.alloc(12);
400
+ head.writeUInt16BE(id, 0);
401
+ head.writeUInt16BE(0x0100, 2); // standard query, recursion desired
402
+ head.writeUInt16BE(1, 4); // one question
403
+ const tail = Buffer.alloc(4);
404
+ tail.writeUInt16BE(1, 0); // A
405
+ tail.writeUInt16BE(1, 2); // IN
406
+ return Buffer.concat([
407
+ head,
408
+ ...labels.map((label) => {
409
+ const bytes = Buffer.from(label, "ascii");
410
+ return Buffer.concat([Buffer.from([bytes.length]), bytes]);
411
+ }),
412
+ Buffer.from([0]),
413
+ tail,
414
+ ]);
415
+ }
416
+
417
+ /**
418
+ * Is something serving DNS on this port?
419
+ *
420
+ * Any well-formed reply counts, including NXDOMAIN and SERVFAIL. The question
421
+ * is whether the resolver is up, and a bridge whose upstreams are unreachable
422
+ * is still a bridge that started — conflating the two would turn a bad network
423
+ * into a failed start.
424
+ */
425
+ export function probeResolver({ host = "127.0.0.1", port, name = "a.eggs", timeoutMs = 500 } = {}) {
426
+ return new Promise((resolve) => {
427
+ const socket = dgram.createSocket("udp4");
428
+ const id = Math.floor(Math.random() * 65536);
429
+ let done = false;
430
+ const finish = (answered) => {
431
+ if (done) return;
432
+ done = true;
433
+ clearTimeout(timer);
434
+ try {
435
+ socket.close();
436
+ } catch {
437
+ // Already closed by the error that brought us here.
438
+ }
439
+ resolve(answered);
440
+ };
441
+ const timer = setTimeout(() => finish(false), timeoutMs);
442
+ socket.once("error", () => finish(false));
443
+ socket.on("message", (msg) => finish(msg.length >= 2 && msg.readUInt16BE(0) === id));
444
+ socket.send(encodeQuery(name, id), port, host, (err) => {
445
+ if (err) finish(false);
446
+ });
447
+ });
448
+ }
449
+
450
+ async function readLogTail(path, lines = LOG_TAIL_LINES) {
451
+ const text = await readFile(path, "utf8").catch(() => "");
452
+ const trimmed = text.trimEnd();
453
+ return trimmed ? trimmed.split("\n").slice(-lines).join("\n") : "";
454
+ }
455
+
456
+ /**
457
+ * Start the bridge detached, so the shell that launched it can exit — and do
458
+ * not claim it started until it has proved it is there.
459
+ *
460
+ * The old version spawned with `stdio: "ignore"`, wrote the pidfile from
461
+ * `child.pid`, and returned `started: true` in the same tick. Both halves of
462
+ * that were wrong on any machine where the daemon dies on startup. `enable`
463
+ * printed `ok bridge started (pid N)` for a process that was already gone, then
464
+ * installed catch-all routing — `Domains=~.` — pointing every lookup on the box
465
+ * at a port with nothing behind it. The failure took the machine's whole
466
+ * resolver down and left no way to find out why, because the one stream the
467
+ * daemon wrote its reason to had been routed to /dev/null. A node that is not
468
+ * on root's PATH, a port it cannot bind, a half-written install: all of them
469
+ * arrived as the same confident success line.
470
+ *
471
+ * So: stdout and stderr go to a file, an early exit is a failed start that
472
+ * reports what the daemon said, and the pidfile is written only once the
473
+ * process is still there — never for one that is not, which is what made
474
+ * `daemonStatus` report a stale pid as a crash that had never happened.
475
+ *
476
+ * Still not a systemd unit / launchd job / Windows service, which means it does
373
477
  * not survive a reboot. `moshcode dns status` says so plainly rather than
374
478
  * letting someone discover it when their names stop resolving.
375
479
  */
376
- export async function startDaemon({ port, registryBase, path = pidfilePath(), entry, proxy = null }) {
480
+ export async function startDaemon({
481
+ port,
482
+ registryBase,
483
+ path = pidfilePath(),
484
+ entry,
485
+ proxy = null,
486
+ host = "127.0.0.1",
487
+ logPath = null,
488
+ readyTimeoutMs = READY_TIMEOUT_MS,
489
+ probe = probeResolver,
490
+ sleep = defaultSleep,
491
+ }) {
377
492
  const existing = await daemonStatus(path);
378
493
  if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true };
379
494
 
380
495
  await mkdir(dirname(path), { recursive: true });
496
+ const log = logPath || daemonLogPath(path);
381
497
  const args = [entry, "dns", "start", "--port", String(port)];
382
498
  if (registryBase) args.push("--registry", registryBase);
383
499
  // Passed at spawn time because it is what the resolver answers with, not
@@ -385,10 +501,64 @@ export async function startDaemon({ port, registryBase, path = pidfilePath(), en
385
501
  // short of restarting it, which is why `enable` decides this before starting.
386
502
  if (proxy) args.push("--proxy", proxy);
387
503
 
388
- const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
504
+ // Truncated rather than appended: the only question this file ever answers is
505
+ // "why did the run I just did fail", and a previous crash above this run's
506
+ // output is how that question gets answered wrong.
507
+ await writeFile(log, "");
508
+ const handle = await open(log, "a");
509
+ let child;
510
+ try {
511
+ child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", handle.fd, handle.fd] });
512
+ } finally {
513
+ // The child holds its own duplicate of the descriptor from spawn onward.
514
+ await handle.close();
515
+ }
516
+
517
+ // `error` covers the spawn itself failing — execPath gone, not executable —
518
+ // which never reaches `exit` at all.
519
+ const died = new Promise((resolve) => {
520
+ child.once("error", (error) => resolve({ reason: error.message }));
521
+ child.once("exit", (code, signal) => resolve({
522
+ reason: signal ? `killed by ${signal}` : `exited ${code} before it could serve`,
523
+ code,
524
+ signal,
525
+ }));
526
+ });
527
+
528
+ let gone = null;
529
+ let verified = false;
530
+ const deadline = Date.now() + readyTimeoutMs;
531
+ while (Date.now() < deadline) {
532
+ gone = await Promise.race([died, sleep(POLL_MS).then(() => null)]);
533
+ if (gone) break;
534
+ if (await probe({ host, port })) {
535
+ verified = true;
536
+ break;
537
+ }
538
+ }
539
+
389
540
  child.unref();
541
+
542
+ if (gone) {
543
+ // No pidfile for a process that is not there. Writing one anyway is what
544
+ // made the next `enable` believe a bridge was running and skip starting one.
545
+ await rm(path, { force: true });
546
+ return {
547
+ started: false,
548
+ alreadyRunning: false,
549
+ pid: null,
550
+ error: gone.reason,
551
+ log: await readLogTail(log),
552
+ logPath: log,
553
+ };
554
+ }
555
+
390
556
  await writeFile(path, `${child.pid}\n`);
391
- return { started: true, pid: child.pid, alreadyRunning: false };
557
+ // `verified: false` is a process that is alive but had not answered by the
558
+ // deadline — a slow registry fetch on a slow link, most often. Reported as
559
+ // what it is rather than rounded up to success or down to failure: killing a
560
+ // bridge that was merely still waking up would be the worse mistake.
561
+ return { started: true, pid: child.pid, alreadyRunning: false, verified, logPath: log };
392
562
  }
393
563
 
394
564
  export async function stopDaemon(path = pidfilePath()) {
package/src/dns.mjs CHANGED
@@ -2892,11 +2892,43 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2892
2892
  // too rather than pinning the answer to one family.
2893
2893
  proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null,
2894
2894
  });
2895
+ // The routing this is about to install is catch-all — every lookup on the
2896
+ // machine, not just Moshpit ones — so a bridge that did not come up is not
2897
+ // a degraded feature, it is the machine's resolver pointed at nothing.
2898
+ // Refused here, before the drop-in is written, because the alternative was
2899
+ // discovering it from a box that could no longer resolve its own package
2900
+ // mirror. Nothing has been changed at this point except the restore point,
2901
+ // which is removed on the way out.
2902
+ if (!started.reused && !started.alreadyRunning && !started.started) {
2903
+ out(` FAIL bridge did not start on ${DEFAULT_HOST}:${wanted} — ${started.error}`);
2904
+ if (started.log) {
2905
+ out("");
2906
+ for (const line of started.log.split("\n")) out(` ${line}`);
2907
+ }
2908
+ out("");
2909
+ out("Refusing to route this machine's DNS at a bridge that is not running.");
2910
+ out("Nothing has been changed.");
2911
+ if (started.logPath) out(` the daemon's output is at ${started.logPath}`);
2912
+ out(` to watch it start in the foreground: moshcode dns start --port ${wanted}`);
2913
+ if (recorded2.ok) await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the switch never happened" }] });
2914
+ return 1;
2915
+ }
2895
2916
  out(started.reused
2896
2917
  ? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one`
2897
2918
  : started.alreadyRunning
2898
2919
  ? ` ok bridge already running (pid ${started.pid})`
2899
- : ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid})`);
2920
+ : started.verified === true
2921
+ ? ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid}) — answering`
2922
+ : ` ok bridge started on ${DEFAULT_HOST}:${wanted} (pid ${started.pid})`);
2923
+ // Alive, but it had not answered a query by the deadline. Said out loud
2924
+ // rather than swallowed: if the routing below fails to verify, this line is
2925
+ // the reason, and it is cheaper to read it here than to derive it later.
2926
+ // Strictly `false`, never merely absent: a starter that does not report on
2927
+ // verification has not failed it, and rounding the two together would print
2928
+ // a warning about every bridge that was started by something else.
2929
+ if (started.started && started.verified === false) {
2930
+ out(` -- it has not answered a query yet — still starting, or it will not serve`);
2931
+ }
2900
2932
 
2901
2933
  const outcome = await applyWith(plan, {
2902
2934
  verify: () => verify({ moshpit: moshpitProbe }),