190 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
});
|