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