#!/usr/bin/env bun /** * @license * Copyright 2025 AionUi (aionui.com) * SPDX-License-Identifier: Apache-2.0 * * Pure Bun CLI — launches the WebUI (backend + static server + auth) without * starting Electron. Replaces the former `electron-vite dev -- --webui` flow. * * Env vars: * AIONUI_PORT : static server port (default 33000) * AIONUI_HOST : listen host; set to 0.0.0.0 to imply --remote * AIONUI_ALLOW_REMOTE : "1"/"true" to expose to LAN * AIONUI_DATA_DIR : override userData path (default Electron-compatible) * AIONUI_LOG_DIR : override log dir (default /logs) * AIONUI_STATIC_DIR : override static dir (default out/renderer) * AIONUI_BACKEND_BIN : absolute path to aioncore binary (else PATH lookup) * AIONUI_BACKEND_BUNDLED_DIR : dir containing bundled-aioncore//binary * AIONUI_OPEN_BROWSER : "1"/"true" to force open, "0"/"false" to disable */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { startWebHost } from '@aionui/web-host'; import { openBrowserUrl, shouldAutoOpenBrowser } from '../packages/web-cli/src/browser.js'; // Aligned with packages/desktop/src/common/config/constants.ts WEBUI_DEFAULT_PORT. const DEFAULT_PORT = (() => { if (process.env.NODE_ENV === 'production') return 25808; if (process.env.AIONUI_MULTI_INSTANCE === '1') return 25810; return 25809; })(); const BACKEND_BINARY = process.platform === 'win32' ? 'aioncore.exe' : 'aioncore'; const __filename = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(__filename), '..'); const args = process.argv.slice(2); const has = (name: string): boolean => args.includes(name); const getFlag = (name: string): string | undefined => { const idx = args.indexOf(name); if (idx === -1) return undefined; const next = args[idx + 1]; return next && !next.startsWith('--') ? next : undefined; }; /** * Resolve the directory where aioncore persists its SQLite DB. * * `bun run webui` runs **independently of the Electron desktop app** — it must * work on hosts that never installed AionUi.app, and its default work dir must * NOT collide with Electron's. * * --data-dir CLI override (highest priority) * $AIONUI_DATA_DIR env override (same effect) * otherwise ~/.aionui-web (production) * ~/.aionui-web-dev (dev, default) * ~/.aionui-web-dev-2 (dev + AIONUI_MULTI_INSTANCE=1) * * Why a dedicated `-web` name, not the same `~/.aionui[-dev]` that Electron * uses: on macOS, Electron's getDataPath() (packages/desktop/src/process/utils/ * utils.ts) creates `~/.aionui-dev` as a **symlink** to * `~/Library/Application Support/AionUi-Dev/aionui` so CLI tools (claude, * gemini, qwen…) don't choke on the literal space in "Application Support". * If standalone webui runs first on a clean machine, it would create the * symlink location as a **real directory** instead. When Electron is later * installed, its `ensureCliSafeSymlink` refuses to overwrite a real dir and * falls back to returning the space-containing path — and then every ACP * agent inside the desktop app starts failing on CLI commands. Using * `.aionui-web` keeps standalone webui's data dir off of the path Electron's * symlink needs. * * If the user wants the two to share data they opt-in explicitly via * --data-dir ~/.aionui-dev (or equivalent on other OSes) * which is safe because by that point Electron has created the symlink and * `bun run webui` just follows it. */ function resolveBackendDataDir(): string { const override = getFlag('--data-dir') ?? process.env.AIONUI_DATA_DIR; if (override && override.trim().length > 0) { const resolved = path.resolve(override); fs.mkdirSync(resolved, { recursive: true }); return resolved; } const suffix = process.env.NODE_ENV === 'production' ? '' : process.env.AIONUI_MULTI_INSTANCE === '1' ? '-dev-2' : '-dev'; const dir = path.join(os.homedir(), `.aionui-web${suffix}`); fs.mkdirSync(dir, { recursive: true }); return dir; } function parseBoolean(v: string | undefined): boolean { if (!v) return false; return ['1', 'true', 'yes', 'on'].includes(v.trim().toLowerCase()); } function resolvePort(): number { const cli = getFlag('--port'); if (cli && /^\d+$/.test(cli)) return Number(cli); const env = process.env.AIONUI_PORT ?? process.env.PORT; if (env && /^\d+$/.test(env)) return Number(env); return DEFAULT_PORT; } function resolveAllowRemote(): boolean { if (has('--remote')) return true; const host = process.env.AIONUI_HOST?.trim(); if (host && ['0.0.0.0', '::', '::0'].includes(host)) return true; return parseBoolean(process.env.AIONUI_ALLOW_REMOTE ?? process.env.AIONUI_REMOTE); } function resolveStaticDir(): string { if (process.env.AIONUI_STATIC_DIR) return process.env.AIONUI_STATIC_DIR; const candidate = path.join(repoRoot, 'out', 'renderer'); if (fs.existsSync(path.join(candidate, 'index.html'))) return candidate; throw new Error(`Renderer assets not found at ${candidate}. Run "bun run package" first, or set AIONUI_STATIC_DIR.`); } /** * Rebuild renderer/main bundles before launching, so that `bun run webui` always * serves the latest source. Skipped when: * --no-build flag : explicit opt-out (e.g., iterating on this script) * $AIONUI_NO_BUILD=1 : env-level opt-out * $AIONUI_STATIC_DIR is set : caller is pointing us at a prebuilt artifact dir */ function runPackageIfNeeded(): void { if (has('--no-build')) return; if (parseBoolean(process.env.AIONUI_NO_BUILD)) return; if (process.env.AIONUI_STATIC_DIR) return; console.log('[webui] running "bun run package" to refresh out/renderer (pass --no-build to skip)...'); const start = Date.now(); execSync('bun run package', { cwd: repoRoot, stdio: 'inherit' }); console.log(`[webui] package finished in ${((Date.now() - start) / 1000).toFixed(1)}s`); } function resolveBackendBinary(): string { if (process.env.AIONUI_BACKEND_BIN) return process.env.AIONUI_BACKEND_BIN; const bundledBase = process.env.AIONUI_BACKEND_BUNDLED_DIR ?? path.join(repoRoot, 'resources', 'bundled-aioncore'); const runtimeKey = `${process.platform}-${process.arch}`; const bundled = path.join(bundledBase, runtimeKey, BACKEND_BINARY); if (fs.existsSync(bundled)) return bundled; try { const cmd = process.platform === 'win32' ? `where ${BACKEND_BINARY}` : `which ${BACKEND_BINARY}`; const found = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]; if (found && fs.existsSync(found)) return found; } catch { // fall through } throw new Error( `Cannot find "${BACKEND_BINARY}". Set AIONUI_BACKEND_BIN, put it on PATH, or place it at ${bundled}.` ); } /** * Prepend all nvm-managed Node bin dirs to PATH. Electron's main process does * this (see packages/desktop/src/index.ts), otherwise CLI tools installed under * a specific Node version (e.g. gemini under v25) won't be found by the backend * spawned by ACP — the `Superset: X not found in PATH` wrapper bails, so the * ACP handshake times out after 30s and the UI sees `502 Bad Gateway`. */ function augmentPathWithNvm(): void { if (process.platform === 'win32') return; const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm'); const versionsDir = path.join(nvmDir, 'versions', 'node'); if (!fs.existsSync(versionsDir)) return; try { const versions = fs.readdirSync(versionsDir); const nvmBins = versions.map((v) => path.join(versionsDir, v, 'bin')).filter((p) => fs.existsSync(p)); if (nvmBins.length === 0) return; const current = process.env.PATH || ''; const missing = nvmBins.filter((p) => !current.split(path.delimiter).includes(p)); if (missing.length > 0) { process.env.PATH = [...missing, current].join(path.delimiter); } } catch { // best-effort } } /** * Read the WebUI admin username from backend. Returns 'admin' as a best-effort * fallback — useful when the backend is unreachable or the SQLite users row * has not been seeded yet. */ async function fetchAdminUsername(backendPort: number): Promise { try { const res = await fetch(`http://127.0.0.1:${backendPort}/api/auth/internal/users/system`); if (!res.ok) return 'admin'; const json = (await res.json()) as { data?: { username?: string } }; return json.data?.username || 'admin'; } catch { return 'admin'; } } async function main(): Promise { augmentPathWithNvm(); runPackageIfNeeded(); const port = resolvePort(); const allowRemote = resolveAllowRemote(); const autoOpenBrowser = shouldAutoOpenBrowser({ allowRemote, env: process.env, openFlag: has('--open'), noOpenFlag: has('--no-open'), }); // One working dir for the whole standalone webui: backend SQLite and chat // history live here. Admin credentials live in the backend's users table. // This keeps `bun run webui` fully self-contained on hosts without AionUi.app. const workDir = resolveBackendDataDir(); const staticDir = resolveStaticDir(); const backendBin = resolveBackendBinary(); const logDir = process.env.AIONUI_LOG_DIR ?? path.join(workDir, 'logs'); console.log('[webui] work dir :', workDir); console.log('[webui] static dir :', staticDir); console.log('[webui] backend bin:', backendBin); console.log(`[webui] launching : port=${port} allowRemote=${allowRemote}`); const handle = await startWebHost({ app: { version: '0.0.0', isPackaged: false, resourcesPath: repoRoot, userDataPath: workDir, }, staticDir, port, allowRemote, dataDir: workDir, logDir, // Surface the same work dir on /api/system/info so the browser UI shows // where standalone webui is actually persisting data. Without this the // backend inherits process.env and may report the parent shell's cwd. dirs: { cacheDir: workDir, workDir: workDir, logDir, }, backend: { kind: 'ownBackend', resolveBackend: () => backendBin, }, }); console.log(''); console.log('AionUi WebUI is ready'); console.log(` Local : ${handle.localUrl}`); if (handle.networkUrl) console.log(` Network: ${handle.networkUrl}`); // If SQLite has no admin yet (fresh install), seed one via backend and print // the plaintext credentials. Mirrors webuiBridge.ts:maybeSeedInitialPassword // for the Electron path — SQLite is now the single source of truth. // // Username is surfaced explicitly: legacy dev databases may have the seeded // user as `system` instead of `admin`, and Electron users can rename it via // Settings. Always read it from the backend rather than assuming a value. try { const statusRes = await fetch(`http://127.0.0.1:${handle.backendPort}/api/auth/status`); if (statusRes.ok) { const status = (await statusRes.json()) as { needs_setup?: boolean }; if (status.needs_setup === true) { const resetRes = await fetch(`http://127.0.0.1:${handle.backendPort}/api/webui/reset-password`, { method: 'POST', }); if (resetRes.ok) { const payload = (await resetRes.json()) as { data?: { new_password?: string } }; const initialPassword = payload.data?.new_password; if (initialPassword) { const adminUsername = await fetchAdminUsername(handle.backendPort); console.log(''); console.log(`Initial admin username: ${adminUsername}`); console.log(`Initial admin password: ${initialPassword}`); console.log('(change them after first login)'); } } } else { // Credentials already exist; just remind the user what username to use. const adminUsername = await fetchAdminUsername(handle.backendPort); console.log(''); console.log(`Login username: ${adminUsername}`); console.log('(forgot the password? run `bun run resetpass` to generate a new one)'); } } } catch (err) { console.warn('[webui] could not query admin credentials:', err); } if (autoOpenBrowser) { const openResult = openBrowserUrl(handle.localUrl); if (openResult.ok) { console.log(`[webui] opened ${handle.localUrl} in your browser.`); } else { console.warn(`[webui] could not open the browser automatically: ${openResult.reason}`); } } console.log(''); console.log('Press Ctrl+C to stop.'); let shuttingDown = false; const shutdown = async (signal: string) => { if (shuttingDown) return; shuttingDown = true; console.log(`\n[webui] received ${signal}, stopping...`); try { await handle.stop(); } catch (err) { console.error('[webui] stop error:', err); } finally { process.exit(0); } }; process.on('SIGINT', () => void shutdown('SIGINT')); process.on('SIGTERM', () => void shutdown('SIGTERM')); } main().catch((err) => { console.error('[webui] failed to start:', err instanceof Error ? err.message : err); process.exit(1); });