← Files BranchaARCHIVED FILE
skills/brancha/scripts/brancha.mjs
16.3 KB · Oct 3, 2026 · 06:30 UTC
#!/usr/bin/env node
import { randomUUID } from 'node:crypto';
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
const skillDirectory = resolve(scriptsDirectory, '..');
const appDirectory = join(skillDirectory, 'app');
const serverEntry = join(appDirectory, 'dist', 'server.mjs');
const uiEntry = join(appDirectory, 'dist', 'public', 'index.html');
const version = JSON.parse(readFileSync(join(appDirectory, 'package.json'), 'utf8')).version;
const HELP = `Brancha ${version}
Usage:
brancha.mjs start|stop|doctor|setup
brancha.mjs theme [light|dark|system]
brancha.mjs language [zh-CN|en|ja|es|de]
brancha.mjs project list
brancha.mjs project create (--prompt <text>|--file <path>|--stdin) [--title <text>]
brancha.mjs project tree <project-id> [--compact]
brancha.mjs project rename <project-id> --title <text> [--expected-updated-at <timestamp>]
brancha.mjs project delete <project-id>
brancha.mjs node add --project <id> --parent <id> --title <text> [--content <text>] [--artifact-type <type>] [--file <path>|--stdin]
brancha.mjs node batch --project <id> (--file <path>|--stdin)
brancha.mjs node complete <node-id> --artifact-type <type> (--file <path>|--stdin|--artifact <text>)
brancha.mjs node update <node-id> [--title <text>] [--content <text>] [--parent <id>] [--position <n>]
brancha.mjs node error <node-id> (--message <text>|--file <path>|--stdin)
brancha.mjs node clear-artifact <node-id>
brancha.mjs node delete <node-id>
Environment:
BRANCHA_PORT, BRANCHA_HOST, BRANCHA_ALLOWED_HOSTS, BRANCHA_DATA_DIR
`;
function dataDirectory() {
if (process.env.BRANCHA_DATA_DIR) return process.env.BRANCHA_DATA_DIR;
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Brancha');
if (process.platform === 'win32') return join(process.env.APPDATA || homedir(), 'Brancha');
return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'brancha');
}
const runtimeFile = () => join(dataDirectory(), 'runtime.json');
function readRuntime() {
try { return JSON.parse(readFileSync(runtimeFile(), 'utf8')); } catch { return null; }
}
function writeRuntime(runtime) {
mkdirSync(dataDirectory(), { recursive: true });
writeFileSync(runtimeFile(), `${JSON.stringify(runtime, null, 2)}\n`);
}
function resolvePort() {
return Number(process.env.BRANCHA_PORT) || Number(readRuntime()?.port) || 9519;
}
function resolveHost() {
return process.env.BRANCHA_HOST || readRuntime()?.host || '127.0.0.1';
}
function resolveAllowedHosts() {
return process.env.BRANCHA_ALLOWED_HOSTS ?? readRuntime()?.allowedHosts ?? '';
}
function stoppedRuntime(runtime) {
return {
url: runtime.url,
host: runtime.host,
port: runtime.port,
allowedHosts: runtime.allowedHosts || '',
pid: null,
instanceId: null,
stoppedAt: new Date().toISOString(),
version,
};
}
function ensureRuntime() {
const [major, minor] = process.versions.node.split('.').map(Number);
if (major < 22 || (major === 22 && minor < 5)) {
throw new Error(`Brancha requires Node.js 22.5 or newer; found ${process.versions.node}.`);
}
if (!existsSync(serverEntry) || !existsSync(uiEntry)) {
throw new Error('Brancha runtime is incomplete. Reinstall or update the skill.');
}
}
async function health(url) {
try {
const response = await fetch(`${url}/api/health`);
const body = await response.json();
return response.ok && body.service === 'brancha' ? body : null;
} catch {
return null;
}
}
async function start({ quiet = false } = {}) {
ensureRuntime();
const stored = readRuntime();
const hasAddressOverride = Boolean(process.env.BRANCHA_PORT || process.env.BRANCHA_HOST);
const overrideMatches = !hasAddressOverride || (
(!process.env.BRANCHA_PORT || Number(process.env.BRANCHA_PORT) === stored?.port)
&& (!process.env.BRANCHA_HOST || process.env.BRANCHA_HOST === stored?.host)
);
if (stored?.url && !overrideMatches) {
const previous = await health(stored.url);
if (previous && previous.instanceId !== stored.instanceId) {
throw new Error(`Refusing to replace runtime state: ${stored.url} belongs to a different Brancha instance.`);
}
if (previous?.instanceId && previous.instanceId === stored.instanceId && previous.pid === stored.pid) {
try { process.kill(stored.pid, 'SIGTERM'); } catch {}
}
}
if (stored?.url && overrideMatches) {
const current = await health(stored.url);
if (current && (!stored.instanceId || stored.instanceId === current.instanceId)) {
if (!stored.instanceId) {
writeRuntime({
...stored,
pid: current.pid,
instanceId: current.instanceId,
startedAt: new Date().toISOString(),
stoppedAt: undefined,
version: current.version,
});
}
if (!quiet) console.log(stored.url);
return stored.url;
}
}
const port = resolvePort();
const host = resolveHost();
const allowedHosts = resolveAllowedHosts();
const clientHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
const url = `http://${clientHost}:${port}`;
const occupied = await health(url);
if (occupied) {
writeRuntime({ url, host, port, allowedHosts, pid: occupied.pid, instanceId: occupied.instanceId, startedAt: new Date().toISOString(), version: occupied.version });
if (!quiet) console.log(url);
return url;
}
const data = dataDirectory();
const instanceId = randomUUID();
mkdirSync(data, { recursive: true });
const log = openSync(join(data, 'brancha.log'), 'a');
const child = spawn(process.execPath, [serverEntry], {
cwd: appDirectory,
detached: true,
env: {
...process.env,
PORT: String(port),
HOST: host,
BRANCHA_APP_DIR: appDirectory,
BRANCHA_DATA_DIR: data,
BRANCHA_ALLOWED_HOSTS: allowedHosts,
BRANCHA_INSTANCE_ID: instanceId,
BRANCHA_VERSION: version,
},
stdio: ['ignore', log, log],
});
child.unref();
closeSync(log);
for (let attempt = 0; attempt < 20; attempt++) {
const ready = await health(url);
if (ready?.instanceId === instanceId) {
writeRuntime({ url, host, port, allowedHosts, pid: child.pid, instanceId, startedAt: new Date().toISOString(), version });
if (!quiet) console.log(url);
return url;
}
await delay(250);
}
try { process.kill(child.pid, 'SIGTERM'); } catch {}
throw new Error(`Brancha failed to start. See ${join(data, 'brancha.log')}`);
}
async function stop() {
const runtime = readRuntime();
if (!runtime) {
console.log('Brancha is not running.');
return;
}
const current = await health(runtime.url);
if (current && current.instanceId && current.instanceId === runtime.instanceId && current.pid === runtime.pid) {
try { process.kill(runtime.pid, 'SIGTERM'); } catch {}
writeRuntime(stoppedRuntime(runtime));
console.log('Brancha stopped.');
return;
}
writeRuntime(stoppedRuntime(runtime));
console.log('Cleared stale Brancha process state; saved network settings were kept and no process was killed.');
}
async function doctor() {
const runtime = readRuntime();
const host = resolveHost();
const clientHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
const url = process.env.BRANCHA_PORT || process.env.BRANCHA_HOST
? `http://${clientHost}:${resolvePort()}`
: runtime?.url || `http://${clientHost}:${resolvePort()}`;
const current = await health(url);
console.log(`Skill: ${skillDirectory}`);
console.log(`Node: v${process.versions.node}`);
console.log(`Brancha: v${version}`);
console.log(`UI: ${existsSync(uiEntry) ? 'ready' : 'missing'}`);
console.log(`API: ${existsSync(serverEntry) ? 'ready' : 'missing'}`);
console.log(`Data: ${dataDirectory()}`);
console.log(`Server: ${current ? `running at ${url} (${current.instanceId || 'untracked'})` : 'stopped'}`);
}
function parseArgs(values) {
const options = {};
const positional = [];
for (let index = 0; index < values.length; index++) {
const value = values[index];
if (!value.startsWith('--')) { positional.push(value); continue; }
const name = value.slice(2);
const next = values[index + 1];
if (!next || next.startsWith('--')) options[name] = true;
else { options[name] = next; index++; }
}
return { options, positional };
}
function textOption(options, name, { required = false } = {}) {
const value = options[name];
if (required && typeof value !== 'string') throw new Error(`--${name} is required`);
return typeof value === 'string' ? value : undefined;
}
function inputText(options, directName = 'content') {
const sources = [typeof options.file === 'string', options.stdin === true, typeof options[directName] === 'string'].filter(Boolean).length;
if (sources > 1) throw new Error(`Use only one of --file, --stdin, or --${directName}.`);
if (typeof options.file === 'string') return readFileSync(resolve(options.file), 'utf8');
if (options.stdin === true) return readFileSync(0, 'utf8');
return typeof options[directName] === 'string' ? options[directName] : undefined;
}
async function apiRequest(path, { method = 'GET', body } = {}) {
const baseUrl = await start({ quiet: true });
const attempts = method === 'GET' ? 2 : 1;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const contentType = response.headers.get('content-type') || '';
const result = contentType.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) {
const message = typeof result === 'object' && result ? `${result.code || 'REQUEST_FAILED'}: ${result.error}` : `HTTP ${response.status}`;
const error = new Error(message);
error.retryable = response.status >= 500;
throw error;
}
return result;
} catch (error) {
if (attempt + 1 < attempts && (error.retryable || error instanceof TypeError)) { await delay(200); continue; }
throw error;
}
}
}
function print(value) {
console.log(JSON.stringify(value, null, 2));
}
async function projectCommand(action, values) {
const { options, positional } = parseArgs(values);
if (action === 'list') return print(await apiRequest('/api/projects'));
if (action === 'create') {
const prompt = inputText(options, 'prompt');
if (!prompt?.trim()) throw new Error('Provide --prompt, --file, or --stdin.');
return print(await apiRequest('/api/projects', { method: 'POST', body: { prompt, title: textOption(options, 'title') } }));
}
const projectId = positional[0];
if (!projectId) throw new Error(`project ${action} requires a project id`);
if (action === 'tree') {
const tree = await apiRequest(`/api/projects/${projectId}/tree`);
if (!options.compact) return print(tree);
const depth = new Map();
for (const node of tree.nodes) {
depth.set(node.id, node.parent_id ? (depth.get(node.parent_id) || 0) + 1 : 0);
const state = ['html', 'markdown', 'svg', 'image', 'video', 'audio'].includes(node.type)
? node.content ? 'ready' : 'generating'
: node.type === 'error' ? 'failed' : node.content ? 'content' : 'title';
console.log(`${' '.repeat(depth.get(node.id))}#${node.seq} ${node.id} [${node.type}/${state}] ${node.title}`);
}
return;
}
if (action === 'rename') {
return print(await apiRequest(`/api/projects/${projectId}`, { method: 'PUT', body: {
title: textOption(options, 'title', { required: true }),
expectedUpdatedAt: textOption(options, 'expected-updated-at'),
} }));
}
if (action === 'delete') return print(await apiRequest(`/api/projects/${projectId}`, { method: 'DELETE' }));
throw new Error('Usage: project <list|create|tree|rename|delete>');
}
async function nodeCommand(action, values) {
const { options, positional } = parseArgs(values);
if (action === 'add') {
const projectId = textOption(options, 'project', { required: true });
const artifactType = textOption(options, 'artifact-type');
const artifact = inputText(options, 'artifact');
const body = {
parentId: textOption(options, 'parent', { required: true }),
title: textOption(options, 'title', { required: true }),
content: textOption(options, 'content'),
position: options.position === undefined ? undefined : Number(options.position),
artifactType, artifact,
};
return print(await apiRequest(`/api/projects/${projectId}/nodes`, { method: 'POST', body }));
}
if (action === 'batch') {
const projectId = textOption(options, 'project', { required: true });
const source = inputText(options);
if (!source) throw new Error('Provide --file, --stdin, or --content with batch JSON.');
const body = JSON.parse(source);
return print(await apiRequest(`/api/projects/${projectId}/nodes/batch`, { method: 'POST', body }));
}
const nodeId = positional[0];
if (!nodeId) throw new Error(`node ${action} requires a node id`);
const expectedUpdatedAt = textOption(options, 'expected-updated-at');
if (action === 'complete') {
const artifact = inputText(options, 'artifact');
if (!artifact?.trim()) throw new Error('Provide --file, --stdin, or --artifact.');
return print(await apiRequest(`/api/nodes/${nodeId}/artifact`, { method: 'PUT', body: {
artifact, artifactType: textOption(options, 'artifact-type', { required: true }), expectedUpdatedAt,
} }));
}
if (action === 'error') {
const error = inputText(options, 'message');
if (!error?.trim()) throw new Error('Provide --message, --file, or --stdin.');
return print(await apiRequest(`/api/nodes/${nodeId}/artifact/error`, { method: 'PUT', body: { error, expectedUpdatedAt } }));
}
if (action === 'update') {
return print(await apiRequest(`/api/nodes/${nodeId}`, { method: 'PUT', body: {
title: textOption(options, 'title'), content: textOption(options, 'content'),
parentId: textOption(options, 'parent'),
position: options.position === undefined ? undefined : Number(options.position), expectedUpdatedAt,
} }));
}
if (action === 'clear-artifact') return print(await apiRequest(`/api/nodes/${nodeId}/artifact`, { method: 'DELETE' }));
if (action === 'delete') return print(await apiRequest(`/api/nodes/${nodeId}`, { method: 'DELETE' }));
throw new Error('Usage: node <add|batch|complete|error|update|clear-artifact|delete>');
}
async function themeCommand(theme) {
if (theme === undefined) return print(await apiRequest('/api/settings'));
if (!['light', 'dark', 'system'].includes(theme)) throw new Error('Usage: theme <light|dark|system>');
return print(await apiRequest('/api/settings/theme', { method: 'PUT', body: { theme } }));
}
async function languageCommand(locale) {
if (locale === undefined) return print(await apiRequest('/api/settings'));
if (!['zh-CN', 'en', 'ja', 'es', 'de'].includes(locale)) throw new Error('Usage: language <zh-CN|en|ja|es|de>');
return print(await apiRequest('/api/settings/locale', { method: 'PUT', body: { locale } }));
}
const [command = 'start', action, ...values] = process.argv.slice(2);
try {
if (command === 'help' || command === '--help' || command === '-h') console.log(HELP);
else if (command === 'version' || command === '--version' || command === '-v') console.log(version);
else if (command === 'start') await start();
else if (command === 'stop') await stop();
else if (command === 'doctor') await doctor();
else if (command === 'setup') { ensureRuntime(); console.log('Brancha runtime is ready.'); }
else if (command === 'theme') await themeCommand(action);
else if (command === 'language') await languageCommand(action);
else if (command === 'project') await projectCommand(action, values);
else if (command === 'node') await nodeCommand(action, values);
else throw new Error('Usage: brancha.mjs <start|stop|doctor|setup|theme|language|project|node>');
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
SHA-256: 48b50279e5d8fca337c74ccb13e79a36268fe9b729e86b8166761184d91ace40