Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
178 lines
4.8 KiB
TypeScript
178 lines
4.8 KiB
TypeScript
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<void> {
|
|
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.');
|
|
}
|