diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index f38e64a7..913f2ce7 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -20,6 +20,7 @@ import { registerMissionCommand } from './commands/mission.js'; import { registerUninstallCommand } from './commands/uninstall.js'; import { registerRestoreCommand } from './commands/restore.js'; import { registerSkillCommand } from './commands/skill.js'; +import { registerStoreCommand } from './commands/store.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js'; @@ -425,6 +426,10 @@ registerRestoreCommand(program); registerSkillCommand(program); +// ─── store ─────────────────────────────────────────────────────────────────── + +registerStoreCommand(program); + // ─── telemetry ─────────────────────────────────────────────────────────────── registerTelemetryCommand(program); diff --git a/packages/mosaic/src/commands/store.spec.ts b/packages/mosaic/src/commands/store.spec.ts new file mode 100644 index 00000000..91a005be --- /dev/null +++ b/packages/mosaic/src/commands/store.spec.ts @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Command } from 'commander'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + addStoreEntry, + getDefaultStorePaths, + listStoreEntries, + registerStoreCommand, + StoreError, + storeKindDir, + validateStoreKind, + validateStoreName, + validateStoreVersion, + type StorePaths, +} from './store.js'; + +/** Assert a typed StoreError with exactly the expected code. */ +function expectStoreError(run: () => unknown, code: string): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(StoreError); + expect((error as StoreError).code).toBe(code); + return; + } + throw new Error(`expected StoreError ${code}, but nothing threw`); +} + +describe('vetted user store (W-F4)', () => { + let root: string; + let paths: StorePaths; + let sourceRoot: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'mosaic-store-cli-')); + paths = { userRoot: join(root, '.mosaic') }; + sourceRoot = join(root, 'sources'); + mkdirSync(sourceRoot, { recursive: true }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function createSource(name: string): string { + const dir = join(sourceRoot, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`); + return dir; + } + + describe('name and version validation (before any filesystem call)', () => { + const invalidNames = [ + '../../etc', + '/abs/path', + 'a/b', + String.raw`a\b`, + '-rf', + '..', + 'safe.', + 'space name', + 'line\nbreak', + 'escape\u001B[31m', + ]; + + for (const name of invalidNames) { + it(`rejects name ${JSON.stringify(name)}`, () => { + expect(() => validateStoreName(name)).toThrow(StoreError); + }); + } + + const invalidVersions = ['', '-1', '1..0', 'a/b', '..', '1.0 beta', '/x']; + for (const version of invalidVersions) { + it(`rejects version ${JSON.stringify(version)}`, () => { + expect(() => validateStoreVersion(version)).toThrow(StoreError); + }); + } + + it('accepts semver-shaped versions including prerelease and build metadata', () => { + expect(() => validateStoreVersion('0.1.0-beta.1')).not.toThrow(); + expect(() => validateStoreVersion('1.2.3+build.7')).not.toThrow(); + }); + + it('rejects plural and unknown kinds', () => { + expectStoreError(() => validateStoreKind('plugins'), 'STORE_INVALID_KIND'); + expectStoreError(() => validateStoreKind('widget'), 'STORE_INVALID_KIND'); + }); + + it('accepts the two spec kinds', () => { + expect(() => validateStoreKind('plugin')).not.toThrow(); + expect(() => validateStoreKind('skill')).not.toThrow(); + }); + }); + + describe('addStoreEntry', () => { + it('copies content into a versioned directory and writes the marker last', () => { + const result = addStoreEntry( + 'skill', + 'demo', + '1.0.0', + createSource('demo'), + 'op', + undefined, + paths, + ); + expect(result.status).toBe('added'); + const entryPath = join(paths.userRoot, 'skills', 'demo', '1.0.0'); + expect(result.entryPath).toBe(entryPath); + expect(existsSync(join(entryPath, 'SKILL.md'))).toBe(true); + expect(existsSync(join(entryPath, 'store-entry.json'))).toBe(true); + const meta = JSON.parse(readFileSync(join(entryPath, 'store-entry.json'), 'utf-8')); + expect(meta).toMatchObject({ + schema: 1, + kind: 'skill', + name: 'demo', + version: '1.0.0', + vettedBy: 'op', + }); + expect(typeof meta['vettedAt']).toBe('string'); + }); + + it('writes plugins under plugins/ and skills under skills/', () => { + addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'op', undefined, paths); + addStoreEntry('skill', 'beta', '2.0.0', createSource('beta'), 'op', undefined, paths); + expect(existsSync(join(paths.userRoot, 'plugins', 'alpha', '0.1.0'))).toBe(true); + expect(existsSync(join(paths.userRoot, 'skills', 'beta', '2.0.0'))).toBe(true); + }); + + it('is append-only: an existing version with a marker is refused, not overwritten', () => { + const sourceA = createSource('demo'); + const sourceB = join(sourceRoot, 'demo-other'); + mkdirSync(sourceB, { recursive: true }); + writeFileSync(join(sourceB, 'SKILL.md'), '# changed\n'); + addStoreEntry('skill', 'demo', '1.0.0', sourceA, 'op', undefined, paths); + expectStoreError( + () => addStoreEntry('skill', 'demo', '1.0.0', sourceB, 'op', undefined, paths), + 'STORE_ALREADY_PRESENT', + ); + expect( + readFileSync(join(paths.userRoot, 'skills', 'demo', '1.0.0', 'SKILL.md'), 'utf-8'), + ).toBe('# demo\n'); + }); + + it('allows a second version alongside the first', () => { + addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths); + const result = addStoreEntry( + 'skill', + 'demo', + '1.1.0', + createSource('demo'), + 'op', + undefined, + paths, + ); + expect(result.status).toBe('added'); + expect(readdirSync(join(paths.userRoot, 'skills', 'demo')).sort()).toEqual([ + '1.0.0', + '1.1.0', + ]); + }); + + it('reclaims a partial write (directory without marker) and reports recovery', () => { + const partial = join(paths.userRoot, 'skills', 'demo', '1.0.0'); + mkdirSync(partial, { recursive: true }); + writeFileSync(join(partial, 'SKILL.md'), '# torn write\n'); + const result = addStoreEntry( + 'skill', + 'demo', + '1.0.0', + createSource('demo'), + 'op', + undefined, + paths, + ); + expect(result.status).toBe('recovered-partial'); + expect(readFileSync(join(partial, 'SKILL.md'), 'utf-8')).toBe('# demo\n'); + }); + + it('refuses a missing source with a typed error', () => { + expectStoreError( + () => + addStoreEntry('skill', 'demo', '1.0.0', join(sourceRoot, 'nope'), 'op', undefined, paths), + 'STORE_SOURCE_MISSING', + ); + }); + + it('refuses a file (non-directory) source with a typed error', () => { + const filePath = join(sourceRoot, 'file.txt'); + writeFileSync(filePath, 'x'); + expectStoreError( + () => addStoreEntry('skill', 'demo', '1.0.0', filePath, 'op', undefined, paths), + 'STORE_SOURCE_NOT_DIR', + ); + }); + + it('refuses a symlinked source with a typed error and writes nothing', () => { + const real = createSource('demo'); + const link = join(sourceRoot, 'demo-link'); + symlinkSync(real, link); + expectStoreError( + () => addStoreEntry('skill', 'demo', '1.0.0', link, 'op', undefined, paths), + 'STORE_SOURCE_SYMLINK', + ); + expect(existsSync(join(paths.userRoot, 'skills', 'demo'))).toBe(false); + }); + + it('refuses a source tree containing nested symlinks and writes nothing', () => { + const src = createSource('demo'); + const target = join(sourceRoot, 'elsewhere'); + mkdirSync(target, { recursive: true }); + symlinkSync(target, join(src, 'escape')); + expectStoreError( + () => addStoreEntry('skill', 'demo', '1.0.0', src, 'op', undefined, paths), + 'STORE_SOURCE_SYMLINK', + ); + expect(existsSync(join(paths.userRoot, 'skills', 'demo'))).toBe(false); + }); + + it('refuses adding from inside the store itself', () => { + const first = addStoreEntry( + 'skill', + 'demo', + '1.0.0', + createSource('demo'), + 'op', + undefined, + paths, + ); + expectStoreError( + () => addStoreEntry('skill', 'copy', '1.0.0', first.entryPath, 'op', undefined, paths), + 'STORE_SOURCE_INSIDE_STORE', + ); + }); + + it('refuses a symlinked user root ancestor', () => { + const linkedRoot = join(sourceRoot, 'linked-mosaic'); + symlinkSync(paths.userRoot, linkedRoot); + expectStoreError( + () => + addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, { + userRoot: linkedRoot, + }), + 'STORE_SYMLINK_ROOT', + ); + }); + + it('requires a non-empty vetting attribution', () => { + expectStoreError( + () => addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), ' ', undefined, paths), + 'STORE_INVALID_VETTER', + ); + }); + }); + + describe('listStoreEntries', () => { + it('returns empty for an absent store without creating it', () => { + expect(listStoreEntries(paths)).toEqual([]); + expect(existsSync(paths.userRoot)).toBe(false); + }); + + it('lists entries deterministically with vetting metadata', () => { + addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'fred', undefined, paths); + addStoreEntry('skill', 'beta', '2.0.0', createSource('beta'), 'fargo', 'looked fine', paths); + addStoreEntry('skill', 'beta', '2.1.0', createSource('beta'), 'fargo', undefined, paths); + + const entries = listStoreEntries(paths); + expect(entries.map((e) => `${e.kind}:${e.name}:${e.version}`)).toEqual([ + 'plugin:alpha:0.1.0', + 'skill:beta:2.0.0', + 'skill:beta:2.1.0', + ]); + expect(entries[0]?.meta?.vettedBy).toBe('fred'); + expect(entries[1]?.meta?.notes).toBe('looked fine'); + }); + + it('classifies markerless version directories as incomplete', () => { + addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths); + mkdirSync(join(paths.userRoot, 'skills', 'demo', '2.0.0'), { recursive: true }); + const entries = listStoreEntries(paths, { kind: 'skill', name: 'demo' }); + expect(entries.find((e) => e.version === '1.0.0')?.status).toBe('vetted'); + expect(entries.find((e) => e.version === '2.0.0')?.status).toBe('incomplete'); + }); + + it('classifies malformed marker JSON as invalid-metadata, not vetted', () => { + addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths); + writeFileSync( + join(paths.userRoot, 'skills', 'demo', '1.0.0', 'store-entry.json'), + '{not json', + ); + const entries = listStoreEntries(paths); + expect(entries[0]?.status).toBe('invalid-metadata'); + }); + + it('surfaces foreign files (never mutates them)', () => { + mkdirSync(join(paths.userRoot, 'skills'), { recursive: true }); + writeFileSync(join(paths.userRoot, 'skills', 'stray.txt'), 'x'); + const entries = listStoreEntries(paths); + expect(entries[0]?.status).toBe('foreign'); + expect(existsSync(join(paths.userRoot, 'skills', 'stray.txt'))).toBe(true); + }); + + it('filters by kind and name', () => { + addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'op', undefined, paths); + addStoreEntry('skill', 'beta', '1.0.0', createSource('beta'), 'op', undefined, paths); + expect(listStoreEntries(paths, { kind: 'plugin' }).map((e) => e.name)).toEqual(['alpha']); + expect(listStoreEntries(paths, { name: 'beta' }).map((e) => e.name)).toEqual(['beta']); + expect(() => listStoreEntries(paths, { name: '../escape' })).toThrow(StoreError); + }); + }); + + describe('default paths seam', () => { + it('honors MOSAIC_USER_HOME', () => { + const previous = process.env['MOSAIC_USER_HOME']; + try { + process.env['MOSAIC_USER_HOME'] = join(root, 'custom-user-home'); + expect(getDefaultStorePaths().userRoot).toBe(join(root, 'custom-user-home')); + expect(storeKindDir('plugin')).toBe(join(root, 'custom-user-home', 'plugins')); + } finally { + if (previous === undefined) delete process.env['MOSAIC_USER_HOME']; + else process.env['MOSAIC_USER_HOME'] = previous; + } + }); + }); + + describe('CLI', () => { + let previousExitCode: string | number | null | undefined; + + beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = undefined; + }); + + afterEach(() => { + process.exitCode = previousExitCode; + }); + + const parse = (args: string[]) => { + const program = new Command().exitOverride(); + registerStoreCommand(program, paths); + return program.parseAsync(['node', 'mosaic', 'store', ...args]); + }; + + it('registers on the parent program and renders help', () => { + const program = new Command().exitOverride(); + registerStoreCommand(program, paths); + const cmd = program.commands.find((c) => c.name() === 'store'); + expect(cmd).toBeDefined(); + expect(() => cmd?.helpInformation()).not.toThrow(); + }); + + it('add exits nonzero with a typed code for an invalid name', async () => { + await parse([ + 'add', + 'skill', + '../../etc', + '1.0.0', + '--from', + createSource('x'), + '--by', + 'op', + ]); + expect(process.exitCode).toBe(1); + }); + + it('add exits nonzero when the kind is plural', async () => { + await parse(['add', 'skills', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']); + expect(process.exitCode).toBe(1); + }); + + it('add succeeds and creates the entry directory', async () => { + await parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']); + expect(process.exitCode).toBeUndefined(); + expect(lstatSync(join(paths.userRoot, 'skills', 'demo', '1.0.0')).isDirectory()).toBe(true); + }); + + it('add requires --by (commander requiredOption)', async () => { + await expect( + parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo')]), + ).rejects.toThrow(/--by/); + }); + + it('list exits 0 on an empty store', async () => { + await parse(['list']); + expect(process.exitCode).toBeUndefined(); + }); + }); +}); diff --git a/packages/mosaic/src/commands/store.ts b/packages/mosaic/src/commands/store.ts new file mode 100644 index 00000000..6227e1f2 --- /dev/null +++ b/packages/mosaic/src/commands/store.ts @@ -0,0 +1,518 @@ +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 a partial write, never a + * usable entry. + * + * 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' | 'recovered-partial'; + +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. + */ +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(), +): 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.`, + ); + } + // Partial write (no marker): safe to reclaim. + rmSync(target, { recursive: true, force: true }); + status = 'recovered-partial'; + } + + 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') + .action( + async ( + kind: string, + name: string, + version: string, + opts: { + from: string; + by: string; + notes?: string; + }, + ) => { + try { + const result = addStoreEntry( + kind as StoreKind, + name, + version, + opts.from, + opts.by, + opts.notes, + paths, + ); + const suffix = result.status === 'recovered-partial' ? ' (recovered partial write)' : ''; + 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); + } + }); +} diff --git a/packages/mosaic/src/constants.ts b/packages/mosaic/src/constants.ts index cad29340..400989b3 100644 --- a/packages/mosaic/src/constants.ts +++ b/packages/mosaic/src/constants.ts @@ -5,6 +5,13 @@ export const VERSION = '0.0.2'; export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic'); +/** + * USER data root (HARNESS-HOMES two-root split): everything under here is user + * content — never replaced or removed by installs, updates, or uninstallers. + * Distinct from the SYSTEM root above, which is update-owned. + */ +export const DEFAULT_MOSAIC_USER_HOME = join(homedir(), '.mosaic'); + export const DEFAULTS = { agentName: 'Assistant', roleDescription: 'execution partner and visibility engine',