diff --git a/README.md b/README.md index 41a3696..eabe6eb 100644 --- a/README.md +++ b/README.md @@ -1738,6 +1738,7 @@ Companion runtime helper: - `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). - `src/companion/releaseVerify.ts` provides `verifyCompanionReleaseBundle()` for validating bundle checksums and optional signature metadata. +- `src/companion/releasePipeline.ts` provides `buildAndVerifyCompanionReleaseBundle()` for build-and-verify automation (including signed bundle flows). Minimal companion CLI: - `flynn companion --once` connects to the gateway, registers a node, publishes one heartbeat, then exits. @@ -1748,6 +1749,7 @@ Minimal companion CLI: - `flynn companion --export-release-bundle ./dist/companion-macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` also writes `CHECKSUMS.sha256.sig` for signed verification workflows. - `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 --verify-release-bundle ./dist/companion-macos --verify-signing-key ./keys/release-public.pem --verify-signing-key-id team-k1 --require-signature` verifies checksums and signature metadata before install. +- `pnpm companion:bundle -- --output ./dist/companion-macos --platform macos --signing-key ./keys/release-private.pem --signing-key-id team-k1` builds and verifies a release bundle in one step. `run-companion.sh` verifies bundle checksums (`CHECKSUMS.sha256`) before launching `flynn companion`. - `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. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 16303ef..38b237a 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1865,4 +1865,5 @@ For more implementation details, see: - 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`; optional `CHECKSUMS.sha256.sig` when a signing key is provided. Launcher performs checksum verification before exec.) - Companion release bundle verifier: `src/companion/releaseVerify.ts` (validates `CHECKSUMS.sha256` and optional signature metadata against a provided public key) +- Companion release automation pipeline: `src/companion/releasePipeline.ts` + `scripts/build-companion-release-bundle.ts` (build-and-verify workflow for deterministic companion artifact generation) - 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 be79f35..fc956d7 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -160,6 +160,7 @@ Gateway streaming UX signals: - `flynn companion --export-release-bundle ... --signing-key ` can additionally emit `CHECKSUMS.sha256.sig` for signed artifact verification pipelines. - `flynn companion --verify-release-bundle ` can validate bundle checksums and optional signatures before installation or rollout. - `flynn companion --export-shell-template ` can emit platform starter shell templates (macOS/iOS/Android native scaffold files + bootstrap JSON) for reference app bootstrapping. +- `pnpm companion:bundle -- --output ...` runs a build-and-verify release pipeline for repeatable companion artifact generation. - `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 7ea16bb..dfefaa2 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -25,6 +25,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Generated launchers validate `CHECKSUMS.sha256` before invoking `flynn companion`, reducing accidental tampered-bundle launches. - Companion release-bundle exports can optionally be signed (`--signing-key`, `--signing-key-id`) to emit `CHECKSUMS.sha256.sig` for distribution trust verification. - Companion release bundles can be verified before install via `flynn companion --verify-release-bundle ` with optional signature-key checks. +- Companion packaging automation is available via `pnpm companion:bundle -- --output ...`, which builds and verifies the release bundle in one pass. - 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. diff --git a/docs/operations/COMPANION_RELEASE_BUNDLE.md b/docs/operations/COMPANION_RELEASE_BUNDLE.md index a80ba19..f2159aa 100644 --- a/docs/operations/COMPANION_RELEASE_BUNDLE.md +++ b/docs/operations/COMPANION_RELEASE_BUNDLE.md @@ -36,6 +36,18 @@ Additional file: - `CHECKSUMS.sha256.sig` +One-command automation: + +```bash +pnpm companion:bundle -- \ + --output ./dist/companion-macos \ + --platform macos \ + --signing-key ./keys/release-private.pem \ + --signing-key-id team-k1 +``` + +This script builds the bundle and immediately verifies checksums/signatures before returning success. + ## Generate Platform Starter Shell Template For native app bootstrapping (without launcher/checksum artifacts), export a platform template: 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 8bfa5ad..d34dc24 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`, optional `CHECKSUMS.sha256.sig` with `--signing-key`), `flynn companion --verify-release-bundle ` now validates checksum/signature artifacts before install, `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. +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`, optional `CHECKSUMS.sha256.sig` with `--signing-key`), `flynn companion --verify-release-bundle ` now validates checksum/signature artifacts before install, `pnpm companion:bundle -- --output ...` now provides one-pass build-and-verify automation, `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 5ec862d..4345145 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -7091,10 +7091,31 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/companion/releaseBundle.test.ts + pnpm typecheck passing" + }, + "personal-assistant-productization-phase1-companion-bundle-automation-pipeline": { + "status": "completed", + "date": "2026-02-27", + "updated": "2026-02-27", + "summary": "Added build-and-verify companion bundle automation: new `buildAndVerifyCompanionReleaseBundle()` helper plus `pnpm companion:bundle` script creates bootstrap manifest, release artifacts, optional signatures, and verifies checksums/signatures in one deterministic pipeline step.", + "files_modified": [ + "src/companion/releasePipeline.ts", + "src/companion/releasePipeline.test.ts", + "src/companion/index.ts", + "scripts/build-companion-release-bundle.ts", + "package.json", + "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/companion/releasePipeline.test.ts src/companion/releaseBundle.test.ts src/companion/releaseVerify.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 2583, + "total_test_count": 2584, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -7109,7 +7130,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 + platform templates + signed/verified artifacts + launcher integrity gate) — 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 + optional signatures + verification mode + checksum-gated launcher), 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 distribution automation hardening.", + "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 + signed/verified artifacts + automation pipeline) — 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 + optional signatures + verification mode + checksum-gated launcher + one-pass build/verify automation), 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 end-to-end distribution workflows.", "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", @@ -7142,7 +7163,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, optional signature emission, verification mode, checksum-gated launchers, 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 distribution automation polish.", + "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, optional signature emission, verification mode, checksum-gated launchers, one-pass build/verify automation pipeline, 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 end-to-end distribution workflows.", "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/package.json b/package.json index ca7051b..7b9d172 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "config:profiles:check": "node scripts/generate-config-profiles.mjs --check", "audit:backend-canary": "node --import tsx/esm scripts/summarize-backend-canary.ts", "audit:phase0-baseline": "node --import tsx/esm scripts/summarize-phase0-baseline.ts", - "audit:backend-canary:probes": "node --import tsx/esm scripts/run-pi-canary-guard-probes.ts" + "audit:backend-canary:probes": "node --import tsx/esm scripts/run-pi-canary-guard-probes.ts", + "companion:bundle": "node --import tsx/esm scripts/build-companion-release-bundle.ts" }, "keywords": [ "ai", diff --git a/scripts/build-companion-release-bundle.ts b/scripts/build-companion-release-bundle.ts new file mode 100644 index 0000000..ec8dcb0 --- /dev/null +++ b/scripts/build-companion-release-bundle.ts @@ -0,0 +1,116 @@ +import { readFile } from 'node:fs/promises'; +import { hostname } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { buildAndVerifyCompanionReleaseBundle } from '../src/companion/index.js'; + +type Platform = 'macos' | 'ios' | 'android' | 'linux' | 'windows' | 'unknown'; + +function usage(): string { + return [ + 'Usage:', + ' pnpm companion:bundle -- --output [options]', + '', + 'Options:', + ' --output Required output directory', + ' --url Gateway URL (default: ws://127.0.0.1:18800)', + ' --token Gateway token', + ' --platform macos|ios|android|linux|windows|unknown (default: macos)', + ' --node-id Node ID (default: --)', + ' --role Node role (default: companion)', + ' --capability Comma-delimited capabilities', + ' --heartbeat Heartbeat interval (default: 30)', + ' --handoff-timeout Handoff timeout (default: 120000)', + ' --signing-key Optional private-key PEM path', + ' --signing-key-id Optional signing key identifier', + ].join('\n'); +} + +function getArg(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + if (index < 0) { + return undefined; + } + return args[index + 1]; +} + +function requireArg(args: string[], name: string): string { + const value = getArg(args, name); + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${name}`); + } + return value; +} + +function parseIntArg(value: string, name: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function resolveDefaultCapabilities(platform: Platform): string[] { + if (platform === 'ios' || platform === 'macos' || platform === 'android') { + return ['ui.canvas', 'node.status.write', 'node.location.write', 'node.push.register']; + } + return ['ui.canvas', 'node.status.write']; +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) { + console.log(usage()); + return; + } + + const outputDir = requireArg(args, '--output'); + const url = getArg(args, '--url') ?? 'ws://127.0.0.1:18800'; + const token = getArg(args, '--token'); + const platformRaw = (getArg(args, '--platform') ?? 'macos') as Platform; + if (!['macos', 'ios', 'android', 'linux', 'windows', 'unknown'].includes(platformRaw)) { + throw new Error(`Unsupported platform: ${platformRaw}`); + } + const role = getArg(args, '--role') ?? 'companion'; + const nodeId = getArg(args, '--node-id') ?? `${platformRaw}-${hostname()}-${randomUUID().slice(0, 8)}`; + const heartbeatSeconds = parseIntArg(getArg(args, '--heartbeat') ?? '30', '--heartbeat'); + const handoffTimeoutMs = parseIntArg(getArg(args, '--handoff-timeout') ?? '120000', '--handoff-timeout'); + const capabilities = (getArg(args, '--capability') ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const resolvedCapabilities = capabilities.length > 0 + ? capabilities + : resolveDefaultCapabilities(platformRaw); + const signingKeyPath = getArg(args, '--signing-key'); + const signingKeyId = getArg(args, '--signing-key-id'); + const signingKeyPem = signingKeyPath ? await readFile(signingKeyPath, 'utf8') : undefined; + + const result = await buildAndVerifyCompanionReleaseBundle({ + outputDir, + gatewayUrl: url, + gatewayToken: token, + nodeId, + role, + platform: platformRaw, + capabilities: resolvedCapabilities, + heartbeatSeconds, + handoffTimeoutMs, + autoReconnect: true, + signingKeyPem, + signingKeyId: signingKeyId ?? undefined, + }); + + console.log(`Companion bundle created and verified: ${outputDir}`); + console.log(`- node_id: ${result.manifest.node.nodeId}`); + console.log(`- checksums: ${result.bundle.checksumsPath}`); + if (result.bundle.signaturePath) { + console.log(`- signature: ${result.bundle.signaturePath}`); + } + console.log(`- files verified: ${result.verification.verifiedFiles.length}`); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`companion:bundle failed: ${message}`); + process.exitCode = 1; +}); diff --git a/src/companion/index.ts b/src/companion/index.ts index d2fbc79..749f7c3 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -13,6 +13,7 @@ export { createCompanionBootstrapManifest } from './bootstrapManifest.js'; export { writeCompanionReleaseBundle } from './releaseBundle.js'; export { writeCompanionShellTemplate } from './shellTemplate.js'; export { verifyCompanionReleaseBundle } from './releaseVerify.js'; +export { buildAndVerifyCompanionReleaseBundle } from './releasePipeline.js'; export type { CompanionRuntimeClientOptions, @@ -93,3 +94,7 @@ export type { VerifyCompanionReleaseBundleResult, VerifiedReleaseFile, } from './releaseVerify.js'; +export type { + BuildAndVerifyCompanionReleaseBundleInput, + BuildAndVerifyCompanionReleaseBundleResult, +} from './releasePipeline.js'; diff --git a/src/companion/releasePipeline.test.ts b/src/companion/releasePipeline.test.ts new file mode 100644 index 0000000..f93c2c6 --- /dev/null +++ b/src/companion/releasePipeline.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { generateKeyPairSync } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { buildAndVerifyCompanionReleaseBundle } from './releasePipeline.js'; + +describe('buildAndVerifyCompanionReleaseBundle', () => { + it('builds and verifies a signed companion release bundle', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'flynn-release-pipeline-')); + const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const privatePem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + + const result = await buildAndVerifyCompanionReleaseBundle({ + outputDir: join(tempDir, 'bundle'), + gatewayUrl: 'ws://127.0.0.1:18800', + gatewayToken: 'token-123', + nodeId: 'pipeline-node', + role: 'companion', + platform: 'ios', + capabilities: ['ui.canvas', 'node.push.register'], + heartbeatSeconds: 45, + handoffTimeoutMs: 5000, + autoReconnect: true, + signingKeyPem: privatePem, + signingKeyId: 'pipeline-k1', + status: { + appVersion: '1.0.0', + statusText: 'ready', + powerSource: 'battery', + }, + push: { + provider: 'apns', + token: '0123456789abcdef0123456789abcdef', + environment: 'production', + }, + generatedAt: new Date('2026-02-27T00:00:00.000Z'), + }); + + expect(result.bundle.signaturePath).toBeDefined(); + expect(result.verification.signaturePresent).toBe(true); + expect(result.verification.signatureVerified).toBe(true); + expect(result.verification.signatureKeyId).toBe('pipeline-k1'); + expect(result.manifest.node.nodeId).toBe('pipeline-node'); + + await rm(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/src/companion/releasePipeline.ts b/src/companion/releasePipeline.ts new file mode 100644 index 0000000..585323c --- /dev/null +++ b/src/companion/releasePipeline.ts @@ -0,0 +1,80 @@ +import { createPrivateKey, createPublicKey } from 'node:crypto'; +import type { + CreateCompanionBootstrapManifestInput, + CompanionBootstrapManifest, +} from './bootstrapManifest.js'; +import { createCompanionBootstrapManifest } from './bootstrapManifest.js'; +import { + writeCompanionReleaseBundle, + type WriteCompanionReleaseBundleResult, +} from './releaseBundle.js'; +import { + verifyCompanionReleaseBundle, + type VerifyCompanionReleaseBundleResult, +} from './releaseVerify.js'; + +export interface BuildAndVerifyCompanionReleaseBundleInput + extends CreateCompanionBootstrapManifestInput { + outputDir: string; + signingKeyPem?: string; + signingKeyId?: string; + verifyPublicKeyPem?: string; +} + +export interface BuildAndVerifyCompanionReleaseBundleResult { + manifest: CompanionBootstrapManifest; + bundle: WriteCompanionReleaseBundleResult; + verification: VerifyCompanionReleaseBundleResult; +} + +function resolveVerifyPublicKey(input: BuildAndVerifyCompanionReleaseBundleInput): string | undefined { + if (input.verifyPublicKeyPem && input.verifyPublicKeyPem.trim().length > 0) { + return input.verifyPublicKeyPem; + } + if (input.signingKeyPem && input.signingKeyPem.trim().length > 0) { + const privateKey = createPrivateKey(input.signingKeyPem); + return createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString(); + } + return undefined; +} + +export async function buildAndVerifyCompanionReleaseBundle( + input: BuildAndVerifyCompanionReleaseBundleInput, +): Promise { + const manifest = createCompanionBootstrapManifest({ + gatewayUrl: input.gatewayUrl, + gatewayToken: input.gatewayToken, + nodeId: input.nodeId, + role: input.role, + platform: input.platform, + capabilities: input.capabilities, + heartbeatSeconds: input.heartbeatSeconds, + handoffTimeoutMs: input.handoffTimeoutMs, + autoReconnect: input.autoReconnect, + status: input.status, + location: input.location, + push: input.push, + generatedAt: input.generatedAt, + }); + + const bundle = await writeCompanionReleaseBundle({ + outputDir: input.outputDir, + manifest, + signingKeyPem: input.signingKeyPem, + signingKeyId: input.signingKeyId, + }); + + const verifyPublicKeyPem = resolveVerifyPublicKey(input); + const verification = await verifyCompanionReleaseBundle({ + bundleDir: input.outputDir, + publicKeyPem: verifyPublicKeyPem, + expectedKeyId: input.signingKeyId, + requireSignature: Boolean(input.signingKeyPem), + }); + + return { + manifest, + bundle, + verification, + }; +}