@8bitscript/cli 0.1.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.
@@ -0,0 +1,510 @@
1
+ // `8bs setup cx16` — get a machine from nothing to a working `8bs run cx16`:
2
+ // the mos-cx16-clang compiler (checked, not installed — docs/setup/llvm-mos.md),
3
+ // x16emu and makecart built from X16Community/x16-emulator, a *matching*
4
+ // rom.bin built from X16Community/x16-rom (upstream is explicit that the
5
+ // emulator expects a contemporary ROM, so both are always built together
6
+ // from current source), all installed under /opt/commander-x16 with
7
+ // launchers in /usr/local/bin.
8
+ //
9
+ // Every step is idempotent. A complete installation is recognised up front
10
+ // and skips the dependency/build/install stages entirely; a broken launcher
11
+ // is repaired without rebuilding anything; `--update` forces a fresh
12
+ // pull+build+install of the pair.
13
+ //
14
+ // Platform strategies are explicit (CX16_PLATFORMS below) because one of
15
+ // them is a tested trap: on macOS, x16emu resolves its default rom.bin
16
+ // relative to the path it was invoked through, so the Linux-style
17
+ // `/usr/local/bin/x16emu -> /opt/commander-x16/x16emu` symlink fails with
18
+ // "Cannot open /usr/local/bin/rom.bin!". macOS therefore gets a wrapper
19
+ // script that passes `-rom /opt/commander-x16/rom.bin` explicitly; Linux
20
+ // keeps the symlink, which resolves the ROM correctly there.
21
+ import {
22
+ access, lstat, mkdir, readFile, readlink, stat, writeFile,
23
+ } from 'node:fs/promises';
24
+ import { constants as fsConstants } from 'node:fs';
25
+ import { join } from 'node:path';
26
+
27
+ import { execCapture, execInherit, sudoRun } from './exec.mjs';
28
+ import { reportStep, reportLine } from './report.mjs';
29
+ import { confirm, canPromptInteractively } from './prompt.mjs';
30
+ import {
31
+ CX16_BREW_PACKAGES, CX16_PACMAN_PACKAGES, CX16_AUR_TOOLS,
32
+ missingBrewPackages, installBrewPackages, missingPacmanPackages, installPacmanPackages, missingPathTools,
33
+ } from './deps.mjs';
34
+ import {
35
+ hasBinaryOnPath, isDirOnPath, hasXcodeCommandLineTools, installXcodeCommandLineTools,
36
+ } from './host.mjs';
37
+ import { syncRepository, runBuild, pathExists } from './source.mjs';
38
+ import { ensureDirectory, installFiles } from './install.mjs';
39
+ import { ensureLauncher, inspectLauncher } from './launcher.mjs';
40
+ import {
41
+ x16EmulatorSourceDir, x16RomSourceDir, cx16WorkDir,
42
+ CX16_INSTALL_DIR, X16EMU_INSTALL_PATH, MAKECART_INSTALL_PATH, CX16_ROM_INSTALL_PATH,
43
+ LOCAL_BIN_DIR, X16EMU_LAUNCHER_PATH, MAKECART_LAUNCHER_PATH,
44
+ } from './paths.mjs';
45
+
46
+ export const X16_EMULATOR_REPO = 'https://github.com/X16Community/x16-emulator.git';
47
+ export const X16_ROM_REPO = 'https://github.com/X16Community/x16-rom.git';
48
+
49
+ /** The one tested `exec` line for the macOS wrapper — `"$@"` after the
50
+ * explicit ROM so `8bs run cx16`'s `-prg <file> -run` still gets through. */
51
+ export const X16EMU_WRAPPER_EXEC_LINE = `exec ${X16EMU_INSTALL_PATH} -rom ${CX16_ROM_INSTALL_PATH} "$@"`;
52
+
53
+ /**
54
+ * Per-platform installation strategy. Deliberately a table, not a set of
55
+ * `if (darwin)` branches: the x16emu launcher kind is the whole reason this
56
+ * table exists (see the module comment), and the next retro target that
57
+ * needs a platform split should add a column here rather than an assumption
58
+ * somewhere else.
59
+ */
60
+ export const CX16_PLATFORMS = Object.freeze({
61
+ darwin: Object.freeze({ name: 'macOS', packageManager: 'brew', x16emuLauncher: 'wrapper', makecartLauncher: 'symlink' }),
62
+ linux: Object.freeze({ name: 'Linux', packageManager: 'pacman', x16emuLauncher: 'symlink', makecartLauncher: 'symlink' }),
63
+ });
64
+
65
+ // ---- pure helpers, unit-tested --------------------------------------------
66
+
67
+ /** "### Release 50 ("next") 77f2bab3" -> "Release 50". Never requires a
68
+ * specific release: the installer builds whatever upstream currently is. */
69
+ export function parseX16emuVersion(output) {
70
+ const match = /Release\s+(\d+)/i.exec(output ?? '');
71
+ return match ? `Release ${match[1]}` : null;
72
+ }
73
+
74
+ /** The path x16emu complained about, from "Cannot open <path>!" — the
75
+ * exact symptom of the macOS symlink trap (and of any missing ROM). */
76
+ export function romLoadFailure(output) {
77
+ const match = /Cannot open (.+?)!/.exec(output ?? '');
78
+ return match ? match[1] : null;
79
+ }
80
+
81
+ /** Did a `-testbench` run boot the ROM? Testbench mode with stdin closed
82
+ * (confirmed against a real r50 build) prints "Testbench mode...", boots to
83
+ * the KERNAL's "RDY", then "Exit testbench." and exits 0 — with no window.
84
+ * A ROM it can't open exits 1 with "Cannot open <path>!" instead. */
85
+ export function testbenchBooted({ code, output }) {
86
+ return code === 0 && /\bRDY\b/.test(output ?? '') && !romLoadFailure(output);
87
+ }
88
+
89
+ // ---- inspection (shared with doctor.mjs) ----------------------------------
90
+
91
+ const defaultFs = {
92
+ exists: pathExists, statFn: stat, accessFn: access, lstatFn: lstat, readlinkFn: readlink, readFileFn: readFile,
93
+ };
94
+
95
+ /** Is there a usable ROM at `path`: a regular, readable, non-empty file?
96
+ * Existence alone isn't enough — an empty file left by an interrupted copy
97
+ * satisfies `-e` and still can't boot anything. */
98
+ export async function inspectRomFile(path, fs = {}) {
99
+ const { statFn, accessFn } = { ...defaultFs, ...fs };
100
+ let stats;
101
+ try {
102
+ stats = await statFn(path);
103
+ } catch {
104
+ return { state: 'missing', path };
105
+ }
106
+ if (!stats.isFile()) return { state: 'not-a-file', path };
107
+ if (stats.size === 0) return { state: 'empty', path, size: 0 };
108
+ try {
109
+ await accessFn(path, fsConstants.R_OK);
110
+ } catch {
111
+ return { state: 'unreadable', path, size: stats.size };
112
+ }
113
+ return { state: 'ok', path, size: stats.size };
114
+ }
115
+
116
+ export function x16emuLauncherSpec(platform) {
117
+ return { path: X16EMU_LAUNCHER_PATH, target: X16EMU_INSTALL_PATH, execLine: X16EMU_WRAPPER_EXEC_LINE, kind: CX16_PLATFORMS[platform]?.x16emuLauncher ?? 'symlink' };
118
+ }
119
+
120
+ export function makecartLauncherSpec(platform) {
121
+ return { path: MAKECART_LAUNCHER_PATH, target: MAKECART_INSTALL_PATH, execLine: null, kind: CX16_PLATFORMS[platform]?.makecartLauncher ?? 'symlink' };
122
+ }
123
+
124
+ /**
125
+ * Every separately-detectable state of a Commander X16 installation, in one
126
+ * read-only pass: source checkouts, build artifacts, installed binaries and
127
+ * ROM, and both launchers. setupCx16() decides what to do from this;
128
+ * doctor.mjs reports from the same shape so the two never disagree about
129
+ * what "installed" means.
130
+ */
131
+ export async function inspectCx16Installation({
132
+ platform = process.platform, emulatorSourceDir = x16EmulatorSourceDir(), romSourceDir = x16RomSourceDir(),
133
+ } = {}, fs = {}) {
134
+ const io = { ...defaultFs, ...fs };
135
+ const [sourceEmulator, sourceRom, buildEmulator, buildMakecart, buildRom] = await Promise.all([
136
+ io.exists(join(emulatorSourceDir, '.git')),
137
+ io.exists(join(romSourceDir, '.git')),
138
+ io.exists(join(emulatorSourceDir, 'build', 'x16emu')),
139
+ io.exists(join(emulatorSourceDir, 'build', 'makecart')),
140
+ io.exists(join(romSourceDir, 'build', 'x16', 'rom.bin')),
141
+ ]);
142
+ const [emulatorInstalled, makecartInstalled] = await Promise.all([
143
+ io.exists(X16EMU_INSTALL_PATH), io.exists(MAKECART_INSTALL_PATH),
144
+ ]);
145
+ const rom = await inspectRomFile(CX16_ROM_INSTALL_PATH, io);
146
+ const launcher = await inspectLauncher(x16emuLauncherSpec(platform), io);
147
+ const makecartLauncher = await inspectLauncher(makecartLauncherSpec(platform), io);
148
+ const installed = emulatorInstalled && makecartInstalled && rom.state === 'ok';
149
+ return {
150
+ platform,
151
+ source: { emulator: sourceEmulator, rom: sourceRom },
152
+ build: { emulator: buildEmulator && buildMakecart, rom: buildRom },
153
+ emulatorInstalled,
154
+ makecartInstalled,
155
+ rom,
156
+ launcher,
157
+ makecartLauncher,
158
+ installed,
159
+ launcherOk: launcher.state === x16emuLauncherSpec(platform).kind,
160
+ complete: installed && launcher.state === x16emuLauncherSpec(platform).kind
161
+ && makecartLauncher.state === makecartLauncherSpec(platform).kind,
162
+ };
163
+ }
164
+
165
+ /** Is `inspection` (from inspectLauncher) the tested-broken macOS layout:
166
+ * x16emu on PATH as a direct symlink into /opt/commander-x16? */
167
+ export function isBrokenMacosSymlink(platform, inspection) {
168
+ return platform === 'darwin' && inspection?.state === 'symlink';
169
+ }
170
+
171
+ // ---- build ----------------------------------------------------------------
172
+
173
+ /** Clone/update x16-emulator and `make` it. The repository's Makefile
174
+ * delegates to CMake (`cmake -S . -B build -DCMAKE_BUILD_TYPE=Release`,
175
+ * `cmake --build build`); the artifacts land in build/, not the repo root.
176
+ * AppleClang's "ld: warning: reducing alignment of section" is expected and
177
+ * not a failure — see source.mjs's runBuild(). */
178
+ export async function buildX16Emulator({
179
+ sourceDir = x16EmulatorSourceDir(), repo = X16_EMULATOR_REPO, exec = execInherit, exists = pathExists, mkdirFn = mkdir,
180
+ } = {}) {
181
+ const sync = await syncRepository({ sourceDir, repo, exec, exists, mkdirFn });
182
+ if (!sync.ok) return sync;
183
+ const emulatorPath = join(sourceDir, 'build', 'x16emu');
184
+ const makecartPath = join(sourceDir, 'build', 'makecart');
185
+ const build = await runBuild({ cwd: sourceDir, artifacts: [emulatorPath, makecartPath], exec, exists });
186
+ if (!build.ok) return build;
187
+ return { ok: true, emulatorPath, makecartPath };
188
+ }
189
+
190
+ /** Clone/update x16-rom and `make` it (cc65 + lzsa + python underneath).
191
+ * The build prints pages of ca65/ld65 warnings on a perfectly good run;
192
+ * only a non-zero exit or a missing build/x16/rom.bin is a failure. */
193
+ export async function buildX16Rom({
194
+ sourceDir = x16RomSourceDir(), repo = X16_ROM_REPO, exec = execInherit, exists = pathExists, mkdirFn = mkdir,
195
+ } = {}) {
196
+ const sync = await syncRepository({ sourceDir, repo, exec, exists, mkdirFn });
197
+ if (!sync.ok) return sync;
198
+ const romPath = join(sourceDir, 'build', 'x16', 'rom.bin');
199
+ const build = await runBuild({ cwd: sourceDir, artifacts: [romPath], exec, exists });
200
+ if (!build.ok) return build;
201
+ return { ok: true, romPath };
202
+ }
203
+
204
+ /** Boot the freshly-built emulator against the freshly-built ROM, headless,
205
+ * before either is installed — `-testbench` with stdin closed (see
206
+ * testbenchBooted()). Both `-version` and the boot go through the real
207
+ * binary with an explicit `-rom`, so this validates the pair itself, not
208
+ * any launcher. */
209
+ export async function validateX16Pair({ emulatorPath, romPath, exec = execCapture }) {
210
+ const version = await exec(emulatorPath, ['-rom', romPath, '-version'], { timeout: 30_000 });
211
+ const boot = await exec(emulatorPath, ['-rom', romPath, '-testbench'], { timeout: 60_000 });
212
+ const output = (boot.stdout ?? '') + (boot.stderr ?? '');
213
+ if (boot.missing) return { ok: false, detail: `could not run ${emulatorPath}` };
214
+ if (!testbenchBooted({ code: boot.code, output })) {
215
+ const failure = romLoadFailure(output);
216
+ return {
217
+ ok: false,
218
+ detail: failure
219
+ ? `x16emu could not open ${failure}`
220
+ : `x16emu -testbench did not boot (exit ${boot.code}): ${output.trim().split('\n').pop() || 'no output'}`,
221
+ };
222
+ }
223
+ return { ok: true, version: parseX16emuVersion((version.stdout ?? '') + (version.stderr ?? '')) };
224
+ }
225
+
226
+ // ---- the setup pipeline ---------------------------------------------------
227
+
228
+ // Every I/O boundary setupCx16() touches, as one overridable bag — real by
229
+ // default, fakes in tests (the brief: no sudo, Homebrew, clones, or builds
230
+ // in unit tests). Same shape as mega65.mjs's generateRom() io bag.
231
+ const defaultIo = {
232
+ platform: process.platform,
233
+ env: process.env,
234
+ exec: execCapture,
235
+ execLive: execInherit,
236
+ sudoExec: sudoRun,
237
+ hasBinary: hasBinaryOnPath,
238
+ exists: pathExists,
239
+ statFn: stat,
240
+ accessFn: access,
241
+ lstatFn: lstat,
242
+ readlinkFn: readlink,
243
+ readFileFn: readFile,
244
+ mkdirFn: mkdir,
245
+ writeFileFn: writeFile,
246
+ confirm,
247
+ canPromptInteractively,
248
+ emulatorSourceDir: x16EmulatorSourceDir,
249
+ romSourceDir: x16RomSourceDir,
250
+ workDir: cx16WorkDir,
251
+ buildEmulator: buildX16Emulator,
252
+ buildRom: buildX16Rom,
253
+ validatePair: validateX16Pair,
254
+ };
255
+
256
+ async function checkCompiler(io) {
257
+ const home = io.env.LLVM_MOS_HOME;
258
+ if (!home) return { ok: false, detail: 'LLVM_MOS_HOME is not set — docs/setup/llvm-mos.md' };
259
+ const driver = join(home, 'bin', 'mos-cx16-clang');
260
+ const r = await io.exec(driver, ['--version']);
261
+ if (r.missing) return { ok: false, detail: `mos-cx16-clang not found at ${driver} — docs/setup/llvm-mos.md` };
262
+ return { ok: true, detail: 'mos-cx16-clang' };
263
+ }
264
+
265
+ /** macOS host prerequisites: the Apple Command Line Tools (checked with
266
+ * `xcode-select -p` before ever offering the installer), then Homebrew —
267
+ * required by this backend, found via PATH (never a hardcoded
268
+ * /opt/homebrew), and never bootstrapped by curl on the user's behalf. */
269
+ async function ensureMacosPrerequisites(io) {
270
+ if (!(await hasXcodeCommandLineTools(io.exec))) {
271
+ reportLine('\n The Apple Command Line Tools are required to build x16emu (xcode-select --install).');
272
+ if (io.canPromptInteractively() && await io.confirm(' Open the Command Line Tools installer now?', { defaultValue: true })) {
273
+ await installXcodeCommandLineTools(io.execLive);
274
+ return { ok: false, label: 'Xcode CLT', detail: 'installer opened — re-run `8bs setup cx16` once it finishes' };
275
+ }
276
+ return { ok: false, label: 'Xcode CLT', detail: 'run: xcode-select --install, then re-run `8bs setup cx16`' };
277
+ }
278
+ reportStep('ok', 'Xcode CLT', 'installed');
279
+ if (!io.hasBinary('brew')) {
280
+ reportLine('\n Homebrew is required by the macOS Commander X16 setup backend, and `brew` is not on PATH.');
281
+ reportLine(' Install it from https://brew.sh (8bs will not run the bootstrap script for you),');
282
+ reportLine(' make sure `brew` is on PATH in this shell, then re-run `8bs setup cx16`.');
283
+ return { ok: false, label: 'Homebrew', detail: 'not found — https://brew.sh' };
284
+ }
285
+ reportStep('ok', 'Homebrew', 'found');
286
+ return { ok: true };
287
+ }
288
+
289
+ async function ensureDependencies(io, platform) {
290
+ if (platform.packageManager === 'brew') {
291
+ const missing = await missingBrewPackages(CX16_BREW_PACKAGES, io.exec);
292
+ if (missing.length === 0) return { ok: true, detail: 'installed' };
293
+ reportLine(`\n The Commander X16 build needs: ${missing.join(' ')}`);
294
+ const hint = `run: brew install ${missing.join(' ')}`;
295
+ if (!io.canPromptInteractively()) return { ok: false, detail: hint };
296
+ if (!(await io.confirm(' Install with Homebrew now?', { defaultValue: true }))) return { ok: false, detail: hint };
297
+ const result = await installBrewPackages(missing, io.execLive);
298
+ if (result.code !== 0) return { ok: false, detail: 'brew reported an error — see the output above' };
299
+ return { ok: true, detail: 'installed' };
300
+ }
301
+ // Linux (Arch/Manjaro): official packages via pacman, plus the two
302
+ // AUR-only tools checked on PATH with a pointer rather than an install —
303
+ // there's no single trusted AUR helper to run unattended.
304
+ const missing = await missingPacmanPackages(CX16_PACMAN_PACKAGES, io.exec);
305
+ if (missing.length > 0) {
306
+ reportLine(`\n The Commander X16 build needs: ${missing.join(' ')}`);
307
+ const hint = `run: sudo pacman -S --needed ${missing.join(' ')}`;
308
+ if (!io.canPromptInteractively()) return { ok: false, detail: hint };
309
+ if (!(await io.confirm(' Install with pacman now?', { defaultValue: true }))) return { ok: false, detail: hint };
310
+ const result = await installPacmanPackages(missing, io.sudoExec);
311
+ if (result.code !== 0) return { ok: false, detail: 'pacman reported an error — see the output above' };
312
+ }
313
+ const aur = missingPathTools(CX16_AUR_TOOLS, io.hasBinary);
314
+ if (aur.length > 0) {
315
+ return { ok: false, detail: `${aur.join(' and ')} not on PATH — AUR only; run: pamac build ${aur.join(' ')} (docs/setup/cx16.md)` };
316
+ }
317
+ return { ok: true, detail: 'installed' };
318
+ }
319
+
320
+ async function buildPair(io) {
321
+ const buildFs = { exec: io.execLive, exists: io.exists, mkdirFn: io.mkdirFn };
322
+ reportStep('running', 'emulator', 'cloning/updating and building x16-emulator');
323
+ const emulator = await io.buildEmulator({ sourceDir: io.emulatorSourceDir(), ...buildFs });
324
+ if (!emulator.ok) {
325
+ const why = emulator.missingArtifact ? `built, but ${emulator.missingArtifact} is missing` : `failed at '${emulator.step}' (exit ${emulator.code})`;
326
+ return { ok: false, label: 'emulator', detail: `x16-emulator build ${why} — docs/setup/cx16.md` };
327
+ }
328
+ reportStep('ok', 'emulator', emulator.emulatorPath);
329
+ reportStep('running', 'ROM', 'cloning/updating and building x16-rom');
330
+ const rom = await io.buildRom({ sourceDir: io.romSourceDir(), ...buildFs });
331
+ if (!rom.ok) {
332
+ const why = rom.missingArtifact ? `built, but ${rom.missingArtifact} is missing` : `failed at '${rom.step}' (exit ${rom.code})`;
333
+ return { ok: false, label: 'ROM', detail: `x16-rom build ${why} — docs/setup/cx16.md` };
334
+ }
335
+ reportStep('ok', 'ROM', rom.romPath);
336
+ const pair = await io.validatePair({ emulatorPath: emulator.emulatorPath, romPath: rom.romPath, exec: io.exec });
337
+ if (!pair.ok) return { ok: false, label: 'validate', detail: pair.detail };
338
+ reportStep('ok', 'validate', `${pair.version ?? 'x16emu'} boots the freshly built ROM`);
339
+ return { ok: true, ...emulator, romPath: rom.romPath };
340
+ }
341
+
342
+ /** `sudo mkdir -p` only when the directory is actually absent — a re-run
343
+ * against a complete install must never prompt for a password just to
344
+ * re-create a directory that's already there. */
345
+ async function ensureDirectoryIfMissing(io, dir) {
346
+ if (await io.exists(dir)) return { ok: true };
347
+ return ensureDirectory(dir, io.sudoExec);
348
+ }
349
+
350
+ async function installPair(io, built) {
351
+ const dir = await ensureDirectoryIfMissing(io, CX16_INSTALL_DIR);
352
+ if (!dir.ok) return { ok: false, detail: `sudo mkdir ${CX16_INSTALL_DIR} failed (exit ${dir.code})` };
353
+ const result = await installFiles([
354
+ { src: built.emulatorPath, dst: X16EMU_INSTALL_PATH, mode: '755' },
355
+ { src: built.makecartPath, dst: MAKECART_INSTALL_PATH, mode: '755' },
356
+ { src: built.romPath, dst: CX16_ROM_INSTALL_PATH, mode: '644' },
357
+ ], io.sudoExec, { identical: (a, b) => filesIdenticalWith(io, a, b) });
358
+ if (!result.ok) return { ok: false, detail: `sudo install ${result.path} failed (exit ${result.code})` };
359
+ const detail = result.installed.length === 0
360
+ ? `${CX16_INSTALL_DIR} (already current)`
361
+ : `${CX16_INSTALL_DIR} (${result.installed.length === 3 ? 'x16emu, makecart, rom.bin' : result.installed.map((p) => p.split('/').pop()).join(', ')})`;
362
+ return { ok: true, detail };
363
+ }
364
+
365
+ async function filesIdenticalWith(io, a, b) {
366
+ try {
367
+ const [x, y] = await Promise.all([io.readFileFn(a), io.readFileFn(b)]);
368
+ return Buffer.compare(Buffer.from(x), Buffer.from(y)) === 0;
369
+ } catch {
370
+ return false;
371
+ }
372
+ }
373
+
374
+ function describeForeign(inspection) {
375
+ if (inspection.state === 'foreign-symlink') return `a symlink to ${inspection.target}`;
376
+ const first = (inspection.content ?? '').split('\n').find((l) => l.trim() && !l.startsWith('#!'));
377
+ return first ? `a file that isn't managed by 8bs (first line: ${first.trim().slice(0, 60)})` : 'a file that isn\'t managed by 8bs';
378
+ }
379
+
380
+ async function confirmReplaceLauncher(io, inspection) {
381
+ reportLine(`\n ${inspection.path} already exists and is ${describeForeign(inspection)}.`);
382
+ if (!io.canPromptInteractively()) {
383
+ reportLine(' Not replacing it without confirmation — remove or rename it, then re-run `8bs setup cx16`.');
384
+ return false;
385
+ }
386
+ return io.confirm(' Replace it with the 8bs-managed launcher?', { defaultValue: false });
387
+ }
388
+
389
+ async function ensureLaunchers(io, platform) {
390
+ const bin = await ensureDirectoryIfMissing(io, LOCAL_BIN_DIR);
391
+ if (!bin.ok) return { ok: false, label: 'launcher', detail: `sudo mkdir ${LOCAL_BIN_DIR} failed (exit ${bin.code})` };
392
+ const fs = { lstatFn: io.lstatFn, readlinkFn: io.readlinkFn, readFileFn: io.readFileFn, mkdirFn: io.mkdirFn, writeFileFn: io.writeFileFn };
393
+ const confirmReplace = (inspection) => confirmReplaceLauncher(io, inspection);
394
+
395
+ const x16emu = await ensureLauncher({ ...x16emuLauncherSpec(io.platform), sudoExec: io.sudoExec, workDir: io.workDir(), confirmReplace }, fs);
396
+ if (!x16emu.ok) {
397
+ return {
398
+ ok: false, label: 'launcher',
399
+ detail: x16emu.action === 'skipped-foreign' ? `${X16EMU_LAUNCHER_PATH} left untouched` : `sudo ${x16emu.step} failed (exit ${x16emu.code})`,
400
+ };
401
+ }
402
+ const kind = platform.x16emuLauncher === 'wrapper' ? `wrapper, -rom ${CX16_ROM_INSTALL_PATH}` : `symlink -> ${X16EMU_INSTALL_PATH}`;
403
+ const repaired = x16emu.action === 'repaired' && isBrokenMacosSymlink(io.platform, x16emu.inspection);
404
+ reportStep('ok', 'launcher', `${X16EMU_LAUNCHER_PATH} (${kind}${repaired ? '; replaced the direct symlink that could not find rom.bin' : x16emu.action === 'unchanged' ? '' : `; ${x16emu.action}`})`);
405
+
406
+ const makecart = await ensureLauncher({ ...makecartLauncherSpec(io.platform), sudoExec: io.sudoExec, workDir: io.workDir(), confirmReplace }, fs);
407
+ if (!makecart.ok) {
408
+ return {
409
+ ok: false, label: 'makecart',
410
+ detail: makecart.action === 'skipped-foreign' ? `${MAKECART_LAUNCHER_PATH} left untouched` : `sudo ${makecart.step} failed (exit ${makecart.code})`,
411
+ };
412
+ }
413
+ reportStep('ok', 'makecart', `${MAKECART_LAUNCHER_PATH} -> ${MAKECART_INSTALL_PATH}`);
414
+ return { ok: true };
415
+ }
416
+
417
+ /** Run the *installed launcher* — not the /opt binary — headless, exactly as
418
+ * `8bs run cx16` and the user will invoke it. This is the check that would
419
+ * have caught the macOS symlink trap: `-version` alone never touches the
420
+ * ROM (confirmed: it succeeds even with `-rom /nonexistent`), so only a
421
+ * real boot proves the launcher supplies a ROM the emulator can open. */
422
+ async function verifyLauncher(io) {
423
+ const version = await io.exec(X16EMU_LAUNCHER_PATH, ['-version'], { timeout: 30_000 });
424
+ const boot = await io.exec(X16EMU_LAUNCHER_PATH, ['-testbench'], { timeout: 60_000 });
425
+ if (version.missing || boot.missing) return { ok: false, detail: `could not run ${X16EMU_LAUNCHER_PATH}` };
426
+ const output = (boot.stdout ?? '') + (boot.stderr ?? '');
427
+ if (!testbenchBooted({ code: boot.code, output })) {
428
+ const failure = romLoadFailure(output);
429
+ return { ok: false, detail: failure ? `x16emu could not open ${failure} — run: 8bs setup cx16 --repair` : `x16emu -testbench did not boot (exit ${boot.code})` };
430
+ }
431
+ const release = parseX16emuVersion((version.stdout ?? '') + (version.stderr ?? ''));
432
+ return { ok: true, detail: `${release ?? 'x16emu'} boots via ${X16EMU_LAUNCHER_PATH}` };
433
+ }
434
+
435
+ /**
436
+ * @param {{ repair?: boolean, update?: boolean }} options — `--repair` is
437
+ * accepted for readability (it's what `8bs doctor` suggests for a broken
438
+ * launcher) and behaves exactly like a plain run, which already repairs
439
+ * without rebuilding; `--update` forces a fresh pull, build and install
440
+ * of the emulator+ROM pair even when everything is already in place.
441
+ * @returns {Promise<{ok: boolean}>}
442
+ */
443
+ export async function setupCx16(options = {}, ioOverrides = {}) {
444
+ const io = { ...defaultIo, ...ioOverrides };
445
+ reportLine('Commander X16 setup\n');
446
+
447
+ const platform = CX16_PLATFORMS[io.platform];
448
+ if (!platform) {
449
+ reportStep('attn', 'platform', `no Commander X16 setup backend for '${io.platform}' — docs/setup/cx16.md`);
450
+ return { ok: false };
451
+ }
452
+ reportStep('ok', 'platform', `${platform.name} (x16emu launcher: ${platform.x16emuLauncher})`);
453
+
454
+ const compiler = await checkCompiler(io);
455
+ reportStep(compiler.ok ? 'ok' : 'attn', 'compiler', compiler.detail);
456
+ if (!compiler.ok) return { ok: false };
457
+
458
+ const fs = { exists: io.exists, statFn: io.statFn, accessFn: io.accessFn, lstatFn: io.lstatFn, readlinkFn: io.readlinkFn, readFileFn: io.readFileFn };
459
+ const state = await inspectCx16Installation({ platform: io.platform, emulatorSourceDir: io.emulatorSourceDir(), romSourceDir: io.romSourceDir() }, fs);
460
+
461
+ if (state.installed && !options.update) {
462
+ reportStep('ok', 'installed', `${CX16_INSTALL_DIR} (x16emu, makecart, rom.bin already present)`);
463
+ } else {
464
+ if (state.emulatorInstalled || state.makecartInstalled || state.rom.state === 'ok') {
465
+ const missing = [
466
+ !state.emulatorInstalled && 'x16emu', !state.makecartInstalled && 'makecart',
467
+ state.rom.state !== 'ok' && (state.rom.state === 'missing' ? 'rom.bin' : `rom.bin is ${state.rom.state}`),
468
+ ].filter(Boolean);
469
+ if (missing.length) reportLine(` ${CX16_INSTALL_DIR} is incomplete (missing: ${missing.join(', ')}) — rebuilding the emulator+ROM pair together so they match.`);
470
+ }
471
+ if (io.platform === 'darwin') {
472
+ const host = await ensureMacosPrerequisites(io);
473
+ if (!host.ok) {
474
+ reportStep('attn', host.label, host.detail);
475
+ return { ok: false };
476
+ }
477
+ }
478
+ const deps = await ensureDependencies(io, platform);
479
+ reportStep(deps.ok ? 'ok' : 'attn', 'dependencies', deps.detail);
480
+ if (!deps.ok) return { ok: false };
481
+
482
+ const built = await buildPair(io);
483
+ if (!built.ok) {
484
+ reportStep('attn', built.label, built.detail);
485
+ return { ok: false };
486
+ }
487
+ const installed = await installPair(io, built);
488
+ reportStep(installed.ok ? 'ok' : 'attn', 'installed', installed.detail);
489
+ if (!installed.ok) return { ok: false };
490
+ }
491
+
492
+ const launchers = await ensureLaunchers(io, platform);
493
+ if (!launchers.ok) {
494
+ reportStep('attn', launchers.label, launchers.detail);
495
+ return { ok: false };
496
+ }
497
+
498
+ if (!isDirOnPath(LOCAL_BIN_DIR, io.env)) {
499
+ reportStep('attn', 'PATH', `${LOCAL_BIN_DIR} is not on PATH in this shell — add it, or \`8bs run cx16\` won't find x16emu`);
500
+ } else {
501
+ reportStep('ok', 'PATH', `${LOCAL_BIN_DIR} is on PATH`);
502
+ }
503
+
504
+ const verified = await verifyLauncher(io);
505
+ reportStep(verified.ok ? 'ok' : 'attn', 'verified', verified.detail);
506
+ if (!verified.ok) return { ok: false };
507
+
508
+ reportLine('\nCommander X16 is ready.');
509
+ return { ok: true };
510
+ }
@@ -0,0 +1,103 @@
1
+ // Arch/Manjaro package dependencies for building Xemu's MEGA65 core, and for
2
+ // extracting the C64 Forever MSI. `pacman -S --needed` is already idempotent
3
+ // (it only touches packages that aren't at the requested state), so rather
4
+ // than hand-rolling per-package "is this installed" detection this checks
5
+ // with `pacman -T` — pacman's own "which of these are NOT already
6
+ // satisfied" query, confirmed on a real Manjaro box to handle both ordinary
7
+ // packages and Manjaro's `base-devel` meta-package correctly — and only
8
+ // prompts for a sudo install when something is actually missing.
9
+ export const XEMU_BUILD_PACKAGES = Object.freeze([
10
+ 'base-devel', 'git', 'pkgconf', 'sdl2-compat', 'gtk3', 'readline',
11
+ ]);
12
+
13
+ export const MSITOOLS_PACKAGE = 'msitools';
14
+
15
+ // Tested Apple Silicon macOS build: `brew install sdl2 wget git` was
16
+ // sufficient. `sdl2` currently resolves to the `sdl2-compat` formula on
17
+ // current Homebrew — confirmed working, same shim Arch's `sdl2-compat`
18
+ // package provides. No msitools equivalent here: the C64-Forever-MSI ROM
19
+ // generation flow (see setup/mega65-rom.mjs) is gated behind the explicit
20
+ // `--c64-forever` flag and isn't part of the macOS milestone — `--rom`
21
+ // (installing an already-generated MEGA65.ROM) is.
22
+ export const MEGA65_BREW_PACKAGES = Object.freeze(['sdl2', 'wget', 'git']);
23
+
24
+ /** Parse `pacman -T`'s stdout: one not-yet-satisfied package name per line,
25
+ * nothing at all when every package is already installed. */
26
+ export function parseMissingPackages(stdout) {
27
+ return stdout.split('\n').map((line) => line.trim()).filter(Boolean);
28
+ }
29
+
30
+ /** Which of `packages` still needs installing on this machine. Returns the
31
+ * full list back (rather than throwing) if `pacman` itself isn't found —
32
+ * callers on a non-Arch box, or one with a broken PATH, get a clear "needs
33
+ * install" signal instead of a crash. */
34
+ export async function missingPacmanPackages(packages, exec) {
35
+ const r = await exec('pacman', ['-T', ...packages]);
36
+ if (r.missing) return [...packages];
37
+ return parseMissingPackages(r.stdout);
38
+ }
39
+
40
+ /** `sudo pacman -S --needed <packages>` — no `--noconfirm`: pacman's own
41
+ * confirmation prompt reaches the user naturally, since setup runs it with
42
+ * stdio inherited, matching the brief's own example command exactly. */
43
+ export function installPacmanPackages(packages, sudoExec) {
44
+ return sudoExec('pacman', ['-S', '--needed', ...packages]);
45
+ }
46
+
47
+ // ---- Homebrew (macOS) ------------------------------------------------------
48
+ //
49
+ // The tested Commander X16 build on Apple Silicon macOS uses these Homebrew
50
+ // formulae. `pkgconf` isn't strictly needed — without it CMake only says
51
+ // "pkg-config missing. Skipping FluidSynth auto-detection." and the emulator
52
+ // still builds — but installing it lets dependency detection work normally.
53
+ // FluidSynth itself is optional and not installed: basic X16 emulation
54
+ // doesn't need it.
55
+ export const CX16_BREW_PACKAGES = Object.freeze([
56
+ 'git', 'cmake', 'python', 'pkgconf', 'sdl2', 'cc65', 'lzsa',
57
+ ]);
58
+
59
+ // Arch/Manjaro equivalents for the same build. cc65 and lzsa are AUR-only
60
+ // there (confirmed against `pacman -Si` — "package not found"), so they're
61
+ // checked as binaries on PATH rather than as pacman packages; see
62
+ // missingPathTools() and docs/setup/cx16.md's `pamac build cc65 lzsa`.
63
+ export const CX16_PACMAN_PACKAGES = Object.freeze([
64
+ 'base-devel', 'cmake', 'git', 'python', 'zlib', 'sdl2-compat',
65
+ ]);
66
+ export const CX16_AUR_TOOLS = Object.freeze(['cc65', 'lzsa']);
67
+
68
+ /**
69
+ * Which of `packages` Homebrew doesn't have installed. One `brew list
70
+ * --versions <all>` call first: it exits 0 only when every formula is
71
+ * installed, which is the common (complete-install) case and costs ~0.2s.
72
+ * Only when that fails does this ask per package — necessary because brew
73
+ * resolves aliases in its output (`python` prints as `python@3.14`, `sdl2`
74
+ * as `sdl2-compat`, both confirmed on a real macOS box), so the names in
75
+ * the combined output can't be matched back to what was asked for. Returns
76
+ * every package when `brew` itself isn't found, mirroring
77
+ * missingPacmanPackages().
78
+ */
79
+ export async function missingBrewPackages(packages, exec) {
80
+ const all = await exec('brew', ['list', '--versions', ...packages]);
81
+ if (all.missing) return [...packages];
82
+ if (all.code === 0) return [];
83
+ const missing = [];
84
+ for (const pkg of packages) {
85
+ const r = await exec('brew', ['list', '--versions', pkg]);
86
+ if (r.missing || r.code !== 0) missing.push(pkg);
87
+ }
88
+ return missing;
89
+ }
90
+
91
+ /** `brew install <packages>` — as the normal user, never under sudo (brew
92
+ * refuses to run as root), with stdio inherited so brew's own progress and
93
+ * any prompts reach the terminal. */
94
+ export function installBrewPackages(packages, exec) {
95
+ return exec('brew', ['install', ...packages]);
96
+ }
97
+
98
+ /** Which of `tools` (bare command names) aren't on PATH — for dependencies
99
+ * that only exist outside the platform's package manager (AUR builds of
100
+ * cc65/lzsa on Arch). `hasBinary` is injected so tests never touch PATH. */
101
+ export function missingPathTools(tools, hasBinary) {
102
+ return tools.filter((tool) => !hasBinary(tool));
103
+ }