Files
stack/packages/mosaic/src/commands/store.spec.ts
T
fargo c23a71d7e3 feat(mosaic): vetted user store — mosaic store add|list (W-F4)
First command over the USER data root (~/.mosaic), per the HARNESS-HOMES
two-root split: ~/.config/mosaic is update-owned system space; ~/.mosaic is
user content that installs/updates never touch. The store is the vetting
boundary for plugins and skills.

- commands/store.ts: store add <kind> <name> <version> --from <dir> --by
  <operator> [--notes] — copies real directory content (symlinks refused,
  source must be outside the store) into <root>/<kind>s/<name>/<version>/
  and writes store-entry.json LAST, so a partial write can never list as a
  usable entry (a markerless dir is reclaimed with status
  recovered-partial; an existing marker makes add append-only-refusing).
  store list [--kind] [--name] — deterministic enumeration with typed
  statuses: vetted | incomplete | invalid-metadata | foreign (surfaced,
  never mutated).
- Name/version validated before any filesystem call; rich status enum over
  booleans; env seam MOSAIC_USER_HOME for tests — modelled on skill.ts,
  pointed at the user root instead of the system root.
- constants: DEFAULT_MOSAIC_USER_HOME. cli.ts: registration only.
- store.spec.ts: 45 tests — validation matrix, marker-last/append-only
  semantics, symlink refusal (source link and nested), self-copy guard,
  partial recovery, listing classification, CLI exit codes.

Gates (worktree, sb-it-1-dt): store spec 45/45; package build+typecheck+lint
green; package pnpm test vitest 87 files/1593 tests green — framework-shell
chain stops at invariant_r (host pi 0.84.2 vs recorded 0.84.1, inherited);
root build 25/25 + typecheck 45/45; prettier clean (diff-scanned).

Deferred to W-F6: activation/symlink-install into agent homes, version
pinning, network acquisition (add is local-path only, by design).
2026-08-17 13:09:24 -05:00

400 lines
14 KiB
TypeScript

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