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).
400 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|