feat: mosaic uninstall (IUH-M01) (#429)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #429.
This commit is contained in:
2026-04-05 17:06:21 +00:00
parent be6553101c
commit 25cada7735
7 changed files with 1205 additions and 0 deletions

View 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();
});
});

View 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,
});
},
);
}