Files
2026-07-13 13:20:22 +08:00

328 lines
13 KiB
TypeScript

#!/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 <dataDir>/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/<plat-arch>/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 <path> 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<string> {
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<void> {
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);
});