/** * uninstall.ts — top-level `mosaic uninstall` command * * Flags: * --framework Remove ~/.config/mosaic/ (honor --keep-data for SOUL.md etc.) * --cli npm uninstall -g @mosaicstack/mosaic * --gateway Delegate to gateway/uninstall runUninstall * --all All three + runtime asset reversal * --keep-data Preserve memory/, SOUL.md, USER.md, TOOLS.md, gateway DB/storage * --yes / -y Skip confirmation (also: MOSAIC_ASSUME_YES=1) * --dry-run List what would be removed; mutate nothing * * Default (no category flag): interactive prompt per category. */ import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs'; import { createInterface } from 'node:readline'; import { execSync } from 'node:child_process'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import type { Command } from 'commander'; import { DEFAULT_MOSAIC_HOME } from '../constants.js'; import { readManifest, heuristicRuntimeAssetDests, DEFAULT_SCOPE_LINE, } from '../runtime/install-manifest.js'; // ─── types ─────────────────────────────────────────────────────────────────── interface UninstallOptions { framework: boolean; cli: boolean; gateway: boolean; all: boolean; keepData: boolean; yes: boolean; dryRun: boolean; mosaicHome: string; } // ─── protected data paths (relative to MOSAIC_HOME) ────────────────────────── /** Paths inside MOSAIC_HOME that --keep-data protects. */ const KEEP_DATA_PATHS = ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory', 'sources']; // ─── public entry point ─────────────────────────────────────────────────────── export async function runTopLevelUninstall(opts: UninstallOptions): Promise { const assume = opts.yes || process.env['MOSAIC_ASSUME_YES'] === '1'; const doFramework = opts.all || opts.framework; const doCli = opts.all || opts.cli; const doGateway = opts.all || opts.gateway; const interactive = !doFramework && !doCli && !doGateway; if (opts.dryRun) { console.log('[dry-run] No changes will be made.\n'); } const rl = createInterface({ input: process.stdin, output: process.stdout }); const ask = (q: string): Promise => new Promise((resolve) => { if (assume) { console.log(`${q} [auto-yes]`); resolve(true); return; } rl.question(`${q} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y')); }); try { const shouldFramework = interactive ? await ask('Uninstall Mosaic framework (~/.config/mosaic)?') : doFramework; const shouldCli = interactive ? await ask('Uninstall @mosaicstack/mosaic CLI (npm global)?') : doCli; const shouldGateway = interactive ? await ask('Uninstall Mosaic Gateway?') : doGateway; if (!shouldFramework && !shouldCli && !shouldGateway) { console.log('Nothing to uninstall. Exiting.'); return; } // 1. Gateway if (shouldGateway) { await uninstallGateway(opts.dryRun); } // 2. Runtime assets (reverse linked files) — always run when framework removal if (shouldFramework) { reverseRuntimeAssets(opts.mosaicHome, opts.dryRun); } // 3. npmrc scope line if (shouldCli || shouldFramework) { reverseNpmrc(opts.mosaicHome, opts.dryRun); } // 4. Framework directory if (shouldFramework) { removeFramework(opts.mosaicHome, opts.keepData, opts.dryRun); } // 5. CLI npm package if (shouldCli) { removeCli(opts.dryRun); } if (!opts.dryRun) { console.log('\nUninstall complete.'); } else { console.log('\n[dry-run] No changes made.'); } } finally { rl.close(); } } // ─── step: gateway ──────────────────────────────────────────────────────────── async function uninstallGateway(dryRun: boolean): Promise { console.log('\n[gateway] Delegating to gateway uninstaller…'); if (dryRun) { console.log('[dry-run] Would call gateway/uninstall runUninstall()'); return; } try { const { runUninstall } = await import('./gateway/uninstall.js'); await runUninstall(); } catch (err) { console.warn( ` Warning: gateway uninstall failed — ${err instanceof Error ? err.message : String(err)}`, ); } } // ─── step: reverse runtime assets ──────────────────────────────────────────── /** * Reverse all runtime asset copies made by mosaic-link-runtime-assets: * - If a .mosaic-bak-* backup exists → restore it * - Else if the managed copy exists → remove it */ export function reverseRuntimeAssets(mosaicHome: string, dryRun: boolean): void { const home = homedir(); const manifest = readManifest(mosaicHome); let copies: Array<{ dest: string; backup?: string }>; if (manifest) { copies = manifest.mutations.runtimeAssetCopies; } else { // Heuristic mode console.warn(' Warning: no install manifest found — using heuristic mode for runtime assets.'); copies = heuristicRuntimeAssetDests(home).map((dest) => ({ dest })); } for (const entry of copies) { const dest = entry.dest; const backupFromManifest = entry.backup; // Resolve backup: manifest may have one, or scan for pattern const backup = backupFromManifest ?? findLatestBackup(dest); if (backup && existsSync(backup)) { if (dryRun) { console.log(`[dry-run] Would restore backup: ${backup} → ${dest}`); } else { try { const content = readFileSync(backup); writeFileSync(dest, content); rmSync(backup, { force: true }); console.log(` Restored: ${dest}`); } catch (err) { console.warn( ` Warning: could not restore ${dest}: ${err instanceof Error ? err.message : String(err)}`, ); } } } else if (existsSync(dest)) { if (dryRun) { console.log(`[dry-run] Would remove managed copy: ${dest}`); } else { try { rmSync(dest, { force: true }); console.log(` Removed: ${dest}`); } catch (err) { console.warn( ` Warning: could not remove ${dest}: ${err instanceof Error ? err.message : String(err)}`, ); } } } } } /** * Scan the directory of `filePath` for the most recent `.mosaic-bak-*` backup. */ function findLatestBackup(filePath: string): string | undefined { const dir = dirname(filePath); const base = filePath.split('/').at(-1) ?? ''; if (!existsSync(dir)) return undefined; let entries: string[]; try { entries = readdirSync(dir); } catch { return undefined; } const backups = entries .filter((e) => e.startsWith(`${base}.mosaic-bak-`)) .sort() .reverse(); // most recent first (timestamp suffix) return backups.length > 0 ? join(dir, backups[0]!) : undefined; } // ─── step: reverse npmrc ────────────────────────────────────────────────────── /** * Remove the @mosaicstack:registry line added by tools/install.sh. * Only removes the exact line; never touches anything else. */ export function reverseNpmrc(mosaicHome: string, dryRun: boolean): void { const npmrcPath = join(homedir(), '.npmrc'); if (!existsSync(npmrcPath)) return; const manifest = readManifest(mosaicHome); const linesToRemove: string[] = manifest?.mutations.npmrcLines && manifest.mutations.npmrcLines.length > 0 ? manifest.mutations.npmrcLines : [DEFAULT_SCOPE_LINE]; let content: string; try { content = readFileSync(npmrcPath, 'utf8'); } catch { return; } const lines = content.split('\n'); const filtered = lines.filter((l) => !linesToRemove.includes(l.trimEnd())); if (filtered.length === lines.length) { // Nothing to remove return; } if (dryRun) { for (const line of linesToRemove) { if (lines.some((l) => l.trimEnd() === line)) { console.log(`[dry-run] Would remove from ~/.npmrc: ${line}`); } } return; } try { writeFileSync(npmrcPath, filtered.join('\n'), 'utf8'); console.log(' Removed @mosaicstack registry from ~/.npmrc'); } catch (err) { console.warn( ` Warning: could not update ~/.npmrc: ${err instanceof Error ? err.message : String(err)}`, ); } } // ─── step: remove framework directory ──────────────────────────────────────── export function removeFramework(mosaicHome: string, keepData: boolean, dryRun: boolean): void { if (!existsSync(mosaicHome)) { console.log(` Framework directory not found: ${mosaicHome}`); return; } if (!keepData) { if (dryRun) { console.log(`[dry-run] Would remove: ${mosaicHome} (entire directory)`); } else { rmSync(mosaicHome, { recursive: true, force: true }); console.log(` Removed: ${mosaicHome}`); } return; } // --keep-data: remove everything except protected paths const entries = readdirSync(mosaicHome); for (const entry of entries) { if (KEEP_DATA_PATHS.some((p) => entry === p)) { continue; // protected } const full = join(mosaicHome, entry); if (dryRun) { console.log(`[dry-run] Would remove: ${full}`); } else { try { rmSync(full, { recursive: true, force: true }); console.log(` Removed: ${full}`); } catch (err) { console.warn( ` Warning: could not remove ${full}: ${err instanceof Error ? err.message : String(err)}`, ); } } } if (!dryRun) { console.log(` Framework removed (preserved: ${KEEP_DATA_PATHS.join(', ')})`); } } // ─── step: remove CLI npm package ──────────────────────────────────────────── export function removeCli(dryRun: boolean): void { if (dryRun) { console.log('[dry-run] Would run: npm uninstall -g @mosaicstack/mosaic'); return; } console.log(' Uninstalling @mosaicstack/mosaic (npm global)…'); try { execSync('npm uninstall -g @mosaicstack/mosaic', { stdio: 'inherit' }); console.log(' CLI uninstalled.'); } catch (err) { console.warn( ` Warning: npm uninstall failed — ${err instanceof Error ? err.message : String(err)}`, ); console.warn(' You may need to run: npm uninstall -g @mosaicstack/mosaic'); } } // ─── commander registration ─────────────────────────────────────────────────── export function registerUninstallCommand(program: Command): void { program .command('uninstall') .description('Uninstall Mosaic (framework, CLI, and/or gateway)') .option('--framework', 'Remove ~/.config/mosaic/ framework directory') .option('--cli', 'Uninstall @mosaicstack/mosaic npm global package') .option('--gateway', 'Uninstall the Mosaic Gateway (delegates to gateway uninstaller)') .option('--all', 'Uninstall everything (framework + CLI + gateway + runtime asset reversal)') .option( '--keep-data', 'Preserve user data: memory/, SOUL.md, USER.md, TOOLS.md, gateway DB/storage', ) .option('--yes, -y', 'Skip confirmation prompts (also: MOSAIC_ASSUME_YES=1)') .option('--dry-run', 'List what would be removed without making any changes') .option( '--mosaic-home ', 'Override MOSAIC_HOME directory', process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME, ) .action( async (opts: { framework?: boolean; cli?: boolean; gateway?: boolean; all?: boolean; keepData?: boolean; yes?: boolean; dryRun?: boolean; mosaicHome: string; }) => { await runTopLevelUninstall({ framework: opts.framework ?? false, cli: opts.cli ?? false, gateway: opts.gateway ?? false, all: opts.all ?? false, keepData: opts.keepData ?? false, yes: opts.yes ?? false, dryRun: opts.dryRun ?? false, mosaicHome: opts.mosaicHome, }); }, ); }