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);
});
}