moshcode 0.51.0 → 0.53.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 CHANGED
@@ -449,6 +449,12 @@ MoshCode resolves the latest GitHub release and drops the binary in
449
449
  goes through your distro's package manager and will ask for sudo (on macOS it
450
450
  delegates to the App Store).
451
451
 
452
+ MoshCode asks for that password **before** starting the work rather than letting
453
+ the installer stop for it partway through — which matters most in `moshcode
454
+ update`, where tailscale is one step in a long unattended run and the prompt
455
+ would otherwise land where nobody is watching. Nothing is asked when the plan has
456
+ no privileged step in it, when a credential is already cached, or on macOS.
457
+
452
458
  Top-level passthrough preserves stdin, stdout, stderr, environment variables,
453
459
  the current directory, and the native exit result. That keeps JSON pipelines
454
460
  usable:
@@ -1035,7 +1041,7 @@ chmod +x deploy.mosh
1035
1041
  | `ask(prompt)` | blocking gate — waits for human reply at moshcode.sh |
1036
1042
  | `say("…")` | print a line |
1037
1043
  | `sleep(ms)` | pause for N milliseconds (blocking) |
1038
- | `shell(cmd)` | run a shell command (blocking, `$SHELL -c`); returns `{ ok, code }` |
1044
+ | `shell(cmd)` | run a shell command (blocking, `$SHELL +m -ic`, so your rc file loads without job control taking the terminal); returns `{ ok, code }` |
1039
1045
  | `stop()` | end the loop (`alive = false`) |
1040
1046
  | `repeat()` | back to the top of the loop |
1041
1047
 
package/bin/moshcode.mjs CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  renderMarkdown, renderScriptVerb, suggest, wantsHelp, withoutHelp,
44
44
  } from "../src/help.mjs";
45
45
  import { moshcodeVersion } from "../src/ui.mjs";
46
+ import { needsRootHere, primeEscalation } from "../src/escalate.mjs";
46
47
 
47
48
  const HERE = path.dirname(fileURLToPath(import.meta.url));
48
49
  const EXAMPLE = path.join(HERE, "..", "examples", "alive.mosh");
@@ -445,6 +446,9 @@ async function main() {
445
446
  }
446
447
  const { install, desc, bin } = entry;
447
448
  console.log(`🎸 installing ${target} — ${desc}\n$ ${install.cmd} ${install.args.join(" ")}\n`);
449
+ // Ask for the password before the installer starts, not after it has spent a
450
+ // minute refreshing package lists and then stopped to wait on one.
451
+ if (needsRootHere(entry)) primeEscalation({ what: target });
448
452
  const result = await runCmd(install.cmd, install.args);
449
453
  if (!result.ok) {
450
454
  console.error(`install failed: ${result.error?.message || result.error || "unknown error"}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
package/src/aliases.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // An alias is a name and a line. The line is a shell command unless it starts
9
9
  // with `/`, in which case it is a pit command:
10
10
  //
11
- // /alias set gs "git status" → /gs runs `$SHELL -c "git status"`
11
+ // /alias set gs "git status" → /gs runs `$SHELL -ic "git status"`
12
12
  // /alias set cc "/agents claude" → /cc opens claude autonomously
13
13
  //
14
14
  // Shell-by-default because that is what the prompt is mostly asked for, and the
@@ -148,7 +148,7 @@ export function removeAlias(name) {
148
148
  *
149
149
  * Appended rather than substituted, the way a shell alias behaves: `/gs -sb` is
150
150
  * `git status -sb`. `args` is the raw remainder of the typed line, not the
151
- * tokenized parts, so the user's own quoting survives into `$SHELL -c`.
151
+ * tokenized parts, so the user's own quoting survives into `$SHELL -ic`.
152
152
  *
153
153
  * The `!` is what routes a bare value to the shell — the pit already reads a
154
154
  * leading `!` as "run this in $SHELL", so an alias does not need a second path
package/src/commands.mjs CHANGED
@@ -20,6 +20,7 @@ import { cliVerb, aiVerb } from "./cli.mjs";
20
20
  import { ingestApproval, pollApproval } from "./notify.mjs";
21
21
  import { capture, killSession, sendPrompt } from "./herd.mjs";
22
22
  import { herdStart, roster, waitFor } from "./herd-cli.mjs";
23
+ import { shellInvocation } from "./shell.mjs";
23
24
 
24
25
  // The moshcoding pit-anthem playlist. mosh() blasts this URL and, on a desktop
25
26
  // with a GUI, tries to open it in the default browser.
@@ -190,9 +191,9 @@ const COMMANDS = [
190
191
 
191
192
  {
192
193
  name: "shell",
193
- summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL -c elsewhere)",
194
+ summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL elsewhere)",
194
195
  usage: "shell(cmd)",
195
- detail: "runs cmd in $SHELL; returns { ok, code, signal }",
196
+ detail: "runs cmd in $SHELL, loading your rc file where it can; returns { ok, code, signal }",
196
197
  // The moshscript system verb for arbitrary shell commands. Blocking
197
198
  // (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
198
199
  // and the child owns the terminal for interactive commands. Returns
@@ -202,15 +203,16 @@ const COMMANDS = [
202
203
  const cmd = args.join(" ");
203
204
  if (!cmd) throw new Error("moshscript: shell() requires a command string");
204
205
  if (ctx.dryRun) {
205
- ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL -c ${JSON.stringify(cmd)}`);
206
+ ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL ${shellInvocation(cmd).flags} ${JSON.stringify(cmd)}`);
206
207
  // Same R8 contract as the comment above: `code` is always present, so a
207
208
  // script branching on the exit status behaves the same under --dry-run.
208
209
  return { ok: true, code: 0, dryRun: true };
209
210
  }
210
- const sh = process.platform === "win32"
211
- ? (process.env.COMSPEC || "cmd.exe")
212
- : (process.env.SHELL || "/bin/sh");
213
- const shArgs = process.platform === "win32" ? ["/d", "/s", "/c", cmd] : ["-c", cmd];
211
+ // Same invocation the pit's own `!cmd` uses, so a command that works when
212
+ // typed works when scripted: interactive where a terminal is attached, so
213
+ // the user's rc file — and the aliases in it — are loaded. src/shell.mjs
214
+ // has the reasoning, including why a headless run stays non-interactive.
215
+ const { shell: sh, args: shArgs } = shellInvocation(cmd);
214
216
  ctx.out(` ▶ shell: ${cmd}`);
215
217
  const res = spawnSync(sh, shArgs, { stdio: "inherit" });
216
218
  if (res.error) throw res.error;
package/src/escalate.mjs CHANGED
@@ -27,6 +27,22 @@ function defaultProbe(tool) {
27
27
  return spawnSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }).status === 0;
28
28
  }
29
29
 
30
+ /**
31
+ * How to ask a helper to cache a credential without running anything real.
32
+ *
33
+ * `sudo -v` exists for exactly this: validate, refresh the timestamp, run no
34
+ * command. doas has no equivalent flag, so it gets the smallest possible real
35
+ * command instead — the point is only to make it prompt.
36
+ */
37
+ function primeArgs(tool) {
38
+ return tool === "sudo" ? ["-v"] : ["true"];
39
+ }
40
+
41
+ /** `-n` is "never prompt" in both, so a zero exit means a credential is ready. */
42
+ function alreadyCached(tool, spawn) {
43
+ return spawn(tool, ["-n", "true"], { stdio: "ignore" })?.status === 0;
44
+ }
45
+
30
46
  /**
31
47
  * Which escalation helper this machine has, honouring an explicit override.
32
48
  * Returns null when there is none — a container running as a non-root user
@@ -42,6 +58,82 @@ export function findEscalator({ env = process.env, probe = defaultProbe } = {})
42
58
  return null;
43
59
  }
44
60
 
61
+ /**
62
+ * Does installing or upgrading this entry need root *on this machine*?
63
+ *
64
+ * A spec says `needsRoot: true` when it always does, or `{ except: [...] }` when
65
+ * a platform is the exception. tailscale is the reason for the second form: its
66
+ * script goes through the distro package manager on Linux and delegates to the
67
+ * App Store on macOS, where nothing escalates. Without the distinction, every
68
+ * mac running `moshcode update` would be asked for a password by a step that
69
+ * never wanted one — which is the same bug as prompting halfway through, just
70
+ * earlier and more annoying.
71
+ */
72
+ export function needsRootHere(entry, platform = process.platform) {
73
+ const spec = entry?.needsRoot;
74
+ if (!spec) return false;
75
+ if (spec === true) return true;
76
+ if (Array.isArray(spec)) return spec.includes(platform);
77
+ if (Array.isArray(spec.except)) return !spec.except.includes(platform);
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Ask for the password now, for a step that will need root later.
83
+ *
84
+ * Some installers escalate on their own partway through their own work —
85
+ * tailscale's goes through the distro package manager, so it calls sudo after
86
+ * refreshing package lists. That is fine when it is the only thing running and
87
+ * miserable inside `moshcode update`, which walks a plan: moshcode itself, then
88
+ * every installed engine, then every tool. The operator sees a long stream of
89
+ * downloads scroll past, looks away, and comes back to a run that has been
90
+ * parked on a password prompt — or worse, to sudo's own timeout having failed
91
+ * the step. The work is not interactive, so nobody is watching the one moment
92
+ * that is.
93
+ *
94
+ * So we prompt before starting instead. sudo caches the credential against the
95
+ * terminal, and every installer we hand off to inherits that same terminal, so
96
+ * the escalation they do later finds it already there and never asks.
97
+ *
98
+ * Returns `{ primed, tool, reason }` and never throws. `primed: false` is not
99
+ * fatal anywhere it is called: the caller carries on and the installer prompts
100
+ * whenever it was going to, which is exactly the old behaviour. Being unable to
101
+ * ask early is a missed convenience, not a reason to refuse to install.
102
+ */
103
+ export function primeEscalation({
104
+ what = "this",
105
+ env = process.env,
106
+ isTTY = Boolean(process.stdin?.isTTY && process.stdout?.isTTY),
107
+ spawn = spawnSync,
108
+ probe = defaultProbe,
109
+ out = console.log,
110
+ getuid = typeof process.getuid === "function" ? process.getuid : null,
111
+ } = {}) {
112
+ // Already root — nothing to ask for, and nothing to ask with.
113
+ if (getuid && getuid() === 0) return { primed: true, tool: null, reason: "already-root" };
114
+ // No terminal means no prompt. Warming a credential here would either fail or
115
+ // hang a CI job on a password nobody can type, which is the thing this exists
116
+ // to prevent rather than to cause.
117
+ if (!isTTY) return { primed: false, tool: null, reason: "no-tty" };
118
+
119
+ const tool = findEscalator({ env, probe });
120
+ if (!tool) return { primed: false, tool: null, reason: "no-escalator" };
121
+
122
+ // Silence is the right outcome when a credential is already cached, or when
123
+ // this operator's rule is NOPASSWD. Printing "asking for your password" and
124
+ // then not asking reads as a bug.
125
+ if (alreadyCached(tool, spawn)) return { primed: true, tool, reason: "cached" };
126
+
127
+ out(`· ${what} needs root partway through — asking ${tool} for your password now, so it doesn't stop halfway.`);
128
+ const result = spawn(tool, primeArgs(tool), { stdio: "inherit" });
129
+ if (result?.error) return { primed: false, tool, reason: "spawn-failed" };
130
+ // A non-zero exit is a wrong password, a cancelled prompt, or an operator who
131
+ // is not in sudoers. All three mean "carry on unprimed" rather than "stop":
132
+ // the installer may well not need root on this machine at all.
133
+ if (result?.status !== 0) return { primed: false, tool, reason: "declined" };
134
+ return { primed: true, tool, reason: "prompted" };
135
+ }
136
+
45
137
  /**
46
138
  * Re-run this CLI's own argv under the escalation helper.
47
139
  *
package/src/shell.mjs ADDED
@@ -0,0 +1,134 @@
1
+ // One answer to "how does the pit run a shell command".
2
+ //
3
+ // The pit is not a shell, so everything it runs on the user's behalf goes out
4
+ // through $SHELL: `!cmd`, `/shell`, a shell-valued alias from /alias, and
5
+ // moshscript's shell(). The obvious spelling is `$SHELL -c "<cmd>"`, and it is
6
+ // wrong in a way that costs an afternoon to find. `zsh -c` and `bash -c` are
7
+ // non-interactive shells, and a non-interactive shell does not read ~/.zshrc or
8
+ // ~/.bashrc — so the aliases and functions defined there are simply not there:
9
+ //
10
+ // /alias set prs gh-prs-all → zsh -c gh-prs-all
11
+ // → zsh:1: command not found: gh-prs-all
12
+ //
13
+ // while the identical word works when typed at a prompt. That is a bug rather
14
+ // than a footnote, because naming a shell command is most of what /alias is
15
+ // for, and the shell commands people name are the ones they already named once
16
+ // in ~/.zsh_aliases. An alias that resolves at the prompt and not in the pit
17
+ // makes the pit look broken, and from the user's side it is.
18
+ //
19
+ // So we ask for an interactive shell. `-i` is the switch that makes bash and
20
+ // zsh read their rc file, and the rc file is where the user's shell actually
21
+ // lives. Anything already on PATH worked before and still works; what changes
22
+ // is that aliases and functions now resolve too.
23
+
24
+ /**
25
+ * Shells whose startup file is read only when the shell is interactive.
26
+ *
27
+ * Deliberately just bash and zsh. fish sources config.fish however it was
28
+ * started, so it needs nothing from us; plain sh/dash have no rc file to miss
29
+ * and `-i` would only buy them job-control machinery; and a shell we have not
30
+ * heard of is likelier to be harmed by an unexpected flag than helped by it.
31
+ * Being wrong here means running a command in a shell that cannot see the
32
+ * user's aliases, which is exactly where we started — so an unknown shell
33
+ * lands on the old behaviour rather than on a guess.
34
+ */
35
+ const RC_ON_INTERACTIVE = new Set(["bash", "zsh"]);
36
+
37
+ /**
38
+ * Shells that will give up job control if asked, and why we ask.
39
+ *
40
+ * `-i` buys the rc file and, unasked, brings job control with it. An interactive
41
+ * shell with job control makes itself a process group leader and takes the
42
+ * terminal — and when it hands it back, it hands it back to what it thinks the
43
+ * shell before it was. The pit is not a shell and does not play that game, so
44
+ * the terminal can be left belonging to a process group that has exited. The
45
+ * pit's very next write then takes SIGTTOU and the whole pit stops:
46
+ *
47
+ * · shell exited (code 0). back in the pit.
48
+ * [1] + 3034615 suspended (tty output) moshcode
49
+ *
50
+ * `+m` unsets MONITOR, so zsh reads the rc file and never touches the terminal's
51
+ * process group. Nothing is lost: job control exists to manage several jobs at a
52
+ * prompt, and this shell runs one command and exits. It also restores exactly
53
+ * the signal behaviour of the plain `-c` this replaced, where the command shared
54
+ * the pit's process group.
55
+ *
56
+ * bash is not in this set because it will not honour it — an interactive bash
57
+ * turns job control back on regardless of `+m`, which is measurable: `bash +m
58
+ * -ic 'case $- in *m*)…'` still reports `m`. Passing a flag that is ignored
59
+ * would only suggest a protection that is not there.
60
+ */
61
+ const NO_JOB_CONTROL = new Set(["zsh"]);
62
+
63
+ /** Set this to opt a session out of rc loading and get plain `-c` back. */
64
+ export const NO_RC_ENV = "MOSHCODE_SHELL_NO_RC";
65
+
66
+ /** Windows has no rc file in this sense; cmd.exe wants its own flag spelling. */
67
+ const CMD_FLAGS = ["/d", "/s", "/c"];
68
+
69
+ /** The shell the user runs, or the platform's fallback. */
70
+ export function shellPath(env = process.env, platform = process.platform) {
71
+ if (platform === "win32") return env.COMSPEC || "cmd.exe";
72
+ return env.SHELL || "/bin/sh";
73
+ }
74
+
75
+ /**
76
+ * `zsh` from `/usr/bin/zsh`, `bash` from `C:\...\bash.exe`.
77
+ *
78
+ * Both separators by hand rather than path.basename, which is bound to the
79
+ * platform the code is running on: it would leave a Windows path intact when
80
+ * asked on Linux, and this function is also asked about the other platform —
81
+ * shellInvocation takes `platform` as an option so the Windows branch can be
82
+ * tested from anywhere.
83
+ */
84
+ export function shellName(shell) {
85
+ const tail = String(shell || "").split(/[\\/]/).pop() || "";
86
+ return tail.replace(/\.exe$/i, "");
87
+ }
88
+
89
+ /**
90
+ * How to spawn `rawCmd`, as { shell, args, flags, interactive }.
91
+ *
92
+ * `rawCmd` empty means "a shell to sit in" — no args at all, which is already
93
+ * an interactive shell and already reads the rc file.
94
+ *
95
+ * `tty` is why this takes options rather than reading the world directly. An
96
+ * interactive bash with no terminal attached prints
97
+ *
98
+ * bash: cannot set terminal process group (…): Inappropriate ioctl for device
99
+ * bash: no job control in this shell
100
+ *
101
+ * on stderr before it runs a thing, which would turn every headless run — cron,
102
+ * CI, `moshcode run script.mosh` in a pipeline — into noise around the output
103
+ * someone is trying to read. With a terminal attached, both shells are silent.
104
+ * So the rc file is loaded where a person is watching, which is the case that
105
+ * wanted it, and a headless run keeps the old quiet behaviour. zsh alone would
106
+ * not need the guard; the guard is not worth splitting per shell for.
107
+ */
108
+ export function shellInvocation(rawCmd, {
109
+ env = process.env,
110
+ platform = process.platform,
111
+ tty = Boolean(process.stdin?.isTTY && process.stdout?.isTTY),
112
+ } = {}) {
113
+ const shell = shellPath(env, platform);
114
+ const name = shellName(shell);
115
+ if (!rawCmd) return { shell, args: [], flags: "", interactive: true, name };
116
+ if (platform === "win32") {
117
+ return { shell, args: [...CMD_FLAGS, rawCmd], flags: CMD_FLAGS.join(" "), interactive: false, name };
118
+ }
119
+ const interactive = tty && RC_ON_INTERACTIVE.has(name) && !env[NO_RC_ENV];
120
+ if (!interactive) return { shell, args: ["-c", rawCmd], flags: "-c", interactive, name, jobControl: false };
121
+ // `+m` before `-ic`: options have to precede the command string, and this one
122
+ // is what keeps an interactive shell from taking the terminal's process group
123
+ // away from the pit. See NO_JOB_CONTROL.
124
+ const argv = NO_JOB_CONTROL.has(name) ? ["+m", "-ic"] : ["-ic"];
125
+ return {
126
+ shell,
127
+ args: [...argv, rawCmd],
128
+ flags: argv.join(" "),
129
+ interactive,
130
+ name,
131
+ // True only where we could not turn it off — bash forces it back on.
132
+ jobControl: !NO_JOB_CONTROL.has(name),
133
+ };
134
+ }
package/src/tools.mjs CHANGED
@@ -118,6 +118,17 @@ export const TOOLS = {
118
118
  // which means it needs root — it finds sudo/doas itself and may prompt for a
119
119
  // password (stdio is inherited, so the prompt works). On macOS the same
120
120
  // script delegates to the App Store.
121
+ //
122
+ // `needsRoot` is what lets us get that prompt out of the way before the work
123
+ // starts rather than partway through it. It says nothing about how the
124
+ // escalation happens — the vendor script still does its own — only that one
125
+ // is coming, which is all primeEscalation needs to know. `tailscale update`
126
+ // needs root for the same reason, so it covers both directions.
127
+ //
128
+ // macOS is the exception, and the same line above says why: there the script
129
+ // delegates to the App Store, which does its own authorisation. Asking for a
130
+ // sudo password there would be a prompt for a step that never escalates.
131
+ needsRoot: { except: ["darwin"] },
121
132
  install: { cmd: "sh", args: ["-c", "curl -fsSL https://tailscale.com/install.sh | sh"] },
122
133
  // Native updater on Linux (v1.36+) and Windows. macOS updates come from the
123
134
  // App Store, so there it fails with tailscale's own message rather than
package/src/tui.mjs CHANGED
@@ -25,6 +25,8 @@ import { stocksCommand } from "./advisor.mjs";
25
25
  import { cryptoCommand } from "./crypto.mjs";
26
26
  import { gamesCommand } from "./games.mjs";
27
27
  import { canOpenBrowser, openBrowser } from "./open-url.mjs";
28
+ import { shellInvocation } from "./shell.mjs";
29
+ import { needsRootHere, primeEscalation } from "./escalate.mjs";
28
30
  import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
29
31
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
30
32
  import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
@@ -128,7 +130,7 @@ export function splitCommandLine(line) {
128
130
  }
129
131
 
130
132
  // Everything after the first word of a command line, exactly as typed. `/shell`
131
- // hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does
133
+ // hands this straight to `$SHELL -ic`, the same way `!cmd` does: the shell does
132
134
  // its own parsing, so re-joining the tokenized parts would strip the user's
133
135
  // quotes and escapes and silently split `-m "two words"` into two arguments.
134
136
  function commandRemainder(line, words = 1) {
@@ -148,7 +150,7 @@ function commandRemainder(line, words = 1) {
148
150
  * quotes there belong to the shell. Tokenizing tells the two apart: exactly one
149
151
  * token means the whole value was quoted, so use it with the quotes stripped;
150
152
  * anything else is a bare command line, and it goes through verbatim so the
151
- * user's own quoting survives into `$SHELL -c`.
153
+ * user's own quoting survives into `$SHELL -ic`.
152
154
  */
153
155
  export function aliasValue(line) {
154
156
  const raw = commandRemainder(line, 3); // past "/alias", "set", "<name>"
@@ -508,12 +510,12 @@ async function openWorkflowTool(key, tool, args) {
508
510
 
509
511
  // Spawn the user's shell with the terminal fully handed over (stdio inherit),
510
512
  // inheriting the current cwd + env. No args → an interactive shell; a raw
511
- // command string → `$SHELL -c "<cmd>"` (one-off). Resolves { ok, code, signal }.
513
+ // command string → `$SHELL +m -ic "<cmd>"` (one-off). Interactive so the command
514
+ // can see the aliases and functions in ~/.zshrc — see src/shell.mjs for why
515
+ // that is not optional. Resolves { ok, code, signal }.
512
516
  function runShell(rawCmd) {
513
517
  return new Promise((resolve) => {
514
- const shell = process.env.SHELL
515
- || (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh");
516
- const args = rawCmd ? ["-c", rawCmd] : [];
518
+ const { shell, args } = shellInvocation(rawCmd);
517
519
  let child;
518
520
  try { child = spawn(shell, args, { stdio: "inherit" }); }
519
521
  catch (e) { resolve({ ok: false, error: e }); return; }
@@ -525,9 +527,12 @@ function runShell(rawCmd) {
525
527
  // vim `:sh` — drop into a shell and land back at the mosh prompt on exit, with
526
528
  // the whole TUI session (history, cwd) intact. `rawCmd` runs a one-off instead.
527
529
  async function openShell(rawCmd) {
528
- const shellName = path.basename(process.env.SHELL || "sh");
530
+ // The flags come from the same place the spawn does, so the echoed line is
531
+ // what actually ran — a `-c` printed above an `-ic` invocation is the kind of
532
+ // small lie that sends someone debugging the wrong shell.
533
+ const { flags, name: shellName } = shellInvocation(rawCmd);
529
534
  console.log(info(rawCmd
530
- ? `${bone(shellName)} ${ash("-c")} ${ash(rawCmd)}`
535
+ ? `${bone(shellName)} ${ash(flags)} ${ash(rawCmd)}`
531
536
  : `dropping to ${bone(shellName)} — ${ash("`exit` or Ctrl-D brings you back to the pit")}`));
532
537
  console.log(hr());
533
538
  const r = await runShell(rawCmd);
@@ -546,6 +551,9 @@ function installTarget(key) {
546
551
  const target = (Object.hasOwn(ENGINES, key) && ENGINES[key]) || (Object.hasOwn(TOOLS, key) && TOOLS[key]);
547
552
  if (!target) { console.log(err(`unknown engine or tool "${key}"`)); return resolve(); }
548
553
  console.log(info(`installing ${key}: ${target.install.cmd} ${target.install.args.join(" ")}`));
554
+ // Before the rule, so the prompt reads as the pit asking rather than as
555
+ // something the installer's output scrolled into view.
556
+ if (needsRootHere(target)) primeEscalation({ what: key, out: (s) => console.log(info(s.replace(/^· /, ""))) });
549
557
  console.log(hr());
550
558
  const child = spawn(target.install.cmd, target.install.args, { stdio: "inherit" });
551
559
  child.on("error", (e) => {
package/src/upgrade.mjs CHANGED
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import fs from "node:fs";
7
7
  import { ENGINES, engineStatus, exitReason, ranOk, resolveEngine, upgradeSpec, runCmd } from "./engines.mjs";
8
8
  import { TOOLS, resolveTool, toolStatus, toolUpgradeSpec } from "./tools.mjs";
9
+ import { needsRootHere, primeEscalation } from "./escalate.mjs";
9
10
 
10
11
  // Self-upgrade re-runs the moshcode installer's `update` path. Defaults to the
11
12
  // GitHub-hosted install.sh (always live); override with MOSHCODE_INSTALL_URL.
@@ -84,6 +85,7 @@ export function planUpgrade(targets = []) {
84
85
  // is something other than the installer, so a fallback can never repeat
85
86
  // the command that just failed.
86
87
  fallback: installed && upgradeSpec(ENGINES[key]) !== ENGINES[key].install ? ENGINES[key].install : null,
88
+ needsRoot: needsRootHere(ENGINES[key]),
87
89
  installed,
88
90
  });
89
91
  };
@@ -98,6 +100,7 @@ export function planUpgrade(targets = []) {
98
100
  kind: "tool",
99
101
  spec: installed ? toolUpgradeSpec(TOOLS[key]) : TOOLS[key].install,
100
102
  fallback: installed && toolUpgradeSpec(TOOLS[key]) !== TOOLS[key].install ? TOOLS[key].install : null,
103
+ needsRoot: needsRootHere(TOOLS[key]),
101
104
  installed,
102
105
  });
103
106
  };
@@ -167,6 +170,17 @@ export async function runUpgrade(targets = [], io = {}) {
167
170
 
168
171
  const exec = io.runCmd || runCmd;
169
172
 
173
+ // Get the password prompt out of the way before the first download rather than
174
+ // somewhere in the middle of the plan. A plan is long and unattended by
175
+ // design; the one interactive moment in it should not be buried where nobody
176
+ // is looking. Skipped entirely when nothing in the plan needs root, so the
177
+ // common `moshcode update` never asks.
178
+ const rootItems = items.filter((it) => it.needsRoot);
179
+ if (rootItems.length) {
180
+ const prime = io.primeEscalation || primeEscalation;
181
+ prime({ what: rootItems.map((it) => it.label).join(", "), out: log });
182
+ }
183
+
170
184
  const results = [];
171
185
  const attempt = async (name, spec, note) => {
172
186
  log(`\n⬆ upgrading ${name}${note ? ` ${note}` : ""} — ${spec.cmd} ${spec.args.join(" ")}`);