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 +90 -0
- package/bin/moshcode.mjs +9 -0
- package/package.json +1 -1
- package/prd/0010-cloud-settings-sync.md +132 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +65 -0
- package/src/dns-system.mjs +176 -6
- package/src/dns.mjs +33 -1
- package/src/games-chess.mjs +376 -0
- package/src/games-hangman.mjs +97 -0
- package/src/games-pacman.mjs +205 -0
- package/src/games-snake.mjs +111 -0
- package/src/games-tetris.mjs +221 -0
- package/src/games-tictactoe.mjs +124 -0
- package/src/games.mjs +337 -0
- package/src/settings-sync.mjs +659 -0
- package/src/tui.mjs +18 -0
- package/src/ui.mjs +3 -1
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
|
@@ -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 -->
|
package/src/cli-schema.mjs
CHANGED
|
@@ -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,
|
package/src/dns-system.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
371
|
+
* Where a daemon that died on startup left its reason.
|
|
371
372
|
*
|
|
372
|
-
*
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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 }),
|