feat: wizard remediation — password mask, hooks preview, headless (IUH-M02) (#431)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #431.
This commit is contained in:
2026-04-05 17:47:53 +00:00
parent 8fa5995bde
commit cd8b1f666d
11 changed files with 1098 additions and 43 deletions

View File

@@ -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 {