feat(companion): add bootstrap manifest export for app packaging

This commit is contained in:
William Valentin
2026-02-26 18:40:55 -08:00
parent 62c427da4a
commit 6620afcf1f
11 changed files with 363 additions and 61 deletions
+124
View File
@@ -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
View File
@@ -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 {
+56
View File
@@ -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' });
});
});
+59
View File
@@ -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,
},
};
}
+6
View File
@@ -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';