204 lines
6.2 KiB
TypeScript
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();
|
|
}
|
|
}
|