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