/** * Registry read/write operations for the marketplace plugin system. * * Three registries: * - marketplaces.json at getMarketplacesRegistryPath() — which catalogs the user has added * - installed_plugins.json under getPluginsDir() — which plugins are installed * - project_registries.json next to it — which project installed_plugins.json files * link into the shared user plugin cache * * Read/write functions accept explicit file paths so callers control the * location. Path helpers compute the default paths from the dir singleton. * * Both use atomic write (tmp + rename). On Windows, rename over existing file * can fail with EPERM — fallback: unlink target then rename. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getPluginsDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils"; export { getMarketplacesRegistryPath } from "@oh-my-pi/pi-utils"; import type { InstalledPluginEntry, InstalledPluginsRegistry, MarketplaceRegistryEntry, MarketplacesRegistry, } from "./types"; import { nameSegmentCollisionKey } from "./types"; export function getInstalledPluginsRegistryPath(): string { return path.join(getPluginsDir(), "installed_plugins.json"); } export function getMarketplacesCacheDir(): string { return path.join(getPluginsDir(), "cache", "marketplaces"); } export function getPluginsCacheDir(): string { return path.join(getPluginsDir(), "cache", "plugins"); } // ── Atomic write ───────────────────────────────────────────────────── async function atomicWriteJson(filePath: string, data: unknown): Promise { const content = `${JSON.stringify(data, null, 2)}\n`; const tmpPath = `${filePath}.tmp`; await Bun.write(tmpPath, content); try { await fs.rename(tmpPath, filePath); } catch (err) { // Windows EPERM fallback: unlink target, then rename if ((err as NodeJS.ErrnoException).code === "EPERM") { try { await fs.unlink(filePath); } catch { // Target may not exist — that's fine } await fs.rename(tmpPath, filePath); } else { // Clean up tmp on unexpected errors try { await fs.unlink(tmpPath); } catch { // Best effort } throw err; } } } // ── Marketplaces registry ──────────────────────────────────────────── function emptyMarketplacesRegistry(): MarketplacesRegistry { return { version: 1, marketplaces: [] }; } export async function readMarketplacesRegistry(filePath: string): Promise { try { const content = await Bun.file(filePath).text(); const data = tryParseJson(content); if (!data || typeof data !== "object" || data.version !== 1 || !Array.isArray(data.marketplaces)) { logger.warn("Invalid marketplaces registry, returning empty", { path: filePath }); return emptyMarketplacesRegistry(); } return data; } catch (err) { if (isEnoent(err)) return emptyMarketplacesRegistry(); throw err; } } export async function writeMarketplacesRegistry(filePath: string, reg: MarketplacesRegistry): Promise { await atomicWriteJson(filePath, reg); } // ── Installed plugins registry ─────────────────────────────────────── function emptyInstalledPluginsRegistry(): InstalledPluginsRegistry { return { version: 2, plugins: {} }; } export async function readInstalledPluginsRegistry(filePath: string): Promise { try { const content = await Bun.file(filePath).text(); const data = tryParseJson(content); if ( !data || typeof data !== "object" || typeof data.version !== "number" || !data.plugins || typeof data.plugins !== "object" || Array.isArray(data.plugins) ) { logger.warn("Invalid installed plugins registry, returning empty", { path: filePath }); return emptyInstalledPluginsRegistry(); } // Accept any numeric version — forward compatible reads return { ...data, version: 2 }; } catch (err) { if (isEnoent(err)) return emptyInstalledPluginsRegistry(); throw err; } } export async function writeInstalledPluginsRegistry(filePath: string, reg: InstalledPluginsRegistry): Promise { await atomicWriteJson(filePath, reg); } // ── Project registries index ───────────────────────────────────────── /** * Path of the user-level index of project `installed_plugins.json` files. * Project-scope installs link into the shared user plugin cache, so cache * cleanup must consult every project registry, not only the cwd one. */ export function getProjectRegistriesIndexPath(userRegistryPath: string): string { return path.join(path.dirname(userRegistryPath), "project_registries.json"); } /** Reads the indexed project registry paths; a missing or malformed index reads as empty. */ export async function readProjectRegistriesIndex(filePath: string): Promise { try { const data = tryParseJson<{ registries?: unknown }>(await Bun.file(filePath).text()); const registries = data?.registries; if (!Array.isArray(registries)) { logger.warn("Invalid project registries index, returning empty", { path: filePath }); return []; } return registries.filter((r): r is string => typeof r === "string"); } catch (err) { if (isEnoent(err)) return []; throw err; } } export async function writeProjectRegistriesIndex(filePath: string, registries: readonly string[]): Promise { await atomicWriteJson(filePath, { version: 1, registries }); } // ── Marketplace CRUD ───────────────────────────────────────────────── // Pure functions that transform registry state. Caller is responsible for // reading, mutating, and writing back. export function addMarketplaceEntry(reg: MarketplacesRegistry, entry: MarketplaceRegistryEntry): MarketplacesRegistry { const key = nameSegmentCollisionKey(entry.name); const existing = reg.marketplaces.find(m => nameSegmentCollisionKey(m.name) === key); if (existing) { if (existing.name === entry.name) { throw new Error(`Marketplace "${entry.name}" already exists`); } throw new Error( `Marketplace "${entry.name}" conflicts with existing marketplace "${existing.name}" on case-insensitive filesystems`, ); } return { ...reg, marketplaces: [...reg.marketplaces, entry] }; } export function removeMarketplaceEntry(reg: MarketplacesRegistry, name: string): MarketplacesRegistry { const filtered = reg.marketplaces.filter(m => m.name !== name); if (filtered.length === reg.marketplaces.length) { throw new Error(`Marketplace "${name}" not found`); } return { ...reg, marketplaces: filtered }; } export function getMarketplaceEntry(reg: MarketplacesRegistry, name: string): MarketplaceRegistryEntry | undefined { return reg.marketplaces.find(m => m.name === name); } // ── Installed plugin CRUD ──────────────────────────────────────────── export function addInstalledPlugin( reg: InstalledPluginsRegistry, id: string, entry: InstalledPluginEntry, ): InstalledPluginsRegistry { const existing = reg.plugins[id] ?? []; return { ...reg, plugins: { ...reg.plugins, [id]: [...existing, entry] }, }; } export function removeInstalledPlugin(reg: InstalledPluginsRegistry, id: string): InstalledPluginsRegistry { if (!(id in reg.plugins)) { throw new Error(`Plugin "${id}" not found in registry`); } const { [id]: _, ...rest } = reg.plugins; return { ...reg, plugins: rest }; } export function getInstalledPlugin(reg: InstalledPluginsRegistry, id: string): InstalledPluginEntry[] | undefined { return reg.plugins[id]; } /** * Collect all installPath values referenced by any of the provided registries. * Use this before deleting a cached plugin directory to verify it is not still * referenced by another scope's registry. */ export function collectReferencedPaths(...registries: InstalledPluginsRegistry[]): Set { return new Set( registries.flatMap(r => Object.values(r.plugins) .flat() .map(e => e.installPath), ), ); }