#!/usr/bin/env node
* Development environment startup script
* Manages pre-build tasks and dev server startup
*/
const fs = require('fs');
const net = require('net');
const { execSync, spawn } = require('child_process');
const path = require('path');
const { pathToFileURL } = require('url');
const {
printHeader,
printSuccess,
printInfo,
printError,
printWarning,
printStep,
printComplete,
printBlank,
} = require('./console-style.cjs');
const ROOT_DIR = path.resolve(__dirname, '..');
const DEV_SERVER_PORT = 1422;
const DEV_SERVER_HOSTS = ['localhost', '127.0.0.1', '::1'];
const DESKTOP_PREVIEW_REBUILD_INPUTS = [
path.join(ROOT_DIR, 'Cargo.toml'),
path.join(ROOT_DIR, 'src', 'apps', 'desktop'),
path.join(ROOT_DIR, 'src', 'crates'),
];
const DESKTOP_PREVIEW_REBUILD_IGNORED_DIRS = new Set([
'.bitfun',
'.git',
'coverage',
'dist',
'node_modules',
'target',
]);
const DESKTOP_PREVIEW_REBUILD_RELEVANT_EXTENSIONS = new Set([
'.ftl',
'.json',
'.md',
'.rs',
'.toml',
'.yaml',
'.yml',
]);
const DESKTOP_PREVIEW_REBUILD_IGNORED_BASENAMES = new Set([
'AGENTS-CN.md',
'AGENTS.md',
'CONTRIBUTING.md',
'CONTRIBUTING_CN.md',
'README.md',
'README.zh-CN.md',
'README_CN.md',
]);
function isDesktopMode(mode) {
return mode === 'desktop' || mode === 'desktop-preview';
}
function getDesktopBinaryPath() {
const suffix = process.platform === 'win32' ? '.exe' : '';
const binaryName = `bitfun-desktop${suffix}`;
if (process.platform === 'darwin') {
return path.join(ROOT_DIR, 'target', 'debug', 'BitFun.app', 'Contents', 'MacOS', 'BitFun');
}
return path.join(ROOT_DIR, 'target', 'debug', binaryName);
}
function decodeOutput(output) {
if (!output) return '';
if (typeof output === 'string') return output;
const buffer = Buffer.isBuffer(output) ? output : Buffer.from(output);
if (process.platform !== 'win32') return buffer.toString('utf-8');
const utf8 = buffer.toString('utf-8');
if (!utf8.includes('�')) return utf8;
try {
const { TextDecoder } = require('util');
const decoder = new TextDecoder('gbk');
const gbk = decoder.decode(buffer);
if (gbk && !gbk.includes('�')) return gbk;
return gbk || utf8;
} catch (error) {
return utf8;
}
}
* Run command and show output
*/
function runCommand(command, cwd = ROOT_DIR) {
return new Promise((resolve, reject) => {
const isWindows = process.platform === 'win32';
const shell = isWindows ? 'cmd.exe' : '/bin/sh';
const shellArgs = isWindows ? ['/c', command] : ['-c', command];
const child = spawn(shell, shellArgs, {
cwd,
stdio: 'inherit'
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with code ${code}`));
}
});
child.on('error', reject);
});
}
* Spawn a command with explicit args array (no shell interpolation, safe for paths with spaces)
*/
function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = false) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit',
shell,
env: {
...process.env,
...envOverrides,
},
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with code ${code}`));
}
});
child.on('error', reject);
});
}
* Run a command asynchronously with piped output, prefixing every line so
* parallel preparation steps stay distinguishable. Resolves (never rejects)
* with { ok, code, error }.
*/
function runCommandPrefixed(prefix, cmd, args, cwd = ROOT_DIR, envOverrides = {}) {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
cwd,
shell: process.platform === 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
...envOverrides,
},
});
const forward = (stream, out) => {
let buffered = '';
stream.on('data', (chunk) => {
buffered += decodeOutput(chunk);
const lines = buffered.split(/\r?\n/);
buffered = lines.pop() ?? '';
for (const line of lines) {
if (line.trim() !== '') {
out.write(`[${prefix}] ${line}\n`);
}
}
});
stream.on('end', () => {
if (buffered.trim() !== '') {
out.write(`[${prefix}] ${buffered}\n`);
}
});
};
forward(child.stdout, process.stdout);
forward(child.stderr, process.stderr);
child.on('error', (error) => resolve({ ok: false, code: null, error }));
child.on('close', (code) => resolve({ ok: code === 0, code, error: null }));
});
}
function spawnBackgroundCommand(cmd, args, cwd = ROOT_DIR, env = process.env) {
return spawn(cmd, args, {
cwd,
stdio: 'inherit',
env,
});
}
function spawnWindowsCommand(command, cwd = ROOT_DIR, env = process.env) {
return spawn(process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', ['/d', '/s', '/c', command], {
cwd,
stdio: 'inherit',
env,
});
}
function spawnWindowsCommandArgs(command, args, cwd = ROOT_DIR, env = process.env) {
return spawn(process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe', ['/d', '/s', '/c', command, ...args], {
cwd,
stdio: 'inherit',
env,
});
}
function runWindowsCommandArgs(command, args, cwd = ROOT_DIR, env = process.env) {
return new Promise((resolve, reject) => {
const child = spawnWindowsCommandArgs(command, args, cwd, env);
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with code ${code}`));
}
});
child.on('error', reject);
});
}
function stopChildProcess(child) {
if (!child || child.exitCode !== null) {
return;
}
if (process.platform === 'win32') {
try {
execSync(`taskkill /pid ${child.pid} /T /F >nul 2>&1`);
return;
} catch (error) {
}
}
try {
child.kill('SIGTERM');
} catch (error) {
}
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function isPortOpen(port, hosts = DEV_SERVER_HOSTS) {
return Promise.any(hosts.map((host) => {
return new Promise((resolve, reject) => {
const client = new net.Socket();
client.setTimeout(1500);
client.connect(port, host, () => {
client.destroy();
resolve(true);
});
client.on('error', (error) => {
client.destroy();
reject(error);
});
client.on('timeout', () => {
client.destroy();
reject(new Error(`Timeout connecting to ${host}:${port}`));
});
});
})).then(() => true).catch(() => false);
}
async function waitForPort(port, hosts = DEV_SERVER_HOSTS, timeoutMs = 30000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isPortOpen(port, hosts)) {
return;
}
await wait(500);
}
throw new Error(`Port ${port} did not become ready within ${timeoutMs}ms`);
}
async function runDesktopTargetGcBestEffort(profile = 'debug') {
try {
const { runGcBestEffort } = await import(
pathToFileURL(path.join(__dirname, 'cargo-target-gc.mjs')).href
);
printInfo('Pruning stale Cargo target caches (keep latest only)');
runGcBestEffort({
rootDir: ROOT_DIR,
profile,
logger: {
info: (message) => printInfo(message),
warn: (message) => printError(message),
},
});
} catch (error) {
printError(`Target GC skipped: ${error.message || String(error)}`);
}
}
async function runDesktopTargetGc(profile = 'debug') {
try {
const { runGcBestEffort } = await import(
pathToFileURL(path.join(__dirname, 'cargo-target-gc.mjs')).href
);
printInfo('Pruning stale Cargo target caches (keep latest only)...');
runGcBestEffort({
rootDir: ROOT_DIR,
profile,
logger: {
info: (message) => printInfo(message),
warn: (message) => printWarning(message),
},
});
} catch (error) {
printWarning(`target-gc skipped: ${error.message || String(error)}`);
}
}
async function rebuildDesktopDebugBinary() {
const buildEnv = {
...process.env,
CARGO_PROFILE_DEV_DEBUG: process.env.CARGO_PROFILE_DEV_DEBUG || '0',
CARGO_PROFILE_DEV_INCREMENTAL: process.env.CARGO_PROFILE_DEV_INCREMENTAL || 'true',
CARGO_PROFILE_DEV_CODEGEN_UNITS: process.env.CARGO_PROFILE_DEV_CODEGEN_UNITS || '256',
};
printInfo('Building bitfun-desktop in dev mode with reduced debug info for faster local relink');
printInfo(
`Fast local build env: CARGO_PROFILE_DEV_DEBUG=${buildEnv.CARGO_PROFILE_DEV_DEBUG}, ` +
`CARGO_PROFILE_DEV_CODEGEN_UNITS=${buildEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS}`
);
await spawnCommand(
process.platform === 'win32' ? 'cargo.exe' : 'cargo',
['build', '-p', 'bitfun-desktop'],
ROOT_DIR,
buildEnv,
);
}
function getNewestTrackedInput(entryPath) {
if (!fs.existsSync(entryPath)) {
return null;
}
const stat = fs.lstatSync(entryPath);
if (stat.isSymbolicLink()) {
return null;
}
if (stat.isFile()) {
const basename = path.basename(entryPath);
if (DESKTOP_PREVIEW_REBUILD_IGNORED_BASENAMES.has(basename)) {
return null;
}
const ext = path.extname(entryPath).toLowerCase();
if (!DESKTOP_PREVIEW_REBUILD_RELEVANT_EXTENSIONS.has(ext)) {
return null;
}
if (ext === '.md' && !entryPath.includes(`${path.sep}prompts${path.sep}`)) {
return null;
}
return {
path: entryPath,
mtimeMs: stat.mtimeMs,
};
}
if (!stat.isDirectory()) {
return null;
}
let newest = null;
for (const entry of fs.readdirSync(entryPath, { withFileTypes: true })) {
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory() && DESKTOP_PREVIEW_REBUILD_IGNORED_DIRS.has(entry.name)) {
continue;
}
const candidate = getNewestTrackedInput(path.join(entryPath, entry.name));
if (candidate && (!newest || candidate.mtimeMs > newest.mtimeMs)) {
newest = candidate;
}
}
return newest;
}
function getDesktopPreviewRebuildPlan(desktopBinary, forceRebuild = false) {
if (forceRebuild) {
return {
shouldRebuild: true,
reason: 'Force rebuild requested for desktop preview',
};
}
if (!fs.existsSync(desktopBinary)) {
return {
shouldRebuild: true,
reason: 'Debug desktop binary is missing',
};
}
const binaryMtimeMs = fs.statSync(desktopBinary).mtimeMs;
let newestInput = null;
for (const input of DESKTOP_PREVIEW_REBUILD_INPUTS) {
const candidate = getNewestTrackedInput(input);
if (candidate && (!newestInput || candidate.mtimeMs > newestInput.mtimeMs)) {
newestInput = candidate;
}
}
if (newestInput && newestInput.mtimeMs > binaryMtimeMs) {
return {
shouldRebuild: true,
reason: `Rust / Tauri inputs changed since the last preview (${path.relative(ROOT_DIR, newestInput.path)})`,
};
}
return {
shouldRebuild: false,
reason: `Reusing debug desktop binary: ${path.relative(ROOT_DIR, desktopBinary)}`,
};
}
async function ensureDesktopDebugBinaryForPreview(forceRebuild = false) {
const desktopBinary = getDesktopBinaryPath();
const rebuildPlan = getDesktopPreviewRebuildPlan(desktopBinary, forceRebuild);
if (!rebuildPlan.shouldRebuild) {
printInfo(rebuildPlan.reason);
return desktopBinary;
}
printInfo(`${rebuildPlan.reason}; rebuilding before preview`);
await rebuildDesktopDebugBinary();
printSuccess('Debug desktop binary rebuilt for preview');
return desktopBinary;
}
async function startDesktopPreview() {
const desktopBinary = getDesktopBinaryPath();
if (!fs.existsSync(desktopBinary)) {
printError(`Debug desktop binary not found: ${desktopBinary}`);
printInfo('Retry with `pnpm run desktop:preview:debug -- --force-rebuild` or build it with `cargo build -p bitfun-desktop`');
process.exit(1);
}
let appProcess = null;
let devServerProcess = null;
let ownsDevServer = false;
let shuttingDown = false;
const cleanup = () => {
stopChildProcess(appProcess);
if (ownsDevServer) {
stopChildProcess(devServerProcess);
}
};
const shutdown = async (exitCode = 0) => {
if (shuttingDown) {
return;
}
shuttingDown = true;
cleanup();
await runDesktopTargetGc('debug');
process.exit(exitCode);
};
process.on('SIGINT', () => {
printInfo('Stopping desktop preview...');
void shutdown(0);
});
process.on('SIGTERM', () => {
printInfo('Stopping desktop preview...');
void shutdown(0);
});
if (await isPortOpen(DEV_SERVER_PORT)) {
printInfo(`Reusing web UI dev server on http://localhost:${DEV_SERVER_PORT}`);
} else {
printInfo(`Starting web UI dev server on http://localhost:${DEV_SERVER_PORT}`);
const viteArgs = ['--dir', 'src/web-ui', 'exec', 'vite', '--host', 'localhost', '--port', String(DEV_SERVER_PORT)];
const viteEnv = {
...process.env,
TAURI_DEV_HOST: 'localhost',
};
devServerProcess = process.platform === 'win32'
? spawnWindowsCommand(`pnpm ${viteArgs.join(' ')}`, ROOT_DIR, viteEnv)
: spawnBackgroundCommand('pnpm', viteArgs, ROOT_DIR, viteEnv);
ownsDevServer = true;
devServerProcess.on('error', (error) => {
printError(`Web UI dev server failed to start: ${error.message || String(error)}`);
shutdown(1);
});
devServerProcess.on('exit', (code, signal) => {
devServerProcess = null;
ownsDevServer = false;
if (!appProcess && !shuttingDown && code !== 0) {
printError(`Web UI dev server exited before desktop launch (code=${code ?? 'null'}, signal=${signal ?? 'null'})`);
shutdown(code ?? 1);
return;
}
if (appProcess && appProcess.exitCode === null && !shuttingDown) {
printError(`Web UI dev server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'})`);
shutdown(code ?? 1);
}
});
try {
await waitForPort(DEV_SERVER_PORT);
} catch (error) {
printError(error.message || String(error));
await shutdown(1);
}
printSuccess(`Web UI dev server is ready on http://localhost:${DEV_SERVER_PORT}`);
}
printInfo(`Launching debug desktop binary: ${desktopBinary}`);
appProcess = spawnBackgroundCommand(desktopBinary, [], ROOT_DIR, {
...process.env,
});
appProcess.on('error', (error) => {
printError(`Desktop preview failed to start: ${error.message || String(error)}`);
void shutdown(1);
});
appProcess.on('exit', (code, signal) => {
if (!shuttingDown) {
printInfo(`Desktop preview exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`);
}
void shutdown(code ?? 0);
});
printSuccess('Desktop preview is running');
printInfo('Front-end edits continue to use Vite HMR; rebuild Rust only when desktop-side code changes');
await new Promise(() => {});
}
function flashgrepBinaryNames() {
if (process.platform === 'win32' && process.arch === 'x64') {
return ['flashgrep-x86_64-pc-windows-msvc.exe'];
}
if (process.platform === 'win32' && process.arch === 'arm64') {
return ['flashgrep-aarch64-pc-windows-msvc.exe'];
}
if (process.platform === 'darwin' && process.arch === 'x64') {
return ['flashgrep-x86_64-apple-darwin'];
}
if (process.platform === 'darwin' && process.arch === 'arm64') {
return ['flashgrep-aarch64-apple-darwin'];
}
if (process.platform === 'linux' && process.arch === 'x64') {
return [
'flashgrep-x86_64-unknown-linux-musl',
'flashgrep-x86_64-unknown-linux-gnu',
];
}
if (process.platform === 'linux' && process.arch === 'arm64') {
return [
'flashgrep-aarch64-unknown-linux-musl',
'flashgrep-aarch64-unknown-linux-gnu',
];
}
return [process.platform === 'win32' ? 'flashgrep.exe' : 'flashgrep'];
}
function flashgrepBinaryName() {
return flashgrepBinaryNames()[0];
}
function ensureFlashgrepBinary() {
for (const binaryName of flashgrepBinaryNames()) {
const binaryPath = path.join(ROOT_DIR, 'resources', 'flashgrep', binaryName);
if (!fs.existsSync(binaryPath)) {
continue;
}
return { ok: true, binaryPath };
}
return {
ok: false,
error: new Error(
`flashgrep binary not found for ${process.platform}/${process.arch}. Expected one of: ${flashgrepBinaryNames()
.map((name) => `resources/flashgrep/${name}`)
.join(', ')}`
),
};
}
async function ensureFlashgrepBundleResource() {
const helperUrl = pathToFileURL(path.join(__dirname, 'prepare-flashgrep-resource.mjs')).href;
const helper = await import(helperUrl);
return helper.ensureFlashgrepBinary();
}
* Main entry
*/
async function main() {
const startTime = Date.now();
let mode = process.argv[2] || 'web';
const extraArgs = process.argv.slice(3);
let forceDesktopPreviewRebuild = extraArgs.includes('--force-rebuild');
if (mode === 'desktop-preview-rebuild') {
mode = 'desktop-preview';
forceDesktopPreviewRebuild = true;
}
const desktopMode = isDesktopMode(mode);
const modeLabelMap = {
desktop: 'Desktop',
'desktop-preview': 'Desktop Debug Preview',
web: 'Web',
};
const modeLabel = modeLabelMap[mode] || 'Web';
printHeader(`BitFun ${modeLabel} Development`);
printBlank();
const totalSteps = 2;
let currentStep = 1;
printStep(
currentStep++,
totalSteps,
desktopMode
? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep)'
: 'Prepare resources (parallel: monaco, version)'
);
const prepTasks = [
{
name: 'Copy Monaco resources',
hint: 'Hint: run `pnpm install` in repo root if dependencies are missing',
promise: runCommandPrefixed('copy-monaco', 'pnpm', ['run', 'copy-monaco', '--silent']),
},
{
name: 'Generate version info',
promise: runCommandPrefixed('version', 'node', [
'scripts/generate-version.cjs',
'--build-env',
'development',
]),
},
];
if (desktopMode) {
prepTasks.push({
name: 'Build mobile-web',
promise: runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']),
});
prepTasks.push({
name: 'Prepare workspace search daemon',
promise: (async () => {
const flashgrepResult = ensureFlashgrepBinary();
if (!flashgrepResult.ok) {
return {
ok: false,
code: null,
error: flashgrepResult.error || new Error('Workspace search daemon is missing'),
};
}
process.env.FLASHGREP_DAEMON_BIN = flashgrepResult.binaryPath;
try {
await ensureFlashgrepBundleResource();
} catch (error) {
return { ok: false, code: null, error };
}
return { ok: true, code: 0, error: null };
})(),
});
}
const prepResults = await Promise.all(prepTasks.map((task) => task.promise));
let prepFailed = false;
prepResults.forEach((result, index) => {
const task = prepTasks[index];
if (result.ok) {
return;
}
prepFailed = true;
printError(`${task.name} failed`);
if (result.error && result.error.message) {
printError(result.error.message);
}
if (result.code !== null && result.code !== undefined && result.code !== 0) {
printError(`Exit code: ${result.code}`);
}
if (task.hint) {
printInfo(task.hint);
}
});
if (prepFailed) {
process.exit(1);
}
printSuccess('Preparation tasks complete');
const prepTime = ((Date.now() - startTime) / 1000).toFixed(1);
const startStepLabel = mode === 'desktop-preview'
? 'Start desktop preview'
: 'Start dev server';
printStep(currentStep, totalSteps, startStepLabel);
printInfo(`Prep took ${prepTime}s`);
printComplete('Initialization complete');
try {
if (mode === 'desktop') {
const desktopDir = path.join(ROOT_DIR, 'src/apps/desktop');
const tauriConfig = path.join(desktopDir, 'tauri.dev.conf.json');
const tauriDevEnv = {
...process.env,
CARGO_PROFILE_DEV_CODEGEN_UNITS: process.env.CARGO_PROFILE_DEV_CODEGEN_UNITS || '256',
};
try {
if (process.platform === 'win32') {
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd');
await runWindowsCommandArgs(tauriBin, ['dev', '--config', tauriConfig], desktopDir, tauriDevEnv);
} else {
const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri');
await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir, {
CARGO_PROFILE_DEV_CODEGEN_UNITS: tauriDevEnv.CARGO_PROFILE_DEV_CODEGEN_UNITS,
});
}
} finally {
await runDesktopTargetGc('debug');
}
} else if (mode === 'desktop-preview') {
await ensureDesktopDebugBinaryForPreview(forceDesktopPreviewRebuild);
await startDesktopPreview();
} else {
await runCommand('pnpm exec vite', path.join(ROOT_DIR, 'src/web-ui'));
}
} catch (error) {
printError('Dev server failed to start');
if (error?.message) {
printError(error.message);
}
process.exit(1);
}
}
main().catch((error) => {
printError('Startup failed: ' + error.message);
process.exit(1);
});