380 lines
12 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
);
|
|
}
|