feat: mosaic uninstall (IUH-M01) #429
69
docs/scratchpads/install-ux-hardening-20260405.md
Normal file
69
docs/scratchpads/install-ux-hardening-20260405.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
379
packages/mosaic/src/commands/uninstall.ts
Normal file
379
packages/mosaic/src/commands/uninstall.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
167
packages/mosaic/src/runtime/install-manifest.spec.ts
Normal file
167
packages/mosaic/src/runtime/install-manifest.spec.ts
Normal 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=');
|
||||
});
|
||||
});
|
||||
163
packages/mosaic/src/runtime/install-manifest.ts
Normal file
163
packages/mosaic/src/runtime/install-manifest.ts
Normal 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/';
|
||||
188
tools/install.sh
188
tools/install.sh
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user