@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,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
|
+
}
|