instabox 0.4.2 → 0.4.3

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.
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ // Stages the checked Environment items for the box, filtered, in a local
3
+ // directory laid out like the box's HOME, plus a setup script that merges
4
+ // them into what the box already has and installs the tools.
5
+ //
6
+ // node stage-env.mjs --selection selection.json --out DIR
7
+ // tar -C DIR -czf - . | instabox ssh BOX -- 'tar -xzf - --skip-old-files --warning=no-unknown-keyword -C ~'
8
+ // instabox ssh BOX -- 'bash -l ~/.config/instabox-move/setup.sh'
9
+ //
10
+ // Lines that look like credentials are replaced by a comment, macOS-only
11
+ // lines are commented out, and this machine's HOME is rewritten to the box's.
12
+ // Credentials themselves go to the Vault (push-creds.mjs), never here.
13
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync, readlinkSync, symlinkSync } from 'node:fs';
14
+ import { basename, dirname, extname, join } from 'node:path';
15
+ import {
16
+ BOX_HOME,
17
+ MACOS_ONLY,
18
+ SECRET_KEY_LINE,
19
+ SECRET_VALUE,
20
+ STAGE_DIR,
21
+ isBinary,
22
+ isMain,
23
+ isSecretName,
24
+ listFiles,
25
+ lstatOrNull,
26
+ readJSON,
27
+ } from './lib.mjs';
28
+ import { installCommands } from './tools.mjs';
29
+ import { SHELL_RC } from './scan.mjs';
30
+
31
+ const SHELL_BLOCK = /^\s*(?:if|elif|else|fi|for|while|until|do|done|case|esac|then|function\b|[A-Za-z_][\w-]*\s*\(\)\s*\{?|\{|\}|;;)|(?:\bthen|\bdo|\{|\()\s*(?:#.*)?$/;
32
+
33
+ function commentStyle(path) {
34
+ const b = basename(path);
35
+ const e = extname(path);
36
+ if (e === '.lua') return '--';
37
+ if (e === '.vim' || b === '.vimrc' || b === '.gvimrc') return '"';
38
+ if (b === '.psqlrc') return '--';
39
+ return '#';
40
+ }
41
+
42
+ // Filters one text file. Returns the new text and how many lines were changed.
43
+ export function scrubText(text, { path, home, shell = false }) {
44
+ const c = shell ? null : commentStyle(path);
45
+ let secrets = 0;
46
+ let macos = 0;
47
+ const out = text.split('\n').map((line) => {
48
+ const assign = line.match(/^\s*(?:export\s+|declare\s+-x\s+|typeset\s+-x\s+)?([A-Za-z_][A-Za-z0-9_]*)=/);
49
+ const secret =
50
+ SECRET_VALUE.test(line) ||
51
+ (shell && assign && isSecretName(assign[1])) ||
52
+ (!shell && SECRET_KEY_LINE.test(line) && !/^\s*(?:#|;|--|")/.test(line));
53
+ if (secret) {
54
+ secrets++;
55
+ const what = assign ? ` ${assign[1]}` : '';
56
+ // `:` keeps an if/for body valid when its only line goes.
57
+ return shell
58
+ ? `: # [instabox-move-all] removed${what}: looked like a credential (it is in the Vault if you moved it)`
59
+ : `${c} [instabox-move-all] removed a line that looked like a credential`;
60
+ }
61
+ if (MACOS_ONLY.test(line) && !/^\s*(?:#|--|")/.test(line)) {
62
+ if (shell && SHELL_BLOCK.test(line)) return line.split(home).join(BOX_HOME); // a condition on a macOS path is just false here
63
+ macos++;
64
+ return shell ? `: # [macOS only] ${line.trim()}` : `${c} [macOS only] ${line.trim()}`;
65
+ }
66
+ return line.split(home).join(BOX_HOME);
67
+ });
68
+ return { text: out.join('\n'), secrets, macos };
69
+ }
70
+
71
+ // Deep copy of a JSON value with this HOME rewritten, credentials and macOS-only commands dropped.
72
+ function scrubJSON(v, home, drop) {
73
+ if (typeof v === 'string') return v.split(home).join(BOX_HOME);
74
+ if (Array.isArray(v)) return v.map((x) => scrubJSON(x, home, drop)).filter((x) => x !== undefined);
75
+ if (v && typeof v === 'object') {
76
+ const o = {};
77
+ for (const [k, x] of Object.entries(v)) {
78
+ if (drop(k, x)) continue;
79
+ const y = scrubJSON(x, home, drop);
80
+ if (y !== undefined) o[k] = y;
81
+ }
82
+ return o;
83
+ }
84
+ return v;
85
+ }
86
+
87
+ const secretString = (k, x) => typeof x === 'string' && (SECRET_VALUE.test(x) || isSecretName(k));
88
+
89
+ export function scrubClaudeSettings(s, home) {
90
+ const notes = [];
91
+ if (s.apiKeyHelper) notes.push('apiKeyHelper left out (a local script): set ANTHROPIC_API_KEY in the Vault instead');
92
+ const copy = { ...s };
93
+ delete copy.apiKeyHelper;
94
+ const out = scrubJSON(copy, home, (k, x) => {
95
+ if (secretString(k, x)) return true;
96
+ // Hooks and status lines that call macOS-only programs.
97
+ if (k === 'command' && typeof x === 'string' && MACOS_ONLY.test(x)) {
98
+ notes.push(`left out a macOS-only command: ${x.slice(0, 60)}`);
99
+ return true;
100
+ }
101
+ return false;
102
+ });
103
+ // A hook entry whose command was dropped is dropped too.
104
+ if (out.hooks) {
105
+ for (const [ev, list] of Object.entries(out.hooks)) {
106
+ if (!Array.isArray(list)) continue;
107
+ out.hooks[ev] = list
108
+ .map((m) => (m && Array.isArray(m.hooks) ? { ...m, hooks: m.hooks.filter((h) => h.command || h.type !== 'command') } : m))
109
+ .filter((m) => !m || !Array.isArray(m.hooks) || m.hooks.length);
110
+ if (!out.hooks[ev].length) delete out.hooks[ev];
111
+ }
112
+ if (!Object.keys(out.hooks).length) delete out.hooks;
113
+ }
114
+ if (out.statusLine && !out.statusLine.command) delete out.statusLine;
115
+ return { settings: out, notes };
116
+ }
117
+
118
+ export function scrubMcp(servers, home) {
119
+ const out = {};
120
+ for (const [name, s] of Object.entries(servers || {})) {
121
+ if (!s.command) continue; // remote servers are signed in to again on the box
122
+ const c = scrubJSON(s, home, (k, x) => secretString(k, x));
123
+ // A Homebrew path on macOS is on PATH on the box under the same name.
124
+ if (/^\/(?:opt\/homebrew|usr\/local)\/bin\//.test(c.command)) c.command = basename(c.command);
125
+ out[name] = c;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
131
+
132
+ export function stage(sel, outDir) {
133
+ const home = sel.home;
134
+ const files = [];
135
+ const filtered = {};
136
+ const notes = [];
137
+ const write = (rel, data, mode = 0o644) => {
138
+ const p = join(outDir, rel);
139
+ mkdirSync(dirname(p), { recursive: true });
140
+ writeFileSync(p, data);
141
+ chmodSync(p, mode);
142
+ files.push(rel);
143
+ };
144
+ const copyText = (src, rel, { shell = false } = {}) => {
145
+ const st = lstatOrNull(src);
146
+ if (!st) return;
147
+ if (st.isSymbolicLink()) {
148
+ const t = readlinkSync(src).split(home).join(BOX_HOME);
149
+ const p = join(outDir, rel);
150
+ mkdirSync(dirname(p), { recursive: true });
151
+ symlinkSync(t, p);
152
+ files.push(rel);
153
+ return;
154
+ }
155
+ const buf = readFileSync(src);
156
+ const mode = st.mode & 0o777;
157
+ if (isBinary(buf)) return write(rel, buf, mode);
158
+ const r = scrubText(buf.toString('utf8'), { path: src, home, shell });
159
+ if (r.secrets || r.macos) filtered[rel] = { credentialLines: r.secrets, macosLines: r.macos };
160
+ write(rel, r.text, mode);
161
+ };
162
+ const copyTree = (srcDir, rel) => {
163
+ for (const f of listFiles(srcDir, (n) => n === '.git' || n === 'node_modules' || n === '.DS_Store')) copyText(join(srcDir, f), `${rel}/${f}`);
164
+ };
165
+ const S = STAGE_DIR;
166
+ const setup = [];
167
+ const once = []; // marker-guarded steps
168
+
169
+ const shellFiles = [];
170
+ for (const e of sel.environment) {
171
+ if (e.kind === 'dotfile') {
172
+ const src = join(home, e.path);
173
+ if (SHELL_RC.includes(e.path)) {
174
+ const name = e.path.replace(/^\./, '');
175
+ copyText(src, `${S}/shell/${name}`, { shell: true });
176
+ shellFiles.push(e.path);
177
+ } else if (e.path === '.gitconfig' || e.path === '.config/git/config') {
178
+ copyText(src, `${S}/${e.path === '.gitconfig' ? 'gitconfig' : 'git-config'}`);
179
+ const inc = `${BOX_HOME}/${S}/${e.path === '.gitconfig' ? 'gitconfig' : 'git-config'}`;
180
+ setup.push([
181
+ 'gitconfig include',
182
+ `git config --global --get-all include.path | grep -qxF ${q(inc)} || git config --global --add include.path ${q(inc)}`,
183
+ ]);
184
+ } else if (e.dir) copyTree(src, e.path);
185
+ else copyText(src, e.path);
186
+ } else if (e.id === 'env:claude:settings') {
187
+ const s = readJSON(join(home, '.claude', 'settings.json'));
188
+ if (s) {
189
+ const r = scrubClaudeSettings(s, home);
190
+ notes.push(...r.notes);
191
+ write(`${S}/claude/settings.json`, JSON.stringify(r.settings, null, 2) + '\n');
192
+ setup.push([
193
+ 'Claude settings merge',
194
+ `mkdir -p ~/.claude && if [ -f ~/.claude/settings.json ]; then jq -s '.[0] * .[1]' ~/.claude/settings.json ~/${S}/claude/settings.json > ~/.claude/settings.json.new && mv ~/.claude/settings.json.new ~/.claude/settings.json; else cp ~/${S}/claude/settings.json ~/.claude/settings.json; fi`,
195
+ ]);
196
+ }
197
+ } else if (e.id === 'env:claude:CLAUDE.md' || e.id === 'env:codex:AGENTS.md') {
198
+ const staged = `${S}/${e.id === 'env:claude:CLAUDE.md' ? 'claude/CLAUDE.md' : 'codex/AGENTS.md'}`;
199
+ copyText(join(home, e.path), staged);
200
+ once.push([
201
+ e.id,
202
+ `mkdir -p ~/${dirname(e.path)} && if [ -s ~/${e.path} ]; then printf '\\n' >> ~/${e.path} && cat ~/${staged} >> ~/${e.path}; else cp ~/${staged} ~/${e.path}; fi`,
203
+ ]);
204
+ } else if (e.id === 'env:codex:config') {
205
+ copyText(join(home, e.path), `${S}/codex/config.toml`);
206
+ setup.push([
207
+ 'Codex config',
208
+ `mkdir -p ~/.codex && if [ -e ~/.codex/config.toml ]; then echo "kept the box's ~/.codex/config.toml; yours is in ~/${S}/codex/config.toml"; else cp ~/${S}/codex/config.toml ~/.codex/config.toml; fi`,
209
+ ]);
210
+ } else if (e.id === 'env:claude:mcp') {
211
+ const mcp = scrubMcp(readJSON(join(home, '.claude.json'))?.mcpServers, home);
212
+ write(`${S}/claude/mcp.json`, JSON.stringify(mcp, null, 2) + '\n');
213
+ for (const name of Object.keys(mcp).sort())
214
+ setup.push([
215
+ `MCP server ${name}`,
216
+ `claude mcp get ${q(name)} >/dev/null 2>&1 || claude mcp add-json --scope user ${q(name)} "$(jq -c --arg n ${q(name)} '.[$n]' ~/${S}/claude/mcp.json)"`,
217
+ ]);
218
+ } else if (e.names && (e.kind === 'claude' || e.kind === 'agents')) {
219
+ for (const n of e.names) {
220
+ const src = join(home, e.path, n);
221
+ if (lstatOrNull(src)?.isDirectory()) copyTree(src, `${e.path}/${n}`);
222
+ else copyText(src, `${e.path}/${n}`);
223
+ }
224
+ }
225
+ }
226
+
227
+ // Shell: the box keeps its own startup files and sources yours from them.
228
+ const bashSide = shellFiles.filter((f) => !f.startsWith('.z')).map((f) => `~/${S}/shell/${f.replace(/^\./, '')}`);
229
+ if (bashSide.length)
230
+ once.push([
231
+ 'bashrc',
232
+ `cat >> ~/.bashrc <<'EOF'\n\n# instabox-move-all: the shell setup from your old machine\nif [ -z "\${__instabox_moved_rc-}" ]; then __instabox_moved_rc=1; for f in ${bashSide.join(' ')}; do [ -r "$f" ] && . "$f"; done; fi\nEOF`,
233
+ ]);
234
+ const zsh = shellFiles.filter((f) => f.startsWith('.z'));
235
+ const toolSel = { apt: [], npm: [], uv: [], cargo: [], go: [], ...(sel.tools || {}) };
236
+ if (zsh.length) {
237
+ toolSel.apt = [...new Set([...toolSel.apt, 'zsh'])].sort();
238
+ for (const f of zsh) {
239
+ const staged = `~/${S}/shell/${f.replace(/^\./, '')}`;
240
+ if (f === '.zprofile') continue;
241
+ setup.push([`~/${f}`, `[ -e ~/${f} ] || cp ${staged} ~/${f}`]);
242
+ }
243
+ // zsh does not read /etc/profile, where the box's environment and the Vault's variables come from.
244
+ setup.push([
245
+ '~/.zprofile',
246
+ `[ -e ~/.zprofile ] || { echo "emulate sh -c '. /etc/profile' # instabox-move-all: the box's environment and Vault"; cat ~/${S}/shell/zprofile 2>/dev/null || true; } > ~/.zprofile`,
247
+ ]);
248
+ notes.push('zsh is installed but bash stays the login shell; switch with: sudo chsh -s "$(command -v zsh)" dev');
249
+ }
250
+
251
+ // .env files from the Vault, linked into their repos.
252
+ for (const c of sel.credentials) {
253
+ if (!c.link) continue;
254
+ setup.push([
255
+ `link ${c.link}`,
256
+ `mkdir -p ${q(dirname(c.link))} && { [ -e ${q(c.link)} ] && [ ! -L ${q(c.link)} ] && echo ${q(`kept the existing ${c.link}`)} || ln -sfn ${q(`${BOX_HOME}/${c.path}`)} ${q(c.link)}; }`,
257
+ ]);
258
+ }
259
+
260
+ const tools = installCommands(toolSel);
261
+ const script = [
262
+ '#!/usr/bin/env bash',
263
+ '# Written by instabox-move-all (stage-env.mjs). Safe to run again.',
264
+ 'set -u',
265
+ `S=~/${S}`,
266
+ '# Every failed step is collected and reported at the end; the script then exits 1.',
267
+ 'failed=()',
268
+ 'fail() { failed+=("$1"); echo "instabox-move-all: failed: $1" >&2; }',
269
+ 'step() { eval "$2" || fail "$1"; }',
270
+ 'once() { [ -e "$S/.done-$1" ] && return 0; if eval "$2"; then touch "$S/.done-$1"; else fail "$1"; fi; }',
271
+ '',
272
+ '# Config',
273
+ ...setup.map(([label, cmd]) => `step ${q(label)} ${q(cmd)}`),
274
+ ...once.map(([k, cmd]) => `once ${q(k.replace(/[^A-Za-z0-9.-]/g, '-'))} ${q(cmd)}`),
275
+ '',
276
+ '# Tools (fresh Linux installs; nothing copied from macOS)',
277
+ ...tools,
278
+ '',
279
+ 'if [ ${#failed[@]} -gt 0 ]; then',
280
+ ' printf \'instabox-move-all: %d step(s) failed:\\n\' "${#failed[@]}" >&2',
281
+ ' printf \' %s\\n\' "${failed[@]}" >&2',
282
+ ' exit 1',
283
+ 'fi',
284
+ 'echo "instabox-move-all: environment set up"',
285
+ '',
286
+ ].join('\n');
287
+ write(`${S}/setup.sh`, script, 0o755);
288
+ if (lstatOrNull(join(outDir, '.ssh'))) chmodSync(join(outDir, '.ssh'), 0o700);
289
+ return { out: outDir, files: files.sort(), filtered, notes, setup: `~/${S}/setup.sh` };
290
+ }
291
+
292
+ function main() {
293
+ const args = process.argv.slice(2);
294
+ const get = (n) => {
295
+ const i = args.indexOf(n);
296
+ return i >= 0 ? args[i + 1] : undefined;
297
+ };
298
+ const selFile = get('--selection');
299
+ const out = get('--out');
300
+ if (!selFile || !out || args.length !== 4) {
301
+ process.stderr.write('usage: stage-env.mjs --selection selection.json --out DIR\n');
302
+ process.exit(2);
303
+ }
304
+ const sel = JSON.parse(readFileSync(selFile, 'utf8'));
305
+ if (lstatOrNull(out) && listFiles(out).length) {
306
+ process.stderr.write(`${out} is not empty: give a new directory\n`);
307
+ process.exit(1);
308
+ }
309
+ mkdirSync(out, { recursive: true, mode: 0o700 });
310
+ process.stdout.write(JSON.stringify(stage(sel, out), null, 2) + '\n');
311
+ }
312
+
313
+ if (isMain(import.meta)) main();
@@ -0,0 +1,173 @@
1
+ // Global tools from this machine, as install commands for the Debian box.
2
+ // Never copies binaries: everything is installed fresh for Linux.
3
+
4
+ // Homebrew formula -> Debian package. null: already on the universal template.
5
+ // A string starting with 'uv:' or 'npm:' installs with that tool instead.
6
+ export const BREW_TO_LINUX = {
7
+ git: null,
8
+ gh: null,
9
+ node: null,
10
+ nvm: null,
11
+ python: null,
12
+ 'python@3.10': null,
13
+ 'python@3.11': null,
14
+ 'python@3.12': null,
15
+ 'python@3.13': null,
16
+ 'python@3.14': null,
17
+ go: null,
18
+ rust: null,
19
+ rustup: null,
20
+ 'rustup-init': null,
21
+ uv: null,
22
+ docker: null,
23
+ 'docker-compose': null,
24
+ 'docker-buildx': null,
25
+ curl: null,
26
+ jq: null,
27
+ openssl: null,
28
+ 'openssl@3': null,
29
+ coreutils: null,
30
+ findutils: null,
31
+ 'gnu-sed': null,
32
+ 'gnu-tar': null,
33
+ grep: null,
34
+ gawk: 'gawk',
35
+ ripgrep: 'ripgrep',
36
+ fd: 'fd-find',
37
+ fzf: 'fzf',
38
+ bat: 'bat',
39
+ eza: 'eza',
40
+ tree: 'tree',
41
+ htop: 'htop',
42
+ btop: 'btop',
43
+ tmux: 'tmux',
44
+ screen: 'screen',
45
+ zsh: 'zsh',
46
+ fish: 'fish',
47
+ neovim: 'neovim',
48
+ vim: 'vim',
49
+ emacs: 'emacs-nox',
50
+ nano: 'nano',
51
+ wget: 'wget',
52
+ watch: 'procps',
53
+ 'git-lfs': 'git-lfs',
54
+ 'git-delta': 'git-delta',
55
+ tig: 'tig',
56
+ lazygit: null,
57
+ direnv: 'direnv',
58
+ zoxide: 'zoxide',
59
+ shellcheck: 'shellcheck',
60
+ shfmt: 'shfmt',
61
+ cmake: 'cmake',
62
+ ninja: 'ninja-build',
63
+ 'pkg-config': 'pkg-config',
64
+ pkgconf: 'pkgconf',
65
+ autoconf: 'autoconf',
66
+ automake: 'automake',
67
+ libtool: 'libtool',
68
+ protobuf: 'protobuf-compiler',
69
+ sqlite: 'sqlite3',
70
+ postgresql: 'postgresql-client',
71
+ 'postgresql@14': 'postgresql-client',
72
+ 'postgresql@15': 'postgresql-client',
73
+ 'postgresql@16': 'postgresql-client',
74
+ 'postgresql@17': 'postgresql-client',
75
+ libpq: 'libpq-dev',
76
+ mysql: 'default-mysql-client',
77
+ 'mysql-client': 'default-mysql-client',
78
+ redis: 'redis-tools',
79
+ graphviz: 'graphviz',
80
+ ffmpeg: 'ffmpeg',
81
+ imagemagick: 'imagemagick',
82
+ pandoc: 'pandoc',
83
+ httpie: 'httpie',
84
+ p7zip: '7zip',
85
+ 'xz': 'xz-utils',
86
+ zstd: 'zstd',
87
+ rsync: 'rsync',
88
+ mosh: 'mosh',
89
+ gnupg: 'gnupg',
90
+ 'pinentry-mac': null,
91
+ mas: null,
92
+ awscli: 'uv:awscli',
93
+ pipx: 'pipx',
94
+ poetry: 'uv:poetry',
95
+ ruff: 'uv:ruff',
96
+ black: 'uv:black',
97
+ 'pre-commit': 'uv:pre-commit',
98
+ pnpm: 'npm:pnpm',
99
+ yarn: 'npm:yarn',
100
+ typescript: 'npm:typescript',
101
+ ruby: 'ruby-full',
102
+ 'openjdk': 'default-jdk',
103
+ maven: 'maven',
104
+ gradle: 'gradle',
105
+ hugo: 'hugo',
106
+ yq: 'yq',
107
+ 'just': 'just',
108
+ };
109
+
110
+ // npm globals the box has already.
111
+ const NPM_SKIP = new Set(['npm', 'corepack', '@anthropic-ai/claude-code', '@openai/codex', 'open-computer-use', 'instabox']);
112
+
113
+ // One group from scan.mjs (tool:brew, tool:npm, ...) -> {apt, npm, uv, cargo, go, manual}.
114
+ export function mapTools(groups) {
115
+ const out = { apt: new Set(), npm: new Set(), uv: new Set(), cargo: new Set(), go: new Set(), manual: [] };
116
+ for (const g of groups) {
117
+ if (g.manager === 'brew') {
118
+ for (const n of g.names) {
119
+ if (!(n in BREW_TO_LINUX)) {
120
+ out.manual.push(`brew ${n}: no automatic Linux package; install it by hand if you need it`);
121
+ continue;
122
+ }
123
+ const t = BREW_TO_LINUX[n];
124
+ if (t === null) continue;
125
+ if (t.startsWith('uv:')) out.uv.add(t.slice(3));
126
+ else if (t.startsWith('npm:')) out.npm.add(t.slice(4));
127
+ else out.apt.add(t);
128
+ }
129
+ } else if (g.manager === 'npm') {
130
+ for (const n of g.names) if (!NPM_SKIP.has(n)) out.npm.add(n);
131
+ } else if (g.manager === 'uv') {
132
+ for (const n of g.names) out.uv.add(n);
133
+ } else if (g.manager === 'cargo') {
134
+ for (const n of g.names) out.cargo.add(n);
135
+ } else if (g.manager === 'go') {
136
+ for (const n of g.names) out.go.add(n);
137
+ for (const n of g.unknown || []) out.manual.push(`~/go/bin/${n}: module unknown; \`go install <module>@latest\` on the box`);
138
+ }
139
+ }
140
+ const sorted = (s) => [...s].sort();
141
+ return { apt: sorted(out.apt), npm: sorted(out.npm), uv: sorted(out.uv), cargo: sorted(out.cargo), go: sorted(out.go), manual: out.manual };
142
+ }
143
+
144
+ // A one-line preview of what a tool group becomes on the box.
145
+ export function describeTools(g) {
146
+ const m = mapTools([g]);
147
+ const parts = [];
148
+ if (m.apt.length) parts.push(`apt: ${m.apt.join(' ')}`);
149
+ if (m.npm.length) parts.push(`npm -g: ${m.npm.join(' ')}`);
150
+ if (m.uv.length) parts.push(`uv tool: ${m.uv.join(' ')}`);
151
+ if (m.cargo.length) parts.push(`cargo install: ${m.cargo.join(' ')}`);
152
+ if (m.go.length) parts.push(`go install: ${m.go.join(' ')}`);
153
+ if (m.manual.length) parts.push(`by hand: ${m.manual.map((s) => s.split(':')[0]).join(', ')}`);
154
+ return parts.join('; ') || 'nothing to install (already on the box)';
155
+ }
156
+
157
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
158
+
159
+ // Shell lines that install the tools on the box (run as dev, with sudo).
160
+ // Lines for setup.sh, which defines `fail` (collects a failed step for the final report).
161
+ export function installCommands(m) {
162
+ const cmds = [];
163
+ if (m.apt.length)
164
+ cmds.push(
165
+ `sudo apt-get update -qq || fail 'apt-get update'; for p in ${m.apt.map(q).join(' ')}; do sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$p" || fail "apt: $p"; done`,
166
+ );
167
+ // The box's npm prefix is /usr/local, owned by root.
168
+ if (m.npm.length) cmds.push(`sudo npm install -g ${m.npm.map(q).join(' ')} || fail ${q(`npm -g: ${m.npm.join(' ')}`)}`);
169
+ for (const n of m.uv) cmds.push(`uv tool install ${q(n)} || fail ${q(`uv tool: ${n}`)}`);
170
+ for (const n of m.go) cmds.push(`go install ${q(`${n}@latest`)} || fail ${q(`go: ${n}`)}`);
171
+ for (const n of m.cargo) cmds.push(`cargo install --locked ${q(n)} || fail ${q(`cargo: ${n}`)}`);
172
+ return cmds;
173
+ }