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/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, pressKey, teeOutput } from "./mirror.mjs";
|
|
21
|
+
import { createMirror, pressKey, setActiveSink, 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";
|
|
@@ -27,6 +28,7 @@ import { cryptoCommand } from "./crypto.mjs";
|
|
|
27
28
|
import { gamesCommand } from "./games.mjs";
|
|
28
29
|
import { canOpenBrowser, openBrowser } from "./open-url.mjs";
|
|
29
30
|
import { shellInvocation } from "./shell.mjs";
|
|
31
|
+
import { captureSpec } from "./pty.mjs";
|
|
30
32
|
import { needsRootHere, primeEscalation } from "./escalate.mjs";
|
|
31
33
|
import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
|
|
32
34
|
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
|
|
@@ -677,14 +679,23 @@ async function openWorkflowTool(key, tool, args) {
|
|
|
677
679
|
// command string → `$SHELL +m -ic "<cmd>"` (one-off). Interactive so the command
|
|
678
680
|
// can see the aliases and functions in ~/.zshrc — see src/shell.mjs for why
|
|
679
681
|
// that is not optional. Resolves { ok, code, signal }.
|
|
680
|
-
|
|
682
|
+
//
|
|
683
|
+
// Captured through a pty when the mirror is watching, for the same reason an
|
|
684
|
+
// engine is: a shell command is where most of what a pit does actually happens
|
|
685
|
+
// — `!cmd`, /shell, and every shell-valued /alias land here — and with plain
|
|
686
|
+
// `inherit` none of its bytes, on stdout or stderr, ever pass through this
|
|
687
|
+
// process. The session page was showing the echoed command line and the exit
|
|
688
|
+
// note with nothing in between.
|
|
689
|
+
export function runShell(rawCmd, { onOutput } = {}) {
|
|
681
690
|
return new Promise((resolve) => {
|
|
682
691
|
const { shell, args } = shellInvocation(rawCmd);
|
|
692
|
+
const launch = captureSpec({ cmd: shell, args }, onOutput);
|
|
693
|
+
const done = (result) => { try { launch.stop(); } catch { /* already drained */ } resolve(result); };
|
|
683
694
|
let child;
|
|
684
|
-
try { child = spawn(
|
|
685
|
-
catch (e) {
|
|
686
|
-
child.on("error", (e) =>
|
|
687
|
-
child.on("exit", (code, signal) =>
|
|
695
|
+
try { child = spawn(launch.cmd, launch.args, { stdio: "inherit" }); }
|
|
696
|
+
catch (e) { done({ ok: false, error: e }); return; }
|
|
697
|
+
child.on("error", (e) => done({ ok: false, error: e }));
|
|
698
|
+
child.on("exit", (code, signal) => done({ ok: true, code, signal }));
|
|
688
699
|
});
|
|
689
700
|
}
|
|
690
701
|
|
|
@@ -699,7 +710,7 @@ async function openShell(rawCmd) {
|
|
|
699
710
|
? `${bone(shellName)} ${ash(flags)} ${ash(rawCmd)}`
|
|
700
711
|
: `dropping to ${bone(shellName)} — ${ash("`exit` or Ctrl-D brings you back to the pit")}`));
|
|
701
712
|
console.log(hr());
|
|
702
|
-
const r = await runShell(rawCmd);
|
|
713
|
+
const r = await runShell(rawCmd, { onOutput: childSink() });
|
|
703
714
|
console.log(hr());
|
|
704
715
|
if (!r.ok) {
|
|
705
716
|
console.log(err(`couldn't start shell: ${r.error?.message || r.error}`));
|
|
@@ -719,14 +730,22 @@ function installTarget(key) {
|
|
|
719
730
|
// something the installer's output scrolled into view.
|
|
720
731
|
if (needsRootHere(target)) primeEscalation({ what: key, out: (s) => console.log(info(s.replace(/^· /, ""))) });
|
|
721
732
|
console.log(hr());
|
|
722
|
-
|
|
733
|
+
// Installers are long, chatty, and the thing you most want to read from a
|
|
734
|
+
// phone — so they go through the mirror's pty like everything else.
|
|
735
|
+
const launch = captureSpec(
|
|
736
|
+
{ cmd: target.install.cmd, args: target.install.args },
|
|
737
|
+
childSink(),
|
|
738
|
+
);
|
|
739
|
+
const child = spawn(launch.cmd, launch.args, { stdio: "inherit" });
|
|
723
740
|
child.on("error", (e) => {
|
|
741
|
+
launch.stop();
|
|
724
742
|
console.log(hr());
|
|
725
743
|
console.log(err(`install failed: ${e.message}`));
|
|
726
744
|
if (e.code === "ENOENT" && target.installHelp) console.log(info(target.installHelp));
|
|
727
745
|
resolve();
|
|
728
746
|
});
|
|
729
747
|
child.on("exit", (code) => {
|
|
748
|
+
launch.stop();
|
|
730
749
|
console.log(hr());
|
|
731
750
|
if (code !== 0) { console.log(err(`install exited ${code}`)); return resolve(); }
|
|
732
751
|
console.log(ok(`${key} installed. 🤘`));
|
|
@@ -822,6 +841,14 @@ export async function tui() {
|
|
|
822
841
|
|
|
823
842
|
const { restoreTee, drainRemote, atPrompt } = await startMirror();
|
|
824
843
|
|
|
844
|
+
// Settings sync, unattended. Started per `tui()` call and stopped in the
|
|
845
|
+
// teardown below, because the pit is re-entered after an engine session
|
|
846
|
+
// (`backToPit`) and a timer left behind would be joined by another one.
|
|
847
|
+
// Deliberately holds no reference to `rl`: the loop closes and rebuilds it
|
|
848
|
+
// around a dozen commands, so a tick that captured it would be writing to a
|
|
849
|
+
// readline that no longer exists.
|
|
850
|
+
const stopAutoSync = startAutoSync();
|
|
851
|
+
|
|
825
852
|
let rl = mkrl();
|
|
826
853
|
// An alias expands into a line that is dispatched exactly as if it had been
|
|
827
854
|
// typed, so it goes back through the top of this loop instead of through a
|
|
@@ -1252,6 +1279,7 @@ export async function tui() {
|
|
|
1252
1279
|
: `unknown command "${line}". /help for the list.`));
|
|
1253
1280
|
}
|
|
1254
1281
|
|
|
1282
|
+
stopAutoSync();
|
|
1255
1283
|
try { rl.close(); } catch { /* noop */ }
|
|
1256
1284
|
saveHistory();
|
|
1257
1285
|
console.log("\n" + ash("code hard, mosh harder. 🤘"));
|
|
@@ -1282,6 +1310,10 @@ async function startMirror() {
|
|
|
1282
1310
|
if (!started) return noop;
|
|
1283
1311
|
|
|
1284
1312
|
activeMirror = mirror;
|
|
1313
|
+
// Every launcher that spawns a child reads this rather than being handed a
|
|
1314
|
+
// sink, so a command run from the pit is captured whether or not whoever
|
|
1315
|
+
// wrote that launcher knew the mirror existed.
|
|
1316
|
+
setActiveSink((chunk) => activeMirror?.write(chunk));
|
|
1285
1317
|
const restoreTee = teeOutput((chunk) => mirror.write(chunk));
|
|
1286
1318
|
|
|
1287
1319
|
// Commands arrive whenever; the prompt is only ready between engine
|
|
@@ -1316,6 +1348,7 @@ async function startMirror() {
|
|
|
1316
1348
|
async function stopMirror(restoreTee) {
|
|
1317
1349
|
const mirror = activeMirror;
|
|
1318
1350
|
activeMirror = null;
|
|
1351
|
+
setActiveSink(null);
|
|
1319
1352
|
try { restoreTee?.(); } catch { /* noop */ }
|
|
1320
1353
|
try { await mirror?.stop(); } catch { /* best effort */ }
|
|
1321
1354
|
}
|