moshcode 0.39.0 → 0.40.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,6 +43,8 @@ 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 |
@@ -579,6 +581,52 @@ event, and publishes it to the displayed relays. Both flows leave the final
579
581
  confirmation in the browser. If the pit is remote or headless, `/post` prints
580
582
  the composer URL instead.
581
583
 
584
+ ## Settings sync (`/save` and `/load`)
585
+
586
+ Your pit becomes yours by accretion — a dozen aliases, herd rules you tuned until
587
+ the roster stopped lying to you. All of it lives in `~/.moshcode` on one machine,
588
+ which is why every new laptop, container and droplet used to feel like someone
589
+ else's prompt.
590
+
591
+ `/save` pushes that configuration to your `app.moshcode.sh` account. `/load`
592
+ brings it down onto any machine you have run `/login` on.
593
+
594
+ ```sh
595
+ moshcode save # push this machine's settings (pit: /save)
596
+ moshcode save --dry-run # what would go up, and stop
597
+
598
+ # on the new box
599
+ moshcode login
600
+ moshcode load # pull them down (pit: /load)
601
+ moshcode load --dry-run # the per-file plan, changing nothing
602
+ ```
603
+
604
+ What syncs is an allowlist, not a directory walk:
605
+
606
+ | file | what it is |
607
+ |---|---|
608
+ | `~/.moshcode/aliases.json` | your pit aliases (`/alias`) |
609
+ | `~/.moshcode/herd/rules.json` | herd state-detection overrides |
610
+
611
+ What never syncs, by name: `credentials.json` (the account token this very
612
+ feature authenticates with), `herd/sessions.json` (live state pinned to one tmux
613
+ server), `sync.json`, and the `pkg/` binary cache. Engine configuration
614
+ (`~/.claude.json` and friends) is deliberately left alone — those files carry
615
+ provider API keys.
616
+
617
+ Nothing is overwritten quietly:
618
+
619
+ - Each save is a numbered **revision**. `/save` sends the revision it last agreed
620
+ on, and the app refuses the write if another machine has saved since — you get
621
+ told, with `/load` and `/save --force` as the two ways out.
622
+ - `/load` refuses to replace a settings file you edited since this machine last
623
+ synced, and names it. `--force` overrides.
624
+ - The last ten revisions are kept. See them, and which machine each came from, at
625
+ [app.moshcode.sh/settings/sync](https://app.moshcode.sh/settings/sync) — where
626
+ you can also promote an older revision or delete the lot.
627
+
628
+ Both verbs take `--json`, so a provisioning script can act on the result.
629
+
582
630
  ## Browser terminal (`moshcode console`)
583
631
 
584
632
  A real terminal in the browser — arrow keys, history, full-screen TUIs — because
package/bin/moshcode.mjs CHANGED
@@ -26,6 +26,7 @@ import { canOpenBrowser, openBrowser } from "../src/open-url.mjs";
26
26
  import { locate, tilde } from "../src/pwd.mjs";
27
27
  import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
28
28
  import { loginAuto, whoami, logout } from "../src/auth.mjs";
29
+ import { loadCommand, saveCommand } from "../src/settings-sync.mjs";
29
30
  import { tui } from "../src/tui.mjs";
30
31
  import { consoleCommand } from "../src/console.mjs";
31
32
  import { herdCommand, herdStart, splitDetachArgs } from "../src/herd-cli.mjs";
@@ -609,6 +610,8 @@ async function main() {
609
610
  return;
610
611
  }
611
612
  if (cmd === "logout") { logout(); return; }
613
+ if (cmd === "save") { process.exitCode = await saveCommand(rest); return; }
614
+ if (cmd === "load") { process.exitCode = await loadCommand(rest); return; }
612
615
  if (cmd === "run") {
613
616
  let max = 3, dryRun = false;
614
617
  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.40.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 -->
@@ -278,6 +278,43 @@ export const CORE_CLI_COMMANDS = [
278
278
  synopsis: [["moshcode logout", ""]],
279
279
  seeAlso: ["login"],
280
280
  },
281
+ {
282
+ name: "save",
283
+ group: "account",
284
+ description: "save this machine's pit settings to your account",
285
+ synopsis: [["moshcode save [--dry-run] [--force] [--json]", ""]],
286
+ flags: [
287
+ ["--dry-run", "list what would be saved and stop", ""],
288
+ ["--force", "save even if another machine saved after this one last synced", ""],
289
+ ["--json", "machine-readable result", ""],
290
+ ],
291
+ examples: [
292
+ ["moshcode save", "push aliases + herd rules to app.moshcode.sh"],
293
+ ["moshcode save --dry-run", "what would go up"],
294
+ ],
295
+ seeAlso: ["load", "login", "alias"],
296
+ note: "aliases (~/.moshcode/aliases.json) and herd rules (~/.moshcode/herd/rules.json). "
297
+ + "credentials, live herd state and the package cache are never included. "
298
+ + "each save is a numbered revision; the last ten are kept at app.moshcode.sh/settings/sync.",
299
+ },
300
+ {
301
+ name: "load",
302
+ group: "account",
303
+ description: "bring your saved pit settings onto this machine",
304
+ synopsis: [["moshcode load [--dry-run] [--force] [--json]", ""]],
305
+ flags: [
306
+ ["--dry-run", "show the per-file plan and change nothing", ""],
307
+ ["--force", "overwrite local settings that changed since the last sync", ""],
308
+ ["--json", "machine-readable result", ""],
309
+ ],
310
+ examples: [
311
+ ["moshcode load", "on a new machine, right after moshcode login"],
312
+ ["moshcode load --dry-run", "which files would change"],
313
+ ],
314
+ seeAlso: ["save", "login", "alias"],
315
+ note: "refuses rather than overwriting a local file you edited since the last sync — "
316
+ + "`moshcode save` to keep it, or --force to replace it.",
317
+ },
281
318
  {
282
319
  name: "console",
283
320
  group: "account",
@@ -891,6 +928,10 @@ export const PIT_COMMANDS = [
891
928
  { name: "whoami", cli: "whoami", description: "who this machine is logged in as" },
892
929
  // Dispatched since forever and missing from /help until now.
893
930
  { name: "logout", cli: "logout", description: "clear the logged-in account" },
931
+ { name: "save", args: "[--dry-run] [--force]", cli: "save",
932
+ description: "save this pit's settings to your moshcode.sh account" },
933
+ { name: "load", args: "[--dry-run] [--force]", cli: "load",
934
+ description: "bring your saved settings onto this machine" },
894
935
  { name: "pwd", aliases: ["where"], cli: "pwd",
895
936
  description: "show the current dir + git repo/branch/origin" },
896
937
  { 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 }),
@@ -0,0 +1,659 @@
1
+ // Cloud sync for the pit's own settings — `/save` and `/load`.
2
+ //
3
+ // The pit accumulates configuration the way a shell rc does: aliases you built
4
+ // up over months, herd rules you tuned for your agents. All of it lives under
5
+ // ~/.moshcode on one machine, which means a new laptop, a fresh container, or a
6
+ // reinstall starts from nothing and the pit feels like someone else's.
7
+ //
8
+ // `/save` pushes that configuration to your app.moshcode.sh account and `/load`
9
+ // brings it back down. Two verbs rather than a background daemon: settings are
10
+ // edited by a person, at a moment they can name, and a sync that runs on its own
11
+ // is a sync that overwrites something you meant to keep at a moment you can't.
12
+ //
13
+ // Three rules the rest of this file exists to enforce:
14
+ //
15
+ // 1. An allowlist, never a directory walk. ~/.moshcode also holds
16
+ // credentials.json — the API token this very feature authenticates with —
17
+ // plus live herd state and a package cache. A walk that gains a file gains
18
+ // it silently; an allowlist has to be edited on purpose, in a diff someone
19
+ // reviews. NEVER_SYNCED is asserted on top of it so the review can't slip.
20
+ // 2. The allowlist is checked again on the way *in*. The response is data from
21
+ // the network, and a path in it is a path this process would write: without
22
+ // the second check a bad snapshot spells `../../.ssh/authorized_keys` and
23
+ // `/load` is a remote write primitive.
24
+ // 3. A revision, and refusal. Two machines both saving means one of them
25
+ // loses; the pit says so and asks, rather than picking for you.
26
+ import crypto from "node:crypto";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import { loadCreds } from "./auth.mjs";
31
+ import { engineStatus } from "./engines.mjs";
32
+ import { toolStatus } from "./tools.mjs";
33
+ import { ash, moshcodeVersion } from "./ui.mjs";
34
+
35
+ /** The snapshot shape this build writes and is willing to read. */
36
+ export const SNAPSHOT_VERSION = 1;
37
+
38
+ /** Owner-only, like everything else moshcode keeps under ~/.moshcode. */
39
+ const FILE_MODE = 0o600;
40
+ const DIR_MODE = 0o700;
41
+
42
+ /**
43
+ * One file at a time, and the whole snapshot. Generous for configuration —
44
+ * aliases.json is a few hundred bytes — and small enough that a stray heredoc
45
+ * pasted into a config file can't push a megabyte into your account, or arrive
46
+ * from it.
47
+ */
48
+ export const MAX_FILE_BYTES = 64 * 1024;
49
+ export const MAX_TOTAL_BYTES = 256 * 1024;
50
+
51
+ /**
52
+ * What syncs, keyed by its path relative to ~/.moshcode.
53
+ *
54
+ * `json: true` means the file is parsed before it is sent and again before it is
55
+ * written. A settings sync that faithfully copies a broken aliases.json to every
56
+ * machine you own has taken one dead prompt and made it four.
57
+ */
58
+ export const SYNCED_FILES = [
59
+ { path: "aliases.json", json: true, label: "pit aliases" },
60
+ { path: "herd/rules.json", json: true, label: "herd state rules" },
61
+ ];
62
+
63
+ /**
64
+ * Paths that must never appear in a snapshot, whichever direction it is moving.
65
+ *
66
+ * Redundant with the allowlist today, and deliberately so: this is the assertion
67
+ * that survives someone adding a convenient-looking entry above. `credentials.json`
68
+ * is the account token — syncing it to the account would hand every machine that
69
+ * ran `/load` a credential it was never issued. `herd/sessions.json` is live
70
+ * state pinned to one tmux server, `pkg/` is a binary cache, and `*.sock` /
71
+ * `*.pid` describe processes on exactly one box.
72
+ */
73
+ export const NEVER_SYNCED = [
74
+ "credentials.json",
75
+ "sync.json",
76
+ "herd/sessions.json",
77
+ "herd/hook.json",
78
+ ];
79
+
80
+ /** True for a path this build is willing to read or write. */
81
+ export function isSyncable(relative) {
82
+ const name = String(relative ?? "");
83
+ if (NEVER_SYNCED.includes(name)) return false;
84
+ if (name.startsWith("pkg/")) return false;
85
+ return SYNCED_FILES.some((f) => f.path === name);
86
+ }
87
+
88
+ export function moshcodeDir(home = os.homedir()) {
89
+ return path.join(home, ".moshcode");
90
+ }
91
+
92
+ /**
93
+ * Where the last sync is remembered: the revision we agreed with the server and
94
+ * the digest of the files as they were at that moment.
95
+ *
96
+ * That digest is the whole mechanism behind "you have local changes". Without it
97
+ * `/load` can tell that local and remote differ but not *why* — and "differ" is
98
+ * both "someone else saved from another machine" and "you edited this file five
99
+ * minutes ago", which want opposite answers.
100
+ */
101
+ export function markerPath(home = os.homedir()) {
102
+ return path.join(moshcodeDir(home), "sync.json");
103
+ }
104
+
105
+ export function loadMarker(home = os.homedir()) {
106
+ try {
107
+ const parsed = JSON.parse(fs.readFileSync(markerPath(home), "utf8"));
108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
109
+ return parsed;
110
+ } catch { return null; }
111
+ }
112
+
113
+ export function saveMarker(marker, home = os.homedir()) {
114
+ const file = markerPath(home);
115
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
116
+ fs.writeFileSync(file, `${JSON.stringify(marker, null, 2)}\n`, { mode: FILE_MODE });
117
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
118
+ }
119
+
120
+ /**
121
+ * The digest of a set of files, over their names and contents.
122
+ *
123
+ * Canonical by construction — names sorted, every field framed by a NUL and
124
+ * preceded by its byte length — so the same files digest the same on every
125
+ * machine regardless of the order they were read in, and no content can be
126
+ * arranged to look like a different file list. NUL rather than a space because a
127
+ * space appears in file contents and a NUL does not appear in text config at
128
+ * all.
129
+ *
130
+ * The app computes the same digest over the same bytes
131
+ * (apps/pwa/src/routes/settings-sync.mjs). Both sides pin the value for a fixed
132
+ * input in their tests, because two implementations of one hash that quietly
133
+ * disagree is a comparison that silently stops meaning anything.
134
+ */
135
+ export function digestFiles(files) {
136
+ const hash = crypto.createHash("sha256");
137
+ for (const name of Object.keys(files).sort()) {
138
+ const content = String(files[name]?.content ?? "");
139
+ hash.update(`${name}\0${Buffer.byteLength(content)}\0${content}\0`);
140
+ }
141
+ return hash.digest("hex");
142
+ }
143
+
144
+ /** Engines and tools this machine has, by name. Informational, never applied. */
145
+ function installedHere() {
146
+ const names = (rows) => rows.filter((r) => r.installed).map((r) => r.key).sort();
147
+ try {
148
+ return { engines: names(engineStatus()), tools: names(toolStatus()) };
149
+ } catch { return { engines: [], tools: [] }; }
150
+ }
151
+
152
+ /**
153
+ * Read the local settings into a snapshot.
154
+ *
155
+ * Returns `{ snapshot, included, skipped }`. A file that is missing is simply
156
+ * absent — most people have never written herd/rules.json — while one that is
157
+ * present and unusable (too big, not the JSON it claims to be) is reported so
158
+ * the reason is visible rather than looking like it synced.
159
+ */
160
+ export function collectSnapshot({
161
+ home = os.homedir(),
162
+ hostname = os.hostname(),
163
+ version = moshcodeVersion(),
164
+ installed = installedHere(),
165
+ } = {}) {
166
+ const dir = moshcodeDir(home);
167
+ const files = {};
168
+ const included = [];
169
+ const skipped = [];
170
+ let total = 0;
171
+
172
+ for (const entry of SYNCED_FILES) {
173
+ const file = path.join(dir, entry.path);
174
+ let content;
175
+ try { content = fs.readFileSync(file, "utf8"); }
176
+ catch { continue; } // not here — nothing to say about it
177
+ const bytes = Buffer.byteLength(content);
178
+ if (bytes > MAX_FILE_BYTES) {
179
+ skipped.push({ path: entry.path, reason: `${bytes} bytes — the cap is ${MAX_FILE_BYTES}` });
180
+ continue;
181
+ }
182
+ if (entry.json) {
183
+ try { JSON.parse(content); }
184
+ catch { skipped.push({ path: entry.path, reason: "not valid JSON — fix it locally first" }); continue; }
185
+ }
186
+ if (total + bytes > MAX_TOTAL_BYTES) {
187
+ skipped.push({ path: entry.path, reason: "the snapshot is already at its size cap" });
188
+ continue;
189
+ }
190
+ total += bytes;
191
+ files[entry.path] = { content };
192
+ included.push({ path: entry.path, bytes, label: entry.label });
193
+ }
194
+
195
+ const snapshot = {
196
+ version: SNAPSHOT_VERSION,
197
+ host: String(hostname || "").slice(0, 60) || null,
198
+ moshcode: version || null,
199
+ installed,
200
+ files,
201
+ };
202
+ return { snapshot, included, skipped };
203
+ }
204
+
205
+ /**
206
+ * Check a snapshot that came off the network before anything is written.
207
+ *
208
+ * Returns `{ ok, error, files, rejected }`. Rejection is per-file and reported
209
+ * rather than fatal: a newer moshcode that syncs one more file must not make
210
+ * `/load` unusable on this one, so an unknown name is dropped with its reason
211
+ * and the files this build does understand still land.
212
+ */
213
+ export function validateSnapshot(snapshot) {
214
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
215
+ return { ok: false, error: "the saved settings are not a snapshot", files: {}, rejected: [] };
216
+ }
217
+ if (Number(snapshot.version) > SNAPSHOT_VERSION) {
218
+ return {
219
+ ok: false,
220
+ files: {},
221
+ rejected: [],
222
+ error: `these settings were saved by a newer moshcode (snapshot v${snapshot.version}) — run \`moshcode upgrade\` first`,
223
+ };
224
+ }
225
+ const raw = snapshot.files;
226
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
227
+ return { ok: false, error: "the snapshot carries no files", files: {}, rejected: [] };
228
+ }
229
+
230
+ const files = {};
231
+ const rejected = [];
232
+ let total = 0;
233
+ for (const [name, value] of Object.entries(raw)) {
234
+ // Every reason a name can be refused, in one place. `isSyncable` is the
235
+ // allowlist; the checks around it catch the shapes that never reach it —
236
+ // an absolute path, a traversal, a non-string body.
237
+ if (typeof name !== "string" || !name || name !== path.posix.normalize(name)
238
+ || path.posix.isAbsolute(name) || name.includes("..") || name.includes("\\")) {
239
+ rejected.push({ path: String(name), reason: "not a settings path" });
240
+ continue;
241
+ }
242
+ if (!isSyncable(name)) { rejected.push({ path: name, reason: "this moshcode does not sync that file" }); continue; }
243
+ const content = value?.content;
244
+ if (typeof content !== "string") { rejected.push({ path: name, reason: "no contents" }); continue; }
245
+ const bytes = Buffer.byteLength(content);
246
+ if (bytes > MAX_FILE_BYTES) { rejected.push({ path: name, reason: `${bytes} bytes — the cap is ${MAX_FILE_BYTES}` }); continue; }
247
+ if (total + bytes > MAX_TOTAL_BYTES) { rejected.push({ path: name, reason: "past the snapshot size cap" }); continue; }
248
+ const entry = SYNCED_FILES.find((f) => f.path === name);
249
+ if (entry?.json) {
250
+ try { JSON.parse(content); }
251
+ catch { rejected.push({ path: name, reason: "not valid JSON — refusing to write it" }); continue; }
252
+ }
253
+ total += bytes;
254
+ files[name] = { content };
255
+ }
256
+ return { ok: true, error: null, files, rejected };
257
+ }
258
+
259
+ /**
260
+ * What `/load` would do, file by file: `new`, `changed` or `same`.
261
+ *
262
+ * Computed before anything is written so --dry-run and the real thing report the
263
+ * same plan, and so "nothing to do" is an answer rather than four no-op writes.
264
+ */
265
+ export function planApply(files, { home = os.homedir() } = {}) {
266
+ const dir = moshcodeDir(home);
267
+ return Object.keys(files).sort().map((name) => {
268
+ let current = null;
269
+ try { current = fs.readFileSync(path.join(dir, name), "utf8"); } catch { /* absent */ }
270
+ const content = files[name].content;
271
+ return {
272
+ path: name,
273
+ action: current === null ? "new" : current === content ? "same" : "changed",
274
+ bytes: Buffer.byteLength(content),
275
+ };
276
+ });
277
+ }
278
+
279
+ /** Write the snapshot's files. Returns the plan, with `written` marked. */
280
+ export function applyFiles(files, { home = os.homedir() } = {}) {
281
+ const dir = moshcodeDir(home);
282
+ const plan = planApply(files, { home });
283
+ for (const item of plan) {
284
+ if (item.action === "same") continue;
285
+ const file = path.join(dir, item.path);
286
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: DIR_MODE });
287
+ // Written beside the target and renamed over it: a settings file truncated
288
+ // by a full disk halfway through a write is a prompt that no longer starts.
289
+ const temp = `${file}.${process.pid}.tmp`;
290
+ fs.writeFileSync(temp, files[item.path].content, { mode: FILE_MODE });
291
+ fs.renameSync(temp, file);
292
+ try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
293
+ item.written = true;
294
+ }
295
+ return plan;
296
+ }
297
+
298
+ /**
299
+ * Which local files have drifted from the last sync.
300
+ *
301
+ * Names, not a boolean, because that list is the message: "aliases.json changed
302
+ * since you last saved" is actionable and "local and remote differ" is not.
303
+ */
304
+ export function localDrift({ home = os.homedir() } = {}) {
305
+ const marker = loadMarker(home);
306
+ const { snapshot } = collectSnapshot({ home, installed: { engines: [], tools: [] } });
307
+ const digest = digestFiles(snapshot.files);
308
+ if (!marker?.digest) return { known: false, drifted: true, digest, files: Object.keys(snapshot.files).sort() };
309
+ if (marker.digest === digest) return { known: true, drifted: false, digest, files: [] };
310
+ const before = marker.files && typeof marker.files === "object" ? marker.files : null;
311
+ const files = before
312
+ ? [...new Set([...Object.keys(before), ...Object.keys(snapshot.files)])]
313
+ .filter((name) => (before[name] ?? null) !== fileDigest(snapshot.files[name]))
314
+ .sort()
315
+ : Object.keys(snapshot.files).sort();
316
+ return { known: true, drifted: true, digest, files };
317
+ }
318
+
319
+ /** Per-file digest, so the marker can name which file moved rather than just that one did. */
320
+ function fileDigest(entry) {
321
+ if (!entry || typeof entry.content !== "string") return null;
322
+ return crypto.createHash("sha256").update(entry.content).digest("hex");
323
+ }
324
+
325
+ /** The marker to write after a successful push or pull. */
326
+ export function markerFor({ revision, digest, files, host = os.hostname(), api }) {
327
+ return {
328
+ revision: Number(revision),
329
+ digest,
330
+ at: Date.now(),
331
+ host: String(host || "").slice(0, 60) || null,
332
+ api: api || null,
333
+ files: Object.fromEntries(Object.keys(files).sort().map((name) => [name, fileDigest(files[name])])),
334
+ };
335
+ }
336
+
337
+ /* ------------------------------------------------------------------ transport */
338
+
339
+ const DEFAULT_API = "https://app.moshcode.sh";
340
+
341
+ function endpoint(creds) {
342
+ return (process.env.MOSHCODE_API || creds?.api || DEFAULT_API).replace(/\/+$/, "");
343
+ }
344
+
345
+ /**
346
+ * A request against the settings API, with every failure turned into a value.
347
+ *
348
+ * `{ ok, status, body, error }`. The callers here print a line and set an exit
349
+ * code; a thrown network error inside the pit's dispatch loop would take the
350
+ * prompt down instead, which is a lost session over a dropped wifi connection.
351
+ */
352
+ async function request(method, route, { creds, body = null, fetchImpl = fetch, timeoutMs = 20_000 } = {}) {
353
+ const controller = new AbortController();
354
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
355
+ try {
356
+ const res = await fetchImpl(`${endpoint(creds)}${route}`, {
357
+ method,
358
+ headers: {
359
+ "content-type": "application/json",
360
+ authorization: `Bearer ${creds?.token}`,
361
+ },
362
+ body: body === null ? undefined : JSON.stringify(body),
363
+ signal: controller.signal,
364
+ });
365
+ const text = await res.text().catch(() => "");
366
+ let parsed = null;
367
+ try { parsed = text ? JSON.parse(text) : null; } catch { /* not JSON — reported as a status */ }
368
+ return { ok: res.ok, status: res.status, body: parsed, error: null };
369
+ } catch (e) {
370
+ const aborted = e?.name === "AbortError";
371
+ return { ok: false, status: 0, body: null, error: aborted ? "the app did not answer in time" : "could not reach the app" };
372
+ } finally {
373
+ clearTimeout(timer);
374
+ }
375
+ }
376
+
377
+ export const pushSnapshot = (snapshot, { ifRevision = null, ...opts }) =>
378
+ request("PUT", "/api/settings", { ...opts, body: { snapshot, ifRevision } });
379
+
380
+ export const pullSnapshot = (opts) => request("GET", "/api/settings", opts);
381
+
382
+ export const listRevisions = (opts) => request("GET", "/api/settings/revisions", opts);
383
+
384
+ /* ------------------------------------------------------------------- commands */
385
+
386
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
387
+
388
+ function whenever(at) {
389
+ const seconds = Math.max(0, Math.floor((Date.now() - Number(at)) / 1000));
390
+ if (!Number.isFinite(seconds)) return "at an unknown time";
391
+ if (seconds < 60) return `${seconds}s ago`;
392
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
393
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
394
+ return `${Math.floor(seconds / 86400)}d ago`;
395
+ }
396
+
397
+ /** The flags both verbs share, plus whatever the caller adds. */
398
+ function parseFlags(argv, allowed) {
399
+ const flags = new Set();
400
+ const unknown = [];
401
+ for (const arg of argv) {
402
+ const name = String(arg);
403
+ if (allowed.includes(name)) flags.add(name);
404
+ else unknown.push(name);
405
+ }
406
+ return { flags, unknown };
407
+ }
408
+
409
+ const notLoggedIn = (write) => {
410
+ write("not logged in — run `/login` (or `moshcode login`) first");
411
+ write(" settings sync stores your configuration on your app.moshcode.sh account");
412
+ };
413
+
414
+ /**
415
+ * `/save` — push the local settings to the account.
416
+ *
417
+ * Returns an exit code, the convention every other command module here uses, so
418
+ * `moshcode save` in a script can be tested for having worked.
419
+ */
420
+ export async function saveCommand(argv = [], {
421
+ home = os.homedir(),
422
+ creds = loadCreds(),
423
+ fetchImpl = fetch,
424
+ write = (line) => console.log(line),
425
+ hostname = os.hostname(),
426
+ version = moshcodeVersion(),
427
+ installed = installedHere(),
428
+ } = {}) {
429
+ const { flags, unknown } = parseFlags(argv, ["--dry-run", "--force", "--json"]);
430
+ if (unknown.length) {
431
+ write(`unknown option ${unknown[0]} — usage: save [--dry-run] [--force] [--json]`);
432
+ return 1;
433
+ }
434
+ const json = flags.has("--json");
435
+ const emit = (value) => { write(JSON.stringify(value, null, 2)); };
436
+
437
+ const { snapshot, included, skipped } = collectSnapshot({ home, hostname, version, installed });
438
+ const digest = digestFiles(snapshot.files);
439
+
440
+ if (!included.length) {
441
+ if (json) emit({ status: "nothing_to_save", files: [], skipped });
442
+ else {
443
+ write("nothing to save yet — the pit has no settings on this machine");
444
+ write(' make one first: `/alias set gs "git status"`');
445
+ for (const s of skipped) write(` skipped ${s.path} — ${s.reason}`);
446
+ }
447
+ return 0;
448
+ }
449
+
450
+ if (!creds?.token) {
451
+ if (json) emit({ status: "not_logged_in", files: included });
452
+ else notLoggedIn(write);
453
+ return 1;
454
+ }
455
+
456
+ const marker = loadMarker(home);
457
+ if (flags.has("--dry-run")) {
458
+ if (json) emit({ status: "dry_run", digest, revision: marker?.revision ?? null, files: included, skipped });
459
+ else {
460
+ write(`would save ${plural(included.length, "file")} to ${endpoint(creds)}:`);
461
+ for (const f of included) write(` ${f.path} ${ash(`${f.bytes}b · ${f.label}`)}`);
462
+ for (const s of skipped) write(` skipped ${s.path} — ${s.reason}`);
463
+ }
464
+ return 0;
465
+ }
466
+
467
+ // "Nothing changed" is the account's answer, not this machine's guess. The app
468
+ // recognises a byte-identical snapshot and hands back the revision it already
469
+ // holds without inserting one, so an unchanged `/save` still costs no history —
470
+ // and a machine whose local marker has gone stale (someone deleted the saved
471
+ // settings from the web) finds out instead of insisting it is up to date.
472
+ const res = await pushSnapshot(snapshot, {
473
+ creds,
474
+ fetchImpl,
475
+ // The revision we last agreed on. The server refuses the write if it has
476
+ // moved on, which is the whole conflict story: another machine saved, and
477
+ // this push would erase it silently.
478
+ ifRevision: flags.has("--force") ? null : (Number.isFinite(Number(marker?.revision)) ? Number(marker.revision) : null),
479
+ });
480
+
481
+ if (res.status === 409) {
482
+ const theirs = res.body?.revision;
483
+ if (json) emit({ status: "conflict", revision: theirs ?? null, mine: marker?.revision ?? null });
484
+ else if (Number(theirs) === 0) {
485
+ // Not a race: the account's saved settings were deleted (the web page's
486
+ // "forget"), so there is nothing to lose and nothing to load.
487
+ write(`the account has no saved settings — this machine last saw revision ${marker?.revision ?? "none"}`);
488
+ write(" `/save --force` to save this machine's settings as the new revision 1");
489
+ } else {
490
+ write(`another machine saved first — the account is at revision ${theirs ?? "?"}, this one last saw ${marker?.revision ?? "none"}`);
491
+ write(" `/load` to take theirs, or `/save --force` to overwrite it with this machine's settings");
492
+ }
493
+ return 1;
494
+ }
495
+ if (res.status === 401) {
496
+ if (json) emit({ status: "expired" });
497
+ else write("the app rejected this machine's credentials — run `/login` again");
498
+ return 1;
499
+ }
500
+ if (!res.ok || !res.body?.revision) {
501
+ if (json) emit({ status: "failed", error: res.error, http: res.status || null });
502
+ else write(`could not save: ${res.error || `the app returned ${res.status}`}`);
503
+ return 1;
504
+ }
505
+
506
+ saveMarker(markerFor({
507
+ revision: res.body.revision,
508
+ digest,
509
+ files: snapshot.files,
510
+ host: hostname,
511
+ api: endpoint(creds),
512
+ }), home);
513
+
514
+ if (res.body.unchanged) {
515
+ if (json) emit({ status: "unchanged", revision: res.body.revision, digest, files: included, skipped });
516
+ else write(`already saved — revision ${res.body.revision} holds these exact files${res.body.savedAt ? `, from ${whenever(res.body.savedAt)}` : ""}`);
517
+ return 0;
518
+ }
519
+
520
+ if (json) {
521
+ emit({ status: "saved", revision: res.body.revision, digest, files: included, skipped });
522
+ return 0;
523
+ }
524
+ write(`saved ${plural(included.length, "file")} to ${creds.email || "your account"} ${ash(`(revision ${res.body.revision})`)}`);
525
+ for (const f of included) write(` ${f.path} ${ash(f.label)}`);
526
+ for (const s of skipped) write(` skipped ${s.path} — ${s.reason}`);
527
+ write(ash(" on another machine: `/login` then `/load`"));
528
+ return 0;
529
+ }
530
+
531
+ /** `/load` — bring the account's settings down onto this machine. */
532
+ export async function loadCommand(argv = [], {
533
+ home = os.homedir(),
534
+ creds = loadCreds(),
535
+ fetchImpl = fetch,
536
+ write = (line) => console.log(line),
537
+ hostname = os.hostname(),
538
+ installed = installedHere(),
539
+ } = {}) {
540
+ const { flags, unknown } = parseFlags(argv, ["--dry-run", "--force", "--json"]);
541
+ if (unknown.length) {
542
+ write(`unknown option ${unknown[0]} — usage: load [--dry-run] [--force] [--json]`);
543
+ return 1;
544
+ }
545
+ const json = flags.has("--json");
546
+ const emit = (value) => { write(JSON.stringify(value, null, 2)); };
547
+
548
+ if (!creds?.token) {
549
+ if (json) emit({ status: "not_logged_in" });
550
+ else notLoggedIn(write);
551
+ return 1;
552
+ }
553
+
554
+ const res = await pullSnapshot({ creds, fetchImpl });
555
+ if (res.status === 404) {
556
+ if (json) emit({ status: "empty" });
557
+ else {
558
+ write("nothing saved to this account yet");
559
+ write(" run `/save` on the machine whose settings you want, then `/load` here");
560
+ }
561
+ return 1;
562
+ }
563
+ if (res.status === 401) {
564
+ if (json) emit({ status: "expired" });
565
+ else write("the app rejected this machine's credentials — run `/login` again");
566
+ return 1;
567
+ }
568
+ if (!res.ok) {
569
+ if (json) emit({ status: "failed", error: res.error, http: res.status || null });
570
+ else write(`could not load: ${res.error || `the app returned ${res.status}`}`);
571
+ return 1;
572
+ }
573
+
574
+ const { ok: valid, error, files, rejected } = validateSnapshot(res.body?.snapshot);
575
+ if (!valid) {
576
+ if (json) emit({ status: "invalid", error });
577
+ else write(`could not load: ${error}`);
578
+ return 1;
579
+ }
580
+ const plan = planApply(files, { home });
581
+ const changes = plan.filter((p) => p.action !== "same");
582
+ const revision = res.body?.revision ?? null;
583
+ const from = res.body?.snapshot?.host || res.body?.host || null;
584
+
585
+ // Local edits that were never saved. Overwriting them is exactly what `/load`
586
+ // is for on a fresh machine and exactly what it must not do on a working one,
587
+ // and only the person at the prompt knows which this is.
588
+ const drift = localDrift({ home });
589
+ const clobbers = drift.drifted
590
+ ? changes.filter((c) => c.action === "changed" && (!drift.known || drift.files.includes(c.path)))
591
+ : [];
592
+ if (clobbers.length && !flags.has("--force") && !flags.has("--dry-run")) {
593
+ if (json) emit({ status: "local_changes", revision, files: clobbers.map((c) => c.path) });
594
+ else {
595
+ write(`${plural(clobbers.length, "local file")} changed since this machine last synced:`);
596
+ for (const c of clobbers) write(` ${c.path}`);
597
+ write(" `/save` to keep them, `/load --force` to replace them, `/load --dry-run` to see the difference");
598
+ }
599
+ return 1;
600
+ }
601
+
602
+ if (flags.has("--dry-run")) {
603
+ if (json) emit({ status: "dry_run", revision, from, plan, rejected });
604
+ else {
605
+ write(changes.length
606
+ ? `revision ${revision} from ${from || "another machine"} would change ${plural(changes.length, "file")}:`
607
+ : `revision ${revision} from ${from || "another machine"} matches this machine — nothing to do`);
608
+ for (const item of plan) write(` ${item.action.padEnd(8)} ${item.path}`);
609
+ for (const r of rejected) write(` ignored ${r.path} — ${r.reason}`);
610
+ if (clobbers.length) {
611
+ write(` ${plural(clobbers.length, "file")} would replace local changes — a plain \`/load\` will ask for --force`);
612
+ }
613
+ }
614
+ return 0;
615
+ }
616
+
617
+ if (!changes.length) {
618
+ // Still write the marker: the files match, so this machine *is* at that
619
+ // revision, and recording it is what lets the next `/save` push without
620
+ // being told it might be clobbering someone.
621
+ saveMarker(markerFor({ revision, digest: digestFiles(files), files, host: hostname, api: endpoint(creds) }), home);
622
+ if (json) emit({ status: "unchanged", revision, files: [] });
623
+ else write(`already at revision ${revision} — nothing to change`);
624
+ return 0;
625
+ }
626
+
627
+ let applied;
628
+ try { applied = applyFiles(files, { home }); }
629
+ catch (e) {
630
+ if (json) emit({ status: "failed", error: String(e.message || e) });
631
+ else write(`could not write the settings: ${String(e.message || e)}`);
632
+ return 1;
633
+ }
634
+
635
+ saveMarker(markerFor({ revision, digest: digestFiles(files), files, host: hostname, api: endpoint(creds) }), home);
636
+
637
+ const written = applied.filter((p) => p.written);
638
+ if (json) {
639
+ emit({ status: "loaded", revision, from, files: written.map((w) => w.path), rejected });
640
+ return 0;
641
+ }
642
+ write(`loaded revision ${revision}${from ? ` from ${from}` : ""} — ${plural(written.length, "file")} written`);
643
+ for (const item of written) write(` ${item.action === "new" ? "added " : "replaced"} ${item.path}`);
644
+ for (const r of rejected) write(` ignored ${r.path} — ${r.reason}`);
645
+
646
+ // Names only, and only the missing ones. The snapshot records what the source
647
+ // machine had installed because that is most of what makes a pit feel like
648
+ // yours — but installing an engine is a download and a shell script, so this
649
+ // is a sentence, not an action.
650
+ const theirs = res.body?.snapshot?.installed || {};
651
+ const missing = [
652
+ ...(theirs.engines || []).filter((n) => !(installed.engines || []).includes(n)),
653
+ ...(theirs.tools || []).filter((n) => !(installed.tools || []).includes(n)),
654
+ ];
655
+ if (missing.length) {
656
+ write(ash(` that machine also had ${missing.join(", ")} — \`/install <name>\` to match it`));
657
+ }
658
+ return 0;
659
+ }
package/src/tui.mjs CHANGED
@@ -15,6 +15,7 @@ import { runUpgrade } from "./upgrade.mjs";
15
15
  import { locate, tilde } from "./pwd.mjs";
16
16
  import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
17
17
  import { loginAuto, whoami, logout } from "./auth.mjs";
18
+ import { loadCommand, saveCommand } from "./settings-sync.mjs";
18
19
  import { createMirror, teeOutput } from "./mirror.mjs";
19
20
  import { fetchMotdAd } from "./ads.mjs";
20
21
  import { runScript } from "./runtime.mjs";
@@ -728,6 +729,10 @@ export async function tui() {
728
729
  continue;
729
730
  }
730
731
  if (cmd === "logout") { logout(); continue; }
732
+ // Settings sync. Never closes readline: both are one request and some
733
+ // printing, and the prompt is where you were about to type `/load` again.
734
+ if (cmd === "save") { await saveCommand(rest, { write: (l) => console.log(` ${l}`) }); continue; }
735
+ if (cmd === "load") { await loadCommand(rest, { write: (l) => console.log(` ${l}`) }); continue; }
731
736
  if (cmd === "run") {
732
737
  await runFile(rest);
733
738
  continue;