diff --git a/README.md b/README.md index 232e2e7..7d397d3 100644 --- a/README.md +++ b/README.md @@ -1735,12 +1735,14 @@ Companion runtime helper: - runtime observability/control passthroughs (`pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `lastDisconnectCode`, `lastDisconnectReason`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, `getConnectionSnapshot()`, `connected`, `waitForIdle()`) - `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). Minimal companion CLI: - `flynn companion --once` connects to the gateway, registers a node, publishes one heartbeat, then exits. - `flynn companion --platform macos --heartbeat 30` runs a long-lived node with periodic heartbeats and logs `agent.stream`/`agent.typing` events. - `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`) 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 665dff0..4382c21 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 `) 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 `), 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) @@ -1863,3 +1863,4 @@ For more implementation details, see: - Companion runtime client helper: `src/companion/runtimeClient.ts` (node + system + `canvas.*` typed RPC wrappers, optional `autoConnect`/`autoReconnect`, optional reconnect state replay, `sendAgentMessage` handoff helper, connection event subscriptions) - 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 for distributable companion shell bundles) diff --git a/docs/architecture/AGENT_DIAGRAM.md b/docs/architecture/AGENT_DIAGRAM.md index bbba502..40ae56b 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -156,6 +156,7 @@ Gateway streaming UX signals: - Routing applies reaction rules with deterministic priority/cooldown (and recursion guard) before intent routing. - 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) for desktop/mobile packaging pipelines. - `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 61e65c3..3d76e33 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -21,6 +21,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Browser workflow reliability primitives (`browser.wait_for/assert/extract/checkpoint.*`) execute in the same queued session lane and apply browser-config guardrails (domain allowlist/high-risk confirmation, bounded retries, workflow step budget). - 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 for installable shell distribution workflows. - 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/plans/2026-02-26-personal-assistant-productization-plan.md b/docs/plans/2026-02-26-personal-assistant-productization-plan.md index cdd82b5..a86d94b 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, and `flynn companion` now 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), 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 f6f00ec..12bc1c5 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -6961,10 +6961,30 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/cli/companion.test.ts src/companion/bootstrapManifest.test.ts + pnpm typecheck passing" + }, + "personal-assistant-productization-phase1-companion-release-bundle-export": { + "status": "completed", + "date": "2026-02-27", + "updated": "2026-02-27", + "summary": "Added companion release-bundle export support: `flynn companion --export-release-bundle ` now writes a distributable bundle directory (`companion.bootstrap.json`, `run-companion.sh`, `README.md`) so operators can package and hand off installable companion-shell artifacts. Added typed `writeCompanionReleaseBundle()` helper and regression tests.", + "files_modified": [ + "src/companion/releaseBundle.ts", + "src/companion/releaseBundle.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/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/bootstrapManifest.test.ts src/companion/releaseBundle.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 2574, + "total_test_count": 2575, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -6979,7 +6999,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 bootstrap packaging contract + shell bootstrap controls) — 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 bootstrap export 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 release packaging.", + "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) — 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) 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.", "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", @@ -7012,7 +7032,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 manifest export, and one-shot shell bootstrap controls for 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 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 and release-bundle artifact generation, 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.", "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 eca11e8..f36fee4 100644 --- a/src/cli/companion.test.ts +++ b/src/cli/companion.test.ts @@ -10,6 +10,7 @@ const { mockRuntimeCtorArgs, mockRuntimeInstances, mockCreateCompanionBootstrapManifest, + mockWriteCompanionReleaseBundle, } = vi.hoisted(() => { const runtimeCtorArgs: Array<{ url: string; token?: string; autoReconnect?: boolean }> = []; const runtimeInstances: Array<{ @@ -85,6 +86,15 @@ const { ...(input.location ? { location: input.location } : {}), ...(input.push ? { push: input.push } : {}), })); + const writeCompanionReleaseBundle = vi.fn(async (input: { + outputDir: string; + manifest: unknown; + }) => ({ + outputDir: input.outputDir, + manifestPath: `${input.outputDir}/companion.bootstrap.json`, + launcherPath: `${input.outputDir}/run-companion.sh`, + readmePath: `${input.outputDir}/README.md`, + })); return { mockLoadConfigSafe: loadConfigSafe, @@ -92,6 +102,7 @@ const { mockRuntimeCtorArgs: runtimeCtorArgs, mockRuntimeInstances: runtimeInstances, mockCreateCompanionBootstrapManifest: createCompanionBootstrapManifest, + mockWriteCompanionReleaseBundle: writeCompanionReleaseBundle, }; }); @@ -102,6 +113,7 @@ vi.mock('./shared.js', () => ({ vi.mock('../companion/index.js', () => ({ createCompanionBootstrapManifest: mockCreateCompanionBootstrapManifest, + writeCompanionReleaseBundle: mockWriteCompanionReleaseBundle, CompanionRuntimeClient: class { private connectionHandlers: Array<(event: { status: string }) => void> = []; connect = vi.fn(async () => { @@ -141,6 +153,7 @@ describe('companion command', () => { mockRuntimeCtorArgs.length = 0; mockRuntimeInstances.length = 0; mockCreateCompanionBootstrapManifest.mockClear(); + mockWriteCompanionReleaseBundle.mockClear(); mockLoadConfigSafe.mockReturnValue({ config: { server: { @@ -360,6 +373,41 @@ describe('companion command', () => { errSpy.mockRestore(); }); + it('exports release bundle 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-release-bundle', + '/tmp/flynn-companion-bundle', + '--platform', + 'android', + '--push-token', + 'abcdef0123456789abcdef0123456789', + ]); + + expect(mockWriteCompanionReleaseBundle).toHaveBeenCalledOnce(); + expect(mockWriteCompanionReleaseBundle).toHaveBeenCalledWith({ + outputDir: '/tmp/flynn-companion-bundle', + manifest: expect.objectContaining({ + node: expect.objectContaining({ platform: 'android' }), + push: expect.objectContaining({ provider: 'fcm' }), + }), + }); + 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); @@ -443,4 +491,26 @@ describe('companion command', () => { errSpy.mockRestore(); }); + + it('sets process exit code when export-bootstrap and export-release-bundle are combined', async () => { + 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-bootstrap', + '-', + '--export-release-bundle', + '/tmp/flynn-companion-bundle', + ]); + + expect(errSpy).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + + errSpy.mockRestore(); + }); }); diff --git a/src/cli/companion.ts b/src/cli/companion.ts index 92e2144..ef1f6f9 100644 --- a/src/cli/companion.ts +++ b/src/cli/companion.ts @@ -5,6 +5,7 @@ import type { Command } from 'commander'; 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 { getConfigPath, loadConfigSafe } from './shared.js'; type CompanionPlatform = SetNodeStatusInput['platform']; @@ -38,6 +39,7 @@ interface CompanionCommandOptions { pushTopic?: string; pushEnvironment?: string; exportBootstrap?: string; + exportReleaseBundle?: string; once?: boolean; } @@ -317,22 +319,28 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro const locationInput = resolveLocationInput(options); const pushInput = resolvePushInput(options, platform); const exportBootstrapPath = options.exportBootstrap?.trim(); + const exportReleaseBundleDir = options.exportReleaseBundle?.trim(); + + if (exportBootstrapPath && exportReleaseBundleDir) { + throw new Error('export-bootstrap and export-release-bundle cannot be used together'); + } + + const manifest = createCompanionBootstrapManifest({ + gatewayUrl, + gatewayToken, + nodeId, + role, + platform, + capabilities, + heartbeatSeconds, + handoffTimeoutMs, + autoReconnect: !options.once, + status: hasDefinedProperties(statusOverrides) ? statusOverrides : undefined, + location: locationInput, + push: pushInput, + }); if (exportBootstrapPath) { - const manifest = createCompanionBootstrapManifest({ - gatewayUrl, - gatewayToken, - nodeId, - role, - platform, - capabilities, - heartbeatSeconds, - handoffTimeoutMs, - autoReconnect: !options.once, - status: hasDefinedProperties(statusOverrides) ? statusOverrides : undefined, - location: locationInput, - push: pushInput, - }); const body = `${JSON.stringify(manifest, null, 2)}\n`; if (exportBootstrapPath === '-') { console.log(body.trimEnd()); @@ -343,6 +351,18 @@ export async function runCompanionSession(options: CompanionCommandOptions): Pro return; } + if (exportReleaseBundleDir) { + const result = await writeCompanionReleaseBundle({ + outputDir: exportReleaseBundleDir, + manifest, + }); + console.log(`Wrote companion release bundle to ${result.outputDir}`); + console.log(`- Manifest: ${result.manifestPath}`); + console.log(`- Launcher: ${result.launcherPath}`); + console.log(`- README: ${result.readmePath}`); + return; + } + const runtime = new CompanionRuntimeClient({ url: gatewayUrl, token: gatewayToken, @@ -525,6 +545,10 @@ export function registerCompanionCommand(program: Command): void { '--export-bootstrap ', 'Write resolved companion bootstrap manifest JSON (`-` for stdout) and exit', ) + .option( + '--export-release-bundle ', + 'Write a companion release bundle (manifest + launcher + 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 a7fe24b..c90b77e 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -10,6 +10,7 @@ export { } from './platformClients.js'; export { CompanionHeartbeatLoop } from './heartbeatLoop.js'; export { createCompanionBootstrapManifest } from './bootstrapManifest.js'; +export { writeCompanionReleaseBundle } from './releaseBundle.js'; export type { CompanionRuntimeClientOptions, @@ -76,3 +77,7 @@ export type { CompanionBootstrapManifest, CreateCompanionBootstrapManifestInput, } from './bootstrapManifest.js'; +export type { + WriteCompanionReleaseBundleInput, + WriteCompanionReleaseBundleResult, +} from './releaseBundle.js'; diff --git a/src/companion/releaseBundle.test.ts b/src/companion/releaseBundle.test.ts new file mode 100644 index 0000000..d724e2d --- /dev/null +++ b/src/companion/releaseBundle.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { writeCompanionReleaseBundle } from './releaseBundle.js'; + +describe('writeCompanionReleaseBundle', () => { + it('writes manifest, launcher script, and README', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'flynn-companion-release-')); + const outputDir = join(tempDir, 'bundle'); + + const result = await writeCompanionReleaseBundle({ + outputDir, + manifest: { + schemaVersion: 1, + generatedAt: '2026-02-27T00:00:00.000Z', + gateway: { url: 'ws://127.0.0.1:18800', token: 'token-123' }, + node: { + nodeId: 'ios-node', + role: 'companion', + platform: 'ios', + capabilities: ['ui.canvas', 'node.push.register'], + }, + runtime: { + heartbeatSeconds: 30, + handoffTimeoutMs: 120000, + autoReconnect: true, + }, + status: { + appVersion: '1.2.3', + statusText: 'ready', + powerSource: 'battery', + }, + location: { + latitude: 37.3349, + longitude: -122.009, + source: 'gps', + }, + push: { + provider: 'apns', + token: '0123456789abcdef0123456789abcdef', + topic: 'com.flynn.mobile', + environment: 'production', + }, + }, + }); + + const manifestRaw = await readFile(result.manifestPath, 'utf8'); + const launcherRaw = await readFile(result.launcherPath, 'utf8'); + const readmeRaw = await readFile(result.readmePath, 'utf8'); + const launcherStat = await stat(result.launcherPath); + + expect(JSON.parse(manifestRaw)).toMatchObject({ + node: { nodeId: 'ios-node', platform: 'ios' }, + push: { provider: 'apns' }, + }); + expect(launcherRaw).toContain('exec flynn'); + expect(launcherRaw).toContain('--push-token'); + expect(launcherRaw).toContain('--latitude'); + expect(readmeRaw).toContain('Flynn Companion Release Bundle'); + expect((launcherStat.mode & 0o111) !== 0).toBe(true); + + await rm(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/src/companion/releaseBundle.ts b/src/companion/releaseBundle.ts new file mode 100644 index 0000000..341754d --- /dev/null +++ b/src/companion/releaseBundle.ts @@ -0,0 +1,161 @@ +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import type { CompanionBootstrapManifest } from './bootstrapManifest.js'; + +export interface WriteCompanionReleaseBundleInput { + outputDir: string; + manifest: CompanionBootstrapManifest; +} + +export interface WriteCompanionReleaseBundleResult { + outputDir: string; + manifestPath: string; + launcherPath: string; + readmePath: string; +} + +function shSingleQuote(value: string): string { + return `'${value.replaceAll('\'', '\'\"\'\"\'')}'`; +} + +function createLauncherScript(manifest: CompanionBootstrapManifest): string { + const args: string[] = [ + 'companion', + '--url', + manifest.gateway.url, + '--node-id', + manifest.node.nodeId, + '--role', + manifest.node.role, + '--platform', + manifest.node.platform, + '--heartbeat', + String(manifest.runtime.heartbeatSeconds), + '--handoff-timeout', + String(manifest.runtime.handoffTimeoutMs), + ]; + + if (manifest.runtime.autoReconnect === false) { + args.push('--once'); + } + if (manifest.gateway.token) { + args.push('--token', manifest.gateway.token); + } + for (const capability of manifest.node.capabilities) { + args.push('--capability', capability); + } + if (manifest.status?.appVersion) { + args.push('--app-version', manifest.status.appVersion); + } + if (manifest.status?.deviceName) { + args.push('--device-name', manifest.status.deviceName); + } + if (manifest.status?.statusText) { + args.push('--status-text', manifest.status.statusText); + } + if (manifest.status?.batteryPct !== undefined) { + args.push('--battery-pct', String(manifest.status.batteryPct)); + } + if (manifest.status?.powerSource) { + args.push('--power-source', manifest.status.powerSource); + } + if (manifest.location) { + args.push('--latitude', String(manifest.location.latitude)); + args.push('--longitude', String(manifest.location.longitude)); + if (manifest.location.source) { + args.push('--location-source', manifest.location.source); + } + if (manifest.location.accuracyMeters !== undefined) { + args.push('--location-accuracy-meters', String(manifest.location.accuracyMeters)); + } + if (manifest.location.altitudeMeters !== undefined) { + args.push('--location-altitude-meters', String(manifest.location.altitudeMeters)); + } + if (manifest.location.headingDegrees !== undefined) { + args.push('--location-heading-degrees', String(manifest.location.headingDegrees)); + } + if (manifest.location.speedMps !== undefined) { + args.push('--location-speed-mps', String(manifest.location.speedMps)); + } + if (manifest.location.capturedAt !== undefined) { + args.push('--location-captured-at', String(manifest.location.capturedAt)); + } + } + if (manifest.push) { + args.push('--push-provider', manifest.push.provider); + args.push('--push-token', manifest.push.token); + if (manifest.push.topic) { + args.push('--push-topic', manifest.push.topic); + } + if (manifest.push.environment) { + args.push('--push-environment', manifest.push.environment); + } + } + + const quotedArgs = args.map(shSingleQuote).join(' '); + return `#!/usr/bin/env bash +set -euo pipefail + +# Generated by Flynn companion release-bundle export. +exec flynn ${quotedArgs} \"$@\" +`; +} + +function createReadme(manifest: CompanionBootstrapManifest): string { + const platform = manifest.node.platform; + const reconnectLabel = manifest.runtime.autoReconnect ? 'enabled' : 'disabled (--once mode)'; + return `# Flynn Companion Release Bundle + +Generated: ${manifest.generatedAt} +Platform: ${platform} +Node ID: ${manifest.node.nodeId} +Role: ${manifest.node.role} +Gateway: ${manifest.gateway.url} +Auto reconnect: ${reconnectLabel} + +## Files + +- \`companion.bootstrap.json\`: resolved gateway/node/runtime contract for packaging flows. +- \`run-companion.sh\`: launcher script that runs \`flynn companion\` with this bundle's resolved defaults. + +## Usage + +1. Ensure \`flynn\` is installed on the target machine. +2. Review \`companion.bootstrap.json\` and remove any embedded secrets before redistribution. +3. Run: + +\`\`\`bash +./run-companion.sh +\`\`\` + +Add extra flags as needed: + +\`\`\`bash +./run-companion.sh --handoff "hello from packaged shell" +\`\`\` +`; +} + +export async function writeCompanionReleaseBundle( + input: WriteCompanionReleaseBundleInput, +): Promise { + await mkdir(input.outputDir, { recursive: true }); + const manifestPath = `${input.outputDir}/companion.bootstrap.json`; + const launcherPath = `${input.outputDir}/run-companion.sh`; + const readmePath = `${input.outputDir}/README.md`; + + await writeFile( + manifestPath, + `${JSON.stringify(input.manifest, null, 2)}\n`, + 'utf8', + ); + await writeFile(launcherPath, createLauncherScript(input.manifest), 'utf8'); + await chmod(launcherPath, 0o755); + await writeFile(readmePath, createReadme(input.manifest), 'utf8'); + + return { + outputDir: input.outputDir, + manifestPath, + launcherPath, + readmePath, + }; +}