@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,515 @@
1
+ // `8bs setup mega65` — get a macOS or Arch/Manjaro machine from nothing to a
2
+ // working `8bs run mega65`: the mos-mega65-clang compiler (checked, not
3
+ // installed — see docs/setup/llvm-mos.md), Xemu's MEGA65 core built from
4
+ // source, and a legally-obtained MEGA65 ROM installed where both Xemu and
5
+ // `8bs doctor` expect it. Every step is idempotent — safe to re-run after a
6
+ // partial failure, or just to confirm everything is still in place.
7
+ //
8
+ // Platform strategies are an explicit table (MEGA65_PLATFORMS below), same
9
+ // shape as setup/cx16.mjs's CX16_PLATFORMS — unlike x16emu, xmega65 needs no
10
+ // macOS-specific launcher trick (see xemu.mjs's xmega65LauncherSpec()), but
11
+ // the package manager and host prerequisites still differ per platform.
12
+ import {
13
+ lstat, mkdir as fsMkdir, readFile, readlink, symlink, unlink, writeFile,
14
+ } from 'node:fs/promises';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ import { execCapture, execInherit, sudoRun } from './exec.mjs';
19
+ import { reportStep, reportLine } from './report.mjs';
20
+ import { promptLine, confirm, canPromptInteractively } from './prompt.mjs';
21
+ import {
22
+ missingPacmanPackages, installPacmanPackages, missingBrewPackages, installBrewPackages,
23
+ XEMU_BUILD_PACKAGES, MSITOOLS_PACKAGE, MEGA65_BREW_PACKAGES,
24
+ } from './deps.mjs';
25
+ import { hasBinaryOnPath, hasXcodeCommandLineTools, installXcodeCommandLineTools } from './host.mjs';
26
+ import { pathExists } from './source.mjs';
27
+ import { ensureDirectory } from './install.mjs';
28
+ import { ensureLauncher } from './launcher.mjs';
29
+ import {
30
+ buildXemuMega65, installXemu, xmega65LauncherSpec, ensureXemuDataDir, inspectXemuDataDir,
31
+ } from './xemu.mjs';
32
+ import {
33
+ extractC64ForeverMsi, locateC65BaseRom, fetchRomPatch, buildRomdiff, patchRom, cleanupWorkDir,
34
+ } from './mega65-rom.mjs';
35
+ import {
36
+ C65_BASE_ROM, MEGA65_ROM_920413, validateRomBuffer, ensureXemuRomLink, inspectXemuRomLink,
37
+ } from './rom.mjs';
38
+ import {
39
+ XEMU_INSTALL_DIR, XMEGA65_INSTALL_PATH, XMEGA65_SYMLINK_PATH,
40
+ MEGA65_ROM_CANONICAL_PATH, MEGA65_ROM_INSTALL_DIR, LOCAL_BIN_DIR,
41
+ xemuRomLinkPath, xemuMega65RealDataDir, mega65WorkDir,
42
+ } from './paths.mjs';
43
+
44
+ /**
45
+ * Per-platform installation strategy — same idea as CX16_PLATFORMS, kept as
46
+ * a table rather than `if (darwin)` branches for the same reason: the next
47
+ * source-built target that needs a platform split should add a column here.
48
+ */
49
+ export const MEGA65_PLATFORMS = Object.freeze({
50
+ darwin: Object.freeze({ name: 'macOS', packageManager: 'brew' }),
51
+ linux: Object.freeze({ name: 'Linux', packageManager: 'pacman' }),
52
+ });
53
+
54
+ // msitools (msiextract) is only needed once the ROM step actually extracts a
55
+ // C64 Forever MSI — that flow is Linux-oriented and gated behind the
56
+ // explicit `--c64-forever` flag (see ensureRom() below) — but it's checked
57
+ // alongside the Xemu build dependencies up front on Arch/Manjaro either way:
58
+ // one pacman prompt instead of two, and a no-op `pacman -T` check when it's
59
+ // already installed.
60
+ const ALL_PACMAN_PACKAGES = [...XEMU_BUILD_PACKAGES, MSITOOLS_PACKAGE];
61
+
62
+ /** `~` and `~/...` expansion for a path a user typed at a prompt — the
63
+ * brief's own example input is `~/Downloads/MEGA65.ROM`, and nothing else
64
+ * in this pipeline expands that. */
65
+ export function expandHome(path) {
66
+ if (!path) return path;
67
+ if (path === '~') return homedir();
68
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2));
69
+ return path;
70
+ }
71
+
72
+ // Every I/O boundary setupMega65() touches, as one overridable bag — real by
73
+ // default, fakes in tests (the brief: no sudo, Homebrew/pacman, clones,
74
+ // builds, or GUI launches in unit tests). Same shape as cx16.mjs's defaultIo.
75
+ const defaultIo = {
76
+ platform: process.platform,
77
+ env: process.env,
78
+ exec: execCapture,
79
+ execLive: execInherit,
80
+ sudoExec: sudoRun,
81
+ hasBinary: hasBinaryOnPath,
82
+ exists: pathExists,
83
+ lstatFn: lstat,
84
+ readlinkFn: readlink,
85
+ readFileFn: readFile,
86
+ mkdirFn: fsMkdir,
87
+ writeFileFn: writeFile,
88
+ symlinkFn: symlink,
89
+ unlinkFn: unlink,
90
+ // Injectable so tests can exercise a "valid ROM" outcome without the real
91
+ // copyrighted 920413 bytes — same idea as generateRom()'s own tests,
92
+ // which fake patchRomFn's validation result rather than matching the
93
+ // real hash. Production code never overrides this.
94
+ validateRom: validateRomBuffer,
95
+ confirm,
96
+ promptLine,
97
+ canPromptInteractively,
98
+ workDir: mega65WorkDir,
99
+ buildXemu: buildXemuMega65,
100
+ fetchImpl: (typeof fetch === 'function' ? fetch : undefined),
101
+ };
102
+
103
+ async function checkCompiler(io) {
104
+ const home = io.env.LLVM_MOS_HOME;
105
+ if (!home) return { ok: false, detail: 'LLVM_MOS_HOME is not set — docs/setup/llvm-mos.md' };
106
+ const driver = join(home, 'bin', 'mos-mega65-clang');
107
+ const r = await io.exec(driver, ['--version']);
108
+ if (r.missing) return { ok: false, detail: `mos-mega65-clang not found at ${driver} — docs/setup/llvm-mos.md` };
109
+ return { ok: true, detail: 'mos-mega65-clang' };
110
+ }
111
+
112
+ /** macOS host prerequisites: the Apple Command Line Tools (checked with
113
+ * `xcode-select -p` before ever offering the installer), then Homebrew —
114
+ * found via PATH, never a hardcoded /opt/homebrew, and never bootstrapped by
115
+ * curl on the user's behalf. Same shape as cx16.mjs's ensureMacosPrerequisites(). */
116
+ async function ensureMacosPrerequisites(io) {
117
+ if (!(await hasXcodeCommandLineTools(io.exec))) {
118
+ reportLine('\n The Apple Command Line Tools are required to build xmega65 (xcode-select --install).');
119
+ if (io.canPromptInteractively() && await io.confirm(' Open the Command Line Tools installer now?', { defaultValue: true })) {
120
+ await installXcodeCommandLineTools(io.execLive);
121
+ return { ok: false, label: 'Xcode CLT', detail: 'installer opened — re-run `8bs setup mega65` once it finishes' };
122
+ }
123
+ return { ok: false, label: 'Xcode CLT', detail: 'run: xcode-select --install, then re-run `8bs setup mega65`' };
124
+ }
125
+ reportStep('ok', 'Xcode CLT', 'installed');
126
+ if (!io.hasBinary('brew')) {
127
+ reportLine('\n Homebrew is required by the macOS MEGA65 setup backend, and `brew` is not on PATH.');
128
+ reportLine(' Install it from https://brew.sh (8bs will not run the bootstrap script for you),');
129
+ reportLine(' make sure `brew` is on PATH in this shell, then re-run `8bs setup mega65`.');
130
+ return { ok: false, label: 'Homebrew', detail: 'not found — https://brew.sh' };
131
+ }
132
+ reportStep('ok', 'Homebrew', 'found');
133
+ return { ok: true };
134
+ }
135
+
136
+ async function ensureDependencies(io, platform) {
137
+ if (platform.packageManager === 'brew') {
138
+ const missing = await missingBrewPackages(MEGA65_BREW_PACKAGES, io.exec);
139
+ if (missing.length === 0) return { ok: true, detail: 'installed' };
140
+ reportLine(`\n Xemu's MEGA65 build needs: ${missing.join(' ')}`);
141
+ const hint = `run: brew install ${missing.join(' ')}`;
142
+ if (!io.canPromptInteractively()) return { ok: false, detail: hint };
143
+ if (!(await io.confirm(' Install with Homebrew now?', { defaultValue: true }))) return { ok: false, detail: hint };
144
+ const result = await installBrewPackages(missing, io.execLive);
145
+ if (result.code !== 0) return { ok: false, detail: 'brew reported an error — see the output above' };
146
+ return { ok: true, detail: 'installed' };
147
+ }
148
+ const missing = await missingPacmanPackages(ALL_PACMAN_PACKAGES, io.exec);
149
+ if (missing.length === 0) return { ok: true, detail: 'installed' };
150
+ reportLine(`\n Xemu's MEGA65 build needs: ${missing.join(' ')}`);
151
+ const hint = `run: sudo pacman -S --needed ${missing.join(' ')}`;
152
+ if (!io.canPromptInteractively()) return { ok: false, detail: hint };
153
+ if (!(await io.confirm(' Install with pacman now?', { defaultValue: true }))) return { ok: false, detail: hint };
154
+ const result = await installPacmanPackages(missing, io.sudoExec);
155
+ if (result.code !== 0) return { ok: false, detail: 'pacman reported an error — see the output above' };
156
+ return { ok: true, detail: 'installed' };
157
+ }
158
+
159
+ function describeForeignLauncher(inspection) {
160
+ if (inspection.state === 'foreign-symlink') return `a symlink to ${inspection.target}`;
161
+ const first = (inspection.content ?? '').split('\n').find((l) => l.trim() && !l.startsWith('#!'));
162
+ 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';
163
+ }
164
+
165
+ async function confirmReplaceLauncher(io, inspection) {
166
+ reportLine(`\n ${XMEGA65_SYMLINK_PATH} already exists and is ${describeForeignLauncher(inspection)}.`);
167
+ if (!io.canPromptInteractively()) {
168
+ reportLine(' Not replacing it without confirmation — remove or rename it, then re-run `8bs setup mega65`.');
169
+ return false;
170
+ }
171
+ return io.confirm(' Replace it with the 8bs-managed launcher?', { defaultValue: false });
172
+ }
173
+
174
+ /** `sudo mkdir -p` only when the directory is actually absent — a re-run
175
+ * against a complete install must never prompt for a password just to
176
+ * re-create a directory that's already there. Same helper as cx16.mjs's
177
+ * ensureDirectoryIfMissing(). */
178
+ async function ensureDirectoryIfMissing(io, dir) {
179
+ if (await io.exists(dir)) return { ok: true };
180
+ return ensureDirectory(dir, io.sudoExec);
181
+ }
182
+
183
+ /** Put xmega65 on PATH: a plain symlink works on every platform this
184
+ * project supports (see xemu.mjs's xmega65LauncherSpec() for why that's
185
+ * notable). Foreign content at the launcher path is reported and left
186
+ * alone unless the user confirms replacing it — never silently overwritten. */
187
+ async function ensureXmega65Launcher(io) {
188
+ const bin = await ensureDirectoryIfMissing(io, LOCAL_BIN_DIR);
189
+ if (!bin.ok) return { ok: false, detail: `sudo mkdir ${LOCAL_BIN_DIR} failed (exit ${bin.code})` };
190
+ const fs = { lstatFn: io.lstatFn, readlinkFn: io.readlinkFn, readFileFn: io.readFileFn, mkdirFn: io.mkdirFn, writeFileFn: io.writeFileFn };
191
+ const confirmReplace = (inspection) => confirmReplaceLauncher(io, inspection);
192
+ const result = await ensureLauncher({ ...xmega65LauncherSpec(), sudoExec: io.sudoExec, workDir: io.workDir(), confirmReplace }, fs);
193
+ if (!result.ok) {
194
+ return {
195
+ ok: false,
196
+ detail: result.action === 'skipped-foreign' ? `${XMEGA65_SYMLINK_PATH} left untouched` : `sudo ${result.step} failed (exit ${result.code})`,
197
+ };
198
+ }
199
+ reportStep('ok', 'launcher', `${XMEGA65_SYMLINK_PATH} -> ${XMEGA65_INSTALL_PATH}${result.action === 'unchanged' ? '' : `; ${result.action}`}`);
200
+ return { ok: true };
201
+ }
202
+
203
+ async function ensureEmulator(io) {
204
+ if (await io.exists(XMEGA65_INSTALL_PATH)) {
205
+ reportStep('ok', 'emulator', XMEGA65_INSTALL_PATH);
206
+ } else {
207
+ reportStep('running', 'emulator', 'building Xemu MEGA65 target');
208
+ const build = await io.buildXemu({ exec: io.execLive, exists: io.exists, mkdirFn: io.mkdirFn });
209
+ if (!build.ok) {
210
+ return { ok: false, detail: `xemu build failed at '${build.step}' (exit ${build.code}) — docs/setup/mega65.md` };
211
+ }
212
+ const install = await installXemu(build.binaryPath, { sudoExec: io.sudoExec, installDir: XEMU_INSTALL_DIR, installPath: XMEGA65_INSTALL_PATH });
213
+ if (!install.ok) {
214
+ return { ok: false, detail: `installing xmega65 failed at '${install.step}' (exit ${install.code})` };
215
+ }
216
+ reportStep('ok', 'emulator', install.installPath);
217
+ }
218
+ return ensureXmega65Launcher(io);
219
+ }
220
+
221
+ async function installCanonicalRom(buffer, sudoExec, io) {
222
+ const { mkdirFn, writeFileFn, workDirPath } = io;
223
+ const workPath = join(workDirPath, 'MEGA65.ROM');
224
+ await mkdirFn(workDirPath, { recursive: true });
225
+ await writeFileFn(workPath, buffer);
226
+ const mkdirResult = await sudoExec('mkdir', ['-p', MEGA65_ROM_INSTALL_DIR]);
227
+ if (mkdirResult.code !== 0) return { ok: false, detail: `sudo mkdir ${MEGA65_ROM_INSTALL_DIR} failed` };
228
+ const installResult = await sudoExec('install', ['-m644', workPath, MEGA65_ROM_CANONICAL_PATH]);
229
+ if (installResult.code !== 0) return { ok: false, detail: `sudo install ${MEGA65_ROM_CANONICAL_PATH} failed` };
230
+ return { ok: true };
231
+ }
232
+
233
+ // Every piece of I/O generateRom() touches, as one overridable bag — real
234
+ // implementations by default, fakes in tests. Kept as a single object rather
235
+ // than a long parameter list because every one of these is a genuine
236
+ // external boundary (process, network, filesystem, or a terminal prompt) the
237
+ // brief asks not to exercise for real in a unit test.
238
+ const defaultRomIo = {
239
+ workDirPath: () => mega65WorkDir(),
240
+ exists: pathExists,
241
+ promptLine,
242
+ canPromptInteractively,
243
+ mkdirFn: fsMkdir,
244
+ writeFileFn: writeFile,
245
+ extractMsi: (msiPath, destDir) => extractC64ForeverMsi(msiPath, destDir, execInherit),
246
+ locateBaseRom: locateC65BaseRom,
247
+ fetchPatch: fetchRomPatch,
248
+ buildRomdiffFn: buildRomdiff,
249
+ patchRomFn: patchRom,
250
+ cleanup: cleanupWorkDir,
251
+ };
252
+
253
+ /**
254
+ * Extract the C65 base ROM from a C64 Forever MSI, fetch the official
255
+ * 920413 patch, build romdiff, and patch the two together — the one part of
256
+ * `8bs setup mega65` this project cannot do without the user's own legally
257
+ * obtained files (see docs/setup/mega65.md). Only reached when the user
258
+ * opts in with `--c64-forever` — the macOS milestone's primary path is
259
+ * `--rom`, below, which installs an already-generated ROM directly.
260
+ * Everything downloaded/extracted lives under a scratch work directory that
261
+ * is always cleaned up afterward, success or failure — it can hold
262
+ * copyrighted ROM bytes the user's own C64 Forever install legally
263
+ * supplied, but this project still shouldn't be the thing leaving them
264
+ * sitting around indefinitely after an error.
265
+ */
266
+ export async function generateRom(options, sudoExec, fetchImpl, ioOverrides = {}) {
267
+ const io = { ...defaultRomIo, ...ioOverrides };
268
+ reportLine('\n The full MEGA65 ROM cannot be redistributed by 8BitScript.\n');
269
+ reportLine(' Download the free C64 Forever installer from Cloanto, then provide');
270
+ reportLine(' the MSI path.\n');
271
+
272
+ let msiPath = options.c64ForeverPath;
273
+ if (!msiPath) {
274
+ if (!io.canPromptInteractively()) {
275
+ return { ok: false, detail: 'no C64 Forever MSI given — pass --c64-forever <path/to/c64-forever-*.msi>' };
276
+ }
277
+ msiPath = await io.promptLine(' C64 Forever MSI: ');
278
+ }
279
+ if (!msiPath || !(await io.exists(msiPath))) {
280
+ return { ok: false, detail: `C64 Forever MSI not found at '${msiPath || '(none given)'}'` };
281
+ }
282
+
283
+ const workDir = typeof io.workDirPath === 'function' ? io.workDirPath() : io.workDirPath;
284
+ const extractDir = join(workDir, 'c64forever');
285
+ try {
286
+ await io.mkdirFn(extractDir, { recursive: true });
287
+ reportStep('running', 'C65 ROM', 'extracting the C64 Forever installer');
288
+ const extraction = await io.extractMsi(msiPath, extractDir);
289
+ if (extraction.missing) {
290
+ return { ok: false, detail: 'msiextract not found — is msitools installed? sudo pacman -S --needed msitools' };
291
+ }
292
+ if (extraction.code !== 0) {
293
+ return { ok: false, detail: `msiextract failed (exit ${extraction.code})` };
294
+ }
295
+
296
+ const base = await io.locateBaseRom(extractDir);
297
+ if (!base) {
298
+ return { ok: false, detail: `could not find ${C65_BASE_ROM.filename} inside the extracted MSI` };
299
+ }
300
+ if (!base.validation.ok) {
301
+ return {
302
+ ok: false,
303
+ detail: `${C65_BASE_ROM.filename} (size ${base.validation.size}, sha256 ${base.validation.sha256}) `
304
+ + 'does not match the expected C65 910828 ROM — this is not the expected base ROM; stopping',
305
+ };
306
+ }
307
+ reportStep('ok', 'C65 ROM', '910828');
308
+
309
+ reportStep('running', 'ROM patch', `downloading official MEGA65 ${MEGA65_ROM_920413.release} patch`);
310
+ let patch;
311
+ try {
312
+ patch = await io.fetchPatch({ localZipPath: options.romPatchPath, fetchImpl });
313
+ } catch (error) {
314
+ return { ok: false, detail: error.message };
315
+ }
316
+
317
+ reportStep('running', 'romdiff', 'building the official MEGA65 patch tool');
318
+ const romdiff = await io.buildRomdiffFn();
319
+ if (!romdiff.ok) {
320
+ return {
321
+ ok: false,
322
+ detail: `building romdiff failed at '${romdiff.step}' (exit ${romdiff.code}) — docs/setup/mega65.md`,
323
+ };
324
+ }
325
+
326
+ const rdfPath = join(workDir, 'patch.rdf');
327
+ await io.writeFileFn(rdfPath, patch.rdfBytes);
328
+ const patched = await io.patchRomFn({
329
+ romdiffPath: romdiff.binaryPath,
330
+ rdfPath,
331
+ workDir: join(workDir, 'patched'),
332
+ baseRomPath: base.path,
333
+ header: patch.header,
334
+ });
335
+ if (!patched.ok) {
336
+ return {
337
+ ok: false,
338
+ detail: patched.validation
339
+ ? `generated ROM did not match the expected ${MEGA65_ROM_920413.release} hash (got ${patched.validation.sha256})`
340
+ : `romdiff failed: ${(patched.output ?? '').trim().split('\n').pop() || 'unknown error'}`,
341
+ };
342
+ }
343
+ reportStep('ok', 'MEGA65 ROM', 'generated');
344
+
345
+ const installed = await installCanonicalRom(patched.buffer, sudoExec, { ...io, workDirPath: workDir });
346
+ if (!installed.ok) return installed;
347
+ return { ok: true, ready: true, detail: MEGA65_ROM_920413.release };
348
+ } finally {
349
+ await io.cleanup(workDir);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Install an already-generated MEGA65 ROM file directly — `--rom
355
+ * /path/to/MEGA65.ROM`, or the interactive prompt's answer. This is the
356
+ * macOS milestone's primary ROM path: no C64 Forever MSI, no network patch
357
+ * fetch, just validate-and-install. 8BitScript currently pins the 920413
358
+ * release as the one it verifies for compatibility (see docs/setup/
359
+ * mega65.md) — a right-sized ROM with a different hash is installed anyway
360
+ * (per the brief: never silently reject or delete a same-size ROM of
361
+ * unknown provenance) but reported as unverified, and `ready: false` so the
362
+ * caller doesn't print "MEGA65 is ready" over it; `8bs doctor` will agree
363
+ * and report it as not ready too, rather than the two disagreeing.
364
+ */
365
+ export async function installProvidedRom(romPath, sudoExec, io = {}) {
366
+ const {
367
+ readFileFn = readFile, mkdirFn = fsMkdir, writeFileFn = writeFile, workDirPath = mega65WorkDir(),
368
+ validateRom = validateRomBuffer,
369
+ } = io;
370
+ let buffer;
371
+ try {
372
+ buffer = await readFileFn(romPath);
373
+ } catch {
374
+ return { ok: false, detail: `ROM not found at '${romPath}'` };
375
+ }
376
+ const validation = validateRom(buffer, { size: MEGA65_ROM_920413.romSize, sha256: MEGA65_ROM_920413.romSha256 });
377
+ if (!validation.sizeOk) {
378
+ return { ok: false, detail: `'${romPath}' is ${validation.size} bytes — a full MEGA65 ROM is ${MEGA65_ROM_920413.romSize} bytes` };
379
+ }
380
+ const installed = await installCanonicalRom(buffer, sudoExec, { mkdirFn, writeFileFn, workDirPath });
381
+ if (!installed.ok) return installed;
382
+ if (validation.hashOk) return { ok: true, ready: true, detail: MEGA65_ROM_920413.release };
383
+ return {
384
+ ok: true,
385
+ ready: false,
386
+ detail: `installed, but sha256 ${validation.sha256} is not the known-good ${MEGA65_ROM_920413.release} — unverified ROM version`,
387
+ };
388
+ }
389
+
390
+ async function ensureRom(options, io) {
391
+ let canonicalBuffer = null;
392
+ try {
393
+ canonicalBuffer = await io.readFileFn(MEGA65_ROM_CANONICAL_PATH);
394
+ } catch {
395
+ // Not installed yet — fall through below.
396
+ }
397
+ if (canonicalBuffer) {
398
+ const validation = io.validateRom(canonicalBuffer, { size: MEGA65_ROM_920413.romSize, sha256: MEGA65_ROM_920413.romSha256 });
399
+ if (validation.ok) return { ok: true, ready: true, detail: MEGA65_ROM_920413.release };
400
+ reportLine(
401
+ `\n A ROM already exists at ${MEGA65_ROM_CANONICAL_PATH} but does not match the known `
402
+ + `full MEGA65 ROM (${MEGA65_ROM_920413.release}) — it may be an Open ROM or a different `
403
+ + 'release. Regenerating.',
404
+ );
405
+ }
406
+
407
+ // Explicit opt-in only: generating a ROM from a C64 Forever MSI is a
408
+ // separate, Linux-oriented flow (see generateRom()'s own module comment)
409
+ // that isn't offered by default any more now that --rom exists.
410
+ if (options.c64ForeverPath) {
411
+ return generateRom(options, io.sudoExec, io.fetchImpl);
412
+ }
413
+
414
+ let romPath = options.romPath;
415
+ if (!romPath) {
416
+ if (!io.canPromptInteractively()) {
417
+ return { ok: false, detail: 'no MEGA65 ROM given — pass --rom /path/to/MEGA65.ROM (or --c64-forever <msi> to generate one)' };
418
+ }
419
+ reportLine('\n The full MEGA65 ROM cannot be redistributed by 8BitScript.\n');
420
+ romPath = expandHome(await io.promptLine(' Path to MEGA65.ROM: '));
421
+ }
422
+ if (!romPath) return { ok: false, detail: 'no MEGA65 ROM path given' };
423
+ return installProvidedRom(romPath, io.sudoExec, {
424
+ readFileFn: io.readFileFn, mkdirFn: io.mkdirFn, writeFileFn: io.writeFileFn, workDirPath: io.workDir(), validateRom: io.validateRom,
425
+ });
426
+ }
427
+
428
+ async function ensureXemuLink(io) {
429
+ const linkPath = xemuRomLinkPath();
430
+ const fsBag = { lstatFn: io.lstatFn, readlinkFn: io.readlinkFn, readFileFn: io.readFileFn };
431
+ const inspection = await inspectXemuRomLink(linkPath, MEGA65_ROM_CANONICAL_PATH, MEGA65_ROM_920413, fsBag);
432
+ let allowMigrate = false;
433
+ if (inspection.state === 'migratable') {
434
+ allowMigrate = io.canPromptInteractively()
435
+ ? await io.confirm(` ${linkPath} already has a valid MEGA65 ROM — replace it with a link to the canonical install?`, { defaultValue: true })
436
+ : false;
437
+ }
438
+ return ensureXemuRomLink(linkPath, MEGA65_ROM_CANONICAL_PATH, {
439
+ allowMigrate, expected: MEGA65_ROM_920413, inspect: () => inspection,
440
+ mkdir: io.mkdirFn, symlink: io.symlinkFn, unlink: io.unlinkFn,
441
+ });
442
+ }
443
+
444
+ /**
445
+ * @param {{ c64ForeverPath?: string, romPatchPath?: string, romPath?: string, repair?: boolean }} options
446
+ * `--repair` is accepted for readability (it's what `8bs doctor` suggests
447
+ * for a broken launcher or ROM link) and behaves exactly like a plain
448
+ * run — every step below already checks what's in place first and only
449
+ * repairs what's actually wrong.
450
+ * @returns {Promise<{ok: boolean}>}
451
+ */
452
+ export async function setupMega65(options = {}, ioOverrides = {}) {
453
+ const io = { ...defaultIo, ...ioOverrides };
454
+ reportLine('MEGA65 setup\n');
455
+
456
+ const platform = MEGA65_PLATFORMS[io.platform];
457
+ if (!platform) {
458
+ reportStep('attn', 'platform', `no MEGA65 setup backend for '${io.platform}' — docs/setup/mega65.md`);
459
+ return { ok: false };
460
+ }
461
+
462
+ const compiler = await checkCompiler(io);
463
+ reportStep(compiler.ok ? 'ok' : 'attn', 'compiler', compiler.detail);
464
+ if (!compiler.ok) return { ok: false };
465
+
466
+ if (io.platform === 'darwin') {
467
+ const host = await ensureMacosPrerequisites(io);
468
+ if (!host.ok) {
469
+ reportStep('attn', host.label, host.detail);
470
+ return { ok: false };
471
+ }
472
+ }
473
+
474
+ const deps = await ensureDependencies(io, platform);
475
+ reportStep(deps.ok ? 'ok' : 'attn', 'dependencies', deps.detail);
476
+ if (!deps.ok) return { ok: false };
477
+
478
+ const emulator = await ensureEmulator(io);
479
+ if (!emulator.ok) {
480
+ reportStep('attn', 'emulator', emulator.detail);
481
+ return { ok: false };
482
+ }
483
+
484
+ const rom = await ensureRom(options, io);
485
+ if (!rom.ok) {
486
+ reportStep('attn', 'ROM', rom.detail ?? 'full MEGA65 ROM required');
487
+ return { ok: false };
488
+ }
489
+ reportStep(rom.ready ? 'ok' : 'attn', 'MEGA65 ROM', rom.detail);
490
+ reportStep('ok', 'installed', MEGA65_ROM_CANONICAL_PATH);
491
+ if (!rom.ready) {
492
+ reportLine(`\nMEGA65 ROM installed, but it isn't the verified ${MEGA65_ROM_920413.release} release — \`8bs doctor\` will report it as not ready.`);
493
+ return { ok: false };
494
+ }
495
+
496
+ const dataDir = await ensureXemuDataDir({
497
+ platform: io.platform,
498
+ mkdirFn: io.mkdirFn,
499
+ symlinkFn: io.symlinkFn,
500
+ inspect: (linkPath) => inspectXemuDataDir(linkPath, { lstatFn: io.lstatFn, readlinkFn: io.readlinkFn }),
501
+ });
502
+ reportStep('ok', 'Xemu data dir', dataDir.action === 'created' ? dataDir.realDir : (dataDir.target ?? xemuMega65RealDataDir(io.platform)));
503
+
504
+ const link = await ensureXemuLink(io);
505
+ if (link.action === 'skipped-foreign') {
506
+ reportStep('attn', 'Xemu ROM', `${xemuRomLinkPath()} exists and isn't the MEGA65 ROM — left untouched`);
507
+ } else if (link.action === 'skipped-migratable') {
508
+ reportStep('attn', 'Xemu ROM', `left as-is at ${xemuRomLinkPath()} (already a valid ROM)`);
509
+ } else {
510
+ reportStep('ok', 'Xemu ROM', xemuRomLinkPath());
511
+ }
512
+
513
+ reportLine('\nMEGA65 is ready.');
514
+ return { ok: true };
515
+ }
@@ -0,0 +1,96 @@
1
+ // Canonical filesystem locations `8bs setup` reads and writes. Centralised
2
+ // so doctor.mjs's readiness checks and setup/mega65.mjs's install steps
3
+ // agree on where things live without duplicating the paths by hand.
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ /** Where source checkouts/build trees for setup-built tools are cached,
8
+ * rather than assuming ~/Development or dropping them in cwd. XDG_CACHE_HOME
9
+ * is honoured for anyone who has set it; ~/.cache is the common default on
10
+ * Arch/Manjaro either way. */
11
+ export function setupCacheDir() {
12
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
13
+ return join(base, '8bitscript', 'setup');
14
+ }
15
+
16
+ /** Where `8bs run --screenshot`'s macOS window-capture helper (see
17
+ * ../mac-window-capture.mjs) caches its compiled binary, so it's built
18
+ * once per machine rather than on every atari8 screenshot. */
19
+ export function screenshotCacheDir() {
20
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
21
+ return join(base, '8bitscript', 'screenshot');
22
+ }
23
+
24
+ export function xemuSourceDir() {
25
+ return join(setupCacheDir(), 'xemu');
26
+ }
27
+
28
+ export function mega65ToolsSourceDir() {
29
+ return join(setupCacheDir(), 'mega65-tools');
30
+ }
31
+
32
+ /** Scratch space for MSI extraction and ROM-patch downloads — contents are
33
+ * disposable and never installed from directly; see setup/mega65.mjs. */
34
+ export function mega65WorkDir() {
35
+ return join(setupCacheDir(), 'mega65-rom-work');
36
+ }
37
+
38
+ export const XEMU_INSTALL_DIR = '/opt/xemu';
39
+ export const XMEGA65_INSTALL_PATH = '/opt/xemu/xmega65';
40
+ export const XMEGA65_SYMLINK_PATH = '/usr/local/bin/xmega65';
41
+
42
+ export const MEGA65_ROM_INSTALL_DIR = '/opt/mega65';
43
+ export const MEGA65_ROM_CANONICAL_PATH = '/opt/mega65/MEGA65.ROM';
44
+
45
+ /** Xemu's own compatibility symlink, on every platform: `~/.xemu-lgb`,
46
+ * pointing at the real per-platform data directory below. It may not exist
47
+ * yet if Xemu has never been run; setup creates the same layout itself if
48
+ * needed rather than requiring a prior launch — see setup/xemu.mjs's
49
+ * ensureXemuDataDir(). */
50
+ export function xemuUserDataDir() {
51
+ return join(homedir(), '.xemu-lgb');
52
+ }
53
+
54
+ export function xemuRomLinkPath() {
55
+ return join(xemuUserDataDir(), 'MEGA65.ROM');
56
+ }
57
+
58
+ /** Xemu's real, platform-specific MEGA65 data directory — what
59
+ * `~/.xemu-lgb` is a compatibility symlink *to*. Confirmed against a real
60
+ * first launch on both platforms: macOS uses the standard Application
61
+ * Support location, Linux uses XDG's `~/.local/share`. */
62
+ export function xemuMega65RealDataDir(platform = process.platform) {
63
+ return platform === 'darwin'
64
+ ? join(homedir(), 'Library', 'Application Support', 'xemu-lgb', 'mega65')
65
+ : join(homedir(), '.local', 'share', 'xemu-lgb', 'mega65');
66
+ }
67
+
68
+ // ---- Commander X16 ---------------------------------------------------------
69
+ //
70
+ // The emulator (x16-emulator) and its ROM (x16-rom) are two upstream
71
+ // repositories that have to be built from the same point in time — upstream
72
+ // is explicit that an emulator expects a matching ROM — so they get two
73
+ // sibling source checkouts under the same cache and one shared install dir.
74
+
75
+ export function x16EmulatorSourceDir() {
76
+ return join(setupCacheDir(), 'x16-emulator');
77
+ }
78
+
79
+ export function x16RomSourceDir() {
80
+ return join(setupCacheDir(), 'x16-rom');
81
+ }
82
+
83
+ /** Scratch space for the wrapper script setup stages before `sudo install`
84
+ * copies it into /usr/local/bin — written as the normal user, never as root. */
85
+ export function cx16WorkDir() {
86
+ return join(setupCacheDir(), 'cx16-work');
87
+ }
88
+
89
+ export const CX16_INSTALL_DIR = '/opt/commander-x16';
90
+ export const X16EMU_INSTALL_PATH = '/opt/commander-x16/x16emu';
91
+ export const MAKECART_INSTALL_PATH = '/opt/commander-x16/makecart';
92
+ export const CX16_ROM_INSTALL_PATH = '/opt/commander-x16/rom.bin';
93
+
94
+ export const LOCAL_BIN_DIR = '/usr/local/bin';
95
+ export const X16EMU_LAUNCHER_PATH = '/usr/local/bin/x16emu';
96
+ export const MAKECART_LAUNCHER_PATH = '/usr/local/bin/makecart';
@@ -0,0 +1,28 @@
1
+ // Interactive input for `8bs setup`. Line-based (a filesystem path, unlike
2
+ // doctor.mjs's single-keypress install offer) so a pasted path with spaces
3
+ // reads back correctly.
4
+ import { createInterface } from 'node:readline/promises';
5
+
6
+ export async function promptLine(question) {
7
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8
+ try {
9
+ return (await rl.question(question)).trim();
10
+ } finally {
11
+ rl.close();
12
+ }
13
+ }
14
+
15
+ export async function confirm(question, { defaultValue = false } = {}) {
16
+ const suffix = defaultValue ? 'Y/n' : 'y/N';
17
+ const answer = (await promptLine(`${question} [${suffix}] `)).toLowerCase();
18
+ if (!answer) return defaultValue;
19
+ return answer.startsWith('y');
20
+ }
21
+
22
+ /** Both ends of the terminal have to be interactive — stdin AND stdout — for
23
+ * a prompt to have anywhere to go; a `8bs setup mega65` run piped into a log
24
+ * file, or invoked from CI, must never block waiting on input that will
25
+ * never arrive. Mirrors doctor.mjs's own canPromptInteractively(). */
26
+ export function canPromptInteractively() {
27
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
28
+ }
@@ -0,0 +1,13 @@
1
+ // Shared step-reporting for `8bs setup <target>` — one line per step, in the
2
+ // spirit of doctor.mjs's own `ok`/`FAIL`/`warn` markers but reused across
3
+ // setup targets rather than tied to doctor's specific check shape.
4
+ const MARK = { ok: ' ok', running: ' ..', attn: ' !!' };
5
+
6
+ export function reportStep(status, label, detail = '') {
7
+ const mark = MARK[status] ?? ' ??';
8
+ process.stdout.write(`${mark} ${label.padEnd(14)} ${detail}\n`);
9
+ }
10
+
11
+ export function reportLine(text = '') {
12
+ process.stdout.write(`${text}\n`);
13
+ }