moshcode 0.72.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -9
- package/bin/moshcode.mjs +7 -4
- package/package.json +2 -2
- package/prd/0010-cloud-settings-sync.md +28 -5
- package/src/autosync.mjs +175 -0
- package/src/dns.mjs +12 -1
- package/src/engines.mjs +29 -11
- package/src/mirror.mjs +55 -1
- package/src/pkg-install.mjs +195 -0
- package/src/release-install.mjs +35 -8
- package/src/settings-sync.mjs +73 -1
- package/src/timer.mjs +6 -2
- package/src/tools.mjs +56 -1
- package/src/trust.mjs +148 -0
- package/src/tui.mjs +18 -1
package/README.md
CHANGED
|
@@ -762,15 +762,50 @@ newer, and re-running `moshcode install alchemy` is its upgrade path.
|
|
|
762
762
|
Where CoinPay is the payments product MoshCode ships alongside, Alchemy is the
|
|
763
763
|
read side of the same world — the chain itself rather than one wallet's ledger.
|
|
764
764
|
|
|
765
|
+
### yt-dlp, ffmpeg, ImageMagick — the media toolchain
|
|
766
|
+
|
|
767
|
+
```sh
|
|
768
|
+
moshcode install yt-dlp # static binary → ~/.local/bin
|
|
769
|
+
moshcode install ffmpeg # your distro's package manager (needs sudo)
|
|
770
|
+
moshcode install imagemagick # likewise
|
|
771
|
+
|
|
772
|
+
moshcode yt-dlp https://… # or `dl https://…` from cli-tools
|
|
773
|
+
moshcode ffmpeg -i in.mkv out.mp4
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
The odd three out: not workflow CLIs, but the media toolchain the rest of the
|
|
777
|
+
roster is built on. `cli-tools` fronts all three — `dl` for yt-dlp, `vid` for
|
|
778
|
+
ffmpeg, `img` for ImageMagick — and every one of them used to answer a missing
|
|
779
|
+
binary by telling you to go and install a system package by hand. Now the
|
|
780
|
+
registry that installs `cli-tools` installs what it runs on.
|
|
781
|
+
|
|
782
|
+
**yt-dlp** comes from its own releases as a self-contained binary, so it needs
|
|
783
|
+
no python and no package manager. That is deliberate rather than convenient:
|
|
784
|
+
extractors break whenever a site changes its markup, upstream ships a fix within
|
|
785
|
+
days, and a distro package of yt-dlp is frozen for the life of a release. Its
|
|
786
|
+
upgrade is `yt-dlp -U`, the project's own updater.
|
|
787
|
+
|
|
788
|
+
**ffmpeg** and **ImageMagick** exist only as distro packages — no vendor script,
|
|
789
|
+
and the static rebuilds floating around are unsigned third-party redistributions
|
|
790
|
+
of somebody else's codec stack, on the two tools most likely to be pointed at a
|
|
791
|
+
file from the internet. So they go through `apt`/`dnf`/`zypper`/`pacman`/`apk`,
|
|
792
|
+
or Homebrew on macOS, and ask for sudo everywhere but a Mac (see below).
|
|
793
|
+
Re-running the install upgrades them.
|
|
794
|
+
|
|
795
|
+
ImageMagick answers to two names: `magick` on version 7, `convert` on 6, both
|
|
796
|
+
current across supported distros under the same package name. MoshCode looks for
|
|
797
|
+
either, so a good install is never reported missing.
|
|
798
|
+
|
|
765
799
|
`gh`, `supabase`, and `doctl` publish no cross-platform install script, so
|
|
766
800
|
MoshCode resolves the latest GitHub release and drops the binary in
|
|
767
801
|
`$MOSHCODE_BIN` (default `~/.local/bin`) — no sudo, no package manager. Set
|
|
768
802
|
`MOSHCODE_BIN` to install elsewhere.
|
|
769
803
|
|
|
770
|
-
`tailscale` and `
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
804
|
+
`tailscale`, `spinifex`, `ffmpeg` and `imagemagick` are the exceptions: none of
|
|
805
|
+
them is a user-local binary, so they go through the distro's package manager and
|
|
806
|
+
will ask for sudo (tailscale on macOS delegates to the App Store, and `ffmpeg`
|
|
807
|
+
and `imagemagick` to Homebrew, which refuses to run as root — so neither is
|
|
808
|
+
prompted for a password on a Mac; Spinifex has no macOS build at all).
|
|
774
809
|
|
|
775
810
|
MoshCode asks for that password **before** starting the work rather than letting
|
|
776
811
|
the installer stop for it partway through — which matters most in `moshcode
|
|
@@ -1144,12 +1179,35 @@ What syncs is an allowlist, not a directory walk:
|
|
|
1144
1179
|
|---|---|
|
|
1145
1180
|
| `~/.moshcode/aliases.json` | your pit aliases (`/alias`) |
|
|
1146
1181
|
| `~/.moshcode/herd/rules.json` | herd state-detection overrides |
|
|
1182
|
+
| `~/.moshcode/herd/config.json` | herd notification preferences |
|
|
1183
|
+
| `~/.moshcode/feeds.opml` | your `tcfeed` rss feeds |
|
|
1184
|
+
| `~/.moshcode/news.opml` | `/news` and `/rss` subscriptions |
|
|
1185
|
+
| `~/.moshcode/pricing.json` | per-model price overrides for `/cost` |
|
|
1186
|
+
| `~/.moshcode/dns-filter/filter.json` | dns filter categories and your allow/block lists |
|
|
1187
|
+
| `~/.moshcode/business.json` | clients, teams, rates and invoices |
|
|
1147
1188
|
|
|
1148
|
-
What never syncs,
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1189
|
+
What never syncs, and why:
|
|
1190
|
+
|
|
1191
|
+
| not carried | because |
|
|
1192
|
+
|---|---|
|
|
1193
|
+
| `credentials.json` | the account token this very feature authenticates with |
|
|
1194
|
+
| `herd/sessions.json`, `herd/tasks/`, `*.transcript` | live state, prompt text and full screen captures of your sessions |
|
|
1195
|
+
| `dns-filter/stats.json` | it records the last domains you were blocked from reaching |
|
|
1196
|
+
| `timers.json` | a running work ledger; two machines appending would lose entries |
|
|
1197
|
+
| `news-last.json`, `news-found.json`, `lists/`, `dns-filter/lists/` | caches that refill themselves |
|
|
1198
|
+
| `moshpit-dns.pid`, `*.log`, `*.sock` | one box's daemon |
|
|
1199
|
+
| `sync.json`, `pkg/` | this feature's own bookkeeping, and moshcode itself |
|
|
1200
|
+
|
|
1201
|
+
Engine configuration (`~/.claude.json` and friends) is deliberately left alone —
|
|
1202
|
+
those files carry provider API keys. So is `~/.moshcode_history`, which holds
|
|
1203
|
+
whatever you typed at the prompt.
|
|
1204
|
+
|
|
1205
|
+
Two things worth knowing about the caps. A file over 64 KiB is reported as
|
|
1206
|
+
skipped rather than failing the save, so a `business.json` that grew a year of
|
|
1207
|
+
invoices stops being carried and says so. And the 256 KiB total is spent in the
|
|
1208
|
+
order of the table above, which is why the file most likely to grow is last —
|
|
1209
|
+
otherwise it would push your aliases out of the snapshot rather than being
|
|
1210
|
+
skipped itself.
|
|
1153
1211
|
|
|
1154
1212
|
Nothing is overwritten quietly:
|
|
1155
1213
|
|
|
@@ -1164,6 +1222,29 @@ Nothing is overwritten quietly:
|
|
|
1164
1222
|
|
|
1165
1223
|
Both verbs take `--json`, so a provisioning script can act on the result.
|
|
1166
1224
|
|
|
1225
|
+
### It also syncs on its own
|
|
1226
|
+
|
|
1227
|
+
An open pit runs `/load` then `/save` every five minutes, so the aliases you
|
|
1228
|
+
made on the desktop are on the laptop by the time you sit down at it. It is on
|
|
1229
|
+
by default and there is nothing to configure.
|
|
1230
|
+
|
|
1231
|
+
What makes that safe is that it is never allowed to force. Both refusals above
|
|
1232
|
+
still apply to it: a tick that finds a settings file you edited locally leaves
|
|
1233
|
+
it alone and pushes it instead, and a tick that finds another machine got there
|
|
1234
|
+
first stops and tells you, rather than picking a winner. The order matters —
|
|
1235
|
+
loading first is what keeps the ordinary two-machine case from ever becoming a
|
|
1236
|
+
conflict you have to resolve by hand.
|
|
1237
|
+
|
|
1238
|
+
It is quiet on purpose. Nothing is printed when nothing changed, when you are
|
|
1239
|
+
not logged in, or when the network is down. Four things get a line: settings
|
|
1240
|
+
arriving from another machine (your aliases just changed under you), a revision
|
|
1241
|
+
this machine pushed, a conflict, and a credential the app rejected.
|
|
1242
|
+
|
|
1243
|
+
```sh
|
|
1244
|
+
MOSHCODE_NO_AUTOSYNC=1 moshcode # turn it off for this pit
|
|
1245
|
+
MOSHCODE_AUTOSYNC_MS=900000 moshcode # every fifteen minutes instead
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1167
1248
|
## Browser terminal (`moshcode console`)
|
|
1168
1249
|
|
|
1169
1250
|
A real terminal in the browser — arrow keys, history, full-screen TUIs — because
|
package/bin/moshcode.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
ENGINES,
|
|
11
11
|
engineStatus,
|
|
12
12
|
openSession,
|
|
13
|
+
primaryBin,
|
|
13
14
|
resolveEngine,
|
|
14
15
|
resolveExecutable,
|
|
15
16
|
runCmd,
|
|
@@ -109,13 +110,15 @@ function printStatus(entries, json = false) {
|
|
|
109
110
|
console.log(JSON.stringify(entries.map(({ key, desc, bin, installed }) => ({
|
|
110
111
|
name: key,
|
|
111
112
|
description: desc,
|
|
112
|
-
|
|
113
|
+
// One name, even for an entry that answers to several: `binary` is a
|
|
114
|
+
// documented string in this JSON and something is parsing it.
|
|
115
|
+
binary: primaryBin(bin),
|
|
113
116
|
installed,
|
|
114
117
|
})), null, 2));
|
|
115
118
|
return;
|
|
116
119
|
}
|
|
117
120
|
for (const entry of entries) {
|
|
118
|
-
console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(
|
|
121
|
+
console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(11)} ${entry.desc}`);
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
@@ -375,7 +378,7 @@ async function main() {
|
|
|
375
378
|
const r = await openTool(tool, translated.args);
|
|
376
379
|
if (!r.ok) {
|
|
377
380
|
console.error(r.error?.code === "ENOENT"
|
|
378
|
-
? `alpaca isn't installed (\`${tool.bin}\`). run: moshcode install alpaca`
|
|
381
|
+
? `alpaca isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install alpaca`
|
|
379
382
|
: `launch failed: ${r.error?.message || r.error}`);
|
|
380
383
|
process.exitCode = 1;
|
|
381
384
|
return;
|
|
@@ -806,7 +809,7 @@ async function main() {
|
|
|
806
809
|
const r = await openTool(tool, rest);
|
|
807
810
|
if (!r.ok) {
|
|
808
811
|
console.error(r.error?.code === "ENOENT"
|
|
809
|
-
? `${key} isn't installed (\`${tool.bin}\`). run: moshcode install ${key}`
|
|
812
|
+
? `${key} isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install ${key}`
|
|
810
813
|
: `launch failed: ${r.error?.message || r.error}`);
|
|
811
814
|
process.exitCode = 1;
|
|
812
815
|
return;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moshcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "moshcode
|
|
5
|
+
"description": "moshcode \u2014 a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/moshcoder/moshcode.git"
|
|
@@ -41,8 +41,13 @@ configuration is already there and already paired with every machine.
|
|
|
41
41
|
|
|
42
42
|
## Non-Goals
|
|
43
43
|
|
|
44
|
-
-
|
|
45
|
-
|
|
44
|
+
- Background sync that can *overwrite*. This line used to rule out background
|
|
45
|
+
sync altogether — "a daemon that pushes silently is a daemon that overwrites
|
|
46
|
+
silently" — and the reasoning was right about the daemon it imagined. R10
|
|
47
|
+
narrows it rather than dropping it: the pit does sync on its own, and is
|
|
48
|
+
allowed to because it is never permitted to force. Every refusal in R3 and R4
|
|
49
|
+
is what makes an unattended tick safe, and a background sync that could pass
|
|
50
|
+
`--force` would be exactly the thing this line was written to prevent.
|
|
46
51
|
- Syncing engine configuration (`~/.claude.json`, `~/.codex`, MCP registrations).
|
|
47
52
|
Those files carry provider API keys and are owned by other tools' schemas.
|
|
48
53
|
- Syncing machine state: live herd sessions, the package cache, shell history.
|
|
@@ -62,9 +67,17 @@ configuration is already there and already paired with every machine.
|
|
|
62
67
|
|
|
63
68
|
- R1 [P0] `/save` (and `moshcode save`) uploads this machine's pit settings to the
|
|
64
69
|
logged-in account. `/load` (`moshcode load`) brings them back down.
|
|
65
|
-
- R2 [P0] What syncs is an allowlist, not a directory walk:
|
|
66
|
-
`
|
|
67
|
-
|
|
70
|
+
- R2 [P0] What syncs is an allowlist, not a directory walk: the pit's settings
|
|
71
|
+
(`aliases.json`), herd's (`herd/rules.json`, `herd/config.json`), the feed and
|
|
72
|
+
news subscriptions, `pricing.json`, the DNS filter's policy, and
|
|
73
|
+
`business.json`. `~/.moshcode` is also where moshcode installs itself and
|
|
74
|
+
where the account token lives, so the allowlist is load-bearing rather than
|
|
75
|
+
tidy. `credentials.json`, `herd/sessions.json`, `sync.json` and `pkg/` are
|
|
76
|
+
named as never-synced and asserted in tests, alongside the state that is
|
|
77
|
+
meaningless or private off its own machine: task ledgers and transcripts,
|
|
78
|
+
`timers.json`, `dns-filter/stats.json` (a list of blocked domains is browsing
|
|
79
|
+
history), listing caches, and one box's pidfiles and logs. Directory and
|
|
80
|
+
extension rules are enforced, not only documented.
|
|
68
81
|
- R3 [P0] Each save is a numbered revision. `/save` sends the revision it last
|
|
69
82
|
agreed on and the app refuses the write if the account has moved past it, so
|
|
70
83
|
two machines cannot silently erase one another.
|
|
@@ -83,6 +96,16 @@ configuration is already there and already paired with every machine.
|
|
|
83
96
|
- R9 [P2] Not logged in, session expired, nothing saved yet, conflict: each is a
|
|
84
97
|
sentence naming the command that resolves it (`/login`, `/save`, `/load`,
|
|
85
98
|
`--force`).
|
|
99
|
+
- R10 [P1] The pit syncs on its own every five minutes: `/load` then `/save`, in
|
|
100
|
+
that order, never with `--force`. Loading first means the ordinary
|
|
101
|
+
two-machine case settles itself; when `/load` declines because of unsaved
|
|
102
|
+
local edits, the `/save` behind it carries exactly those edits up, which is
|
|
103
|
+
the resolution R4 already recommends. It is silent when logged out, silent
|
|
104
|
+
when nothing changed, and silent about network failure; it speaks only for
|
|
105
|
+
settings that arrived from another machine, a revision it pushed, and the two
|
|
106
|
+
states that need a person — a conflict and a rejected credential. On by
|
|
107
|
+
default. `MOSHCODE_NO_AUTOSYNC` turns it off, `MOSHCODE_AUTOSYNC_MS` retimes
|
|
108
|
+
it.
|
|
86
109
|
|
|
87
110
|
## UX Notes
|
|
88
111
|
|
package/src/autosync.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Automatic settings sync — the unattended `/load` then `/save`.
|
|
2
|
+
//
|
|
3
|
+
// PRD 0010 ruled background sync out, and the reason it gave was the right
|
|
4
|
+
// reason for the mechanism it had in mind: "a daemon that pushes silently is a
|
|
5
|
+
// daemon that overwrites silently." What makes this one allowed is that it is
|
|
6
|
+
// not permitted to overwrite anything. It never passes `--force`, and both
|
|
7
|
+
// verbs already refuse rather than guess — `/load` stops when a settings file
|
|
8
|
+
// changed locally since the last sync, `/save` stops on the 409 when another
|
|
9
|
+
// machine saved first. So the worst an unattended tick can do is decline and
|
|
10
|
+
// leave the decision exactly where it was: with the person at the prompt.
|
|
11
|
+
//
|
|
12
|
+
// The order is `/load` then `/save`, and that order is the whole design:
|
|
13
|
+
//
|
|
14
|
+
// - `/load` first means this machine is at the account's revision before it
|
|
15
|
+
// pushes, so the ordinary two-machine case settles itself and nobody is
|
|
16
|
+
// ever shown a conflict they would only have resolved by loading anyway.
|
|
17
|
+
// - When `/load` declines because there are unsaved local edits, the `/save`
|
|
18
|
+
// that follows pushes exactly those edits — which is the resolution the
|
|
19
|
+
// manual conflict message already recommends ("`/save` to keep them").
|
|
20
|
+
//
|
|
21
|
+
// Quiet is a feature. A tick that changed nothing prints nothing, because a
|
|
22
|
+
// line every five minutes saying "still fine" trains you to stop reading the
|
|
23
|
+
// pit. Three things do print: settings that arrived from another machine (your
|
|
24
|
+
// aliases just changed under you and you are owed that sentence), a revision
|
|
25
|
+
// this machine pushed, and the two states that need a human — a conflict, and
|
|
26
|
+
// credentials the app rejected. Network failures stay silent; a laptop on a
|
|
27
|
+
// train would otherwise narrate every tunnel.
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import { loadCreds } from "./auth.mjs";
|
|
30
|
+
import { loadCommand, saveCommand } from "./settings-sync.mjs";
|
|
31
|
+
|
|
32
|
+
/** Five minutes. Long enough that a tick is never in the way of typing. */
|
|
33
|
+
export const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A floor, not a suggestion. `MOSHCODE_AUTOSYNC_MS=1` would turn the account
|
|
37
|
+
* into a write loop, so anything under this is treated as the minimum rather
|
|
38
|
+
* than refused — an env var is not the place to learn you typed milliseconds
|
|
39
|
+
* where you meant minutes.
|
|
40
|
+
*/
|
|
41
|
+
export const MIN_INTERVAL_MS = 30 * 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Off switch, in the shape the rest of the codebase already uses for one:
|
|
45
|
+
* presence disables, exactly like MOSHCODE_NO_MIRROR and MOSHCODE_NO_ADS.
|
|
46
|
+
*/
|
|
47
|
+
export function autoSyncEnabled(env = process.env) {
|
|
48
|
+
return !env.MOSHCODE_NO_AUTOSYNC;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `Number(x) || default`, the MOSHCODE_AD_COLS idiom, with a floor. */
|
|
52
|
+
export function autoSyncInterval(env = process.env) {
|
|
53
|
+
const raw = Number(env.MOSHCODE_AUTOSYNC_MS);
|
|
54
|
+
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_INTERVAL_MS;
|
|
55
|
+
return Math.max(MIN_INTERVAL_MS, raw);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run one verb and read its answer as data rather than as prose.
|
|
60
|
+
*
|
|
61
|
+
* Both commands take `--json` and emit a single object through their `write`
|
|
62
|
+
* sink, which is the only reason this can be quiet: it can tell "loaded four
|
|
63
|
+
* files" from "already at revision 9" without matching on English.
|
|
64
|
+
*/
|
|
65
|
+
async function runJson(command, argv, deps) {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
const code = await command([...argv, "--json"], {
|
|
68
|
+
...deps,
|
|
69
|
+
write: (line) => chunks.push(String(line)),
|
|
70
|
+
});
|
|
71
|
+
let body = null;
|
|
72
|
+
try { body = JSON.parse(chunks.join("\n")); } catch { /* not our business */ }
|
|
73
|
+
return { code, body, status: body?.status ?? null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One tick: load, then save.
|
|
78
|
+
*
|
|
79
|
+
* Returns what happened, so the caller decides what is worth a line and the
|
|
80
|
+
* tests can assert on the sequence without reading output.
|
|
81
|
+
*/
|
|
82
|
+
export async function syncOnce({
|
|
83
|
+
load = loadCommand,
|
|
84
|
+
save = saveCommand,
|
|
85
|
+
creds = loadCreds(),
|
|
86
|
+
write = () => {},
|
|
87
|
+
...deps
|
|
88
|
+
} = {}) {
|
|
89
|
+
// Logged out is not an error and must never print. A pit that has never seen
|
|
90
|
+
// `/login` would otherwise nag about an account its owner has not asked for,
|
|
91
|
+
// every five minutes, forever.
|
|
92
|
+
if (!creds?.token) return { skipped: "not_logged_in" };
|
|
93
|
+
|
|
94
|
+
const loaded = await runJson(load, [], { ...deps, creds });
|
|
95
|
+
|
|
96
|
+
// `local_changes` is the expected, healthy half of this: you edited an alias
|
|
97
|
+
// and have not saved it. `/load` correctly declined to replace it, and the
|
|
98
|
+
// `/save` below is what carries it up. Anything else that failed is a reason
|
|
99
|
+
// to stop rather than push on top of a machine we could not read.
|
|
100
|
+
const loadBlocked = loaded.status === "expired";
|
|
101
|
+
if (loadBlocked) {
|
|
102
|
+
write("the app rejected this machine's credentials — run `/login` again");
|
|
103
|
+
return { load: loaded.status, save: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (loaded.status === "loaded") {
|
|
107
|
+
const count = Array.isArray(loaded.body?.files) ? loaded.body.files.length : 0;
|
|
108
|
+
const from = loaded.body?.from;
|
|
109
|
+
write(`settings synced${from ? ` from ${from}` : ""} — ${count} file${count === 1 ? "" : "s"} changed (revision ${loaded.body?.revision ?? "?"})`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const saved = await runJson(save, [], { ...deps, creds });
|
|
113
|
+
|
|
114
|
+
if (saved.status === "saved") {
|
|
115
|
+
write(`settings saved — revision ${saved.body?.revision ?? "?"}`);
|
|
116
|
+
} else if (saved.status === "conflict") {
|
|
117
|
+
// The one case an unattended tick cannot resolve: this machine loaded, and
|
|
118
|
+
// the account moved again between the load and the save. Say so once and
|
|
119
|
+
// stop; `--force` is a decision, not a retry.
|
|
120
|
+
write(`another machine saved first — \`/load\` to take theirs, or \`/save --force\` to keep this machine's`);
|
|
121
|
+
} else if (saved.status === "expired") {
|
|
122
|
+
write("the app rejected this machine's credentials — run `/login` again");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { load: loaded.status, save: saved.status };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Start the timer. Returns the function that stops it.
|
|
130
|
+
*
|
|
131
|
+
* The caller must call that on the way out: `tui()` is re-entered after an
|
|
132
|
+
* engine session (bin/moshcode.mjs `backToPit`), so a timer left running would
|
|
133
|
+
* be joined by another on the next entry, and by a third after that.
|
|
134
|
+
*/
|
|
135
|
+
export function startAutoSync({
|
|
136
|
+
intervalMs = autoSyncInterval(),
|
|
137
|
+
enabled = autoSyncEnabled(),
|
|
138
|
+
write = (line) => console.log(` ${line}`),
|
|
139
|
+
timers = { setInterval, clearInterval },
|
|
140
|
+
...deps
|
|
141
|
+
} = {}) {
|
|
142
|
+
if (!enabled) return () => {};
|
|
143
|
+
|
|
144
|
+
// A tick that is still running when the next one fires would race two writes
|
|
145
|
+
// to the same files, so ticks are single-flight rather than queued: a sync
|
|
146
|
+
// this machine skipped is one it does five minutes later, unchanged.
|
|
147
|
+
let running = false;
|
|
148
|
+
let stopped = false;
|
|
149
|
+
|
|
150
|
+
const tick = async () => {
|
|
151
|
+
if (running || stopped) return;
|
|
152
|
+
running = true;
|
|
153
|
+
try { await syncOnce({ write, ...deps }); }
|
|
154
|
+
catch { /* a background sync never takes the pit down with it */ }
|
|
155
|
+
finally { running = false; }
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Deliberately no tick at startup. The pit is most likely to be typed into in
|
|
159
|
+
// the second after it opens, and that is the worst moment to rewrite the
|
|
160
|
+
// aliases under it — the first sync can wait five minutes.
|
|
161
|
+
const handle = timers.setInterval(tick, intervalMs);
|
|
162
|
+
|
|
163
|
+
// Never hold the process open for the sake of a sync. `pty.mjs` sets the
|
|
164
|
+
// precedent: a piped `moshcode` that has run out of stdin should exit now,
|
|
165
|
+
// not at the end of the interval.
|
|
166
|
+
handle?.unref?.();
|
|
167
|
+
|
|
168
|
+
return () => {
|
|
169
|
+
stopped = true;
|
|
170
|
+
try { timers.clearInterval(handle); } catch { /* already gone */ }
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Exported for the tests; the pit has no reason to care. */
|
|
175
|
+
export const _internals = { runJson, hostname: os.hostname };
|
package/src/dns.mjs
CHANGED
|
@@ -2274,7 +2274,7 @@ import { createParkingServer, DEFAULT_PARKING_HTTP_PORT } from "./parking-http.m
|
|
|
2274
2274
|
// use it without importing this one back.
|
|
2275
2275
|
export { pitNameUrl } from "./pit-url.mjs";
|
|
2276
2276
|
import { pitNameUrl } from "./pit-url.mjs";
|
|
2277
|
-
import { applyTrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs";
|
|
2277
|
+
import { applyTrust, applyUntrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs";
|
|
2278
2278
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2279
2279
|
import { existsSync } from "node:fs";
|
|
2280
2280
|
import { fileURLToPath } from "node:url";
|
|
@@ -2326,6 +2326,9 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine
|
|
|
2326
2326
|
--no-trust with enable: route names but skip the local CA. They will
|
|
2327
2327
|
resolve and then fail TLS, which is the state this flag exists
|
|
2328
2328
|
to leave you in deliberately.
|
|
2329
|
+
--keep-trust with disable: put the routing back but leave the local CA in
|
|
2330
|
+
your trust store. For turning resolution off for an afternoon
|
|
2331
|
+
without paying to install the root again afterwards.
|
|
2329
2332
|
--no-proxy with enable: answer each name's origin rather than the local
|
|
2330
2333
|
pinned-TLS proxy. Only the proxy can hand a stock client a
|
|
2331
2334
|
certificate it will accept, so this is the other half of the
|
|
@@ -2956,6 +2959,14 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
|
|
|
2956
2959
|
const cleared2 = await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the restore point has been used" }] });
|
|
2957
2960
|
if (cleared2.ok) out(` ok remove ${manifestFile}`);
|
|
2958
2961
|
}
|
|
2962
|
+
|
|
2963
|
+
// The routing is back, which leaves the trust anchor as the last thing
|
|
2964
|
+
// `enable` did that is still on this machine — and it was the one change
|
|
2965
|
+
// the restore point never covered, because it is not a file in /etc.
|
|
2966
|
+
// Removing it by default is what makes `disable` mean "as it was".
|
|
2967
|
+
// `--keep-trust` is for turning resolution off for an afternoon without
|
|
2968
|
+
// paying for a re-install of the root afterwards.
|
|
2969
|
+
if (!rest.includes("--keep-trust")) await applyUntrust(out, deps);
|
|
2959
2970
|
out("");
|
|
2960
2971
|
|
|
2961
2972
|
// The line the old implementation printed unconditionally, now only when
|
package/src/engines.mjs
CHANGED
|
@@ -283,20 +283,36 @@ export function resolveEngine(token) {
|
|
|
283
283
|
// script unpacks to $HOME/.turso and only appends it to your shell profile).
|
|
284
284
|
// Searching them after PATH keeps a real `turso` on PATH winning, while still
|
|
285
285
|
// finding the one we just installed.
|
|
286
|
+
/**
|
|
287
|
+
* The name to print when talking about a `bin` that may be several.
|
|
288
|
+
*
|
|
289
|
+
* A `bin` is normally one string. ImageMagick is why it can be a list: the
|
|
290
|
+
* command is `magick` on ImageMagick 7 and `convert` on 6, both are current on
|
|
291
|
+
* supported distros at the same time, and picking either one alone makes a
|
|
292
|
+
* successful install report as missing on half of them. The first name is the
|
|
293
|
+
* one we prefer and the one worth naming in a message.
|
|
294
|
+
*/
|
|
295
|
+
export function primaryBin(bin) {
|
|
296
|
+
return Array.isArray(bin) ? bin[0] : bin;
|
|
297
|
+
}
|
|
298
|
+
|
|
286
299
|
function executableCandidates(bin, extraDirs = []) {
|
|
287
300
|
const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
288
|
-
const
|
|
289
|
-
? [""]
|
|
290
|
-
: [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)];
|
|
301
|
+
const names = (Array.isArray(bin) ? bin : [bin]).filter(Boolean);
|
|
291
302
|
const seen = new Set();
|
|
292
303
|
const candidates = [];
|
|
293
|
-
for (const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
304
|
+
for (const name of names) {
|
|
305
|
+
const dirs = path.isAbsolute(name) || name.includes(path.sep)
|
|
306
|
+
? [""]
|
|
307
|
+
: [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)];
|
|
308
|
+
for (const dir of dirs) {
|
|
309
|
+
for (const ext of exts) {
|
|
310
|
+
const candidate = dir ? path.join(dir, name + ext) : name + ext;
|
|
311
|
+
const key = candidate.toLowerCase();
|
|
312
|
+
if (!seen.has(key)) {
|
|
313
|
+
seen.add(key);
|
|
314
|
+
candidates.push(candidate);
|
|
315
|
+
}
|
|
300
316
|
}
|
|
301
317
|
}
|
|
302
318
|
}
|
|
@@ -323,7 +339,9 @@ function nodeShebang(file) {
|
|
|
323
339
|
|
|
324
340
|
function spawnSpec(bin, args = [], extraDirs = []) {
|
|
325
341
|
const resolved = resolveExecutable(bin, extraDirs);
|
|
326
|
-
|
|
342
|
+
// Unresolved, so hand the spawn the preferred name and let it produce the
|
|
343
|
+
// ENOENT — a list would be spawned as a single nonsense filename.
|
|
344
|
+
if (!resolved) return { cmd: primaryBin(bin), args };
|
|
327
345
|
if (process.platform === "win32" && path.extname(resolved) === "" && nodeShebang(resolved)) {
|
|
328
346
|
return { cmd: process.execPath, args: [resolved, ...args] };
|
|
329
347
|
}
|
package/src/mirror.mjs
CHANGED
|
@@ -12,6 +12,47 @@
|
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import { loadCreds } from "./auth.mjs";
|
|
14
14
|
|
|
15
|
+
// A key pressed on the session page travels through the same queue as a typed
|
|
16
|
+
// line, tagged with this sentinel. ESC leads it because it is a byte you cannot
|
|
17
|
+
// put in the send box by typing, so a key can never collide with a real command
|
|
18
|
+
// — and the app refuses to queue one for a mosh too old to decode it, rather
|
|
19
|
+
// than letting the sentinel get typed at somebody's prompt as text.
|
|
20
|
+
export const KEY_PREFIX = "\u001bmoshkey:";
|
|
21
|
+
/** Keys the page can send. Anything else is ignored on both ends. */
|
|
22
|
+
export const KEY_NAMES = ["up", "down", "left", "right", "enter"];
|
|
23
|
+
|
|
24
|
+
/** The name of the key this command carries, or null if it is an ordinary line. */
|
|
25
|
+
export function decodeKey(body) {
|
|
26
|
+
if (typeof body !== "string" || !body.startsWith(KEY_PREFIX)) return null;
|
|
27
|
+
const name = body.slice(KEY_PREFIX.length);
|
|
28
|
+
return KEY_NAMES.includes(name) ? name : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// What each key looks like to a program reading the tty in raw mode, and the
|
|
32
|
+
// keypress readline wants when it is the one holding the line.
|
|
33
|
+
const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" };
|
|
34
|
+
const KEY_PRESS = {
|
|
35
|
+
up: { name: "up" }, down: { name: "down" }, right: { name: "right" },
|
|
36
|
+
left: { name: "left" }, enter: { name: "return" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Deliver one key to whatever is reading this terminal. Returns false for a key
|
|
41
|
+
* we don't know, so a newer app can never make an older CLI do something odd.
|
|
42
|
+
*/
|
|
43
|
+
export function pressKey(name, rl = null, stdin = process.stdin) {
|
|
44
|
+
const bytes = KEY_BYTES[name];
|
|
45
|
+
if (!bytes) return false;
|
|
46
|
+
// At the prompt readline owns the line editor, so hand it a keypress rather
|
|
47
|
+
// than bytes: ↑/↓ walk the history, ←/→ move within the line, enter runs it.
|
|
48
|
+
if (rl) {
|
|
49
|
+
try { rl.write(null, KEY_PRESS[name]); return true; } catch { /* fall through to the tty */ }
|
|
50
|
+
}
|
|
51
|
+
// Otherwise something has the tty in raw mode — a herd bar, the reader, a
|
|
52
|
+
// menu — and it is waiting on the real escape sequence, not on readline.
|
|
53
|
+
try { stdin.emit("data", Buffer.from(bytes, "latin1")); return true; } catch { return false; }
|
|
54
|
+
}
|
|
55
|
+
|
|
15
56
|
const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty
|
|
16
57
|
const MAX_BUFFER = 16000; // flush early once a batch gets big
|
|
17
58
|
|
|
@@ -32,6 +73,7 @@ export function createMirror({
|
|
|
32
73
|
// process alive for up to a full poll window after the pit closes.
|
|
33
74
|
let poll = null;
|
|
34
75
|
const onCommand = new Set();
|
|
76
|
+
const onKey = new Set();
|
|
35
77
|
|
|
36
78
|
const api = (creds?.api || "https://app.moshcode.sh").replace(/\/+$/, "");
|
|
37
79
|
const headers = { "content-type": "application/json", authorization: `Bearer ${creds?.token}` };
|
|
@@ -114,7 +156,13 @@ export function createMirror({
|
|
|
114
156
|
if (stopped) return;
|
|
115
157
|
if (!got) { await sleep(5000); continue; }
|
|
116
158
|
for (const c of got.commands || []) {
|
|
117
|
-
|
|
159
|
+
// A key is a navigation action, not a line, so it goes to its own
|
|
160
|
+
// handlers: the pit presses it the moment it lands instead of parking
|
|
161
|
+
// it behind whatever text is still waiting for the prompt. A "down"
|
|
162
|
+
// delivered a command later would land on a different row.
|
|
163
|
+
const key = decodeKey(c.body);
|
|
164
|
+
if (key) { for (const fn of onKey) { try { fn(key); } catch { /* handler's problem */ } } }
|
|
165
|
+
else { for (const fn of onCommand) { try { fn(c.body); } catch { /* handler's problem */ } } }
|
|
118
166
|
post(`/api/sessions/${sessionId}/commands/${c.id}`, {});
|
|
119
167
|
}
|
|
120
168
|
}
|
|
@@ -131,6 +179,10 @@ export function createMirror({
|
|
|
131
179
|
host: os.hostname(),
|
|
132
180
|
version,
|
|
133
181
|
cwd,
|
|
182
|
+
// What this build can be asked to do. The page arms its arrow pad on
|
|
183
|
+
// the strength of this: a mosh that never says "keys" is one that would
|
|
184
|
+
// type the sentinel at the prompt instead of pressing it.
|
|
185
|
+
features: ["keys"],
|
|
134
186
|
...size(),
|
|
135
187
|
});
|
|
136
188
|
if (!r?.id) return false;
|
|
@@ -146,6 +198,8 @@ export function createMirror({
|
|
|
146
198
|
setEngine,
|
|
147
199
|
/** Subscribe to commands sent from the web. Returns an unsubscribe fn. */
|
|
148
200
|
onCommand(fn) { onCommand.add(fn); return () => onCommand.delete(fn); },
|
|
201
|
+
/** Subscribe to keys pressed on the web. Returns an unsubscribe fn. */
|
|
202
|
+
onKey(fn) { onKey.add(fn); return () => onKey.delete(fn); },
|
|
149
203
|
async stop() {
|
|
150
204
|
if (!sessionId || stopped) return;
|
|
151
205
|
stopped = true;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Installer for the tools that only ship through a system package manager.
|
|
2
|
+
//
|
|
3
|
+
// ffmpeg and ImageMagick are the odd ones in TOOLS: they are not a vendor's CLI
|
|
4
|
+
// with a `curl … | sh` of its own, and they are not a static binary on a GitHub
|
|
5
|
+
// release either. They are distro packages, which is why they are installed the
|
|
6
|
+
// way a distro package is installed — and why this is a separate file from
|
|
7
|
+
// release-install.mjs rather than another descriptor in it.
|
|
8
|
+
//
|
|
9
|
+
// Static builds do exist for both. They are third-party redistributions of
|
|
10
|
+
// somebody else's codec stack, unsigned, and updated by nobody in particular.
|
|
11
|
+
// Downloading one to avoid a sudo prompt would be trading a password for a
|
|
12
|
+
// binary we cannot vouch for, on the two tools most likely to be pointed at a
|
|
13
|
+
// file from the internet.
|
|
14
|
+
//
|
|
15
|
+
// Everything that decides *what to run* is a pure function so the per-manager
|
|
16
|
+
// argv (which differ in irritating ways) is unit-tested offline; the only
|
|
17
|
+
// impure part is the loop at the bottom that runs it.
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { realpathSync } from "node:fs";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { findEscalator } from "./escalate.mjs";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How each manager installs, non-interactively.
|
|
26
|
+
*
|
|
27
|
+
* Non-interactive is the point: this runs inside `moshcode install` and inside
|
|
28
|
+
* `moshcode upgrade tools`, and a manager that stops to ask "Do you want to
|
|
29
|
+
* continue? [Y/n]" inside an upgrade sweep parks the whole plan.
|
|
30
|
+
*
|
|
31
|
+
* apt refreshes first because its index goes stale on its own: a box that has
|
|
32
|
+
* not run `apt-get update` in a few months gets a 404 on the archive rather
|
|
33
|
+
* than a package, and the error names a URL instead of the actual problem.
|
|
34
|
+
*/
|
|
35
|
+
export const MANAGERS = {
|
|
36
|
+
brew: {
|
|
37
|
+
// Never escalated. Homebrew refuses to run as root and says so at length.
|
|
38
|
+
root: false,
|
|
39
|
+
steps: (pkg) => [["brew", ["install", pkg]]],
|
|
40
|
+
},
|
|
41
|
+
"apt-get": {
|
|
42
|
+
root: true,
|
|
43
|
+
steps: (pkg) => [
|
|
44
|
+
["apt-get", ["update", "-qq"]],
|
|
45
|
+
["apt-get", ["install", "-y", "--no-install-recommends", pkg]],
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
dnf: { root: true, steps: (pkg) => [["dnf", ["install", "-y", pkg]]] },
|
|
49
|
+
zypper: { root: true, steps: (pkg) => [["zypper", ["--non-interactive", "install", pkg]]] },
|
|
50
|
+
pacman: { root: true, steps: (pkg) => [["pacman", ["-S", "--needed", "--noconfirm", pkg]]] },
|
|
51
|
+
apk: { root: true, steps: (pkg) => [["apk", ["add", "--no-cache", pkg]]] },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The order managers are probed in. brew first, and only because of macOS. */
|
|
55
|
+
export const MANAGER_ORDER = ["brew", "apt-get", "dnf", "zypper", "pacman", "apk"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Package names per tool, per manager, in the order they are worth trying.
|
|
59
|
+
*
|
|
60
|
+
* Two entries have more than one name and both are facts about somebody else's
|
|
61
|
+
* archive rather than hedging:
|
|
62
|
+
*
|
|
63
|
+
* Fedora ships `ffmpeg-free` in the main repositories and the full `ffmpeg`
|
|
64
|
+
* only from RPM Fusion, so a box without that repo enabled has exactly one of
|
|
65
|
+
* the two names and `dnf install ffmpeg` fails outright on it.
|
|
66
|
+
*
|
|
67
|
+
* `imagemagick` is one name for two different programs: on Ubuntu up to
|
|
68
|
+
* 24.04 it depends on the 6.x package and puts `convert` on PATH, and from
|
|
69
|
+
* 25.04 it depends on the 7.x one and puts `magick` there instead. The
|
|
70
|
+
* package name is stable, which is why this table has one entry and the
|
|
71
|
+
* tool's `bin` has two.
|
|
72
|
+
*/
|
|
73
|
+
export const PACKAGES = {
|
|
74
|
+
ffmpeg: {
|
|
75
|
+
brew: ["ffmpeg"],
|
|
76
|
+
"apt-get": ["ffmpeg"],
|
|
77
|
+
dnf: ["ffmpeg", "ffmpeg-free"],
|
|
78
|
+
zypper: ["ffmpeg"],
|
|
79
|
+
pacman: ["ffmpeg"],
|
|
80
|
+
apk: ["ffmpeg"],
|
|
81
|
+
},
|
|
82
|
+
imagemagick: {
|
|
83
|
+
brew: ["imagemagick"],
|
|
84
|
+
"apt-get": ["imagemagick"],
|
|
85
|
+
dnf: ["ImageMagick"],
|
|
86
|
+
zypper: ["ImageMagick"],
|
|
87
|
+
pacman: ["imagemagick"],
|
|
88
|
+
apk: ["imagemagick"],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function defaultProbe(tool) {
|
|
93
|
+
return spawnSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }).status === 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Resolve a name to its package table, or throw. Own properties only. */
|
|
97
|
+
export function resolvePackage(tool) {
|
|
98
|
+
const key = String(tool ?? "").trim().toLowerCase();
|
|
99
|
+
if (!Object.hasOwn(PACKAGES, key)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`unknown package ${JSON.stringify(tool)} — expected one of ${Object.keys(PACKAGES).join(", ")}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return [key, PACKAGES[key]];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Which package manager this machine has, or null. */
|
|
108
|
+
export function findManager({ probe = defaultProbe, order = MANAGER_ORDER } = {}) {
|
|
109
|
+
for (const name of order) {
|
|
110
|
+
if (probe(name)) return name;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The commands that install one package name with one manager.
|
|
117
|
+
*
|
|
118
|
+
* Escalation is applied here rather than by the caller because whether a step
|
|
119
|
+
* needs it is a property of the manager: brew must not be escalated, the rest
|
|
120
|
+
* must be unless we are already root. A `null` escalator on a manager that
|
|
121
|
+
* needs one yields the bare command, which fails with the manager's own
|
|
122
|
+
* permission message — better advice than anything we would write.
|
|
123
|
+
*/
|
|
124
|
+
export function installSteps(manager, pkg, { escalator = null, isRoot = false } = {}) {
|
|
125
|
+
const spec = MANAGERS[manager];
|
|
126
|
+
if (!spec) throw new Error(`unknown package manager ${JSON.stringify(manager)}`);
|
|
127
|
+
const escalate = spec.root && !isRoot && escalator;
|
|
128
|
+
return spec.steps(pkg).map(([cmd, args]) =>
|
|
129
|
+
escalate ? { cmd: escalator, args: [cmd, ...args] } : { cmd, args },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Install a tool through whichever package manager is here.
|
|
135
|
+
*
|
|
136
|
+
* Package names are tried in order and the first that installs wins, because a
|
|
137
|
+
* name that is absent from this box's archive is a normal outcome (see the
|
|
138
|
+
* Fedora note above) rather than a failure to report. Only when every candidate
|
|
139
|
+
* has failed is there something to say.
|
|
140
|
+
*/
|
|
141
|
+
export function installPackage(tool, { run = spawnSync, probe = defaultProbe, log = console.log } = {}) {
|
|
142
|
+
const [key, table] = resolvePackage(tool);
|
|
143
|
+
const manager = findManager({ probe });
|
|
144
|
+
if (!manager) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`no supported package manager found (${MANAGER_ORDER.join(", ")}) — install ${key} yourself and re-run`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const candidates = table[manager];
|
|
151
|
+
if (!candidates?.length) {
|
|
152
|
+
throw new Error(`${key} has no known package name for ${manager} — install it yourself and re-run`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
|
|
156
|
+
const escalator = MANAGERS[manager].root && !isRoot ? findEscalator({ probe }) : null;
|
|
157
|
+
|
|
158
|
+
const failures = [];
|
|
159
|
+
for (const pkg of candidates) {
|
|
160
|
+
log(`↓ ${manager} ${pkg}`);
|
|
161
|
+
let ok = true;
|
|
162
|
+
for (const step of installSteps(manager, pkg, { escalator, isRoot })) {
|
|
163
|
+
const result = run(step.cmd, step.args, { stdio: "inherit" });
|
|
164
|
+
if (result?.error || result?.status !== 0) {
|
|
165
|
+
failures.push(`${pkg}: ${step.cmd} ${step.args.join(" ")} ${result?.error ? `(${result.error.message})` : `exited ${result?.status}`}`);
|
|
166
|
+
ok = false;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (ok) {
|
|
171
|
+
log(`✓ ${key} installed with ${manager}`);
|
|
172
|
+
return { manager, pkg };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new Error(`could not install ${key} with ${manager}:\n ${failures.join("\n ")}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when this file was executed directly rather than imported. */
|
|
180
|
+
function invokedDirectly() {
|
|
181
|
+
try {
|
|
182
|
+
return realpathSync(process.argv[1] || "") === realpathSync(fileURLToPath(import.meta.url));
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (invokedDirectly()) {
|
|
189
|
+
try {
|
|
190
|
+
installPackage(process.argv[2]);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.error(`install failed: ${e.message}`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/release-install.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import { fileURLToPath } from "node:url";
|
|
|
29
29
|
* - supabase also publishes version-less asset aliases, so `latest/download`
|
|
30
30
|
* resolves without asking the API for a tag first.
|
|
31
31
|
* - doctl separates its asset fields with "-" instead of "_".
|
|
32
|
+
* - yt-dlp publishes the executable itself rather than an archive, so there
|
|
33
|
+
* is nothing to unpack; `bare` is what says so.
|
|
32
34
|
*/
|
|
33
35
|
export const RELEASES = {
|
|
34
36
|
gh: {
|
|
@@ -54,6 +56,27 @@ export const RELEASES = {
|
|
|
54
56
|
asset: ({ version, platform, arch }) => `doctl-${version}-${platform}-${arch}.tar.gz`,
|
|
55
57
|
binPath: () => "doctl",
|
|
56
58
|
},
|
|
59
|
+
"yt-dlp": {
|
|
60
|
+
repo: "yt-dlp/yt-dlp",
|
|
61
|
+
binary: "yt-dlp",
|
|
62
|
+
// The asset IS the executable — a PyInstaller bundle, so it needs no
|
|
63
|
+
// python on the box, and there is no archive around it to unpack.
|
|
64
|
+
bare: true,
|
|
65
|
+
// `unversioned` is not a convenience here, it is required: yt-dlp tags
|
|
66
|
+
// releases by date with no leading "v" (2025.08.11), so the versioned URL
|
|
67
|
+
// this builds otherwise — /download/v2025.08.11/ — is a 404. The
|
|
68
|
+
// /releases/latest/download/ alias sidesteps the tag spelling entirely.
|
|
69
|
+
unversioned: true,
|
|
70
|
+
// macOS gets one universal2 build for both architectures; Linux names arm64
|
|
71
|
+
// "aarch64" while every other vendor here calls it arm64.
|
|
72
|
+
asset: ({ platform, arch }) =>
|
|
73
|
+
platform === "darwin"
|
|
74
|
+
? "yt-dlp_macos"
|
|
75
|
+
: arch === "arm64"
|
|
76
|
+
? "yt-dlp_linux_aarch64"
|
|
77
|
+
: "yt-dlp_linux",
|
|
78
|
+
binPath: () => "yt-dlp",
|
|
79
|
+
},
|
|
57
80
|
};
|
|
58
81
|
|
|
59
82
|
// Node's process.arch names differ from the ones release assets use.
|
|
@@ -151,14 +174,18 @@ export async function installRelease(tool, { fetchImpl = fetch } = {}) {
|
|
|
151
174
|
try {
|
|
152
175
|
const archive = path.join(work, path.posix.basename(new URL(url).pathname));
|
|
153
176
|
writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
177
|
+
|
|
178
|
+
let from = archive;
|
|
179
|
+
if (!spec.bare) {
|
|
180
|
+
const unpacked = path.join(work, "unpacked");
|
|
181
|
+
mkdirSync(unpacked);
|
|
182
|
+
extract(archive, unpacked);
|
|
183
|
+
|
|
184
|
+
const relative = spec.binPath(target);
|
|
185
|
+
from = path.join(unpacked, relative);
|
|
186
|
+
if (!existsSync(from)) {
|
|
187
|
+
throw new Error(`${spec.binary} was not at ${relative} inside ${path.basename(archive)} — the vendor's archive layout changed`);
|
|
188
|
+
}
|
|
162
189
|
}
|
|
163
190
|
|
|
164
191
|
const dir = installDir();
|
package/src/settings-sync.mjs
CHANGED
|
@@ -74,6 +74,28 @@ export const SYNCED_FILES = [
|
|
|
74
74
|
// reported as skipped rather than failing the snapshot, which is the right
|
|
75
75
|
// answer for a list that got big by importing somebody else's.
|
|
76
76
|
{ path: "news.opml", json: false, label: "news subscriptions" },
|
|
77
|
+
// Herd's notification preferences — which states nag you and whether they
|
|
78
|
+
// ask. `rules.json` above has been carried since the first version and this
|
|
79
|
+
// sat next to it, unsynced, which made "my herd settings came across" true
|
|
80
|
+
// of half of them.
|
|
81
|
+
{ path: "herd/config.json", json: true, label: "herd notifications" },
|
|
82
|
+
// Per-model price overrides for `/cost`. Nothing writes this file; a person
|
|
83
|
+
// types it, once, from a pricing page — which is exactly the kind of work
|
|
84
|
+
// `/save` exists so you only do once.
|
|
85
|
+
{ path: "pricing.json", json: true, label: "cost model prices" },
|
|
86
|
+
// The DNS filter's policy: categories, and your own allow and block lists.
|
|
87
|
+
// A curated decision, not machine state — the blocklists it names are a
|
|
88
|
+
// cache that re-downloads itself, and `dns-filter/stats.json` next to it is
|
|
89
|
+
// browsing history and named below as never-synced.
|
|
90
|
+
{ path: "dns-filter/filter.json", json: true, label: "dns filter policy" },
|
|
91
|
+
// Clients, teams, rates, invoices. The largest single "start from nothing"
|
|
92
|
+
// on a new machine, and safe to carry because payment gateways are stored
|
|
93
|
+
// here as references into a vault rather than as keys (see payments.mjs).
|
|
94
|
+
//
|
|
95
|
+
// Last on purpose. The total cap is spent in this order, so the file most
|
|
96
|
+
// likely to grow past it goes after the small ones — otherwise a year of
|
|
97
|
+
// invoices silently pushes your aliases out of the snapshot.
|
|
98
|
+
{ path: "business.json", json: true, label: "clients, rates and invoices" },
|
|
77
99
|
];
|
|
78
100
|
|
|
79
101
|
/**
|
|
@@ -91,13 +113,63 @@ export const NEVER_SYNCED = [
|
|
|
91
113
|
"sync.json",
|
|
92
114
|
"herd/sessions.json",
|
|
93
115
|
"herd/hook.json",
|
|
116
|
+
// A billing ledger, not a preference. Two machines both appending hours and
|
|
117
|
+
// then both saving means the later `/save` drops the earlier one's entries,
|
|
118
|
+
// and it grows without bound — the two properties that make a file wrong for
|
|
119
|
+
// a last-write-wins sync.
|
|
120
|
+
"timers.json",
|
|
121
|
+
// "the numbers you last saw here", so that `add 3` means something. Carrying
|
|
122
|
+
// them would make `add 3` on another machine refer to a listing it never saw.
|
|
123
|
+
"news-last.json",
|
|
124
|
+
"news-found.json",
|
|
125
|
+
// Counters, and `recent[]` — the last twenty domains this machine was
|
|
126
|
+
// blocked from reaching. That is browsing history, and it has no business in
|
|
127
|
+
// a settings snapshot even one belonging to the person who generated it.
|
|
128
|
+
"dns-filter/stats.json",
|
|
129
|
+
// One box's daemon.
|
|
130
|
+
"moshpit-dns.pid",
|
|
131
|
+
"moshpit-dns.log",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whole subtrees that never sync, matched by prefix.
|
|
136
|
+
*
|
|
137
|
+
* `NEVER_SYNCED` is an exact-string list, so a directory named there would be
|
|
138
|
+
* inert — the entries under it would not match it and would fall through. Any
|
|
139
|
+
* rule about a directory has to live here to have an effect.
|
|
140
|
+
*/
|
|
141
|
+
export const NEVER_SYNCED_PREFIXES = [
|
|
142
|
+
// The program itself. ~/.moshcode is also the install directory.
|
|
143
|
+
"pkg/",
|
|
144
|
+
// Hook reports, remote polls and per-session task ledgers: all pinned to one
|
|
145
|
+
// runtime, and the task ledgers carry prompt text and output artifacts.
|
|
146
|
+
"herd/status/",
|
|
147
|
+
"herd/remote/",
|
|
148
|
+
"herd/tasks/",
|
|
149
|
+
// Cached copies of published feed lists, re-fetched on demand.
|
|
150
|
+
"lists/",
|
|
151
|
+
// Downloaded blocklists. Megabytes, and self-renewing.
|
|
152
|
+
"dns-filter/lists/",
|
|
94
153
|
];
|
|
95
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The pty substrate, by extension.
|
|
157
|
+
*
|
|
158
|
+
* The header comment above has claimed since the first version that `*.sock`
|
|
159
|
+
* and `*.pid` are excluded "because they describe processes on exactly one
|
|
160
|
+
* box". That was true of the intent and never of the code: nothing enforced
|
|
161
|
+
* it, and the allowlist alone happened to be doing the work. Now it is a rule.
|
|
162
|
+
* A transcript is the one that matters — it is a full screen capture and will
|
|
163
|
+
* hold whatever was typed into that session.
|
|
164
|
+
*/
|
|
165
|
+
export const NEVER_SYNCED_SUFFIXES = [".transcript", ".stdin", ".exit", ".sock", ".pid", ".log"];
|
|
166
|
+
|
|
96
167
|
/** True for a path this build is willing to read or write. */
|
|
97
168
|
export function isSyncable(relative) {
|
|
98
169
|
const name = String(relative ?? "");
|
|
99
170
|
if (NEVER_SYNCED.includes(name)) return false;
|
|
100
|
-
if (name.startsWith(
|
|
171
|
+
if (NEVER_SYNCED_PREFIXES.some((prefix) => name.startsWith(prefix))) return false;
|
|
172
|
+
if (NEVER_SYNCED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return false;
|
|
101
173
|
return SYNCED_FILES.some((f) => f.path === name);
|
|
102
174
|
}
|
|
103
175
|
|
package/src/timer.mjs
CHANGED
|
@@ -45,8 +45,12 @@ export function parseDuration(text) {
|
|
|
45
45
|
export function humanDuration(seconds) {
|
|
46
46
|
const total = Math.max(0, Math.round(Number(seconds) || 0));
|
|
47
47
|
if (total < 60) return `${total}s`;
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Round to whole minutes first, then split — computing hours and minutes off
|
|
49
|
+
// the raw seconds lets a remainder that rounds up to 60 (59m30s..59m59s) print
|
|
50
|
+
// as "60m"/"1h 60m" instead of carrying into the hour it belongs in.
|
|
51
|
+
const minutes = Math.round(total / 60);
|
|
52
|
+
const h = Math.floor(minutes / 60);
|
|
53
|
+
const m = minutes % 60;
|
|
50
54
|
if (!h) return `${m}m`;
|
|
51
55
|
return m ? `${h}h ${m}m` : `${h}h`;
|
|
52
56
|
}
|
package/src/tools.mjs
CHANGED
|
@@ -18,6 +18,12 @@ import { isInstalled, openPassthrough } from "./engines.mjs";
|
|
|
18
18
|
const RELEASE_INSTALLER = path.join(path.dirname(fileURLToPath(import.meta.url)), "release-install.mjs");
|
|
19
19
|
const releaseInstall = (tool) => ({ cmd: process.execPath, args: [RELEASE_INSTALLER, tool] });
|
|
20
20
|
|
|
21
|
+
// ffmpeg and ImageMagick ship as distro packages and nothing else — no vendor
|
|
22
|
+
// installer, no release binary we would trust. See src/pkg-install.mjs for why
|
|
23
|
+
// the static rebuilds floating around are not an option here.
|
|
24
|
+
const PACKAGE_INSTALLER = path.join(path.dirname(fileURLToPath(import.meta.url)), "pkg-install.mjs");
|
|
25
|
+
const packageInstall = (tool) => ({ cmd: process.execPath, args: [PACKAGE_INSTALLER, tool] });
|
|
26
|
+
|
|
21
27
|
export const TOOLS = {
|
|
22
28
|
ugig: {
|
|
23
29
|
desc: "UGig — freelance marketplace CLI for humans and agents",
|
|
@@ -354,6 +360,55 @@ export const TOOLS = {
|
|
|
354
360
|
// there is deliberately no `upgrade` key.
|
|
355
361
|
install: { cmd: "npm", args: ["install", "-g", "@elevenlabs/cli"] },
|
|
356
362
|
},
|
|
363
|
+
"yt-dlp": {
|
|
364
|
+
desc: "yt-dlp — download video and audio from a URL (a thousand sites, not just YouTube)",
|
|
365
|
+
bin: "yt-dlp",
|
|
366
|
+
// The three tools below are not workflow CLIs like everything above: they
|
|
367
|
+
// are the media toolchain `cli-tools` builds on. `dl` is a front for
|
|
368
|
+
// yt-dlp, `vid` for ffmpeg and `img` for ImageMagick, and all three used to
|
|
369
|
+
// tell you to go and install a system package by hand. Now the same
|
|
370
|
+
// registry that installs cli-tools can install what it runs on.
|
|
371
|
+
//
|
|
372
|
+
// A PyInstaller bundle from the project's own releases, so it needs no
|
|
373
|
+
// python and no package manager, and it lands in ~/.local/bin like
|
|
374
|
+
// gh/supabase/doctl. Distro packages of yt-dlp are the one thing worth
|
|
375
|
+
// avoiding here: extractors break whenever a site changes, upstream ships a
|
|
376
|
+
// fix within days, and a distro package is frozen for the life of a release.
|
|
377
|
+
install: releaseInstall("yt-dlp"),
|
|
378
|
+
// Which is also why the upgrade is yt-dlp's own `-U` rather than a
|
|
379
|
+
// re-download: it is the update path the project documents, it checks
|
|
380
|
+
// before it fetches, and it is the one an operator will reach for anyway.
|
|
381
|
+
// On a yt-dlp that came from a package manager instead, `-U` declines and
|
|
382
|
+
// says so, which is the correct answer rather than a failure.
|
|
383
|
+
upgrade: { cmd: "yt-dlp", args: ["-U"] },
|
|
384
|
+
// Same gap turso, gradient and kimi have: nothing appends to PATH.
|
|
385
|
+
binDirs: [path.join(homedir(), ".local", "bin")],
|
|
386
|
+
},
|
|
387
|
+
ffmpeg: {
|
|
388
|
+
desc: "ffmpeg — convert, cut, scale and inspect audio and video",
|
|
389
|
+
bin: "ffmpeg",
|
|
390
|
+
// Through the distro package manager, which means root everywhere but
|
|
391
|
+
// macOS, where Homebrew refuses to run as root at all. Same shape as
|
|
392
|
+
// tailscale, and for the same reason: get the password prompt out of the
|
|
393
|
+
// way before a sweep starts rather than partway through one.
|
|
394
|
+
needsRoot: { except: ["darwin"] },
|
|
395
|
+
install: packageInstall("ffmpeg"),
|
|
396
|
+
// No upgrade key: `apt-get install` / `brew install` on a package that is
|
|
397
|
+
// already there upgrades it, so re-running the install IS the upgrade —
|
|
398
|
+
// the same reasoning as mcpjam and railway, and toolUpgradeSpec falls back
|
|
399
|
+
// to install on its own.
|
|
400
|
+
},
|
|
401
|
+
imagemagick: {
|
|
402
|
+
desc: "ImageMagick — resize, convert and composite images from the command line",
|
|
403
|
+
// Two names, deliberately. The command is `magick` on ImageMagick 7 and
|
|
404
|
+
// `convert` on 6, and both are current: Ubuntu 24.04 and earlier ship 6,
|
|
405
|
+
// 25.04 and later ship 7, and the package is called `imagemagick` on both.
|
|
406
|
+
// A single name would report a perfectly good install as missing on
|
|
407
|
+
// whichever half of the fleet has the other one.
|
|
408
|
+
bin: ["magick", "convert"],
|
|
409
|
+
needsRoot: { except: ["darwin"] },
|
|
410
|
+
install: packageInstall("imagemagick"),
|
|
411
|
+
},
|
|
357
412
|
};
|
|
358
413
|
|
|
359
414
|
/** Resolve a name to `[key, tool]`, or null. */
|
|
@@ -377,7 +432,7 @@ export function toolStatus() {
|
|
|
377
432
|
|
|
378
433
|
export function toolList() {
|
|
379
434
|
return Object.entries(TOOLS)
|
|
380
|
-
.map(([key, tool]) => ` ${key.padEnd(
|
|
435
|
+
.map(([key, tool]) => ` ${key.padEnd(11)} ${tool.desc}`)
|
|
381
436
|
.join("\n");
|
|
382
437
|
}
|
|
383
438
|
|
package/src/trust.mjs
CHANGED
|
@@ -189,6 +189,15 @@ export function trustStores({ platform = process.platform, home = os.homedir(),
|
|
|
189
189
|
needsRoot: true,
|
|
190
190
|
command: "security",
|
|
191
191
|
args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file],
|
|
192
|
+
// The one store that can only be told with the certificate in hand:
|
|
193
|
+
// `remove-trusted-cert` takes a file, not a nickname. `needsFile` is what
|
|
194
|
+
// lets the undo say so out loud instead of leaving an anchor behind and
|
|
195
|
+
// reporting success.
|
|
196
|
+
remove: {
|
|
197
|
+
needsFile: true,
|
|
198
|
+
command: "security",
|
|
199
|
+
args: ["remove-trusted-cert", "-d", file],
|
|
200
|
+
},
|
|
192
201
|
});
|
|
193
202
|
return stores;
|
|
194
203
|
}
|
|
@@ -207,6 +216,13 @@ export function trustStores({ platform = process.platform, home = os.homedir(),
|
|
|
207
216
|
ownedDir: path.join(home, ".pki", "nssdb"),
|
|
208
217
|
command: "certutil",
|
|
209
218
|
args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-A", "-t", "C,,", "-n", "Moshpit Local CA", "-i", file],
|
|
219
|
+
// By nickname, so the anchor can still be withdrawn after the root file
|
|
220
|
+
// itself is gone — which is the ordinary case, since a person who wants
|
|
221
|
+
// rid of this deletes the certificate first and asks questions after.
|
|
222
|
+
remove: {
|
|
223
|
+
command: "certutil",
|
|
224
|
+
args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-D", "-n", "Moshpit Local CA"],
|
|
225
|
+
},
|
|
210
226
|
});
|
|
211
227
|
stores.push({
|
|
212
228
|
id: "system",
|
|
@@ -218,6 +234,14 @@ export function trustStores({ platform = process.platform, home = os.homedir(),
|
|
|
218
234
|
copyTo: "/usr/local/share/ca-certificates/moshpit-local-ca.crt",
|
|
219
235
|
command: "update-ca-certificates",
|
|
220
236
|
args: [],
|
|
237
|
+
// Delete the copy, then rebuild. `--fresh` rather than a bare refresh:
|
|
238
|
+
// the bare form adds what is new, and it is the rebuild that drops the
|
|
239
|
+
// symlink for a source file that is no longer there.
|
|
240
|
+
remove: {
|
|
241
|
+
removeFile: "/usr/local/share/ca-certificates/moshpit-local-ca.crt",
|
|
242
|
+
command: "update-ca-certificates",
|
|
243
|
+
args: ["--fresh"],
|
|
244
|
+
},
|
|
221
245
|
});
|
|
222
246
|
return stores;
|
|
223
247
|
}
|
|
@@ -278,6 +302,130 @@ export function trustPlan({
|
|
|
278
302
|
return { ok: true, steps, skipped, file, why: constrained.why };
|
|
279
303
|
}
|
|
280
304
|
|
|
305
|
+
/**
|
|
306
|
+
* What `dns disable` should do about trust.
|
|
307
|
+
*
|
|
308
|
+
* Deliberately not `trustPlan(...).steps.reverse()`. Installing is gated on the
|
|
309
|
+
* root being safe to install — name constraints, a certificate that parses —
|
|
310
|
+
* and none of that has any bearing on taking it back out: a root that should
|
|
311
|
+
* never have been trusted is the *most* important one to be able to withdraw.
|
|
312
|
+
* So this plan asks two questions only, whether the store can be reached and
|
|
313
|
+
* whether the undo needs the certificate file, and never refuses.
|
|
314
|
+
*
|
|
315
|
+
* Pure, like trustPlan, so `dns disable` can be tested without a trust store.
|
|
316
|
+
*/
|
|
317
|
+
export function untrustPlan({
|
|
318
|
+
platform = process.platform,
|
|
319
|
+
home = os.homedir(),
|
|
320
|
+
caFile = null,
|
|
321
|
+
isRoot = false,
|
|
322
|
+
haveCertutil = true,
|
|
323
|
+
haveFile = true,
|
|
324
|
+
} = {}) {
|
|
325
|
+
const file = caFile || caPath({ home });
|
|
326
|
+
const steps = [];
|
|
327
|
+
const skipped = [];
|
|
328
|
+
|
|
329
|
+
for (const store of trustStores({ platform, home, caFile: file })) {
|
|
330
|
+
if (!store.remove) {
|
|
331
|
+
skipped.push({ ...store, why: "this build knows how to install it but not how to remove it" });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (store.id === "nss" && !haveCertutil) {
|
|
335
|
+
skipped.push({ ...store, why: "certutil is not installed (Debian/Ubuntu: libnss3-tools)" });
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (store.needsRoot && !isRoot) {
|
|
339
|
+
skipped.push({ ...store, why: "needs root" });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// The macOS case. Saying "the root is gone, so the anchor cannot be named"
|
|
343
|
+
// is worth a line, because the alternative is a machine that keeps trusting
|
|
344
|
+
// a certificate nobody can produce any more.
|
|
345
|
+
if (store.remove.needsFile && !haveFile) {
|
|
346
|
+
skipped.push({ ...store, why: `the root at ${file} is gone, and this store can only be told with it` });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
steps.push(store);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { ok: true, steps, skipped, file };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The trust half of `dns disable` — take back what `applyTrust` installed.
|
|
357
|
+
*
|
|
358
|
+
* Non-fatal throughout, for the same reason its counterpart is: resolution has
|
|
359
|
+
* already been put back by the time this runs, and failing the whole command
|
|
360
|
+
* over a trust store would undo working DNS to fix a certificate. What it must
|
|
361
|
+
* not do is report a removal it did not achieve, since a trust anchor believed
|
|
362
|
+
* gone is worse than one known to be present.
|
|
363
|
+
*/
|
|
364
|
+
export async function applyUntrust(out, deps = {}) {
|
|
365
|
+
const {
|
|
366
|
+
readFile = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"),
|
|
367
|
+
runner = run,
|
|
368
|
+
env = process.env,
|
|
369
|
+
home = operatorHome({ env }),
|
|
370
|
+
platform = process.platform,
|
|
371
|
+
uid = typeof process.getuid === "function" ? process.getuid() : 0,
|
|
372
|
+
} = deps;
|
|
373
|
+
const owner = env.SUDO_USER || env.DOAS_USER || null;
|
|
374
|
+
|
|
375
|
+
const file = caPath({ home });
|
|
376
|
+
const haveFile = await readFile(file).then(() => true, () => false);
|
|
377
|
+
const plan = untrustPlan({
|
|
378
|
+
platform, home, caFile: file, isRoot: uid === 0,
|
|
379
|
+
haveCertutil: (await runner("which", ["certutil"])).ok,
|
|
380
|
+
haveFile,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (!plan.steps.length && !plan.skipped.length) return { ok: true, removed: 0, skipped: 0 };
|
|
384
|
+
|
|
385
|
+
out("");
|
|
386
|
+
out("trust (taking the local root back out)");
|
|
387
|
+
|
|
388
|
+
let removed = 0;
|
|
389
|
+
for (const step of plan.steps) {
|
|
390
|
+
const undo = step.remove;
|
|
391
|
+
if (undo.removeFile) {
|
|
392
|
+
// `rm -f`: the copy not being there is the state we are trying to reach,
|
|
393
|
+
// so its absence is success rather than something to report.
|
|
394
|
+
const gone = await runner("rm", ["-f", undo.removeFile]);
|
|
395
|
+
if (!gone.ok) {
|
|
396
|
+
out(` FAIL ${step.label} — ${gone.stderr.split("\n")[0] || `could not remove ${undo.removeFile}`}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const done = await runner(undo.command, undo.args);
|
|
401
|
+
if (!done.ok) {
|
|
402
|
+
const first = done.stderr.split("\n")[0] || "";
|
|
403
|
+
// certutil says this when the nickname is not in the database, which is
|
|
404
|
+
// the same end state as a successful removal and must not read as a
|
|
405
|
+
// failure — most often it means `dns disable` is being run twice.
|
|
406
|
+
if (/SEC_ERROR_BAD_DATA|not found|PR_FILE_NOT_FOUND/i.test(first)) {
|
|
407
|
+
out(` ok ${step.label} — was not there`);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
out(` FAIL ${step.label} — ${first || `${undo.command} failed`}`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (step.ownedDir && owner && uid === 0) {
|
|
414
|
+
const owned = await runner("chown", ["-R", `${owner}:`, step.ownedDir]);
|
|
415
|
+
if (!owned.ok) out(` -- ${step.ownedDir} is left owned by root — chown -R ${owner}: ${step.ownedDir}`);
|
|
416
|
+
}
|
|
417
|
+
removed++;
|
|
418
|
+
out(` ok removed from ${step.label}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const step of plan.skipped) {
|
|
422
|
+
out(` -- ${step.label} — ${step.why}`);
|
|
423
|
+
if (step.needsRoot && uid !== 0) out(" re-run with root to cover it: sudo moshcode dns disable");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { ok: true, removed, skipped: plan.skipped.length };
|
|
427
|
+
}
|
|
428
|
+
|
|
281
429
|
/**
|
|
282
430
|
* What to do about a refusal, which depends entirely on which one it is.
|
|
283
431
|
*
|
package/src/tui.mjs
CHANGED
|
@@ -16,8 +16,9 @@ import { runUpgrade } from "./upgrade.mjs";
|
|
|
16
16
|
import { locate, tilde } from "./pwd.mjs";
|
|
17
17
|
import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
|
|
18
18
|
import { loginAuto, whoami, logout } from "./auth.mjs";
|
|
19
|
+
import { startAutoSync } from "./autosync.mjs";
|
|
19
20
|
import { loadCommand, saveCommand } from "./settings-sync.mjs";
|
|
20
|
-
import { createMirror, teeOutput } from "./mirror.mjs";
|
|
21
|
+
import { createMirror, pressKey, teeOutput } from "./mirror.mjs";
|
|
21
22
|
import { fetchMotdAd } from "./ads.mjs";
|
|
22
23
|
import { runScript } from "./runtime.mjs";
|
|
23
24
|
import { moshVocabulary } from "./commands.mjs";
|
|
@@ -822,6 +823,14 @@ export async function tui() {
|
|
|
822
823
|
|
|
823
824
|
const { restoreTee, drainRemote, atPrompt } = await startMirror();
|
|
824
825
|
|
|
826
|
+
// Settings sync, unattended. Started per `tui()` call and stopped in the
|
|
827
|
+
// teardown below, because the pit is re-entered after an engine session
|
|
828
|
+
// (`backToPit`) and a timer left behind would be joined by another one.
|
|
829
|
+
// Deliberately holds no reference to `rl`: the loop closes and rebuilds it
|
|
830
|
+
// around a dozen commands, so a tick that captured it would be writing to a
|
|
831
|
+
// readline that no longer exists.
|
|
832
|
+
const stopAutoSync = startAutoSync();
|
|
833
|
+
|
|
825
834
|
let rl = mkrl();
|
|
826
835
|
// An alias expands into a line that is dispatched exactly as if it had been
|
|
827
836
|
// typed, so it goes back through the top of this loop instead of through a
|
|
@@ -1252,6 +1261,7 @@ export async function tui() {
|
|
|
1252
1261
|
: `unknown command "${line}". /help for the list.`));
|
|
1253
1262
|
}
|
|
1254
1263
|
|
|
1264
|
+
stopAutoSync();
|
|
1255
1265
|
try { rl.close(); } catch { /* noop */ }
|
|
1256
1266
|
saveHistory();
|
|
1257
1267
|
console.log("\n" + ash("code hard, mosh harder. 🤘"));
|
|
@@ -1302,6 +1312,13 @@ async function startMirror() {
|
|
|
1302
1312
|
};
|
|
1303
1313
|
mirror.onCommand((body) => { queue.push(body); drainRemote(); });
|
|
1304
1314
|
|
|
1315
|
+
// Keys skip the queue: they are pressed the instant they arrive, whether the
|
|
1316
|
+
// prompt is armed or something else has the tty (a herd bar, the reader, a
|
|
1317
|
+
// menu). Nothing is echoed for them either — a line gets a `▸ (web)` note
|
|
1318
|
+
// because it would otherwise appear from nowhere, but a key's effect is the
|
|
1319
|
+
// redraw it causes, and printing over that would shift it out of place.
|
|
1320
|
+
mirror.onKey((name) => { pressKey(name, promptRl); });
|
|
1321
|
+
|
|
1305
1322
|
console.log(info(`mirroring this session → ${acid(mirror.url)}`));
|
|
1306
1323
|
return { restoreTee, drainRemote, atPrompt: (rl) => { promptRl = rl; } };
|
|
1307
1324
|
}
|