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 { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
import { registerFleetAuthCommands } from './commands/fleet-auth-command.js';
|
import { registerFleetAuthCommands } from './commands/fleet-auth-command.js';
|
||||||
|
import { registerStoreCommand } from './commands/store-command.js';
|
||||||
import { registerFederationCommand } from './commands/federation.js';
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
import {
|
import {
|
||||||
@@ -353,6 +354,12 @@ sessionsCmd
|
|||||||
|
|
||||||
registerFleetAuthCommands(registerAuthCommand(program));
|
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 ──────────────────────────────────────────────────────────
|
// ─── gateway ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerGatewayCommand(program);
|
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',
|
'migrate-v1',
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
'plan',
|
||||||
|
'plugin',
|
||||||
'profile',
|
'profile',
|
||||||
'provision',
|
'provision',
|
||||||
'ps',
|
'ps',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
type FleetAgentScaffoldCommandDeps,
|
type FleetAgentScaffoldCommandDeps,
|
||||||
} from './fleet-agent-scaffold-command.js';
|
} from './fleet-agent-scaffold-command.js';
|
||||||
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
|
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
|
||||||
|
import { registerFleetPluginCommand } from './fleet-plugin-command.js';
|
||||||
import {
|
import {
|
||||||
registerFleetMigrationCommand,
|
registerFleetMigrationCommand,
|
||||||
type FleetMigrationCommandDeps,
|
type FleetMigrationCommandDeps,
|
||||||
@@ -2087,6 +2088,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
registerFleetAdoptCommand(cmd, {
|
registerFleetAdoptCommand(cmd, {
|
||||||
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
|
...(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
|
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||||
registerFleetAgentCrudCommands(cmd, deps);
|
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