fleet: move directories off managed paths instead of refusing forever
Launch will not delete a real directory sitting where it expects a managed link -- an auth/<harness>/primary that someone logged into by hand, or a plugin directory a seat acquired before the central store existed. That refusal is right and it is also a dead end: the operator gets a composition error and no way forward. `mosaic fleet adopt` is the way forward. Bare, it lists every such directory and the command that resolves it. With a verb, it moves one where it belongs. Nothing here deletes. A promotion is a rename; an occupied destination is a refusal, not a merge; a cross-device rename is reported rather than retried as copy-then-delete, because a copy-then-delete is a delete. Store adoption stops at the move and does not install the link. The seat's .mosaic-managed-links.json belongs to launch, and a link written behind it fails the next composition as an unrecorded symlink -- one refusal traded for another. The next launch installs and records it when the profile lists the entry; whether a seat gets a plugin stays `mosaic fleet plugin`'s decision. W-F3 of docs/plans/2026-08-14_fleet-seats-on-web1.md.
This commit is contained in:
@@ -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<void>;
|
||||
}
|
||||
|
||||
beforeEach((): void => {
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
async function harness(): Promise<Harness> {
|
||||
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<void> => {
|
||||
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<string, unknown>): 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 <account>');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 <agent>',
|
||||
);
|
||||
}
|
||||
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>', `Harness: ${HARNESSES.join(', ')}`)
|
||||
.requiredOption('--as <bundle>', '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} <name>`)
|
||||
.description(`Move a real ${store} directory out of a seat and into the central store`)
|
||||
.requiredOption('--seat <agent>', '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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'add',
|
||||
'adopt',
|
||||
'agent',
|
||||
'apply',
|
||||
'backlog',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> => {
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
async function userHome(): Promise<string> {
|
||||
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<string, unknown> = { 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 <account>');
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/<harness>/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<StoreKind, string> = { 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<string, unknown>;
|
||||
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 <account>`,
|
||||
});
|
||||
}
|
||||
|
||||
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} <bundle>`,
|
||||
);
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user