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 +48 -0
- package/bin/moshcode.mjs +3 -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 +41 -0
- package/src/dns-system.mjs +176 -6
- package/src/dns.mjs +33 -1
- package/src/settings-sync.mjs +659 -0
- package/src/tui.mjs +5 -0
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
|
@@ -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
|
@@ -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,
|
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 }),
|
|
@@ -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;
|