@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.
package/src/doctor.mjs ADDED
@@ -0,0 +1,976 @@
1
+ // `8bs doctor` — is this machine able to build and run 8BitScript programs?
2
+ //
3
+ // Every target needs two things: an LLVM-MOS driver (or, for the web, the
4
+ // AssemblyScript compiler) to build with, and an emulator to run against.
5
+ // Every check reports against what the project actually requires, with a
6
+ // pointer to the setup page that installs the tool, an inline brew/apt/
7
+ // pacman command when one is known, and — for anything this machine's
8
+ // platform can install with a single trusted command — an interactive
9
+ // prompt to run that command right here, rather than making the reader
10
+ // leave the terminal and come back.
11
+ //
12
+ // The VIC-20 and Commander X16 checks go further than versions: a VICE
13
+ // build without ROMs prints a version and still cannot boot a machine, so
14
+ // the doctor launches the emulator for a bounded number of cycles and
15
+ // confirms it actually comes up; x16emu's `-version` likewise never touches
16
+ // its ROM, so the doctor boots it headless (`-testbench`) — the only check
17
+ // that catches the tested macOS failure where x16emu on PATH is a direct
18
+ // symlink and dies with "Cannot open /usr/local/bin/rom.bin!" (see
19
+ // checkCx16Target()). docs/setup/vice.md is explicit that anything less
20
+ // reports success on a setup that cannot run a single build. The other
21
+ // targets don't get that same depth yet — existence and, where the tool
22
+ // supports it, a version — that's a known gap, not an oversight.
23
+ import { spawn } from 'node:child_process';
24
+ import { createRequire } from 'node:module';
25
+ import { existsSync } from 'node:fs';
26
+ import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
27
+ import { tmpdir } from 'node:os';
28
+ import { delimiter, dirname, join } from 'node:path';
29
+
30
+ import { MEGA65_ROM_920413, validateRomBuffer, inspectXemuRomLink } from './setup/rom.mjs';
31
+ import { MEGA65_ROM_CANONICAL_PATH, CX16_ROM_INSTALL_PATH, xemuRomLinkPath } from './setup/paths.mjs';
32
+ import { resolveOnPath } from './setup/host.mjs';
33
+ import { inspectLauncher } from './setup/launcher.mjs';
34
+ import {
35
+ inspectRomFile, x16emuLauncherSpec, isBrokenMacosSymlink, parseX16emuVersion, romLoadFailure, testbenchBooted,
36
+ } from './setup/cx16.mjs';
37
+
38
+ // ---- pure helpers, unit-tested --------------------------------------------
39
+
40
+ /** First dotted version in a string, as numbers: "pnpm 12.1.0" -> [12,1,0]. */
41
+ export function parseVersion(text) {
42
+ const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(text ?? '');
43
+ if (!match) return null;
44
+ return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
45
+ }
46
+
47
+ /** Is `version` at least `minimum`? Both are number arrays. */
48
+ export function atLeast(version, minimum) {
49
+ for (let i = 0; i < minimum.length; i += 1) {
50
+ const a = version[i] ?? 0;
51
+ const b = minimum[i];
52
+ if (a > b) return true;
53
+ if (a < b) return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ /** Walk upward from `dir` for node_modules/.bin/<name>. */
59
+ export function findLocalBin(dir, name) {
60
+ const binary = process.platform === 'win32' ? `${name}.cmd` : name;
61
+ let current = dir;
62
+ for (;;) {
63
+ const candidate = join(current, 'node_modules', '.bin', binary);
64
+ if (existsSync(candidate)) return candidate;
65
+ const parent = dirname(current);
66
+ if (parent === current) return null;
67
+ current = parent;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Pick the install plan this platform can actually run, from an installer's
73
+ * `darwin`/`linux` options — the first Linux package manager found on PATH,
74
+ * since a machine only ever has some of apt/pacman/pamac/yay/paru/brew
75
+ * (linuxbrew). `buildFromSource` blocks this outright: x16emu and xmega65
76
+ * (Xemu) are both source-only, and neither one's AUR package is trusted here
77
+ * (see the comments on their INSTALLERS entries) — the interactive one-key
78
+ * install never applies to them; `8bs setup <target>` is the real path in.
79
+ * Returns null for an unsupported platform, or a Linux box with none of the
80
+ * listed managers on PATH (docs/hints still apply either way; this only
81
+ * decides whether the interactive one-key install applies).
82
+ */
83
+ export function pickInstallPlan(installer, platform = process.platform, hasBinary = onPath) {
84
+ if (!installer || installer.buildFromSource) return null;
85
+ if (platform === 'darwin') {
86
+ return installer.darwin && hasBinary('brew') ? installer.darwin : null;
87
+ }
88
+ if (platform === 'linux') {
89
+ return (installer.linux ?? []).find((plan) => hasBinary(plan.manager === 'apt' ? 'apt-get' : plan.manager)) ?? null;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ // ---- process running ------------------------------------------------------
95
+
96
+ /** Run a command; resolve with { code, stdout, stderr, missing, timedOut }. */
97
+ function run(command, args, { timeout = 10_000 } = {}) {
98
+ return new Promise((resolvePromise) => {
99
+ let child;
100
+ try {
101
+ child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
102
+ } catch {
103
+ resolvePromise({ code: null, stdout: '', stderr: '', missing: true });
104
+ return;
105
+ }
106
+ let stdout = '';
107
+ let stderr = '';
108
+ let timedOut = false;
109
+ const timer = setTimeout(() => {
110
+ timedOut = true;
111
+ child.kill('SIGKILL');
112
+ }, timeout);
113
+ child.stdout.on('data', (d) => { stdout += d; });
114
+ child.stderr.on('data', (d) => { stderr += d; });
115
+ child.on('error', () => {
116
+ clearTimeout(timer);
117
+ resolvePromise({ code: null, stdout, stderr, missing: true });
118
+ });
119
+ child.on('close', (code) => {
120
+ clearTimeout(timer);
121
+ resolvePromise({ code, stdout, stderr, missing: false, timedOut });
122
+ });
123
+ });
124
+ }
125
+
126
+ function onPath(name) {
127
+ const binary = process.platform === 'win32' ? `${name}.exe` : name;
128
+ return (process.env.PATH ?? '')
129
+ .split(delimiter)
130
+ .some((dir) => dir && existsSync(join(dir, binary)));
131
+ }
132
+
133
+ // ---- the checks -----------------------------------------------------------
134
+
135
+ const OK = 'ok';
136
+ const FAIL = 'fail';
137
+ const WARN = 'warn';
138
+ const SKIP = 'skip';
139
+
140
+ const result = (status, label, detail, hint = null, extra = {}) => ({
141
+ status, label, detail, hint, installer: extra.installer ?? null, targets: extra.targets ?? [],
142
+ });
143
+
144
+ async function versionCheck(label, command, args, minimum, hint, describeMin) {
145
+ const r = await run(command, args);
146
+ if (r.missing) return result(FAIL, label, 'not found', hint);
147
+ const version = parseVersion(r.stdout + r.stderr);
148
+ if (!version) {
149
+ return result(WARN, label, `installed, but the version was unreadable`, hint);
150
+ }
151
+ const pretty = version.join('.');
152
+ if (minimum && !atLeast(version, minimum)) {
153
+ return result(FAIL, label, `${pretty} — need ${describeMin}`, hint);
154
+ }
155
+ return result(OK, label, minimum ? `${pretty} (need ${describeMin})` : pretty);
156
+ }
157
+
158
+ async function checkHost() {
159
+ const node = parseVersion(process.version);
160
+ const checks = [
161
+ atLeast(node, [26])
162
+ ? result(OK, 'Node.js', `${node.join('.')} (need >=26)`)
163
+ : result(FAIL, 'Node.js', `${node.join('.')} — need >=26`, 'docs/setup/host-toolchain.md'),
164
+ await versionCheck('pnpm', 'pnpm', ['--version'], [12], 'docs/setup/host-toolchain.md', '>=12'),
165
+ await versionCheck('git', 'git', ['--version'], [2, 30], 'docs/setup/host-toolchain.md', '>=2.30'),
166
+ ];
167
+ return { title: 'Host', checks };
168
+ }
169
+
170
+ /**
171
+ * Where the toolchain will actually run `asc` from: the web backend's own
172
+ * dependencies. pnpm isolates each package's node_modules, so the binary lives
173
+ * next to @8bitscript/backend-web rather than at the workspace root. The
174
+ * user's own project is checked as a fallback, for a project that installs
175
+ * assemblyscript itself.
176
+ */
177
+ function findAsc() {
178
+ try {
179
+ const require = createRequire(import.meta.url);
180
+ const backend = dirname(require.resolve('@8bitscript/backend-web/package.json'));
181
+ const local = findLocalBin(backend, 'asc');
182
+ if (local) return local;
183
+ } catch {
184
+ // The backend is not resolvable from here; fall through to the cwd walk.
185
+ }
186
+ return findLocalBin(process.cwd(), 'asc');
187
+ }
188
+
189
+ async function checkWeb() {
190
+ const asc = findAsc();
191
+ const checks = [];
192
+ if (!asc) {
193
+ checks.push(result(
194
+ FAIL, 'asc', 'not found',
195
+ 'The AssemblyScript compiler ships with the toolchain — a missing asc\n' +
196
+ ' usually means an incomplete install. Run: pnpm install',
197
+ ));
198
+ } else {
199
+ const r = await run(asc, ['--version']);
200
+ const version = parseVersion(r.stdout + r.stderr);
201
+ checks.push(version
202
+ ? result(OK, 'asc', version.join('.'), null, { targets: ['web'] })
203
+ : result(WARN, 'asc', 'found, but the version was unreadable'));
204
+ }
205
+ return { title: 'Web target (.wasm)', checks };
206
+ }
207
+
208
+ // Every LLVM-MOS driver this project builds against, and which target(s) it
209
+ // serves. Confirmed directly against llvm-mos-sdk's own mos-platform/ tree
210
+ // (github.com/llvm-mos/llvm-mos-sdk) — one driver binary per platform, two
211
+ // for Atari 8-bit because that target picks its output format (DOS-loader
212
+ // .xex vs XEGS cartridge) via which driver runs, not a build flag.
213
+ const CLANG_DRIVERS = [
214
+ { driver: 'mos-vic20-clang', targets: ['vic20'] },
215
+ { driver: 'mos-c64-clang', targets: ['c64'] },
216
+ { driver: 'mos-pet-clang', targets: ['pet'] },
217
+ { driver: 'mos-c128-clang', targets: ['c128'] },
218
+ { driver: 'mos-mega65-clang', targets: ['mega65'] },
219
+ { driver: 'mos-cx16-clang', targets: ['cx16'] },
220
+ { driver: 'mos-nes-nrom-clang', targets: ['nes'] },
221
+ { driver: 'mos-atari8-dos-clang', targets: ['atari8'] },
222
+ { driver: 'mos-atari8-cart-xegs-clang', targets: ['atari8'] },
223
+ { driver: 'mos-atari8-cart-std-clang', targets: ['atari8'] },
224
+ { driver: 'mos-atari8-cart-megacart-clang', targets: ['atari8'] },
225
+ ];
226
+
227
+ async function checkMos() {
228
+ const checks = [];
229
+ const home = process.env.LLVM_MOS_HOME;
230
+ const allTargets = [...new Set(CLANG_DRIVERS.flatMap((d) => d.targets))];
231
+
232
+ if (!home) {
233
+ checks.push(result(FAIL, 'LLVM_MOS_HOME', 'not set', 'docs/setup/llvm-mos.md'));
234
+ for (const { driver, targets } of CLANG_DRIVERS) {
235
+ checks.push(result(SKIP, driver, 'skipped — LLVM_MOS_HOME is not set', null, { targets }));
236
+ }
237
+ } else if (!existsSync(join(home, 'bin'))) {
238
+ checks.push(result(
239
+ FAIL, 'LLVM_MOS_HOME', `set to ${home}, but ${join(home, 'bin')} does not exist`,
240
+ 'It must point at the directory that directly contains bin/ — docs/setup/llvm-mos.md',
241
+ ));
242
+ for (const { driver, targets } of CLANG_DRIVERS) {
243
+ checks.push(result(SKIP, driver, 'skipped — LLVM_MOS_HOME is wrong', null, { targets }));
244
+ }
245
+ } else {
246
+ checks.push(result(OK, 'LLVM_MOS_HOME', home, null, { targets: allTargets }));
247
+ for (const { driver, targets } of CLANG_DRIVERS) {
248
+ const path = join(home, 'bin', driver);
249
+ const r = await run(path, ['--version']);
250
+ if (r.missing) {
251
+ checks.push(result(FAIL, driver, `not found at ${path}`, 'docs/setup/llvm-mos.md', { targets }));
252
+ } else if (/clang/i.test(r.stdout + r.stderr)) {
253
+ const version = parseVersion(r.stdout + r.stderr);
254
+ checks.push(result(OK, driver, version ? `clang ${version.join('.')}` : 'a clang', null, { targets }));
255
+ } else {
256
+ checks.push(result(WARN, driver, 'runs, but does not identify itself as clang', null, { targets }));
257
+ }
258
+ }
259
+ }
260
+
261
+ return { title: 'LLVM-MOS SDK (every 6502 target)', checks };
262
+ }
263
+
264
+ // ---- emulator installers ---------------------------------------------------
265
+ //
266
+ // One entry per emulator this project can launch (`8bs run <target>`). Each
267
+ // carries: a doctor-facing label, the target(s) it serves, a brew formula
268
+ // for macOS, a Linux package-manager list (tried in the order a machine is
269
+ // likely to have them — apt/pacman native packages first, AUR-only packages
270
+ // via pamac/yay/paru next, Linuxbrew last), and a `docs/setup/*.md` page.
271
+ // `buildFromSource`/`repo` are set on top of that for the platforms (or, for
272
+ // x16emu, every platform) with no single-command install — `pickInstallPlan()`
273
+ // above only consults `.linux`/`.darwin` for whatever this specific machine
274
+ // can actually run, so `buildFromSource` never overrides a real entry.
275
+ const INSTALLERS = {
276
+ vice: {
277
+ label: 'VICE (xvic, x64sc, xpet, x128)',
278
+ darwin: { manager: 'brew', args: ['install', 'vice'] },
279
+ linux: [
280
+ { manager: 'apt', args: ['install', '-y', 'vice'], sudo: true },
281
+ { manager: 'pacman', args: ['-S', '--noconfirm', 'vice'], sudo: true },
282
+ { manager: 'brew', args: ['install', 'vice'] },
283
+ ],
284
+ docs: 'docs/setup/vice.md',
285
+ },
286
+ atari800: {
287
+ label: 'atari800 (Atari 8-bit)',
288
+ darwin: { manager: 'brew', args: ['install', 'atari800'] },
289
+ // No pacman plan: atari800 is not in Arch/Manjaro's official repos, only
290
+ // the AUR (confirmed against `pacman -Si atari800` — "package not
291
+ // found"). pamac — Manjaro's default package manager — builds AUR
292
+ // packages out of the box (confirmed: `pamac search atari800` resolves
293
+ // the AUR `atari800` package by exact name) and is tried first among the
294
+ // AUR-capable options since it's what Manjaro ships by default; yay/paru
295
+ // cover plain Arch installs that don't have pamac.
296
+ linux: [
297
+ { manager: 'apt', args: ['install', '-y', 'atari800'], sudo: true },
298
+ { manager: 'pamac', args: ['build', '--no-confirm', 'atari800'] },
299
+ { manager: 'yay', args: ['-S', '--noconfirm', 'atari800'] },
300
+ { manager: 'paru', args: ['-S', '--noconfirm', 'atari800'] },
301
+ { manager: 'brew', args: ['install', 'atari800'] },
302
+ ],
303
+ docs: 'docs/setup/atari8.md',
304
+ },
305
+ fceux: {
306
+ label: 'FCEUX (NES)',
307
+ darwin: { manager: 'brew', args: ['install', 'fceux'] },
308
+ linux: [
309
+ { manager: 'apt', args: ['install', '-y', 'fceux'], sudo: true },
310
+ { manager: 'pacman', args: ['-S', '--noconfirm', 'fceux'], sudo: true },
311
+ { manager: 'brew', args: ['install', 'fceux'] },
312
+ ],
313
+ docs: 'docs/setup/nes.md',
314
+ },
315
+ x16emu: {
316
+ label: 'x16emu (Commander X16)',
317
+ // An AUR `x16-emulator` package exists, but it can drift out of sync
318
+ // with the ROM the emulator needs — the two have to be a matching pair,
319
+ // per upstream's own notes — and may be outdated or broken. There's no
320
+ // single-command install plan here as a result: `8bs setup cx16` builds
321
+ // both the emulator and a matching ROM from upstream source instead
322
+ // (docs/setup/cx16.md). See checkCx16Target() for the resulting checks.
323
+ buildFromSource: true,
324
+ repo: 'https://github.com/X16Community/x16-emulator',
325
+ docs: 'docs/setup/cx16.md',
326
+ setupCommand: 'cx16',
327
+ },
328
+ xmega65: {
329
+ label: 'Xemu — MEGA65 core (xmega65)',
330
+ // No brew formula. An AUR `xmega65-git` package exists, but it's
331
+ // unreliable/outdated and this project doesn't depend on it — `8bs setup
332
+ // mega65` builds targets/mega65 from lgblgblgb/xemu directly instead.
333
+ buildFromSource: true,
334
+ repo: 'https://github.com/lgblgblgb/xemu',
335
+ docs: 'docs/setup/mega65.md',
336
+ // `8bs setup mega65` builds+installs this (and the ROM — see
337
+ // checkMega65Target() below) end to end; point installerHint() at it
338
+ // instead of just the bare upstream repo.
339
+ setupCommand: 'mega65',
340
+ },
341
+ };
342
+
343
+ /** `sudo apt-get install -y foo` style text for one darwin/linux plan. */
344
+ function planCommand(plan) {
345
+ const manager = plan.manager === 'apt' ? 'apt-get' : plan.manager;
346
+ return plan.sudo ? `sudo ${manager} ${plan.args.join(' ')}` : `${manager} ${plan.args.join(' ')}`;
347
+ }
348
+
349
+ /**
350
+ * Every command line for this platform, in the order it's tried — so a FAIL
351
+ * always shows concrete things to try, not just the one this machine can run
352
+ * unattended right now, collapsed behind "your distro's package manager".
353
+ * The one `pickInstallPlan` would actually run is marked "(detected)".
354
+ */
355
+ function installerHint(installer) {
356
+ const plans = process.platform === 'darwin'
357
+ ? (installer.darwin ? [installer.darwin] : [])
358
+ : process.platform === 'linux'
359
+ ? (installer.linux ?? [])
360
+ : [];
361
+ if (plans.length === 0) {
362
+ if (installer.buildFromSource) {
363
+ const setupHint = installer.setupCommand ? `run: 8bs setup ${installer.setupCommand} — ` : '';
364
+ return `${setupHint}no packaged build found — ${installer.repo} — ${installer.docs}`;
365
+ }
366
+ return `brew install <formula>, or your distro's package manager — ${installer.docs}`;
367
+ }
368
+ const detected = pickInstallPlan(installer);
369
+ const lines = plans.map((plan) => `${planCommand(plan)}${plan === detected ? ' (detected)' : ''}`);
370
+ return `try:\n ${lines.join('\n ')}\n — ${installer.docs}`;
371
+ }
372
+
373
+ /** Existence + best-effort version for an emulator binary that may hang on
374
+ * an unrecognised flag (a GUI emulator opening a window instead of printing
375
+ * a version) — existence is the check that matters; the version probe only
376
+ * ever upgrades a result, never fails one, so a slow/silent version flag
377
+ * can't turn a real install into a false FAIL. `versionArgs` is there for
378
+ * a tool whose flag isn't `--version` (x16emu's is `-version`, but that
379
+ * one has its own deeper checks now — checkCx16Target()). */
380
+ async function checkEmulator(binary, { label = binary, targets, installerKey, tryVersion = true, versionArgs = ['--version'] } = {}) {
381
+ const installer = INSTALLERS[installerKey];
382
+ if (!onPath(binary)) {
383
+ return result(FAIL, label, 'not found', installerHint(installer), { installer, targets });
384
+ }
385
+ if (!tryVersion) return result(OK, label, 'found', null, { targets });
386
+ const r = await run(binary, versionArgs);
387
+ if (r.missing || r.timedOut) return result(OK, label, 'found (version unconfirmed)', null, { targets });
388
+ const output = r.stdout + r.stderr;
389
+ const version = parseVersion(output);
390
+ if (version) return result(OK, label, version.join('.'), null, { targets });
391
+ // No dotted version to parse (x16emu reports "Release NN" instead) — show
392
+ // the real first line rather than a canned "unconfirmed" for output we did
393
+ // get back.
394
+ const firstLine = output.trim().split('\n')[0]?.trim();
395
+ return result(OK, label, firstLine || 'found (version unconfirmed)', null, { targets });
396
+ }
397
+
398
+ /**
399
+ * Ask the package manager that installed VICE what version it put down,
400
+ * bypassing the binaries entirely. This is the fallback for
401
+ * `xvic --version` et al crashing outright on some Homebrew bottles
402
+ * (confirmed on 3.9, reproduced here on 3.10) with "Error - argv[0] is
403
+ * NULL, giving up" — a known upstream regression, vice-emu bug #2108 —
404
+ * rather than printing anything a version check could parse. The package
405
+ * manager still knows the version even when the binary can't report its
406
+ * own; null if no manager confirms one.
407
+ */
408
+ export async function vicePackageManagerVersion({
409
+ platform = process.platform, hasBinary = onPath, exec = run,
410
+ } = {}) {
411
+ if (platform === 'darwin' && hasBinary('brew')) {
412
+ const r = await exec('brew', ['list', '--versions', 'vice']);
413
+ const version = parseVersion(r.stdout);
414
+ if (version) return version;
415
+ }
416
+ if (platform === 'linux') {
417
+ const dpkg = await exec('dpkg-query', ['-W', '-f=${Version}', 'vice']);
418
+ const dpkgVersion = parseVersion(dpkg.stdout);
419
+ if (dpkgVersion) return dpkgVersion;
420
+ const pacman = await exec('pacman', ['-Q', 'vice']);
421
+ const pacmanVersion = parseVersion(pacman.stdout);
422
+ if (pacmanVersion) return pacmanVersion;
423
+ }
424
+ return null;
425
+ }
426
+
427
+ async function checkVice() {
428
+ const checks = [];
429
+ // Fetched lazily, at most once per checkVice() run — one VICE install
430
+ // serves all four binaries, so the four fallback lookups would otherwise
431
+ // be identical repeats of each other.
432
+ let packageManagerVersion; // undefined until first needed, then cached (possibly null)
433
+ for (const [binary, machine, label] of [
434
+ ['xvic', 'vic20', 'xvic (VIC-20)'],
435
+ ['x64sc', 'c64', 'x64sc (C64)'],
436
+ ['xpet', 'pet', 'xpet (PET)'],
437
+ ['x128', 'c128', 'x128 (C128)'],
438
+ ]) {
439
+ if (!onPath(binary)) {
440
+ checks.push(result(FAIL, label, 'not found', installerHint(INSTALLERS.vice), { installer: INSTALLERS.vice, targets: [machine] }));
441
+ continue;
442
+ }
443
+ const r = await run(binary, ['--version']);
444
+ let version = parseVersion(r.stdout + r.stderr);
445
+ let viaPackageManager = false;
446
+ if (!version) {
447
+ if (packageManagerVersion === undefined) packageManagerVersion = await vicePackageManagerVersion();
448
+ if (packageManagerVersion) {
449
+ version = packageManagerVersion;
450
+ viaPackageManager = true;
451
+ }
452
+ }
453
+ if (!version) {
454
+ checks.push(result(WARN, label, 'found, but the version was unreadable', 'docs/setup/vice.md', { targets: [machine] }));
455
+ } else if (!atLeast(version, [3, 10])) {
456
+ checks.push(result(WARN, label, `VICE ${version.join('.')} — the project expects 3.10`, 'docs/setup/vice.md', { targets: [machine] }));
457
+ } else {
458
+ const detail = viaPackageManager
459
+ ? `VICE ${version.join('.')} (via the package manager — ${binary} --version doesn't print one on this build)`
460
+ : `VICE ${version.join('.')}`;
461
+ checks.push(result(OK, label, detail, null, { targets: [machine] }));
462
+ }
463
+ }
464
+ checks.push(await bootCheck());
465
+ return { title: 'VICE (VIC-20 / C64 / PET / C128)', checks };
466
+ }
467
+
468
+ /**
469
+ * Launch xvic for a bounded number of cycles and confirm it reaches a running
470
+ * machine. This is the check a version string cannot stand in for: a VICE
471
+ * without ROMs reports its version and then refuses to boot.
472
+ *
473
+ * Two things about real VICE builds shape this code, both learned the hard way
474
+ * against the GTK3 build Arch ships:
475
+ *
476
+ * - The exit-screenshot flag is spelled `-exitscreenshot` on GTK3 builds and
477
+ * `-exitscreenshotname` on SDL builds, so the flags are probed from
478
+ * `xvic -help` rather than assumed.
479
+ * - Reaching the cycle limit is reported as an *error* ("cycle limit
480
+ * reached") with a non-zero exit, even though it is exactly the success
481
+ * case. The exit code is useless here; the evidence of a boot is the
482
+ * screenshot on disk, with the cycle-limit message as fallback.
483
+ *
484
+ * ~8 million cycles is a few seconds of emulated VIC-20, comfortably past the
485
+ * BASIC startup screen; -warp makes it quick on the host.
486
+ */
487
+ async function bootCheck() {
488
+ if (!onPath('xvic')) return result(SKIP, 'VIC-20 boot', 'skipped — xvic is not installed');
489
+
490
+ const help = await run('xvic', ['-help']);
491
+ const helpText = help.stdout + help.stderr;
492
+ if (!/-limitcycles\b/.test(helpText)) {
493
+ return result(WARN, 'VIC-20 boot', 'could not be verified — this VICE does not support -limitcycles');
494
+ }
495
+ const screenshotFlag = /-exitscreenshot\b/.test(helpText)
496
+ ? '-exitscreenshot'
497
+ : /-exitscreenshotname\b/.test(helpText)
498
+ ? '-exitscreenshotname'
499
+ : null;
500
+
501
+ const scratch = await mkdtemp(join(tmpdir(), '8bs-doctor-'));
502
+ try {
503
+ const shot = join(scratch, 'boot.png');
504
+ const args = ['-default', '-warp', '+sound', '-limitcycles', '8000000'];
505
+ if (screenshotFlag) args.push(screenshotFlag, shot);
506
+ const r = await run('xvic', args, { timeout: 60_000 });
507
+ const output = r.stdout + r.stderr;
508
+
509
+ if (r.timedOut) {
510
+ return result(FAIL, 'VIC-20 boot', 'the emulator did not finish within 60s', 'docs/setup/vice.md');
511
+ }
512
+ if (/cannot load system file|sysfile.*error/i.test(output)) {
513
+ return result(
514
+ FAIL, 'VIC-20 boot', 'xvic cannot load its ROMs',
515
+ 'The emulator is installed but the Commodore ROM images are missing — docs/setup/vice.md',
516
+ );
517
+ }
518
+ const booted =
519
+ (screenshotFlag && existsSync(shot)) || /cycle limit reached/i.test(output);
520
+ if (booted) {
521
+ return result(OK, 'VIC-20 boot', 'the emulator boots to a running machine');
522
+ }
523
+ const reason = (r.stderr.trim().split('\n').pop() ?? '').slice(0, 120);
524
+ return result(
525
+ FAIL, 'VIC-20 boot',
526
+ `xvic exited without booting${reason ? ` — ${reason}` : ''}`,
527
+ 'docs/setup/vice.md',
528
+ );
529
+ } finally {
530
+ await rm(scratch, { recursive: true, force: true });
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Does a MEGA65 ROM exist at either place `8bs setup mega65` (or a manual
536
+ * install) would put it, and is it the full, official 920413 ROM — not just
537
+ * present, since a redistributable Open ROM or some other file at the same
538
+ * path is not equivalent for target readiness (see docs/setup/mega65.md).
539
+ * Checks the canonical install first, then Xemu's own per-user copy, mirroring
540
+ * setup/mega65.mjs's own read order.
541
+ */
542
+ export async function findMega65Rom({
543
+ canonicalPath = MEGA65_ROM_CANONICAL_PATH, linkPath = xemuRomLinkPath(), read = readFile,
544
+ } = {}) {
545
+ for (const path of [canonicalPath, linkPath]) {
546
+ try {
547
+ const buffer = await read(path);
548
+ return { path, validation: validateRomBuffer(buffer, { size: MEGA65_ROM_920413.romSize, sha256: MEGA65_ROM_920413.romSha256 }) };
549
+ } catch {
550
+ // Not at this path — try the next, or report not-found below.
551
+ }
552
+ }
553
+ return null;
554
+ }
555
+
556
+ /**
557
+ * MEGA65 readiness, as four separate checks, not one — `xmega65` existing
558
+ * on `PATH` proves nothing about whether it can actually run the machine:
559
+ *
560
+ * xmega65 the launcher on PATH, reported as its resolved path
561
+ * (like checkCx16Target()'s x16emu, not just "found")
562
+ * MEGA65 ROM a full, official 920413 ROM exists *somewhere* this
563
+ * project or a manual install would put it — the full ROM
564
+ * cannot be redistributed by this project
565
+ * (docs/setup/mega65.md), so a fresh Xemu install has none
566
+ * until `8bs setup mega65` (or the manual steps) makes one
567
+ * Xemu ROM link separately: can *Xemu itself* actually see that ROM?
568
+ * `MEGA65 ROM ok` alone doesn't imply this — a canonical
569
+ * install at /opt/mega65/MEGA65.ROM with no
570
+ * ~/.xemu-lgb/MEGA65.ROM link is a real, tested gap this
571
+ * check exists to catch (see setup/rom.mjs's
572
+ * inspectXemuRomLink(), shared with `8bs setup mega65`)
573
+ * MEGA65 ready only when every check above passes
574
+ */
575
+ export async function checkMega65Target({
576
+ hasEmulator, emulatorPath = null, find = findMega65Rom,
577
+ canonicalPath = MEGA65_ROM_CANONICAL_PATH, linkPath = xemuRomLinkPath(), inspectLink = inspectXemuRomLink,
578
+ } = {}) {
579
+ const targets = ['mega65'];
580
+ const installer = INSTALLERS.xmega65;
581
+ const checks = [];
582
+
583
+ checks.push(hasEmulator
584
+ ? result(OK, 'xmega65 (MEGA65, via Xemu)', emulatorPath ?? 'found', null, { targets })
585
+ : result(FAIL, 'xmega65 (MEGA65, via Xemu)', 'not found', installerHint(installer), { installer, targets }));
586
+
587
+ const found = await find({ canonicalPath, linkPath });
588
+ let romOk = false;
589
+ if (!found) {
590
+ checks.push(result(
591
+ FAIL, 'MEGA65 ROM', 'not found',
592
+ hasEmulator
593
+ ? 'xmega65 is installed, but the full MEGA65 ROM is missing.\n run: 8bs setup mega65'
594
+ : 'run: 8bs setup mega65',
595
+ { targets },
596
+ ));
597
+ } else if (found.validation.ok) {
598
+ romOk = true;
599
+ checks.push(result(OK, 'MEGA65 ROM', MEGA65_ROM_920413.release, null, { targets }));
600
+ } else {
601
+ checks.push(result(
602
+ FAIL, 'MEGA65 ROM',
603
+ `found at ${found.path}, but it isn't the full ${MEGA65_ROM_920413.release} ROM `
604
+ + '(may be an Open ROM, or a different release)',
605
+ 'run: 8bs setup mega65',
606
+ { targets },
607
+ ));
608
+ }
609
+
610
+ const linkInspection = await inspectLink(linkPath, canonicalPath, MEGA65_ROM_920413);
611
+ let linkOk = false;
612
+ if (linkInspection.state === 'linked') {
613
+ linkOk = true;
614
+ checks.push(result(OK, 'Xemu ROM link', 'configured', null, { targets }));
615
+ } else if (linkInspection.state === 'migratable') {
616
+ linkOk = true;
617
+ checks.push(result(OK, 'Xemu ROM link', `${linkPath} (installed directly, not linked to the canonical copy)`, null, { targets }));
618
+ } else if (linkInspection.state === 'absent') {
619
+ checks.push(result(
620
+ FAIL, 'Xemu ROM link', 'MEGA65.ROM exists but Xemu is not configured to use it.',
621
+ 'run: 8bs setup mega65 --repair', { targets },
622
+ ));
623
+ } else {
624
+ checks.push(result(
625
+ FAIL, 'Xemu ROM link', `${linkPath} exists but isn't the MEGA65 ROM`,
626
+ 'run: 8bs setup mega65 --repair', { targets },
627
+ ));
628
+ }
629
+
630
+ const ready = hasEmulator && romOk && linkOk;
631
+ const firstFail = checks.find((c) => c.status === FAIL);
632
+ checks.push(ready
633
+ ? result(OK, 'MEGA65', 'ready', null, { targets })
634
+ : result(SKIP, 'MEGA65', `not ready — ${firstFail?.label ?? 'xmega65'} must pass first`, null, { targets }));
635
+
636
+ return checks;
637
+ }
638
+
639
+ const ROM_STATE_WORDS = {
640
+ 'not-a-file': 'not a regular file', empty: 'an empty file', unreadable: 'not readable',
641
+ };
642
+
643
+ /**
644
+ * Commander X16 readiness, as five separate checks plus a summary — because
645
+ * `command -v x16emu` succeeding proves almost nothing here. Every one of
646
+ * these has failed for real on a setup that passed the one before it:
647
+ *
648
+ * x16emu the launcher on PATH (reported as the path, so a reader
649
+ * sees /usr/local/bin/x16emu vs. some other install)
650
+ * ROM /opt/commander-x16/rom.bin is a regular, readable,
651
+ * non-empty file — or, for an install this project didn't
652
+ * make, a rom.bin beside the real binary
653
+ * launcher on macOS, a direct symlink into /opt/commander-x16 is
654
+ * the tested-broken layout (x16emu looks for rom.bin beside
655
+ * the *symlink*): reported specifically, with the repair
656
+ * version `x16emu -version` → "Release NN" (any release)
657
+ * boot `x16emu -testbench` headless — the only probe that
658
+ * actually loads the ROM through the launcher
659
+ *
660
+ * `compilerOk` is mos-cx16-clang's status from checkMos(), folded into the
661
+ * summary line so "ready" means the whole toolchain, not just the emulator.
662
+ * Every boundary is injectable for the unit tests; the defaults are real.
663
+ */
664
+ export async function checkCx16Target({
665
+ platform = process.platform, compilerOk = true, resolveBinary = resolveOnPath, exec = run,
666
+ realpathFn = realpath, fs = {},
667
+ } = {}) {
668
+ const installer = INSTALLERS.x16emu;
669
+ const targets = ['cx16'];
670
+ const checks = [];
671
+ const launcherPath = resolveBinary('x16emu');
672
+
673
+ checks.push(launcherPath
674
+ ? result(OK, 'x16emu (Commander X16)', launcherPath, null, { targets })
675
+ : result(FAIL, 'x16emu (Commander X16)', 'not found', installerHint(installer), { installer, targets }));
676
+
677
+ let rom = await inspectRomFile(CX16_ROM_INSTALL_PATH, fs);
678
+ if (rom.state !== 'ok' && launcherPath) {
679
+ // Not the 8bs-managed layout — but an official release zip, or a manual
680
+ // install, keeps rom.bin beside the real binary, and x16emu finds that
681
+ // by itself. Accept it, but say so.
682
+ try {
683
+ const beside = join(dirname(await realpathFn(launcherPath)), 'rom.bin');
684
+ if (beside !== CX16_ROM_INSTALL_PATH) {
685
+ const found = await inspectRomFile(beside, fs);
686
+ if (found.state === 'ok') rom = { ...found, beside: true };
687
+ }
688
+ } catch {
689
+ // realpath failed (dangling symlink) — the launcher/boot checks below report it.
690
+ }
691
+ }
692
+ if (rom.state === 'ok') {
693
+ checks.push(result(OK, 'Commander X16 ROM', rom.beside ? `${rom.path} (beside x16emu — not the 8bs-managed layout)` : rom.path, null, { targets }));
694
+ } else if (rom.state === 'missing') {
695
+ checks.push(result(
696
+ FAIL, 'Commander X16 ROM', 'not found',
697
+ launcherPath ? 'emulator installed but ROM is missing\n run: 8bs setup cx16' : 'run: 8bs setup cx16',
698
+ { targets },
699
+ ));
700
+ } else {
701
+ checks.push(result(FAIL, 'Commander X16 ROM', `${rom.path} is ${ROM_STATE_WORDS[rom.state]}`, 'run: 8bs setup cx16', { targets }));
702
+ }
703
+
704
+ let brokenSymlink = false;
705
+ if (launcherPath) {
706
+ const spec = x16emuLauncherSpec(platform);
707
+ const inspection = await inspectLauncher({ ...spec, path: launcherPath }, fs);
708
+ brokenSymlink = isBrokenMacosSymlink(platform, inspection);
709
+ if (brokenSymlink) {
710
+ checks.push(result(
711
+ FAIL, 'Commander X16 launcher', 'x16emu is installed as a direct symlink and cannot locate rom.bin.',
712
+ 'run: 8bs setup cx16 --repair', { targets },
713
+ ));
714
+ } else if (inspection.state === 'wrapper') {
715
+ checks.push(result(OK, 'Commander X16 launcher', `wrapper, -rom ${CX16_ROM_INSTALL_PATH}`, null, { targets }));
716
+ } else if (inspection.state === 'symlink') {
717
+ checks.push(result(OK, 'Commander X16 launcher', `symlink -> ${inspection.target}`, null, { targets }));
718
+ } else if (inspection.state === 'foreign-symlink' && platform === 'darwin') {
719
+ checks.push(result(
720
+ WARN, 'Commander X16 launcher', `a symlink to ${inspection.target}, not 8bs-managed`,
721
+ 'On macOS x16emu looks for rom.bin beside the symlink, not the real binary — the boot check below is authoritative', { targets },
722
+ ));
723
+ } else {
724
+ checks.push(result(OK, 'Commander X16 launcher', `${launcherPath} (not 8bs-managed)`, null, { targets }));
725
+ }
726
+
727
+ // -version: exits 0 with "### Release NN (...)" on stdout, no window —
728
+ // confirmed against a real r50 build. It never loads the ROM (also
729
+ // confirmed: it succeeds with `-rom /nonexistent`), hence the boot below.
730
+ const version = await exec('x16emu', ['-version']);
731
+ const versionOut = (version.stdout ?? '') + (version.stderr ?? '');
732
+ const release = parseX16emuVersion(versionOut);
733
+ if (version.missing || version.timedOut) {
734
+ checks.push(result(WARN, 'x16emu version', 'unconfirmed — `x16emu -version` did not respond', null, { targets }));
735
+ } else if (release) {
736
+ checks.push(result(OK, 'x16emu version', release, null, { targets }));
737
+ } else if (version.code !== 0) {
738
+ checks.push(result(FAIL, 'x16emu version', `\`x16emu -version\` exited ${version.code}: ${versionOut.trim().split('\n').pop() || 'no output'}`, 'run: 8bs setup cx16', { targets }));
739
+ } else {
740
+ checks.push(result(WARN, 'x16emu version', versionOut.trim().split('\n')[0] || 'unreadable', null, { targets }));
741
+ }
742
+
743
+ const boot = await exec('x16emu', ['-testbench'], { timeout: 30_000 });
744
+ const bootOut = (boot.stdout ?? '') + (boot.stderr ?? '');
745
+ const failure = romLoadFailure(bootOut);
746
+ if (boot.timedOut) {
747
+ checks.push(result(FAIL, 'Commander X16 boot', 'x16emu -testbench did not finish within 30s', 'docs/setup/cx16.md', { targets }));
748
+ } else if (failure) {
749
+ checks.push(result(
750
+ FAIL, 'Commander X16 boot', `x16emu cannot open its ROM (${failure})`,
751
+ brokenSymlink ? 'run: 8bs setup cx16 --repair' : 'run: 8bs setup cx16', { targets },
752
+ ));
753
+ } else if (testbenchBooted({ code: boot.code, output: bootOut })) {
754
+ checks.push(result(OK, 'Commander X16 boot', 'boots to BASIC (headless -testbench)', null, { targets }));
755
+ } else {
756
+ const reason = (bootOut.trim().split('\n').pop() ?? '').slice(0, 120);
757
+ checks.push(result(FAIL, 'Commander X16 boot', `x16emu exited without booting${reason ? ` — ${reason}` : ''}`, 'run: 8bs setup cx16', { targets }));
758
+ }
759
+ }
760
+
761
+ const firstFail = !compilerOk ? { label: 'mos-cx16-clang' } : checks.find((c) => c.status === FAIL);
762
+ checks.push(firstFail
763
+ ? result(SKIP, 'Commander X16', `not ready — ${firstFail.label} must pass first`, null, { targets })
764
+ : result(OK, 'Commander X16', 'ready', null, { targets }));
765
+ return checks;
766
+ }
767
+
768
+ async function checkOtherEmulators({ cx16CompilerOk }) {
769
+ const mega65EmulatorPath = resolveOnPath('xmega65');
770
+ const checks = [
771
+ await checkEmulator('atari800', { label: 'atari800 (Atari 8-bit)', targets: ['atari8'], installerKey: 'atari800' }),
772
+ await checkEmulator('fceux', { label: 'fceux (NES)', targets: ['nes'], installerKey: 'fceux' }),
773
+ // Commander X16 is five checks, not one — see checkCx16Target().
774
+ ...(await checkCx16Target({ compilerOk: cx16CompilerOk })),
775
+ // MEGA65 is four checks, not one — see checkMega65Target().
776
+ ...(await checkMega65Target({ hasEmulator: Boolean(mega65EmulatorPath), emulatorPath: mega65EmulatorPath })),
777
+ ];
778
+ return { title: 'Atari 8-bit / NES / Commander X16 / MEGA65 emulators', checks };
779
+ }
780
+
781
+ // ---- screenshot capability (8bs run <target> --screenshot) -----------------
782
+ //
783
+ // `--screenshot` (see screenshot.mjs) mostly rides on emulators doctor
784
+ // already checks above — a target with a working emulator has a working
785
+ // screenshot path too, with two exceptions worth a dedicated check because
786
+ // they're easy to miss until a --screenshot call fails confusingly later:
787
+ //
788
+ // - cx16's path needs `ffmpeg` on PATH (to pull a still frame out of
789
+ // x16emu's -gif recording) — a dependency nothing else in this project
790
+ // requires, so nothing else checks for it.
791
+ // - atari8's path needs macOS Screen Recording permission (it captures
792
+ // the emulator's real window, since atari800 has no scriptable
793
+ // screenshot flag — see screenshot.mjs's own header comment). Unlike
794
+ // every other check in this file, there's no package manager fix to
795
+ // offer: only a person clicking a checkbox in System Settings can grant
796
+ // this, so the fix here is the exact settings pane to open, not a
797
+ // command to run. This never fails the overall doctor run — a machine
798
+ // with the permission not yet granted can still build and run every
799
+ // target, `--screenshot atari8` just isn't available until it is.
800
+ async function checkScreenshotCapability() {
801
+ const checks = [
802
+ result(
803
+ onPath('ffmpeg') ? OK : WARN,
804
+ 'ffmpeg (cx16 --screenshot)',
805
+ onPath('ffmpeg') ? 'found' : 'not found — cx16 --screenshot cannot extract a still frame without it',
806
+ onPath('ffmpeg') ? null : 'brew install ffmpeg (macOS) / apt install ffmpeg (Debian/Ubuntu) / pacman -S ffmpeg (Arch)',
807
+ { targets: [] },
808
+ ),
809
+ ];
810
+
811
+ if (process.platform === 'darwin') {
812
+ if (!onPath('atari800')) {
813
+ checks.push(result(SKIP, 'macOS Screen Recording (atari8 --screenshot)', 'atari800 not installed — nothing to check yet', null, { targets: [] }));
814
+ } else {
815
+ let granted = false;
816
+ let checkError = null;
817
+ try {
818
+ const { hasScreenRecordingPermission } = await import('./mac-window-capture.mjs');
819
+ granted = await hasScreenRecordingPermission();
820
+ } catch (err) {
821
+ checkError = err;
822
+ }
823
+ if (checkError) {
824
+ checks.push(result(WARN, 'macOS Screen Recording (atari8 --screenshot)', `could not check: ${checkError.message}`, null, { targets: [] }));
825
+ } else {
826
+ checks.push(result(
827
+ granted ? OK : WARN,
828
+ 'macOS Screen Recording (atari8 --screenshot)',
829
+ granted ? 'granted' : 'not granted — atari8 --screenshot would capture a blank/black window',
830
+ granted ? null : 'Grant Screen Recording to whatever runs `8bs` (Terminal, your IDE, ...): System Settings -> Privacy & Security -> Screen Recording. Open that pane directly with: open "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"',
831
+ { targets: [] },
832
+ ));
833
+ }
834
+ }
835
+ } else {
836
+ checks.push(result(SKIP, 'macOS Screen Recording (atari8 --screenshot)', 'not macOS — atari8 --screenshot has no equivalent on this platform yet', null, { targets: [] }));
837
+ }
838
+
839
+ return { title: 'Screenshot capability (`8bs run <target> --screenshot`)', checks };
840
+ }
841
+
842
+ // ---- interactive installer -------------------------------------------------
843
+ //
844
+ // A single keypress, only when there's somewhere for the answer to go: both
845
+ // ends of the terminal have to be interactive (stdin AND stdout — a doctor
846
+ // run piped into `less` or a log file has no way to show the prompt or read
847
+ // a reply) and the install has to be an installer this doctor actually knows
848
+ // how to run unattended (a real package-manager command, not a "build it
849
+ // yourself" pointer). CI and `8bs doctor > log.txt` runs never see a prompt.
850
+ function canPromptInteractively() {
851
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
852
+ }
853
+
854
+ /** Read one keypress. Restores whatever raw-mode state stdin had before. */
855
+ function readKey() {
856
+ return new Promise((resolvePromise) => {
857
+ const wasRaw = process.stdin.isRaw;
858
+ process.stdin.setRawMode(true);
859
+ process.stdin.resume();
860
+ process.stdin.once('data', (buf) => {
861
+ process.stdin.setRawMode(Boolean(wasRaw));
862
+ process.stdin.pause();
863
+ resolvePromise(buf.toString('utf8'));
864
+ });
865
+ });
866
+ }
867
+
868
+ function spawnInstall(command, args) {
869
+ process.stdout.write(`\n$ ${command} ${args.join(' ')}\n`);
870
+ return new Promise((resolvePromise) => {
871
+ const child = spawn(command, args, { stdio: 'inherit' });
872
+ child.on('error', () => resolvePromise(false));
873
+ child.on('close', (code) => resolvePromise(code === 0));
874
+ });
875
+ }
876
+
877
+ /**
878
+ * Offer to install one missing tool, right here. Only called for a FAIL
879
+ * check that carries an `installer` this platform has a real plan for
880
+ * (`pickInstallPlan` found a package manager on PATH) — a build-from-source
881
+ * tool, or a platform/manager combination this doctor doesn't recognise,
882
+ * only ever gets the printed hint, never a prompt.
883
+ *
884
+ * @returns {Promise<boolean>} whether an install ran (regardless of outcome)
885
+ */
886
+ async function offerInstall(check) {
887
+ const plan = pickInstallPlan(check.installer);
888
+ if (!plan) return false;
889
+ process.stdout.write(`\n ${check.label}: ${check.installer.label} is missing.\n`);
890
+ process.stdout.write(` Press [i] to install with ${plan.manager} now, any other key to skip: `);
891
+ const key = await readKey();
892
+ process.stdout.write('\n');
893
+ if (key.toLowerCase() !== 'i') return false;
894
+ const command = plan.sudo ? 'sudo' : (plan.manager === 'apt' ? 'apt-get' : plan.manager);
895
+ const args = plan.sudo ? [plan.manager === 'apt' ? 'apt-get' : plan.manager, ...plan.args] : plan.args;
896
+ const ok = await spawnInstall(command, args);
897
+ process.stdout.write(ok ? ` ${plan.manager} reported success.\n` : ` ${plan.manager} reported an error — see the output above.\n`);
898
+ return true;
899
+ }
900
+
901
+ // ---- report ---------------------------------------------------------------
902
+
903
+ const MARK = { [OK]: ' ok', [FAIL]: 'FAIL', [WARN]: 'warn', [SKIP]: ' --' };
904
+
905
+ const ALL_TARGETS = ['web', 'vic20', 'c64', 'pet', 'c128', 'atari8', 'nes', 'cx16', 'mega65'];
906
+
907
+ /**
908
+ * Which of `targets` are ready: every check that named a target passed. WARN
909
+ * doesn't block readiness (e.g. VICE's `--version` flag is broken upstream —
910
+ * prints nothing parseable — even on a working install, and the VIC-20 boot
911
+ * check is the real, authoritative signal for that one target) — only FAIL
912
+ * does. For mega65 specifically, this is what turns four separate checks
913
+ * (mos-mega65-clang, xmega65, MEGA65 ROM, Xemu ROM link) into one readiness
914
+ * bit: all four carry `targets: ['mega65']`, so mega65 is ready only when
915
+ * none of them FAIL.
916
+ */
917
+ export function readyTargets(checks, targets = ALL_TARGETS) {
918
+ return targets.filter((target) => checks
919
+ .filter((c) => c.targets.includes(target))
920
+ .every((c) => c.status !== FAIL));
921
+ }
922
+
923
+ /** @returns {Promise<number>} process exit code */
924
+ export async function doctor() {
925
+ process.stdout.write('8bs doctor\n');
926
+
927
+ const mos = await checkMos();
928
+ const cx16CompilerOk = mos.checks.some((c) => c.label === 'mos-cx16-clang' && c.status === OK);
929
+ const sections = [
930
+ await checkHost(),
931
+ await checkWeb(),
932
+ mos,
933
+ await checkVice(),
934
+ await checkOtherEmulators({ cx16CompilerOk }),
935
+ await checkScreenshotCapability(),
936
+ ];
937
+ const allChecks = sections.flatMap((s) => s.checks);
938
+ let failures = 0;
939
+ let warnings = 0;
940
+
941
+ for (const section of sections) {
942
+ process.stdout.write(`\n${section.title}\n`);
943
+ for (const c of section.checks) {
944
+ if (c.status === FAIL) failures += 1;
945
+ if (c.status === WARN) warnings += 1;
946
+ process.stdout.write(` ${MARK[c.status]} ${c.label.padEnd(28)} ${c.detail}\n`);
947
+ if (c.hint && c.status !== OK) process.stdout.write(` ${c.hint}\n`);
948
+ }
949
+ }
950
+
951
+ const targets = readyTargets(allChecks, ALL_TARGETS);
952
+
953
+ process.stdout.write(
954
+ `\nTargets ready: ${targets.length ? targets.join(', ') : 'none'}.\n`,
955
+ );
956
+
957
+ // Offer to fix what's broken, one tool at a time, before the final
958
+ // summary — only the FAIL checks that carry an installer this platform
959
+ // can actually run unattended, and only in a real interactive terminal.
960
+ const fixable = allChecks.filter((c) => c.status === FAIL && pickInstallPlan(c.installer));
961
+ if (fixable.length && canPromptInteractively()) {
962
+ process.stdout.write(`\n${fixable.length} of those can be installed right now:\n`);
963
+ for (const check of fixable) {
964
+ await offerInstall(check);
965
+ }
966
+ process.stdout.write('\nRe-run `8bs doctor` to confirm.\n');
967
+ return failures > 0 ? 1 : 0;
968
+ }
969
+
970
+ if (failures || warnings) {
971
+ process.stdout.write(`${failures} problem(s), ${warnings} warning(s). The setup guide is docs/setup/.\n`);
972
+ } else {
973
+ process.stdout.write('Everything this project needs is installed.\n');
974
+ }
975
+ return failures > 0 ? 1 : 0;
976
+ }