From c84f27b56df207cc01705e8c98ddaee3b69c3243 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 5 Apr 2026 11:57:42 -0500 Subject: [PATCH] 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 --- .../install-ux-hardening-20260405.md | 69 ++++ packages/mosaic/src/cli.ts | 5 + .../mosaic/src/commands/uninstall.spec.ts | 234 +++++++++++ packages/mosaic/src/commands/uninstall.ts | 379 ++++++++++++++++++ .../src/runtime/install-manifest.spec.ts | 167 ++++++++ .../mosaic/src/runtime/install-manifest.ts | 163 ++++++++ tools/install.sh | 188 +++++++++ 7 files changed, 1205 insertions(+) create mode 100644 docs/scratchpads/install-ux-hardening-20260405.md create mode 100644 packages/mosaic/src/commands/uninstall.spec.ts create mode 100644 packages/mosaic/src/commands/uninstall.ts create mode 100644 packages/mosaic/src/runtime/install-manifest.spec.ts create mode 100644 packages/mosaic/src/runtime/install-manifest.ts diff --git a/docs/scratchpads/install-ux-hardening-20260405.md b/docs/scratchpads/install-ux-hardening-20260405.md new file mode 100644 index 0000000..a4fd797 --- /dev/null +++ b/docs/scratchpads/install-ux-hardening-20260405.md @@ -0,0 +1,69 @@ +# Install UX Hardening — IUH-M01 Session Notes + +## Session: 2026-04-05 (agent-ad6b6696) + +### Plan + +**Manifest schema decision:** + +- Version 1 JSON at `~/.config/mosaic/.install-manifest.json` (mode 0600) +- Written by `tools/install.sh` after successful install +- Fields: version, installedAt, cliVersion, frameworkVersion, mutations{directories, npmGlobalPackages, npmrcLines, shellProfileEdits, runtimeAssetCopies} +- Uninstall reads it; if missing → heuristic mode (warn user) + +**File list:** + +- NEW: `packages/mosaic/src/runtime/install-manifest.ts` — read/write helpers + types +- NEW: `packages/mosaic/src/runtime/install-manifest.spec.ts` — unit tests +- NEW: `packages/mosaic/src/commands/uninstall.ts` — command implementation +- NEW: `packages/mosaic/src/commands/uninstall.spec.ts` — unit tests +- MOD: `packages/mosaic/src/cli.ts` — register `uninstall` command +- MOD: `tools/install.sh` — write manifest on success + add `--uninstall` path + +**Runtime asset list (from mosaic-link-runtime-assets / framework/install.sh):** + +- `~/.claude/CLAUDE.md` (source: `$MOSAIC_HOME/runtime/claude/CLAUDE.md`) +- `~/.claude/settings.json` (source: `$MOSAIC_HOME/runtime/claude/settings.json`) +- `~/.claude/hooks-config.json` (source: `$MOSAIC_HOME/runtime/claude/hooks-config.json`) +- `~/.claude/context7-integration.md` (source: `$MOSAIC_HOME/runtime/claude/context7-integration.md`) +- `~/.config/opencode/AGENTS.md` (source: `$MOSAIC_HOME/runtime/opencode/AGENTS.md`) +- `~/.codex/instructions.md` (source: `$MOSAIC_HOME/runtime/codex/instructions.md`) + +**Reversal logic:** + +1. If `.mosaic-bak-` exists for a file → restore it +2. Else if managed copy exists → remove it +3. Never touch files not in the known list + +**npmrc reversal:** + +- Only remove line `@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` +- If manifest has the line, use that as authoritative; else check heuristically + +**PATH reversal:** + +- Check install.sh: it does NOT add PATH entries to shell profiles (framework/install.sh migration removes old `$MOSAIC_HOME/bin` PATH entries in v0/v1→v2 migration, but new install does NOT add PATH) +- ASSUMPTION: No PATH edits in current install (v0.0.24+). Shell profiles not modified by current install. +- The `$PREFIX/bin` is mentioned in a warning but NOT added to shell profiles by install.sh. +- shellProfileEdits array will be empty for new installs; heuristic mode also skips it. + +**Test strategy:** + +- Unit test manifest read/write with temp dir mocking +- Unit test command registration +- Unit test dry-run flag (no actual fs mutations) +- Unit test --keep-data skips protected paths +- Unit test heuristic mode warning + +**Implementation order:** + +1. install-manifest.ts helpers +2. install-manifest.spec.ts tests +3. uninstall.ts command +4. uninstall.spec.ts tests +5. cli.ts registration +6. tools/install.sh manifest writing + --uninstall path + +ASSUMPTION: No PATH modifications in current install.sh (v0.0.24). Framework v0/v1→v2 migration cleaned old PATH entries but current install does not add new ones. +ASSUMPTION: `--uninstall` in install.sh handles framework + cli + npmrc only; gateway teardown deferred to `mosaic gateway uninstall`. +ASSUMPTION: Pi settings.json edits (skills paths) added by framework/install.sh are NOT reversed in this iteration — too risky to touch user Pi config without manifest evidence. Noted as follow-up. diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 7bf50ae..a40b47f 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -14,6 +14,7 @@ import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerConfigCommand } from './commands/config.js'; import { registerMissionCommand } from './commands/mission.js'; +import { registerUninstallCommand } from './commands/uninstall.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; import { registerAuthCommand } from './commands/auth.js'; @@ -383,6 +384,10 @@ registerQueueCommand(program); registerStorageCommand(program); +// ─── uninstall ─────────────────────────────────────────────────────────────── + +registerUninstallCommand(program); + // ─── telemetry ─────────────────────────────────────────────────────────────── registerTelemetryCommand(program); diff --git a/packages/mosaic/src/commands/uninstall.spec.ts b/packages/mosaic/src/commands/uninstall.spec.ts new file mode 100644 index 0000000..381abb5 --- /dev/null +++ b/packages/mosaic/src/commands/uninstall.spec.ts @@ -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(); + }); +}); diff --git a/packages/mosaic/src/commands/uninstall.ts b/packages/mosaic/src/commands/uninstall.ts new file mode 100644 index 0000000..d97e92b --- /dev/null +++ b/packages/mosaic/src/commands/uninstall.ts @@ -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 { + 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 => + 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 { + 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 ', + '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, + }); + }, + ); +} diff --git a/packages/mosaic/src/runtime/install-manifest.spec.ts b/packages/mosaic/src/runtime/install-manifest.spec.ts new file mode 100644 index 0000000..ae3073a --- /dev/null +++ b/packages/mosaic/src/runtime/install-manifest.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createManifest, + readManifest, + writeManifest, + manifestPath, + heuristicRuntimeAssetDests, + DEFAULT_SCOPE_LINE, + MANIFEST_VERSION, +} from './install-manifest.js'; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-manifest-test-')); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── createManifest ─────────────────────────────────────────────────────────── + +describe('createManifest', () => { + it('creates a valid manifest with version 1', () => { + const m = createManifest('0.0.24', 2); + expect(m.version).toBe(MANIFEST_VERSION); + expect(m.cliVersion).toBe('0.0.24'); + expect(m.frameworkVersion).toBe(2); + }); + + it('sets installedAt to an ISO-8601 date string', () => { + const before = new Date(); + const m = createManifest('0.0.24', 2); + const after = new Date(); + const ts = new Date(m.installedAt); + expect(ts.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(ts.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('starts with empty mutation arrays', () => { + const m = createManifest('0.0.24', 2); + expect(m.mutations.directories).toHaveLength(0); + expect(m.mutations.npmGlobalPackages).toHaveLength(0); + expect(m.mutations.npmrcLines).toHaveLength(0); + expect(m.mutations.shellProfileEdits).toHaveLength(0); + expect(m.mutations.runtimeAssetCopies).toHaveLength(0); + }); + + it('merges partial mutations', () => { + const m = createManifest('0.0.24', 2, { + npmGlobalPackages: ['@mosaicstack/mosaic'], + }); + expect(m.mutations.npmGlobalPackages).toEqual(['@mosaicstack/mosaic']); + expect(m.mutations.directories).toHaveLength(0); + }); +}); + +// ─── manifestPath ───────────────────────────────────────────────────────────── + +describe('manifestPath', () => { + it('returns mosaicHome/.install-manifest.json', () => { + const p = manifestPath('/home/user/.config/mosaic'); + expect(p).toBe('/home/user/.config/mosaic/.install-manifest.json'); + }); +}); + +// ─── writeManifest / readManifest round-trip ───────────────────────────────── + +describe('writeManifest + readManifest', () => { + it('round-trips a manifest through disk', () => { + const m = createManifest('0.0.24', 2, { + npmGlobalPackages: ['@mosaicstack/mosaic'], + npmrcLines: [DEFAULT_SCOPE_LINE], + }); + + writeManifest(tmpDir, m); + const loaded = readManifest(tmpDir); + + expect(loaded).toBeDefined(); + expect(loaded!.version).toBe(1); + expect(loaded!.cliVersion).toBe('0.0.24'); + expect(loaded!.mutations.npmGlobalPackages).toEqual(['@mosaicstack/mosaic']); + expect(loaded!.mutations.npmrcLines).toEqual([DEFAULT_SCOPE_LINE]); + }); + + it('preserves runtimeAssetCopies with backup path', () => { + const m = createManifest('0.0.24', 2, { + runtimeAssetCopies: [ + { + source: '/src/settings.json', + dest: '/home/user/.claude/settings.json', + backup: '/home/user/.claude/settings.json.mosaic-bak-20260405120000', + }, + ], + }); + + writeManifest(tmpDir, m); + const loaded = readManifest(tmpDir); + + const copies = loaded!.mutations.runtimeAssetCopies; + expect(copies).toHaveLength(1); + expect(copies[0]!.backup).toBe('/home/user/.claude/settings.json.mosaic-bak-20260405120000'); + }); +}); + +// ─── readManifest — missing / invalid ──────────────────────────────────────── + +describe('readManifest error cases', () => { + it('returns undefined when the file does not exist', () => { + expect(readManifest('/nonexistent/path')).toBeUndefined(); + }); + + it('returns undefined when the file contains invalid JSON', () => { + const { writeFileSync } = require('node:fs'); + writeFileSync(join(tmpDir, '.install-manifest.json'), 'not json', 'utf8'); + expect(readManifest(tmpDir)).toBeUndefined(); + }); + + it('returns undefined when version field is wrong', () => { + const { writeFileSync } = require('node:fs'); + writeFileSync( + join(tmpDir, '.install-manifest.json'), + JSON.stringify({ + version: 99, + installedAt: new Date().toISOString(), + cliVersion: '1', + frameworkVersion: 1, + mutations: {}, + }), + 'utf8', + ); + expect(readManifest(tmpDir)).toBeUndefined(); + }); +}); + +// ─── heuristicRuntimeAssetDests ────────────────────────────────────────────── + +describe('heuristicRuntimeAssetDests', () => { + it('returns a non-empty list of absolute paths', () => { + const dests = heuristicRuntimeAssetDests('/home/user'); + expect(dests.length).toBeGreaterThan(0); + for (const d of dests) { + expect(d).toMatch(/^\/home\/user\//); + } + }); + + it('includes the claude settings.json path', () => { + const dests = heuristicRuntimeAssetDests('/home/user'); + expect(dests).toContain('/home/user/.claude/settings.json'); + }); +}); + +// ─── DEFAULT_SCOPE_LINE ─────────────────────────────────────────────────────── + +describe('DEFAULT_SCOPE_LINE', () => { + it('contains the mosaicstack registry URL', () => { + expect(DEFAULT_SCOPE_LINE).toContain('mosaicstack'); + expect(DEFAULT_SCOPE_LINE).toContain('@mosaicstack:registry='); + }); +}); diff --git a/packages/mosaic/src/runtime/install-manifest.ts b/packages/mosaic/src/runtime/install-manifest.ts new file mode 100644 index 0000000..6f3a59f --- /dev/null +++ b/packages/mosaic/src/runtime/install-manifest.ts @@ -0,0 +1,163 @@ +/** + * install-manifest.ts + * + * Read/write helpers for ~/.config/mosaic/.install-manifest.json + * + * The manifest is the authoritative record of what the installer mutated on the + * host system so that `mosaic uninstall` can precisely reverse every change. + * If the manifest is absent the uninstaller falls back to heuristic mode and + * warns the user. + */ + +import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; + +export const MANIFEST_FILENAME = '.install-manifest.json'; +export const MANIFEST_VERSION = 1; + +/** A single runtime asset copy recorded during install. */ +export interface RuntimeAssetCopy { + /** Absolute path to the source file in MOSAIC_HOME (or the npm package). */ + source: string; + /** Absolute path to the destination on the host. */ + dest: string; + /** + * Absolute path to the backup that was created when an existing file was + * displaced. Undefined when no pre-existing file was found. + */ + backup?: string; +} + +/** The full shape of the install manifest (version 1). */ +export interface InstallManifest { + version: 1; + /** ISO-8601 timestamp of when the install completed. */ + installedAt: string; + /** Version of @mosaicstack/mosaic that was installed. */ + cliVersion: string; + /** Framework schema version (integer) that was installed. */ + frameworkVersion: number; + mutations: { + /** Directories that were created by the installer. */ + directories: string[]; + /** npm global packages that were installed. */ + npmGlobalPackages: string[]; + /** + * Exact lines that were appended to ~/.npmrc. + * Each entry is the full line text (no trailing newline). + */ + npmrcLines: string[]; + /** + * Shell profile edits — each entry is an object recording which file was + * edited and what line was appended. + */ + shellProfileEdits: Array<{ file: string; line: string }>; + /** Runtime asset copies performed by mosaic-link-runtime-assets. */ + runtimeAssetCopies: RuntimeAssetCopy[]; + }; +} + +/** Default empty mutations block. */ +function emptyMutations(): InstallManifest['mutations'] { + return { + directories: [], + npmGlobalPackages: [], + npmrcLines: [], + shellProfileEdits: [], + runtimeAssetCopies: [], + }; +} + +/** + * Build a new manifest with sensible defaults. + * Callers fill in the mutation fields before persisting. + */ +export function createManifest( + cliVersion: string, + frameworkVersion: number, + partial?: Partial, +): InstallManifest { + return { + version: MANIFEST_VERSION, + installedAt: new Date().toISOString(), + cliVersion, + frameworkVersion, + mutations: { ...emptyMutations(), ...partial }, + }; +} + +/** + * Return the absolute path to the manifest file. + */ +export function manifestPath(mosaicHome: string): string { + return join(mosaicHome, MANIFEST_FILENAME); +} + +/** + * Read the manifest from disk. + * Returns `undefined` if the file does not exist or cannot be parsed. + * Never throws — callers decide how to handle heuristic-fallback mode. + */ +export function readManifest(mosaicHome: string): InstallManifest | undefined { + const p = manifestPath(mosaicHome); + if (!existsSync(p)) return undefined; + try { + const raw = readFileSync(p, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (!isValidManifest(parsed)) return undefined; + return parsed; + } catch { + return undefined; + } +} + +/** + * Persist the manifest to disk with mode 0600 (owner read/write only). + * Creates the mosaicHome directory if it does not exist. + */ +export function writeManifest(mosaicHome: string, manifest: InstallManifest): void { + const p = manifestPath(mosaicHome); + const json = JSON.stringify(manifest, null, 2) + '\n'; + writeFileSync(p, json, { encoding: 'utf8' }); + try { + chmodSync(p, 0o600); + } catch { + // chmod may fail on some systems (e.g. Windows); non-fatal + } +} + +/** + * Narrow an unknown value to InstallManifest. + * Only checks the minimum structure; does not validate every field. + */ +function isValidManifest(v: unknown): v is InstallManifest { + if (typeof v !== 'object' || v === null) return false; + const m = v as Record; + if (m['version'] !== 1) return false; + if (typeof m['installedAt'] !== 'string') return false; + if (typeof m['cliVersion'] !== 'string') return false; + if (typeof m['frameworkVersion'] !== 'number') return false; + if (typeof m['mutations'] !== 'object' || m['mutations'] === null) return false; + return true; +} + +/** + * The known set of runtime asset destinations managed by + * mosaic-link-runtime-assets / framework/install.sh. + * + * Used by heuristic mode when no manifest is available. + */ +export function heuristicRuntimeAssetDests(homeDir: string): string[] { + return [ + join(homeDir, '.claude', 'CLAUDE.md'), + join(homeDir, '.claude', 'settings.json'), + join(homeDir, '.claude', 'hooks-config.json'), + join(homeDir, '.claude', 'context7-integration.md'), + join(homeDir, '.config', 'opencode', 'AGENTS.md'), + join(homeDir, '.codex', 'instructions.md'), + ]; +} + +/** The npmrc scope line added by tools/install.sh. */ +export const DEFAULT_SCOPE_LINE = + '@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/'; diff --git a/tools/install.sh b/tools/install.sh index 4687d8c..14db8fe 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -18,6 +18,7 @@ # --ref Git ref for framework archive (default: main) # --yes Accept all defaults; headless/non-interactive install # --no-auto-launch Skip automatic mosaic wizard + gateway install on first install +# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line # # Environment: # MOSAIC_HOME — framework install dir (default: ~/.config/mosaic) @@ -41,6 +42,7 @@ FLAG_FRAMEWORK=true FLAG_CLI=true FLAG_NO_AUTO_LAUNCH=false FLAG_YES=false +FLAG_UNINSTALL=false GIT_REF="${MOSAIC_REF:-main}" # MOSAIC_ASSUME_YES env var acts the same as --yes @@ -56,6 +58,7 @@ while [[ $# -gt 0 ]]; do --ref) GIT_REF="${2:-main}"; shift 2 ;; --yes|-y) FLAG_YES=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; + --uninstall) FLAG_UNINSTALL=true; shift ;; *) shift ;; esac done @@ -69,6 +72,109 @@ CLI_PKG="${SCOPE}/mosaic" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-stack" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" +# ─── uninstall path ─────────────────────────────────────────────────────────── +# Shell-level uninstall for when the CLI is broken or not available. +# Handles: framework directory, npm CLI package, npmrc scope line. +# Gateway teardown: if mosaic CLI is still available, delegates to it. +# Does NOT touch gateway DB/storage — user must handle that separately. + +if [[ "$FLAG_UNINSTALL" == "true" ]]; then + echo "" + echo "${BOLD:-}Mosaic Uninstaller (shell fallback)${RESET:-}" + echo "" + + SCOPE_LINE="${SCOPE:-@mosaicstack}:registry=${REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}" + NPMRC_FILE="$HOME/.npmrc" + + # Gateway: try mosaic CLI first, then check pid file + if command -v mosaic &>/dev/null; then + echo "${B:-}ℹ${RESET:-} Attempting gateway uninstall via mosaic CLI…" + if mosaic gateway uninstall --yes 2>/dev/null; then + echo "${G:-}✔${RESET:-} Gateway uninstalled via CLI." + else + echo "${Y:-}⚠${RESET:-} Gateway uninstall via CLI failed or not installed — skipping." + fi + else + # Look for pid file and stop daemon if running + GATEWAY_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}/../mosaic-gateway" + PID_FILE="$GATEWAY_HOME/gateway.pid" + if [[ -f "$PID_FILE" ]]; then + PID="$(cat "$PID_FILE" 2>/dev/null || true)" + if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then + echo "${B:-}ℹ${RESET:-} Stopping gateway daemon (pid $PID)…" + kill "$PID" 2>/dev/null || true + sleep 1 + fi + fi + echo "${Y:-}⚠${RESET:-} mosaic CLI not found — skipping full gateway teardown." + echo " Run 'mosaic gateway uninstall' separately if the CLI is available." + fi + + # Framework directory + if [[ -d "$MOSAIC_HOME" ]]; then + echo "${B:-}ℹ${RESET:-} Removing framework: $MOSAIC_HOME" + rm -rf "$MOSAIC_HOME" + echo "${G:-}✔${RESET:-} Framework removed." + else + echo "${Y:-}⚠${RESET:-} Framework directory not found: $MOSAIC_HOME" + fi + + # Runtime assets: restore backups or remove managed copies + echo "${B:-}ℹ${RESET:-} Reversing runtime asset copies…" + declare -a RUNTIME_DESTS=( + "$HOME/.claude/CLAUDE.md" + "$HOME/.claude/settings.json" + "$HOME/.claude/hooks-config.json" + "$HOME/.claude/context7-integration.md" + "$HOME/.config/opencode/AGENTS.md" + "$HOME/.codex/instructions.md" + ) + for dest in "${RUNTIME_DESTS[@]}"; do + base="$(basename "$dest")" + dir="$(dirname "$dest")" + # Find most recent backup + backup="" + if [[ -d "$dir" ]]; then + backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)" + fi + if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then + cp "$backup" "$dest" + rm -f "$backup" + echo " Restored: $dest" + elif [[ -f "$dest" ]]; then + rm -f "$dest" + echo " Removed: $dest" + fi + done + + # npmrc scope line + if [[ -f "$NPMRC_FILE" ]] && grep -qF "$SCOPE_LINE" "$NPMRC_FILE" 2>/dev/null; then + echo "${B:-}ℹ${RESET:-} Removing $SCOPE_LINE from $NPMRC_FILE…" + # Use sed to remove the exact line (in-place, portable) + if sed -i.mosaic-uninstall-bak "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null; then + rm -f "${NPMRC_FILE}.mosaic-uninstall-bak" + echo "${G:-}✔${RESET:-} npmrc entry removed." + else + # BSD sed syntax (macOS) + sed -i '' "\|^${SCOPE_LINE}\$|d" "$NPMRC_FILE" 2>/dev/null || \ + echo "${Y:-}⚠${RESET:-} Could not auto-remove npmrc line — remove it manually: $SCOPE_LINE" + fi + fi + + # npm CLI package + echo "${B:-}ℹ${RESET:-} Uninstalling npm package: ${CLI_PKG}…" + if npm uninstall -g "${CLI_PKG}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then + echo "${G:-}✔${RESET:-} CLI package removed." + else + echo "${Y:-}⚠${RESET:-} npm uninstall failed — you may need to run manually:" + echo " npm uninstall -g ${CLI_PKG}" + fi + + echo "" + echo "${G:-}✔${RESET:-} Uninstall complete." + exit 0 +fi + # ─── colours ────────────────────────────────────────────────────────────────── if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET="" @@ -358,6 +464,88 @@ if [[ "$FLAG_CHECK" == "false" ]]; then fi fi + # ── Write install manifest ────────────────────────────────────────────────── + # Records what was mutated so that `mosaic uninstall` can precisely reverse it. + # Written last (after all mutations) so an incomplete install leaves no manifest. + MANIFEST_PATH="$MOSAIC_HOME/.install-manifest.json" + MANIFEST_CLI_VERSION="$(installed_cli_version)" + MANIFEST_FW_VERSION="$(framework_version)" + MANIFEST_SCOPE_LINE="${SCOPE}:registry=${REGISTRY}" + MANIFEST_TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")" + + # Build runtimeAssetCopies array by scanning known destinations for backups + collect_runtime_copies() { + local home_dir="$HOME" + local copies="[]" + local dests=( + "$home_dir/.claude/CLAUDE.md" + "$home_dir/.claude/settings.json" + "$home_dir/.claude/hooks-config.json" + "$home_dir/.claude/context7-integration.md" + "$home_dir/.config/opencode/AGENTS.md" + "$home_dir/.codex/instructions.md" + ) + copies="[" + local first=true + for dest in "${dests[@]}"; do + [[ -f "$dest" ]] || continue + local base dir backup_path backup_val + base="$(basename "$dest")" + dir="$(dirname "$dest")" + backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)" + if [[ -n "$backup_path" ]]; then + backup_val="\"$backup_path\"" + else + backup_val="null" + fi + if [[ "$first" == "true" ]]; then + first=false + else + copies="$copies," + fi + copies="$copies{\"source\":\"\",\"dest\":\"$dest\",\"backup\":$backup_val}" + done + copies="$copies]" + echo "$copies" + } + + RUNTIME_COPIES="$(collect_runtime_copies)" + + # Check whether the npmrc line was present (we may have added it above) + NPMRC_LINES_JSON="[]" + if grep -qF "$MANIFEST_SCOPE_LINE" "$HOME/.npmrc" 2>/dev/null; then + NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]" + fi + + node -e " + const fs = require('fs'); + const path = require('path'); + const p = process.argv[1]; + const m = { + version: 1, + installedAt: process.argv[2], + cliVersion: process.argv[3] || '(unknown)', + frameworkVersion: parseInt(process.argv[4] || '0', 10), + mutations: { + directories: [path.dirname(p)], + npmGlobalPackages: ['@mosaicstack/mosaic'], + npmrcLines: JSON.parse(process.argv[5]), + shellProfileEdits: [], + runtimeAssetCopies: JSON.parse(process.argv[6]), + } + }; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(m, null, 2) + '\\n', { mode: 0o600 }); + " \ + "$MANIFEST_PATH" \ + "$MANIFEST_TS" \ + "$MANIFEST_CLI_VERSION" \ + "$MANIFEST_FW_VERSION" \ + "$NPMRC_LINES_JSON" \ + "$RUNTIME_COPIES" 2>/dev/null \ + && ok "Install manifest written: $MANIFEST_PATH" \ + || warn "Could not write install manifest (non-fatal)" + echo "" ok "Done." fi