feat: wizard remediation — password mask, hooks preview, headless (IUH-M02) (#431)
This commit was merged in pull request #431.
This commit is contained in:
@@ -28,11 +28,20 @@ describe('registerConfigCommand', () => {
|
||||
expect(names).toContain('config');
|
||||
});
|
||||
|
||||
it('registers exactly the five required subcommands', () => {
|
||||
it('registers exactly the required subcommands', () => {
|
||||
const program = buildProgram();
|
||||
const config = getConfigCmd(program);
|
||||
const subs = config.commands.map((c) => c.name()).sort();
|
||||
expect(subs).toEqual(['edit', 'get', 'path', 'set', 'show']);
|
||||
expect(subs).toEqual(['edit', 'get', 'hooks', 'path', 'set', 'show']);
|
||||
});
|
||||
|
||||
it('registers hooks sub-subcommands: list, enable, disable', () => {
|
||||
const program = buildProgram();
|
||||
const config = getConfigCmd(program);
|
||||
const hooks = config.commands.find((c) => c.name() === 'hooks');
|
||||
expect(hooks).toBeDefined();
|
||||
const hookSubs = hooks!.commands.map((c) => c.name()).sort();
|
||||
expect(hookSubs).toEqual(['disable', 'enable', 'list']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,6 +273,142 @@ describe('config edit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── config hooks ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_HOOKS_CONFIG = JSON.stringify({
|
||||
name: 'Test Hooks',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Write|Edit',
|
||||
hooks: [{ type: 'command', command: 'bash', args: ['-c', 'echo'] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const MOCK_HOOKS_WITH_DISABLED = JSON.stringify({
|
||||
name: 'Test Hooks',
|
||||
hooks: {
|
||||
PostToolUse: [{ matcher: 'Write|Edit', hooks: [] }],
|
||||
_disabled_PreToolUse: [{ matcher: 'Bash', hooks: [] }],
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
async function getFsMock() {
|
||||
const fs = await import('node:fs');
|
||||
return {
|
||||
existsSync: fs.existsSync as ReturnType<typeof vi.fn>,
|
||||
readFileSync: fs.readFileSync as ReturnType<typeof vi.fn>,
|
||||
writeFileSync: fs.writeFileSync as ReturnType<typeof vi.fn>,
|
||||
};
|
||||
}
|
||||
|
||||
describe('config hooks list', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
vi.clearAllMocks();
|
||||
mockSvc.isInitialized.mockReturnValue(true);
|
||||
const fs = await getFsMock();
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
|
||||
// Ensure CLAUDE_HOME is set to a stable value for tests
|
||||
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
delete process.env['CLAUDE_HOME'];
|
||||
});
|
||||
|
||||
it('lists hooks with enabled/disabled status', async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
||||
expect(output).toContain('PostToolUse');
|
||||
expect(output).toContain('enabled');
|
||||
});
|
||||
|
||||
it('shows disabled hooks from MOCK_HOOKS_WITH_DISABLED', async () => {
|
||||
const fs = await getFsMock();
|
||||
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
|
||||
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
||||
expect(output).toContain('disabled');
|
||||
expect(output).toContain('PreToolUse');
|
||||
});
|
||||
|
||||
it('prints a message when hooks-config.json is missing', async () => {
|
||||
const fs = await getFsMock();
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
||||
expect(output).toContain('No hooks-config.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config hooks disable / enable', () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
vi.clearAllMocks();
|
||||
mockSvc.isInitialized.mockReturnValue(true);
|
||||
const fs = await getFsMock();
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
|
||||
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
delete process.env['CLAUDE_HOME'];
|
||||
});
|
||||
|
||||
it('disables a hook by event name and writes updated config', async () => {
|
||||
const fs = await getFsMock();
|
||||
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'disable', 'PostToolUse']);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
|
||||
hooks: Record<string, unknown>;
|
||||
};
|
||||
expect(written.hooks['_disabled_PostToolUse']).toBeDefined();
|
||||
expect(written.hooks['PostToolUse']).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('disabled'));
|
||||
});
|
||||
|
||||
it('enables a disabled hook and writes updated config', async () => {
|
||||
const fs = await getFsMock();
|
||||
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
|
||||
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'enable', 'PreToolUse']);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
|
||||
hooks: Record<string, unknown>;
|
||||
};
|
||||
expect(written.hooks['PreToolUse']).toBeDefined();
|
||||
expect(written.hooks['_disabled_PreToolUse']).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('enabled'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── not-initialized guard ────────────────────────────────────────────────────
|
||||
|
||||
describe('not-initialized guard', () => {
|
||||
|
||||
@@ -1,8 +1,74 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { Command } from 'commander';
|
||||
import { createConfigService } from '../config/config-service.js';
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
|
||||
// ── Hooks management helpers ──────────────────────────────────────────────────
|
||||
|
||||
const DISABLED_PREFIX = '_disabled_';
|
||||
|
||||
/** Resolve the ~/.claude directory (allow override via CLAUDE_HOME env var). */
|
||||
function getClaudeHome(): string {
|
||||
return process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
|
||||
}
|
||||
|
||||
interface HookEntry {
|
||||
type?: string;
|
||||
command?: string;
|
||||
args?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface HookTrigger {
|
||||
matcher?: string;
|
||||
hooks?: HookEntry[];
|
||||
}
|
||||
|
||||
interface HooksConfig {
|
||||
name?: string;
|
||||
hooks?: Record<string, HookTrigger[]>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function readInstalledHooksConfig(claudeHome: string): HooksConfig | null {
|
||||
const p = join(claudeHome, 'hooks-config.json');
|
||||
if (!existsSync(p)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf-8')) as HooksConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeInstalledHooksConfig(claudeHome: string, config: HooksConfig): void {
|
||||
const p = join(claudeHome, 'hooks-config.json');
|
||||
writeFileSync(p, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a flat list of hook "names" for display purposes.
|
||||
* A hook name is `<EventName>/<matcher>` (e.g. `PostToolUse/Write|Edit`).
|
||||
*/
|
||||
function listHookNames(config: HooksConfig): Array<{ name: string; enabled: boolean }> {
|
||||
const results: Array<{ name: string; enabled: boolean }> = [];
|
||||
const events = config.hooks ?? {};
|
||||
|
||||
for (const [rawEvent, triggers] of Object.entries(events)) {
|
||||
const enabled = !rawEvent.startsWith(DISABLED_PREFIX);
|
||||
const event = enabled ? rawEvent : rawEvent.slice(DISABLED_PREFIX.length);
|
||||
|
||||
for (const trigger of triggers) {
|
||||
const matcher = trigger.matcher ?? '(any)';
|
||||
results.push({ name: `${event}/${matcher}`, enabled });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve mosaicHome from the MOSAIC_HOME env var or the default constant.
|
||||
*/
|
||||
@@ -179,6 +245,138 @@ export function registerConfigCommand(program: Command): void {
|
||||
}
|
||||
});
|
||||
|
||||
// ── config hooks ────────────────────────────────────────────────────────
|
||||
|
||||
const hookCmd = cmd.command('hooks').description('Manage Mosaic hooks installed in ~/.claude/');
|
||||
|
||||
hookCmd
|
||||
.command('list')
|
||||
.description('List installed hooks and their enabled/disabled status')
|
||||
.action(() => {
|
||||
const claudeHome = getClaudeHome();
|
||||
const config = readInstalledHooksConfig(claudeHome);
|
||||
|
||||
if (!config) {
|
||||
console.log(
|
||||
`No hooks-config.json found at ${claudeHome}.\n` +
|
||||
'Run `mosaic wizard` to install hooks, or copy hooks-config.json manually.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = listHookNames(config);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('No hooks defined in hooks-config.json.');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxName = Math.max(...entries.map((e) => e.name.length));
|
||||
const header = `${'Hook'.padEnd(maxName)} Status`;
|
||||
console.log(header);
|
||||
console.log('-'.repeat(header.length));
|
||||
|
||||
for (const { name, enabled } of entries) {
|
||||
console.log(`${name.padEnd(maxName)} ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
});
|
||||
|
||||
hookCmd
|
||||
.command('disable <name>')
|
||||
.description('Disable a hook by name (prefix with _disabled_). Use "list" to see hook names.')
|
||||
.action((name: string) => {
|
||||
const claudeHome = getClaudeHome();
|
||||
const config = readInstalledHooksConfig(claudeHome);
|
||||
|
||||
if (!config) {
|
||||
console.error(
|
||||
`No hooks-config.json found at ${claudeHome}.\n` +
|
||||
'Nothing to disable. Run `mosaic wizard` to install hooks first.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const events = config.hooks ?? {};
|
||||
// Support matching by event key or by event/matcher composite
|
||||
const [targetEvent, targetMatcher] = name.split('/');
|
||||
|
||||
// Find the event key (may already have DISABLED_PREFIX)
|
||||
const existingKey = Object.keys(events).find(
|
||||
(k) =>
|
||||
k === targetEvent ||
|
||||
k === `${DISABLED_PREFIX}${targetEvent}` ||
|
||||
k.replace(DISABLED_PREFIX, '') === targetEvent,
|
||||
);
|
||||
|
||||
if (!existingKey) {
|
||||
console.error(`Hook event "${targetEvent}" not found.`);
|
||||
console.error('Run `mosaic config hooks list` to see available hooks.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existingKey.startsWith(DISABLED_PREFIX)) {
|
||||
console.log(`Hook "${name}" is already disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const disabledKey = `${DISABLED_PREFIX}${existingKey}`;
|
||||
const triggers = events[existingKey];
|
||||
delete events[existingKey];
|
||||
|
||||
// If a matcher was specified, only disable that trigger
|
||||
if (targetMatcher && triggers) {
|
||||
events[disabledKey] = triggers.filter((t) => t.matcher === targetMatcher);
|
||||
events[existingKey] = triggers.filter((t) => t.matcher !== targetMatcher);
|
||||
if ((events[existingKey] ?? []).length === 0) delete events[existingKey];
|
||||
} else {
|
||||
events[disabledKey] = triggers ?? [];
|
||||
}
|
||||
|
||||
config.hooks = events;
|
||||
writeInstalledHooksConfig(claudeHome, config);
|
||||
console.log(`Hook "${name}" disabled.`);
|
||||
});
|
||||
|
||||
hookCmd
|
||||
.command('enable <name>')
|
||||
.description('Re-enable a previously disabled hook.')
|
||||
.action((name: string) => {
|
||||
const claudeHome = getClaudeHome();
|
||||
const config = readInstalledHooksConfig(claudeHome);
|
||||
|
||||
if (!config) {
|
||||
console.error(
|
||||
`No hooks-config.json found at ${claudeHome}.\n` +
|
||||
'Nothing to enable. Run `mosaic wizard` to install hooks first.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const events = config.hooks ?? {};
|
||||
const targetEvent = name.split('/')[0] ?? name;
|
||||
const disabledKey = `${DISABLED_PREFIX}${targetEvent}`;
|
||||
|
||||
if (!events[disabledKey]) {
|
||||
// Check if it's already enabled
|
||||
if (events[targetEvent]) {
|
||||
console.log(`Hook "${name}" is already enabled.`);
|
||||
} else {
|
||||
console.error(`Disabled hook "${name}" not found.`);
|
||||
console.error('Run `mosaic config hooks list` to see available hooks.');
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const triggers = events[disabledKey];
|
||||
delete events[disabledKey];
|
||||
events[targetEvent] = triggers ?? [];
|
||||
|
||||
config.hooks = events;
|
||||
writeInstalledHooksConfig(claudeHome, config);
|
||||
console.log(`Hook "${name}" enabled.`);
|
||||
});
|
||||
|
||||
// ── config path ─────────────────────────────────────────────────────────
|
||||
|
||||
cmd
|
||||
|
||||
@@ -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