diff --git a/forge/bin/forge.ts b/forge/bin/forge.ts index b77c8a4..b71c287 100644 --- a/forge/bin/forge.ts +++ b/forge/bin/forge.ts @@ -11,6 +11,7 @@ import { registerDevCommand } from '../src/commands/dev.js'; import { registerDeployCommand } from '../src/commands/deploy.js'; import { registerEnvCommands } from '../src/commands/env.js'; import { registerPresetCommands } from '../src/commands/preset.js'; +import { registerSetupCommand } from '../src/commands/setup.js'; const program = new Command(); @@ -33,5 +34,6 @@ registerDevCommand(program); registerDeployCommand(program); registerEnvCommands(program); registerPresetCommands(program); +registerSetupCommand(program); program.parse(); diff --git a/forge/src/commands/__tests__/setup.test.ts b/forge/src/commands/__tests__/setup.test.ts new file mode 100644 index 0000000..6c3b277 --- /dev/null +++ b/forge/src/commands/__tests__/setup.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerSetupCommand } from '../setup.js'; +import { readFileSync, writeFileSync, existsSync, readdirSync, chmodSync } from 'node:fs'; + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(), + readdirSync: vi.fn(), + mkdirSync: vi.fn(), + chmodSync: vi.fn(), +})); + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + text: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() })), + log: { step: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn() }, + note: vi.fn(), + cancel: vi.fn(), + isCancel: vi.fn(() => false), +})); + +const mockedReadFileSync = vi.mocked(readFileSync); +const mockedWriteFileSync = vi.mocked(writeFileSync); +const mockedExistsSync = vi.mocked(existsSync); +const mockedReaddirSync = vi.mocked(readdirSync); + +const techMinimalPreset = JSON.stringify({ + monitor: { name: 'Tech Watch', slug: 'tech-watch', domain: 'technology', description: 'Tech dashboard', tags: [], branding: { primaryColor: '#0052CC' } }, + sources: [ + { name: 'hackernews', type: 'rss', url: 'https://hn.algolia.com/api/v1/search', category: 'tech' }, + { name: 'techcrunch', type: 'rss', url: 'https://techcrunch.com/feed/', category: 'tech' }, + { name: 'arstechnica', type: 'rss', url: 'https://feeds.arstechnica.com/arstechnica/index', category: 'tech' }, + ], + panels: [ + { name: 'tech-news', type: 'news-feed', displayName: 'Tech News', position: 0 }, + { name: 'ai-brief', type: 'ai-brief', displayName: 'AI Brief', position: 1 }, + ], + layers: [], + ai: { enabled: true, fallbackChain: ['groq'], providers: { groq: { model: 'llama-3.3-70b-versatile', apiKeyEnv: 'GROQ_API_KEY' } } }, + map: { center: [-95, 38], projection: 'mercator' }, +}); + +function createProgram(): Command { + const program = new Command(); + program.option('--format '); + program.option('--non-interactive'); + program.option('--dry-run'); + registerSetupCommand(program); + return program; +} + +let logOutput: string[] = []; + +beforeEach(() => { + vi.resetAllMocks(); + logOutput = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => { logOutput.push(msg); }); + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`exit:${code}`); + }); +}); + +function mockNoExistingConfig() { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('config.ts')) return false; + if (p.endsWith('.env.local')) return false; + if (p.endsWith('presets')) return true; + if (p.endsWith('.json')) return true; // preset files + return false; + }); +} + +function mockPresetsDir() { + mockedReaddirSync.mockReturnValue(['tech-minimal.json', 'blank.json'] as any); + mockedReadFileSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('tech-minimal.json')) return techMinimalPreset; + if (p.endsWith('blank.json')) return JSON.stringify({ monitor: { domain: 'general' }, sources: [], panels: [], layers: [] }); + return '{}'; + }); +} + +describe('setup command (non-interactive)', () => { + it('creates config with defaults when no options given', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toBe('setup'); + expect(output.data.name).toBe('My Monitor'); + expect(output.data.preset).toBe('blank'); + expect(output.data.aiEnabled).toBe(true); + expect(output.changes).toHaveLength(3); + expect(output.next_steps).toContain('forge validate'); + }); + + it('applies specified preset', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--template', 'tech-minimal', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.preset).toBe('tech-minimal'); + expect(output.data.sources).toBe(3); + expect(output.data.panels).toBe(2); + }); + + it('sets map configuration', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', '-95,38', '--projection', 'globe', '--day-night', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.projection).toBe('globe'); + }); + + it('disables AI with --no-ai', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--no-ai', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.aiEnabled).toBe(false); + }); + + it('writes API keys to .env.local', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--groq-key', 'gsk_test123', '--format', 'json']); + + const envWriteCall = vi.mocked(writeFileSync).mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(envWriteCall).toBeDefined(); + expect(envWriteCall![1]).toContain('GROQ_API_KEY=gsk_test123'); + }); + + it('dry-run does not write files', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--dry-run', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.changes[0].description).toContain('Would'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('fails when config already exists', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return true; + return false; + }); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('already exists'); + }); + + it('warns when AI enabled but no keys provided', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.warnings).toContainEqual(expect.stringContaining('no API keys')); + }); + + it('rejects invalid projection value', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--projection', 'equirectangular', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('Invalid projection'); + }); + + it('warns about missing preset gracefully', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('.env.local')) return false; + if (p.endsWith('nonexistent.json')) return false; + return false; + }); + mockedReaddirSync.mockReturnValue([] as any); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--template', 'nonexistent', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.warnings).toContainEqual(expect.stringContaining('not found')); + }); + + it('rejects invalid center coordinates (NaN)', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', 'abc,def', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('must be numbers'); + }); + + it('rejects center coordinates out of range', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', '200,100', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('out of range'); + }); + + it('rejects center with single value', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--center', '123', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('lng,lat'); + }); + + it('dry-run succeeds even when config already exists', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return true; + if (p.endsWith('presets')) return true; + if (p.endsWith('.json')) return true; + return false; + }); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--dry-run', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.changes[0].description).toContain('Would'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('handles malformed preset JSON gracefully', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('.env.local')) return false; + if (p.endsWith('presets')) return true; + if (p.endsWith('broken.json')) return true; + return false; + }); + mockedReaddirSync.mockReturnValue([] as any); + mockedReadFileSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('broken.json')) return '{invalid json!!!'; + return '{}'; + }); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--template', 'broken', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.warnings).toContainEqual(expect.stringContaining('invalid JSON')); + }); + + it('sets chmod 0600 on .env.local', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup', '--non-interactive', '--groq-key', 'gsk_test', '--format', 'json']); + + const chmodCall = vi.mocked(chmodSync).mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(chmodCall).toBeDefined(); + expect(chmodCall![1]).toBe(0o600); + }); +}); + +describe('setup command (interactive)', () => { + it('happy path through full wizard', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const clack = await import('@clack/prompts'); + const mockedText = vi.mocked(clack.text); + const mockedSelect = vi.mocked(clack.select); + const mockedConfirm = vi.mocked(clack.confirm); + + // Step 1: name, description + mockedText.mockResolvedValueOnce('Test Monitor'); + mockedText.mockResolvedValueOnce('A test dashboard'); + // Step 2: preset + mockedSelect.mockResolvedValueOnce('tech-minimal'); + // Step 3: projection, center, day/night + mockedSelect.mockResolvedValueOnce('globe'); + mockedText.mockResolvedValueOnce('126.97, 37.56'); + mockedConfirm.mockResolvedValueOnce(true); + // Step 4: AI enabled, groq key, openrouter key + mockedConfirm.mockResolvedValueOnce(true); + mockedText.mockResolvedValueOnce('gsk_wizard_key'); + mockedText.mockResolvedValueOnce(''); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup']); + + // Config should be written with correct values + expect(mockedWriteFileSync).toHaveBeenCalled(); + const configCall = mockedWriteFileSync.mock.calls.find( + ([path]) => String(path).endsWith('config.json') + ); + expect(configCall).toBeDefined(); + const writtenConfig = JSON.parse(configCall![1] as string); + expect(writtenConfig.monitor.name).toBe('Test Monitor'); + expect(writtenConfig.monitor.slug).toBe('test-monitor'); + expect(writtenConfig.map.projection).toBe('globe'); + expect(writtenConfig.map.center).toEqual([126.97, 37.56]); + expect(writtenConfig.map.dayNightOverlay).toBe(true); + expect(writtenConfig.ai.enabled).toBe(true); + expect(writtenConfig.sources).toHaveLength(3); + expect(writtenConfig.panels).toHaveLength(2); + + // .env.local should contain the groq key + const envCall = mockedWriteFileSync.mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(envCall).toBeDefined(); + expect(envCall![1]).toContain('GROQ_API_KEY=gsk_wizard_key'); + + // Outro should be called + expect(clack.outro).toHaveBeenCalled(); + }); + + it('cancellation exits gracefully', async () => { + mockNoExistingConfig(); + mockPresetsDir(); + + const clack = await import('@clack/prompts'); + vi.mocked(clack.isCancel).mockReturnValue(true); + vi.mocked(clack.text).mockResolvedValueOnce(Symbol('cancel') as any); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'setup']), + ).rejects.toThrow('exit:0'); + + expect(clack.cancel).toHaveBeenCalledWith('Setup cancelled.'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('merges with existing .env.local', async () => { + mockedExistsSync.mockImplementation((path: any) => { + const p = String(path); + if (p.endsWith('config.json')) return false; + if (p.endsWith('.env.local')) return true; + if (p.endsWith('presets')) return true; + return p.endsWith('tech-minimal.json'); + }); + mockPresetsDir(); + // Return existing .env.local content when reading it + mockedReadFileSync.mockImplementation((path: any, ...args: any[]) => { + const p = String(path); + if (p.endsWith('.env.local')) return 'EXISTING_KEY=existing_value\n'; + if (p.endsWith('tech-minimal.json')) return techMinimalPreset; + if (p.endsWith('blank.json')) return JSON.stringify({ monitor: { domain: 'general' }, sources: [], panels: [], layers: [] }); + return '{}'; + }); + + const clack = await import('@clack/prompts'); + vi.mocked(clack.isCancel).mockReturnValue(false); + // Step 1 + vi.mocked(clack.text).mockResolvedValueOnce('Merge Test'); + vi.mocked(clack.text).mockResolvedValueOnce(''); + // Step 2 + vi.mocked(clack.select).mockResolvedValueOnce('blank'); + // Step 3 + vi.mocked(clack.select).mockResolvedValueOnce('mercator'); + vi.mocked(clack.text).mockResolvedValueOnce('0, 20'); + vi.mocked(clack.confirm).mockResolvedValueOnce(false); + // Step 4: AI disabled + vi.mocked(clack.confirm).mockResolvedValueOnce(false); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'setup']); + + const envCall = mockedWriteFileSync.mock.calls.find( + ([path]) => String(path).endsWith('.env.local') + ); + expect(envCall).toBeDefined(); + expect(envCall![1]).toContain('EXISTING_KEY=existing_value'); + }); +}); diff --git a/forge/src/commands/env.ts b/forge/src/commands/env.ts index 9968f57..1397c8e 100644 --- a/forge/src/commands/env.ts +++ b/forge/src/commands/env.ts @@ -83,13 +83,13 @@ export function registerEnvCommands(program: Command): void { }); } -interface EnvVarInfo { +export interface EnvVarInfo { key: string; description: string; required: boolean; } -function collectRequiredEnvVars(config: import('../config/schema.js').MonitorForgeConfig): EnvVarInfo[] { +export function collectRequiredEnvVars(config: import('../config/schema.js').MonitorForgeConfig): EnvVarInfo[] { const vars: EnvVarInfo[] = []; // AI providers @@ -124,7 +124,7 @@ function collectRequiredEnvVars(config: import('../config/schema.js').MonitorFor return vars; } -function parseEnvFile(content: string): Record { +export function parseEnvFile(content: string): Record { const result: Record = {}; for (const line of content.split('\n')) { const trimmed = line.trim(); diff --git a/forge/src/commands/setup.ts b/forge/src/commands/setup.ts new file mode 100644 index 0000000..e083453 --- /dev/null +++ b/forge/src/commands/setup.ts @@ -0,0 +1,480 @@ +import type { Command } from 'commander'; +import { existsSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'node:fs'; +import { resolve, basename } from 'node:path'; +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { configExists, writeConfig } from '../config/loader.js'; +import { createDefaultConfig } from '../config/defaults.js'; +import { MonitorForgeConfigSchema, type MonitorForgeConfig } from '../config/schema.js'; +import { formatOutput, success, failure, type OutputFormat, type Change } from '../output/format.js'; +import { collectRequiredEnvVars, parseEnvFile } from './env.js'; + +interface SetupParams { + name: string; + description: string; + slug: string; + preset: string; + center: [number, number]; + projection: 'mercator' | 'globe'; + dayNight: boolean; + aiEnabled: boolean; + groqKey: string; + openrouterKey: string; +} + +interface SetupResult { + config: MonitorForgeConfig; + changes: Change[]; + warnings: string[]; +} + +function loadPresets(): Array<{ value: string; label: string; hint: string }> { + const presetsDir = resolve(process.cwd(), 'presets'); + if (!existsSync(presetsDir)) return []; + + const files = readdirSync(presetsDir).filter(f => f.endsWith('.json')); + const results: Array<{ value: string; label: string; hint: string }> = []; + for (const f of files) { + try { + const content = JSON.parse(readFileSync(resolve(presetsDir, f), 'utf-8')); + const name = basename(f, '.json'); + results.push({ + value: name, + label: name, + hint: `${content.monitor?.domain ?? 'general'} | ${content.sources?.length ?? 0} sources, ${content.panels?.length ?? 0} panels`, + }); + } catch { + console.error(`Warning: skipping malformed preset file "${f}"`); + } + } + return results; +} + +function toSlug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-monitor'; +} + +function buildAndWrite(params: SetupParams, dryRun: boolean): SetupResult { + const changes: Change[] = []; + const warnings: string[] = []; + + // Load preset + let presetOverrides: Partial = {}; + if (params.preset !== 'blank') { + const presetPath = resolve(process.cwd(), 'presets', `${params.preset}.json`); + if (existsSync(presetPath)) { + try { + presetOverrides = JSON.parse(readFileSync(presetPath, 'utf-8')); + } catch { + warnings.push(`Preset "${params.preset}" has invalid JSON, using blank config.`); + } + } else { + warnings.push(`Preset "${params.preset}" not found, using blank config.`); + } + } + + // Build AI config + const aiConfig = params.aiEnabled + ? (presetOverrides.ai && Object.keys(presetOverrides.ai.providers ?? {}).length > 0 + ? { ...presetOverrides.ai, enabled: true } + : { + enabled: true, + fallbackChain: ['groq', 'openrouter'], + providers: { + groq: { model: 'llama-3.3-70b-versatile', apiKeyEnv: 'GROQ_API_KEY' }, + openrouter: { model: 'meta-llama/llama-3.3-70b-instruct', apiKeyEnv: 'OPENROUTER_API_KEY' }, + }, + analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false }, + }) + : { + enabled: false, + fallbackChain: [] as string[], + providers: {}, + analysis: { summarization: false, entityExtraction: false, sentimentAnalysis: false, focalPointDetection: false }, + }; + + const config = MonitorForgeConfigSchema.parse(createDefaultConfig({ + ...presetOverrides, + monitor: { + name: params.name, + slug: params.slug, + description: params.description || presetOverrides.monitor?.description || 'A custom real-time intelligence dashboard', + domain: presetOverrides.monitor?.domain ?? 'general', + tags: presetOverrides.monitor?.tags ?? [], + branding: presetOverrides.monitor?.branding ?? { primaryColor: '#0052CC' }, + }, + map: Object.assign({}, presetOverrides.map, { + center: params.center, + projection: params.projection, + dayNightOverlay: params.dayNight, + }), + ai: aiConfig, + })); + + if (dryRun) { + changes.push( + { type: 'created', file: 'monitor-forge.config.json', description: 'Would create config file' }, + { type: 'created', file: '.env.local', description: 'Would create env file with API keys' }, + { type: 'created', file: '.env.example', description: 'Would generate env template' }, + ); + } else { + // Write config + const configPath = writeConfig(config); + changes.push({ type: 'created', file: configPath, description: 'Created config file' }); + + // Write .env.local (merge with existing) + const envLocalPath = resolve(process.cwd(), '.env.local'); + let existing: Record = {}; + if (existsSync(envLocalPath)) { + existing = parseEnvFile(readFileSync(envLocalPath, 'utf-8')); + } + + if (params.groqKey) existing['GROQ_API_KEY'] = params.groqKey; + if (params.openrouterKey) existing['OPENROUTER_API_KEY'] = params.openrouterKey; + + const envVars = collectRequiredEnvVars(config); + const envLines: string[] = []; + for (const v of envVars) { + const value = existing[v.key] ?? ''; + envLines.push(`# ${v.description}${v.required ? ' (required)' : ''}`); + envLines.push(`${v.key}=${value}`); + envLines.push(''); + } + // Include any extra keys from existing .env.local not in envVars + const envVarKeys = new Set(envVars.map(v => v.key)); + for (const [key, value] of Object.entries(existing)) { + if (!envVarKeys.has(key)) { + envLines.push(`${key}=${value}`); + envLines.push(''); + } + } + + writeFileSync(envLocalPath, envLines.join('\n'), 'utf-8'); + chmodSync(envLocalPath, 0o600); + changes.push({ type: 'created', file: '.env.local', description: 'Created env file with API keys' }); + + // Write .env.example + const exampleContent = envVars.map(v => + `# ${v.description}${v.required ? ' (required)' : ' (optional)'}\n${v.key}=` + ).join('\n\n') + '\n'; + writeFileSync(resolve(process.cwd(), '.env.example'), exampleContent, 'utf-8'); + changes.push({ type: 'created', file: '.env.example', description: 'Generated env template' }); + } + + // Validation warnings + if (config.sources.length === 0) { + warnings.push('No data sources configured. Run `forge source add` to add feeds.'); + } + if (config.panels.length === 0) { + warnings.push('No panels configured. Run `forge panel add` to add UI panels.'); + } + if (params.aiEnabled && !params.groqKey && !params.openrouterKey) { + warnings.push('AI is enabled but no API keys provided. Add keys to .env.local before running.'); + } + + return { config, changes, warnings }; +} + +async function runInteractiveWizard(dryRun: boolean): Promise { + p.intro(pc.bgCyan(pc.black(' monitor-forge setup '))); + + // Check existing config + if (configExists() && !dryRun) { + const overwrite = await p.confirm({ + message: 'monitor-forge.config.json already exists. Overwrite?', + }); + if (p.isCancel(overwrite) || !overwrite) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + } + + // Step 1/5: Monitor identity + p.log.step(pc.bold('Step 1/5') + ' — Monitor identity'); + + const name = await p.text({ + message: 'What is the name of your monitor?', + placeholder: 'My Intelligence Dashboard', + defaultValue: 'My Monitor', + validate: (val) => { + if (!val || val.trim().length === 0) return 'Name is required'; + if (val.length > 64) return 'Name must be 64 characters or less'; + }, + }); + if (p.isCancel(name)) { p.cancel('Setup cancelled.'); process.exit(0); } + + const description = await p.text({ + message: 'Short description (optional):', + placeholder: 'A custom real-time intelligence dashboard', + defaultValue: '', + }); + if (p.isCancel(description)) { p.cancel('Setup cancelled.'); process.exit(0); } + + // Step 2/5: Choose a preset + p.log.step(pc.bold('Step 2/5') + ' — Choose a preset'); + + const presets = loadPresets(); + let selectedPreset: string; + + if (presets.length === 0) { + p.log.warn('No presets found. Using blank configuration.'); + selectedPreset = 'blank'; + } else { + const preset = await p.select({ + message: 'Choose a starting preset:', + options: presets, + }); + if (p.isCancel(preset)) { p.cancel('Setup cancelled.'); process.exit(0); } + selectedPreset = preset as string; + } + + // Load preset to get defaults for subsequent steps + let presetData: Partial = {}; + if (selectedPreset !== 'blank') { + const presetPath = resolve(process.cwd(), 'presets', `${selectedPreset}.json`); + if (existsSync(presetPath)) { + try { + presetData = JSON.parse(readFileSync(presetPath, 'utf-8')); + } catch { + p.log.warn(`Preset "${selectedPreset}" has invalid JSON, using defaults.`); + } + } + } + + // Step 3/5: Map configuration + p.log.step(pc.bold('Step 3/5') + ' — Map configuration'); + + const projection = await p.select({ + message: 'Map projection:', + options: [ + { value: 'mercator', label: 'Mercator', hint: 'Flat map — best for regional focus' }, + { value: 'globe', label: 'Globe', hint: '3D globe — best for global view' }, + ], + initialValue: presetData.map?.projection ?? 'mercator', + }); + if (p.isCancel(projection)) { p.cancel('Setup cancelled.'); process.exit(0); } + + const defaultCenter = presetData.map?.center ?? [0, 20]; + const centerInput = await p.text({ + message: 'Map center (lng, lat):', + placeholder: '0, 20', + defaultValue: defaultCenter.join(', '), + validate: (val) => { + const parts = val.split(',').map(s => s.trim()); + if (parts.length !== 2) return 'Enter longitude, latitude (e.g. -95, 38)'; + const [lng, lat] = parts.map(Number); + if (isNaN(lng) || isNaN(lat)) return 'Both values must be numbers'; + if (lng < -180 || lng > 180) return 'Longitude must be between -180 and 180'; + if (lat < -90 || lat > 90) return 'Latitude must be between -90 and 90'; + }, + }); + if (p.isCancel(centerInput)) { p.cancel('Setup cancelled.'); process.exit(0); } + + const dayNight = await p.confirm({ + message: 'Enable day/night terminator overlay?', + initialValue: presetData.map?.dayNightOverlay ?? false, + }); + if (p.isCancel(dayNight)) { p.cancel('Setup cancelled.'); process.exit(0); } + + // Step 4/5: AI analysis + p.log.step(pc.bold('Step 4/5') + ' — AI analysis'); + + const aiEnabled = await p.confirm({ + message: 'Enable AI-powered analysis? (summarization, entity extraction)', + initialValue: presetData.ai?.enabled ?? true, + }); + if (p.isCancel(aiEnabled)) { p.cancel('Setup cancelled.'); process.exit(0); } + + let groqKey = ''; + let openrouterKey = ''; + + if (aiEnabled) { + p.log.info(pc.dim('AI requires API keys. Free tiers available at:')); + p.log.info(pc.dim(' Groq: https://console.groq.com')); + p.log.info(pc.dim(' OpenRouter: https://openrouter.ai/keys')); + + const gk = await p.text({ + message: 'Groq API key (press Enter to skip):', + placeholder: 'gsk_...', + defaultValue: '', + }); + if (p.isCancel(gk)) { p.cancel('Setup cancelled.'); process.exit(0); } + groqKey = gk as string; + + const ork = await p.text({ + message: 'OpenRouter API key (press Enter to skip):', + placeholder: 'sk-or-...', + defaultValue: '', + }); + if (p.isCancel(ork)) { p.cancel('Setup cancelled.'); process.exit(0); } + openrouterKey = ork as string; + } + + // Step 5/5: Create + p.log.step(pc.bold('Step 5/5') + ' — Creating your dashboard'); + + const s = p.spinner(); + s.start('Generating configuration...'); + + const [lng, lat] = (centerInput as string).split(',').map(s => parseFloat(s.trim())); + + const result = buildAndWrite({ + name: name as string, + description: description as string, + slug: toSlug(name as string), + preset: selectedPreset, + center: [lng, lat], + projection: projection as 'mercator' | 'globe', + dayNight: dayNight as boolean, + aiEnabled: aiEnabled as boolean, + groqKey, + openrouterKey, + }, dryRun); + + s.stop('Configuration complete!'); + + // Summary + p.log.success(pc.green('Dashboard created successfully!')); + p.log.info(` Name: ${pc.bold(result.config.monitor.name)}`); + p.log.info(` Preset: ${pc.bold(selectedPreset)}`); + p.log.info(` Sources: ${pc.bold(String(result.config.sources.length))}`); + p.log.info(` Panels: ${pc.bold(String(result.config.panels.length))}`); + p.log.info(` AI: ${pc.bold(result.config.ai.enabled ? 'enabled' : 'disabled')}`); + p.log.info(` Projection: ${pc.bold(result.config.map.projection)}`); + + if (result.warnings.length > 0) { + for (const w of result.warnings) { + p.log.warn(pc.yellow(w)); + } + } + + p.note( + [ + `${pc.dim('$')} npm run validate`, + `${pc.dim('$')} npm run dev`, + ].join('\n'), + 'Next steps', + ); + + p.outro(pc.bgGreen(pc.black(' Happy monitoring! '))); +} + +function runNonInteractive( + opts: Record, + format: OutputFormat, + dryRun: boolean, +): void { + if (configExists() && !dryRun) { + console.log(formatOutput( + failure('setup', 'monitor-forge.config.json already exists. Delete it first or use --dry-run.'), + format, + )); + process.exit(1); + } + + const name = (opts.name as string) ?? 'My Monitor'; + const desc = (opts.description as string) ?? ''; + const template = (opts.template as string) ?? 'blank'; + const centerStr = opts.center as string | undefined; + let center: [number, number] = [0, 20]; + if (centerStr) { + const parts = centerStr.split(',').map(s => s.trim()); + if (parts.length !== 2) { + console.log(formatOutput( + failure('setup', 'Invalid center format. Expected "lng,lat" (e.g. "-95,38").'), + format, + )); + process.exit(1); + } + const [lng, lat] = parts.map(s => parseFloat(s)); + if (isNaN(lng) || isNaN(lat)) { + console.log(formatOutput( + failure('setup', 'Invalid center coordinates. Both values must be numbers.'), + format, + )); + process.exit(1); + } + if (lng < -180 || lng > 180 || lat < -90 || lat > 90) { + console.log(formatOutput( + failure('setup', 'Center coordinates out of range. Longitude: -180..180, Latitude: -90..90.'), + format, + )); + process.exit(1); + } + center = [lng, lat]; + } + const projectionRaw = (opts.projection as string) ?? 'mercator'; + if (projectionRaw !== 'mercator' && projectionRaw !== 'globe') { + console.log(formatOutput( + failure('setup', `Invalid projection "${projectionRaw}". Must be "mercator" or "globe".`), + format, + )); + process.exit(1); + } + const projection = projectionRaw as 'mercator' | 'globe'; + const dayNight = !!opts.dayNight; + const aiEnabled = opts.ai !== false; + const groqKey = (opts.groqKey as string) ?? ''; + const openrouterKey = (opts.openrouterKey as string) ?? ''; + + const result = buildAndWrite({ + name, + description: desc, + slug: toSlug(name), + preset: template, + center, + projection, + dayNight, + aiEnabled, + groqKey, + openrouterKey, + }, dryRun); + + console.log(formatOutput( + success('setup', { + name: result.config.monitor.name, + slug: result.config.monitor.slug, + preset: template, + sources: result.config.sources.length, + layers: result.config.layers.length, + panels: result.config.panels.length, + aiEnabled: result.config.ai.enabled, + projection: result.config.map.projection, + }, { + changes: result.changes, + warnings: result.warnings, + next_steps: [ + 'forge validate', + 'forge dev', + ], + }), + format, + )); +} + +export function registerSetupCommand(program: Command): void { + program + .command('setup') + .description('Interactive setup wizard — go from zero to running dashboard') + .option('--name ', 'Monitor name') + .option('--description ', 'Short description') + .option('--template ', 'Starting preset') + .option('--center ', 'Map center as "lng,lat"') + .option('--projection ', 'Map projection: mercator or globe') + .option('--day-night', 'Enable day/night overlay') + .option('--ai', 'Enable AI analysis') + .option('--no-ai', 'Disable AI analysis') + .option('--groq-key ', 'Groq API key') + .option('--openrouter-key ', 'OpenRouter API key') + .action(async (opts) => { + const format = (program.opts().format ?? 'table') as OutputFormat; + const nonInteractive = program.opts().nonInteractive ?? false; + const dryRun = program.opts().dryRun ?? false; + + if (format === 'json' || nonInteractive) { + runNonInteractive(opts, format, dryRun); + } else { + await runInteractiveWizard(dryRun); + } + }); +}