Files
stack/packages/mosaic/src/commands/uninstall.ts
jason.woltje 25cada7735
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
feat: mosaic uninstall (IUH-M01) (#429)
2026-04-05 17:06:21 +00:00

380 lines
12 KiB
TypeScript

/**
* 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<void> {
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<boolean> =>
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<void> {
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 <path>',
'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,
});
},
);
}