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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user