import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, type Dirent, type Stats, } from 'node:fs'; import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; import type { Command } from 'commander'; import { DEFAULT_MOSAIC_USER_HOME } from '../constants.js'; /** * `mosaic store` — the vetted user store under `~/.mosaic/{plugins,skills}` (W-F4). * * Two roots with distinct ownership (HARNESS-HOMES design, frozen REV3): * - `~/.config/mosaic/` is the SYSTEM root: update-owned, replaceable wholesale. * - `~/.mosaic/` is the USER root: never touched by installs or updates. * * This module only ever writes under the USER root. The store is the vetting * boundary: content lands here only through an explicit `store add` carrying a * named vetting attribution, and every entry is versioned * (`store/s///`) with a `store-entry.json` marker written * LAST — a version directory without its marker is never a usable entry, and * an unmarked target is REFUSED by default: it may be this tool's own debris * from an interrupted add, or content the operator placed by hand, and the * code cannot tell those apart — so deletion happens only under an explicit * `--reclaim` opt-in, and the result status names what was done. * * Deferred by design (W-F6 and later): activation/symlink-install into agent * homes, `current`-pointer pinning, network acquisition. `add` accepts a local * source path only — no network, no credentials, ever. */ export type StoreKind = 'plugin' | 'skill'; export const STORE_KINDS: readonly StoreKind[] = ['plugin', 'skill']; /** On-disk metadata marker; written last so its presence commits an entry. */ export const STORE_ENTRY_MARKER = 'store-entry.json'; export interface StorePaths { /** User data root, e.g. `~/.mosaic`. */ userRoot: string; } export interface StoreEntryMeta { schema: 1; kind: StoreKind; name: string; version: string; /** Absolute source path the content was vetted from, as resolved at add time. */ sourcePath: string; /** Operator who vouched for the content — required, non-empty. */ vettedBy: string; /** ISO timestamp of the add. */ vettedAt: string; /** Free-form vetting notes, if any. */ notes?: string; } export type StoreAddStatus = 'added' | 'reclaimed-unmarked'; export interface StoreAddResult { kind: StoreKind; name: string; version: string; status: StoreAddStatus; entryPath: string; sourcePath: string; } export type StoreEntryStatus = 'vetted' | 'incomplete' | 'invalid-metadata' | 'foreign'; export interface StoreListEntry { kind: StoreKind; name: string; /** Undefined for name-level foreign files (not a directory at all). */ version?: string; status: StoreEntryStatus; entryPath: string; meta?: StoreEntryMeta; } const SAFE_STORE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const SAFE_STORE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/; export class StoreError extends Error { public readonly code: string; public constructor(code: string, message: string) { super(message); this.name = 'StoreError'; this.code = code; } } /** Resolve the user store root while keeping tests injectable. */ export function getDefaultStorePaths(): StorePaths { const userRoot = process.env['MOSAIC_USER_HOME'] ?? DEFAULT_MOSAIC_USER_HOME; return { userRoot }; } /** * Reject a user-supplied name before any filesystem operation. * A store name identifies one directory under `store/s/`. */ export function validateStoreName(name: string): void { if ( name.length === 0 || name.startsWith('-') || name.endsWith('.') || name.includes('..') || name.includes('/') || name.includes('\\') || isAbsolute(name) || !SAFE_STORE_NAME.test(name) ) { throw new StoreError( 'STORE_INVALID_NAME', `Invalid store name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`, ); } } /** Versions share the name discipline plus `+` (semver build metadata). */ export function validateStoreVersion(version: string): void { if ( version.length === 0 || version.startsWith('-') || version.endsWith('.') || version.includes('..') || version.includes('/') || version.includes('\\') || isAbsolute(version) || !SAFE_STORE_VERSION.test(version) ) { throw new StoreError( 'STORE_INVALID_VERSION', `Invalid version ${JSON.stringify(version)}: use letters, numbers, dots, underscores, hyphens, or plus; start with a letter or number; and do not use paths, "..", or a leading "-".`, ); } } export function validateStoreKind(kind: string): asserts kind is StoreKind { if (!(STORE_KINDS as readonly string[]).includes(kind)) { throw new StoreError( 'STORE_INVALID_KIND', `Invalid store kind ${JSON.stringify(kind)}: expected one of ${STORE_KINDS.map((k) => `"${k}"`).join(', ')}.`, ); } } function validateVettedBy(vettedBy: string): void { if (vettedBy.trim().length === 0 || vettedBy.includes('\n') || vettedBy.length > 80) { throw new StoreError( 'STORE_INVALID_VETTER', 'Invalid --by value: name the operator vouching for this content (single line, at most 80 characters).', ); } } function lstatIfPresent(path: string): Stats | undefined { try { return lstatSync(path); } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined; throw error; } } function assertNoSymlinkAncestors(path: string): void { const absolute = resolve(path); const pathRoot = parse(absolute).root; let current = pathRoot; for (const segment of relative(pathRoot, absolute).split(sep)) { if (segment.length === 0) continue; current = join(current, segment); const entry = lstatIfPresent(current); if (!entry) break; if (entry.isSymbolicLink()) { throw new StoreError( 'STORE_SYMLINK_ROOT', `Refusing symlink ancestor at ${current}; the user store root must resolve without symlink traversal.`, ); } } } /** `plugins` for plugin, `skills` for skill — plural on disk per the layout. */ function kindDirName(kind: StoreKind): string { return kind === 'plugin' ? 'plugins' : 'skills'; } export function storeKindDir(kind: StoreKind, paths: StorePaths = getDefaultStorePaths()): string { return join(paths.userRoot, kindDirName(kind)); } function entryDir(kind: StoreKind, name: string, version: string, paths: StorePaths): string { return join(storeKindDir(kind, paths), name, version); } function isInsideRoot(candidate: string, root: string): boolean { const rel = relative(resolve(root), resolve(candidate)); return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); } /** * Refuse any symlink in the source tree — the vetting boundary copies real * content only, so a vetted entry can never carry a link that escapes it. * * NOTE: known check-then-use window between this walk and the `cpSync` below: * a symlink created concurrently with the add could slip through. Accepted * for a local, operator-run CLI; revisit before any unattended or networked * acquisition path exists. */ function assertSourceTreeHasNoSymlinks(sourcePath: string): void { const stack: string[] = [sourcePath]; while (stack.length > 0) { const current = stack.pop()!; for (const dirent of readdirSync(current, { withFileTypes: true })) { const child = join(current, dirent.name); if (dirent.isSymbolicLink()) { throw new StoreError( 'STORE_SOURCE_SYMLINK', `Refusing to vet content containing a symlink: ${child}. Resolve or remove symlinks before adding to the store.`, ); } if (dirent.isDirectory()) stack.push(child); } } } /** * Vet and add one versioned entry to the user store. * * Copies the source directory (real content, no symlinks) to * `/s///` and writes the `store-entry.json` * marker LAST: a crash mid-copy leaves at most a recoverable partial, never a * half-vetted entry that lists as present. */ export function addStoreEntry( kind: StoreKind, name: string, version: string, sourcePath: string, vettedBy: string, notes: string | undefined, paths: StorePaths = getDefaultStorePaths(), options: { reclaim?: boolean } = {}, ): StoreAddResult { validateStoreKind(kind); validateStoreName(name); validateStoreVersion(version); validateVettedBy(vettedBy); assertNoSymlinkAncestors(paths.userRoot); const resolvedSource = resolve(sourcePath); const source = lstatIfPresent(resolvedSource); if (!source) { throw new StoreError('STORE_SOURCE_MISSING', `Source path does not exist: ${resolvedSource}`); } if (source.isSymbolicLink()) { throw new StoreError( 'STORE_SOURCE_SYMLINK', `Refusing to vet a symlink as store content: ${resolvedSource} (points at ${resolve(sourcePath)}). Add the real directory.`, ); } if (!source.isDirectory()) { throw new StoreError( 'STORE_SOURCE_NOT_DIR', `Source path is not a directory: ${resolvedSource}`, ); } if (isInsideRoot(resolvedSource, paths.userRoot)) { throw new StoreError( 'STORE_SOURCE_INSIDE_STORE', `Refusing to add store content from inside the store itself: ${resolvedSource}`, ); } assertSourceTreeHasNoSymlinks(resolvedSource); const target = entryDir(kind, name, version, paths); const existing = lstatIfPresent(target); let status: StoreAddStatus = 'added'; if (existing) { if (existsSync(join(target, STORE_ENTRY_MARKER))) { throw new StoreError( 'STORE_ALREADY_PRESENT', `${kind} "${name}" version "${version}" is already present at ${target}; stores are append-only — add a new version instead.`, ); } // Unmarked target: either this tool's own debris from an interrupted add, // or content the operator placed by hand — indistinguishable on disk. The // USER root's contract is that tooling never destroys operator content, // so deletion requires the explicit --reclaim opt-in (review finding on // c23a71d7: silent rmSync under a benign-sounding status). if (!options.reclaim) { throw new StoreError( 'STORE_TARGET_UNMARKED', `Target exists without ${STORE_ENTRY_MARKER}: ${target}. Refusing to delete unmarked content — if this is debris from an interrupted add, re-run with --reclaim to replace it.`, ); } rmSync(target, { recursive: true, force: true }); status = 'reclaimed-unmarked'; } mkdirSync(target, { recursive: true }); cpSync(resolvedSource, target, { recursive: true }); const meta: StoreEntryMeta = { schema: 1, kind, name, version, sourcePath: resolvedSource, vettedBy: vettedBy.trim(), vettedAt: new Date().toISOString(), ...(notes === undefined ? {} : { notes }), }; writeFileSync(join(target, STORE_ENTRY_MARKER), `${JSON.stringify(meta, null, 2)}\n`); return { kind, name, version, status, entryPath: target, sourcePath: resolvedSource }; } function readEntryMeta(markerPath: string): { meta?: StoreEntryMeta; status: StoreEntryStatus } { let raw: string; try { raw = readFileSync(markerPath, 'utf-8'); } catch { return { status: 'invalid-metadata' }; } try { const parsed = JSON.parse(raw) as StoreEntryMeta; if ( parsed?.schema === 1 && (STORE_KINDS as readonly string[]).includes(parsed.kind) && typeof parsed.name === 'string' && typeof parsed.version === 'string' && typeof parsed.vettedBy === 'string' && typeof parsed.vettedAt === 'string' ) { return { meta: parsed, status: 'vetted' }; } } catch { // fall through } return { status: 'invalid-metadata' }; } /** * Enumerate every store entry deterministically (kind, then name, then * version). Version directories without a marker list as `incomplete`; files * where directories were expected list as `foreign` — surfaced, never mutated. */ export function listStoreEntries( paths: StorePaths = getDefaultStorePaths(), filter: { kind?: StoreKind; name?: string } = {}, ): StoreListEntry[] { if (filter.name !== undefined) validateStoreName(filter.name); assertNoSymlinkAncestors(paths.userRoot); const kinds = filter.kind ? [filter.kind] : [...STORE_KINDS]; const entries: StoreListEntry[] = []; for (const kind of kinds) { const kindRoot = lstatIfPresent(storeKindDir(kind, paths)); if (!kindRoot) continue; if (!kindRoot.isDirectory()) { entries.push({ kind, name: kindDirName(kind), status: 'foreign', entryPath: storeKindDir(kind, paths), }); continue; } for (const nameDirent of readdirSync(storeKindDir(kind, paths), { withFileTypes: true, }).sort(byName) as Dirent[]) { if (filter.name !== undefined && nameDirent.name !== filter.name) continue; const namePath = join(storeKindDir(kind, paths), nameDirent.name); if (!nameDirent.isDirectory()) { entries.push({ kind, name: nameDirent.name, status: 'foreign', entryPath: namePath }); continue; } const versionDirents = readdirSync(namePath, { withFileTypes: true }).sort(byName); if (versionDirents.length === 0) { entries.push({ kind, name: nameDirent.name, status: 'incomplete', entryPath: namePath }); continue; } for (const versionDirent of versionDirents) { const versionPath = join(namePath, versionDirent.name); if (!versionDirent.isDirectory()) { entries.push({ kind, name: nameDirent.name, version: versionDirent.name, status: 'foreign', entryPath: versionPath, }); continue; } const markerPath = join(versionPath, STORE_ENTRY_MARKER); if (!existsSync(markerPath)) { entries.push({ kind, name: nameDirent.name, version: versionDirent.name, status: 'incomplete', entryPath: versionPath, }); continue; } const { meta, status } = readEntryMeta(markerPath); entries.push({ kind, name: nameDirent.name, version: versionDirent.name, status, entryPath: versionPath, ...(meta === undefined ? {} : { meta }), }); } } } return entries; } function byName(a: Dirent, b: Dirent): number { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; } function reportCommandError(error: unknown): void { if (error instanceof StoreError) { console.error(`store: ${error.code}: ${error.message}`); } else { console.error(error instanceof Error ? error.message : String(error)); } process.exitCode = 1; } function displayStoreName(name: string): string { return SAFE_STORE_NAME.test(name) ? name : JSON.stringify(name); } /** Register the `mosaic store` command group (W-F4). */ export function registerStoreCommand( program: Command, paths: StorePaths = getDefaultStorePaths(), ): void { const store = program .command('store') .description('Manage the vetted user store under ~/.mosaic (plugins, skills)') .configureHelp({ sortSubcommands: true }); store .command('add ') .description( 'Vet and add a local plugin/skill directory to the user store (versioned, append-only)', ) .requiredOption('--from ', 'Local source directory to vet (no network acquisition)') .requiredOption('--by ', 'Name of the operator vouching for this content') .option('--notes ', 'Vetting notes recorded in the entry metadata') .option( '--reclaim', 'Replace an existing UNMARKED target directory (e.g. debris from an interrupted add); refuses without this flag', ) .action( async ( kind: string, name: string, version: string, opts: { from: string; by: string; notes?: string; reclaim: boolean; }, ) => { try { const result = addStoreEntry( kind as StoreKind, name, version, opts.from, opts.by, opts.notes, paths, { reclaim: opts.reclaim }, ); const suffix = result.status === 'reclaimed-unmarked' ? ' (replaced unmarked directory)' : ''; console.log( `${result.kind} ${displayStoreName(result.name)} ${result.version}: added${suffix}`, ); console.log(` entry: ${result.entryPath}`); console.log(` vetted by ${opts.by.trim()}`); } catch (error: unknown) { reportCommandError(error); } }, ); store .command('list') .description('List store entries with vetting status') .option('--kind ', 'Filter by kind (plugin | skill)') .option('--name ', 'Filter by entry name') .action((opts: { kind?: string; name?: string }) => { try { let kind: StoreKind | undefined; if (opts.kind !== undefined) { validateStoreKind(opts.kind); kind = opts.kind; } const entries = listStoreEntries(paths, { ...(kind === undefined ? {} : { kind }), ...(opts.name === undefined ? {} : { name: opts.name }), }); if (entries.length === 0) { console.log('No store entries found.'); return; } for (const entry of entries) { const version = entry.version ?? '-'; const vetter = entry.meta?.vettedBy ?? '-'; console.log( `${entry.status.padEnd(17)}${entry.kind.padEnd(8)}${displayStoreName(entry.name).padEnd(24)}${version.padEnd(16)}${vetter}`, ); } } catch (error: unknown) { reportCommandError(error); } }); }