From f0f63c8e6aa1a2063d5a72c7d9d6668ea8adf4ac Mon Sep 17 00:00:00 2001 From: Nick Lombardi Date: Sat, 8 Aug 2026 05:00:08 -0400 Subject: [PATCH] Add stateless headless CLI --- HEADLESS_CLI.md | 102 ++++++++++ README.md | 6 + electron/headless_cleanup.js | 36 ++++ electron/headless_cli.js | 211 +++++++++++++++++++++ electron/main.js | 261 +++++++++++++++++++++++--- js/api.ts | 12 +- js/boot_loader.js | 40 ++-- js/headless_cli.ts | 244 ++++++++++++++++++++++++ js/io/io.js | 16 +- js/plugin_loader.ts | 16 +- package.json | 4 + tests/fixtures/headless_add_cube.js | 20 ++ tests/fixtures/headless_empty.bbmodel | 14 ++ tests/headless_cli.concurrent.test.js | 85 +++++++++ tests/headless_cli.e2e.test.js | 140 ++++++++++++++ tests/headless_cli.test.js | 95 ++++++++++ 16 files changed, 1248 insertions(+), 54 deletions(-) create mode 100644 HEADLESS_CLI.md create mode 100644 electron/headless_cleanup.js create mode 100644 electron/headless_cli.js create mode 100644 js/headless_cli.ts create mode 100644 tests/fixtures/headless_add_cube.js create mode 100644 tests/fixtures/headless_empty.bbmodel create mode 100644 tests/headless_cli.concurrent.test.js create mode 100644 tests/headless_cli.e2e.test.js create mode 100644 tests/headless_cli.test.js diff --git a/HEADLESS_CLI.md b/HEADLESS_CLI.md new file mode 100644 index 000000000..421d355bd --- /dev/null +++ b/HEADLESS_CLI.md @@ -0,0 +1,102 @@ +# Headless CLI + +Blockbench can run one model-editing action without showing a window or starting +a server. Each invocation creates an isolated temporary profile, opens the model +through Blockbench's normal codec, runs trusted JavaScript in the real renderer, +saves a new `.bbmodel` through the project codec, and exits. + +## Run from source + +Build the renderer once: + +```sh +npm install +npm run build-electron +``` + +Then run an action: + +```sh +npm run headless -- \ + --input creature.bbmodel \ + --script remove_saddle.js \ + --output creature_without_saddle.bbmodel +``` + +Packaged builds use the same arguments directly on the Blockbench executable: + +```sh +Blockbench --headless \ + --input creature.bbmodel \ + --script remove_saddle.js \ + --output creature_without_saddle.bbmodel +``` + +Use `--force` to replace an existing output. The input is never changed unless +the same path is explicitly supplied as output together with `--force`. + +## Script environment + +Scripts run after the model and its textures are ready. Blockbench's live globals +are available directly, including `Project`, `Cube`, `Group`, `Mesh`, `Texture`, +`Undo`, `Canvas`, and `Blockbench`: + +```js +const saddle = Group.all.find(group => group.name === 'saddle') +if (!saddle) throw new Error('Saddle group not found') + +const affected = saddle.getAllChildren() +saddle.remove(true) +Canvas.updateAll() + +return {removed: saddle.name, affected: affected.length} +``` + +The script body is an async function, so top-level `await` and `return` work. +It also receives these variables: + +- `context`: `{input, output, args, Blockbench, globals, project, waitForTextures}` +- `input` and `output`: resolved absolute paths +- `args`: values after `--` +- `module` and `exports`: optional CommonJS-style export support + +Instead of using a script body, a file can export a function: + +```js +module.exports = async ({args}) => { + Cube.all[0].name = args[0] + return {renamed: Cube.all[0].uuid} +} +``` + +Run it with arguments using `-- old_name new_name`. For short actions, +`--eval ` can be used instead of `--script`. + +Scripts are not sandboxed. They run with the desktop app's local permissions and +must be trusted. + +## Process contract + +The last stdout line is a JSON result. Script `console` output is written to +stderr so callers can parse stdout reliably. + +Successful result: + +```json +{"ok":true,"input":"...","output":"...","phase":"complete","exitCode":0,"format":"free","elements":12,"durationMs":913,"result":{"removed":"saddle"}} +``` + +Failed result: + +```json +{"ok":false,"input":"...","output":"...","phase":"script","exitCode":5,"error":{"name":"Error","message":"Saddle group not found"}} +``` + +Exit codes are `0` for success, `2` for invalid arguments, `3` for input/load +errors, `4` for output/save errors, `5` for script errors, `6` for renderer or +runtime errors, and `124` for a timeout. + +Every invocation is independent. It does not load installed plugins, contact the +plugin API or updater, modify recent projects, start backups, or reuse a profile. +This lets multiple processes edit different files concurrently without sharing +`Project`, `Undo`, scene, or codec state. diff --git a/README.md b/README.md index f03d92c37..ab1eb35eb 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ Use this command or press Ctrl + Shift + B to launch Blockbench in Electron: To enable debugging in VS Code, switch to the **Run & Debug** tab, select the **"Debug Renderer"** configuration, and press the green arrow button to launch. Now you can set breakpoints and debug inside VSCode. +### Run Headlessly + +Use the [headless CLI](HEADLESS_CLI.md) to open a model, run JavaScript against +Blockbench's live renderer globals, save a `.bbmodel`, and exit without showing +a window or starting a server. + ### Run the web app Use this command to launch the web app locally: diff --git a/electron/headless_cleanup.js b/electron/headless_cleanup.js new file mode 100644 index 000000000..4fd9ad67c --- /dev/null +++ b/electron/headless_cleanup.js @@ -0,0 +1,36 @@ +import {setTimeout as wait} from 'node:timers/promises' + +import {removeHeadlessCLIProfile} from './headless_cli.js' + +const profile_path = process.argv[2] +const parent_pid = Number(process.argv[3]) + +if (!profile_path || !Number.isInteger(parent_pid) || parent_pid <= 0) { + process.exit(2) +} + +function isParentRunning() { + try { + process.kill(parent_pid, 0) + return true + } catch (error) { + return false + } +} + +// Chromium releases profile database and cache handles only after its parent +// exits. Bound both waits so a failed cleanup can never become a stray daemon. +for (let attempt = 0; attempt < 300 && isParentRunning(); attempt++) { + await wait(100) +} + +for (let attempt = 0; attempt < 50; attempt++) { + try { + removeHeadlessCLIProfile(profile_path) + process.exit(0) + } catch (error) { + await wait(100) + } +} + +process.exit(1) diff --git a/electron/headless_cli.js b/electron/headless_cli.js new file mode 100644 index 000000000..03bf26176 --- /dev/null +++ b/electron/headless_cli.js @@ -0,0 +1,211 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export const HEADLESS_CLI_PROFILE_PREFIX = 'blockbench-headless-' + +export const HeadlessExitCode = Object.freeze({ + SUCCESS: 0, + USAGE: 2, + INPUT: 3, + OUTPUT: 4, + SCRIPT: 5, + RUNTIME: 6, + TIMEOUT: 124, +}) + +export const HEADLESS_CLI_USAGE = `Usage: + Blockbench --headless --input --script --output [options] [--