Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions HEADLESS_CLI.md
Original file line number Diff line number Diff line change
@@ -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 <javascript>` 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.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions electron/headless_cleanup.js
Original file line number Diff line number Diff line change
@@ -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)
211 changes: 211 additions & 0 deletions electron/headless_cli.js
Original file line number Diff line number Diff line change
@@ -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 <model> --script <file.js> --output <result.bbmodel> [options] [-- <script args...>]
Blockbench --headless --input <model> --eval <javascript> --output <result.bbmodel> [options] [-- <script args...>]

Options:
-i, --input <path> Model to open using Blockbench's normal codecs
-s, --script <path> JavaScript file to run against the live Blockbench globals
-e, --eval <code> JavaScript source to run instead of a script file
-o, --output <path> Destination Blockbench project (.bbmodel)
--force Allow replacing an existing output file
--timeout <ms> Whole-operation timeout (default: 60000)
-h, --help Show this help

The script runs in Blockbench's renderer after the input model is loaded. It can
use live globals such as Project, Cube, Group, Mesh, Texture, Undo, Canvas, and
Blockbench. Top-level await and return are supported. The variables context,
input, output, args, module, and exports are also provided. A CommonJS export
that is a function is called with context after the script body is evaluated.

This executes trusted code with the same local access as the desktop app. Each
invocation uses a new temporary Blockbench profile and exits after one action.`

export class HeadlessCLIArgumentError extends Error {
constructor(message, exit_code = HeadlessExitCode.USAGE) {
super(message)
this.name = 'HeadlessCLIArgumentError'
this.exitCode = exit_code
}
}

function readOptionValue(argv, index, option) {
const value = argv[index + 1]
if (value === undefined || value === '--') {
throw new HeadlessCLIArgumentError(`Missing value after ${option}`)
}
return value
}

/**
* Parse arguments following --headless. Arguments before --headless belong to
* Electron and are deliberately ignored.
*/
export function parseHeadlessCLIArguments(argv, cwd = process.cwd()) {
const headless_index = argv.indexOf('--headless')
if (headless_index === -1) return null

const options = {
headless: true,
help: false,
input: '',
output: '',
script: '',
eval: '',
force: false,
timeout: 60_000,
args: [],
}

for (let index = headless_index + 1; index < argv.length; index++) {
const argument = argv[index]
if (argument === '--') {
options.args = argv.slice(index + 1)
break
}
switch (argument) {
case '-h':
case '--help':
options.help = true
break
case '-i':
case '--input':
options.input = readOptionValue(argv, index, argument)
index++
break
case '-o':
case '--output':
options.output = readOptionValue(argv, index, argument)
index++
break
case '-s':
case '--script':
options.script = readOptionValue(argv, index, argument)
index++
break
case '-e':
case '--eval':
options.eval = readOptionValue(argv, index, argument)
index++
break
case '--force':
options.force = true
break
case '--timeout': {
const value = readOptionValue(argv, index, argument)
options.timeout = Number(value)
index++
break
}
default:
throw new HeadlessCLIArgumentError(`Unknown headless option: ${argument}`)
}
}

if (options.help) return options
if (!options.input) throw new HeadlessCLIArgumentError('Missing required option: --input')
if (!options.output) throw new HeadlessCLIArgumentError('Missing required option: --output')
if (!options.script && !options.eval) {
throw new HeadlessCLIArgumentError('Specify exactly one of --script or --eval')
}
if (options.script && options.eval) {
throw new HeadlessCLIArgumentError('--script and --eval cannot be used together')
}
if (!Number.isInteger(options.timeout) || options.timeout < 1_000 || options.timeout > 3_600_000) {
throw new HeadlessCLIArgumentError('--timeout must be an integer from 1000 to 3600000 milliseconds')
}

options.input = path.resolve(cwd, options.input)
options.output = path.resolve(cwd, options.output)
if (options.script) options.script = path.resolve(cwd, options.script)
return options
}

export function validateHeadlessCLIPaths(options) {
let input_stat
try {
input_stat = fs.statSync(options.input)
} catch (error) {
throw new HeadlessCLIArgumentError(`Input file does not exist: ${options.input}`, HeadlessExitCode.INPUT)
}
if (!input_stat.isFile()) {
throw new HeadlessCLIArgumentError(`Input path is not a file: ${options.input}`, HeadlessExitCode.INPUT)
}

if (options.script) {
let script_stat
try {
script_stat = fs.statSync(options.script)
} catch (error) {
throw new HeadlessCLIArgumentError(`Script file does not exist: ${options.script}`, HeadlessExitCode.SCRIPT)
}
if (!script_stat.isFile()) {
throw new HeadlessCLIArgumentError(`Script path is not a file: ${options.script}`, HeadlessExitCode.SCRIPT)
}
}

if (path.extname(options.output).toLowerCase() !== '.bbmodel') {
throw new HeadlessCLIArgumentError('Output path must use the .bbmodel extension')
}
let output_directory_stat
const output_directory = path.dirname(options.output)
try {
output_directory_stat = fs.statSync(output_directory)
} catch (error) {
throw new HeadlessCLIArgumentError(`Output directory does not exist: ${output_directory}`, HeadlessExitCode.OUTPUT)
}
if (!output_directory_stat.isDirectory()) {
throw new HeadlessCLIArgumentError(`Output parent is not a directory: ${output_directory}`, HeadlessExitCode.OUTPUT)
}
if (!options.force && fs.existsSync(options.output)) {
throw new HeadlessCLIArgumentError(
`Output file already exists (use --force to replace it): ${options.output}`,
HeadlessExitCode.OUTPUT,
)
}
return options
}

export function createHeadlessCLIProfile() {
return fs.mkdtempSync(path.join(os.tmpdir(), HEADLESS_CLI_PROFILE_PREFIX))
}

/**
* Remove only a profile directory created directly under the OS temp folder.
* Returns false when the directory was already absent.
*/
export function removeHeadlessCLIProfile(profile_path) {
const resolved_profile = path.resolve(profile_path)
const resolved_temp = path.resolve(os.tmpdir())
const profile_name = path.basename(resolved_profile)
const random_suffix = profile_name.slice(HEADLESS_CLI_PROFILE_PREFIX.length)
if (
path.dirname(resolved_profile) !== resolved_temp ||
!profile_name.startsWith(HEADLESS_CLI_PROFILE_PREFIX) ||
!/^[A-Za-z0-9]{6}$/.test(random_suffix)
) {
throw new Error(`Refusing to remove unexpected headless profile path: ${profile_path}`)
}
if (!fs.existsSync(resolved_profile)) return false
const profile_stat = fs.lstatSync(resolved_profile)
if (!profile_stat.isDirectory() || profile_stat.isSymbolicLink()) {
throw new Error(`Refusing to remove non-directory headless profile path: ${profile_path}`)
}
fs.rmSync(resolved_profile, {recursive: true, force: false})
return true
}
Loading