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(); }); }); });