feat: mosaic uninstall (IUH-M01) (#429)
This commit was merged in pull request #429.
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user