feat(companion): export platform shell templates for app starters

This commit is contained in:
William Valentin
2026-02-26 19:03:22 -08:00
parent f10c896a75
commit 5df8ece040
12 changed files with 394 additions and 8 deletions
+52 -1
View File
@@ -11,6 +11,7 @@ const {
mockRuntimeInstances,
mockCreateCompanionBootstrapManifest,
mockWriteCompanionReleaseBundle,
mockWriteCompanionShellTemplate,
} = vi.hoisted(() => {
const runtimeCtorArgs: Array<{ url: string; token?: string; autoReconnect?: boolean }> = [];
const runtimeInstances: Array<{
@@ -96,6 +97,18 @@ const {
readmePath: `${input.outputDir}/README.md`,
checksumsPath: `${input.outputDir}/CHECKSUMS.sha256`,
}));
const writeCompanionShellTemplate = vi.fn(async (input: {
outputDir: string;
platform: string;
}) => ({
outputDir: input.outputDir,
platform: input.platform,
files: [
`${input.outputDir}/companion.bootstrap.json`,
`${input.outputDir}/CompanionBootstrap.swift`,
`${input.outputDir}/README.md`,
],
}));
return {
mockLoadConfigSafe: loadConfigSafe,
@@ -104,6 +117,7 @@ const {
mockRuntimeInstances: runtimeInstances,
mockCreateCompanionBootstrapManifest: createCompanionBootstrapManifest,
mockWriteCompanionReleaseBundle: writeCompanionReleaseBundle,
mockWriteCompanionShellTemplate: writeCompanionShellTemplate,
};
});
@@ -115,6 +129,7 @@ vi.mock('./shared.js', () => ({
vi.mock('../companion/index.js', () => ({
createCompanionBootstrapManifest: mockCreateCompanionBootstrapManifest,
writeCompanionReleaseBundle: mockWriteCompanionReleaseBundle,
writeCompanionShellTemplate: mockWriteCompanionShellTemplate,
CompanionRuntimeClient: class {
private connectionHandlers: Array<(event: { status: string }) => void> = [];
connect = vi.fn(async () => {
@@ -155,6 +170,7 @@ describe('companion command', () => {
mockRuntimeInstances.length = 0;
mockCreateCompanionBootstrapManifest.mockClear();
mockWriteCompanionReleaseBundle.mockClear();
mockWriteCompanionShellTemplate.mockClear();
mockLoadConfigSafe.mockReturnValue({
config: {
server: {
@@ -409,6 +425,39 @@ describe('companion command', () => {
errSpy.mockRestore();
});
it('exports a platform shell template and exits without runtime connection', 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',
'--export-shell-template',
'/tmp/flynn-companion-ios-template',
'--platform',
'ios',
]);
expect(mockWriteCompanionShellTemplate).toHaveBeenCalledOnce();
expect(mockWriteCompanionShellTemplate).toHaveBeenCalledWith({
outputDir: '/tmp/flynn-companion-ios-template',
platform: 'ios',
manifest: expect.objectContaining({
node: expect.objectContaining({ platform: 'ios' }),
}),
});
expect(mockRuntimeCtorArgs).toEqual([]);
expect(mockRuntimeInstances).toEqual([]);
expect(errSpy).not.toHaveBeenCalled();
logSpy.mockRestore();
errSpy.mockRestore();
});
it('applies location, push, and status bootstrap settings to runtime registration', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
@@ -493,7 +542,7 @@ describe('companion command', () => {
errSpy.mockRestore();
});
it('sets process exit code when export-bootstrap and export-release-bundle are combined', async () => {
it('sets process exit code when multiple export modes are combined', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const program = new Command();
const { registerCompanionCommand } = await import('./companion.js');
@@ -507,6 +556,8 @@ describe('companion command', () => {
'-',
'--export-release-bundle',
'/tmp/flynn-companion-bundle',
'--export-shell-template',
'/tmp/flynn-companion-template',
]);
expect(errSpy).toHaveBeenCalled();
+27 -2
View File
@@ -6,6 +6,7 @@ import { CompanionRuntimeClient } from '../companion/index.js';
import type { SetNodeLocationInput, SetNodePushTokenInput, SetNodeStatusInput } from '../companion/index.js';
import { createCompanionBootstrapManifest } from '../companion/index.js';
import { writeCompanionReleaseBundle } from '../companion/index.js';
import { writeCompanionShellTemplate } from '../companion/index.js';
import { getConfigPath, loadConfigSafe } from './shared.js';
type CompanionPlatform = SetNodeStatusInput['platform'];
@@ -40,6 +41,7 @@ interface CompanionCommandOptions {
pushEnvironment?: string;
exportBootstrap?: string;
exportReleaseBundle?: string;
exportShellTemplate?: string;
once?: boolean;
}
@@ -320,9 +322,12 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
const pushInput = resolvePushInput(options, platform);
const exportBootstrapPath = options.exportBootstrap?.trim();
const exportReleaseBundleDir = options.exportReleaseBundle?.trim();
const exportShellTemplateDir = options.exportShellTemplate?.trim();
if (exportBootstrapPath && exportReleaseBundleDir) {
throw new Error('export-bootstrap and export-release-bundle cannot be used together');
const exportCount = [exportBootstrapPath, exportReleaseBundleDir, exportShellTemplateDir]
.filter((value) => Boolean(value)).length;
if (exportCount > 1) {
throw new Error('export-bootstrap, export-release-bundle, and export-shell-template are mutually exclusive');
}
const manifest = createCompanionBootstrapManifest({
@@ -364,6 +369,22 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro
return;
}
if (exportShellTemplateDir) {
if (platform !== 'macos' && platform !== 'ios' && platform !== 'android') {
throw new Error('export-shell-template requires platform to be one of: macos, ios, android');
}
const result = await writeCompanionShellTemplate({
outputDir: exportShellTemplateDir,
platform,
manifest,
});
console.log(`Wrote companion ${result.platform} shell template to ${result.outputDir}`);
for (const file of result.files) {
console.log(`- ${file}`);
}
return;
}
const runtime = new CompanionRuntimeClient({
url: gatewayUrl,
token: gatewayToken,
@@ -550,6 +571,10 @@ export function registerCompanionCommand(program: Command): void {
'--export-release-bundle <dir>',
'Write a companion release bundle (manifest + launcher + README) and exit',
)
.option(
'--export-shell-template <dir>',
'Write a platform shell template (bootstrap + native starter file + README) and exit',
)
.option('--once', 'Connect, register, publish one heartbeat, then exit', false)
.action(async (opts: CompanionCommandOptions) => {
try {
+6
View File
@@ -11,6 +11,7 @@ export {
export { CompanionHeartbeatLoop } from './heartbeatLoop.js';
export { createCompanionBootstrapManifest } from './bootstrapManifest.js';
export { writeCompanionReleaseBundle } from './releaseBundle.js';
export { writeCompanionShellTemplate } from './shellTemplate.js';
export type {
CompanionRuntimeClientOptions,
@@ -81,3 +82,8 @@ export type {
WriteCompanionReleaseBundleInput,
WriteCompanionReleaseBundleResult,
} from './releaseBundle.js';
export type {
CompanionShellTemplatePlatform,
WriteCompanionShellTemplateInput,
WriteCompanionShellTemplateResult,
} from './shellTemplate.js';
+74
View File
@@ -0,0 +1,74 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { describe, expect, it } from 'vitest';
import { writeCompanionShellTemplate } from './shellTemplate.js';
describe('writeCompanionShellTemplate', () => {
const manifest = {
schemaVersion: 1 as const,
generatedAt: '2026-02-27T00:00:00.000Z',
gateway: { url: 'ws://127.0.0.1:18800', token: 'token-123' },
node: {
nodeId: 'test-node',
role: 'companion',
platform: 'ios' as const,
capabilities: ['ui.canvas'],
},
runtime: {
heartbeatSeconds: 30,
handoffTimeoutMs: 120000,
autoReconnect: true,
},
};
it('writes iOS template files', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-shell-template-ios-'));
const outDir = join(tempDir, 'ios');
const result = await writeCompanionShellTemplate({
outputDir: outDir,
platform: 'ios',
manifest,
});
const templateRaw = await readFile(`${outDir}/CompanionBootstrap.swift`, 'utf8');
const manifestRaw = await readFile(`${outDir}/companion.bootstrap.json`, 'utf8');
const readmeRaw = await readFile(`${outDir}/README.md`, 'utf8');
expect(result.files).toEqual([
`${outDir}/companion.bootstrap.json`,
`${outDir}/CompanionBootstrap.swift`,
`${outDir}/README.md`,
]);
expect(templateRaw).toContain('struct CompanionBootstrap: Codable');
expect(templateRaw).toContain('node.push_token.set');
expect(JSON.parse(manifestRaw)).toMatchObject({ node: { nodeId: 'test-node' } });
expect(readmeRaw).toContain('Shell Template');
await rm(tempDir, { recursive: true, force: true });
});
it('writes Android and macOS platform-specific template names', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-shell-template-platforms-'));
const androidDir = join(tempDir, 'android');
const macosDir = join(tempDir, 'macos');
await writeCompanionShellTemplate({
outputDir: androidDir,
platform: 'android',
manifest,
});
await writeCompanionShellTemplate({
outputDir: macosDir,
platform: 'macos',
manifest,
});
const androidTemplate = await readFile(`${androidDir}/CompanionBootstrap.kt`, 'utf8');
const macosTemplate = await readFile(`${macosDir}/MenuBarCompanion.swift`, 'utf8');
expect(androidTemplate).toContain('data class CompanionBootstrap');
expect(macosTemplate).toContain('launchFlynnCompanion');
await rm(tempDir, { recursive: true, force: true });
});
});
+187
View File
@@ -0,0 +1,187 @@
import { mkdir, writeFile } from 'node:fs/promises';
import type { CompanionBootstrapManifest } from './bootstrapManifest.js';
export type CompanionShellTemplatePlatform = 'macos' | 'ios' | 'android';
export interface WriteCompanionShellTemplateInput {
outputDir: string;
platform: CompanionShellTemplatePlatform;
manifest: CompanionBootstrapManifest;
}
export interface WriteCompanionShellTemplateResult {
outputDir: string;
platform: CompanionShellTemplatePlatform;
files: string[];
}
function swiftTemplate(manifest: CompanionBootstrapManifest): string {
return `import Foundation
struct CompanionBootstrap: Codable {
let schemaVersion: Int
let generatedAt: String
let gateway: Gateway
let node: Node
let runtime: Runtime
}
struct Gateway: Codable {
let url: String
let token: String?
}
struct Node: Codable {
let nodeId: String
let role: String
let platform: String
let capabilities: [String]
}
struct Runtime: Codable {
let heartbeatSeconds: Int
let handoffTimeoutMs: Int
let autoReconnect: Bool
}
// Reference entrypoint for a menu-bar app wrapper.
// Production apps should prefer in-process runtime integration over shelling out.
func launchFlynnCompanion() throws {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/bash")
task.arguments = ["./run-companion.sh"]
try task.run()
}
// Generated for node: ${manifest.node.nodeId} (${manifest.node.platform})
`;
}
function iosTemplate(manifest: CompanionBootstrapManifest): string {
return `import Foundation
// Reference iOS bootstrap model for integrating with Flynn gateway runtime.
// Wire APNs token refresh to node.push_token.set and app lifecycle to heartbeat publishing.
struct CompanionBootstrap: Codable {
let schemaVersion: Int
let generatedAt: String
let gateway: Gateway
let node: Node
let runtime: Runtime
}
struct Gateway: Codable {
let url: String
let token: String?
}
struct Node: Codable {
let nodeId: String
let role: String
let platform: String
let capabilities: [String]
}
struct Runtime: Codable {
let heartbeatSeconds: Int
let handoffTimeoutMs: Int
let autoReconnect: Bool
}
// Generated for node: ${manifest.node.nodeId} (${manifest.node.platform})
`;
}
function androidTemplate(manifest: CompanionBootstrapManifest): string {
return `package flynn.companion
// Reference Android bootstrap model for integrating with Flynn gateway runtime.
// Wire FCM token refresh to node.push_token.set and app lifecycle to heartbeat publishing.
data class CompanionBootstrap(
val schemaVersion: Int,
val generatedAt: String,
val gateway: Gateway,
val node: Node,
val runtime: Runtime
)
data class Gateway(
val url: String,
val token: String?
)
data class Node(
val nodeId: String,
val role: String,
val platform: String,
val capabilities: List<String>
)
data class Runtime(
val heartbeatSeconds: Int,
val handoffTimeoutMs: Int,
val autoReconnect: Boolean
)
// Generated for node: ${manifest.node.nodeId} (${manifest.node.platform})
`;
}
function templateFilename(platform: CompanionShellTemplatePlatform): string {
if (platform === 'macos') {
return 'MenuBarCompanion.swift';
}
if (platform === 'ios') {
return 'CompanionBootstrap.swift';
}
return 'CompanionBootstrap.kt';
}
function templateBody(
platform: CompanionShellTemplatePlatform,
manifest: CompanionBootstrapManifest,
): string {
if (platform === 'macos') {
return swiftTemplate(manifest);
}
if (platform === 'ios') {
return iosTemplate(manifest);
}
return androidTemplate(manifest);
}
function readmeBody(platform: CompanionShellTemplatePlatform): string {
return `# Flynn Companion ${platform} Shell Template
This directory contains a generated starter template for a ${platform} companion shell.
Files:
- \`companion.bootstrap.json\`: resolved Flynn companion bootstrap contract
- \`${templateFilename(platform)}\`: platform-native starter model/wrapper snippet
Notes:
- These templates are intentionally minimal and should be integrated into your app project.
- Runtime transport should use Flynn gateway JSON-RPC node methods (\`node.register\`, \`node.status.set\`, \`node.location.set\`, \`node.push_token.set\`).
`;
}
export async function writeCompanionShellTemplate(
input: WriteCompanionShellTemplateInput,
): Promise<WriteCompanionShellTemplateResult> {
await mkdir(input.outputDir, { recursive: true });
const manifestPath = `${input.outputDir}/companion.bootstrap.json`;
const templatePath = `${input.outputDir}/${templateFilename(input.platform)}`;
const readmePath = `${input.outputDir}/README.md`;
await writeFile(manifestPath, `${JSON.stringify(input.manifest, null, 2)}\n`, 'utf8');
await writeFile(templatePath, templateBody(input.platform, input.manifest), 'utf8');
await writeFile(readmePath, readmeBody(input.platform), 'utf8');
return {
outputDir: input.outputDir,
platform: input.platform,
files: [manifestPath, templatePath, readmePath],
};
}