moshcode 0.73.0 → 0.75.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/billing.mjs +7 -1
- package/src/commands.mjs +10 -1
- package/src/dns.mjs +12 -1
- package/src/engines.mjs +46 -47
- package/src/mirror.mjs +22 -0
- package/src/payments.mjs +5 -1
- package/src/pkg-install.mjs +195 -0
- package/src/pty.mjs +58 -1
- 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 +41 -8
package/src/mirror.mjs
CHANGED
|
@@ -56,6 +56,28 @@ export function pressKey(name, rl = null, stdin = process.stdin) {
|
|
|
56
56
|
const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty
|
|
57
57
|
const MAX_BUFFER = 16000; // flush early once a batch gets big
|
|
58
58
|
|
|
59
|
+
// Where a child process's output should be copied while a mirror is watching.
|
|
60
|
+
//
|
|
61
|
+
// Module-level rather than threaded through every call, because "is anyone
|
|
62
|
+
// watching this pit" is one fact about the process and the launchers that need
|
|
63
|
+
// it are scattered: the shell, the installers, the upgrader, the plugin/skill/
|
|
64
|
+
// MCP hand-offs. Passing it down by hand is what left most of them writing
|
|
65
|
+
// straight to the tty with the session page showing nothing — each new launcher
|
|
66
|
+
// had to remember, and none of them did. src/pty.mjs reads this as its default,
|
|
67
|
+
// so capture is what a launcher gets for free and opting out is the deliberate
|
|
68
|
+
// act.
|
|
69
|
+
let activeSink = null;
|
|
70
|
+
|
|
71
|
+
/** Point child capture at this mirror (or null when the pit stops mirroring). */
|
|
72
|
+
export function setActiveSink(sink) {
|
|
73
|
+
activeSink = typeof sink === "function" ? sink : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The sink a child's output should be copied to, or null when unmirrored. */
|
|
77
|
+
export function activeChildSink() {
|
|
78
|
+
return activeSink;
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
export function createMirror({
|
|
60
82
|
version = "",
|
|
61
83
|
cwd = process.cwd(),
|
package/src/payments.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// `/payments connect stripe` records a *reference* — vault and key name — and
|
|
23
23
|
// says out loud where the secret should go.
|
|
24
24
|
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { captureSpec } from "./pty.mjs";
|
|
25
26
|
|
|
26
27
|
import { loadBusiness, updateBusiness } from "./business-store.mjs";
|
|
27
28
|
import { parseFields } from "./clients.mjs";
|
|
@@ -178,7 +179,10 @@ function connectGateway(args, write, run) {
|
|
|
178
179
|
return 1;
|
|
179
180
|
}
|
|
180
181
|
write(info(`handing you to ${bone(gateway.bin)} — it owns its own session`));
|
|
181
|
-
const
|
|
182
|
+
const launch = captureSpec({ cmd: gateway.bin, args: gateway.connect });
|
|
183
|
+
let result;
|
|
184
|
+
try { result = run(launch.cmd, launch.args, { stdio: "inherit" }); }
|
|
185
|
+
finally { launch.stop(); }
|
|
182
186
|
if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
|
|
183
187
|
if (result?.status) {
|
|
184
188
|
write(err(`${gateway.bin} ${gateway.connect.join(" ")} exited ${result.status} — nothing recorded`));
|
|
@@ -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/pty.mjs
CHANGED
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
// `script` disagree on both flag names and argument order, and anything we
|
|
20
20
|
// cannot positively identify falls back to today's plain `inherit`.
|
|
21
21
|
import { spawnSync } from "node:child_process";
|
|
22
|
-
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
22
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
23
25
|
import { StringDecoder } from "node:string_decoder";
|
|
26
|
+
import { activeChildSink } from "./mirror.mjs";
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* POSIX single-quote escaping, for argv that has to survive being flattened
|
|
@@ -178,3 +181,57 @@ export function ptyEnabled(sink, flavor = scriptFlavor()) {
|
|
|
178
181
|
if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
|
|
179
182
|
return Boolean(flavor);
|
|
180
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wrap a spawn spec so a copy of everything the child prints reaches `onOutput`
|
|
187
|
+
* while the child still owns the real terminal.
|
|
188
|
+
*
|
|
189
|
+
* The whole capture dance in one place — temp transcript, the flavour-specific
|
|
190
|
+
* `script` argv, the follower, the banner strip, the cleanup — because every
|
|
191
|
+
* launcher in the pit needs it, and each one growing its own copy is how a
|
|
192
|
+
* shell command ended up invisible in the mirror while `/agents claude` was
|
|
193
|
+
* captured: both spawn `inherit`, and only one of them had been taught this.
|
|
194
|
+
*
|
|
195
|
+
* `onOutput` defaults to whatever the live mirror is (src/mirror.mjs), so a
|
|
196
|
+
* launcher gets capture without having to know the mirror exists — the reverse
|
|
197
|
+
* of how this started, where each launcher had to be taught separately and only
|
|
198
|
+
* two ever were. Pass `null` to opt a launch out.
|
|
199
|
+
*
|
|
200
|
+
* Returns `{ cmd, args, stop }`. With nothing watching, or on a box with no
|
|
201
|
+
* `script(1)` we can drive, `cmd`/`args` come back exactly as passed in and
|
|
202
|
+
* `stop` is a no-op — the caller spawns what it always spawned. `stop()` must
|
|
203
|
+
* be called once the child exits: it drains the tail of the transcript (the
|
|
204
|
+
* last lines of a command are usually the ones you were waiting for) and
|
|
205
|
+
* removes the temp dir.
|
|
206
|
+
*/
|
|
207
|
+
export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), { flavor = scriptFlavor() } = {}) {
|
|
208
|
+
const plain = { cmd, args, stop: () => {} };
|
|
209
|
+
if (!ptyEnabled(onOutput, flavor)) return plain;
|
|
210
|
+
let workDir = null;
|
|
211
|
+
try {
|
|
212
|
+
workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
|
|
213
|
+
const transcript = path.join(workDir, "transcript");
|
|
214
|
+
writeFileSync(transcript, "");
|
|
215
|
+
const wrapped = ptySpec(cmd, args, transcript, flavor);
|
|
216
|
+
if (!wrapped) throw new Error("no script(1) spec for this flavour");
|
|
217
|
+
let first = true;
|
|
218
|
+
const stopFollow = followFile(transcript, (chunk) => {
|
|
219
|
+
const clean = stripScriptBanner(chunk, first);
|
|
220
|
+
first = false;
|
|
221
|
+
if (clean) onOutput(clean);
|
|
222
|
+
});
|
|
223
|
+
const dir = workDir;
|
|
224
|
+
return {
|
|
225
|
+
cmd: wrapped.cmd,
|
|
226
|
+
args: wrapped.args,
|
|
227
|
+
stop() {
|
|
228
|
+
try { stopFollow(); } catch { /* nothing left to drain */ }
|
|
229
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
} catch {
|
|
233
|
+
// Capture is a nicety; never let it stop a command from running.
|
|
234
|
+
if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
|
|
235
|
+
return plain;
|
|
236
|
+
}
|
|
237
|
+
}
|
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
|
|