feat: mosaic uninstall (IUH-M01) #429

Merged
jason.woltje merged 1 commits from feat/mosaic-uninstall into main 2026-04-05 17:06:22 +00:00
7 changed files with 1205 additions and 0 deletions

View File

@@ -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-<stamp>` 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.

View File

@@ -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);

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

View File

@@ -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=');
});
});

View File

@@ -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['mutations']>,
): 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<string, unknown>;
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/';

View File

@@ -18,6 +18,7 @@
# --ref <branch> 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