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