458 lines
15 KiB
TypeScript
458 lines
15 KiB
TypeScript
import type { WizardPrompter } from './prompter/interface.js';
|
|
import type { ConfigService } from './config/config-service.js';
|
|
import type { MenuSection, WizardState } from './types.js';
|
|
import { welcomeStage } from './stages/welcome.js';
|
|
import { detectInstallStage } from './stages/detect-install.js';
|
|
import { soulSetupStage } from './stages/soul-setup.js';
|
|
import { userSetupStage } from './stages/user-setup.js';
|
|
import { toolsSetupStage } from './stages/tools-setup.js';
|
|
import { runtimeSetupStage } from './stages/runtime-setup.js';
|
|
import { hooksPreviewStage } from './stages/hooks-preview.js';
|
|
import { skillsSelectStage } from './stages/skills-select.js';
|
|
import { finalizeStage } from './stages/finalize.js';
|
|
import { gatewayConfigStage } from './stages/gateway-config.js';
|
|
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
|
|
import { providerSetupStage } from './stages/provider-setup.js';
|
|
import { agentIntentStage } from './stages/agent-intent.js';
|
|
import { quickStartPath } from './stages/quick-start.js';
|
|
import { DEFAULTS } from './constants.js';
|
|
|
|
export interface WizardOptions {
|
|
mosaicHome: string;
|
|
sourceDir: string;
|
|
prompter: WizardPrompter;
|
|
configService: ConfigService;
|
|
cliOverrides?: Partial<WizardState>;
|
|
/**
|
|
* Skip the terminal gateway stages. Used by callers that only want to
|
|
* configure the framework (SOUL.md/USER.md/skills/hooks) without touching
|
|
* the gateway daemon. Defaults to `false` — the unified first-run flow
|
|
* runs everything end-to-end.
|
|
*/
|
|
skipGateway?: boolean;
|
|
/** Host passed through to the gateway config stage. Defaults to localhost. */
|
|
gatewayHost?: string;
|
|
/** Default gateway port (14242) — overridable by CLI flag. */
|
|
gatewayPort?: number;
|
|
/**
|
|
* Explicit port override from the caller. Honored even when resuming
|
|
* from an existing `.env` (useful when the saved port conflicts with
|
|
* another service).
|
|
*/
|
|
gatewayPortOverride?: number;
|
|
/** Skip `npm install -g @mosaicstack/gateway` during the config stage. */
|
|
skipGatewayNpmInstall?: boolean;
|
|
}
|
|
|
|
export async function runWizard(options: WizardOptions): Promise<void> {
|
|
const { prompter, configService, mosaicHome, sourceDir } = options;
|
|
|
|
const state: WizardState = {
|
|
mosaicHome,
|
|
sourceDir,
|
|
mode: 'quick',
|
|
installAction: 'fresh',
|
|
soul: {},
|
|
user: {},
|
|
tools: {},
|
|
runtimes: { detected: [], mcpConfigured: false },
|
|
selectedSkills: [],
|
|
completedSections: new Set<MenuSection>(),
|
|
};
|
|
|
|
// Apply CLI overrides (strip undefined values)
|
|
if (options.cliOverrides) {
|
|
if (options.cliOverrides.soul) {
|
|
for (const [k, v] of Object.entries(options.cliOverrides.soul)) {
|
|
if (v !== undefined) {
|
|
(state.soul as Record<string, unknown>)[k] = v;
|
|
}
|
|
}
|
|
}
|
|
if (options.cliOverrides.user) {
|
|
for (const [k, v] of Object.entries(options.cliOverrides.user)) {
|
|
if (v !== undefined) {
|
|
(state.user as Record<string, unknown>)[k] = v;
|
|
}
|
|
}
|
|
}
|
|
if (options.cliOverrides.tools) {
|
|
for (const [k, v] of Object.entries(options.cliOverrides.tools)) {
|
|
if (v !== undefined) {
|
|
(state.tools as Record<string, unknown>)[k] = v;
|
|
}
|
|
}
|
|
}
|
|
if (options.cliOverrides.mode) {
|
|
state.mode = options.cliOverrides.mode;
|
|
}
|
|
}
|
|
|
|
// Stage 1: Welcome
|
|
await welcomeStage(prompter, state);
|
|
|
|
// Stage 2: Existing Install Detection
|
|
await detectInstallStage(prompter, state, configService);
|
|
|
|
// ── Headless bypass ────────────────────────────────────────────────────────
|
|
// When MOSAIC_ASSUME_YES=1 or no TTY, run the linear headless path.
|
|
// This preserves full backward compatibility with tools/install.sh --yes.
|
|
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
|
if (headlessRun) {
|
|
await runHeadlessPath(prompter, state, configService, options);
|
|
return;
|
|
}
|
|
|
|
// ── Interactive: Main Menu ─────────────────────────────────────────────────
|
|
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
|
await runMenuLoop(prompter, state, configService, options);
|
|
} else if (state.installAction === 'reconfigure') {
|
|
state.mode = 'advanced';
|
|
await runMenuLoop(prompter, state, configService, options);
|
|
} else {
|
|
// 'keep' — skip identity setup, go straight to finalize + gateway
|
|
await runKeepPath(prompter, state, configService, options);
|
|
}
|
|
}
|
|
|
|
// ── Menu-driven interactive flow ────────────────────────────────────────────
|
|
|
|
type MenuChoice =
|
|
| 'quick-start'
|
|
| 'providers'
|
|
| 'identity'
|
|
| 'skills'
|
|
| 'gateway-config'
|
|
| 'advanced'
|
|
| 'finish';
|
|
|
|
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
|
const labels: Record<MenuChoice, string> = {
|
|
'quick-start': 'Quick Start',
|
|
providers: 'Providers',
|
|
identity: 'Agent Identity',
|
|
skills: 'Skills',
|
|
'gateway-config': 'Gateway',
|
|
advanced: 'Advanced',
|
|
finish: 'Finish & Apply',
|
|
};
|
|
const base = labels[section];
|
|
const sectionKey: MenuSection =
|
|
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
|
|
if (completed.has(sectionKey)) {
|
|
return `${base} [done]`;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
async function runMenuLoop(
|
|
prompter: WizardPrompter,
|
|
state: WizardState,
|
|
configService: ConfigService,
|
|
options: WizardOptions,
|
|
): Promise<void> {
|
|
const completed = state.completedSections!;
|
|
|
|
for (;;) {
|
|
const choice = await prompter.select<MenuChoice>({
|
|
message: 'What would you like to configure?',
|
|
options: [
|
|
{
|
|
value: 'quick-start',
|
|
label: menuLabel('quick-start', completed),
|
|
hint: 'Recommended defaults, minimal questions',
|
|
},
|
|
{
|
|
value: 'providers',
|
|
label: menuLabel('providers', completed),
|
|
hint: 'LLM API keys (Anthropic, OpenAI)',
|
|
},
|
|
{
|
|
value: 'identity',
|
|
label: menuLabel('identity', completed),
|
|
hint: 'Agent name, intent, persona',
|
|
},
|
|
{
|
|
value: 'skills',
|
|
label: menuLabel('skills', completed),
|
|
hint: 'Install agent skills',
|
|
},
|
|
{
|
|
value: 'gateway-config',
|
|
label: menuLabel('gateway-config', completed),
|
|
hint: 'Port, storage, database',
|
|
},
|
|
{
|
|
value: 'advanced',
|
|
label: menuLabel('advanced', completed),
|
|
hint: 'SOUL.md, USER.md, TOOLS.md, runtimes, hooks',
|
|
},
|
|
{
|
|
value: 'finish',
|
|
label: menuLabel('finish', completed),
|
|
hint: 'Write configs and start gateway',
|
|
},
|
|
],
|
|
});
|
|
|
|
switch (choice) {
|
|
case 'quick-start':
|
|
await quickStartPath(prompter, state, configService, options);
|
|
return; // Quick start is a complete flow — exit menu
|
|
|
|
case 'providers':
|
|
await providerSetupStage(prompter, state);
|
|
completed.add('providers');
|
|
break;
|
|
|
|
case 'identity':
|
|
await agentIntentStage(prompter, state);
|
|
completed.add('identity');
|
|
break;
|
|
|
|
case 'skills':
|
|
await skillsSelectStage(prompter, state);
|
|
completed.add('skills');
|
|
break;
|
|
|
|
case 'gateway-config':
|
|
// Gateway config is handled during Finish — mark as "configured"
|
|
// after user reviews settings.
|
|
await runGatewaySubMenu(prompter, state, options);
|
|
completed.add('gateway');
|
|
break;
|
|
|
|
case 'advanced':
|
|
await runAdvancedSubMenu(prompter, state);
|
|
completed.add('advanced');
|
|
break;
|
|
|
|
case 'finish':
|
|
await runFinishPath(prompter, state, configService, options);
|
|
return; // Done
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Gateway sub-menu ─────────────────────────────────────────────────────────
|
|
|
|
async function runGatewaySubMenu(
|
|
prompter: WizardPrompter,
|
|
state: WizardState,
|
|
_options: WizardOptions,
|
|
): Promise<void> {
|
|
prompter.note(
|
|
'Gateway settings will be applied when you select "Finish & Apply".\n' +
|
|
'Configure the settings you want to customize here.',
|
|
'Gateway Configuration',
|
|
);
|
|
|
|
// For now, just let them know defaults will be used and they can
|
|
// override during finish. The actual gateway config stage runs
|
|
// during Finish & Apply. This menu item exists so users know
|
|
// the gateway is part of the wizard.
|
|
const port = await prompter.text({
|
|
message: 'Gateway port',
|
|
initialValue: (_options.gatewayPort ?? 14242).toString(),
|
|
defaultValue: (_options.gatewayPort ?? 14242).toString(),
|
|
validate: (v) => {
|
|
const n = parseInt(v, 10);
|
|
if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be 1-65535';
|
|
return undefined;
|
|
},
|
|
});
|
|
|
|
// Store for later use in the gateway config stage
|
|
_options.gatewayPort = parseInt(port, 10);
|
|
prompter.log(`Gateway port set to ${port}. Will be applied during Finish & Apply.`);
|
|
}
|
|
|
|
// ── Advanced sub-menu ────────────────────────────────────────────────────────
|
|
|
|
async function runAdvancedSubMenu(prompter: WizardPrompter, state: WizardState): Promise<void> {
|
|
state.mode = 'advanced';
|
|
|
|
// Run the detailed setup stages
|
|
await soulSetupStage(prompter, state);
|
|
await userSetupStage(prompter, state);
|
|
await toolsSetupStage(prompter, state);
|
|
await runtimeSetupStage(prompter, state);
|
|
await hooksPreviewStage(prompter, state);
|
|
}
|
|
|
|
// ── Finish & Apply ──────────────────────────────────────────────────────────
|
|
|
|
async function runFinishPath(
|
|
prompter: WizardPrompter,
|
|
state: WizardState,
|
|
configService: ConfigService,
|
|
options: WizardOptions,
|
|
): Promise<void> {
|
|
// Apply defaults for anything not explicitly configured
|
|
state.soul.agentName ??= 'Mosaic';
|
|
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
|
state.soul.communicationStyle ??= 'direct';
|
|
state.user.background ??= DEFAULTS.background;
|
|
state.user.accessibilitySection ??= DEFAULTS.accessibilitySection;
|
|
state.user.personalBoundaries ??= DEFAULTS.personalBoundaries;
|
|
state.tools.gitProviders ??= [];
|
|
state.tools.credentialsLocation ??= DEFAULTS.credentialsLocation;
|
|
state.tools.customToolsSection ??= DEFAULTS.customToolsSection;
|
|
|
|
// Runtime detection if not already done
|
|
if (state.runtimes.detected.length === 0 && !state.completedSections?.has('advanced')) {
|
|
await runtimeSetupStage(prompter, state);
|
|
await hooksPreviewStage(prompter, state);
|
|
}
|
|
|
|
// Skills defaults if not already configured
|
|
if (!state.completedSections?.has('skills')) {
|
|
await skillsSelectStage(prompter, state);
|
|
}
|
|
|
|
// Finalize (writes configs, links runtime assets, syncs skills)
|
|
await finalizeStage(prompter, state, configService);
|
|
|
|
// Gateway stages
|
|
if (!options.skipGateway) {
|
|
try {
|
|
const configResult = await gatewayConfigStage(prompter, state, {
|
|
host: options.gatewayHost ?? 'localhost',
|
|
defaultPort: options.gatewayPort ?? 14242,
|
|
portOverride: options.gatewayPortOverride,
|
|
skipInstall: options.skipGatewayNpmInstall,
|
|
providerKey: state.providerKey,
|
|
providerType: state.providerType ?? 'none',
|
|
});
|
|
|
|
if (configResult.ready && configResult.host && configResult.port) {
|
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
|
host: configResult.host,
|
|
port: configResult.port,
|
|
});
|
|
if (!bootstrapResult.completed) {
|
|
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Headless linear path (backward compat) ──────────────────────────────────
|
|
|
|
async function runHeadlessPath(
|
|
prompter: WizardPrompter,
|
|
state: WizardState,
|
|
configService: ConfigService,
|
|
options: WizardOptions,
|
|
): Promise<void> {
|
|
// Provider setup from env vars
|
|
await providerSetupStage(prompter, state);
|
|
|
|
// Agent intent from env vars
|
|
await agentIntentStage(prompter, state);
|
|
|
|
// SOUL.md
|
|
await soulSetupStage(prompter, state);
|
|
|
|
// USER.md
|
|
await userSetupStage(prompter, state);
|
|
|
|
// TOOLS.md
|
|
await toolsSetupStage(prompter, state);
|
|
|
|
// Runtime Detection
|
|
await runtimeSetupStage(prompter, state);
|
|
|
|
// Hooks
|
|
await hooksPreviewStage(prompter, state);
|
|
|
|
// Skills
|
|
await skillsSelectStage(prompter, state);
|
|
|
|
// Finalize
|
|
await finalizeStage(prompter, state, configService);
|
|
|
|
// Gateway stages
|
|
if (!options.skipGateway) {
|
|
try {
|
|
const configResult = await gatewayConfigStage(prompter, state, {
|
|
host: options.gatewayHost ?? 'localhost',
|
|
defaultPort: options.gatewayPort ?? 14242,
|
|
portOverride: options.gatewayPortOverride,
|
|
skipInstall: options.skipGatewayNpmInstall,
|
|
providerKey: state.providerKey,
|
|
providerType: state.providerType ?? 'none',
|
|
});
|
|
|
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
|
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
|
process.exit(1);
|
|
} else {
|
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
|
host: configResult.host,
|
|
port: configResult.port,
|
|
});
|
|
if (!bootstrapResult.completed) {
|
|
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Keep path (preserve existing identity) ──────────────────────────────────
|
|
|
|
async function runKeepPath(
|
|
prompter: WizardPrompter,
|
|
state: WizardState,
|
|
configService: ConfigService,
|
|
options: WizardOptions,
|
|
): Promise<void> {
|
|
// Runtime detection
|
|
await runtimeSetupStage(prompter, state);
|
|
|
|
// Hooks
|
|
await hooksPreviewStage(prompter, state);
|
|
|
|
// Skills
|
|
await skillsSelectStage(prompter, state);
|
|
|
|
// Finalize
|
|
await finalizeStage(prompter, state, configService);
|
|
|
|
// Gateway stages
|
|
if (!options.skipGateway) {
|
|
try {
|
|
const configResult = await gatewayConfigStage(prompter, state, {
|
|
host: options.gatewayHost ?? 'localhost',
|
|
defaultPort: options.gatewayPort ?? 14242,
|
|
portOverride: options.gatewayPortOverride,
|
|
skipInstall: options.skipGatewayNpmInstall,
|
|
});
|
|
|
|
if (configResult.ready && configResult.host && configResult.port) {
|
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
|
host: configResult.host,
|
|
port: configResult.port,
|
|
});
|
|
if (!bootstrapResult.completed) {
|
|
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|