vscode: bundle CLI

This commit is contained in:
Steven Hugg committed 2026-09-29 14:25:24 +02:00
1 parent c912c65e0d
commit 65acaa91b1
16 files changed
+293 -13

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
!out/extension.js
!out/buildworker.js
!out/emuworker.js
!out/8bws.js
!out/presets.json
!out/assets.json
!out/assets/8bitworkshop-base-*.tar.br
+4
View File
@@ -17,5 +17,9 @@ First preview.
frame, and can step back.
- `.s`, `.asm`, `.inc` and `.a` files in a project get the highlighting
for the platform's CPU. `.acme` files get 6502 highlighting.
- An `8bws` command in the integrated terminal builds and runs programs
from the command line, for scripts and AI agents.
- New Project writes a README with build instructions and the link that
platform detection reads.
- The platform list shows the 8bitworkshop book for each platform that
has one, with a button to open it.
+5
View File
@@ -50,6 +50,10 @@ To look at the included examples without copying them, run **8bitworkshop: Open
To use code you already have, open its folder: the extension detects the appropriate platform and main file.
To build from the command line, or let an AI agent build and test your
program, use the `8bws` command in VS Code's integrated terminal. See
[Building from the terminal](docs/building.md#building-from-the-terminal).
## Settings
Project settings, saved in `.vscode/settings.json`:
@@ -71,6 +75,7 @@ Your local user settings:
| `8bitworkshop.exportRom` | Write each successful build's ROM to the export folder |
| `8bitworkshop.toolchainPath` | Use a local 8bitworkshop checkout instead of the included toolchains |
| `8bitworkshop.assetUrl` | Server or local directory to download toolchains from, tried before the default servers |
| `8bitworkshop.terminalCommand` | Add the `8bws` command to the integrated terminal (on by default) |
## Commands
+27
View File
@@ -56,6 +56,33 @@ then writes it to the `8bitworkshop.exportRomPath` folder (`bin` by
default), next to the main file, with the platform's usual extension
such as `.nes` or `.a26`.
## Building from the terminal
VS Code's integrated terminal has an `8bws` command that builds and runs
programs with the same toolchains and emulators as the extension. It
needs no Node.js install. Scripts and AI agents that run in the
terminal, such as Claude Code, can use it to check their work:
```bash
8bws build game.c -o game.nes # compile to a ROM
8bws build --check game.c # just report errors
8bws run game.c --frames 120 --png screen.png
8bws run game.c -e "run 60; screen" # a run script; see 8bws help
8bws help
```
Add `--json` for machine-readable output on stdout. Without `--platform`,
`8bws` uses [platform detection](detection.md#from-the-command-line); the
README that **New Project** writes names the platform, so a new project
needs no `--platform`. Library files that aren't in your folder come from
the examples, as they do in the editor.
The command is a launcher script in the extension's storage folder, which
the extension adds to `PATH` for each new terminal. Terminals opened
before the extension started don't have it; open a new one. To turn it
off, set `8bitworkshop.terminalCommand` to `false`. The first run in a
while may take a few seconds while it unpacks the toolchains.
## Where included files come from
8bitworkshop looks for files your program includes or links in two
+3 -1
View File
@@ -8,7 +8,9 @@ your choices.
A folder becomes an 8bitworkshop project in one of these ways:
- **New Project** or **Copy to Workspace** creates it.
- **New Project** or **Copy to Workspace** creates it. It also writes a
`README.md`, unless the folder has one, with the 8bitworkshop link
below and how to build from the terminal.
- You choose **Set as Main File** on a file, or run a file that has no
project yet, and confirm its platform.
- You accept a detected platform when you open a folder. See
+5
View File
@@ -268,6 +268,11 @@
],
"markdownDescription": "When a successful build restarts the running game."
},
"8bitworkshop.terminalCommand": {
"type": "boolean",
"default": true,
"markdownDescription": "Add the `8bws` command to the integrated terminal, to build and run programs from the command line or an AI agent. Open a new terminal after changing this."
},
"8bitworkshop.exportRom": {
"type": "boolean",
"default": false,
+3 -1
View File
@@ -4,7 +4,8 @@
// node scripts/build.mjs --watch # rebuild on change
//
// extension.js stays small; builds and emulation run in worker threads
// (buildworker.js, emuworker.js) that start on first use. presetindex.js
// (buildworker.js, emuworker.js) that start on first use. 8bws.js is the
// command line tool for the integrated terminal (src/cli.ts). presetindex.js
// writes out/presets.json and syntaxes.js writes out/syntaxes/ (npm run
// build runs both).
import esbuild from 'esbuild';
@@ -51,6 +52,7 @@ const ctx = await esbuild.context({
extension: 'src/extension.ts',
buildworker: 'src/buildworker.ts',
emuworker: 'src/emuworker.ts',
'8bws': 'src/cli.ts',
presetindex: 'scripts/presetindex.ts',
assetpack: 'scripts/assetpack.ts',
syntaxes: 'scripts/syntaxes.ts',
+3
View File
@@ -9,6 +9,9 @@ import { createHash } from 'crypto';
import { AssetManifest, PackInfo, readPack } from './assetpacks';
/** Reports download progress: bytes so far of `total`. */
/** The 8bitworkshop asset servers, tried in order after 8bitworkshop.assetUrl. */
export const ASSET_URLS = ['https://sehugg.github.io/8bitworkshop/vscode/', 'https://8bitworkshop.com/vscode/'];
export type ProgressFn = (pack: string, received: number, total: number) => void;
export class AssetStore {
+33
View File
@@ -0,0 +1,33 @@
// cli - the 8bws command in the integrated terminal (out/8bws.js). The
// launcher script from terminalcli.ts runs it with VS Code's own Node. It
// unpacks the bundled toolchains if the extension hasn't yet, then runs the
// repo's CLI (src/tools/8bws.ts) against them.
import * as fs from 'fs';
import * as path from 'path';
import { ASSET_URLS, AssetStore } from './assets';
import type { AssetManifest } from './assetpacks';
/** The asset root: toolchainPath or a checkout, else the extension's store. */
async function toolchainRoot(): Promise<string> {
var cache = process.env.EIGHTBITWORKSHOP_TOOLCHAINS;
if (!cache) throw new Error('Set EIGHTBITWORKSHOP_ROOT or EIGHTBITWORKSHOP_TOOLCHAINS; the 8bws launcher from VS Code sets them.');
var manifest: AssetManifest = JSON.parse(fs.readFileSync(path.join(__dirname, 'assets.json'), 'utf-8'));
var urls = [path.join(__dirname, 'assets')];
if (process.env.EIGHTBITWORKSHOP_ASSET_URL) urls.push(process.env.EIGHTBITWORKSHOP_ASSET_URL);
urls.push(...ASSET_URLS);
// stderr, so --json output stays clean
var store = new AssetStore(cache, manifest, urls, msg => process.stderr.write(msg + '\n'));
return store.ensure(Object.keys(manifest.packs));
}
async function main() {
if (!process.env.EIGHTBITWORKSHOP_ROOT) process.env.EIGHTBITWORKSHOP_ROOT = await toolchainRoot();
// 8bws runs as it loads, so load it only once the root is set
await import('../../src/tools/8bws');
}
main().catch(e => {
process.stderr.write(`8bws: ${e && e.message || e}\n`);
process.exit(1);
});
+55 -5
View File
@@ -20,7 +20,9 @@ import { PRESET_SCHEME, PresetFileSystem, Templates } from './templates';
import { chooseForFile, detectDirectoryAt, detectionSummary, describeDetection, describeFinding, dontAskKey, FolderFinding, platformName, scanFolder } from './detection';
import { Detection, classifyFinding, isBuildableSource } from '../../src/common/detect';
import { TOOL_META } from '../../src/common/toolmeta';
import { AssetStore } from './assets';
import { ASSET_URLS, AssetStore } from './assets';
import { projectReadme, writeLauncher } from './terminalcli';
import type { PlatformInfo } from './presettypes';
import { AssetManifest, packsForPlatform } from './assetpacks';
import { ErrorTelemetry } from './telemetry';
@@ -72,6 +74,10 @@ export function activate(ctx: vscode.ExtensionContext) {
templates = new Templates(ctx.extensionPath, () => toolchainRoot());
scheduler = new BuildScheduler(reason => autoBuild(reason));
ctx.subscriptions.push(output, telemetry, diagnostics, status, scope);
setupTerminalCommand();
ctx.subscriptions.push(vscode.workspace.onDidChangeConfiguration(e => {
if (['terminalCommand', 'toolchainPath', 'assetUrl'].some(k => e.affectsConfiguration(`${CONFIG}.${k}`))) setupTerminalCommand();
}));
ctx.subscriptions.push(vscode.workspace.registerFileSystemProvider(PRESET_SCHEME, new PresetFileSystem(templates),
{ isCaseSensitive: true, isReadonly: PresetFileSystem.readonlyMessage() }));
@@ -160,14 +166,16 @@ function config(uri?: vscode.Uri) {
////// toolchains
// asset servers, tried in order (8bitworkshop.assetUrl goes first)
const ASSET_URLS = ['https://sehugg.github.io/8bitworkshop/vscode/', 'https://8bitworkshop.com/vscode/'];
/** A local copy (toolchainPath, or the repo in development), if there is one. */
function localRoot(): string | null {
return config().get<string>('toolchainPath') || findRootDir(context.extensionPath);
}
/** Where downloaded and unpacked toolchains live. */
function toolchainCacheDir(): string {
return path.join(context.globalStorageUri.fsPath, 'toolchains');
}
/** Toolchain packs shipped inside the extension (see .vscodeignore). */
function bundledAssetsDir(): string {
return path.join(context.extensionPath, 'out', 'assets');
@@ -203,7 +211,7 @@ function getAssets(): AssetStore {
if (Object.keys(manifest.packs).some(p => hasBundledPack(manifest, p))) urls.push(bundledAssetsDir());
if (custom) urls.push(custom);
urls.push(...ASSET_URLS);
assets = new AssetStore(path.join(context.globalStorageUri.fsPath, 'toolchains'), manifest,
assets = new AssetStore(toolchainCacheDir(), manifest,
urls, msg => output.appendLine(msg));
}
return assets;
@@ -231,6 +239,34 @@ async function installPacks(packs: string[]): Promise<string> {
});
}
/**
* Put 8bws on the integrated terminal's PATH (8bitworkshop.terminalCommand):
* a launcher in global storage runs out/8bws.js with VS Code's Node. It is
* rewritten on each activation, since VS Code's and the extension's paths
* change with updates.
*/
function setupTerminalCommand() {
var env = context.environmentVariableCollection;
env.clear();
var script = path.join(context.extensionPath, 'out', '8bws.js');
if (!config().get<boolean>('terminalCommand') || !fs.existsSync(script)) return;
var vars: { [name: string]: string } = {};
var local = localRoot();
if (local) vars.EIGHTBITWORKSHOP_ROOT = local;
else vars.EIGHTBITWORKSHOP_TOOLCHAINS = toolchainCacheDir();
var custom = config().get<string>('assetUrl');
if (custom) vars.EIGHTBITWORKSHOP_ASSET_URL = custom;
var bin = path.join(context.globalStorageUri.fsPath, 'bin');
try {
writeLauncher(bin, { node: process.execPath, script, env: vars });
} catch (e) {
output.appendLine(`Cannot write the 8bws launcher to ${bin}: ${e && e.message || e}`);
return;
}
env.description = 'Adds the 8bws command, to build and run 8bitworkshop programs from the terminal.';
env.prepend('PATH', bin + path.delimiter);
}
async function prepareToolchains() {
if (localRoot()) {
vscode.window.showInformationMessage(`8bitworkshop: using the toolchains in ${localRoot()}.`);
@@ -985,6 +1021,7 @@ async function createFromTemplate(platformId: string, templateId: string, replac
}
var main = await templates.copy(platform, t, dest.dir, withLibraries);
if (!main) return;
await writeReadme(dest.dir, platform, path.relative(dest.dir.fsPath, main.fsPath).split(path.sep).join('/'));
var settings = { platform: platform.id, mainFile: main.fsPath };
if (dest.open) {
await writeFolderSettings(dest.dir, { platform: platform.id, mainFile: path.basename(main.fsPath) });
@@ -996,6 +1033,19 @@ async function createFromTemplate(platformId: string, templateId: string, replac
await openAndBuild(main.fsPath, false);
}
/** A README with build instructions, unless the folder has one. */
async function writeReadme(dir: vscode.Uri, platform: PlatformInfo, mainFile: string) {
var readme = vscode.Uri.joinPath(dir, 'README.md');
try {
await vscode.workspace.fs.stat(readme);
return;
} catch (e) {
// none yet
}
var text = projectReadme(path.basename(dir.fsPath), platform, mainFile);
await vscode.workspace.fs.writeFile(readme, new TextEncoder().encode(text));
}
async function closeEditor(uri: vscode.Uri) {
for (var group of vscode.window.tabGroups.all) {
for (var tab of group.tabs) {
+88
View File
@@ -0,0 +1,88 @@
// terminalcli - the launcher script that puts 8bws on the integrated
// terminal's PATH. It runs out/8bws.js (cli.ts) with VS Code's own Node, so
// users need no Node install. No vscode import, so tests can drive it.
import * as fs from 'fs';
import * as path from 'path';
export interface LauncherOptions {
/** VS Code's executable; ELECTRON_RUN_AS_NODE makes it plain Node */
node: string;
/** out/8bws.js */
script: string;
/** EIGHTBITWORKSHOP_* variables for cli.ts */
env: { [name: string]: string };
}
function shQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
/** The launcher files for `platform`: a shell script, plus a .cmd on Windows. */
export function launcherFiles(opts: LauncherOptions, platform: string = process.platform): { name: string, text: string }[] {
var env = { ELECTRON_RUN_AS_NODE: '1', ...opts.env };
var sh = [
'#!/bin/sh',
'# 8bitworkshop command line tool, written by the VS Code extension.',
...Object.entries(env).map(([k, v]) => `export ${k}=${shQuote(v)}`),
`exec ${shQuote(opts.node)} ${shQuote(opts.script)} "$@"`,
'',
].join('\n');
var files = [{ name: '8bws', text: sh }];
if (platform === 'win32') {
files.push({
name: '8bws.cmd', text: [
'@echo off',
'rem 8bitworkshop command line tool, written by the VS Code extension.',
'setlocal',
...Object.entries(env).map(([k, v]) => `set "${k}=${v}"`),
`"${opts.node}" "${opts.script}" %*`,
'',
].join('\r\n'),
});
}
return files;
}
/** Write the launcher into `binDir`, touching only files that changed. */
export function writeLauncher(binDir: string, opts: LauncherOptions, platform: string = process.platform) {
fs.mkdirSync(binDir, { recursive: true });
for (var f of launcherFiles(opts, platform)) {
var file = path.join(binDir, f.name);
var old = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null;
if (old !== f.text) fs.writeFileSync(file, f.text);
fs.chmodSync(file, 0o755);
}
}
/**
* README.md for a new project. The 8bitworkshop link names the platform and
* main file, which detection reads (parseReadmeBadge in src/common/detect.ts),
* so `8bws build` needs no --platform.
*/
export function projectReadme(name: string, platform: { id: string, name: string, romext?: string }, mainFile: string): string {
var base = path.basename(mainFile, path.extname(mainFile));
var rom = base + (platform.romext || '.bin');
var link = `https://8bitworkshop.com/redir.html?platform=${encodeURIComponent(platform.id)}&file=${encodeURIComponent(mainFile)}`;
return `# ${name}
A ${platform.name} program made with [8bitworkshop](${link}).
## Build and run
In VS Code with the 8bitworkshop extension, open \`${mainFile}\` and click **Run**.
From VS Code's integrated terminal, in this folder:
\`\`\`sh
# compile to a ROM
8bws build ${mainFile} -o ${rom}
# run without a window for 120 frames, and save a screenshot
8bws run ${mainFile} --frames 120 --png screen.png
# all commands and options
8bws help
\`\`\`
Add \`--json\` for machine-readable output.
`;
}
+47
View File
@@ -0,0 +1,47 @@
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { launcherFiles, projectReadme, writeLauncher } from '../src/terminalcli';
import { parseReadmeBadge } from '../../src/common/detect';
describe('terminalcli', () => {
var opts = { node: "/Apps/Code's Helper", script: '/ext/out/8bws.js', env: { EIGHTBITWORKSHOP_TOOLCHAINS: '/store/toolchains' } };
it('quotes paths in the shell launcher', () => {
var files = launcherFiles(opts, 'darwin');
assert.deepStrictEqual(files.map(f => f.name), ['8bws']);
var sh = files[0].text;
assert.match(sh, /^#!\/bin\/sh\n/);
assert.ok(sh.includes(`export ELECTRON_RUN_AS_NODE='1'`));
assert.ok(sh.includes(`export EIGHTBITWORKSHOP_TOOLCHAINS='/store/toolchains'`));
assert.ok(sh.includes(`exec '/Apps/Code'\\''s Helper' '/ext/out/8bws.js' "$@"`));
});
it('adds a .cmd launcher on Windows', () => {
var cmd = launcherFiles(opts, 'win32').find(f => f.name === '8bws.cmd');
assert.ok(cmd);
assert.ok(cmd.text.includes(`"/Apps/Code's Helper" "/ext/out/8bws.js" %*`));
assert.ok(cmd.text.includes('set "ELECTRON_RUN_AS_NODE=1"'));
});
it('writes an executable launcher, rewriting it when paths change', () => {
var dir = fs.mkdtempSync(path.join(os.tmpdir(), '8bws-launcher-'));
try {
writeLauncher(dir, opts, 'linux');
var file = path.join(dir, '8bws');
assert.ok(fs.statSync(file).mode & 0o100);
writeLauncher(dir, { ...opts, script: '/ext2/out/8bws.js' }, 'linux');
assert.ok(fs.readFileSync(file, 'utf-8').includes('/ext2/out/8bws.js'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('writes a README that detection reads', () => {
var readme = projectReadme('my-game', { id: 'nes', name: 'NES', romext: '.nes' }, 'game.c');
assert.deepStrictEqual(parseReadmeBadge(readme), { platform: 'nes', mainFile: 'game.c' });
assert.ok(readme.includes('8bws build game.c -o game.nes'));
assert.ok(projectReadme('x', { id: 'c64', name: 'C64' }, 'a.c').includes('-o a.bin'));
});
});
+2 -1
View File
@@ -23,6 +23,7 @@ import type { BuildInfo } from './debugservice';
import { parseSymbolFile } from '../common/symbols/symbolfile';
import { romBytes, type CompileResult } from './testlib';
import { ROM_PLATFORMS } from '../common/detect';
import { toolRoot } from './toolroot';
interface Args {
[key: string]: string | true | string[];
@@ -349,7 +350,7 @@ async function writeScreenshot(video: ReturnType<EmuTarget['getVideo']>, pngFile
// platform detection
function presetsDir(): string | null {
for (const dir of [path.resolve('presets'), path.resolve(__dirname, '../../presets')]) {
for (const dir of [path.resolve(toolRoot(), 'presets'), path.resolve(__dirname, '../../presets')]) {
if (fs.existsSync(dir)) return dir;
}
return null;
+2 -1
View File
@@ -6,6 +6,7 @@ import { EmuCore } from "../common/emucore";
import { PLATFORMS } from "../common/emu";
import { getRootBasePlatform } from "../common/util";
import { importPlatform } from "../platform/_index";
import { toolRoot } from "./toolroot";
import { mockAudio, mockDOM, mockFetch, mockGlobals, mockScripts, resetScripts } from "./nodemock";
export { EmuCore as EmuTarget, DEFAULT_MAX_FRAMES } from "../common/emucore";
@@ -14,7 +15,7 @@ type EmuTarget = EmuCore;
/** Load a platform module by ID (e.g. "nes", "c64.wasm", "atari8-5200"). */
export async function loadPlatform(platformId: string): Promise<EmuTarget> {
installNodeMocks();
installNodeMocks(toolRoot());
// a headless host may load more than one platform (or the same one twice)
// in a process; scripts kept global state, so evaluate them fresh each time
resetScripts();
+5 -4
View File
@@ -12,6 +12,7 @@ import { FileProvider, buildWorkerMessage, resolveDependencies } from "../common
import { setupNodeEnvironment, handleMessage, store } from "../worker/workerlib";
import { PLATFORM_PARAMS } from "../worker/platforms";
import { TOOLS } from "../worker/workertools";
import { toolRoot } from "./toolroot";
export { store, PLATFORM_PARAMS, TOOLS };
@@ -52,7 +53,7 @@ let initialized = false;
*/
export async function initialize(): Promise<void> {
if (initialized) return;
setupNodeEnvironment();
setupNodeEnvironment(toolRoot());
// The worker tools are already registered through the import chain:
// workerlib -> workertools -> tools/* and builder -> TOOLS
// No need to load the esbuild bundle.
@@ -110,7 +111,7 @@ export async function compile(options: CompileOptions): Promise<CompileResult> {
export async function compileFile(tool: string, platform: string, presetPath: string): Promise<CompileResult> {
await initialize();
var code = fs.readFileSync('presets/' + platform + '/' + presetPath, 'utf-8');
var code = fs.readFileSync(path.join(toolRoot(), 'presets', platform, presetPath), 'utf-8');
return compile({
tool: tool,
platform: platform,
@@ -121,14 +122,14 @@ export async function compileFile(tool: string, platform: string, presetPath: st
/**
* Reads files for a build from the source file's directory, then
* presets/<base platform>, then the current directory.
* presets/<base platform> under the tool root, then the current directory.
*/
export class NodeFileProvider implements FileProvider {
constructor(readonly sourceDir: string, readonly platform: string) { }
async readFile(filePath: string): Promise<FileData | null> {
var searchPaths = [];
if (this.sourceDir) searchPaths.push(path.resolve(this.sourceDir, filePath));
searchPaths.push(path.resolve('presets', getBasePlatform(this.platform), filePath));
searchPaths.push(path.resolve(toolRoot(), 'presets', getBasePlatform(this.platform), filePath));
searchPaths.push(path.resolve(filePath));
for (var p of searchPaths) {
try {
+10
View File
@@ -0,0 +1,10 @@
// toolroot - where the CLI finds toolchains, presets and BIOS images: a
// directory laid out like the repo (src/worker/..., presets/, res/).
/**
* $EIGHTBITWORKSHOP_ROOT when set (the VS Code extension's launcher points it
* at the extension's toolchains), else the current directory (a checkout).
*/
export function toolRoot(): string {
return process.env.EIGHTBITWORKSHOP_ROOT || process.cwd();
}