Files
stack/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts
T

190 lines
6.7 KiB
TypeScript

import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } 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 { registerFleetAgentScaffoldCommand } from './fleet-agent-scaffold-command.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function fleetDataHome(): Promise<string> {
root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-agent-new-'));
return join(root, '.mosaic');
}
function program(dataHome: string): Command {
const result = new Command();
result.exitOverride();
const fleet = result.command('fleet');
registerFleetAgentScaffoldCommand(fleet, { fleetDataHome: dataHome });
return result;
}
async function files(rootDir: string, prefix = ''): Promise<string[]> {
const result: string[] = [];
for (const entry of await readdir(join(rootDir, prefix), { withFileTypes: true })) {
const path = join(prefix, entry.name);
if (entry.isDirectory()) result.push(...(await files(rootDir, path)));
else result.push(path);
}
return result.sort();
}
describe('mosaic fleet agent new', (): void => {
it('creates the exact authored user-data scaffold under a temp ~/.mosaic root', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
const agent = join(dataHome, 'fleet', 'agents', 'mira');
expect(await files(agent)).toEqual([
'.claude/.claude.json',
'.claude/.credentials.json',
'.claude/CLAUDE.md',
'SOUL.md',
'overlay.json',
'profile.json',
]);
expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toEqual({
schema: 1,
harness: 'claude',
bundle: 'primary',
overlay: 'overlay.json',
env: { MOSAIC_AGENT_NAME: 'mira' },
});
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity');
expect(await readFile(join(agent, '.claude', '.claude.json'), 'utf8')).toEqual(
`${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`,
);
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(
join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'),
);
});
it('creates a Pi home without Claude onboarding state', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync([
'node',
'mosaic',
'fleet',
'agent',
'new',
'pi-seat',
'--harness',
'pi',
]);
expect(await files(join(dataHome, 'fleet', 'agents', 'pi-seat'))).toEqual([
'.pi/AGENTS.md',
'.pi/auth.json',
'SOUL.md',
'overlay.json',
'profile.json',
]);
});
it('round-trips quotes, backticks, and shell-looking input literally', async (): Promise<void> => {
const dataHome = await fleetDataHome();
const name = 'seat"`$(literal)`';
const bundle = 'bundle"`$(literal)`';
const model = 'model"`$(literal)`';
await program(dataHome).parseAsync([
'node',
'mosaic',
'fleet',
'agent',
'new',
name,
'--harness',
'pi',
'--bundle',
bundle,
'--model',
model,
]);
const agent = join(dataHome, 'fleet', 'agents', name);
expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toMatchObject({
harness: 'pi',
bundle,
model,
env: { MOSAIC_AGENT_NAME: name },
});
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain(`You are ${name},`);
expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe(
join(dataHome, 'auth', 'pi', bundle, 'auth.json'),
);
});
it.each(['', '../outside', '/absolute', 'a/b', 'a\\b'])(
'rejects unsafe agent name %j with a non-zero outcome',
async (name: string): Promise<void> => {
const dataHome = await fleetDataHome();
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', name]);
} catch {
// Commander rejects a missing positional before the action. That is also
// a non-zero CLI failure; all other unsafe names reach the scaffold.
process.exitCode = 1;
}
expect(process.exitCode).toBe(1);
if (name !== '')
expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request'));
},
);
it.each([
['--harness', 'codex'],
['--bundle', '../outside'],
['--model', ''],
])(
'returns non-zero for invalid %s input',
async (option: string, value: string): Promise<void> => {
const dataHome = await fleetDataHome();
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await program(dataHome).parseAsync([
'node',
'mosaic',
'fleet',
'agent',
'new',
'mira',
option,
value,
]);
expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request'));
},
);
it('is idempotent for byte-identical content and refuses a changed user file', async (): Promise<void> => {
const dataHome = await fleetDataHome();
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira'];
await program(dataHome).parseAsync(command);
await program(dataHome).parseAsync(command);
expect(process.exitCode).toBeUndefined();
const soul = join(dataHome, 'fleet', 'agents', 'mira', 'SOUL.md');
await writeFile(soul, '# user-owned change\n');
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await program(dataHome).parseAsync(command);
expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(expect.stringContaining('SOUL.md'));
expect(await readFile(soul, 'utf8')).toBe('# user-owned change\n');
});
it('does not follow a managed credential link while comparing existing content', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json');
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
expect(process.exitCode).toBeUndefined();
});
});