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:
234
packages/mosaic/src/commands/uninstall.spec.ts
Normal file
234
packages/mosaic/src/commands/uninstall.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import {
|
||||
registerUninstallCommand,
|
||||
reverseRuntimeAssets,
|
||||
reverseNpmrc,
|
||||
removeFramework,
|
||||
removeCli,
|
||||
} from './uninstall.js';
|
||||
import { writeManifest, createManifest } from '../runtime/install-manifest.js';
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
let mosaicHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-uninstall-test-'));
|
||||
mosaicHome = join(tmpDir, 'mosaic');
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── command registration ────────────────────────────────────────────────────
|
||||
|
||||
describe('registerUninstallCommand', () => {
|
||||
it('registers an "uninstall" command on the program', () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerUninstallCommand(program);
|
||||
const names = program.commands.map((c) => c.name());
|
||||
expect(names).toContain('uninstall');
|
||||
});
|
||||
|
||||
it('registers the expected options', () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerUninstallCommand(program);
|
||||
const cmd = program.commands.find((c) => c.name() === 'uninstall')!;
|
||||
const optNames = cmd.options.map((o) => o.long);
|
||||
expect(optNames).toContain('--framework');
|
||||
expect(optNames).toContain('--cli');
|
||||
expect(optNames).toContain('--gateway');
|
||||
expect(optNames).toContain('--all');
|
||||
expect(optNames).toContain('--keep-data');
|
||||
expect(optNames).toContain('--dry-run');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reverseNpmrc ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reverseNpmrc', () => {
|
||||
it('does nothing when .npmrc does not exist (heuristic mode, no manifest)', () => {
|
||||
// Should not throw; mosaicHome has no manifest and home has no .npmrc
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
expect(() => reverseNpmrc(mosaicHome, true)).not.toThrow();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('dry-run mode logs removal without mutating', () => {
|
||||
// Write a manifest with a known npmrc line
|
||||
writeManifest(
|
||||
mosaicHome,
|
||||
createManifest('0.0.24', 2, {
|
||||
npmrcLines: [
|
||||
'@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/',
|
||||
],
|
||||
}),
|
||||
);
|
||||
// reverseNpmrc reads ~/.npmrc from actual homedir; dry-run won't touch anything
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
expect(() => reverseNpmrc(mosaicHome, true)).not.toThrow();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── removeFramework ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeFramework', () => {
|
||||
it('removes the entire directory when --keep-data is false', () => {
|
||||
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
removeFramework(mosaicHome, false, false);
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(existsSync(mosaicHome)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves SOUL.md and memory/ when --keep-data is true', () => {
|
||||
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
||||
writeFileSync(join(mosaicHome, 'SOUL.md'), '# soul', 'utf8');
|
||||
mkdirSync(join(mosaicHome, 'memory'), { recursive: true });
|
||||
writeFileSync(join(mosaicHome, 'memory', 'note.md'), 'note', 'utf8');
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
removeFramework(mosaicHome, true, false);
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(true);
|
||||
expect(existsSync(join(mosaicHome, 'memory'))).toBe(true);
|
||||
expect(existsSync(join(mosaicHome, 'AGENTS.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves USER.md and TOOLS.md when --keep-data is true', () => {
|
||||
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
||||
writeFileSync(join(mosaicHome, 'USER.md'), '# user', 'utf8');
|
||||
writeFileSync(join(mosaicHome, 'TOOLS.md'), '# tools', 'utf8');
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
removeFramework(mosaicHome, true, false);
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(true);
|
||||
expect(existsSync(join(mosaicHome, 'TOOLS.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('dry-run logs but does not remove', () => {
|
||||
writeFileSync(join(mosaicHome, 'AGENTS.md'), '# agents', 'utf8');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
removeFramework(mosaicHome, false, true);
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(existsSync(mosaicHome)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles missing mosaicHome gracefully', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
expect(() => removeFramework('/nonexistent/path', false, false)).not.toThrow();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reverseRuntimeAssets ─────────────────────────────────────────────────────
|
||||
|
||||
describe('reverseRuntimeAssets', () => {
|
||||
it('dry-run does not throw in heuristic mode (no manifest)', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
expect(() => reverseRuntimeAssets(mosaicHome, true)).not.toThrow();
|
||||
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('restores backup when present (with manifest)', () => {
|
||||
// Create a fake dest and backup inside tmpDir
|
||||
const claudeDir = join(tmpDir, 'dot-claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const dest = join(claudeDir, 'settings.json');
|
||||
const backup = join(claudeDir, 'settings.json.mosaic-bak-20260405120000');
|
||||
writeFileSync(dest, '{"current": true}', 'utf8');
|
||||
writeFileSync(backup, '{"original": true}', 'utf8');
|
||||
|
||||
// Write a manifest pointing to these exact paths
|
||||
writeManifest(
|
||||
mosaicHome,
|
||||
createManifest('0.0.24', 2, {
|
||||
runtimeAssetCopies: [{ source: '/src/settings.json', dest, backup }],
|
||||
}),
|
||||
);
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
reverseRuntimeAssets(mosaicHome, false);
|
||||
logSpy.mockRestore();
|
||||
|
||||
// Backup removed, dest has original content
|
||||
expect(existsSync(backup)).toBe(false);
|
||||
expect(readFileSync(dest, 'utf8')).toBe('{"original": true}');
|
||||
});
|
||||
|
||||
it('removes managed copy when no backup present (with manifest)', () => {
|
||||
const claudeDir = join(tmpDir, 'dot-claude2');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const dest = join(claudeDir, 'CLAUDE.md');
|
||||
writeFileSync(dest, '# managed', 'utf8');
|
||||
|
||||
writeManifest(
|
||||
mosaicHome,
|
||||
createManifest('0.0.24', 2, {
|
||||
runtimeAssetCopies: [{ source: '/src/CLAUDE.md', dest }],
|
||||
}),
|
||||
);
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
reverseRuntimeAssets(mosaicHome, false);
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(existsSync(dest)).toBe(false);
|
||||
});
|
||||
|
||||
it('dry-run with manifest logs but does not remove', () => {
|
||||
const claudeDir = join(tmpDir, 'dot-claude3');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
const dest = join(claudeDir, 'hooks-config.json');
|
||||
writeFileSync(dest, '{}', 'utf8');
|
||||
|
||||
writeManifest(
|
||||
mosaicHome,
|
||||
createManifest('0.0.24', 2, {
|
||||
runtimeAssetCopies: [{ source: '/src/hooks-config.json', dest }],
|
||||
}),
|
||||
);
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
reverseRuntimeAssets(mosaicHome, true);
|
||||
logSpy.mockRestore();
|
||||
|
||||
// File should still exist in dry-run mode
|
||||
expect(existsSync(dest)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── removeCli ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('removeCli', () => {
|
||||
it('dry-run logs the npm command without running it', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
removeCli(true);
|
||||
const output = logSpy.mock.calls.map((c) => c[0] as string).join('\n');
|
||||
expect(output).toContain('npm uninstall -g @mosaicstack/mosaic');
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
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