feat(setup): add main orchestrator, menu, and CLI command

Implements Task 6 of the setup wizard:
- orchestrator.ts: runMenu() for interactive configuration loop
- orchestrator.ts: runFirstRunWizard() for new user onboarding
- orchestrator.test.ts: test for menu exit behavior
- setup.ts: registerSetupCommand() and runSetup() handler
  - Handles both first-run and existing config scenarios
  - Saves YAML config to disk
  - Optional daemon startup after first-run

All tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-10 09:35:32 -08:00
parent 182d86957b
commit d8b7b08270
3 changed files with 175 additions and 0 deletions
+70
View File
@@ -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<void> {
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<void>(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 <path>', 'Config file path')
.action(async (opts: { config?: string }) => {
const configPath = opts.config ?? getConfigPath();
await runSetup(configPath);
});
}
+32
View File
@@ -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
});
});
+73
View File
@@ -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<string, (p: Prompter, b: ConfigBuilder) => Promise<void>> = {
providers: setupProviders,
channels: setupChannels,
memory: setupMemory,
automation: setupAutomation,
security: setupSecurity,
gateway: setupGateway,
};
export async function runMenu(p: Prompter, builder: ConfigBuilder): Promise<void> {
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<ConfigBuilder> {
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;
}