@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 8BitScript contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin/8bs.mjs ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ // The `8bs` entry point — thin dispatch to each subcommand's own module in
3
+ // ../src/; see each one for what it actually does. `check` runs the
4
+ // compiler's diagnostics over files; `lsp` starts the language server on
5
+ // stdio for an editor to drive. Both go through @8bitscript/compiler, which
6
+ // is the point: one set of rules, reported in the terminal, in CI, and
7
+ // under the cursor.
8
+ const [, , command, ...rest] = process.argv;
9
+
10
+ const IMPLEMENTED = new Set(['check', 'lsp', 'doctor', 'build', 'run', 'setup', 'targets']);
11
+ const PLANNED = ['dev'];
12
+
13
+ const usage = () => `Usage: 8bs <command> [options]
14
+
15
+ Implemented:
16
+ build --target <t> [--pal] [--profile <name>]
17
+ [--hardware option=value,...] [entry]
18
+ Compile for a target: vic20, c64, pet, c128,
19
+ atari8, nes, cx16, mega65, or web. vic20/c64/
20
+ c128/mega65/atari8 default to NTSC (60Hz) at
21
+ run time; --pal selects the PAL (50Hz)
22
+ machine model (the pet has no region: its
23
+ model is hardware). --profile names the
24
+ hardware fitted — a preset from the machine's
25
+ catalog (8032, 130xe, reu512) or a profile the
26
+ project composes in 8bs.config.ts — and
27
+ --hardware sets single options on top
28
+ (ram=8k, port1=mouse1351). "8bs targets"
29
+ lists every option, value and preset.
30
+ run <target> [--pal] [--profile <name>]
31
+ [--hardware option=value,...] [entry]
32
+ Build, then open that target's emulator
33
+ (VICE for vic20/c64/pet/c128, atari800,
34
+ fceux, x16emu, or Xemu for mega65) at the
35
+ right machine model — or execute the .wasm
36
+ and print its state (web)
37
+ [--screenshot <file.png>] Instead of an interactive window, capture one
38
+ [--frames <n>] screenshot through the target's own emulator
39
+ API (or, for atari8 only, a macOS window
40
+ capture — see docs/setup/verify.md#screenshots).
41
+ --frames means a different unit per target
42
+ (cycles, wall-clock seconds, or exact
43
+ frame-advances); omit it for a tested default.
44
+ targets [--json] List every target and the hardware it can be
45
+ fitted with — options, values, presets, and
46
+ this project's own profiles; --json is what
47
+ the editor reads
48
+ check <files...> Report diagnostics for 8BitScript source files
49
+ doctor Verify the toolchains every target needs
50
+ setup <target> Install/configure what a target needs beyond
51
+ [--rom <path>] what doctor can offer as a single package-
52
+ [--c64-forever <msi>] manager command. mega65 builds Xemu from
53
+ [--rom-patch <zip>] source on macOS (Homebrew) and Arch/Manjaro
54
+ [--repair] [--update] (pacman), then installs a MEGA65 ROM: --rom
55
+ points at an already-generated MEGA65.ROM
56
+ (the primary path); --c64-forever/--rom-patch
57
+ generate one from a C64 Forever MSI instead
58
+ — docs/setup/mega65.md. cx16 builds x16emu and
59
+ a matching ROM from upstream source into
60
+ /opt/commander-x16 (--repair fixes a broken
61
+ launcher, --update rebuilds the pair —
62
+ docs/setup/cx16.md)
63
+ lsp [--stdio] Start the language server on stdio
64
+
65
+ Planned, not implemented:
66
+ ${PLANNED.join(', ')}
67
+
68
+ The compiler covers the first-milestone subset of the language: globals,
69
+ functions, arithmetic, control flow, @address hardware access, and asm6502
70
+ blocks. Constructs beyond that fail with a message. See docs/compiler.md.
71
+ `;
72
+
73
+ if (!command || command === '--help' || command === '-h') {
74
+ process.stdout.write(usage());
75
+ process.exit(command ? 0 : 1);
76
+ }
77
+
78
+ if (command === '--version' || command === '-v') {
79
+ const { readFileSync } = await import('node:fs');
80
+ const { dirname, join } = await import('node:path');
81
+ const { fileURLToPath } = await import('node:url');
82
+ const pkg = JSON.parse(
83
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'),
84
+ );
85
+ process.stdout.write(`8bs ${pkg.version}\n`);
86
+ process.exit(0);
87
+ }
88
+
89
+ if (command === 'check') {
90
+ const { check } = await import('../src/check.mjs');
91
+ process.exit(await check(rest.filter((a) => !a.startsWith('-'))));
92
+ }
93
+
94
+ if (command === 'doctor') {
95
+ const { doctor } = await import('../src/doctor.mjs');
96
+ process.exit(await doctor());
97
+ }
98
+
99
+ if (command === 'build') {
100
+ const { build } = await import('../src/build.mjs');
101
+ process.exit(await build(rest));
102
+ }
103
+
104
+ if (command === 'run') {
105
+ const { run } = await import('../src/run.mjs');
106
+ process.exit(await run(rest));
107
+ }
108
+
109
+ if (command === 'targets') {
110
+ const { targets } = await import('../src/targets.mjs');
111
+ process.exit(await targets(rest));
112
+ }
113
+
114
+ if (command === 'setup') {
115
+ const { setup } = await import('../src/setup.mjs');
116
+ process.exit(await setup(rest));
117
+ }
118
+
119
+ if (command === 'lsp') {
120
+ // --stdio is accepted because every editor passes it by convention; stdio is
121
+ // the only transport, so there is nothing to select.
122
+ const { start } = await import('@8bitscript/language-server');
123
+ start();
124
+ } else {
125
+ const known = IMPLEMENTED.has(command) || PLANNED.includes(command);
126
+ process.stderr.write(
127
+ known
128
+ ? `8bs ${command}: not implemented yet.\nThe compiler has no parser or backend yet, so there is nothing to ${command}.\n`
129
+ : `8bs: unknown command '${command}'\n\n${usage()}`,
130
+ );
131
+ process.exit(1);
132
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@8bitscript/cli",
3
+ "version": "0.1.0",
4
+ "description": "The 8bs command line interface: builds, runs, and diagnoses 8BitScript projects.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=26"
9
+ },
10
+ "bin": {
11
+ "8bs": "./bin/8bs.mjs"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src"
16
+ ],
17
+ "dependencies": {
18
+ "@8bitscript/atari8": "0.1.0",
19
+ "@8bitscript/backend-6502": "0.1.0",
20
+ "@8bitscript/backend-web": "0.1.0",
21
+ "@8bitscript/c128": "0.1.0",
22
+ "@8bitscript/c64": "0.1.0",
23
+ "@8bitscript/compiler": "0.1.0",
24
+ "@8bitscript/cx16": "0.1.0",
25
+ "@8bitscript/language-server": "0.1.0",
26
+ "@8bitscript/mega65": "0.1.0",
27
+ "@8bitscript/nes": "0.1.0",
28
+ "@8bitscript/pet": "0.1.0",
29
+ "@8bitscript/studio": "0.1.0",
30
+ "@8bitscript/vic20": "0.1.0",
31
+ "@8bitscript/web": "0.1.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test"
38
+ }
39
+ }
package/src/build.mjs ADDED
@@ -0,0 +1,309 @@
1
+ // `8bs build` — compile a program for a target.
2
+ //
3
+ // 8bs build --target vic20 [entry.8bs] NTSC (60Hz), the default
4
+ // 8bs build --target vic20 --pal [entry.8bs]
5
+ // 8bs build --target web [entry.8bs]
6
+ // 8bs build --target atari8 --profile 130xe [entry.8bs]
7
+ // 8bs build --target vic20 --profile 16k [entry.8bs]
8
+ // 8bs build --target c64 --profile reu512 [entry.8bs]
9
+ // 8bs build --target pet --profile 8032 [entry.8bs] 80 columns, 32K
10
+ //
11
+ // The system (vic20/c64/pet/c128/atari8/nes/cx16/mega65/web) is the target;
12
+ // NTSC/PAL is a --pal/--ntsc option on top of it, not a separate flavor of
13
+ // the target, because it changes the emulator's machine model at run time
14
+ // and nothing about the build — see the comment on MODEL_ARGS in run.mjs
15
+ // for why that still means picking a whole machine model rather than just a
16
+ // sync-factor flag. It only applies to targets with a real region split
17
+ // (see REGION_TARGETS below); it's silently ignored everywhere else, the
18
+ // same as it already was for web. "NTSC (60Hz)" above is the emulator's real
19
+ // hardware region, not the language's logical frame rate — that's a
20
+ // separate, project-level setting (`frameRate` in 8bs.config.ts, default 60,
21
+ // see packages/backend-6502's FRAME_SYNC and examples/borders/README.md),
22
+ // unaffected by --pal.
23
+ //
24
+ // --profile names the hardware the build is for: a preset from the
25
+ // machine package's catalog (`8032`, `130xe`, `reu512` — the community's
26
+ // names for whole configurations) or a profile the project composes in
27
+ // its 8bs.config.ts (`targets: { c64: { profiles: { loaded: { ram:
28
+ // 'reu512', port1: 'mouse1351' } } } }`). --hardware option=value,...
29
+ // sets single options on top of either. What each option changes — a
30
+ // link symbol, a driver, an emulator flag, a fact a program can read — is
31
+ // the catalog's to say; see packages/cli/src/hardware.mjs and
32
+ // docs/systems.md. `8bs targets` lists every option and preset.
33
+ //
34
+ // The entry defaults to src/main.8bs, or to the `entry` in 8bs.config.ts when
35
+ // the project has one — and whichever file that names, a `.<target>.8bs`
36
+ // twin beside it (main.nes.8bs next to main.8bs) is what a build for that
37
+ // target actually starts from; see resolveEntryPath. Output lands in dist/, named
38
+ // <name>-<machine>[-<hardware>...][-<region>].<ext> — .prg for the Commodore/
39
+ // CX16/MEGA65 targets, .xex (or .rom for an XEGS cartridge) for Atari 8-bit,
40
+ // .nes for the NES, .wasm for the web — with the generated C or
41
+ // AssemblyScript beside it so what the compiler did is never a mystery.
42
+ // Only hardware that changes the *build* is in the name (a PET's model, a
43
+ // VIC-20's RAM): a mouse or a REU makes the same program, so it is not.
44
+ import { readFile } from 'node:fs/promises';
45
+ import { existsSync } from 'node:fs';
46
+ import { basename, resolve } from 'node:path';
47
+
48
+ import {
49
+ MACHINES, isVariantPath, link, positionAt, variantOf,
50
+ unmetRequirements,
51
+ } from '@8bitscript/compiler';
52
+
53
+ import { loadConfig, resolveFrameRate } from './config.mjs';
54
+ import {
55
+ HARDWARE_USAGE, REGION_MACHINES, hardwareArgs, listedTargets, loadCatalog, projectHardware,
56
+ projectProfiles, projectRequires, projectSystems, resolveHardware, whatSatisfies,
57
+ } from './hardware.mjs';
58
+
59
+ const TARGETS = new Set(MACHINES);
60
+
61
+ // The targets whose frame-sync strategy has a real NTSC/PAL split, auto-
62
+ // detected at runtime (packages/backend-6502's FRAME_SYNC 'level' machines)
63
+ // — the only ones where --pal changes the build or a region suffix on the
64
+ // output filename. The PET has no region at all: its refresh is the
65
+ // model's (a hardware option — see packages/pet/package.json's catalog),
66
+ // FRAME_SYNC.pet measures it at start-up, and `8bs run pet` says so if
67
+ // given --pal. It is the same set `8bs targets` reports a region for, so
68
+ // there is one of it (hardware.mjs).
69
+ const REGION_TARGETS = REGION_MACHINES;
70
+
71
+ // Diagnostics may come from any module in the import graph, so each one is
72
+ // rendered against its own file's text, not the entry's.
73
+ function printDiagnostics(diagnostics, sources) {
74
+ for (const d of diagnostics) {
75
+ const { line, column } = positionAt(sources.get(d.file) ?? '', d.start);
76
+ process.stdout.write(`${basename(d.file)}:${line}:${column}\n`);
77
+ process.stdout.write(`${d.severity} ${d.code}: ${d.message}\n\n`);
78
+ }
79
+ }
80
+
81
+ // The old target names, kept only to point people at their replacement
82
+ // rather than failing with a bare "unknown target".
83
+ const RETIRED_TARGET = /^(vic20|c64)-(ntsc|pal)$/;
84
+
85
+ // `entry` in 8bs.config.ts is one path, shared by every target, and the
86
+ // filename rule does the rest: a project whose entry point genuinely has to
87
+ // differ on one machine — its execution model, its screen codes, its grid
88
+ // — puts that machine's version beside the shared file as
89
+ // `main.<target>.8bs`, and a build for that target starts there instead.
90
+ // The same rule applies to every file the entry imports, so this is not a
91
+ // special case for the entry; it is just where the CLI applies it first.
92
+ // The rule is applied to whatever names the entry — the config, the
93
+ // default, or an explicit argument — unless that path already names one
94
+ // machine's version (`8bs build src/main.nes.8bs`), which is taken as is.
95
+ //
96
+ // `entry` may still be an object keyed by machine (`{ default, nes }`), the
97
+ // older spelling of the same idea, kept working for projects that use it;
98
+ // `default` covers whichever targets aren't named.
99
+ export function resolveEntryPath(config, target, entryArg) {
100
+ let entry = config?.entry;
101
+ if (entry && typeof entry === 'object') entry = entry[target] ?? entry.default;
102
+ const path = resolve(entryArg ?? entry ?? 'src/main.8bs');
103
+ if (isVariantPath(path)) return path;
104
+ const variant = variantOf(path, target);
105
+ return existsSync(variant) ? variant : path;
106
+ }
107
+
108
+ /**
109
+ * Compile one entry file for one target.
110
+ *
111
+ * @param {'vic20'|'c64'|'pet'|'c128'|'atari8'|'nes'|'cx16'|'mega65'|'web'} target
112
+ * @param {string} [entryArg]
113
+ * @param {{ pal?: boolean, profile?: string, hardware?: object }} [options] `pal` selects the
114
+ * real hardware/emulator region (NTSC unless true; ignored outside
115
+ * REGION_TARGETS) — it does not affect the logical frame rate, which is
116
+ * read from 8bs.config.ts's `frameRate` instead (default 60). `profile`
117
+ * names a project profile or a catalog preset, `hardware` is option
118
+ * values set on top (`--hardware`); see hardware.mjs.
119
+ * @returns {Promise<{ ok: boolean, outFile?: string, frameRate?: number, hardware?: object }>}
120
+ * `hardware` is the resolved hardware the program was built for, for
121
+ * whoever runs it next.
122
+ */
123
+ export async function compile(target, entryArg, { pal = false, profile, hardware: overrides = {} } = {}) {
124
+ const config = await loadConfig(process.cwd(), '8bs build');
125
+
126
+ const frameRateResult = resolveFrameRate(config);
127
+ if (!frameRateResult.ok) {
128
+ process.stderr.write(`8bs build: ${frameRateResult.error}\n`);
129
+ return { ok: false };
130
+ }
131
+ const { frameRate } = frameRateResult;
132
+
133
+ // Nothing in a build reads the `systems` block, but a typo in one should
134
+ // not wait for someone to open the editor to be noticed. Said once, and
135
+ // not fatal: the build asked for is still the build to make.
136
+ const systems = projectSystems(config);
137
+ if (!systems.ok) process.stderr.write(`8bs build: ${systems.error}\n`);
138
+
139
+ const retired = RETIRED_TARGET.exec(target ?? '');
140
+ if (retired) {
141
+ process.stderr.write(
142
+ `8bs build: '${target}' is no longer a target. Use '${retired[1]}'` +
143
+ `${retired[2] === 'pal' ? " with '--pal'" : ' (NTSC is the default)'} instead.\n`,
144
+ );
145
+ return { ok: false };
146
+ }
147
+ if (!TARGETS.has(target)) {
148
+ process.stderr.write(
149
+ `8bs build: unknown target '${target}'. Targets: ${[...TARGETS].join(', ')}\n`,
150
+ );
151
+ return { ok: false };
152
+ }
153
+ const listed = listedTargets(config);
154
+ if (listed && !listed.includes(target)) {
155
+ process.stderr.write(
156
+ `8bs build: this project's 8bs.config.ts does not list '${target}' ` +
157
+ `(targets: ${listed.join(', ')})\n`,
158
+ );
159
+ return { ok: false };
160
+ }
161
+
162
+ const resolved = resolveHardware(loadCatalog(target), {
163
+ profile, overrides, profiles: projectProfiles(config, target), defaults: projectHardware(config, target),
164
+ });
165
+ if (!resolved.ok) {
166
+ process.stderr.write(`8bs build: ${resolved.error}\n`);
167
+ return { ok: false };
168
+ }
169
+ const { hardware } = resolved;
170
+
171
+ // What the program needs of the machine, before the machine gets a
172
+ // chance to disappoint it. A program that cannot run in the RAM it was
173
+ // given fails at the linker with an overflow measured in bytes of
174
+ // section; this says the same thing in the program's own terms, names
175
+ // what it asked for, and — the useful half — what this machine could be
176
+ // fitted with that would do.
177
+ const required = projectRequires(config);
178
+ if (!required.ok) {
179
+ process.stderr.write(`8bs build: ${required.error}\n`);
180
+ return { ok: false };
181
+ }
182
+ const unmet = unmetRequirements(required.requires, hardware.facts);
183
+ if (unmet.length > 0) {
184
+ const catalog = loadCatalog(target);
185
+ process.stderr.write(
186
+ `8bs build: this program asks for more than a ${hardware.label === 'stock' ? `stock ${target}` : `${target} with ${hardware.label}`} gives.\n`,
187
+ );
188
+ for (const { key, need, have } of unmet) {
189
+ process.stderr.write(
190
+ ` ${key}: needs ${need === true ? 'it' : need}, this build has ${have === true ? 'it' : have}\n`,
191
+ );
192
+ const fits = whatSatisfies(catalog, key, need, {
193
+ profile, overrides, profiles: projectProfiles(config, target), defaults: projectHardware(config, target),
194
+ });
195
+ process.stderr.write(fits.length > 0
196
+ ? ` fit one of: ${fits.join(', ')} (--hardware, or a system in 8bs.config.ts)\n`
197
+ : ` no ${target} can be fitted with that; this program is not for this machine\n`);
198
+ }
199
+ return { ok: false };
200
+ }
201
+
202
+ const entry = resolveEntryPath(config, target, entryArg);
203
+ if (!existsSync(entry)) {
204
+ process.stderr.write(`8bs build: entry ${entry} does not exist\n`);
205
+ return { ok: false };
206
+ }
207
+
208
+ const text = await readFile(entry, 'utf8');
209
+
210
+ // The linker runs the full front end over the entry and everything it
211
+ // imports, then merges the graph into one program. Any error in any module
212
+ // means no build. The machine rides along so packages with target-
213
+ // conditional entries resolve to this machine's implementation, and the
214
+ // hardware's facts so every `#fact(...)` — the sheet @8bitscript/system
215
+ // declares — folds to this build's value, and the
216
+ // hardware's tags so a file with a `.<machine>.<tag>.8bs` twin resolves
217
+ // to that.
218
+ const { ir, diagnostics, sources } = link(text, entry, { machine: target, tags: hardware.tags, facts: hardware.facts, frameRate });
219
+ if (diagnostics.length > 0) {
220
+ printDiagnostics(diagnostics, sources);
221
+ process.stdout.write(`${diagnostics.length} problem(s); not building.\n`);
222
+ return { ok: false };
223
+ }
224
+
225
+ // A target's own entry file is named after it (main.nes.8bs — see
226
+ // resolveEntryPath); the output name carries the target once, in the
227
+ // same place every other target's does, so main.nes.8bs builds to
228
+ // main-nes.nes just as main.8bs does, not to main.nes-nes.nes.
229
+ let stem = basename(entry, '.8bs');
230
+ if (stem.endsWith(`.${target}`)) stem = stem.slice(0, -(target.length + 1));
231
+ if (target === 'web') {
232
+ const { buildWasm } = await import('@8bitscript/backend-web');
233
+ const outFile = resolve('dist', `${stem}.wasm`);
234
+ const result = await buildWasm(ir, { outFile });
235
+ if (!result.ok) {
236
+ process.stderr.write(`8bs build: ${result.error}\n`);
237
+ return { ok: false };
238
+ }
239
+ const { writeWebBundle } = await import('./web-runtime.mjs');
240
+ const webDir = resolve('dist', 'web');
241
+ await writeWebBundle(webDir, await readFile(outFile), { frameRate });
242
+ process.stdout.write(`built ${outFile}\n(generated AssemblyScript: ${result.asFile})\n`);
243
+ process.stdout.write(`web bundle: ${webDir}/\n`);
244
+ process.stdout.write(`${memoryLine(ir.memory)}\n`);
245
+ return { ok: true, outFile, frameRate, hardware, webDir };
246
+ }
247
+
248
+ const { buildPrg, outputExtension } = await import('@8bitscript/backend-6502');
249
+ // Hardware that changes the build is in the name (an 8032 PET, an
250
+ // expanded VIC-20, an XEGS cartridge); hardware that only changes the
251
+ // emulator is not, because the file is the same file.
252
+ const nameParts = [stem, target, ...hardware.buildValues];
253
+ if (REGION_TARGETS.has(target)) nameParts.push(pal ? 'pal' : 'ntsc');
254
+ const ext = outputExtension(target, hardware);
255
+ const outFile = resolve('dist', `${nameParts.join('-')}.${ext}`);
256
+ const result = await buildPrg(ir, { machine: target, hardware, outFile, frameRate });
257
+ if (!result.ok) {
258
+ process.stderr.write(`8bs build: ${result.error}\n`);
259
+ return { ok: false };
260
+ }
261
+ process.stdout.write(`built ${outFile}\n(generated C: ${result.cFile})\n`);
262
+ process.stdout.write(`${memoryLine(ir.memory, result.memory)}\n`);
263
+ return { ok: true, outFile, frameRate, hardware };
264
+ }
265
+
266
+ /**
267
+ * The memory line under "built": how much RAM the program's variables
268
+ * take and how much constant data it carries. Measured from the linked
269
+ * program when the backend could (the 6502 backend reads the ELF; a
270
+ * variable LLVM dropped for being unread is not counted), else as
271
+ * declared in the source. The machine's own limit is the toolchain's:
272
+ * a program that does not fit does not build, and the build says so.
273
+ */
274
+ export function memoryLine(declared, measured = null) {
275
+ if (measured) {
276
+ return `memory: ${measured.variables} bytes of RAM for variables, ${measured.program} bytes of program (code and data)`;
277
+ }
278
+ return `memory: ${declared.variables} bytes of RAM for variables, ${declared.data} bytes of constant data (as declared)`;
279
+ }
280
+
281
+ /** @returns {Promise<number>} exit code */
282
+ export async function build(args) {
283
+ const pal = args.includes('--pal');
284
+ const targetIndex = args.indexOf('--target');
285
+ const hw = hardwareArgs(args);
286
+ if (!hw.ok) {
287
+ process.stderr.write(`8bs build: ${hw.error}\n`);
288
+ return 2;
289
+ }
290
+ const positionals = args.filter((a, i) => {
291
+ if (targetIndex >= 0 && (i === targetIndex || i === targetIndex + 1)) return false;
292
+ if (hw.consumed.has(i)) return false;
293
+ return !a.startsWith('-');
294
+ });
295
+ const target = targetIndex >= 0 ? args[targetIndex + 1] : positionals[0];
296
+ const entry = targetIndex >= 0 ? positionals[0] : positionals[1];
297
+
298
+ if (!target) {
299
+ process.stderr.write(
300
+ 'Usage: 8bs build --target <vic20|c64|pet|c128|atari8|nes|cx16|mega65|web>\n'
301
+ + ' [--pal]\n'
302
+ + HARDWARE_USAGE
303
+ + ' [entry.8bs]\n',
304
+ );
305
+ return 2;
306
+ }
307
+ const { ok } = await compile(target, entry, { pal, profile: hw.profile, hardware: hw.overrides });
308
+ return ok ? 0 : 1;
309
+ }
package/src/check.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // `8bs check` — run the compiler's diagnostics over files and print them.
2
+ //
3
+ // The output format is the one the editor shows, in terminal form. Both come
4
+ // from the same analyze() call, so a green `8bs check` and a clean editor mean
5
+ // the same thing.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { relative, resolve } from 'node:path';
8
+
9
+ import { analyze, positionAt } from '@8bitscript/compiler';
10
+
11
+ import { loadConfig, resolveFrameRate } from './config.mjs';
12
+
13
+ /**
14
+ * @param {string[]} files
15
+ * @returns {Promise<number>} process exit code
16
+ */
17
+ export async function check(files) {
18
+ if (files.length === 0) {
19
+ process.stderr.write('8bs check: no files given\n\nUsage: 8bs check <file.8bs> [...]\n');
20
+ return 2;
21
+ }
22
+
23
+ // The same project-level `frameRate` a build would use (8bs.config.ts,
24
+ // default 60) — `#frames(...)` folds against it, so a project's `8bs
25
+ // check` and its `8bs build` agree on what a duration means. There is no
26
+ // per-project config for the language server yet (no workspace-root
27
+ // concept to load 8bs.config.ts from), so its diagnostics still assume 60
28
+ // until that's picked up as follow-up work.
29
+ const config = await loadConfig(process.cwd(), '8bs check');
30
+ const frameRateResult = resolveFrameRate(config);
31
+ if (!frameRateResult.ok) {
32
+ process.stderr.write(`8bs check: ${frameRateResult.error}\n`);
33
+ return 2;
34
+ }
35
+ const { frameRate } = frameRateResult;
36
+
37
+ let total = 0;
38
+
39
+ for (const file of files) {
40
+ const path = resolve(file);
41
+ let text;
42
+ try {
43
+ text = await readFile(path, 'utf8');
44
+ } catch {
45
+ process.stderr.write(`8bs check: cannot read ${file}\n`);
46
+ total += 1;
47
+ continue;
48
+ }
49
+
50
+ const display = relative(process.cwd(), path) || file;
51
+ // Resolution needs the real path; the display path is only for printing.
52
+ for (const d of analyze(text, path, { resolveImports: true, frameRate })) {
53
+ const { line, column } = positionAt(text, d.start);
54
+ process.stdout.write(`${display}:${line}:${column}\n`);
55
+ process.stdout.write(`${d.severity} ${d.code}: ${d.message}\n\n`);
56
+ total += 1;
57
+ }
58
+ }
59
+
60
+ if (total === 0) {
61
+ process.stdout.write(`Checked ${files.length} file(s). No problems found.\n`);
62
+ return 0;
63
+ }
64
+
65
+ process.stdout.write(`${total} problem(s) found.\n`);
66
+ return 1;
67
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // Shared 8bs.config.ts loading, used by both `8bs build` and `8bs check` (and,
2
+ // through them, anything else that needs a project's config without
3
+ // duplicating the loader).
4
+ import { existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ /**
9
+ * The project's 8bs.config.ts, if present. Node 26 imports TypeScript with
10
+ * type stripping, so the config is an ordinary module, not a parsed format.
11
+ *
12
+ * @param {string} dir
13
+ * @param {string} [label] Prefixes a load error, e.g. "8bs build".
14
+ */
15
+ export async function loadConfig(dir, label = '8bs') {
16
+ const path = join(dir, '8bs.config.ts');
17
+ if (!existsSync(path)) return null;
18
+ try {
19
+ const module = await import(pathToFileURL(path).href);
20
+ return module.default ?? null;
21
+ } catch (error) {
22
+ process.stderr.write(`${label}: cannot load 8bs.config.ts: ${error.message}\n`);
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The project's logical frame rate — what `waitFrame()` runs at, the same on every target,
29
+ * independent of --pal (which only selects a real hardware/emulator
30
+ * region, not the logical rate; see packages/backend-6502's FRAME_SYNC).
31
+ * Defaults to 60; `#frames(...)` durations and the waitFrame() runtime are
32
+ * both built against whatever this resolves to.
33
+ *
34
+ * @param {object|null} config
35
+ * @returns {{ ok: true, frameRate: number } | { ok: false, error: string }}
36
+ */
37
+ export function resolveFrameRate(config) {
38
+ const frameRate = config?.frameRate ?? 60;
39
+ if (!Number.isInteger(frameRate) || frameRate <= 0) {
40
+ return {
41
+ ok: false,
42
+ error: `8bs.config.ts's frameRate must be a positive integer, got ${JSON.stringify(config?.frameRate)}`,
43
+ };
44
+ }
45
+ return { ok: true, frameRate };
46
+ }