feat(companion): add bootstrap manifest export for app packaging
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Command } from 'commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -6,6 +9,7 @@ const {
|
||||
mockGetConfigPath,
|
||||
mockRuntimeCtorArgs,
|
||||
mockRuntimeInstances,
|
||||
mockCreateCompanionBootstrapManifest,
|
||||
} = vi.hoisted(() => {
|
||||
const runtimeCtorArgs: Array<{ url: string; token?: string; autoReconnect?: boolean }> = [];
|
||||
const runtimeInstances: Array<{
|
||||
@@ -29,12 +33,42 @@ const {
|
||||
}));
|
||||
|
||||
const getConfigPath = vi.fn(() => '/tmp/flynn-config.yaml');
|
||||
const createCompanionBootstrapManifest = vi.fn((input: {
|
||||
gatewayUrl: string;
|
||||
gatewayToken?: string;
|
||||
nodeId: string;
|
||||
role: string;
|
||||
platform: string;
|
||||
capabilities: string[];
|
||||
heartbeatSeconds: number;
|
||||
handoffTimeoutMs: number;
|
||||
autoReconnect: boolean;
|
||||
}) => ({
|
||||
schemaVersion: 1,
|
||||
generatedAt: '2026-02-27T00:00:00.000Z',
|
||||
gateway: {
|
||||
url: input.gatewayUrl,
|
||||
...(input.gatewayToken ? { token: input.gatewayToken } : {}),
|
||||
},
|
||||
node: {
|
||||
nodeId: input.nodeId,
|
||||
role: input.role,
|
||||
platform: input.platform,
|
||||
capabilities: input.capabilities,
|
||||
},
|
||||
runtime: {
|
||||
heartbeatSeconds: input.heartbeatSeconds,
|
||||
handoffTimeoutMs: input.handoffTimeoutMs,
|
||||
autoReconnect: input.autoReconnect,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
mockLoadConfigSafe: loadConfigSafe,
|
||||
mockGetConfigPath: getConfigPath,
|
||||
mockRuntimeCtorArgs: runtimeCtorArgs,
|
||||
mockRuntimeInstances: runtimeInstances,
|
||||
mockCreateCompanionBootstrapManifest: createCompanionBootstrapManifest,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,6 +78,7 @@ vi.mock('./shared.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../companion/index.js', () => ({
|
||||
createCompanionBootstrapManifest: mockCreateCompanionBootstrapManifest,
|
||||
CompanionRuntimeClient: class {
|
||||
private connectionHandlers: Array<(event: { status: string }) => void> = [];
|
||||
connect = vi.fn(async () => {
|
||||
@@ -80,6 +115,7 @@ describe('companion command', () => {
|
||||
vi.clearAllMocks();
|
||||
mockRuntimeCtorArgs.length = 0;
|
||||
mockRuntimeInstances.length = 0;
|
||||
mockCreateCompanionBootstrapManifest.mockClear();
|
||||
mockLoadConfigSafe.mockReturnValue({
|
||||
config: {
|
||||
server: {
|
||||
@@ -175,6 +211,94 @@ describe('companion command', () => {
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('exports bootstrap manifest and exits without runtime connection', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-companion-bootstrap-'));
|
||||
const outputPath = join(tempDir, 'manifest.json');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const program = new Command();
|
||||
const { registerCompanionCommand } = await import('./companion.js');
|
||||
registerCompanionCommand(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'companion',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--node-id',
|
||||
'ios-device',
|
||||
'--heartbeat',
|
||||
'45',
|
||||
'--handoff-timeout',
|
||||
'5000',
|
||||
'--export-bootstrap',
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
const raw = await readFile(outputPath, 'utf8');
|
||||
const manifest = JSON.parse(raw) as {
|
||||
gateway: { url: string; token?: string };
|
||||
node: { nodeId: string; platform: string; capabilities: string[] };
|
||||
runtime: { heartbeatSeconds: number; handoffTimeoutMs: number };
|
||||
};
|
||||
expect(manifest.gateway).toEqual({ url: 'ws://127.0.0.1:18888', token: 'config-token' });
|
||||
expect(manifest.node.nodeId).toBe('ios-device');
|
||||
expect(manifest.node.platform).toBe('ios');
|
||||
expect(manifest.node.capabilities).toEqual([
|
||||
'ui.canvas',
|
||||
'node.status.write',
|
||||
'node.location.write',
|
||||
'node.push.register',
|
||||
]);
|
||||
expect(manifest.runtime.heartbeatSeconds).toBe(45);
|
||||
expect(manifest.runtime.handoffTimeoutMs).toBe(5000);
|
||||
expect(mockCreateCompanionBootstrapManifest).toHaveBeenCalledOnce();
|
||||
expect(mockRuntimeCtorArgs).toEqual([]);
|
||||
expect(mockRuntimeInstances).toEqual([]);
|
||||
expect(errSpy).not.toHaveBeenCalled();
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
logSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('prints bootstrap manifest to stdout when export path is dash', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const program = new Command();
|
||||
const { registerCompanionCommand } = await import('./companion.js');
|
||||
registerCompanionCommand(program);
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'test',
|
||||
'companion',
|
||||
'--once',
|
||||
'--export-bootstrap',
|
||||
'-',
|
||||
'--url',
|
||||
'ws://10.0.0.5:19000',
|
||||
'--token',
|
||||
'override-token',
|
||||
'--node-id',
|
||||
'test-node',
|
||||
]);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
const output = logSpy.mock.calls[0]?.[0];
|
||||
expect(typeof output).toBe('string');
|
||||
const manifest = JSON.parse(String(output)) as { gateway: { url: string; token?: string } };
|
||||
expect(manifest.gateway).toEqual({ url: 'ws://10.0.0.5:19000', token: 'override-token' });
|
||||
expect(mockCreateCompanionBootstrapManifest).toHaveBeenCalledOnce();
|
||||
expect(mockRuntimeCtorArgs).toEqual([]);
|
||||
expect(mockRuntimeInstances).toEqual([]);
|
||||
expect(errSpy).not.toHaveBeenCalled();
|
||||
|
||||
logSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('sets process exit code when options are invalid', async () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const program = new Command();
|
||||
|
||||
+31
-1
@@ -1,8 +1,10 @@
|
||||
import type { Command } from 'commander';
|
||||
import { hostname } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { Command } from 'commander';
|
||||
import { CompanionRuntimeClient } from '../companion/index.js';
|
||||
import type { SetNodeStatusInput } from '../companion/index.js';
|
||||
import { createCompanionBootstrapManifest } from '../companion/index.js';
|
||||
import { getConfigPath, loadConfigSafe } from './shared.js';
|
||||
|
||||
type CompanionPlatform = SetNodeStatusInput['platform'];
|
||||
@@ -18,6 +20,7 @@ interface CompanionCommandOptions {
|
||||
heartbeat?: string;
|
||||
handoff?: string;
|
||||
handoffTimeout?: string;
|
||||
exportBootstrap?: string;
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
@@ -105,6 +108,29 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
|
||||
const heartbeatSeconds = parseHeartbeatSeconds(options.heartbeat);
|
||||
const handoffMessage = options.handoff?.trim();
|
||||
const handoffTimeoutMs = parseHandoffTimeoutMs(options.handoffTimeout);
|
||||
const exportBootstrapPath = options.exportBootstrap?.trim();
|
||||
|
||||
if (exportBootstrapPath) {
|
||||
const manifest = createCompanionBootstrapManifest({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
nodeId,
|
||||
role,
|
||||
platform,
|
||||
capabilities,
|
||||
heartbeatSeconds,
|
||||
handoffTimeoutMs,
|
||||
autoReconnect: !options.once,
|
||||
});
|
||||
const body = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
if (exportBootstrapPath === '-') {
|
||||
console.log(body.trimEnd());
|
||||
return;
|
||||
}
|
||||
await writeFile(exportBootstrapPath, body, 'utf8');
|
||||
console.log(`Wrote companion bootstrap manifest to ${exportBootstrapPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = new CompanionRuntimeClient({
|
||||
url: gatewayUrl,
|
||||
@@ -255,6 +281,10 @@ export function registerCompanionCommand(program: Command): void {
|
||||
.option('--heartbeat <seconds>', 'Heartbeat interval in seconds', '30')
|
||||
.option('--handoff <message>', 'Optional one-shot agent message handoff after registration')
|
||||
.option('--handoff-timeout <ms>', 'Handoff timeout in milliseconds', '120000')
|
||||
.option(
|
||||
'--export-bootstrap <path>',
|
||||
'Write resolved companion bootstrap manifest JSON (`-` for stdout) and exit',
|
||||
)
|
||||
.option('--once', 'Connect, register, publish one heartbeat, then exit', false)
|
||||
.action(async (opts: CompanionCommandOptions) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createCompanionBootstrapManifest } from './bootstrapManifest.js';
|
||||
|
||||
describe('createCompanionBootstrapManifest', () => {
|
||||
it('builds a manifest with gateway token and runtime settings', () => {
|
||||
const manifest = createCompanionBootstrapManifest({
|
||||
gatewayUrl: 'ws://127.0.0.1:18800',
|
||||
gatewayToken: 'secret-token',
|
||||
nodeId: 'ios-test-node',
|
||||
role: 'companion',
|
||||
platform: 'ios',
|
||||
capabilities: ['ui.canvas', 'node.push.register'],
|
||||
heartbeatSeconds: 45,
|
||||
handoffTimeoutMs: 5000,
|
||||
autoReconnect: true,
|
||||
generatedAt: new Date('2026-02-27T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(manifest).toEqual({
|
||||
schemaVersion: 1,
|
||||
generatedAt: '2026-02-27T00:00:00.000Z',
|
||||
gateway: {
|
||||
url: 'ws://127.0.0.1:18800',
|
||||
token: 'secret-token',
|
||||
},
|
||||
node: {
|
||||
nodeId: 'ios-test-node',
|
||||
role: 'companion',
|
||||
platform: 'ios',
|
||||
capabilities: ['ui.canvas', 'node.push.register'],
|
||||
},
|
||||
runtime: {
|
||||
heartbeatSeconds: 45,
|
||||
handoffTimeoutMs: 5000,
|
||||
autoReconnect: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits an empty gateway token', () => {
|
||||
const manifest = createCompanionBootstrapManifest({
|
||||
gatewayUrl: 'ws://127.0.0.1:18800',
|
||||
gatewayToken: '',
|
||||
nodeId: 'desktop-node',
|
||||
role: 'companion',
|
||||
platform: 'macos',
|
||||
capabilities: ['ui.canvas'],
|
||||
heartbeatSeconds: 30,
|
||||
handoffTimeoutMs: 120000,
|
||||
autoReconnect: false,
|
||||
generatedAt: new Date('2026-02-27T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(manifest.gateway).toEqual({ url: 'ws://127.0.0.1:18800' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { RegisterNodeInput, SetNodeStatusInput } from './runtimeClient.js';
|
||||
|
||||
export type CompanionBootstrapPlatform = SetNodeStatusInput['platform'];
|
||||
|
||||
export interface CompanionBootstrapManifest {
|
||||
schemaVersion: 1;
|
||||
generatedAt: string;
|
||||
gateway: {
|
||||
url: string;
|
||||
token?: string;
|
||||
};
|
||||
node: Pick<RegisterNodeInput, 'nodeId' | 'role' | 'capabilities'> & {
|
||||
platform: CompanionBootstrapPlatform;
|
||||
};
|
||||
runtime: {
|
||||
heartbeatSeconds: number;
|
||||
handoffTimeoutMs: number;
|
||||
autoReconnect: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateCompanionBootstrapManifestInput {
|
||||
gatewayUrl: string;
|
||||
gatewayToken?: string;
|
||||
nodeId: string;
|
||||
role: string;
|
||||
platform: CompanionBootstrapPlatform;
|
||||
capabilities: string[];
|
||||
heartbeatSeconds: number;
|
||||
handoffTimeoutMs: number;
|
||||
autoReconnect: boolean;
|
||||
generatedAt?: Date;
|
||||
}
|
||||
|
||||
export function createCompanionBootstrapManifest(
|
||||
input: CreateCompanionBootstrapManifestInput,
|
||||
): CompanionBootstrapManifest {
|
||||
const generatedAt = (input.generatedAt ?? new Date()).toISOString();
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
gateway: {
|
||||
url: input.gatewayUrl,
|
||||
...(input.gatewayToken && input.gatewayToken.length > 0 ? { token: input.gatewayToken } : {}),
|
||||
},
|
||||
node: {
|
||||
nodeId: input.nodeId,
|
||||
role: input.role,
|
||||
platform: input.platform,
|
||||
capabilities: input.capabilities,
|
||||
},
|
||||
runtime: {
|
||||
heartbeatSeconds: input.heartbeatSeconds,
|
||||
handoffTimeoutMs: input.handoffTimeoutMs,
|
||||
autoReconnect: input.autoReconnect,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
AndroidCompanionClient,
|
||||
} from './platformClients.js';
|
||||
export { CompanionHeartbeatLoop } from './heartbeatLoop.js';
|
||||
export { createCompanionBootstrapManifest } from './bootstrapManifest.js';
|
||||
|
||||
export type {
|
||||
CompanionRuntimeClientOptions,
|
||||
@@ -70,3 +71,8 @@ export type {
|
||||
CompanionHeartbeatLoopOptions,
|
||||
CompanionHeartbeatLoopState,
|
||||
} from './heartbeatLoop.js';
|
||||
export type {
|
||||
CompanionBootstrapPlatform,
|
||||
CompanionBootstrapManifest,
|
||||
CreateCompanionBootstrapManifestInput,
|
||||
} from './bootstrapManifest.js';
|
||||
|
||||
Reference in New Issue
Block a user