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/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
- function runShell(rawCmd) {
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(shell, args, { stdio: "inherit" }); }
685
- catch (e) { resolve({ ok: false, error: e }); return; }
686
- child.on("error", (e) => resolve({ ok: false, error: e }));
687
- child.on("exit", (code, signal) => resolve({ ok: true, 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
- const child = spawn(target.install.cmd, target.install.args, { stdio: "inherit" });
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
  }