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

238 lines
8.4 KiB
TypeScript

#!/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<boolean> {
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<string> {
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<void> {
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();