@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,110 @@
1
+ // Process execution for `8bs setup`. Two shapes, both plain argv arrays
2
+ // (never a shell string — see execCapture/execInherit below), plus a sudo
3
+ // wrapper that is the *only* place elevated privileges are allowed to enter
4
+ // this pipeline. Kept separate from doctor.mjs's own private `run()`: that
5
+ // one buffers output for parsing a version string; a source build wants its
6
+ // output streamed to the terminal live, and setup also needs a sudo path
7
+ // doctor's checks never do.
8
+ import { spawn } from 'node:child_process';
9
+
10
+ /** Run a command, buffering stdout/stderr. For output setup needs to parse
11
+ * or validate (e.g. `pacman -Qi`) rather than show the user directly. */
12
+ export function execCapture(command, args, { timeout = 30_000, cwd } = {}) {
13
+ return new Promise((resolvePromise) => {
14
+ let child;
15
+ try {
16
+ child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], cwd });
17
+ } catch {
18
+ resolvePromise({ code: null, stdout: '', stderr: '', missing: true });
19
+ return;
20
+ }
21
+ let stdout = '';
22
+ let stderr = '';
23
+ const timer = timeout ? setTimeout(() => child.kill('SIGKILL'), timeout) : null;
24
+ child.stdout.on('data', (d) => { stdout += d; });
25
+ child.stderr.on('data', (d) => { stderr += d; });
26
+ child.on('error', () => {
27
+ if (timer) clearTimeout(timer);
28
+ resolvePromise({ code: null, stdout, stderr, missing: true });
29
+ });
30
+ child.on('close', (code) => {
31
+ if (timer) clearTimeout(timer);
32
+ resolvePromise({ code, stdout, stderr, missing: false });
33
+ });
34
+ });
35
+ }
36
+
37
+ /** Run a command with its stdio connected straight to this process — for a
38
+ * build (`make`), a clone (`git`), or an extraction (`msiextract`) whose
39
+ * live output the user should see, and whose exit code is the only thing
40
+ * that matters afterward. Never treats stderr output alone as failure: the
41
+ * brief is explicit that harmless compiler warnings are not a build error —
42
+ * only a non-zero exit code (or, separately, a missing resulting binary) is. */
43
+ export function execInherit(command, args, { cwd } = {}) {
44
+ return new Promise((resolvePromise) => {
45
+ let child;
46
+ try {
47
+ child = spawn(command, args, { stdio: 'inherit', cwd });
48
+ } catch {
49
+ resolvePromise({ code: null, missing: true });
50
+ return;
51
+ }
52
+ child.on('error', () => resolvePromise({ code: null, missing: true }));
53
+ child.on('close', (code) => resolvePromise({ code, missing: false }));
54
+ });
55
+ }
56
+
57
+ // The only destinations any `8bs setup <target>` is allowed to write to as
58
+ // root: one /opt/<tool> directory per source-built tool, plus /usr/local/bin
59
+ // for the launchers that put them on PATH. Adding a target means adding its
60
+ // /opt directory here — deliberately, in one place — rather than widening
61
+ // the check.
62
+ export const SUDO_ALLOWED_ROOTS = Object.freeze([
63
+ '/opt/xemu', '/opt/mega65', '/opt/commander-x16', '/usr/local/bin',
64
+ ]);
65
+
66
+ /** Last non-flag argument — for `mkdir -p <dir>`, `install -m755 <src>
67
+ * <dst>`, and `ln -sf <target> <linkname>`, that's the one path each of
68
+ * these commands actually *writes* to (a copy/link source, like `install`'s
69
+ * <src>, is only ever read — it isn't a privilege-escalation target and
70
+ * legitimately lives outside the allowed roots, e.g. under the user's own
71
+ * ~/.cache build tree). */
72
+ function writeTarget(args) {
73
+ const positionals = args.filter((a) => !a.startsWith('-'));
74
+ return positionals[positionals.length - 1];
75
+ }
76
+
77
+ /**
78
+ * Enforce the brief's sudo allowlist at the one place every elevated call in
79
+ * this pipeline goes through, rather than leaving it as a comment code
80
+ * review has to keep true by hand: `pacman` (package installation) is always
81
+ * allowed; `mkdir`/`install`/`ln` are allowed only when the path they write
82
+ * to falls under SUDO_ALLOWED_ROOTS; anything else is refused outright.
83
+ * Homebrew is deliberately *not* here: `brew` must never run as root (it
84
+ * refuses to), so setup runs it as the normal user through execInherit.
85
+ * Throws rather than silently skipping, since a caller reaching this with an
86
+ * unexpected command/target is a bug worth surfacing immediately.
87
+ */
88
+ export function assertSudoAllowed(command, args) {
89
+ if (command === 'pacman') return;
90
+ if (command === 'mkdir' || command === 'install' || command === 'ln') {
91
+ const target = writeTarget(args);
92
+ const allowed = target && SUDO_ALLOWED_ROOTS.some((root) => target === root || target.startsWith(`${root}/`));
93
+ if (allowed) return;
94
+ throw new Error(
95
+ `refusing 'sudo ${command} ${args.join(' ')}' — '${target}' is outside the allowed `
96
+ + `sudo write targets (${SUDO_ALLOWED_ROOTS.join(', ')})`,
97
+ );
98
+ }
99
+ throw new Error(`refusing 'sudo ${command}' — not one of the sudo operations this setup pipeline allows`);
100
+ }
101
+
102
+ /** Run one command under sudo, as an explicit argv array — never a shell
103
+ * string, so a path or filename containing spaces or shell metacharacters
104
+ * can't be reinterpreted. `-n` (non-interactive) is deliberately not passed:
105
+ * a real sudo password prompt should work normally; this only removes the
106
+ * possibility of silently shelling out through an interpreter. */
107
+ export function sudoRun(command, args, opts) {
108
+ assertSudoAllowed(command, args);
109
+ return execInherit('sudo', [command, ...args], opts);
110
+ }
@@ -0,0 +1,58 @@
1
+ // Host-platform prerequisites for `8bs setup` that aren't packages: the
2
+ // Apple Command Line Tools on macOS, and PATH inspection every launcher
3
+ // install needs. Process boundaries are injected (an `exec` matching
4
+ // exec.mjs's execCapture/execInherit) so unit tests never run them.
5
+ import { existsSync } from 'node:fs';
6
+ import { delimiter, join } from 'node:path';
7
+
8
+ /** Is `name` an executable on PATH? Same logic as doctor.mjs's private
9
+ * onPath(); exported here so setup shares it rather than re-deriving it. */
10
+ export function hasBinaryOnPath(name, env = process.env, platform = process.platform) {
11
+ const binary = platform === 'win32' ? `${name}.exe` : name;
12
+ return (env.PATH ?? '')
13
+ .split(delimiter)
14
+ .some((dir) => dir && existsSync(join(dir, binary)));
15
+ }
16
+
17
+ /** Is `dir` one of PATH's entries? `/usr/local/bin` is the tested launcher
18
+ * location on both macOS and Linux, but a minimal PATH (some CI images,
19
+ * some shells' non-login profiles) can omit it — in which case a launcher
20
+ * installed there is real but invisible, and setup should say so. */
21
+ export function isDirOnPath(dir, env = process.env) {
22
+ return (env.PATH ?? '').split(delimiter).some((entry) => entry && entry.replace(/\/+$/, '') === dir);
23
+ }
24
+
25
+ /**
26
+ * `xcode-select -p` exits 0 (printing the active developer directory) only
27
+ * when the Command Line Tools — or a full Xcode — are installed; it exits
28
+ * non-zero with "unable to get active developer directory" otherwise.
29
+ * Checked before ever offering `xcode-select --install`, so a machine that
30
+ * already has them is never asked again (the install command is a GUI
31
+ * dialog, not something to re-trigger on every run).
32
+ */
33
+ export async function hasXcodeCommandLineTools(exec) {
34
+ const r = await exec('xcode-select', ['-p']);
35
+ return !r.missing && r.code === 0;
36
+ }
37
+
38
+ /** Trigger Apple's Command Line Tools installer. This opens a macOS dialog
39
+ * and returns immediately — the download runs outside our process — so the
40
+ * caller has to tell the user to re-run setup once it finishes, rather than
41
+ * waiting on the exit code. */
42
+ export function installXcodeCommandLineTools(exec) {
43
+ return exec('xcode-select', ['--install']);
44
+ }
45
+
46
+ /** Full path of the first `name` on PATH, or null — what `command -v`
47
+ * answers. The doctor reports this for x16emu so a reader sees *which*
48
+ * launcher is in play (`/usr/local/bin/x16emu`), and inspects that exact
49
+ * file for the macOS symlink trap. */
50
+ export function resolveOnPath(name, env = process.env, platform = process.platform) {
51
+ const binary = platform === 'win32' ? `${name}.exe` : name;
52
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
53
+ if (!dir) continue;
54
+ const candidate = join(dir, binary);
55
+ if (existsSync(candidate)) return candidate;
56
+ }
57
+ return null;
58
+ }
@@ -0,0 +1,47 @@
1
+ // Privileged file installation into /opt/<tool> and /usr/local/bin — the
2
+ // only place `8bs setup` writes as root, and always through exec.mjs's
3
+ // sudoRun allowlist. Files are compared before they're copied, so a re-run
4
+ // against an already-complete install never asks for a password just to
5
+ // overwrite a byte-identical binary.
6
+ import { readFile } from 'node:fs/promises';
7
+
8
+ /** Byte-for-byte comparison of two files; `false` when either is unreadable
9
+ * (missing, or a directory), which callers treat as "needs installing". */
10
+ export async function filesIdentical(a, b, read = readFile) {
11
+ try {
12
+ const [x, y] = await Promise.all([read(a), read(b)]);
13
+ return x.equals(y);
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ /** `sudo mkdir -p <dir>` — idempotent, and refused by sudoRun's allowlist
20
+ * for any directory outside the roots exec.mjs names. */
21
+ export async function ensureDirectory(dir, sudoExec) {
22
+ const r = await sudoExec('mkdir', ['-p', dir]);
23
+ if (r.code !== 0) return { ok: false, step: 'mkdir', code: r.code, path: dir };
24
+ return { ok: true };
25
+ }
26
+
27
+ /**
28
+ * `sudo install -m<mode> <src> <dst>` for each file that isn't already in
29
+ * place with identical contents. Returns which paths were actually written
30
+ * so the caller can report "installed" vs "already current" honestly.
31
+ * Stops at the first failed step — a half-installed layout is reported, not
32
+ * papered over by the steps after it.
33
+ */
34
+ export async function installFiles(files, sudoExec, { identical = filesIdentical } = {}) {
35
+ const installed = [];
36
+ const unchanged = [];
37
+ for (const { src, dst, mode } of files) {
38
+ if (await identical(src, dst)) {
39
+ unchanged.push(dst);
40
+ continue;
41
+ }
42
+ const r = await sudoExec('install', [`-m${mode}`, src, dst]);
43
+ if (r.code !== 0) return { ok: false, step: 'install', code: r.code, path: dst, installed, unchanged };
44
+ installed.push(dst);
45
+ }
46
+ return { ok: true, installed, unchanged };
47
+ }
@@ -0,0 +1,112 @@
1
+ // PATH launchers for source-built tools installed under /opt/<tool>: the
2
+ // `/usr/local/bin/<name>` entry that makes `8bs run` (and the user) able to
3
+ // type the bare command. Two kinds, chosen per tool *and per platform* by
4
+ // the target's setup module — never assumed:
5
+ //
6
+ // symlink /usr/local/bin/<name> -> /opt/<tool>/<name>
7
+ // wrapper a two-line /bin/sh script that exec's the real binary with
8
+ // explicit arguments
9
+ //
10
+ // The wrapper exists because of a real, tested macOS failure: x16emu
11
+ // locates its default rom.bin relative to the path it was *invoked* through,
12
+ // so a direct symlink in /usr/local/bin makes it look for
13
+ // /usr/local/bin/rom.bin and die with "Cannot open /usr/local/bin/rom.bin!".
14
+ // On Linux the same symlink resolves fine. See setup/cx16.mjs for the
15
+ // per-platform choice, and inspectLauncher() below for how a launcher that
16
+ // exists but wasn't installed by this project is recognised and left alone
17
+ // until the user says otherwise.
18
+ import { lstat, readlink, readFile, mkdir, writeFile } from 'node:fs/promises';
19
+ import { join, basename } from 'node:path';
20
+
21
+ export const MANAGED_MARKER = '# managed by 8bs setup — re-run `8bs setup <target>` rather than editing';
22
+
23
+ /** The wrapper's exact contents. The `exec` line is the tested one:
24
+ * `exec /opt/commander-x16/x16emu -rom /opt/commander-x16/rom.bin "$@"` for
25
+ * x16emu on macOS — `"$@"` last so every argument `8bs run` passes (-prg,
26
+ * -run) still reaches the emulator after the explicit ROM. */
27
+ export function wrapperScript(execLine) {
28
+ return `#!/bin/sh\n${MANAGED_MARKER}\n${execLine}\n`;
29
+ }
30
+
31
+ const defaultFs = { lstatFn: lstat, readlinkFn: readlink, readFileFn: readFile };
32
+
33
+ /**
34
+ * What's at `path` right now, classified against what this project would
35
+ * put there (`target` for a symlink, `execLine` for a wrapper):
36
+ *
37
+ * missing nothing there
38
+ * symlink a symlink to exactly `target` — correct on Linux, the
39
+ * broken layout on macOS; the caller's strategy decides
40
+ * wrapper a script whose exec line is exactly `execLine`
41
+ * stale-wrapper a script this project wrote (carries MANAGED_MARKER)
42
+ * whose exec line is now out of date — safe to rewrite
43
+ * foreign-symlink a symlink somewhere else — not ours; ask before touching
44
+ * foreign-file a file (or directory) this project didn't write — same
45
+ *
46
+ * Pure classification: no writes, and every filesystem call is injectable.
47
+ */
48
+ export async function inspectLauncher({ path, target, execLine }, fs = {}) {
49
+ const { lstatFn, readlinkFn, readFileFn } = { ...defaultFs, ...fs };
50
+ let stats;
51
+ try {
52
+ stats = await lstatFn(path);
53
+ } catch {
54
+ return { state: 'missing', path };
55
+ }
56
+ if (stats.isSymbolicLink()) {
57
+ const linkTarget = await readlinkFn(path);
58
+ return { state: linkTarget === target ? 'symlink' : 'foreign-symlink', path, target: linkTarget };
59
+ }
60
+ if (!stats.isFile()) return { state: 'foreign-file', path };
61
+ let content;
62
+ try {
63
+ content = String(await readFileFn(path));
64
+ } catch {
65
+ return { state: 'foreign-file', path };
66
+ }
67
+ const lines = content.split('\n').map((l) => l.trim());
68
+ if (execLine && lines.includes(execLine.trim())) return { state: 'wrapper', path, content };
69
+ if (content.includes(MANAGED_MARKER)) return { state: 'stale-wrapper', path, content };
70
+ return { state: 'foreign-file', path, content };
71
+ }
72
+
73
+ /**
74
+ * Make `path` the launcher `kind` ('symlink' | 'wrapper') for `target`.
75
+ * Idempotent: an already-correct launcher is left untouched with no sudo
76
+ * call at all. Anything this project put there (the other kind, or a stale
77
+ * wrapper) is replaced without asking — that's the repair path for the old
78
+ * macOS symlink layout. Anything foreign is only replaced when
79
+ * `confirmReplace(inspection)` says so; otherwise it's reported and left.
80
+ *
81
+ * Both writes go through `sudoExec` (and so exec.mjs's allowlist): `ln -sf`
82
+ * for a symlink, and `install -m755` of a wrapper staged in `workDir` as
83
+ * the normal user. Both replace a symlink or regular file already at the
84
+ * path — BSD and GNU `install` unlink the destination first, and `ln -f`
85
+ * does by definition — so no separate `rm` runs as root.
86
+ */
87
+ export async function ensureLauncher({
88
+ path, kind, target, execLine, sudoExec, workDir, confirmReplace = async () => false,
89
+ }, fs = {}) {
90
+ const { mkdirFn = mkdir, writeFileFn = writeFile } = fs;
91
+ const inspection = await inspectLauncher({ path, target, execLine }, fs);
92
+ const correct = kind === 'symlink' ? 'symlink' : 'wrapper';
93
+ if (inspection.state === correct) return { ok: true, action: 'unchanged', inspection };
94
+
95
+ const foreign = inspection.state === 'foreign-symlink' || inspection.state === 'foreign-file';
96
+ if (foreign && !(await confirmReplace(inspection))) {
97
+ return { ok: false, action: 'skipped-foreign', inspection };
98
+ }
99
+ const action = inspection.state === 'missing' ? 'created' : foreign ? 'replaced' : 'repaired';
100
+
101
+ if (kind === 'symlink') {
102
+ const r = await sudoExec('ln', ['-sf', target, path]);
103
+ if (r.code !== 0) return { ok: false, action: 'failed', step: 'ln', code: r.code, inspection };
104
+ return { ok: true, action, inspection };
105
+ }
106
+ await mkdirFn(workDir, { recursive: true });
107
+ const staged = join(workDir, `${basename(path)}.launcher`);
108
+ await writeFileFn(staged, wrapperScript(execLine), { mode: 0o755 });
109
+ const r = await sudoExec('install', ['-m755', staged, path]);
110
+ if (r.code !== 0) return { ok: false, action: 'failed', step: 'install', code: r.code, inspection };
111
+ return { ok: true, action, inspection };
112
+ }
@@ -0,0 +1,165 @@
1
+ // Building MEGA65.ROM from a user-supplied, legally obtained C65 base ROM
2
+ // plus the official 920413 patch — the pipeline docs/setup/mega65.md
3
+ // documents in prose. Every step here is a thin, mockable wrapper around one
4
+ // piece of I/O (a download, an extraction, a build, a filesystem write), so
5
+ // setup/mega65.mjs can drive them under real conditions while tests drive
6
+ // them under fakes. Nothing in this file ever bundles, mirrors, or commits
7
+ // ROM bytes — it only ever operates on files the user already has, or the
8
+ // freely-redistributable .rdf patch (a diff, not ROM content) from MEGA65's
9
+ // own file host.
10
+ import {
11
+ mkdir, readFile, cp, rm, access,
12
+ } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ import { execInherit, execCapture } from './exec.mjs';
16
+ import { readZipEntries, extractZipEntry, findZipEntry } from './zip.mjs';
17
+ import {
18
+ C65_BASE_ROM, MEGA65_ROM_920413, validateRomBuffer, parseRdfHeader,
19
+ } from './rom.mjs';
20
+ import { mega65ToolsSourceDir } from './paths.mjs';
21
+
22
+ /** Recursively find a file by exact basename under `rootDir`. Node's own
23
+ * recursive readdir, rather than shelling out to `find` — no risk of an
24
+ * untrusted path (an MSI's own internal names) reaching a shell. */
25
+ export async function findByBasename(rootDir, basename, readdirFn) {
26
+ const { readdir } = await import('node:fs/promises');
27
+ const list = readdirFn ?? readdir;
28
+ const entries = await list(rootDir, { recursive: true, withFileTypes: true });
29
+ const match = entries.find((e) => e.isFile() && e.name === basename);
30
+ if (!match) return null;
31
+ const parent = match.parentPath ?? match.path;
32
+ return join(parent, match.name);
33
+ }
34
+
35
+ /** `msiextract -C <destDir> <msiPath>` — always as the normal user; never
36
+ * under sudo (the brief is explicit: msiextract never runs elevated). */
37
+ export function extractC64ForeverMsi(msiPath, destDir, exec = execInherit) {
38
+ return exec('msiextract', ['-C', destDir, msiPath]);
39
+ }
40
+
41
+ /** Locate and validate the C65 910828 base ROM inside an extracted C64
42
+ * Forever tree. Returns `{ path, buffer, validation }` or null if the file
43
+ * isn't found anywhere under `extractedDir`. Validation failure is not
44
+ * thrown — the caller decides how to report a wrong/corrupt base ROM, per
45
+ * the brief's "stop and explain" requirement. */
46
+ export async function locateC65BaseRom(extractedDir, { find = findByBasename, read = readFile } = {}) {
47
+ const path = await find(extractedDir, C65_BASE_ROM.filename);
48
+ if (!path) return null;
49
+ const buffer = await read(path);
50
+ return { path, buffer, validation: validateRomBuffer(buffer, C65_BASE_ROM) };
51
+ }
52
+
53
+ /**
54
+ * Get the 920413 .rdf patch's bytes, either from a local zip the user
55
+ * already downloaded (`localZipPath` — the escape hatch for the case the
56
+ * user flagged: `patchUrl`'s hashed-looking suffix may not be stable) or by
57
+ * downloading `MEGA65_ROM_920413.patchUrl` fresh. Either way the zip is read
58
+ * with the pure zip.mjs reader, never shelled out to `unzip`.
59
+ */
60
+ export async function fetchRomPatch(
61
+ { localZipPath, fetchImpl = fetch, read = readFile } = {},
62
+ ) {
63
+ const zipBytes = localZipPath
64
+ ? Buffer.from(await read(localZipPath))
65
+ : await downloadZip(fetchImpl, MEGA65_ROM_920413.patchUrl);
66
+
67
+ const entries = readZipEntries(zipBytes);
68
+ const rdfEntry = findZipEntry(entries, MEGA65_ROM_920413.rdfEntryName);
69
+ if (!rdfEntry) {
70
+ throw new Error(
71
+ `the ROM patch archive did not contain ${MEGA65_ROM_920413.rdfEntryName} — `
72
+ + 'it may be a different release than expected',
73
+ );
74
+ }
75
+ const rdfBytes = extractZipEntry(zipBytes, rdfEntry);
76
+ const header = parseRdfHeader(rdfBytes);
77
+ if (!header) {
78
+ throw new Error(`${MEGA65_ROM_920413.rdfEntryName} does not look like a MEGA65 ROM diff file`);
79
+ }
80
+ return { rdfBytes, header };
81
+ }
82
+
83
+ async function downloadZip(fetchImpl, url) {
84
+ let response;
85
+ try {
86
+ response = await fetchImpl(url);
87
+ } catch (cause) {
88
+ throw new Error(
89
+ `could not download the MEGA65 ROM patch from ${url} — `
90
+ + `the download URL may have changed since this was written; pass --rom-patch <zip> `
91
+ + `with a copy downloaded by hand from https://files.mega65.org/`,
92
+ { cause },
93
+ );
94
+ }
95
+ if (!response.ok) {
96
+ throw new Error(
97
+ `downloading the MEGA65 ROM patch from ${url} failed (HTTP ${response.status}) — `
98
+ + `the download URL may have changed since this was written; pass --rom-patch <zip> `
99
+ + `with a copy downloaded by hand from https://files.mega65.org/`,
100
+ );
101
+ }
102
+ return Buffer.from(await response.arrayBuffer());
103
+ }
104
+
105
+ /** Clone-or-update MEGA65/mega65-tools into the setup source cache, and
106
+ * build only `bin/romdiff` — never `make all`, which the brief confirms
107
+ * fails under GCC 16 in the bundled cbmconvert project (a `false` enum
108
+ * identifier colliding with the C23 `false` keyword) and which this project
109
+ * doesn't need anyway. `which: no acme` warnings from this build are
110
+ * upstream noise and are not treated as failure — only a non-zero `make`
111
+ * exit or a missing resulting binary is. */
112
+ export async function buildRomdiff({
113
+ sourceDir = mega65ToolsSourceDir(),
114
+ repo = 'https://github.com/MEGA65/mega65-tools.git',
115
+ exec = execInherit,
116
+ exists = async (p) => { try { await access(p); return true; } catch { return false; } },
117
+ mkdirFn = mkdir,
118
+ } = {}) {
119
+ const gitDir = join(sourceDir, '.git');
120
+ if (await exists(gitDir)) {
121
+ const pull = await exec('git', ['-C', sourceDir, 'pull', '--ff-only']);
122
+ if (pull.code !== 0) return { ok: false, step: 'git pull', code: pull.code };
123
+ } else {
124
+ await mkdirFn(dirname(sourceDir), { recursive: true });
125
+ const clone = await exec('git', ['clone', repo, sourceDir]);
126
+ if (clone.code !== 0) return { ok: false, step: 'git clone', code: clone.code };
127
+ }
128
+ const build = await exec('make', ['bin/romdiff'], { cwd: sourceDir });
129
+ const binaryPath = join(sourceDir, 'bin', 'romdiff');
130
+ if (build.code !== 0 || !(await exists(binaryPath))) {
131
+ return { ok: false, step: 'make bin/romdiff', code: build.code, binaryPath };
132
+ }
133
+ return { ok: true, binaryPath };
134
+ }
135
+
136
+ /**
137
+ * Run `romdiff <rdfPath> <outputPath>` in a scratch working directory that
138
+ * contains only the base ROM, copied in under the exact filename the .rdf's
139
+ * own header names (`header.referenceFilename`, e.g. `910828.BIN`) — romdiff
140
+ * reads that filename from the diff file itself and looks for it in its
141
+ * current directory, so this never assumes a hardcoded name. The user's
142
+ * original base-ROM file and the mega65-tools checkout are both left
143
+ * untouched; everything romdiff touches lives under `workDir`.
144
+ */
145
+ export async function patchRom({
146
+ romdiffPath, rdfPath, workDir, baseRomPath, header, exec = execCapture, copy = cp, mkdirFn = mkdir, read = readFile,
147
+ }) {
148
+ await mkdirFn(workDir, { recursive: true });
149
+ const referenceFilename = header.referenceFilename;
150
+ await copy(baseRomPath, join(workDir, referenceFilename));
151
+ const outputPath = join(workDir, 'MEGA65.ROM');
152
+ const result = await exec(romdiffPath, [rdfPath, outputPath], { cwd: workDir });
153
+ if (result.code !== 0) {
154
+ return { ok: false, output: result.stdout + result.stderr };
155
+ }
156
+ const buffer = await read(outputPath);
157
+ const validation = validateRomBuffer(buffer, { size: MEGA65_ROM_920413.romSize, sha256: MEGA65_ROM_920413.romSha256 });
158
+ return {
159
+ ok: validation.ok, outputPath, buffer, validation, output: result.stdout + result.stderr,
160
+ };
161
+ }
162
+
163
+ export async function cleanupWorkDir(workDir, remove = rm) {
164
+ await remove(workDir, { recursive: true, force: true });
165
+ }