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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user