import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; import { platform } from 'node:os'; import type { WizardPrompter } from '../prompter/interface.js'; import type { ConfigService } from '../config/config-service.js'; import type { WizardState } from '../types.js'; import { getShellProfilePath } from '../platform/detect.js'; function linkRuntimeAssets(mosaicHome: string): void { const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets'); if (existsSync(script)) { try { spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' }); } catch { // Non-fatal: wizard continues } } } function syncSkills(mosaicHome: string): void { const script = join(mosaicHome, 'bin', 'mosaic-sync-skills'); if (existsSync(script)) { try { spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' }); } catch { // Non-fatal } } } interface DoctorResult { warnings: number; output: string; } function runDoctor(mosaicHome: string): DoctorResult { const script = join(mosaicHome, 'bin', 'mosaic-doctor'); if (!existsSync(script)) { return { warnings: 0, output: 'mosaic-doctor not found' }; } try { const result = spawnSync('bash', [script], { timeout: 30000, encoding: 'utf-8', stdio: 'pipe', }); const output = result.stdout ?? ''; const warnings = (output.match(/WARN/g) ?? []).length; return { warnings, output }; } catch { return { warnings: 1, output: 'Doctor check failed' }; } } type PathAction = 'already' | 'added' | 'skipped'; function setupPath( mosaicHome: string, p: WizardPrompter, ): PathAction { const binDir = join(mosaicHome, 'bin'); const currentPath = process.env.PATH ?? ''; if (currentPath.includes(binDir)) { return 'already'; } const profilePath = getShellProfilePath(); if (!profilePath) return 'skipped'; const isWindows = platform() === 'win32'; const exportLine = isWindows ? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n` : `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`; // Check if already in profile if (existsSync(profilePath)) { const content = readFileSync(profilePath, 'utf-8'); if (content.includes(binDir)) { return 'already'; } } try { appendFileSync(profilePath, exportLine, 'utf-8'); return 'added'; } catch { return 'skipped'; } } export async function finalizeStage( p: WizardPrompter, state: WizardState, config: ConfigService, ): Promise { p.separator(); const spin = p.spinner(); // 1. Sync framework files (before config writes so identity files aren't overwritten) spin.update('Syncing framework files...'); await config.syncFramework(state.installAction); // 2. Write config files (after sync so they aren't overwritten by source templates) if (state.installAction !== 'keep') { spin.update('Writing configuration files...'); await config.writeSoul(state.soul); await config.writeUser(state.user); await config.writeTools(state.tools); } // 3. Link runtime assets spin.update('Linking runtime assets...'); linkRuntimeAssets(state.mosaicHome); // 4. Sync skills if (state.selectedSkills.length > 0) { spin.update('Syncing skills...'); syncSkills(state.mosaicHome); } // 5. Run doctor spin.update('Running health audit...'); const doctorResult = runDoctor(state.mosaicHome); spin.stop('Installation complete'); // 6. PATH setup const pathAction = setupPath(state.mosaicHome, p); // 7. Summary const summary: string[] = [ `Agent: ${state.soul.agentName ?? 'Assistant'}`, `Style: ${state.soul.communicationStyle ?? 'direct'}`, `Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`, `Skills: ${state.selectedSkills.length} selected`, `Config: ${state.mosaicHome}`, ]; if (doctorResult.warnings > 0) { summary.push( `Health: ${doctorResult.warnings} warning(s) — run 'mosaic doctor' for details`, ); } else { summary.push('Health: all checks passed'); } p.note(summary.join('\n'), 'Installation Summary'); // 8. Next steps const nextSteps: string[] = []; if (pathAction === 'added') { const profilePath = getShellProfilePath(); nextSteps.push( `Reload shell: source ${profilePath ?? '~/.profile'}`, ); } if (state.runtimes.detected.length === 0) { nextSteps.push( 'Install at least one runtime (claude, codex, or opencode)', ); } nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)"); nextSteps.push( 'Edit identity files directly in ~/.config/mosaic/ for fine-tuning', ); p.note( nextSteps.map((s, i) => `${i + 1}. ${s}`).join('\n'), 'Next Steps', ); p.outro('Mosaic is ready.'); }