feat: mosaic uninstall (IUH-M01) (#429)
This commit was merged in pull request #429.
This commit is contained in:
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 { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
@@ -383,6 +384,10 @@ registerQueueCommand(program);
|
|||||||
|
|
||||||
registerStorageCommand(program);
|
registerStorageCommand(program);
|
||||||
|
|
||||||
|
// ─── uninstall ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerUninstallCommand(program);
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
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)
|
# --ref <branch> Git ref for framework archive (default: main)
|
||||||
# --yes Accept all defaults; headless/non-interactive install
|
# --yes Accept all defaults; headless/non-interactive install
|
||||||
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first 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:
|
# Environment:
|
||||||
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
|
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
|
||||||
@@ -41,6 +42,7 @@ FLAG_FRAMEWORK=true
|
|||||||
FLAG_CLI=true
|
FLAG_CLI=true
|
||||||
FLAG_NO_AUTO_LAUNCH=false
|
FLAG_NO_AUTO_LAUNCH=false
|
||||||
FLAG_YES=false
|
FLAG_YES=false
|
||||||
|
FLAG_UNINSTALL=false
|
||||||
GIT_REF="${MOSAIC_REF:-main}"
|
GIT_REF="${MOSAIC_REF:-main}"
|
||||||
|
|
||||||
# MOSAIC_ASSUME_YES env var acts the same as --yes
|
# 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 ;;
|
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
--yes|-y) FLAG_YES=true; shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||||
|
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -69,6 +72,109 @@ CLI_PKG="${SCOPE}/mosaic"
|
|||||||
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-stack"
|
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-stack"
|
||||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
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 ──────────────────────────────────────────────────────────────────
|
# ─── colours ──────────────────────────────────────────────────────────────────
|
||||||
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
|
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
|
||||||
R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET=""
|
R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET=""
|
||||||
@@ -358,6 +464,88 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
fi
|
fi
|
||||||
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 ""
|
echo ""
|
||||||
ok "Done."
|
ok "Done."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user