164 lines
5.1 KiB
TypeScript
164 lines
5.1 KiB
TypeScript
/**
|
|
* 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/';
|