diff --git a/packages/mosaic/src/commands/fleet-adopt-command.spec.ts b/packages/mosaic/src/commands/fleet-adopt-command.spec.ts new file mode 100644 index 00000000..ef8a05ce --- /dev/null +++ b/packages/mosaic/src/commands/fleet-adopt-command.spec.ts @@ -0,0 +1,196 @@ +import { mkdirSync, readFileSync, symlinkSync, 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, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerFleetAdoptCommand } from './fleet-adopt-command.js'; + +let root: string | undefined; + +interface Harness { + readonly home: string; + readonly out: string[]; + readonly err: string[]; + readonly run: (argv: string[]) => Promise; +} + +beforeEach((): void => { + process.exitCode = undefined; +}); + +afterEach(async (): Promise => { + vi.restoreAllMocks(); + process.exitCode = undefined; + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function harness(): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-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(); + const fleet = program.command('fleet'); + registerFleetAdoptCommand(fleet, { fleetDataHome: home }); + return { + home, + out, + err, + run: async (argv: string[]): Promise => { + await program.parseAsync(['node', 'mosaic', 'fleet', 'adopt', ...argv]); + }, + }; +} + +function realAliasDirectory(home: string, harnessName: string): string { + const path = join(home, 'auth', harnessName, 'primary'); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, '.credentials.json'), '{"token":"kept"}'); + return path; +} + +function seat(home: string, name: string, profile: Record): void { + const dir = join(home, 'fleet', 'agents', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`); +} + +function seatDirectory(home: string, agent: string, plural: string, name: string): string { + const path = join(home, 'fleet', 'agents', agent, '.claude', plural, name); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, 'marker.txt'), 'kept'); + return path; +} + +describe('mosaic fleet adopt', () => { + it('says there is nothing to adopt on a clean host', async () => { + const h = await harness(); + + await h.run([]); + + expect(h.out.join('\n')).toContain('Nothing to adopt'); + expect(process.exitCode).toBeUndefined(); + }); + + // A read-only listing that exits non-zero is one people stop running, so the scan reports + // and stays out of the way. + it('lists each finding with the command that resolves it, and exits zero', async () => { + const h = await harness(); + const path = realAliasDirectory(h.home, 'claude'); + + await h.run([]); + + const printed = h.out.join('\n'); + expect(printed).toContain(path); + expect(printed).toContain('resolve: mosaic fleet adopt bundle --harness claude --as '); + expect(printed).toContain('1 found, 0 needing a decision before adoption. Nothing was moved.'); + expect(process.exitCode).toBeUndefined(); + }); + + it('separates findings it can resolve from findings that need a decision first', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' }); + seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer'); + mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true }); + + await h.run([]); + + const printed = h.out.join('\n'); + expect(printed).toContain('blocked (destination occupied)'); + expect(printed).toContain('1 found, 1 needing a decision before adoption.'); + }); + + it('adopts a bundle and reports where the credentials went and what the alias points at', async () => { + const h = await harness(); + realAliasDirectory(h.home, 'claude'); + + await h.run(['bundle', '--harness', 'claude', '--as', 'jason_woltje.com']); + + const target = join(h.home, 'auth', 'claude', 'jason_woltje.com'); + expect(readFileSync(join(target, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}'); + const printed = h.out.join('\n'); + expect(printed).toContain(`bundle: ${target}`); + expect(printed).toContain('-> jason_woltje.com'); + // The name is the operator's claim about the account; only a listing shows what is in it. + expect(printed).toContain('mosaic auth list --harness claude'); + expect(process.exitCode).toBeUndefined(); + }); + + it('rejects an unknown harness instead of building a path out of it', async () => { + const h = await harness(); + + await h.run(['bundle', '--harness', 'nonsense', '--as', 'x']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('--harness must be one of: claude, codex, opencode, pi'); + }); + + it('exits non-zero and names the failure when there is nothing to adopt', async () => { + const h = await harness(); + + await h.run(['bundle', '--harness', 'pi', '--as', 'jason_woltje.com']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('mosaic fleet adopt bundle failed (nothing-to-adopt)'); + }); + + it('adopts a plugin into the store and says the next launch links it back', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { + schema: 1, + harness: 'claude', + bundle: 'primary', + plugins: ['reviewer'], + }); + seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer'); + + await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']); + + expect(readFileSync(join(h.home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe('kept'); + expect(h.out.join('\n')).toContain('next launch links it back from the store'); + }); + + it('says plainly when no seat uses the adopted entry yet', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' }); + seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer'); + + await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']); + + expect(h.out.join('\n')).toContain("is not listed in uc-e6-coder's profile"); + }); + + it('adopts a skill into the skill store, not the plugin store', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-rev', { schema: 1, harness: 'claude', bundle: 'primary' }); + seatDirectory(h.home, 'uc-e6-rev', 'skills', 'spec-audit'); + + await h.run(['skill', 'spec-audit', '--seat', 'uc-e6-rev']); + + expect(readFileSync(join(h.home, 'skills', 'spec-audit', 'marker.txt'), 'utf8')).toBe('kept'); + }); + + it('leaves an already-linked entry alone and exits non-zero', async () => { + const h = await harness(); + seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' }); + mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true }); + const installRoot = join(h.home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins'); + mkdirSync(installRoot, { recursive: true }); + symlinkSync(join(h.home, 'plugins', 'reviewer'), join(installRoot, 'reviewer')); + + await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']); + + expect(process.exitCode).toBe(1); + expect(h.err.join('')).toContain('already a link into the store'); + }); +}); diff --git a/packages/mosaic/src/commands/fleet-adopt-command.ts b/packages/mosaic/src/commands/fleet-adopt-command.ts new file mode 100644 index 00000000..41efce5d --- /dev/null +++ b/packages/mosaic/src/commands/fleet-adopt-command.ts @@ -0,0 +1,137 @@ +/** + * `mosaic fleet adopt` -- resolve the real directories that sit where a managed link belongs. + * + * Launch refuses to delete anything an operator put on a managed path, which is right, but on + * its own it leaves the operator holding a composition error and no way forward. This command + * is the way forward: bare, it lists every such directory and the command that resolves it; + * with a verb, it moves one of them where it belongs. + * + * The bare scan reads only, and exits zero whatever it finds. It is meant to be safe to run + * out of curiosity, and a non-zero exit from a read-only listing would make it something + * people avoid running. + */ + +import type { Command } from 'commander'; +import { + AdoptionError, + type StoreKind, + promoteBundleAlias, + promoteStoreEntry, + scanAdoptions, +} from '../fleet/adoption.js'; +import type { CredentialHarness } from '../fleet/credential-sharing.js'; +import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js'; + +const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi']; + +export interface FleetAdoptCommandDeps { + /** Test seam for the user-owned ~/.mosaic root. */ + readonly fleetDataHome?: string; +} + +function requireHarness(value: string | undefined): CredentialHarness { + if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) { + throw new AdoptionError('invalid-request', `--harness must be one of: ${HARNESSES.join(', ')}`); + } + return value as CredentialHarness; +} + +function requireSeat(value: string | undefined): string { + if (value === undefined || value.trim() === '') { + throw new AdoptionError( + 'invalid-request', + 'give the seat this directory belongs to: --seat ', + ); + } + return value; +} + +function fail(error: unknown, verb: string): void { + process.exitCode = 1; + const message = error instanceof Error ? error.message : String(error); + const code = error instanceof AdoptionError ? error.code : 'failed'; + process.stderr.write( + `mosaic fleet adopt${verb === '' ? '' : ` ${verb}`} failed (${code}): ${message}\n`, + ); +} + +/** Registers the adoption scan and its three promotion verbs. */ +export function registerFleetAdoptCommand( + fleetCommand: Command, + deps: FleetAdoptCommandDeps = {}, +): void { + const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome(); + + const adopt = fleetCommand + .command('adopt') + .description('Find and resolve real directories occupying paths the fleet manages with links') + .action((): void => { + try { + const findings = scanAdoptions(dataHome()); + if (findings.length === 0) { + console.log('Nothing to adopt: no real directory occupies a managed path.'); + return; + } + for (const finding of findings) { + console.log(finding.path); + console.log(` ${finding.reason}`); + console.log( + finding.blocked === undefined + ? ` resolve: ${finding.remedy}` + : ` blocked (${finding.blocked}): ${finding.remedy}`, + ); + } + const blocked = findings.filter((finding) => finding.blocked !== undefined).length; + console.log( + `\n${String(findings.length)} found, ${String(blocked)} needing a decision before adoption. Nothing was moved.`, + ); + } catch (error: unknown) { + fail(error, ''); + } + }); + + adopt + .command('bundle') + .description(`Adopt a real directory on the "primary" alias path as a named bundle`) + .requiredOption('--harness ', `Harness: ${HARNESSES.join(', ')}`) + .requiredOption('--as ', 'Account this directory holds, e.g. jason_woltje.com') + .action((options: { harness?: string; as: string }): void => { + try { + const result = promoteBundleAlias(dataHome(), requireHarness(options.harness), options.as); + console.log(`Adopted ${result.from}`); + console.log(` bundle: ${result.to}`); + console.log(` alias: ${result.alias} -> ${result.bundle}`); + console.log( + `\nCheck the account it actually holds before trusting the name:\n mosaic auth list --harness ${result.harness}`, + ); + } catch (error: unknown) { + fail(error, 'bundle'); + } + }); + + for (const store of ['plugin', 'skill'] as const) { + adopt + .command(`${store} `) + .description(`Move a real ${store} directory out of a seat and into the central store`) + .requiredOption('--seat ', 'Seat the directory currently sits in') + .action((name: string, options: { seat?: string }): void => { + try { + const result = promoteStoreEntry( + dataHome(), + requireSeat(options.seat), + store as StoreKind, + name, + ); + console.log(`Adopted ${result.from}`); + console.log(` store: ${result.to}`); + console.log( + result.listedInProfile + ? `\n"${result.name}" is listed in ${result.agent}'s profile, so its next launch links it back from the store.` + : `\n"${result.name}" is not listed in ${result.agent}'s profile, so no seat uses it yet. It is now vetted store content any seat can be given.`, + ); + } catch (error: unknown) { + fail(error, store); + } + }); + } +} diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index c6e6b516..1bff81bc 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -82,6 +82,7 @@ describe('registerFleetCommand', () => { expect(fleet).toBeDefined(); expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ 'add', + 'adopt', 'agent', 'apply', 'backlog', diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 56f3171a..942a0e20 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -42,6 +42,7 @@ import { registerFleetAgentScaffoldCommand, type FleetAgentScaffoldCommandDeps, } from './fleet-agent-scaffold-command.js'; +import { registerFleetAdoptCommand } from './fleet-adopt-command.js'; import { registerFleetMigrationCommand, type FleetMigrationCommandDeps, @@ -2080,6 +2081,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = ...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }), mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome, }); + // The counterpart to launch's refusals: launch will not delete a real directory sitting on + // a managed path, and this is how one gets moved out of the way instead. + registerFleetAdoptCommand(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/fleet/adoption.spec.ts b/packages/mosaic/src/fleet/adoption.spec.ts new file mode 100644 index 00000000..a5b01e5b --- /dev/null +++ b/packages/mosaic/src/fleet/adoption.spec.ts @@ -0,0 +1,341 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AdoptionError, promoteBundleAlias, promoteStoreEntry, scanAdoptions } from './adoption.js'; + +let root: string | undefined; + +afterEach(async (): Promise => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function userHome(): Promise { + root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-')); + return join(root, '.mosaic'); +} + +/** A real directory where the primary alias belongs, with something inside worth not losing. */ +function realAliasDirectory( + home: string, + harness: string, + credential = '.credentials.json', +): string { + const path = join(home, 'auth', harness, 'primary'); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, credential), '{"token":"kept"}'); + return path; +} + +function seat( + home: string, + name: string, + profile: Record = { schema: 1, harness: 'claude', bundle: 'primary' }, +): string { + const dir = join(home, 'fleet', 'agents', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`); + return dir; +} + +/** A real plugin/skill directory inside a seat, where a link into the store belongs. */ +function seatDirectory( + home: string, + agent: string, + harness: string, + plural: string, + name: string, +): string { + const path = join(home, 'fleet', 'agents', agent, `.${harness}`, plural, name); + mkdirSync(path, { recursive: true }); + writeFileSync(join(path, 'marker.txt'), 'kept'); + return path; +} + +describe('scanAdoptions', () => { + it('finds nothing on a host that has no ~/.mosaic at all', async () => { + expect(scanAdoptions(await userHome())).toEqual([]); + }); + + it('finds a real directory on the primary alias path and names the command that resolves it', async () => { + const home = await userHome(); + const path = realAliasDirectory(home, 'claude'); + + const findings = scanAdoptions(home); + + expect(findings).toHaveLength(1); + expect(findings[0]?.kind).toBe('bundle-alias'); + expect(findings[0]?.path).toBe(path); + expect(findings[0]?.harness).toBe('claude'); + expect(findings[0]?.blocked).toBeUndefined(); + expect(findings[0]?.remedy).toBe('mosaic fleet adopt bundle --harness claude --as '); + }); + + it('ignores a primary alias that is already a symlink', async () => { + const home = await userHome(); + mkdirSync(join(home, 'auth', 'claude', 'jason_woltje.com'), { recursive: true }); + symlinkSync('jason_woltje.com', join(home, 'auth', 'claude', 'primary')); + + expect(scanAdoptions(home)).toEqual([]); + }); + + it('finds a real plugin directory inside a seat', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + const path = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + const findings = scanAdoptions(home); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + kind: 'store-entry', + path, + agent: 'uc-e6-coder', + store: 'plugin', + name: 'reviewer', + }); + expect(findings[0]?.remedy).toBe('mosaic fleet adopt plugin reviewer --seat uc-e6-coder'); + }); + + it('finds skills the same way it finds plugins', async () => { + const home = await userHome(); + seat(home, 'uc-e6-rev'); + seatDirectory(home, 'uc-e6-rev', 'claude', 'skills', 'spec-audit'); + + const findings = scanAdoptions(home); + + expect(findings).toHaveLength(1); + expect(findings[0]?.store).toBe('skill'); + expect(findings[0]?.remedy).toBe('mosaic fleet adopt skill spec-audit --seat uc-e6-rev'); + }); + + // Scanning the wrong directory name would report nothing on a pi seat while launch keeps + // refusing to compose it, which is worse than not having the scan. + it('looks in the seat home the launcher uses, not always the claude one', async () => { + const home = await userHome(); + seat(home, 'terra', { schema: 1, harness: 'pi', bundle: 'primary' }); + const path = seatDirectory(home, 'terra', 'pi', 'plugins', 'notes'); + + expect(scanAdoptions(home).map((finding) => finding.path)).toEqual([path]); + }); + + it('does not report a link that is already pointing into the store', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins'); + mkdirSync(installRoot, { recursive: true }); + symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer')); + + expect(scanAdoptions(home)).toEqual([]); + }); + + it('marks the finding blocked when the store already holds that name', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + + const findings = scanAdoptions(home); + + expect(findings[0]?.blocked).toBe('destination occupied'); + expect(findings[0]?.remedy).toContain('compare the two'); + }); + + // One malformed profile hiding every finding behind it would make the scan useless exactly + // on the hosts that need it most. + it('reports an unreadable seat as a gap and keeps scanning the others', async () => { + const home = await userHome(); + mkdirSync(join(home, 'fleet', 'agents', 'broken'), { recursive: true }); + writeFileSync(join(home, 'fleet', 'agents', 'broken', 'profile.json'), 'not json'); + seat(home, 'working'); + const path = seatDirectory(home, 'working', 'claude', 'plugins', 'reviewer'); + + const findings = scanAdoptions(home); + + expect(findings.map((finding) => finding.kind)).toEqual(['unreadable-seat', 'store-entry']); + expect(findings[0]?.blocked).toBe('unreadable profile'); + expect(findings[1]?.path).toBe(path); + }); +}); + +describe('promoteBundleAlias', () => { + it('moves the directory to its account name and points the alias at it', async () => { + const home = await userHome(); + const from = realAliasDirectory(home, 'claude'); + + const result = promoteBundleAlias(home, 'claude', 'jason_woltje.com'); + + expect(result.to).toBe(join(home, 'auth', 'claude', 'jason_woltje.com')); + expect(result.from).toBe(from); + // The credential travelled with the directory; adoption is a move, never a re-creation. + expect(readFileSync(join(result.to, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}'); + const alias = lstatSync(result.alias); + expect(alias.isSymbolicLink()).toBe(true); + expect(scanAdoptions(home)).toEqual([]); + }); + + it('refuses when the alias path is already a symlink', async () => { + const home = await userHome(); + mkdirSync(join(home, 'auth', 'pi', 'jason_woltje.com'), { recursive: true }); + symlinkSync('jason_woltje.com', join(home, 'auth', 'pi', 'primary')); + + expect(() => promoteBundleAlias(home, 'pi', 'other')).toThrow( + /already an alias symlink.*mosaic auth default/su, + ); + }); + + it('refuses when there is nothing on the alias path', async () => { + const home = await userHome(); + expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(AdoptionError); + }); + + it('refuses to adopt a directory as the alias name itself', async () => { + const home = await userHome(); + realAliasDirectory(home, 'claude'); + + expect(() => promoteBundleAlias(home, 'claude', 'primary')).toThrow( + /that is the alias being freed/u, + ); + }); + + it('refuses a name that would escape the auth root', async () => { + const home = await userHome(); + realAliasDirectory(home, 'claude'); + + expect(() => promoteBundleAlias(home, 'claude', '../elsewhere')).toThrow( + /not a safe bundle name/u, + ); + expect(existsSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'))).toBe(true); + }); + + // The failure that would cost data: an occupied destination silently merged into, or worse, + // replaced. Both directories must still be exactly where they were. + it('refuses an occupied destination and moves nothing', async () => { + const home = await userHome(); + realAliasDirectory(home, 'claude'); + const occupied = join(home, 'auth', 'claude', 'jason_woltje.com'); + mkdirSync(occupied, { recursive: true }); + writeFileSync(join(occupied, '.credentials.json'), '{"token":"other"}'); + + expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow( + /already exists and will not be overwritten/u, + ); + expect(readFileSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'), 'utf8')).toBe( + '{"token":"kept"}', + ); + expect(readFileSync(join(occupied, '.credentials.json'), 'utf8')).toBe('{"token":"other"}'); + }); +}); + +describe('promoteStoreEntry', () => { + it('moves the directory into the central store, creating the store root', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder', { + schema: 1, + harness: 'claude', + bundle: 'primary', + plugins: ['reviewer'], + }); + const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + const result = promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer'); + + expect(result.to).toBe(join(home, 'plugins', 'reviewer')); + expect(readFileSync(join(result.to, 'marker.txt'), 'utf8')).toBe('kept'); + expect(existsSync(from)).toBe(false); + expect(result.listedInProfile).toBe(true); + }); + + // Installing the link here would fail the next launch as an unrecorded symlink, because the + // seat's .mosaic-managed-links.json is launch's to write. Adoption stops at the move. + it('leaves the seat path empty rather than installing the link itself', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder', { + schema: 1, + harness: 'claude', + bundle: 'primary', + plugins: ['reviewer'], + }); + const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer'); + + expect(existsSync(from)).toBe(false); + expect(() => lstatSync(from)).toThrow(); + }); + + it('says when the seat does not list the entry, because then nothing links it back', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + + expect(promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer').listedInProfile).toBe( + false, + ); + }); + + it('refuses an occupied destination and moves nothing', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + writeFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'store copy'); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow( + /already exists and will not be overwritten/u, + ); + expect(readFileSync(join(from, 'marker.txt'), 'utf8')).toBe('kept'); + expect(readFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe( + 'store copy', + ); + }); + + it('refuses an entry that is already a link into the store', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true }); + const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins'); + mkdirSync(installRoot, { recursive: true }); + symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer')); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow( + /already a link into the store/u, + ); + }); + + it('refuses a name that would escape the store root', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', '../escape')).toThrow( + /not a safe plugin name/u, + ); + }); + + it('names the profile it could not read rather than guessing the seat home', async () => { + const home = await userHome(); + + expect(() => promoteStoreEntry(home, 'ghost', 'plugin', 'reviewer')).toThrow( + /ghost.*profile\.json.*cannot be located/su, + ); + }); + + it('reports a missing directory as nothing to adopt', async () => { + const home = await userHome(); + seat(home, 'uc-e6-coder'); + + expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'skill', 'absent')).toThrow( + /no such skill directory/u, + ); + }); +}); diff --git a/packages/mosaic/src/fleet/adoption.ts b/packages/mosaic/src/fleet/adoption.ts new file mode 100644 index 00000000..c5c819c8 --- /dev/null +++ b/packages/mosaic/src/fleet/adoption.ts @@ -0,0 +1,380 @@ +/** + * Adopting real directories that sit where the fleet expects a managed link. + * + * A host used before the fleet arrived -- or an operator who ran a login by hand -- ends up + * with a real directory on a path launch reserves for a link: `auth//primary`, or a + * plugin/skill directory inside a seat's home. Launch refuses those on purpose, because the + * only way to make a link fit there is to delete whatever is already there. + * + * This module is the other half of that refusal. It finds those directories and moves them + * where they belong. Nothing here deletes anything: a promotion is a rename, and an occupied + * destination is a refusal rather than a merge or an overwrite. Cross-device renames are + * surfaced instead of being retried as copy-then-delete, because a copy-then-delete is a + * delete and this module does not do that. + * + * Link creation is deliberately NOT done here. Seat store links are recorded in the seat's + * `.mosaic-managed-links.json`, and that manifest is owned by launch -- a link installed + * behind its back reads as "unrecorded symlink occupies managed path" on the next launch, + * which trades one refusal for another. So a promoted plugin lands in the central store and + * the next launch links it, provided the seat's profile lists it. Whether a seat gets a + * plugin is `mosaic fleet plugin`'s decision, not this one's. + * + * The auth alias is different: it lives in the auth root, no manifest covers it, and + * setDefaultBundle() already owns installing it. So a bundle promotion finishes the job. + */ + +import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, type Stats } from 'node:fs'; +import { join } from 'node:path'; +import { PRIMARY_ALIAS, assertSafeBundleName, authRoot, setDefaultBundle } from './auth-bundles.js'; +import type { CredentialHarness } from './credential-sharing.js'; + +/** Mirrors the harness list the auth and launch surfaces accept. */ +const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi']; + +/** Store kinds a seat can hold, and the directory name each uses in both trees. */ +const STORE_DIRECTORY: Record = { plugin: 'plugins', skill: 'skills' }; + +/** Same charset as a bundle name; anything with a separator or a dot-dot never reaches a join. */ +const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/; + +export type StoreKind = 'plugin' | 'skill'; + +export type AdoptionErrorCode = + | 'invalid-request' + | 'nothing-to-adopt' + | 'destination-occupied' + | 'cross-device' + | 'unsafe-shape'; + +export class AdoptionError extends Error { + readonly code: AdoptionErrorCode; + + constructor(code: AdoptionErrorCode, message: string) { + super(message); + this.name = 'AdoptionError'; + this.code = code; + } +} + +export interface AdoptionFinding { + /** `bundle-alias` and `store-entry` are adoptable; `unreadable-seat` is a scan gap. */ + readonly kind: 'bundle-alias' | 'store-entry' | 'unreadable-seat'; + /** The real directory that a launch would refuse to touch. */ + readonly path: string; + /** What this is, in one line. */ + readonly reason: string; + /** The exact command that resolves it, or what to look at when nothing can. */ + readonly remedy: string; + readonly harness?: CredentialHarness; + readonly agent?: string; + readonly store?: StoreKind; + readonly name?: string; + /** Set when the promotion cannot run as-is; the remedy then describes the obstacle. */ + readonly blocked?: string; +} + +export interface BundlePromotion { + readonly harness: CredentialHarness; + /** Where the adopted directory now lives. */ + readonly bundle: string; + readonly from: string; + readonly to: string; + /** The alias path now pointing at it. */ + readonly alias: string; +} + +export interface StorePromotion { + readonly agent: string; + readonly store: StoreKind; + readonly name: string; + readonly from: string; + readonly to: string; + /** True when the seat's profile lists this entry, so the next launch will link it back. */ + readonly listedInProfile: boolean; +} + +function lstatIfPresent(path: string): Stats | undefined { + try { + return lstatSync(path); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function isRealDirectory(path: string): boolean { + const info = lstatIfPresent(path); + return info !== undefined && info.isDirectory() && !info.isSymbolicLink(); +} + +function assertSafeEntryName(name: string, store: StoreKind): void { + if (!ENTRY_NAME.test(name)) { + throw new AdoptionError( + 'invalid-request', + `"${name}" is not a safe ${store} name; use letters, digits, and . _ @ -`, + ); + } +} + +function agentsRoot(dataHome: string): string { + return join(dataHome, 'fleet', 'agents'); +} + +/** + * A seat's harness home, by the same rule launch uses (`harnessHome()` in commands/launch.ts). + * Scanning by any other rule finds directories launch never looks at and misses the ones it + * refuses on. + */ +function seatHome(dataHome: string, agent: string, harness: CredentialHarness): string { + return join(agentsRoot(dataHome), agent, `.${harness}`); +} + +interface SeatProfile { + readonly harness: CredentialHarness; + readonly plugins: readonly string[]; + readonly skills: readonly string[]; +} + +/** + * Read only what adoption needs out of a seat profile, leniently. + * + * A scan that dies on one malformed profile hides every finding behind it, so an unreadable + * profile is reported as a scan gap and the walk continues. Strictness belongs at launch, + * which validates the whole profile and refuses to run the seat. + */ +function readSeatProfile(dataHome: string, agent: string): SeatProfile | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(join(agentsRoot(dataHome), agent, 'profile.json'), 'utf8')); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined; + const raw = parsed as Record; + const harness = raw['harness']; + if (typeof harness !== 'string' || !HARNESSES.includes(harness as CredentialHarness)) { + return undefined; + } + const names = (value: unknown): string[] => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; + return { + harness: harness as CredentialHarness, + plugins: names(raw['plugins']), + skills: names(raw['skills']), + }; +} + +function listDirectory(path: string): string[] { + try { + return readdirSync(path, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => entry.name) + .sort(); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } +} + +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; + } +} + +/** + * Everything under `~/.mosaic` that occupies a path the fleet manages with a link. + * + * Read-only. Every finding carries the command that resolves it, because the value of the + * scan is that an operator does not have to work out what a composition refusal meant. + */ +export function scanAdoptions(dataHome: string): AdoptionFinding[] { + const findings: AdoptionFinding[] = []; + + for (const harness of HARNESSES) { + const alias = join(authRoot(dataHome, harness), PRIMARY_ALIAS); + if (!isRealDirectory(alias)) continue; + findings.push({ + kind: 'bundle-alias', + path: alias, + harness, + reason: `a real directory occupies the ${PRIMARY_ALIAS} alias path; ${harness} seats pointed at "${PRIMARY_ALIAS}" cannot launch`, + remedy: `mosaic fleet adopt bundle --harness ${harness} --as `, + }); + } + + for (const agent of listAgents(dataHome)) { + const profile = readSeatProfile(dataHome, agent); + if (profile === undefined) { + findings.push({ + kind: 'unreadable-seat', + path: join(agentsRoot(dataHome), agent, 'profile.json'), + agent, + reason: + 'profile could not be read, or names no known harness, so this seat was not scanned', + remedy: `mosaic fleet agent get ${agent}`, + blocked: 'unreadable profile', + }); + continue; + } + for (const store of ['plugin', 'skill'] as const) { + const plural = STORE_DIRECTORY[store]; + const installRoot = join(seatHome(dataHome, agent, profile.harness), plural); + for (const name of listDirectory(installRoot)) { + const destination = join(dataHome, plural, name); + const occupied = lstatIfPresent(destination) !== undefined; + findings.push({ + kind: 'store-entry', + path: join(installRoot, name), + agent, + store, + name, + reason: `a real ${store} directory sits where the seat expects a link into the central store`, + remedy: occupied + ? `${destination} already exists; compare the two and remove or rename one by hand` + : `mosaic fleet adopt ${store} ${name} --seat ${agent}`, + ...(occupied ? { blocked: 'destination occupied' } : {}), + }); + } + } + } + + return findings; +} + +/** + * Move a directory, refusing every case where the move would cost data. + * + * EXDEV is surfaced rather than handled: the fallback for a cross-device rename is copy then + * delete, and this module does not delete. + */ +function movePreservingBoth(from: string, to: string, label: string): void { + if (lstatIfPresent(to) !== undefined) { + throw new AdoptionError( + 'destination-occupied', + `${label} destination already exists and will not be overwritten: ${to}`, + ); + } + try { + renameSync(from, to); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + throw new AdoptionError( + 'cross-device', + `${from} and ${to} are on different filesystems, so this cannot be a rename. Copy it across yourself and remove the original once you have checked the copy: ${to}`, + ); + } + throw error; + } +} + +/** + * Adopt a real directory sitting on the `primary` alias path as a named bundle. + * + * The directory is moved to its account name first and the alias installed second. That order + * is the one that survives a failure: if the alias cannot be created, the credentials are + * intact under their own name and the error says where they are. The reverse order would have + * a window where the alias points at nothing. + */ +export function promoteBundleAlias( + dataHome: string, + harness: CredentialHarness, + as: string, +): BundlePromotion { + assertSafeBundleName(as); + if (as === PRIMARY_ALIAS) { + throw new AdoptionError( + 'invalid-request', + `--as must be the account this directory holds, not "${PRIMARY_ALIAS}" — that is the alias being freed`, + ); + } + const root = authRoot(dataHome, harness); + const alias = join(root, PRIMARY_ALIAS); + const info = lstatIfPresent(alias); + if (info === undefined) { + throw new AdoptionError('nothing-to-adopt', `nothing at ${alias}; there is nothing to adopt`); + } + if (info.isSymbolicLink()) { + throw new AdoptionError( + 'nothing-to-adopt', + `${alias} is already an alias symlink. Retarget it with: mosaic auth default --harness ${harness} `, + ); + } + if (!info.isDirectory()) { + throw new AdoptionError( + 'unsafe-shape', + `${alias} is neither a directory nor a symlink; adoption only moves directories`, + ); + } + + const destination = join(root, as); + movePreservingBoth(alias, destination, 'bundle'); + return { + harness, + bundle: as, + from: alias, + to: destination, + alias: setDefaultBundle(dataHome, harness, as), + }; +} + +/** + * Adopt a real plugin/skill directory out of a seat and into the central store. + * + * No link is installed. The seat's link manifest belongs to launch, and a link this command + * created behind it would fail the next composition as an unrecorded symlink. The next launch + * installs and records the link itself when the seat's profile lists the entry -- and when it + * does not, the entry is now vetted store content that any seat can be given deliberately, + * which is the outcome that was wanted anyway. + */ +export function promoteStoreEntry( + dataHome: string, + agent: string, + store: StoreKind, + name: string, +): StorePromotion { + assertSafeEntryName(name, store); + const profile = readSeatProfile(dataHome, agent); + if (profile === undefined) { + throw new AdoptionError( + 'invalid-request', + `cannot read a harness out of ${join(agentsRoot(dataHome), agent, 'profile.json')}, so the seat's home cannot be located`, + ); + } + const plural = STORE_DIRECTORY[store]; + const source = join(seatHome(dataHome, agent, profile.harness), plural, name); + const info = lstatIfPresent(source); + if (info === undefined) { + throw new AdoptionError('nothing-to-adopt', `no such ${store} directory: ${source}`); + } + if (info.isSymbolicLink()) { + throw new AdoptionError( + 'nothing-to-adopt', + `${source} is already a link into the store; there is nothing to adopt`, + ); + } + if (!info.isDirectory()) { + throw new AdoptionError( + 'unsafe-shape', + `${source} is not a directory; adoption only moves directories`, + ); + } + + mkdirSync(join(dataHome, plural), { recursive: true }); + const destination = join(dataHome, plural, name); + movePreservingBoth(source, destination, store); + return { + agent, + store, + name, + from: source, + to: destination, + listedInProfile: (store === 'plugin' ? profile.plugins : profile.skills).includes(name), + }; +}