moshcode 0.52.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 +7 -1
- package/bin/moshcode.mjs +4 -0
- package/package.json +1 -1
- package/src/escalate.mjs +92 -0
- package/src/shell.mjs +40 -2
- package/src/tools.mjs +11 -0
- package/src/tui.mjs +5 -1
- package/src/upgrade.mjs +14 -0
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 -ic`, so your rc file loads); 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
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
CHANGED
|
@@ -34,6 +34,32 @@
|
|
|
34
34
|
*/
|
|
35
35
|
const RC_ON_INTERACTIVE = new Set(["bash", "zsh"]);
|
|
36
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
|
+
|
|
37
63
|
/** Set this to opt a session out of rc loading and get plain `-c` back. */
|
|
38
64
|
export const NO_RC_ENV = "MOSHCODE_SHELL_NO_RC";
|
|
39
65
|
|
|
@@ -91,6 +117,18 @@ export function shellInvocation(rawCmd, {
|
|
|
91
117
|
return { shell, args: [...CMD_FLAGS, rawCmd], flags: CMD_FLAGS.join(" "), interactive: false, name };
|
|
92
118
|
}
|
|
93
119
|
const interactive = tty && RC_ON_INTERACTIVE.has(name) && !env[NO_RC_ENV];
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
};
|
|
96
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
|
@@ -26,6 +26,7 @@ import { cryptoCommand } from "./crypto.mjs";
|
|
|
26
26
|
import { gamesCommand } from "./games.mjs";
|
|
27
27
|
import { canOpenBrowser, openBrowser } from "./open-url.mjs";
|
|
28
28
|
import { shellInvocation } from "./shell.mjs";
|
|
29
|
+
import { needsRootHere, primeEscalation } from "./escalate.mjs";
|
|
29
30
|
import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
|
|
30
31
|
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
|
|
31
32
|
import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
|
|
@@ -509,7 +510,7 @@ async function openWorkflowTool(key, tool, args) {
|
|
|
509
510
|
|
|
510
511
|
// Spawn the user's shell with the terminal fully handed over (stdio inherit),
|
|
511
512
|
// inheriting the current cwd + env. No args → an interactive shell; a raw
|
|
512
|
-
// command string → `$SHELL -ic "<cmd>"` (one-off). Interactive so the command
|
|
513
|
+
// command string → `$SHELL +m -ic "<cmd>"` (one-off). Interactive so the command
|
|
513
514
|
// can see the aliases and functions in ~/.zshrc — see src/shell.mjs for why
|
|
514
515
|
// that is not optional. Resolves { ok, code, signal }.
|
|
515
516
|
function runShell(rawCmd) {
|
|
@@ -550,6 +551,9 @@ function installTarget(key) {
|
|
|
550
551
|
const target = (Object.hasOwn(ENGINES, key) && ENGINES[key]) || (Object.hasOwn(TOOLS, key) && TOOLS[key]);
|
|
551
552
|
if (!target) { console.log(err(`unknown engine or tool "${key}"`)); return resolve(); }
|
|
552
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(/^· /, ""))) });
|
|
553
557
|
console.log(hr());
|
|
554
558
|
const child = spawn(target.install.cmd, target.install.args, { stdio: "inherit" });
|
|
555
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(" ")}`);
|