From 5df8ece0409fbbdb49f2a58f680d8bd2dd2c9b09 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 19:03:22 -0800 Subject: [PATCH] feat(companion): export platform shell templates for app starters --- README.md | 2 + docs/api/PROTOCOL.md | 3 +- docs/architecture/AGENT_DIAGRAM.md | 1 + .../GATEWAY_SESSIONS_AND_QUEUE.md | 1 + docs/operations/COMPANION_RELEASE_BUNDLE.md | 17 ++ ...-personal-assistant-productization-plan.md | 2 +- docs/plans/state.json | 27 ++- src/cli/companion.test.ts | 53 ++++- src/cli/companion.ts | 29 ++- src/companion/index.ts | 6 + src/companion/shellTemplate.test.ts | 74 +++++++ src/companion/shellTemplate.ts | 187 ++++++++++++++++++ 12 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 src/companion/shellTemplate.test.ts create mode 100644 src/companion/shellTemplate.ts diff --git a/README.md b/README.md index e9d660e..17dcfed 100644 --- a/README.md +++ b/README.md @@ -1736,6 +1736,7 @@ Companion runtime helper: - `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load (with safe normalization for invalid random samples), `tickNow()` for manual sends, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures. - `src/companion/bootstrapManifest.ts` provides `createCompanionBootstrapManifest()` for generating a typed gateway/node/runtime bootstrap contract used by packaging flows, including optional initial status/location/push payloads. - `src/companion/releaseBundle.ts` provides `writeCompanionReleaseBundle()` for writing a distributable companion bundle directory (bootstrap JSON + launcher script + README + SHA-256 checksums). +- `src/companion/shellTemplate.ts` provides `writeCompanionShellTemplate()` for emitting platform starter shells (macOS/iOS/Android native scaffold snippets + bootstrap JSON). Minimal companion CLI: - `flynn companion --once` connects to the gateway, registers a node, publishes one heartbeat, then exits. @@ -1743,6 +1744,7 @@ Minimal companion CLI: - `flynn companion --once --handoff "summarize my status"` performs one post-registration `agent.send` handoff and prints the `done` content. - `flynn companion --export-bootstrap ./companion.bootstrap.json` writes a resolved bootstrap manifest for desktop/mobile companion app packaging (use `-` for stdout). - `flynn companion --export-release-bundle ./dist/companion-macos` writes a release bundle directory (`companion.bootstrap.json`, `run-companion.sh`, `README.md`, `CHECKSUMS.sha256`) and exits. +- `flynn companion --platform ios --export-shell-template ./dist/companion-ios-template` writes a platform-native starter template directory (`companion.bootstrap.json`, native starter file, `README.md`) and exits. - `flynn companion --once --platform ios --app-version 1.2.3 --device-name "iPhone" --status-text ready --battery-pct 84 --power-source battery` sends richer initial node status metadata. - `flynn companion --once --latitude 37.3349 --longitude -122.009 --location-source gps` bootstraps node location metadata. - `flynn companion --once --platform android --push-token ` (or `--platform ios --push-token `) registers push routing metadata during bootstrap. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 8c5177d..edbc92c 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -23,7 +23,7 @@ The gateway provides: - **HTTP Server**: Serves static dashboard and handles webhook endpoints - **Node Capability Negotiation**: Optional companion-node role/capability registration -Operational note: onboarding (`flynn setup` / `flynn onboard`) now runs post-save live readiness checks (model/channel/memory/automation) and prints a guided first-success task flow. Companion CLI now also supports bootstrap-manifest export (`flynn companion --export-bootstrap `), release-bundle export (`--export-release-bundle `), plus richer shell bootstrap flags for status/location/push (`--app-version`, `--latitude/--longitude`, `--push-token`, etc.) for desktop/mobile app packaging without changing JSON-RPC method/event shapes. +Operational note: onboarding (`flynn setup` / `flynn onboard`) now runs post-save live readiness checks (model/channel/memory/automation) and prints a guided first-success task flow. Companion CLI now also supports bootstrap-manifest export (`flynn companion --export-bootstrap `), release-bundle export (`--export-release-bundle `), platform shell-template export (`--export-shell-template `), plus richer shell bootstrap flags for status/location/push (`--app-version`, `--latitude/--longitude`, `--push-token`, etc.) for desktop/mobile app packaging without changing JSON-RPC method/event shapes. ### Execution Model (Sessions + Per-Session Queue) @@ -1864,3 +1864,4 @@ For more implementation details, see: - Platform companion wrappers: `src/companion/platformClients.ts` - Companion bootstrap manifest helper: `src/companion/bootstrapManifest.ts` (typed packaging manifest contract used by `flynn companion --export-bootstrap`, including optional initial status/location/push payloads) - Companion release bundle helper: `src/companion/releaseBundle.ts` (writes bootstrap JSON + launcher script + README + `CHECKSUMS.sha256` for distributable companion shell bundles) +- Companion shell template helper: `src/companion/shellTemplate.ts` (writes platform-native starter template files for `macos`, `ios`, and `android` shell scaffolding) diff --git a/docs/architecture/AGENT_DIAGRAM.md b/docs/architecture/AGENT_DIAGRAM.md index aab3e01..72c375d 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -157,6 +157,7 @@ Gateway streaming UX signals: - Companion nodes re-register `node.*` capabilities after reconnect; runtime clients can auto-reconnect, optionally replay cached node state (`register/status/location/push`), and surface connection events. - `flynn companion --export-bootstrap ` can emit a resolved companion bootstrap manifest (gateway/node/runtime contract) for desktop/mobile packaging flows without opening a runtime connection. - `flynn companion --export-release-bundle ` can emit a distributable shell bundle (bootstrap JSON + launcher + README + SHA-256 checksums) for desktop/mobile packaging pipelines. +- `flynn companion --export-shell-template ` can emit platform starter shell templates (macOS/iOS/Android native scaffold files + bootstrap JSON) for reference app bootstrapping. - `flynn companion` can bootstrap status/location/push metadata on connect (`node.status.set` + optional `node.location.set`/`node.push_token.set`) so thin companion shells can register operational context in one launch. - Canvas artifacts are persisted by the gateway so session UI surfaces can recover after daemon restarts. - TTS synthesis uses an ordered provider chain with health cooldown tracking; if all providers fail, replies degrade to text-only without dropping the response. diff --git a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md index 16e5409..9d7a0b5 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -22,6 +22,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods (or use runtime-client reconnect state replay to re-register/status/location/push automatically). - Companion packaging/bootstrap can be generated offline via `flynn companion --export-bootstrap `, which emits resolved gateway/node/runtime settings without opening a WebSocket session. - Companion release artifacts can be generated via `flynn companion --export-release-bundle `, producing bootstrap JSON + launcher + README + `CHECKSUMS.sha256` for installable shell distribution workflows. +- Companion platform starter scaffolds can be generated via `flynn companion --export-shell-template ` for macOS/iOS/Android reference app bootstrapping. - Companion CLI supports one-shot shell bootstrap metadata for live sessions (`--app-version`/`--status-text`, `--latitude`/`--longitude`, `--push-token`) so desktop/mobile wrappers can initialize node status/location/push in a single launch flow. - Canvas artifacts are persisted per session under the gateway data directory for UI recovery across restarts. - TTS output is best-effort with ordered provider fallback + per-provider cooldown tracking; synthesis failures still fall back to text-only responses. diff --git a/docs/operations/COMPANION_RELEASE_BUNDLE.md b/docs/operations/COMPANION_RELEASE_BUNDLE.md index 7e83d08..cd163d8 100644 --- a/docs/operations/COMPANION_RELEASE_BUNDLE.md +++ b/docs/operations/COMPANION_RELEASE_BUNDLE.md @@ -21,6 +21,23 @@ Generated files: - `README.md` - `CHECKSUMS.sha256` +## Generate Platform Starter Shell Template + +For native app bootstrapping (without launcher/checksum artifacts), export a platform template: + +```bash +flynn companion \ + --platform ios \ + --node-id companion-ios \ + --export-shell-template ./dist/companion-ios-template +``` + +Generated files: + +- `companion.bootstrap.json` +- platform starter file (`CompanionBootstrap.swift`, `CompanionBootstrap.kt`, or `MenuBarCompanion.swift`) +- `README.md` + ## Verify Bundle Integrity On the target host (before launch), verify checksums: diff --git a/docs/plans/2026-02-26-personal-assistant-productization-plan.md b/docs/plans/2026-02-26-personal-assistant-productization-plan.md index ca2020a..aa3c84c 100644 --- a/docs/plans/2026-02-26-personal-assistant-productization-plan.md +++ b/docs/plans/2026-02-26-personal-assistant-productization-plan.md @@ -49,7 +49,7 @@ Within 8-10 weeks, ship a stable "Personal Assistant Mode" that supports: 2. Ship a minimal mobile companion shell (iOS + Android) for registration, status, push token, and message handoff. 3. Add signed release artifacts and installation docs. -Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap ` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle ` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256`), and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run. +Status update (2026-02-27): companion bootstrap-manifest export is now available via `flynn companion --export-bootstrap ` as a packaging contract for desktop/mobile shells, `flynn companion --export-release-bundle ` now emits bundle artifacts (bootstrap JSON + launcher + README + `CHECKSUMS.sha256`), `flynn companion --export-shell-template ` now emits macOS/iOS/Android starter shell templates, and `flynn companion` supports one-shot status/location/push bootstrap flags (`--app-version`, `--latitude/--longitude`, `--push-token`) so thin shells can initialize companion metadata in a single run. ### Implementation Anchors diff --git a/docs/plans/state.json b/docs/plans/state.json index 5ddb9b9..a694cc2 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -7013,10 +7013,31 @@ "docs/plans/state.json" ], "test_status": "docs-only runbook update; no runtime code changes" + }, + "personal-assistant-productization-phase1-companion-shell-template-export": { + "status": "completed", + "date": "2026-02-27", + "updated": "2026-02-27", + "summary": "Added platform shell-template export for reference companion apps. `flynn companion --export-shell-template ` now writes platform starter scaffolds for macOS/iOS/Android (`MenuBarCompanion.swift`, `CompanionBootstrap.swift`, `CompanionBootstrap.kt`) plus bootstrap JSON and template README.", + "files_modified": [ + "src/companion/shellTemplate.ts", + "src/companion/shellTemplate.test.ts", + "src/companion/index.ts", + "src/cli/companion.ts", + "src/cli/companion.test.ts", + "README.md", + "docs/api/PROTOCOL.md", + "docs/architecture/AGENT_DIAGRAM.md", + "docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md", + "docs/operations/COMPANION_RELEASE_BUNDLE.md", + "docs/plans/2026-02-26-personal-assistant-productization-plan.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/cli/companion.test.ts src/companion/shellTemplate.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 2575, + "total_test_count": 2577, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -7031,7 +7052,7 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "rebaselined 2026-02-26 and updated 2026-02-27 (phase 3 + phase 1 + phase 2 + phase 4 slices + companion packaging/bundle tooling + shell bootstrap controls + install runbook) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging primitives (bootstrap export + release-bundle artifacts + checksum manifests), companion install/verification runbook, and one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented; remaining high-impact personal-assistant gaps center on shipped desktop/mobile companion app binaries and signed distribution pipelines.", + "feature_gap_scorecard": "rebaselined 2026-02-26 and updated 2026-02-27 (phase 3 + phase 1 + phase 2 + phase 4 slices + companion packaging/bundle tooling + shell bootstrap controls + platform templates) — channel breadth, setup wizard, baseline browser automation, subagent controls, browser workflow reliability primitives (wait/assert/extract/retries/checkpoints/guardrails/budgets), companion reconnect/runtime-handoff foundations, companion packaging primitives (bootstrap export + release-bundle artifacts + checksum manifests), companion install/verification runbook, platform starter shell templates for macOS/iOS/Android, and one-shot status/location/push shell bootstrap controls, voice reliability hardening (talk controls + TTS fallback/health + interruption-safe cancel semantics), and onboarding first-success funnel improvements are implemented; remaining high-impact personal-assistant gaps center on production-grade companion app binaries and signed distribution pipelines.", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "dashboard_observability": "completed — service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", @@ -7064,7 +7085,7 @@ "deeper_surfaces_phase3_companion_canvas_voice": "completed — companion reconnect resilience (auto-reconnect with backoff, pending-wait cancellation on disconnect), canvas artifact persistence (SQLite-backed store, daemon-restart durability), voice TTS fallback coverage (text-only reply on TTS failure, no dropped responses)", "deeper_surfaces_phase4_rollout": "completed — phase 4 rollout and operator readiness plan documented: canary rollout plan by feature flag/surface, explicit rollback playbook, operator docs and architecture/protocol docs synchronized", "post_phase_test_fixes": "completed — fixed 4 test failures introduced by phases 1-3: iOS/Android push listNodes (missing publishHeartbeat before platform-filtered query), server.test agent.send (run_state events now precede done; added sendAndWaitForDone helper), httpBody 413 (req.destroy() closed socket before response could be sent; replaced with Connection: close header on 413 responses)", - "personal_assistant_productization_plan": "in_progress — 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, companion packaging primitives now include bootstrap manifest export, release-bundle artifact generation, checksum manifests, and an install/verification runbook, companion shell bootstrap controls cover status/location/push metadata, Phase 2 voice reliability ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping, and Phase 4 onboarding includes Personal Assistant Mode preset + live readiness checks + first-success guidance. Remaining phase focus: shipped companion app surfaces and signed release artifacts.", + "personal_assistant_productization_plan": "in_progress — 8-10 week phased roadmap active; Phase 3 browser workflow reliability shipped, Phase 1 companion runtime reliability includes reconnect state replay + typed handoff support, companion packaging primitives now include bootstrap manifest export, release-bundle artifact generation, checksum manifests, platform starter shell-template generation, and an install/verification runbook, companion shell bootstrap controls cover status/location/push metadata, Phase 2 voice reliability ships talk controls + TTS provider fallback/health + interruption-safe voice cancel mapping, and Phase 4 onboarding includes Personal Assistant Mode preset + live readiness checks + first-success guidance. Remaining phase focus: production-ready companion app surfaces and signed release artifacts.", "subagents_support": "completed — subagent phases 1-3 shipped with `subagent.spawn/send/list/cancel/delete/summary`, per-child queue mode (`followup|interrupt`), budgets (`max_turns`, `max_total_tokens`, `turn_timeout_ms`), tool-profile overrides, trace-linked audit events, `/subagents` inspection commands, and focused regression tests." }, "soul_md_and_cron_create": { diff --git a/src/cli/companion.test.ts b/src/cli/companion.test.ts index 120b67c..d633936 100644 --- a/src/cli/companion.test.ts +++ b/src/cli/companion.test.ts @@ -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(); diff --git a/src/cli/companion.ts b/src/cli/companion.ts index aa9eb58..2cc9cde 100644 --- a/src/cli/companion.ts +++ b/src/cli/companion.ts @@ -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 ', 'Write a companion release bundle (manifest + launcher + README) and exit', ) + .option( + '--export-shell-template ', + '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 { diff --git a/src/companion/index.ts b/src/companion/index.ts index c90b77e..601f756 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -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'; diff --git a/src/companion/shellTemplate.test.ts b/src/companion/shellTemplate.test.ts new file mode 100644 index 0000000..cff8654 --- /dev/null +++ b/src/companion/shellTemplate.test.ts @@ -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 }); + }); +}); diff --git a/src/companion/shellTemplate.ts b/src/companion/shellTemplate.ts new file mode 100644 index 0000000..476f1c2 --- /dev/null +++ b/src/companion/shellTemplate.ts @@ -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 +) + +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 { + 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], + }; +}