@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/LICENSE +21 -0
- package/bin/8bs.mjs +132 -0
- package/package.json +39 -0
- package/src/build.mjs +309 -0
- package/src/check.mjs +67 -0
- package/src/config.mjs +46 -0
- package/src/doctor.mjs +976 -0
- package/src/font8x8.mjs +27 -0
- package/src/hardware.mjs +444 -0
- package/src/mac-window-capture.mjs +150 -0
- package/src/png.mjs +158 -0
- package/src/run.mjs +311 -0
- package/src/screenshot.mjs +485 -0
- package/src/setup/cx16.mjs +510 -0
- package/src/setup/deps.mjs +103 -0
- package/src/setup/exec.mjs +110 -0
- package/src/setup/host.mjs +58 -0
- package/src/setup/install.mjs +47 -0
- package/src/setup/launcher.mjs +112 -0
- package/src/setup/mega65-rom.mjs +165 -0
- package/src/setup/mega65.mjs +515 -0
- package/src/setup/paths.mjs +96 -0
- package/src/setup/prompt.mjs +28 -0
- package/src/setup/report.mjs +13 -0
- package/src/setup/rom.mjs +188 -0
- package/src/setup/source.mjs +60 -0
- package/src/setup/xemu.mjs +107 -0
- package/src/setup/zip.mjs +75 -0
- package/src/setup.mjs +56 -0
- package/src/targets.mjs +158 -0
- package/src/wasm-host.mjs +75 -0
- package/src/web-runtime.mjs +557 -0
|
@@ -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
|
+
}
|