@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,188 @@
1
+ // MEGA65 ROM handling: validating the legally-obtained C65 base ROM and the
2
+ // official 920413 patch's own byte format, and deciding how the canonical
3
+ // ROM should relate to Xemu's per-user data directory — all pure/testable,
4
+ // no filesystem or network access. The one filesystem-touching helper at the
5
+ // bottom (`inspectXemuRomLink`) is a thin real-fs wrapper around the pure
6
+ // `classifyXemuRomLink` below it, kept here so doctor.mjs and setup/mega65.mjs
7
+ // share one implementation of "is this symlink already correct".
8
+ //
9
+ // 8BitScript never bundles or downloads the ROM itself — see docs/setup/
10
+ // mega65.md. What lives here only checks a ROM the *user* already has.
11
+ import { createHash } from 'node:crypto';
12
+ import {
13
+ lstat as fsLstat, readlink as fsReadlink, readFile as fsReadFile,
14
+ mkdir as fsMkdir, symlink as fsSymlink, unlink as fsUnlink,
15
+ } from 'node:fs/promises';
16
+ import { dirname, resolve } from 'node:path';
17
+
18
+ // The free C65 910828 ROM from Cloanto's "C64 Forever Free Express Edition",
19
+ // which the official MEGA65 920413 patch is diffed against. Confirmed size
20
+ // and hash from a working install (see the setup brief this module
21
+ // implements) — this is the gate that stops a wrong/corrupt base ROM from
22
+ // silently producing a bad MEGA65.ROM.
23
+ export const C65_BASE_ROM = {
24
+ filename: 'c-65-19910828.rom',
25
+ size: 131072,
26
+ sha256: '0c4a00b45b65ca553b8a9f38cae83fe5f7dca7e809c24c0051ae40956640509d',
27
+ };
28
+
29
+ // The official MEGA65 920413 ROM release. `patchUrl` carries a suffix
30
+ // (`_Sn7YEw`) that looks like a content hash or random token MEGA65's file
31
+ // host generated for this specific upload — nothing says it is stable across
32
+ // future releases or re-uploads of this same release, so treat it as a
33
+ // best-effort default, not a permanent API. `romSha256` is the value that
34
+ // actually matters: it's checked against the *generated* MEGA65.ROM, which
35
+ // stays correct even if this URL goes stale (setup's --rom-patch flag is the
36
+ // escape hatch for that case — see setup/mega65.mjs).
37
+ export const MEGA65_ROM_920413 = {
38
+ release: '920413',
39
+ patchUrl: 'https://files.mega65.org/files/other/920413_Sn7YEw.zip',
40
+ rdfEntryName: '920413.rdf',
41
+ romSize: 131072,
42
+ romSha256: 'af3c447f791a2fdc48cb21e1bd3fab015e32641228d9d30d21259b9e878c6fa0',
43
+ };
44
+
45
+ export function sha256Hex(buffer) {
46
+ return createHash('sha256').update(buffer).digest('hex');
47
+ }
48
+
49
+ /** Check a ROM (or ROM-shaped) buffer against a known {size, sha256}. */
50
+ export function validateRomBuffer(buffer, expected) {
51
+ const size = buffer.length;
52
+ const sha256 = sha256Hex(buffer);
53
+ return {
54
+ size,
55
+ sha256,
56
+ sizeOk: size === expected.size,
57
+ hashOk: sha256 === expected.sha256,
58
+ ok: size === expected.size && sha256 === expected.sha256,
59
+ };
60
+ }
61
+
62
+ // ---- RDF (romdiff patch) header ---------------------------------------
63
+ //
64
+ // Confirmed directly against the real 920413.rdf from
65
+ // https://files.mega65.org/files/other/920413_Sn7YEw.zip, and against
66
+ // romdiff's own source (MEGA65/mega65-tools, src/tools/romdiff.c): a fixed
67
+ // 256-byte header before the diff payload —
68
+ // offset 0x00, 32 bytes: magic, "MEGA65ROMPATCH01.00", NUL-padded
69
+ // offset 0x20, 64 bytes: reference (base) ROM filename, NUL-padded
70
+ // offset 0x60, 160 bytes: output ROM filename, NUL-padded
71
+ // romdiff itself only checks the first 16 bytes ("MEGA65ROMPATCH01") and
72
+ // reads the reference filename as a NUL-terminated C string at offset 32 —
73
+ // this parses the same way rather than assuming this release's exact
74
+ // filenames, per the brief.
75
+ const RDF_MAGIC = 'MEGA65ROMPATCH01';
76
+ const RDF_HEADER_SIZE = 256;
77
+ const RDF_FIELDS = [
78
+ ['magic', 0, 32],
79
+ ['referenceFilename', 32, 64],
80
+ ['outputFilename', 96, 160],
81
+ ];
82
+
83
+ function readNulTerminated(buffer, start, maxLen) {
84
+ const field = buffer.subarray(start, start + maxLen);
85
+ const nul = field.indexOf(0);
86
+ return (nul === -1 ? field : field.subarray(0, nul)).toString('ascii');
87
+ }
88
+
89
+ /**
90
+ * Parse an .rdf patch file's header. Returns null if `buffer` is too short
91
+ * or does not carry the expected magic — the caller should treat that as
92
+ * "not a MEGA65 ROM diff file", the same thing romdiff itself refuses.
93
+ */
94
+ export function parseRdfHeader(buffer) {
95
+ if (buffer.length < RDF_HEADER_SIZE) return null;
96
+ const magic = readNulTerminated(buffer, 0, 32);
97
+ if (!magic.startsWith(RDF_MAGIC)) return null;
98
+ const fields = {};
99
+ for (const [name, start, len] of RDF_FIELDS) {
100
+ fields[name] = readNulTerminated(buffer, start, len);
101
+ }
102
+ return fields;
103
+ }
104
+
105
+ // ---- canonical ROM vs. Xemu's per-user ROM -----------------------------
106
+
107
+ /**
108
+ * How does the path Xemu reads its ROM from (~/.xemu-lgb/MEGA65.ROM) relate
109
+ * to the canonical install (/opt/mega65/MEGA65.ROM)? Pure classifier over
110
+ * pre-gathered facts, so it never has to be exercised against a real
111
+ * filesystem to test the branch matrix the "never overwrite an unrelated
112
+ * ROM without confirmation" requirement hinges on.
113
+ *
114
+ * 'absent' — nothing at the link path yet; safe to create the symlink
115
+ * 'linked' — already a symlink to the canonical path; nothing to do
116
+ * 'migratable' — a regular file that *is* a valid copy of the expected
117
+ * ROM; safe to offer replacing it with the symlink
118
+ * 'foreign' — a symlink elsewhere, or a regular file that doesn't
119
+ * match the expected ROM; never touch without asking
120
+ */
121
+ export function classifyXemuRomLink({ exists, isSymlink, resolvedTarget, canonicalPath, isValidRom }) {
122
+ if (!exists) return 'absent';
123
+ if (isSymlink) return resolvedTarget === canonicalPath ? 'linked' : 'foreign';
124
+ return isValidRom ? 'migratable' : 'foreign';
125
+ }
126
+
127
+ /**
128
+ * Real-filesystem version of the above: gathers the facts `classifyXemuRomLink`
129
+ * needs from `linkPath` and `canonicalPath`, then classifies them. Kept
130
+ * separate from the pure classifier so doctor.mjs and setup/mega65.mjs can
131
+ * both call this one thing instead of duplicating the fs plumbing, while
132
+ * still being able to unit-test the decision itself without a filesystem.
133
+ * `fs` overrides (`lstatFn`/`readlinkFn`/`readFileFn`) let both callers fake
134
+ * the filesystem entirely for `8bs setup mega65`'s own unit tests — real
135
+ * `node:fs/promises` functions by default.
136
+ */
137
+ export async function inspectXemuRomLink(linkPath, canonicalPath, expected = MEGA65_ROM_920413, {
138
+ lstatFn = fsLstat, readlinkFn = fsReadlink, readFileFn = fsReadFile,
139
+ } = {}) {
140
+ let exists = false;
141
+ let isSymlink = false;
142
+ let resolvedTarget = null;
143
+ let isValidRom = false;
144
+ try {
145
+ const lstat = await lstatFn(linkPath);
146
+ exists = true;
147
+ isSymlink = lstat.isSymbolicLink();
148
+ if (isSymlink) {
149
+ const target = await readlinkFn(linkPath);
150
+ // A relative symlink target resolves against the *directory containing
151
+ // the link*, not the link path itself.
152
+ resolvedTarget = target.startsWith('/') ? target : resolve(dirname(linkPath), target);
153
+ } else if (lstat.isFile()) {
154
+ const { ok } = validateRomBuffer(await readFileFn(linkPath), { size: expected.romSize, sha256: expected.romSha256 });
155
+ isValidRom = ok;
156
+ }
157
+ } catch {
158
+ exists = false;
159
+ }
160
+ const state = classifyXemuRomLink({ exists, isSymlink, resolvedTarget, canonicalPath, isValidRom });
161
+ return { state, exists, isSymlink, resolvedTarget, isValidRom };
162
+ }
163
+
164
+ /**
165
+ * Create or repair the Xemu-local ROM link, based on what `inspectXemuRomLink`
166
+ * found. Never touches anything but `linkPath` itself, and never overwrites
167
+ * a 'foreign' file (an unrelated symlink, or a regular file that isn't a
168
+ * valid copy of the expected ROM) — that always comes back as
169
+ * 'skipped-foreign' with no write performed, regardless of `allowMigrate`.
170
+ * A 'migratable' regular file (a valid ROM already sitting at the link path,
171
+ * predating the canonical install) is only replaced with the symlink when
172
+ * the caller passes `allowMigrate: true` — i.e. after asking the user.
173
+ */
174
+ export async function ensureXemuRomLink(linkPath, canonicalPath, {
175
+ allowMigrate = false, expected = MEGA65_ROM_920413,
176
+ mkdir = fsMkdir, symlink = fsSymlink, unlink = fsUnlink,
177
+ lstatFn, readlinkFn, readFileFn,
178
+ inspect = (lp, cp, exp) => inspectXemuRomLink(lp, cp, exp, { lstatFn, readlinkFn, readFileFn }),
179
+ } = {}) {
180
+ const inspection = await inspect(linkPath, canonicalPath, expected);
181
+ if (inspection.state === 'linked') return { action: 'none', ...inspection };
182
+ if (inspection.state === 'foreign') return { action: 'skipped-foreign', ...inspection };
183
+ if (inspection.state === 'migratable' && !allowMigrate) return { action: 'skipped-migratable', ...inspection };
184
+ await mkdir(dirname(linkPath), { recursive: true });
185
+ if (inspection.state === 'migratable') await unlink(linkPath);
186
+ await symlink(canonicalPath, linkPath);
187
+ return { action: inspection.state === 'migratable' ? 'migrated' : 'linked', ...inspection };
188
+ }
@@ -0,0 +1,60 @@
1
+ // Source checkout and build steps shared by every source-built target
2
+ // (`8bs setup mega65` builds Xemu, `8bs setup cx16` builds x16-emulator
3
+ // and x16-rom). Every process/filesystem boundary is injected — an `exec`
4
+ // matching exec.mjs's execInherit, plus `exists`/`mkdirFn` — so the
5
+ // per-target modules stay thin and unit tests never clone or compile.
6
+ import { access, mkdir } from 'node:fs/promises';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ import { execInherit } from './exec.mjs';
10
+
11
+ export async function pathExists(p) {
12
+ try {
13
+ await access(p);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Clone `repo` into `sourceDir` the first time; fast-forward it on every
22
+ * later run. Never re-clones an existing checkout — a build directory that
23
+ * already exists there is exactly what makes a re-run cheap (an incremental
24
+ * `make` instead of a from-scratch build). Runs as the normal user; there is
25
+ * no sudo anywhere in this module.
26
+ */
27
+ export async function syncRepository({
28
+ sourceDir, repo, exec = execInherit, exists = pathExists, mkdirFn = mkdir,
29
+ }) {
30
+ if (await exists(join(sourceDir, '.git'))) {
31
+ const pull = await exec('git', ['-C', sourceDir, 'pull', '--ff-only']);
32
+ if (pull.code !== 0) return { ok: false, step: 'git pull', code: pull.code };
33
+ return { ok: true, action: 'updated' };
34
+ }
35
+ await mkdirFn(dirname(sourceDir), { recursive: true });
36
+ const clone = await exec('git', ['clone', repo, sourceDir]);
37
+ if (clone.code !== 0) return { ok: false, step: 'git clone', code: clone.code };
38
+ return { ok: true, action: 'cloned' };
39
+ }
40
+
41
+ /**
42
+ * Run one build command in `cwd` and confirm every path in `artifacts` came
43
+ * out of it. Warnings on stderr are never a failure — every source build
44
+ * this project does (Xemu under GCC, x16-emulator under AppleClang with its
45
+ * "reducing alignment of section" linker warnings, x16-rom's pages of
46
+ * ca65/ld65 notices) emits some on a perfectly good build. Only a non-zero
47
+ * exit, or a missing artifact afterward, is.
48
+ */
49
+ export async function runBuild({
50
+ cwd, command = 'make', args = [], artifacts = [], exec = execInherit, exists = pathExists,
51
+ }) {
52
+ const build = await exec(command, args, { cwd });
53
+ if (build.code !== 0) return { ok: false, step: command, code: build.code };
54
+ for (const artifact of artifacts) {
55
+ if (!(await exists(artifact))) {
56
+ return { ok: false, step: command, code: build.code, missingArtifact: artifact };
57
+ }
58
+ }
59
+ return { ok: true };
60
+ }
@@ -0,0 +1,107 @@
1
+ // Building and installing xmega65 (Xemu's MEGA65 core) from source. The AUR
2
+ // `xmega65-git` package is deliberately not used anywhere in this
3
+ // project — see doctor.mjs's INSTALLERS.xmega65 comment — so this is the
4
+ // only path `8bs setup mega65` has to a working emulator.
5
+ import { lstat, mkdir, readlink, symlink } from 'node:fs/promises';
6
+ import { dirname, join, resolve } from 'node:path';
7
+
8
+ import { execInherit } from './exec.mjs';
9
+ import { syncRepository, runBuild, pathExists } from './source.mjs';
10
+ import {
11
+ xemuSourceDir, XEMU_INSTALL_DIR, XMEGA65_INSTALL_PATH, XMEGA65_SYMLINK_PATH,
12
+ xemuUserDataDir, xemuMega65RealDataDir,
13
+ } from './paths.mjs';
14
+
15
+ const XEMU_REPO = 'https://github.com/lgblgblgb/xemu.git';
16
+
17
+ /**
18
+ * Clone-or-update lgblgblgb/xemu, then `make` only `targets/mega65` — this
19
+ * project has no use for Xemu's other machine cores. Harmless compiler
20
+ * warnings (confirmed against GCC 16 / sdl2-compat 2.32.70 / GTK3 on current
21
+ * Arch, and against Apple clang 21 / SDL 2.32.72 on Apple Silicon macOS —
22
+ * "experimental memory data pointers", "no _mm_malloc() on ARM", unused
23
+ * variables) are not a failure; only a non-zero `make` exit or a missing
24
+ * resulting binary is.
25
+ */
26
+ export async function buildXemuMega65({
27
+ sourceDir = xemuSourceDir(), repo = XEMU_REPO, exec = execInherit, exists = pathExists, mkdirFn = mkdir,
28
+ } = {}) {
29
+ const sync = await syncRepository({ sourceDir, repo, exec, exists, mkdirFn });
30
+ if (!sync.ok) return sync;
31
+ const binaryPath = join(sourceDir, 'build', 'bin', 'xmega65.native');
32
+ const build = await runBuild({ cwd: join(sourceDir, 'targets', 'mega65'), artifacts: [binaryPath], exec, exists });
33
+ if (!build.ok) return { ...build, binaryPath };
34
+ return { ok: true, binaryPath };
35
+ }
36
+
37
+ /**
38
+ * Install the built binary system-wide: `/opt/xemu/xmega65` (never named
39
+ * `xmega65.native` — the brief is explicit). Putting it on `PATH` is a
40
+ * separate step — see xmega65LauncherSpec() and setup/launcher.mjs — so a
41
+ * foreign `/usr/local/bin/xmega65` is never silently overwritten here.
42
+ */
43
+ export async function installXemu(binaryPath, { sudoExec, installDir = XEMU_INSTALL_DIR, installPath = XMEGA65_INSTALL_PATH } = {}) {
44
+ const mkdirResult = await sudoExec('mkdir', ['-p', installDir]);
45
+ if (mkdirResult.code !== 0) return { ok: false, step: 'mkdir', code: mkdirResult.code };
46
+ const installResult = await sudoExec('install', ['-m755', binaryPath, installPath]);
47
+ if (installResult.code !== 0) return { ok: false, step: 'install', code: installResult.code };
48
+ return { ok: true, installPath };
49
+ }
50
+
51
+ /**
52
+ * Unlike Commander X16's x16emu, a plain `/usr/local/bin/xmega65 ->
53
+ * /opt/xemu/xmega65` symlink works correctly on macOS too — confirmed on a
54
+ * real Apple Silicon install: Xemu resolves its own data directory from
55
+ * `$HOME`, never from the path it was invoked through, so there's no
56
+ * platform split here and no wrapper script.
57
+ */
58
+ export function xmega65LauncherSpec() {
59
+ return { path: XMEGA65_SYMLINK_PATH, target: XMEGA65_INSTALL_PATH, execLine: null, kind: 'symlink' };
60
+ }
61
+
62
+ /**
63
+ * What's at Xemu's `~/.xemu-lgb` compatibility path right now: `'missing'`
64
+ * (nothing there yet), `'symlink'` (resolved to its real target — Xemu's
65
+ * own first-run behaviour, or a prior run of this function), or `'other'`
66
+ * (a real directory, or anything else Xemu itself put there — left alone
67
+ * either way).
68
+ */
69
+ export async function inspectXemuDataDir(linkPath, { lstatFn = lstat, readlinkFn = readlink } = {}) {
70
+ let stats;
71
+ try {
72
+ stats = await lstatFn(linkPath);
73
+ } catch {
74
+ return { state: 'missing' };
75
+ }
76
+ if (stats.isSymbolicLink()) {
77
+ const target = await readlinkFn(linkPath);
78
+ const resolved = target.startsWith('/') ? target : resolve(dirname(linkPath), target);
79
+ return { state: 'symlink', target: resolved };
80
+ }
81
+ return { state: 'other' };
82
+ }
83
+
84
+ /**
85
+ * Xemu creates `~/.xemu-lgb` itself, as a compatibility symlink into its
86
+ * real per-platform data directory (`~/Library/Application Support/
87
+ * xemu-lgb/mega65` on macOS, `~/.local/share/xemu-lgb/mega65` on Linux —
88
+ * both confirmed on real first launches), the first time it runs. Setup
89
+ * never launches the emulator just to get that layout, so if nothing is
90
+ * there yet it creates the identical layout itself: the real directory,
91
+ * then the symlink — so the ROM link step right after this has somewhere
92
+ * correct to land. Anything already at `linkPath` — any symlink, or a real
93
+ * directory — is Xemu's own (or a prior run of this same function) and is
94
+ * left completely untouched, per the brief's "never overwrite an existing
95
+ * ~/.xemu-lgb that points somewhere else" — extended here to "never
96
+ * overwrite it at all" once it exists.
97
+ */
98
+ export async function ensureXemuDataDir({
99
+ platform = process.platform, linkPath = xemuUserDataDir(), realDir = xemuMega65RealDataDir(platform),
100
+ mkdirFn = mkdir, symlinkFn = symlink, inspect = inspectXemuDataDir,
101
+ } = {}) {
102
+ const inspection = await inspect(linkPath);
103
+ if (inspection.state !== 'missing') return { ok: true, action: 'unchanged', ...inspection };
104
+ await mkdirFn(realDir, { recursive: true });
105
+ await symlinkFn(realDir, linkPath);
106
+ return { ok: true, action: 'created', realDir };
107
+ }
@@ -0,0 +1,75 @@
1
+ // A minimal ZIP reader, just enough to pull one named entry out of the
2
+ // official MEGA65 ROM-patch archive (or a C64 Forever MSI's own zip-shaped
3
+ // internals, if ever needed) without adding a zip library dependency this
4
+ // project doesn't otherwise need. Supports the two compression methods a
5
+ // real-world zip actually uses: 0 (stored) and 8 (deflate, via Node's own
6
+ // zlib). Confirmed directly against the real 920413_Sn7YEw.zip from
7
+ // files.mega65.org, whose three entries are all stored (method 0) — deflate
8
+ // support is here so a future re-upload that does compress them still works.
9
+ import { inflateRawSync } from 'node:zlib';
10
+
11
+ const EOCD_SIGNATURE = 0x06054b50;
12
+ const CENTRAL_DIR_SIGNATURE = 0x02014b50;
13
+ const LOCAL_HEADER_SIGNATURE = 0x04034b50;
14
+
15
+ /** Find the End Of Central Directory record by scanning backward from the
16
+ * end of the file — it can be followed by a variable-length comment, so its
17
+ * offset isn't fixed. */
18
+ function findEndOfCentralDirectory(buffer) {
19
+ const maxCommentLength = 65535;
20
+ const searchStart = Math.max(0, buffer.length - 22 - maxCommentLength);
21
+ for (let offset = buffer.length - 22; offset >= searchStart; offset -= 1) {
22
+ if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
23
+ }
24
+ return -1;
25
+ }
26
+
27
+ /** List every entry in a ZIP archive: { name, method, compressedSize,
28
+ * uncompressedSize, localHeaderOffset }. Throws if `buffer` isn't a ZIP
29
+ * this reader understands. */
30
+ export function readZipEntries(buffer) {
31
+ const eocd = findEndOfCentralDirectory(buffer);
32
+ if (eocd === -1) throw new Error('not a ZIP file (no end-of-central-directory record found)');
33
+
34
+ const entryCount = buffer.readUInt16LE(eocd + 10);
35
+ const centralDirOffset = buffer.readUInt32LE(eocd + 16);
36
+
37
+ const entries = [];
38
+ let offset = centralDirOffset;
39
+ for (let i = 0; i < entryCount; i += 1) {
40
+ if (buffer.readUInt32LE(offset) !== CENTRAL_DIR_SIGNATURE) {
41
+ throw new Error(`ZIP central directory entry ${i} has a bad signature`);
42
+ }
43
+ const method = buffer.readUInt16LE(offset + 10);
44
+ const compressedSize = buffer.readUInt32LE(offset + 20);
45
+ const uncompressedSize = buffer.readUInt32LE(offset + 24);
46
+ const nameLength = buffer.readUInt16LE(offset + 28);
47
+ const extraLength = buffer.readUInt16LE(offset + 30);
48
+ const commentLength = buffer.readUInt16LE(offset + 32);
49
+ const localHeaderOffset = buffer.readUInt32LE(offset + 42);
50
+ const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
51
+ entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset });
52
+ offset += 46 + nameLength + extraLength + commentLength;
53
+ }
54
+ return entries;
55
+ }
56
+
57
+ /** Decompressed bytes for one entry from `readZipEntries`. */
58
+ export function extractZipEntry(buffer, entry) {
59
+ const localNameLength = buffer.readUInt16LE(entry.localHeaderOffset + 26);
60
+ const localExtraLength = buffer.readUInt16LE(entry.localHeaderOffset + 28);
61
+ const dataOffset = entry.localHeaderOffset + 30 + localNameLength + localExtraLength;
62
+ const compressed = buffer.subarray(dataOffset, dataOffset + entry.compressedSize);
63
+ if (entry.method === 0) return Buffer.from(compressed);
64
+ if (entry.method === 8) return inflateRawSync(compressed);
65
+ throw new Error(`ZIP entry '${entry.name}' uses unsupported compression method ${entry.method}`);
66
+ }
67
+
68
+ /** Find an entry by exact path, or by basename if no exact match — the
69
+ * official archive nests entries under a release-number directory
70
+ * (`920413/920413.rdf`) that this project should not assume is permanent. */
71
+ export function findZipEntry(entries, name) {
72
+ const exact = entries.find((e) => e.name === name);
73
+ if (exact) return exact;
74
+ return entries.find((e) => e.name.split('/').pop() === name) ?? null;
75
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // `8bs setup <target>` — install/configure what a target needs beyond what
2
+ // `8bs doctor` can offer as a single package-manager command: mega65 (Xemu
3
+ // built from source, plus the legally-obtained ROM pipeline
4
+ // docs/setup/mega65.md documents) and cx16 (x16emu and a matching ROM built
5
+ // from upstream source, docs/setup/cx16.md). This file stays a thin
6
+ // dispatcher; the shared machinery lives in setup/{deps,host,source,
7
+ // install,launcher}.mjs so the next source-built target follows the same
8
+ // shape.
9
+ const TARGETS = {
10
+ mega65: () => import('./setup/mega65.mjs').then((m) => m.setupMega65),
11
+ cx16: () => import('./setup/cx16.mjs').then((m) => m.setupCx16),
12
+ };
13
+
14
+ const USAGE = 'Usage: 8bs setup <mega65|cx16> [--rom <path>] [--c64-forever <msi>] [--rom-patch <zip>] [--repair] [--update]\n';
15
+
16
+ function parseArgs(args) {
17
+ const options = {};
18
+ const positionals = [];
19
+ for (let i = 0; i < args.length; i += 1) {
20
+ const arg = args[i];
21
+ if (arg === '--rom') {
22
+ options.romPath = args[i + 1];
23
+ i += 1;
24
+ } else if (arg === '--c64-forever') {
25
+ options.c64ForeverPath = args[i + 1];
26
+ i += 1;
27
+ } else if (arg === '--rom-patch') {
28
+ options.romPatchPath = args[i + 1];
29
+ i += 1;
30
+ } else if (arg === '--repair') {
31
+ options.repair = true;
32
+ } else if (arg === '--update') {
33
+ options.update = true;
34
+ } else if (!arg.startsWith('-')) {
35
+ positionals.push(arg);
36
+ }
37
+ }
38
+ return { target: positionals[0], options };
39
+ }
40
+
41
+ /** @returns {Promise<number>} process exit code */
42
+ export async function setup(args) {
43
+ const { target, options } = parseArgs(args);
44
+ if (!target) {
45
+ process.stderr.write(USAGE);
46
+ return 2;
47
+ }
48
+ const load = TARGETS[target];
49
+ if (!load) {
50
+ process.stderr.write(`8bs setup: no setup available for '${target}'. Run '8bs doctor' for what's missing — docs/setup/.\n`);
51
+ return 2;
52
+ }
53
+ const run = await load();
54
+ const result = await run(options);
55
+ return result.ok ? 0 : 1;
56
+ }
@@ -0,0 +1,158 @@
1
+ // `8bs targets [--json]` — every machine the toolchain builds for, and the
2
+ // hardware each can be fitted with: the catalog every machine package
3
+ // declares (packages/cli/src/hardware.mjs), plus the profiles the project
4
+ // in the current directory composes in its 8bs.config.ts, and the whole
5
+ // machines that config has been set up for in its `systems` block. The
6
+ // editor reads the JSON form to build its System and Hardware controls, so
7
+ // a new machine or option in a package — or a new system in a project — is
8
+ // a new row there with nothing to update by hand.
9
+ import { FACTS, MACHINES } from '@8bitscript/compiler';
10
+
11
+ import { loadConfig } from './config.mjs';
12
+ import {
13
+ REGION_MACHINES, loadCatalog, projectHardware, projectProfiles, projectRequires, projectSystems,
14
+ stockFacts,
15
+ } from './hardware.mjs';
16
+ import { VICE_EMULATOR } from './run.mjs';
17
+
18
+ const EMULATOR = {
19
+ ...VICE_EMULATOR, atari8: 'atari800', nes: 'fceux', cx16: 'x16emu', mega65: 'xmega65', web: 'the browser',
20
+ };
21
+ const TITLE = {
22
+ vic20: 'Commodore VIC-20', c64: 'Commodore 64', pet: 'Commodore PET', c128: 'Commodore 128',
23
+ atari8: 'Atari 8-bit', nes: 'Nintendo Entertainment System', cx16: 'Commander X16', mega65: 'MEGA65', web: 'Web',
24
+ };
25
+
26
+ /**
27
+ * One entry per target: what the editor's dropdowns and hardware panel
28
+ * are built from.
29
+ *
30
+ * @param {object|null} config the project's 8bs.config.ts, if any
31
+ */
32
+ export function describeTargets(config) {
33
+ return MACHINES.map((id) => {
34
+ const catalog = loadCatalog(id);
35
+ const options = Object.fromEntries(Object.entries(catalog.options).map(([optionId, option]) => [optionId, {
36
+ label: option.label,
37
+ default: option.default,
38
+ // The package subpath whose probe finds this hardware on the machine
39
+ // at run time, when one probe finds every value of the option; a
40
+ // value may name its own instead (see below). Null when the choice
41
+ // is the build's (a PET model).
42
+ detect: option.detect ?? null,
43
+ values: Object.fromEntries(Object.entries(option.values).map(([value, entry]) => [value, {
44
+ label: entry.label,
45
+ affectsBuild: Boolean(entry.build),
46
+ // What finds *this* value at run time: its own probe, or the
47
+ // option's when one probe covers them all.
48
+ detect: entry.detect ?? option.detect ?? null,
49
+ tag: Object.hasOwn(entry, 'tag') ? entry.tag : (value === option.default ? null : value),
50
+ facts: entry.facts ?? {},
51
+ }])),
52
+ }]));
53
+ return {
54
+ id,
55
+ title: TITLE[id],
56
+ emulator: EMULATOR[id],
57
+ region: REGION_MACHINES.has(id),
58
+ options,
59
+ presets: catalog.presets,
60
+ profiles: projectProfiles(config, id),
61
+ // The project's own default values for this machine, under any profile.
62
+ hardware: projectHardware(config, id),
63
+ // The stock machine's sheet; a chosen value's `facts` change it, in
64
+ // option order, the way resolveHardware merges them.
65
+ facts: stockFacts(id),
66
+ };
67
+ });
68
+ }
69
+
70
+ /** The fact keys, typed and described, so the editor's sheet labels itself from one place. */
71
+ export function describeFacts() {
72
+ return [...FACTS].map(([key, fact]) => ({ key, ...fact }));
73
+ }
74
+
75
+ /** One line per system a config declares, for the table form. */
76
+ function printSystems(systems) {
77
+ if (systems.length === 0) return;
78
+ process.stdout.write('\nThis project is set up for:\n');
79
+ for (const system of systems) {
80
+ const parts = [
81
+ system.profile && `--profile ${system.profile}`,
82
+ Object.entries(system.hardware).length > 0
83
+ && `--hardware ${Object.entries(system.hardware).map(([k, v]) => `${k}=${v}`).join(',')}`,
84
+ system.region === 'pal' && '--pal',
85
+ ].filter(Boolean);
86
+ process.stdout.write(` ${system.name.padEnd(24)} 8bs run ${system.target}${parts.length > 0 ? ` ${parts.join(' ')}` : ''}\n`);
87
+ for (const { key, need, have } of system.unmet ?? []) {
88
+ process.stdout.write(` ${' '.repeat(24)} short: ${key} needs ${need === true ? 'it' : need}, has ${have === true ? 'it' : have}\n`);
89
+ }
90
+ }
91
+ }
92
+
93
+ /** What the program asks of any machine, for the table form. */
94
+ function printRequires(requires) {
95
+ const entries = Object.entries(requires);
96
+ if (entries.length === 0) return;
97
+ process.stdout.write('\nThis program needs, of any machine:\n');
98
+ for (const [key, need] of entries) {
99
+ process.stdout.write(` ${key.padEnd(24)} ${need === true ? 'yes' : `at least ${need}`}\n`);
100
+ }
101
+ }
102
+
103
+ /** @returns {Promise<number>} exit code */
104
+ export async function targets(args) {
105
+ const config = await loadConfig(process.cwd(), '8bs targets');
106
+ const required = projectRequires(config);
107
+ const systems = projectSystems(config);
108
+ const described = describeTargets(config);
109
+ if (args.includes('--json')) {
110
+ // A `systems` block the config gets wrong costs the reader its
111
+ // systems and nothing else: the machines and their catalogs are the
112
+ // toolchain's and are still true. The editor reads this, and a whole
113
+ // hardware panel disappearing because of a typo three lines away
114
+ // would say nothing about what is wrong.
115
+ process.stdout.write(`${JSON.stringify({
116
+ targets: described,
117
+ systems: systems.ok ? systems.systems : [],
118
+ systemsError: systems.ok ? null : systems.error,
119
+ requires: required.ok ? required.requires : {},
120
+ requiresError: required.ok ? null : required.error,
121
+ facts: describeFacts(),
122
+ }, null, 2)}\n`);
123
+ return 0;
124
+ }
125
+ for (const result of [required, systems]) {
126
+ if (!result.ok) {
127
+ process.stderr.write(`8bs targets: ${result.error}\n`);
128
+ return 1;
129
+ }
130
+ }
131
+ for (const t of described) {
132
+ const presets = Object.keys(t.presets);
133
+ const profiles = Object.keys(t.profiles);
134
+ process.stdout.write(`${t.id.padEnd(8)} ${t.title} — ${t.emulator}${t.region ? ', --pal available' : ''}\n`);
135
+ for (const [optionId, option] of Object.entries(t.options)) {
136
+ const values = Object.entries(option.values)
137
+ .map(([value, entry]) => `${value}${value === option.default ? '*' : ''}${entry.affectsBuild ? ' (build)' : ''}`)
138
+ .join(', ');
139
+ process.stdout.write(` --hardware ${optionId}= ${option.label}: ${values}\n`);
140
+ const probes = new Map();
141
+ for (const [value, entry] of Object.entries(option.values)) {
142
+ if (!entry.detect || value === option.default) continue;
143
+ probes.set(entry.detect, [...(probes.get(entry.detect) ?? []), value]);
144
+ }
145
+ for (const [probe, found] of probes) {
146
+ process.stdout.write(` ${found.join(', ')} found at run time by ${probe}\n`);
147
+ }
148
+ }
149
+ if (presets.length > 0) process.stdout.write(` --profile presets: ${presets.join(', ')}\n`);
150
+ if (profiles.length > 0) process.stdout.write(` --profile this project: ${profiles.join(', ')}\n`);
151
+ const own = Object.entries(t.hardware).map(([k, v]) => `${k}=${v}`);
152
+ if (own.length > 0) process.stdout.write(` this project's default: ${own.join(' ')}\n`);
153
+ }
154
+ printRequires(required.requires);
155
+ printSystems(systems.systems);
156
+ process.stdout.write('\n* the default; (build) changes the program, not only the emulator\n');
157
+ return 0;
158
+ }