clawdie-ai/setup/operator-auth.ts
Operator & Codex 2ab3fa050a refactor(setup): unify operator auth entrypoints
---
Build: pass | Tests: pass — Tests  1991 passed (1991)
2026-04-27 08:13:36 +02:00

204 lines
6.2 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { execFileSync, spawnSync } from 'child_process';
import { stdin as input, stdout as output } from 'process';
import * as readline from 'readline/promises';
import pg from 'pg';
import {
BETTER_AUTH_SECRET,
BETTER_AUTH_URL,
CONTROLPLANE_API_PORT,
CONTROLPLANE_HOST_LABEL,
CONTROLPLANE_INTERNAL_DOMAIN,
MEMORY_DB_URL,
PLATFORM_INTERNAL_BASE,
PLATFORM_PUBLIC_BASE,
} from '../src/config.js';
import {
createControlplaneBootstrapAuth,
deriveControlplaneOperatorEmail,
ensureControlplaneBootstrapOperator,
runControlplaneBootstrapMigrations,
} from '../src/controlplane-auth-bootstrap.js';
import { setOperatorPassword } from '../src/controlplane-db.js';
import { logger } from '../src/logger.js';
import { commandExists } from './platform.js';
import { emitStatus } from './status.js';
interface OperatorAuthArgs {
email?: string;
name?: string;
}
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value);
}
export function parseArgs(args: string[]): OperatorAuthArgs {
const result: OperatorAuthArgs = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--email' && args[i + 1]) result.email = args[++i];
if (args[i] === '--name' && args[i + 1]) result.name = args[++i];
}
return result;
}
export function updateOperatorAuthEnvContent(
envContent: string,
operatorEmail: string,
operatorPassword: string,
): string {
const lines = envContent ? envContent.replace(/\r\n/gu, '\n').split('\n') : [];
function setLine(key: string, value: string): void {
const pattern = new RegExp(`^${key}=.*$`, 'u');
const idx = lines.findIndex((line) => pattern.test(line));
const nextLine = `${key}=${value}`;
if (idx === -1) {
lines.push(nextLine);
} else {
lines[idx] = nextLine;
}
}
setLine('CONTROLPLANE_OPERATOR_EMAIL', operatorEmail);
setLine('OPERATOR_PASSWORD', operatorPassword);
return `${lines.filter(Boolean).join('\n')}\n`;
}
function writeOperatorAuthEnv(
envFile: string,
operatorEmail: string,
operatorPassword: string,
): void {
const current = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
const updated = updateOperatorAuthEnvContent(
current,
operatorEmail,
operatorPassword,
);
fs.writeFileSync(envFile, updated, { mode: 0o600 });
}
function runBsddialogPassword(title: string, text: string): string {
const result = spawnSync(
'bsddialog',
['--title', title, '--passwordbox', text, '0', '0', ''],
{
encoding: 'utf-8',
stdio: ['inherit', 'inherit', 'pipe'],
},
);
const status = result.status ?? 1;
if (result.error) throw result.error;
if (status === 1 || status === 255) process.exit(130);
if (status !== 0) throw new Error(`bsddialog exited with status ${status}`);
return (result.stderr ?? '').trim();
}
async function promptHidden(label: string): Promise<string> {
if (process.stdin.isTTY && commandExists('bsddialog')) {
return runBsddialogPassword('Operator Password', label).trim();
}
if (!process.stdin.isTTY) {
throw new Error('operator-auth requires a TTY for secure password entry');
}
const rl = readline.createInterface({ input, output, terminal: true });
try {
output.write(`${label}: `);
execFileSync('stty', ['-echo'], { stdio: 'inherit' });
const value = await rl.question('');
output.write('\n');
return value.trim();
} finally {
try {
execFileSync('stty', ['echo'], { stdio: 'inherit' });
} catch {
// best-effort only
}
rl.close();
}
}
async function promptForPassword(): Promise<string> {
const first = await promptHidden('Enter operator password');
if (!first) throw new Error('Password cannot be empty');
const second = await promptHidden('Confirm operator password');
if (first !== second) throw new Error('Password confirmation did not match');
return first;
}
export async function run(args: string[]): Promise<void> {
const opts = parseArgs(args);
if (!MEMORY_DB_URL) {
throw new Error('MEMORY_DB_URL not set — cannot bootstrap operator auth');
}
if (!BETTER_AUTH_SECRET.trim()) {
throw new Error('BETTER_AUTH_SECRET is required for operator auth bootstrap');
}
const operatorName = (opts.name || CONTROLPLANE_HOST_LABEL).trim();
if (opts.email && !isValidEmail(opts.email.trim())) {
throw new Error(`Invalid operator email: ${opts.email}`);
}
const operatorEmail = deriveControlplaneOperatorEmail({
operatorName,
explicitEmail: opts.email,
internalDomain: CONTROLPLANE_INTERNAL_DOMAIN,
publicBase: PLATFORM_PUBLIC_BASE || undefined,
});
const operatorPassword = await promptForPassword();
const pool = new pg.Pool({ connectionString: MEMORY_DB_URL, max: 2 });
try {
await setOperatorPassword(pool, operatorName, operatorPassword);
const auth = createControlplaneBootstrapAuth(pool, {
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
controlplaneApiPort: CONTROLPLANE_API_PORT,
internalBase: PLATFORM_INTERNAL_BASE,
internalDomain: CONTROLPLANE_INTERNAL_DOMAIN,
publicBase: PLATFORM_PUBLIC_BASE || undefined,
});
await runControlplaneBootstrapMigrations(auth);
const betterAuthStatus = await ensureControlplaneBootstrapOperator(auth, {
name: operatorName,
email: operatorEmail,
password: operatorPassword,
});
const envFile = path.join(process.cwd(), '.env');
writeOperatorAuthEnv(envFile, operatorEmail, operatorPassword);
emitStatus('SETUP_OPERATOR_AUTH', {
STATUS: 'success',
OPERATOR: operatorName,
OPERATOR_EMAIL: operatorEmail,
BETTER_AUTH: betterAuthStatus,
});
console.log(`\nOperator auth ready.`);
console.log(` Name: ${operatorName}`);
console.log(` Email: ${operatorEmail}`);
if (betterAuthStatus === 'existing') {
console.log(
' Better Auth user already existed; dashboard password may need a manual reset if this was not the intended account.',
);
}
console.log(' Saved CONTROLPLANE_OPERATOR_EMAIL and OPERATOR_PASSWORD to .env');
} catch (error) {
logger.error({ err: error }, 'operator-auth setup failed');
throw error;
} finally {
await pool.end();
}
}