feat: add mosaic uninstall command and install manifest (IUH-M01)
Add top-level `mosaic uninstall` with --framework, --cli, --gateway, --all, --keep-data, --yes, and --dry-run flags. Implements runtime asset reversal (backup restore or managed-copy removal), npmrc scope line removal, and gateway delegation. Adds install manifest (write on install, read on uninstall) for precise reversal; falls back to heuristic mode when manifest is absent. Shell fallback via `tools/install.sh --uninstall`. Closes #425. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
379
packages/mosaic/src/commands/uninstall.ts
Normal file
379
packages/mosaic/src/commands/uninstall.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user