W-F4/W-F6: mosaic store + mosaic fleet plugin

Two commands, and the seam between them is the point. `mosaic store` admits a
reviewed directory into ~/.mosaic/{plugins,skills} and entitles nobody.
`mosaic fleet plugin enable` writes one entry into one seat's profile.json and
copies nothing. A plugin reaching a seat therefore takes two deliberate acts,
and neither happens as a side effect of the other -- the same split adoption.ts
already documents.

Entries are `<name>@<version>` as one path segment, and admission has no flag to
skip the version. HARNESS-HOMES triage #8 specified `store/<name>/<version>`
with symlink pinning; that layout is unreachable from a profile as launch is
built today. `resolveManagedLinks` in fleet-launch-command.ts resolves a profile
entry as a single path segment and refuses a symlink there, and its STORE_ENTRY
charset admits `@` while rejecting `/`. Proven against the built CLI, not
assumed: a dry-run launch of a seat with [email protected] enabled plans
  .../agents/smoke/.claude/plugins/[email protected] -> .../.mosaic/plugins/[email protected]
This discrepancy is reported to the design owner rather than settled here.

Admission is `--as <name>@<version>`, not `--name` + `--version`. The first cut
used `--version` and a unit test could not see the problem: Commander's own
--version is on the program, so `mosaic store add … --version 1.2.0` printed the
CLI version and admitted nothing. Only an end-to-end run of the built binary
caught it. The regression test now registers under a program that sets a
version, which is the shape that fails.

`--live` is refused rather than accepted-and-ignored. An operator who asks for a
live change and gets a success message would reasonably believe the seat changed.

Not addressed here: launch composes `enabledPlugins` from the settings layers
only, never from profile.plugins, so an entitled plugin is linked into the seat
but not listed there. Reported separately.

Tests: 48 new (16 store module, 11 store command, 21 fleet plugin). Suite 1753
passed / 4 failed, the 4 being the mutator-gate acceptance failures already red
on origin/main. Root build 25/25.
This commit is contained in:
Jason Woltje
2026-08-15 10:48:05 -05:00
parent bac0a697b8
commit 1af7ae4b7f
9 changed files with 1526 additions and 0 deletions
+7
View File
@@ -26,6 +26,7 @@ import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
import { registerAuthCommand } from './commands/auth.js';
import { registerFleetAuthCommands } from './commands/fleet-auth-command.js';
import { registerStoreCommand } from './commands/store-command.js';
import { registerFederationCommand } from './commands/federation.js';
import { registerGatewayCommand } from './commands/gateway.js';
import {
@@ -353,6 +354,12 @@ sessionsCmd
registerFleetAuthCommands(registerAuthCommand(program));
// ─── store ────────────────────────────────────────────────────────────
// The vetted plugin/skill store. Admission only: entitling a seat is
// `mosaic fleet plugin enable`, deliberately a separate decision.
registerStoreCommand(program);
// ─── gateway ──────────────────────────────────────────────────────────
registerGatewayCommand(program);
@@ -0,0 +1,309 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetPluginCommand } from './fleet-plugin-command.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
interface Harness {
readonly home: string;
readonly out: string[];
readonly err: string[];
readonly root: string;
run: (argv: string[]) => Promise<void>;
profile: (agent: string) => Record<string, unknown>;
}
async function harness(): Promise<Harness> {
root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-plugin-'));
const home = join(root, '.mosaic');
const out: string[] = [];
const err: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
out.push(parts.map(String).join(' '));
});
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => {
err.push(String(chunk));
return true;
});
const program = new Command();
program.exitOverride();
const fleet = program.command('fleet');
registerFleetPluginCommand(fleet, { fleetDataHome: home });
return {
home,
out,
err,
root: root as string,
run: async (argv: string[]): Promise<void> => {
await program.parseAsync(['node', 'mosaic', ...argv]);
},
profile: (agent: string): Record<string, unknown> =>
JSON.parse(
readFileSync(join(home, 'fleet', 'agents', agent, 'profile.json'), 'utf8'),
) as Record<string, unknown>,
};
}
function seat(home: string, agent: string, overrides: Record<string, unknown> = {}): void {
const dir = join(home, 'fleet', 'agents', agent);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'profile.json'),
`${JSON.stringify({ schema: 1, harness: 'claude', plugins: [], skills: [], ...overrides }, null, 2)}\n`,
);
}
function reviewed(root: string, name: string): string {
const dir = join(root, 'reviewed', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'plugin.json'), '{"name":"demo"}\n');
return dir;
}
async function admit(h: Harness, name: string, version: string): Promise<void> {
await h.run([
'fleet',
'plugin',
'add',
`${name}@${version}`,
'--from',
reviewed(h.root, `${name}-${version}`),
]);
}
describe('mosaic fleet plugin add', () => {
it('admits into the shared store, and enables it for nobody', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
expect(h.out.join('\n')).toContain('[email protected]');
// Admission and entitlement are two decisions with two owners. Adding must not
// hand the plugin to a seat as a side effect.
expect(h.profile('coder')['plugins']).toEqual([]);
});
it('refuses an unversioned entry rather than admitting something unpinnable', async () => {
const h = await harness();
await h.run(['fleet', 'plugin', 'add', 'linear', '--from', reviewed(h.root, 'linear')]);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toContain('unversioned');
});
it('refuses to replace a version a seat may already be pinned to', async () => {
const h = await harness();
await admit(h, 'linear', '1.2.0');
await admit(h, 'linear', '1.2.0');
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toContain('destination-occupied');
});
});
describe('mosaic fleet plugin enable', () => {
it('pins the exact entry into the seat profile', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
expect(h.profile('coder')['plugins']).toEqual(['[email protected]']);
});
it('leaves every other profile field byte-identical', async () => {
const h = await harness();
seat(h.home, 'coder', { bundle: 'work', env: { FLEET_SEAT: 'coder' } });
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
const profile = h.profile('coder');
expect(profile['bundle']).toBe('work');
expect(profile['env']).toEqual({ FLEET_SEAT: 'coder' });
expect(profile['harness']).toBe('claude');
});
it('refuses an entry that is not in the store, naming what is', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toContain('[email protected]');
expect(h.profile('coder')['plugins']).toEqual([]);
});
it('refuses a bare name, because an unpinned entry is not a decision', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', 'linear', '--agent', 'coder']);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toMatch(/linear@1\.2\.0/);
expect(h.profile('coder')['plugins']).toEqual([]);
});
it('is idempotent and says so, rather than listing the plugin twice', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
h.out.length = 0;
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
expect(h.profile('coder')['plugins']).toEqual(['[email protected]']);
expect(h.out.join('\n')).toMatch(/already/i);
expect(process.exitCode).toBeUndefined();
});
it('refuses a second version of a plugin the seat already has', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await admit(h, 'linear', '1.3.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
// Both would link to <seat>/plugins/linear@<v>, so this is not a name collision launch
// would catch -- the seat would just silently run two copies of the same plugin.
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toMatch(/linear@1\.2\.0/);
expect(h.profile('coder')['plugins']).toEqual(['[email protected]']);
});
it('says the change takes effect at the next launch', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
expect(h.out.join('\n')).toMatch(/next launch/i);
});
it('refuses --live rather than accepting it as a silent no-op', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder', '--live']);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toMatch(/running seat|restart/i);
expect(h.profile('coder')['plugins']).toEqual([]);
});
it('names the scaffold command when the seat does not exist', async () => {
const h = await harness();
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'ghost']);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toContain('mosaic fleet agent new ghost');
});
});
describe('mosaic fleet plugin disable', () => {
it('removes the entry and leaves the others', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await admit(h, 'notion', '2.0.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
await h.run(['fleet', 'plugin', 'disable', '[email protected]', '--agent', 'coder']);
expect(h.profile('coder')['plugins']).toEqual(['[email protected]']);
});
it('resolves a bare name to the one version the seat actually has', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
await h.run(['fleet', 'plugin', 'disable', 'linear', '--agent', 'coder']);
expect(h.profile('coder')['plugins']).toEqual([]);
});
it('does not delete the store entry, only the entitlement', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
await h.run(['fleet', 'plugin', 'disable', '[email protected]', '--agent', 'coder']);
h.out.length = 0;
await h.run(['fleet', 'plugin', 'list', '--store']);
expect(h.out.join('\n')).toContain('[email protected]');
});
it('reports a plugin the seat never had instead of claiming a change', async () => {
const h = await harness();
seat(h.home, 'coder');
await h.run(['fleet', 'plugin', 'disable', '[email protected]', '--agent', 'coder']);
expect(h.out.join('\n')).toMatch(/not enabled/i);
expect(process.exitCode).toBeUndefined();
});
it('refuses --live rather than accepting it as a silent no-op', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
await h.run(['fleet', 'plugin', 'disable', '[email protected]', '--agent', 'coder', '--live']);
expect(process.exitCode).toBe(1);
expect(h.profile('coder')['plugins']).toEqual(['[email protected]']);
});
});
describe('mosaic fleet plugin list', () => {
it('shows, for one seat, what is enabled and what is merely available', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await admit(h, 'notion', '2.0.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
h.out.length = 0;
await h.run(['fleet', 'plugin', 'list', '--agent', 'coder']);
const text = h.out.join('\n');
expect(text).toMatch(/linear@1\.2\.0.*enabled/is);
expect(text).toContain('[email protected]');
});
it('flags a pinned entry that is no longer in the store, because launch will fail on it', async () => {
const h = await harness();
seat(h.home, 'coder', { plugins: ['[email protected]'] });
h.out.length = 0;
await h.run(['fleet', 'plugin', 'list', '--agent', 'coder']);
expect(h.out.join('\n')).toMatch(/ghost@1\.0\.0.*missing/is);
});
it('lists every seat when no agent is named', async () => {
const h = await harness();
seat(h.home, 'coder');
seat(h.home, 'reviewer');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
h.out.length = 0;
await h.run(['fleet', 'plugin', 'list']);
const text = h.out.join('\n');
expect(text).toContain('coder');
expect(text).toContain('reviewer');
});
it('emits machine-readable state under --json', async () => {
const h = await harness();
seat(h.home, 'coder');
await admit(h, 'linear', '1.2.0');
await h.run(['fleet', 'plugin', 'enable', '[email protected]', '--agent', 'coder']);
h.out.length = 0;
await h.run(['fleet', 'plugin', 'list', '--agent', 'coder', '--json']);
const parsed = JSON.parse(h.out.join('\n')) as Record<string, unknown>;
expect(parsed).toEqual(
expect.objectContaining({
agents: [expect.objectContaining({ agent: 'coder', plugins: ['[email protected]'] })],
}),
);
});
});
@@ -0,0 +1,319 @@
/**
* `mosaic fleet plugin add | enable | disable | list` -- which seat is entitled to which
* vetted plugin.
*
* The division of labour is the point. `add` admits into the shared store and hands the
* plugin to nobody; `enable` writes one entry into one seat's `profile.json` and copies
* nothing. Launch then links what the profile names. So a plugin reaching a seat requires two
* deliberate acts, and neither one can happen as a side effect of the other.
*
* Entries are pinned. `enable linear` is refused where `enable [email protected]` is accepted,
* because an unpinned entitlement means "whatever is in the store at launch time", and a
* store admission would then change what a seat runs without anyone touching that seat.
*
* Nothing here restarts or edits a running seat. A profile change is next-launch, and `--live`
* is refused rather than accepted-and-ignored -- an operator who asks for a live change and
* gets a success message would reasonably believe the seat changed.
*/
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
StoreError,
type StoreEntry,
addEntry,
defaultStoreDataHome,
listEntries,
parseEntry,
} from '../fleet/store.js';
import { FleetLaunchError, parseFleetAgentProfile } from './fleet-launch-command.js';
import { requireAdmissionEntry } from './store-command.js';
export interface FleetPluginCommandDeps {
/** Test seam for the user-owned ~/.mosaic root. */
readonly fleetDataHome?: string;
}
function fail(error: unknown, verb: string): void {
process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error);
const code =
error instanceof StoreError
? error.code
: error instanceof FleetLaunchError
? error.code
: 'failed';
process.stderr.write(`mosaic fleet plugin ${verb} failed (${code}): ${message}\n`);
}
function agentsRoot(dataHome: string): string {
return join(dataHome, 'fleet', 'agents');
}
function profilePath(dataHome: string, agent: string): string {
return join(agentsRoot(dataHome), agent, 'profile.json');
}
function listAgents(dataHome: string): string[] {
try {
return readdirSync(agentsRoot(dataHome), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}
interface LoadedProfile {
readonly path: string;
/** The verbatim source, so a write can patch one key and leave the rest alone. */
readonly raw: Record<string, unknown>;
readonly plugins: readonly string[];
}
/**
* Read a seat profile, validating it before returning.
*
* Re-parsing rather than patching blind means an already-invalid profile is reported here as
* invalid, instead of being re-serialized into something that looks fine and fails at launch.
*/
function loadProfile(dataHome: string, agent: string): LoadedProfile {
const path = profilePath(dataHome, agent);
let source: string;
try {
source = readFileSync(path, 'utf8');
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new StoreError(
'invalid-request',
`no such fleet agent: ${path} — scaffold it first: mosaic fleet agent new ${agent}`,
);
}
throw error;
}
const profile = parseFleetAgentProfile(source);
return { path, raw: JSON.parse(source) as Record<string, unknown>, plugins: profile.plugins };
}
function writePlugins(loaded: LoadedProfile, plugins: readonly string[]): void {
const raw = { ...loaded.raw, plugins: [...plugins] };
writeFileSync(loaded.path, `${JSON.stringify(raw, null, 2)}\n`);
}
/** `--live` is a refusal, not a no-op. See the module comment. */
function refuseLive(live: boolean | undefined, agent: string): void {
if (live !== true) return;
throw new StoreError(
'invalid-request',
`a running seat cannot be changed in place; this only edits ${agent}'s profile. Drop --live and restart the seat to pick it up.`,
);
}
/** Resolve the store entry an operator named, refusing anything unpinned. */
function requirePinnedStoreEntry(dataHome: string, entry: string): StoreEntry {
const available = listEntries(dataHome, 'plugin');
const found = available.find((item) => item.entry === entry);
if (found !== undefined && found.version !== undefined) return found;
const present =
available.length === 0
? 'the store is empty — admit it first: mosaic fleet plugin add <name>@<version> --from <path>'
: `present: ${available.map((item) => item.entry).join(', ')}`;
if (found !== undefined) {
throw new StoreError(
'unversioned',
`plugin ${entry} is in the store without a version and cannot be pinned; re-admit it at a version.`,
);
}
throw new StoreError(
'no-such-entry',
parseEntry(entry).version === undefined
? `plugin ${entry} must be named with its version, as <name>@<version>; ${present}`
: `no plugin ${entry} in the store; ${present}`,
);
}
export function registerFleetPluginCommand(
fleetCommand: Command,
deps: FleetPluginCommandDeps = {},
): Command {
const dataHome = (): string => deps.fleetDataHome ?? defaultStoreDataHome();
const plugin = fleetCommand
.command('plugin')
.description('Admit plugins into the shared store and entitle seats to them');
plugin
.command('add')
.argument('<entry>', 'The entry to admit, written <name>@<version>')
.description('Admit a reviewed plugin directory into the shared store (entitles no seat)')
.requiredOption('--from <path>', 'A reviewed directory to admit. Copied, never moved.')
.option('--harness <harness>', 'Record which harness this plugin is for')
.action((admitAs: string, opts: { from: string; harness?: string }): void => {
try {
const { name, version } = requireAdmissionEntry(admitAs);
const entry = addEntry({
dataHome: dataHome(),
kind: 'plugin',
source: opts.from,
name,
version,
...(opts.harness === undefined ? {} : { harness: opts.harness }),
});
console.log(`Admitted plugin ${entry.entry} at ${entry.path}`);
console.log(
`No seat runs it yet. Entitle one: mosaic fleet plugin enable ${entry.entry} --agent <agent>`,
);
} catch (error: unknown) {
fail(error, 'add');
}
});
plugin
.command('enable')
.argument('<entry>', 'A store entry, as <name>@<version>')
.description("Add a store entry to one seat's profile (takes effect at its next launch)")
.requiredOption('--agent <agent>', 'The seat to entitle')
.option('--live', 'Refused: a running seat cannot be changed in place')
.action((entry: string, opts: { agent: string; live?: boolean }): void => {
try {
refuseLive(opts.live, opts.agent);
const home = dataHome();
const resolved = requirePinnedStoreEntry(home, entry);
const loaded = loadProfile(home, opts.agent);
if (loaded.plugins.includes(resolved.entry)) {
console.log(`${opts.agent} already has ${resolved.entry}; nothing to do.`);
return;
}
// Two versions of one plugin would link to the same seat path under different entry
// names, so launch would not catch it -- the seat would just run both.
const sameName = loaded.plugins.find((item) => parseEntry(item).name === resolved.name);
if (sameName !== undefined) {
throw new StoreError(
'invalid-request',
`${opts.agent} already has ${sameName}; disable it before enabling ${resolved.entry}.`,
);
}
writePlugins(loaded, [...loaded.plugins, resolved.entry]);
console.log(`Enabled ${resolved.entry} for ${opts.agent}.`);
console.log(`This takes effect at the next launch of ${opts.agent}.`);
} catch (error: unknown) {
fail(error, 'enable');
}
});
plugin
.command('disable')
.argument('<entry>', 'A store entry, as <name>@<version> or a bare name if unambiguous')
.description("Remove a store entry from one seat's profile (the store keeps it)")
.requiredOption('--agent <agent>', 'The seat to change')
.option('--live', 'Refused: a running seat cannot be changed in place')
.action((entry: string, opts: { agent: string; live?: boolean }): void => {
try {
refuseLive(opts.live, opts.agent);
const loaded = loadProfile(dataHome(), opts.agent);
// A bare name is safe here in a way it is not for enable: it names something the seat
// already has, so there is a fact to resolve against rather than a guess to make.
const matches = loaded.plugins.includes(entry)
? [entry]
: loaded.plugins.filter((item) => parseEntry(item).name === entry);
if (matches.length === 0) {
console.log(`${opts.agent} does not have ${entry}; it is not enabled. Nothing to do.`);
return;
}
if (matches.length > 1) {
throw new StoreError(
'invalid-request',
`${opts.agent} has more than one ${entry}: ${matches.join(', ')}. Name the exact entry.`,
);
}
const target = matches[0] as string;
writePlugins(
loaded,
loaded.plugins.filter((item) => item !== target),
);
console.log(`Disabled ${target} for ${opts.agent}. It stays in the store.`);
console.log(`This takes effect at the next launch of ${opts.agent}.`);
} catch (error: unknown) {
fail(error, 'disable');
}
});
plugin
.command('list')
.description('Show what is in the store and which seats are entitled to it')
.option('--agent <agent>', 'Restrict to one seat')
.option('--store', 'Show only the store, not the seats')
.option('--json', 'Print JSON')
.action((opts: { agent?: string; store?: boolean; json?: boolean }): void => {
try {
const home = dataHome();
const store = listEntries(home, 'plugin');
const known = new Set(store.map((item) => item.entry));
if (opts.store === true) {
if (opts.json === true) {
console.log(JSON.stringify({ store }, null, 2));
return;
}
if (store.length === 0) console.log('store: empty');
else for (const item of store) console.log(` ${item.entry}`);
return;
}
const names = opts.agent === undefined ? listAgents(home) : [opts.agent];
const agents = names.map((agent) => {
try {
const loaded = loadProfile(home, agent);
return {
agent,
plugins: [...loaded.plugins],
missing: loaded.plugins.filter((item) => !known.has(item)),
};
} catch (error: unknown) {
return {
agent,
plugins: [],
missing: [],
error: error instanceof Error ? error.message : String(error),
};
}
});
if (opts.json === true) {
console.log(JSON.stringify({ store, agents }, null, 2));
return;
}
for (const item of agents) {
console.log(`${item.agent}:`);
if ('error' in item) {
console.log(` profile unreadable: ${String(item.error)}`);
continue;
}
for (const entry of item.plugins) {
// A pinned entry with no store directory is the failure launch will hit, so it is
// named here rather than left to look like any other enabled plugin.
const note = known.has(entry) ? 'enabled' : 'enabled — MISSING from the store';
console.log(` ${entry} ${note}`);
}
for (const entry of store) {
if (item.plugins.includes(entry.entry)) continue;
console.log(` ${entry.entry} available`);
}
if (item.plugins.length === 0 && store.length === 0)
console.log(' nothing in the store');
}
} catch (error: unknown) {
fail(error, 'list');
}
});
return plugin;
}
@@ -97,6 +97,7 @@ describe('registerFleetCommand', () => {
'migrate-v1',
'persona',
'plan',
'plugin',
'profile',
'provision',
'ps',
+6
View File
@@ -43,6 +43,7 @@ import {
type FleetAgentScaffoldCommandDeps,
} from './fleet-agent-scaffold-command.js';
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
import { registerFleetPluginCommand } from './fleet-plugin-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
@@ -2087,6 +2088,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
registerFleetAdoptCommand(cmd, {
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
});
// Entitlement, the counterpart to `mosaic store`'s admission: what the store holds is
// available to the host, and this is what decides which seat actually gets it.
registerFleetPluginCommand(cmd, {
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
});
// Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps);
@@ -0,0 +1,205 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerStoreCommand } from './store-command.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
interface Harness {
readonly home: string;
readonly out: string[];
readonly err: string[];
readonly root: string;
run: (argv: string[]) => Promise<void>;
}
async function harness(): Promise<Harness> {
root = await mkdtemp(join(tmpdir(), 'mosaic-store-cmd-'));
const home = join(root, '.mosaic');
const out: string[] = [];
const err: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
out.push(parts.map(String).join(' '));
});
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => {
err.push(String(chunk));
return true;
});
const program = new Command();
program.exitOverride();
registerStoreCommand(program, { fleetDataHome: home });
return {
home,
out,
err,
root: root as string,
run: async (argv: string[]): Promise<void> => {
await program.parseAsync(['node', 'mosaic', ...argv]);
},
};
}
function reviewed(root: string, name: string): string {
const dir = join(root, 'reviewed', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'plugin.json'), '{"name":"demo"}\n');
return dir;
}
describe('mosaic store init', () => {
it('scaffolds both store roots', async () => {
const h = await harness();
await h.run(['store', 'init']);
expect(h.out.join('\n')).toContain(join(h.home, 'plugins'));
expect(h.out.join('\n')).toContain(join(h.home, 'skills'));
expect(process.exitCode).toBeUndefined();
});
});
describe('mosaic store add', () => {
it('admits a reviewed directory at a pinned version', async () => {
const h = await harness();
await h.run([
'store',
'add',
reviewed(h.root, 'linear'),
'--kind',
'plugin',
'--as',
'[email protected]',
]);
expect(h.out.join('\n')).toContain('[email protected]');
expect(process.exitCode).toBeUndefined();
});
it('requires an entry to admit as', async () => {
const h = await harness();
await expect(h.run(['store', 'add', reviewed(h.root, 'linear')])).rejects.toThrowError(/--as/);
});
it('exits non-zero and explains when the version is already in the store', async () => {
const h = await harness();
const source = reviewed(h.root, 'linear');
const argv = ['store', 'add', source, '--as', '[email protected]'];
await h.run(argv);
await h.run(argv);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toContain('destination-occupied');
});
it('reports a bad name without a stack trace and without writing', async () => {
const h = await harness();
await h.run(['store', 'add', reviewed(h.root, 'linear'), '--as', '../[email protected]']);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toContain('invalid-request');
expect(h.err.join('\n')).not.toContain('at Object');
});
it('refuses an unversioned --as instead of admitting something unpinnable', async () => {
const h = await harness();
await h.run(['store', 'add', reviewed(h.root, 'linear'), '--as', 'linear']);
expect(process.exitCode).toBe(1);
expect(h.err.join('\n')).toContain('unversioned');
});
it('still admits when the program defines its own --version', async () => {
// Regression: the flag used to be `--version`, which Commander had already claimed on the
// program. `store add ... --version 1.2.0` printed the CLI version and admitted nothing.
// A bare test program has no version option, so only this shape catches it.
root = await mkdtemp(join(tmpdir(), 'mosaic-store-cmd-'));
const home = join(root, '.mosaic');
const out: string[] = [];
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
out.push(parts.map(String).join(' '));
});
const program = new Command();
program.exitOverride();
program.version('9.9.9');
registerStoreCommand(program, { fleetDataHome: home });
await program.parseAsync([
'node',
'mosaic',
'store',
'add',
reviewed(root, 'linear'),
'--as',
'[email protected]',
]);
expect(out.join('\n')).toContain('[email protected]');
expect(out.join('\n')).not.toContain('9.9.9');
});
});
describe('mosaic store list', () => {
it('says the store is empty rather than printing nothing', async () => {
const h = await harness();
await h.run(['store', 'list']);
expect(h.out.join('\n')).toMatch(/empty|no .* in the store/i);
});
it('lists both kinds with their versions', async () => {
const h = await harness();
await h.run(['store', 'add', reviewed(h.root, 'linear'), '--as', '[email protected]']);
await h.run([
'store',
'add',
reviewed(h.root, 'triage'),
'--kind',
'skill',
'--as',
'[email protected]',
]);
h.out.length = 0;
await h.run(['store', 'list']);
const text = h.out.join('\n');
expect(text).toContain('[email protected]');
expect(text).toContain('[email protected]');
});
it('emits machine-readable entries under --json', async () => {
const h = await harness();
await h.run([
'store',
'add',
reviewed(h.root, 'linear'),
'--as',
'[email protected]',
'--harness',
'claude',
]);
h.out.length = 0;
await h.run(['store', 'list', '--json']);
const parsed = JSON.parse(h.out.join('\n')) as Array<Record<string, unknown>>;
expect(parsed).toEqual([
expect.objectContaining({
kind: 'plugin',
entry: '[email protected]',
name: 'linear',
version: '1.2.0',
harness: 'claude',
}),
]);
});
it('marks a legacy unversioned entry instead of hiding it', async () => {
const h = await harness();
mkdirSync(join(h.home, 'plugins', 'legacy-flat'), { recursive: true });
await h.run(['store', 'list', '--kind', 'plugin']);
const text = h.out.join('\n');
expect(text).toContain('legacy-flat');
expect(text).toMatch(/unversioned/i);
});
});
@@ -0,0 +1,142 @@
/**
* `mosaic store init | add | list` -- the operator surface for the vetted store.
*
* This is the admission gate and nothing else. Admitting a plugin does not give it to any
* seat; `mosaic fleet plugin enable` does that, and the split is deliberate: reviewing
* material and entitling a seat to run it are two decisions, and one operator making the
* first should not silently make the second.
*
* All the rules live in `../fleet/store.js`, which `mosaic fleet plugin add` also calls. There
* is one admission path, so there is one place where the versioning rule can be enforced.
*/
import type { Command } from 'commander';
import {
STORE_KINDS,
StoreError,
type StoreEntry,
type StoreKind,
addEntry,
assertStoreKind,
defaultStoreDataHome,
ensureStoreLayout,
listEntries,
parseEntry,
} from '../fleet/store.js';
/**
* Split `<name>@<version>` for admission.
*
* The flag is `--as` rather than `--version` because Commander's own `--version` is already on
* the program and swallows the subcommand's: `mosaic store add … --version 1.2.0` printed the
* CLI version and admitted nothing. Naming the whole entry also means an operator types the
* same string here that they later pass to `fleet plugin enable`.
*/
export function requireAdmissionEntry(value: string): { name: string; version: string } {
const parsed = parseEntry(value);
if (parsed.version === undefined) {
throw new StoreError(
'unversioned',
`--as must name the entry and its version, as <name>@<version>: ${value}`,
);
}
return { name: parsed.name, version: parsed.version };
}
export interface StoreCommandDeps {
/** Test seam for the user-owned ~/.mosaic root. */
readonly fleetDataHome?: string;
}
function fail(error: unknown, verb: string): void {
process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof StoreError ? error.code : 'failed';
process.stderr.write(`mosaic store ${verb} failed (${code}): ${message}\n`);
}
/** One entry as a person reads it. An unversioned entry is called out, never rendered as if
* it were pinnable. */
export function describeEntry(entry: StoreEntry): string {
const version = entry.version === undefined ? ' (unversioned — cannot be pinned)' : '';
const harness = entry.harness === undefined ? '' : ` [${entry.harness}]`;
return ` ${entry.entry}${harness}${version}`;
}
export function registerStoreCommand(program: Command, deps: StoreCommandDeps = {}): Command {
const dataHome = (): string => deps.fleetDataHome ?? defaultStoreDataHome();
const store = program
.command('store')
.description('The vetted plugin and skill store shared by every seat on this host');
store
.command('init')
.description('Create the store layout under ~/.mosaic')
.action((): void => {
try {
for (const root of ensureStoreLayout(dataHome())) console.log(root);
} catch (error: unknown) {
fail(error, 'init');
}
});
store
.command('add')
.argument('<source>', 'A reviewed directory to admit. Copied, never moved.')
.description('Admit a reviewed directory into the store at a pinned version')
.option('--kind <kind>', `One of: ${STORE_KINDS.join(', ')}`, 'plugin')
.requiredOption('--as <entry>', 'The entry to admit it as, written <name>@<version>')
.option('--harness <harness>', 'Record which harness this entry is for')
.action((source: string, opts: { kind?: string; as: string; harness?: string }): void => {
try {
const kind = assertStoreKind(opts.kind);
const { name, version } = requireAdmissionEntry(opts.as);
const entry = addEntry({
dataHome: dataHome(),
kind,
source,
name,
version,
...(opts.harness === undefined ? {} : { harness: opts.harness }),
});
console.log(`Admitted ${kind} ${entry.entry} at ${entry.path}`);
console.log(
`No seat runs it yet. Entitle one: mosaic fleet plugin enable ${entry.entry} --agent <agent>`,
);
} catch (error: unknown) {
fail(error, 'add');
}
});
store
.command('list')
.description('List what has been admitted')
.option('--kind <kind>', `Restrict to one of: ${STORE_KINDS.join(', ')}`)
.option('--json', 'Print JSON')
.action((opts: { kind?: string; json?: boolean }): void => {
try {
const kinds: readonly StoreKind[] =
opts.kind === undefined ? STORE_KINDS : [assertStoreKind(opts.kind)];
const home = dataHome();
const all = kinds.flatMap((kind) => listEntries(home, kind));
if (opts.json === true) {
console.log(JSON.stringify(all, null, 2));
return;
}
for (const kind of kinds) {
const entries = all.filter((entry) => entry.kind === kind);
if (entries.length === 0) {
console.log(`${kind}s: empty`);
continue;
}
console.log(`${kind}s:`);
for (const entry of entries) console.log(describeEntry(entry));
}
} catch (error: unknown) {
fail(error, 'list');
}
});
return store;
}
+249
View File
@@ -0,0 +1,249 @@
import {
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
StoreError,
addEntry,
ensureStoreLayout,
formatEntry,
listEntries,
parseEntry,
requireEntry,
storeRoot,
} from './store.js';
const roots: string[] = [];
function scratch(): string {
const dir = mkdtempSync(join(tmpdir(), 'mosaic-store-'));
roots.push(dir);
return dir;
}
/** A reviewed directory an operator would admit. */
function reviewedSource(root: string, name: string): string {
const dir = join(root, 'reviewed', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'plugin.json'), '{"name":"demo"}\n');
return dir;
}
afterEach(() => {
roots.length = 0;
});
describe('store layout', () => {
it('creates both kinds and is idempotent', () => {
const home = join(scratch(), '.mosaic');
expect(ensureStoreLayout(home)).toEqual([join(home, 'plugins'), join(home, 'skills')]);
const marker = join(home, 'plugins', '[email protected]');
mkdirSync(marker, { recursive: true });
ensureStoreLayout(home);
expect(lstatSync(marker).isDirectory()).toBe(true);
});
it('reports an absent store as empty rather than throwing', () => {
expect(listEntries(join(scratch(), 'never-created'), 'plugin')).toEqual([]);
});
});
describe('entry naming', () => {
it('round-trips name and version through a single path segment', () => {
expect(formatEntry('linear', '1.2.0')).toBe('[email protected]');
expect(parseEntry('[email protected]')).toEqual({ name: 'linear', version: '1.2.0' });
});
it('reads a legacy flat entry as unversioned instead of rejecting it', () => {
// Adoption promoted flat entries before versioning was enforced, and a seat may already
// link one. The read path has to surface it; only admission enforces the rule.
expect(parseEntry('linear')).toEqual({ name: 'linear' });
});
it('treats a trailing or leading @ as no version, never as an empty one', () => {
expect(parseEntry('linear@')).toEqual({ name: 'linear@' });
expect(parseEntry('@1.2.0')).toEqual({ name: '@1.2.0' });
});
it('splits on the last @ so a version may not be silently absorbed into a name', () => {
expect(parseEntry('a@[email protected]')).toEqual({ name: 'a@b', version: '1.0' });
});
});
describe('admission', () => {
it('admits a reviewed directory as <name>@<version> and records it', () => {
const root = scratch();
const home = join(root, '.mosaic');
const entry = addEntry({
dataHome: home,
kind: 'plugin',
source: reviewedSource(root, 'linear'),
name: 'linear',
version: '1.2.0',
harness: 'claude',
});
expect(entry.entry).toBe('[email protected]');
expect(entry.path).toBe(join(home, 'plugins', '[email protected]'));
expect(readFileSync(join(entry.path, 'plugin.json'), 'utf8')).toContain('demo');
expect(listEntries(home, 'plugin')).toEqual([
expect.objectContaining({ name: 'linear', version: '1.2.0', harness: 'claude' }),
]);
});
it('lands a real directory, because launch refuses a symlinked store entry', () => {
const root = scratch();
const home = join(root, '.mosaic');
const entry = addEntry({
dataHome: home,
kind: 'plugin',
source: reviewedSource(root, 'linear'),
name: 'linear',
version: '1.2.0',
});
const info = lstatSync(entry.path);
expect(info.isDirectory()).toBe(true);
expect(info.isSymbolicLink()).toBe(false);
});
it('refuses an occupied destination rather than replacing a pinned version', () => {
const root = scratch();
const home = join(root, '.mosaic');
const request = {
dataHome: home,
kind: 'plugin' as const,
source: reviewedSource(root, 'linear'),
name: 'linear',
version: '1.2.0',
};
addEntry(request);
writeFileSync(join(home, 'plugins', '[email protected]', 'marker'), 'original\n');
expect(() => addEntry(request)).toThrowError(StoreError);
// The refusal must leave the first admission exactly as it was.
expect(readFileSync(join(home, 'plugins', '[email protected]', 'marker'), 'utf8')).toBe(
'original\n',
);
});
it('rejects a name carrying an @, which would parse back as a different version', () => {
const root = scratch();
expect(() =>
addEntry({
dataHome: join(root, '.mosaic'),
kind: 'plugin',
source: reviewedSource(root, 'linear'),
name: 'linear@2',
version: '1.0.0',
}),
).toThrowError(/contain no "@"/);
});
it('rejects a path separator or dot-dot in either half', () => {
const root = scratch();
const home = join(root, '.mosaic');
const source = reviewedSource(root, 'linear');
for (const bad of ['../escape', 'a/b', '.hidden']) {
expect(() =>
addEntry({ dataHome: home, kind: 'plugin', source, name: bad, version: '1.0.0' }),
).toThrowError(StoreError);
expect(() =>
addEntry({ dataHome: home, kind: 'plugin', source, name: 'linear', version: bad }),
).toThrowError(StoreError);
}
});
it('refuses a source that is a link or a file, not just one that is missing', () => {
const root = scratch();
const home = join(root, '.mosaic');
const real = reviewedSource(root, 'linear');
const link = join(root, 'linked');
symlinkSync(real, link);
const file = join(root, 'a-file');
writeFileSync(file, 'not a plugin\n');
for (const source of [link, file]) {
expect(() =>
addEntry({ dataHome: home, kind: 'plugin', source, name: 'linear', version: '1.0.0' }),
).toThrowError(/real directory/);
}
expect(() =>
addEntry({
dataHome: home,
kind: 'plugin',
source: join(root, 'absent'),
name: 'linear',
version: '1.0.0',
}),
).toThrowError(/no such directory/);
});
it('keeps skills in their own root', () => {
const root = scratch();
const home = join(root, '.mosaic');
addEntry({
dataHome: home,
kind: 'skill',
source: reviewedSource(root, 'triage'),
name: 'triage',
version: '0.1.0',
});
expect(listEntries(home, 'plugin')).toEqual([]);
expect(listEntries(home, 'skill')).toEqual([
expect.objectContaining({ entry: '[email protected]', kind: 'skill' }),
]);
});
});
describe('reading the store', () => {
it('survives an unreadable registry, because directories are the store', () => {
const root = scratch();
const home = join(root, '.mosaic');
addEntry({
dataHome: home,
kind: 'plugin',
source: reviewedSource(root, 'linear'),
name: 'linear',
version: '1.2.0',
harness: 'claude',
});
writeFileSync(join(storeRoot(home, 'plugin'), '.mosaic-store.json'), 'not json{');
const entries = listEntries(home, 'plugin');
expect(entries).toHaveLength(1);
expect(entries[0]?.entry).toBe('[email protected]');
expect(entries[0]?.harness).toBeUndefined();
});
it('hides dotfiles so the registry is never offered as an entry', () => {
const root = scratch();
const home = join(root, '.mosaic');
ensureStoreLayout(home);
mkdirSync(join(storeRoot(home, 'plugin'), '.hidden-dir'), { recursive: true });
expect(listEntries(home, 'plugin')).toEqual([]);
});
it('names what is present when an entry is missing', () => {
const root = scratch();
const home = join(root, '.mosaic');
addEntry({
dataHome: home,
kind: 'plugin',
source: reviewedSource(root, 'linear'),
name: 'linear',
version: '1.2.0',
});
expect(() => requireEntry(home, 'plugin', '[email protected]')).toThrowError(/linear@1\.2\.0/);
expect(() => requireEntry(join(root, 'empty'), 'plugin', '[email protected]')).toThrowError(
/store is empty/,
);
});
});
+288
View File
@@ -0,0 +1,288 @@
/**
* The vetted store: `~/.mosaic/plugins` and `~/.mosaic/skills`.
*
* This is the boundary between "something an operator reviewed" and "something a seat can
* run". Nothing reaches a seat by being downloaded; it reaches a seat because it was admitted
* here first and then named in that seat's `profile.json`. Those are two decisions with two
* owners, and this module owns only the first one. Whether a seat gets an entry is
* `mosaic fleet plugin enable`'s decision, the same way it is not adoption's.
*
* Entries are versioned, and that is a rule rather than a convention: an entry directory is
* `<name>@<version>`, and a seat pins the exact one it links. An unversioned entry cannot be
* pinned, so upgrading it silently rewrites what every seat already linked -- the same hazard
* class as the settings hot-reload that bricked the orchestrator seat. `addEntry()` therefore
* requires a version and has no flag to skip it.
*
* The single-segment `<name>@<version>` spelling is not arbitrary. `mosaic fleet launch`
* resolves a profile entry as one path segment under the store root and requires the result
* to be a real, non-symlink directory (`resolveManagedLinks` in fleet-launch-command.ts), and
* the profile-entry charset it validates against already admits `@` and rejects `/`. A
* two-segment `<name>/<version>` layout is therefore unreachable from a profile without
* changing launch, which is built and verified. See the note in docs on the W-F4 seam.
*
* Nothing here deletes or overwrites. An occupied destination is a refusal, never a merge.
*/
import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
/** Store kinds, and the directory each uses under the user root. */
export const STORE_DIRECTORY: Record<StoreKind, string> = { plugin: 'plugins', skill: 'skills' };
export const STORE_KINDS: readonly StoreKind[] = ['plugin', 'skill'];
export type StoreKind = 'plugin' | 'skill';
/**
* Name and version charsets. Deliberately narrower than the profile-entry charset launch
* validates: `@` is the separator here, so it may not appear on either side of it, and a
* name that contained one would parse back as a different name and version.
*/
const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const VERSION = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
/** Registry file, one per store kind. A dotfile so it is never mistaken for an entry. */
const REGISTRY_FILE = '.mosaic-store.json';
export type StoreErrorCode =
| 'invalid-request'
| 'unversioned'
| 'destination-occupied'
| 'no-such-source'
| 'no-such-entry';
export class StoreError extends Error {
readonly code: StoreErrorCode;
constructor(code: StoreErrorCode, message: string) {
super(message);
this.name = 'StoreError';
this.code = code;
}
}
export interface StoreEntry {
readonly kind: StoreKind;
/** The directory name, `<name>@<version>` for a versioned entry. */
readonly entry: string;
readonly name: string;
/** Absent for a legacy flat entry admitted before versioning was enforced. */
readonly version?: string;
readonly path: string;
/** Recorded at admission; absent for an entry that predates the registry. */
readonly harness?: string;
readonly source?: string;
}
interface RegistryRecord {
readonly name: string;
readonly version: string;
readonly harness?: string;
readonly source?: string;
}
/** The user-owned root. Mirrors `defaultFleetDataHome()`, which this module must not import
* from a command module. */
export function defaultStoreDataHome(): string {
return process.env['MOSAIC_DATA_HOME'] ?? join(homedir(), '.mosaic');
}
export function storeRoot(dataHome: string, kind: StoreKind): string {
return join(dataHome, STORE_DIRECTORY[kind]);
}
export function formatEntry(name: string, version: string): string {
return `${name}@${version}`;
}
/**
* Split a directory name back into name and version.
*
* A name with no `@` is a legacy flat entry, reported as such rather than rejected: it may
* already be linked into a seat, and this function is on the read path. Admission is where
* the rule is enforced.
*/
export function parseEntry(entry: string): { name: string; version?: string } {
const at = entry.lastIndexOf('@');
if (at <= 0 || at === entry.length - 1) return { name: entry };
return { name: entry.slice(0, at), version: entry.slice(at + 1) };
}
function assertName(name: string): void {
if (!NAME.test(name)) {
throw new StoreError(
'invalid-request',
`entry name must match ${String(NAME)} and contain no "@": ${name}`,
);
}
}
function assertVersion(version: string): void {
if (!VERSION.test(version)) {
throw new StoreError(
'invalid-request',
`version must match ${String(VERSION)} and contain no "@": ${version}`,
);
}
}
export function assertStoreKind(value: string | undefined): StoreKind {
if (value === undefined || !STORE_KINDS.includes(value as StoreKind)) {
throw new StoreError('invalid-request', `--kind must be one of: ${STORE_KINDS.join(', ')}`);
}
return value as StoreKind;
}
function lstatIfPresent(path: string): ReturnType<typeof lstatSync> | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
function readRegistry(dataHome: string, kind: StoreKind): Record<string, RegistryRecord> {
try {
const parsed: unknown = JSON.parse(
readFileSync(join(storeRoot(dataHome, kind), REGISTRY_FILE), 'utf8'),
);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
return parsed as Record<string, RegistryRecord>;
} catch {
// A missing or unreadable registry is not an error on the read path: the directories on
// disk are the store, and the registry only annotates them. Losing an annotation must
// never make a present entry invisible.
return {};
}
}
function writeRegistry(
dataHome: string,
kind: StoreKind,
registry: Record<string, RegistryRecord>,
): void {
const path = join(storeRoot(dataHome, kind), REGISTRY_FILE);
writeFileSync(path, `${JSON.stringify(registry, null, 2)}\n`);
}
/** Create the store layout. Idempotent; never touches an existing directory's contents. */
export function ensureStoreLayout(dataHome: string): string[] {
return STORE_KINDS.map((kind) => {
const root = storeRoot(dataHome, kind);
mkdirSync(root, { recursive: true });
return root;
});
}
export function listEntries(dataHome: string, kind: StoreKind): StoreEntry[] {
const root = storeRoot(dataHome, kind);
const registry = readRegistry(dataHome, kind);
let names: string[];
try {
names = readdirSync(root, { withFileTypes: true })
.filter((item) => item.isDirectory() && !item.name.startsWith('.'))
.map((item) => item.name)
.sort();
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
return names.map((entry) => {
const parsed = parseEntry(entry);
const record = registry[entry];
return {
kind,
entry,
name: parsed.name,
...(parsed.version === undefined ? {} : { version: parsed.version }),
path: join(root, entry),
...(record?.harness === undefined ? {} : { harness: record.harness }),
...(record?.source === undefined ? {} : { source: record.source }),
};
});
}
export interface AddEntryRequest {
readonly dataHome: string;
readonly kind: StoreKind;
/** A directory the operator has reviewed. Copied, never moved: the source stays theirs. */
readonly source: string;
readonly name: string;
readonly version: string;
/** Recorded for reporting. The store is not partitioned by harness -- launch resolves an
* entry by name alone -- so this annotates the entry rather than placing it. */
readonly harness?: string;
}
/**
* Admit a reviewed directory into the store as `<name>@<version>`.
*
* The copy is not a merge and not an overwrite: an occupied destination is refused before
* anything is written, so a failed admission cannot leave a half-populated entry that a seat
* would then link.
*/
export function addEntry(request: AddEntryRequest): StoreEntry {
const { dataHome, kind, source, name, version, harness } = request;
assertName(name);
assertVersion(version);
const sourceInfo = lstatIfPresent(source);
if (sourceInfo === undefined) {
throw new StoreError('no-such-source', `no such directory to admit: ${source}`);
}
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
throw new StoreError(
'invalid-request',
`a store entry must be admitted from a real directory, not a link or a file: ${source}`,
);
}
const root = storeRoot(dataHome, kind);
mkdirSync(root, { recursive: true });
const entry = formatEntry(name, version);
const destination = join(root, entry);
if (lstatIfPresent(destination) !== undefined) {
throw new StoreError(
'destination-occupied',
`${kind} ${entry} is already in the store at ${destination}; publish a new version rather than replacing one seats may already be pinned to.`,
);
}
// dereference:false keeps a link inside the reviewed material a link, rather than quietly
// inflating it into a copy of whatever it pointed at on the admitting host.
cpSync(source, destination, { recursive: true, dereference: false, errorOnExist: true });
const registry = readRegistry(dataHome, kind);
registry[entry] = {
name,
version,
...(harness === undefined ? {} : { harness }),
source,
};
writeRegistry(dataHome, kind, registry);
return {
kind,
entry,
name,
version,
path: destination,
...(harness === undefined ? {} : { harness }),
source,
};
}
/** Resolve one entry by its `<name>@<version>` directory name, or fail with what is there. */
export function requireEntry(dataHome: string, kind: StoreKind, entry: string): StoreEntry {
const found = listEntries(dataHome, kind).find((item) => item.entry === entry);
if (found !== undefined) return found;
const available = listEntries(dataHome, kind).map((item) => item.entry);
throw new StoreError(
'no-such-entry',
available.length === 0
? `no ${kind} named ${entry} in the store, and the store is empty — admit it first: mosaic store add <path> --kind ${kind} --name <name> --version <version>`
: `no ${kind} named ${entry} in the store. Present: ${available.join(', ')}`,
);
}