/** * install-manifest.ts * * Read/write helpers for ~/.config/mosaic/.install-manifest.json * * The manifest is the authoritative record of what the installer mutated on the * host system so that `mosaic uninstall` can precisely reverse every change. * If the manifest is absent the uninstaller falls back to heuristic mode and * warns the user. */ import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; export const MANIFEST_FILENAME = '.install-manifest.json'; export const MANIFEST_VERSION = 1; /** A single runtime asset copy recorded during install. */ export interface RuntimeAssetCopy { /** Absolute path to the source file in MOSAIC_HOME (or the npm package). */ source: string; /** Absolute path to the destination on the host. */ dest: string; /** * Absolute path to the backup that was created when an existing file was * displaced. Undefined when no pre-existing file was found. */ backup?: string; } /** The full shape of the install manifest (version 1). */ export interface InstallManifest { version: 1; /** ISO-8601 timestamp of when the install completed. */ installedAt: string; /** Version of @mosaicstack/mosaic that was installed. */ cliVersion: string; /** Framework schema version (integer) that was installed. */ frameworkVersion: number; mutations: { /** Directories that were created by the installer. */ directories: string[]; /** npm global packages that were installed. */ npmGlobalPackages: string[]; /** * Exact lines that were appended to ~/.npmrc. * Each entry is the full line text (no trailing newline). */ npmrcLines: string[]; /** * Shell profile edits — each entry is an object recording which file was * edited and what line was appended. */ shellProfileEdits: Array<{ file: string; line: string }>; /** Runtime asset copies performed by mosaic-link-runtime-assets. */ runtimeAssetCopies: RuntimeAssetCopy[]; }; } /** Default empty mutations block. */ function emptyMutations(): InstallManifest['mutations'] { return { directories: [], npmGlobalPackages: [], npmrcLines: [], shellProfileEdits: [], runtimeAssetCopies: [], }; } /** * Build a new manifest with sensible defaults. * Callers fill in the mutation fields before persisting. */ export function createManifest( cliVersion: string, frameworkVersion: number, partial?: Partial, ): InstallManifest { return { version: MANIFEST_VERSION, installedAt: new Date().toISOString(), cliVersion, frameworkVersion, mutations: { ...emptyMutations(), ...partial }, }; } /** * Return the absolute path to the manifest file. */ export function manifestPath(mosaicHome: string): string { return join(mosaicHome, MANIFEST_FILENAME); } /** * Read the manifest from disk. * Returns `undefined` if the file does not exist or cannot be parsed. * Never throws — callers decide how to handle heuristic-fallback mode. */ export function readManifest(mosaicHome: string): InstallManifest | undefined { const p = manifestPath(mosaicHome); if (!existsSync(p)) return undefined; try { const raw = readFileSync(p, 'utf8'); const parsed: unknown = JSON.parse(raw); if (!isValidManifest(parsed)) return undefined; return parsed; } catch { return undefined; } } /** * Persist the manifest to disk with mode 0600 (owner read/write only). * Creates the mosaicHome directory if it does not exist. */ export function writeManifest(mosaicHome: string, manifest: InstallManifest): void { const p = manifestPath(mosaicHome); const json = JSON.stringify(manifest, null, 2) + '\n'; writeFileSync(p, json, { encoding: 'utf8' }); try { chmodSync(p, 0o600); } catch { // chmod may fail on some systems (e.g. Windows); non-fatal } } /** * Narrow an unknown value to InstallManifest. * Only checks the minimum structure; does not validate every field. */ function isValidManifest(v: unknown): v is InstallManifest { if (typeof v !== 'object' || v === null) return false; const m = v as Record; if (m['version'] !== 1) return false; if (typeof m['installedAt'] !== 'string') return false; if (typeof m['cliVersion'] !== 'string') return false; if (typeof m['frameworkVersion'] !== 'number') return false; if (typeof m['mutations'] !== 'object' || m['mutations'] === null) return false; return true; } /** * The known set of runtime asset destinations managed by * mosaic-link-runtime-assets / framework/install.sh. * * Used by heuristic mode when no manifest is available. */ export function heuristicRuntimeAssetDests(homeDir: string): string[] { return [ join(homeDir, '.claude', 'CLAUDE.md'), join(homeDir, '.claude', 'settings.json'), join(homeDir, '.claude', 'hooks-config.json'), join(homeDir, '.claude', 'context7-integration.md'), join(homeDir, '.config', 'opencode', 'AGENTS.md'), join(homeDir, '.codex', 'instructions.md'), ]; } /** The npmrc scope line added by tools/install.sh. */ export const DEFAULT_SCOPE_LINE = '@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';