feat: wizard remediation — password mask, hooks preview, headless (IUH-M02) (#431)
This commit was merged in pull request #431.
This commit is contained in:
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { GatewayMeta } from './daemon.js';
|
||||
import { promptMaskedConfirmed } from '../../prompter/masked-prompt.js';
|
||||
import {
|
||||
ENV_FILE,
|
||||
GATEWAY_HOME,
|
||||
@@ -65,6 +66,15 @@ function prompt(rl: ReturnType<typeof createInterface>, question: string): Promi
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the process should skip interactive prompts.
|
||||
* Headless mode is activated by `MOSAIC_ASSUME_YES=1` or when stdin is not a
|
||||
* TTY (piped/redirected — typical in CI and Docker).
|
||||
*/
|
||||
function isHeadless(): boolean {
|
||||
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||
}
|
||||
|
||||
export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
@@ -298,37 +308,81 @@ async function runConfigWizard(
|
||||
console.log('(Preserving existing BETTER_AUTH_SECRET — auth sessions will remain valid.)\n');
|
||||
}
|
||||
|
||||
console.log('Storage tier:');
|
||||
console.log(' 1. Local (embedded database, no dependencies)');
|
||||
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
||||
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
||||
const tier = tierAnswer === '2' ? 'team' : 'local';
|
||||
|
||||
const port =
|
||||
opts.port !== 14242
|
||||
? opts.port
|
||||
: parseInt(
|
||||
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
||||
10,
|
||||
);
|
||||
|
||||
let tier: 'local' | 'team';
|
||||
let port: number;
|
||||
let databaseUrl: string | undefined;
|
||||
let valkeyUrl: string | undefined;
|
||||
let anthropicKey: string;
|
||||
let corsOrigin: string;
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl =
|
||||
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
||||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
if (isHeadless()) {
|
||||
// ── Headless / non-interactive path ────────────────────────────────────
|
||||
console.log('Headless mode detected — reading configuration from environment variables.\n');
|
||||
|
||||
valkeyUrl =
|
||||
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
||||
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
|
||||
tier = storageTierEnv === 'team' ? 'team' : 'local';
|
||||
|
||||
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
|
||||
port = portEnv ? parseInt(portEnv, 10) : opts.port;
|
||||
|
||||
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
|
||||
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
|
||||
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
|
||||
// Validate required vars for team tier
|
||||
if (tier === 'team') {
|
||||
const missing: string[] = [];
|
||||
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
|
||||
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`Error: headless install with tier=team requires the following env vars:\n` +
|
||||
missing.map((v) => ` ${v}`).join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Storage tier: ${tier}`);
|
||||
console.log(` Gateway port: ${port.toString()}`);
|
||||
if (tier === 'team') {
|
||||
console.log(` DATABASE_URL: ${databaseUrl ?? ''}`);
|
||||
console.log(` VALKEY_URL: ${valkeyUrl ?? ''}`);
|
||||
}
|
||||
console.log(` CORS origin: ${corsOrigin}`);
|
||||
console.log();
|
||||
} else {
|
||||
// ── Interactive path ────────────────────────────────────────────────────
|
||||
console.log('Storage tier:');
|
||||
console.log(' 1. Local (embedded database, no dependencies)');
|
||||
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
||||
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
||||
tier = tierAnswer === '2' ? 'team' : 'local';
|
||||
|
||||
port =
|
||||
opts.port !== 14242
|
||||
? opts.port
|
||||
: parseInt(
|
||||
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
||||
10,
|
||||
);
|
||||
|
||||
if (tier === 'team') {
|
||||
databaseUrl =
|
||||
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
||||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
valkeyUrl =
|
||||
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
||||
}
|
||||
|
||||
anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
||||
|
||||
corsOrigin =
|
||||
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
||||
|
||||
const corsOrigin =
|
||||
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
||||
|
||||
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
|
||||
|
||||
const envLines = [
|
||||
@@ -488,22 +542,56 @@ async function bootstrapFirstUser(
|
||||
|
||||
console.log('─── Admin User Setup ───\n');
|
||||
|
||||
const name = (await prompt(rl, 'Admin name: ')).trim();
|
||||
if (!name) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
let name: string;
|
||||
let email: string;
|
||||
let password: string;
|
||||
|
||||
const email = (await prompt(rl, 'Admin email: ')).trim();
|
||||
if (!email) {
|
||||
console.error('Email is required.');
|
||||
return;
|
||||
}
|
||||
if (isHeadless()) {
|
||||
// ── Headless path ──────────────────────────────────────────────────────
|
||||
const nameEnv = process.env['MOSAIC_ADMIN_NAME']?.trim() ?? '';
|
||||
const emailEnv = process.env['MOSAIC_ADMIN_EMAIL']?.trim() ?? '';
|
||||
const passwordEnv = process.env['MOSAIC_ADMIN_PASSWORD'] ?? '';
|
||||
|
||||
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
|
||||
if (password.length < 8) {
|
||||
console.error('Password must be at least 8 characters.');
|
||||
return;
|
||||
const missing: string[] = [];
|
||||
if (!nameEnv) missing.push('MOSAIC_ADMIN_NAME');
|
||||
if (!emailEnv) missing.push('MOSAIC_ADMIN_EMAIL');
|
||||
if (!passwordEnv) missing.push('MOSAIC_ADMIN_PASSWORD');
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`Error: headless admin bootstrap requires the following env vars:\n` +
|
||||
missing.map((v) => ` ${v}`).join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (passwordEnv.length < 8) {
|
||||
console.error('Error: MOSAIC_ADMIN_PASSWORD must be at least 8 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
name = nameEnv;
|
||||
email = emailEnv;
|
||||
password = passwordEnv;
|
||||
} else {
|
||||
// ── Interactive path ────────────────────────────────────────────────────
|
||||
name = (await prompt(rl, 'Admin name: ')).trim();
|
||||
if (!name) {
|
||||
console.error('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
email = (await prompt(rl, 'Admin email: ')).trim();
|
||||
if (!email) {
|
||||
console.error('Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
password = await promptMaskedConfirmed(
|
||||
'Admin password (min 8 chars): ',
|
||||
'Confirm password: ',
|
||||
(v) => (v.length < 8 ? 'Password must be at least 8 characters' : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user