diff --git a/src/cli/setup.ts b/src/cli/setup.ts new file mode 100644 index 0000000..496974a --- /dev/null +++ b/src/cli/setup.ts @@ -0,0 +1,70 @@ +import type { Command } from 'commander'; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; +import { dirname } from 'path'; +import { createInterface } from 'readline/promises'; +import { parse } from 'yaml'; +import { getConfigPath } from './shared.js'; +import { createPrompter } from './setup/prompts.js'; +import { ConfigBuilder } from './setup/config.js'; +import { runFirstRunWizard, runMenu } from './setup/orchestrator.js'; + +export async function runSetup(configPath: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const p = createPrompter(rl); + + try { + if (existsSync(configPath)) { + // Existing config → menu mode + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parse(raw) ?? {}; + const builder = ConfigBuilder.fromObject(parsed); + await runMenu(p, builder); + saveConfig(configPath, builder, p); + } else { + // No config → first-run wizard + const builder = await runFirstRunWizard(p); + saveConfig(configPath, builder, p); + + const shouldStart = await p.confirm('Start Flynn now?', true); + if (shouldStart) { + rl.close(); + const { startDaemon } = await import('../daemon/index.js'); + const { loadConfig } = await import('../config/index.js'); + const config = loadConfig(configPath); + const daemon = await startDaemon(config); + await new Promise(resolve => daemon.lifecycle.onShutdown(async () => resolve())); + return; + } + + const wantMore = await p.confirm('Configure more features?', false); + if (wantMore) { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parse(raw) ?? {}; + const menuBuilder = ConfigBuilder.fromObject(parsed); + await runMenu(p, menuBuilder); + saveConfig(configPath, menuBuilder, p); + } + } + } finally { + rl.close(); + } +} + +function saveConfig(configPath: string, builder: ConfigBuilder, p: { println(msg?: string): void }): void { + const dir = dirname(configPath); + mkdirSync(dir, { recursive: true }); + writeFileSync(configPath, builder.toYaml(), 'utf-8'); + p.println(); + p.println(`✓ Config saved to ${configPath}`); +} + +export function registerSetupCommand(program: Command): void { + program + .command('setup') + .description('Interactive setup wizard') + .option('-c, --config ', 'Config file path') + .action(async (opts: { config?: string }) => { + const configPath = opts.config ?? getConfigPath(); + await runSetup(configPath); + }); +} diff --git a/src/cli/setup/orchestrator.test.ts b/src/cli/setup/orchestrator.test.ts new file mode 100644 index 0000000..66acf0b --- /dev/null +++ b/src/cli/setup/orchestrator.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { createInterface } from 'readline/promises'; +import { Readable, Writable } from 'stream'; +import { createPrompter } from './prompts.js'; +import { runMenu } from './orchestrator.js'; +import { ConfigBuilder } from './config.js'; + +function mockReadline(inputs: string[]) { + let idx = 0; + const input = new Readable({ + read() { + if (idx < inputs.length) { + this.push(inputs[idx++] + '\n'); + } else { + this.push(null); + } + }, + }); + const output = new Writable({ write(_, __, cb) { cb(); } }); + return createInterface({ input, output }); +} + +describe('runMenu', () => { + it('exits immediately on 0', async () => { + const rl = mockReadline(['0']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + await runMenu(p, builder); + // Should return without error + }); +}); diff --git a/src/cli/setup/orchestrator.ts b/src/cli/setup/orchestrator.ts new file mode 100644 index 0000000..eb06724 --- /dev/null +++ b/src/cli/setup/orchestrator.ts @@ -0,0 +1,73 @@ +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; +import { renderSummary } from './summary.js'; +import { setupProviders } from './providers.js'; +import { setupChannels } from './channels.js'; +import { setupMemory } from './memory.js'; +import { setupAutomation } from './automation.js'; +import { setupSecurity } from './security.js'; +import { setupGateway } from './gateway.js'; + +const MENU_OPTIONS = [ + { label: 'Model Providers', value: 'providers' }, + { label: 'Channels', value: 'channels' }, + { label: 'Memory', value: 'memory' }, + { label: 'Automation', value: 'automation' }, + { label: 'Security', value: 'security' }, + { label: 'Gateway', value: 'gateway' }, +]; + +const SECTION_HANDLERS: Record Promise> = { + providers: setupProviders, + channels: setupChannels, + memory: setupMemory, + automation: setupAutomation, + security: setupSecurity, + gateway: setupGateway, +}; + +export async function runMenu(p: Prompter, builder: ConfigBuilder): Promise { + while (true) { + p.println(); + p.println('Flynn Setup — Current Configuration'); + p.println(renderSummary(builder.build())); + p.println(); + p.println('What would you like to configure?'); + for (let i = 0; i < MENU_OPTIONS.length; i++) { + p.println(` ${i + 1}. ${MENU_OPTIONS[i].label}`); + } + p.println(' 0. Done — save and exit'); + + const answer = await p.ask('>', '0'); + const idx = parseInt(answer, 10); + + if (idx === 0 || isNaN(idx)) break; + if (idx >= 1 && idx <= MENU_OPTIONS.length) { + const section = MENU_OPTIONS[idx - 1].value; + const handler = SECTION_HANDLERS[section]; + if (handler) { + p.println(); + await handler(p, builder); + } + } + } +} + +export async function runFirstRunWizard(p: Prompter): Promise { + const { ConfigBuilder } = await import('./config.js'); + + p.println(); + p.println("Let's get Flynn running. This takes about 2 minutes."); + p.println(); + + const builder = new ConfigBuilder(); + + // Step 1: Model provider + await setupProviders(p, builder); + + // Step 2: Channels + p.println(); + await setupChannels(p, builder); + + return builder; +}