diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 46e3b570..6298dbdb 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -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); diff --git a/packages/mosaic/src/commands/fleet-plugin-command.spec.ts b/packages/mosaic/src/commands/fleet-plugin-command.spec.ts new file mode 100644 index 00000000..0047d469 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-plugin-command.spec.ts @@ -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 => { + 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; + profile: (agent: string) => Record; +} + +async function harness(): Promise { + 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 => { + await program.parseAsync(['node', 'mosaic', ...argv]); + }, + profile: (agent: string): Record => + JSON.parse( + readFileSync(join(home, 'fleet', 'agents', agent, 'profile.json'), 'utf8'), + ) as Record, + }; +} + +function seat(home: string, agent: string, overrides: Record = {}): 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 { + 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('linear@1.2.0'); + // 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', 'linear@1.2.0', '--agent', 'coder']); + expect(h.profile('coder')['plugins']).toEqual(['linear@1.2.0']); + }); + + 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', 'linear@1.2.0', '--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', 'linear@9.9.9', '--agent', 'coder']); + expect(process.exitCode).toBe(1); + expect(h.err.join('\n')).toContain('linear@1.2.0'); + 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', 'linear@1.2.0', '--agent', 'coder']); + h.out.length = 0; + await h.run(['fleet', 'plugin', 'enable', 'linear@1.2.0', '--agent', 'coder']); + expect(h.profile('coder')['plugins']).toEqual(['linear@1.2.0']); + 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', 'linear@1.2.0', '--agent', 'coder']); + await h.run(['fleet', 'plugin', 'enable', 'linear@1.3.0', '--agent', 'coder']); + // Both would link to /plugins/linear@, 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(['linear@1.2.0']); + }); + + 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', 'linear@1.2.0', '--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', 'linear@1.2.0', '--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', 'linear@1.2.0', '--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', 'linear@1.2.0', '--agent', 'coder']); + await h.run(['fleet', 'plugin', 'enable', 'notion@2.0.0', '--agent', 'coder']); + await h.run(['fleet', 'plugin', 'disable', 'linear@1.2.0', '--agent', 'coder']); + expect(h.profile('coder')['plugins']).toEqual(['notion@2.0.0']); + }); + + 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', 'linear@1.2.0', '--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', 'linear@1.2.0', '--agent', 'coder']); + await h.run(['fleet', 'plugin', 'disable', 'linear@1.2.0', '--agent', 'coder']); + h.out.length = 0; + await h.run(['fleet', 'plugin', 'list', '--store']); + expect(h.out.join('\n')).toContain('linear@1.2.0'); + }); + + 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', 'linear@1.2.0', '--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', 'linear@1.2.0', '--agent', 'coder']); + await h.run(['fleet', 'plugin', 'disable', 'linear@1.2.0', '--agent', 'coder', '--live']); + expect(process.exitCode).toBe(1); + expect(h.profile('coder')['plugins']).toEqual(['linear@1.2.0']); + }); +}); + +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', 'linear@1.2.0', '--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('notion@2.0.0'); + }); + + 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: ['ghost@1.0.0'] }); + 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', 'linear@1.2.0', '--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', 'linear@1.2.0', '--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; + expect(parsed).toEqual( + expect.objectContaining({ + agents: [expect.objectContaining({ agent: 'coder', plugins: ['linear@1.2.0'] })], + }), + ); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-plugin-command.ts b/packages/mosaic/src/commands/fleet-plugin-command.ts new file mode 100644 index 00000000..fc22f887 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-plugin-command.ts @@ -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 linear@1.2.0` 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; + 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, 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 @ --from ' + : `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 @; ${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('', 'The entry to admit, written @') + .description('Admit a reviewed plugin directory into the shared store (entitles no seat)') + .requiredOption('--from ', 'A reviewed directory to admit. Copied, never moved.') + .option('--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 `, + ); + } catch (error: unknown) { + fail(error, 'add'); + } + }); + + plugin + .command('enable') + .argument('', 'A store entry, as @') + .description("Add a store entry to one seat's profile (takes effect at its next launch)") + .requiredOption('--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('', 'A store entry, as @ or a bare name if unambiguous') + .description("Remove a store entry from one seat's profile (the store keeps it)") + .requiredOption('--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 ', '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; +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index bf932c98..f2570d3f 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -97,6 +97,7 @@ describe('registerFleetCommand', () => { 'migrate-v1', 'persona', 'plan', + 'plugin', 'profile', 'provision', 'ps', diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 04b0cb70..1ab33cff 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -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); diff --git a/packages/mosaic/src/commands/store-command.spec.ts b/packages/mosaic/src/commands/store-command.spec.ts new file mode 100644 index 00000000..0c8fecec --- /dev/null +++ b/packages/mosaic/src/commands/store-command.spec.ts @@ -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 => { + 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; +} + +async function harness(): Promise { + 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 => { + 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', + 'linear@1.2.0', + ]); + expect(h.out.join('\n')).toContain('linear@1.2.0'); + 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', 'linear@1.2.0']; + 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', '../escape@1.0.0']); + 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', + 'linear@1.2.0', + ]); + expect(out.join('\n')).toContain('linear@1.2.0'); + 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', 'linear@1.2.0']); + await h.run([ + 'store', + 'add', + reviewed(h.root, 'triage'), + '--kind', + 'skill', + '--as', + 'triage@0.1.0', + ]); + h.out.length = 0; + await h.run(['store', 'list']); + const text = h.out.join('\n'); + expect(text).toContain('linear@1.2.0'); + expect(text).toContain('triage@0.1.0'); + }); + + it('emits machine-readable entries under --json', async () => { + const h = await harness(); + await h.run([ + 'store', + 'add', + reviewed(h.root, 'linear'), + '--as', + 'linear@1.2.0', + '--harness', + 'claude', + ]); + h.out.length = 0; + await h.run(['store', 'list', '--json']); + const parsed = JSON.parse(h.out.join('\n')) as Array>; + expect(parsed).toEqual([ + expect.objectContaining({ + kind: 'plugin', + entry: 'linear@1.2.0', + 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); + }); +}); diff --git a/packages/mosaic/src/commands/store-command.ts b/packages/mosaic/src/commands/store-command.ts new file mode 100644 index 00000000..5cd8adfe --- /dev/null +++ b/packages/mosaic/src/commands/store-command.ts @@ -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 `@` 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 @: ${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('', 'A reviewed directory to admit. Copied, never moved.') + .description('Admit a reviewed directory into the store at a pinned version') + .option('--kind ', `One of: ${STORE_KINDS.join(', ')}`, 'plugin') + .requiredOption('--as ', 'The entry to admit it as, written @') + .option('--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 `, + ); + } catch (error: unknown) { + fail(error, 'add'); + } + }); + + store + .command('list') + .description('List what has been admitted') + .option('--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; +} diff --git a/packages/mosaic/src/fleet/store.spec.ts b/packages/mosaic/src/fleet/store.spec.ts new file mode 100644 index 00000000..facfc228 --- /dev/null +++ b/packages/mosaic/src/fleet/store.spec.ts @@ -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', 'keep@1.0.0'); + 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('linear@1.2.0'); + expect(parseEntry('linear@1.2.0')).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@b@1.0')).toEqual({ name: 'a@b', version: '1.0' }); + }); +}); + +describe('admission', () => { + it('admits a reviewed directory as @ 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('linear@1.2.0'); + expect(entry.path).toBe(join(home, 'plugins', 'linear@1.2.0')); + 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', 'linear@1.2.0', 'marker'), 'original\n'); + + expect(() => addEntry(request)).toThrowError(StoreError); + // The refusal must leave the first admission exactly as it was. + expect(readFileSync(join(home, 'plugins', 'linear@1.2.0', '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: 'triage@0.1.0', 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('linear@1.2.0'); + 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', 'linear@9.9.9')).toThrowError(/linear@1\.2\.0/); + expect(() => requireEntry(join(root, 'empty'), 'plugin', 'linear@1.2.0')).toThrowError( + /store is empty/, + ); + }); +}); diff --git a/packages/mosaic/src/fleet/store.ts b/packages/mosaic/src/fleet/store.ts new file mode 100644 index 00000000..151f05c5 --- /dev/null +++ b/packages/mosaic/src/fleet/store.ts @@ -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 + * `@`, 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 `@` 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 `/` 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 = { 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, `@` 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 | 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 { + 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; + } 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, +): 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 `@`. + * + * 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 `@` 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 --kind ${kind} --name --version ` + : `no ${kind} named ${entry} in the store. Present: ${available.join(', ')}`, + ); +}