#!/usr/bin/env bun /** * @license * Copyright 2025 AionUi (aionui.com) * SPDX-License-Identifier: Apache-2.0 * * Pure Node/Bun CLI — resets the WebUI admin password for the standalone * `bun run webui` host (independent of Electron). * * After the M6 auth cleanup, SQLite `users` is the single source of truth. * Two paths: * 1. A `bun run webui` is already running on the default port → reach its * reverse-proxied /api/webui/reset-password directly. Users don't have to * stop the server first; the just-reset password can be used immediately. * 2. No webui running → spawn a short-lived aioncore against the same * data-dir, POST /api/webui/reset-password, and stop the backend. This is * the offline / cold-start path. * * Usage: * bun run resetpass # default work dir * bun run resetpass --data-dir /x # custom work dir * AIONUI_DATA_DIR=/x bun run resetpass * NODE_ENV=production bun run resetpass */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { startBackend, stopBackend } from '@aionui/web-host'; const BACKEND_BINARY = process.platform === 'win32' ? 'aioncore.exe' : 'aioncore'; const __filename = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(__filename), '..'); const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', }; const log = { info: (msg: string) => console.log(`${colors.blue}i${colors.reset} ${msg}`), success: (msg: string) => console.log(`${colors.green}OK${colors.reset} ${msg}`), error: (msg: string) => console.log(`${colors.red}ERR${colors.reset} ${msg}`), warning: (msg: string) => console.log(`${colors.yellow}WARN${colors.reset} ${msg}`), highlight: (msg: string) => console.log(`${colors.cyan}${colors.bright}${msg}${colors.reset}`), }; function getFlag(name: string): string | undefined { const idx = process.argv.indexOf(name); if (idx === -1) return undefined; const next = process.argv[idx + 1]; return next && !next.startsWith('--') ? next : undefined; } /** * Same resolution as scripts/webui.ts:resolveBackendDataDir — keep both in sync * so `bun run webui` and `bun run resetpass` always target the same SQLite DB. * See the comment there for why the default is `~/.aionui-web*` (not `~/.aionui*`). */ function resolveWorkDir(): 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 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}.` ); } /** * Same default port as scripts/webui.ts (mirrors WEBUI_DEFAULT_PORT on the * desktop side). Callers can override with `--port` / `AIONUI_PORT` to match * a non-default webui launch. */ function resolveWebUIProbePort(): 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); if (process.env.NODE_ENV === 'production') return 25808; if (process.env.AIONUI_MULTI_INSTANCE === '1') return 25810; return 25809; } /** * Probe an in-flight `bun run webui` on the expected port. Returns the port if * its /api/auth/status responds 200 within ~1.5s, otherwise undefined. * We intentionally do NOT try to auto-discover arbitrary ports — the user can * pass --port / AIONUI_PORT if they launched webui on a non-default one. */ async function detectRunningWebUI(port: number): Promise { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1500); const res = await fetch(`http://127.0.0.1:${port}/api/auth/status`, { signal: controller.signal }); clearTimeout(timer); return res.ok; } catch { return false; } } async function resetPasswordVia(url: string): Promise { const res = await fetch(url, { method: 'POST' }); if (!res.ok) { const body = await res.text(); throw new Error(`reset-password failed (${res.status}): ${body}`); } const payload = (await res.json()) as { data?: { new_password?: string } }; const newPassword = payload.data?.new_password; if (!newPassword) throw new Error('reset-password returned no new_password'); return newPassword; } // Skip flag values (e.g. `--data-dir /some/path`) so they don't get picked up // as the username positional argument. const FLAGS_WITH_VALUES = new Set(['--data-dir', '--port']); function resolveUsername(): string { const args = process.argv.slice(2); const positional: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith('--')) { if (FLAGS_WITH_VALUES.has(a)) i++; // skip the flag's value too continue; } positional.push(a); } return positional[0] || 'admin'; } async function main(): Promise { const username = resolveUsername(); const workDir = resolveWorkDir(); const webuiPort = resolveWebUIProbePort(); log.info(`Target user: ${username} (advisory — operates on system_default_user)`); log.info(`Work dir : ${workDir}`); // Fast path: a `bun run webui` is already up. Go through its proxy so the // running server immediately sees the new password — no need for the user // to stop the server or deal with two backends fighting over the same db. if (await detectRunningWebUI(webuiPort)) { log.info(`Detected running WebUI at http://127.0.0.1:${webuiPort} — reusing it`); try { const newPassword = await resetPasswordVia(`http://127.0.0.1:${webuiPort}/api/webui/reset-password`); log.success('Password reset successfully.'); log.info('New password:'); log.highlight(newPassword); log.info(''); log.warning('Please change this password after next login.'); return; } catch (error) { log.error(error instanceof Error ? error.message : 'Password reset failed'); process.exitCode = 1; return; } } // Slow path: no webui running. Spawn a short-lived backend against the same // data-dir, reset, stop. const logDir = process.env.AIONUI_LOG_DIR ?? path.join(workDir, 'logs'); fs.mkdirSync(logDir, { recursive: true }); const backendBin = resolveBackendBinary(); log.info(`No running WebUI on port ${webuiPort} — starting short-lived backend`); log.info(`Backend bin: ${backendBin}`); const handle = await startBackend({ app: { version: '0.0.0', isPackaged: false, resourcesPath: repoRoot, userDataPath: workDir, }, resolveBackend: () => backendBin, dataDir: workDir, logDir, dirs: { cacheDir: workDir, workDir, logDir, }, }); try { const newPassword = await resetPasswordVia(`http://127.0.0.1:${handle.port}/api/webui/reset-password`); log.success('Password reset successfully.'); log.info('New password:'); log.highlight(newPassword); log.info(''); log.warning('Please change this password after next login.'); } catch (error) { log.error(error instanceof Error ? error.message : 'Password reset failed'); process.exitCode = 1; } finally { await stopBackend(handle).catch((err) => { log.warning(`backend stop failed: ${err instanceof Error ? err.message : String(err)}`); }); } } void main();