import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { generateKeyPairSync, verify, createPublicKey } from 'node:crypto'; 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 checksumsRaw = await readFile(result.checksumsPath, 'utf8'); const bundleManifestRaw = await readFile(result.releaseManifestPath, '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(launcherRaw).toContain('sha256sum --check CHECKSUMS.sha256'); expect(readmeRaw).toContain('Flynn Companion Release Bundle'); expect(checksumsRaw).toContain('companion.bootstrap.json'); expect(checksumsRaw).toContain('run-companion.sh'); expect(checksumsRaw).toContain('README.md'); expect(JSON.parse(bundleManifestRaw)).toMatchObject({ schemaVersion: 1, bundle: { nodeId: 'ios-node', platform: 'ios' }, files: expect.arrayContaining([ expect.objectContaining({ path: 'CHECKSUMS.sha256' }), ]), }); expect((launcherStat.mode & 0o111) !== 0).toBe(true); await rm(tempDir, { recursive: true, force: true }); }); it('writes signature artifact when signing key is provided', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'flynn-companion-release-sign-')); const outputDir = join(tempDir, 'bundle'); const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 }); const privatePem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); const publicPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); const result = await writeCompanionReleaseBundle({ outputDir, signingKeyPem: privatePem, signingKeyId: 'test-key', manifest: { schemaVersion: 1, generatedAt: '2026-02-27T00:00:00.000Z', gateway: { url: 'ws://127.0.0.1:18800' }, node: { nodeId: 'macos-node', role: 'companion', platform: 'macos', capabilities: ['ui.canvas'], }, runtime: { heartbeatSeconds: 30, handoffTimeoutMs: 120000, autoReconnect: true, }, }, }); expect(result.signaturePath).toBe(`${outputDir}/CHECKSUMS.sha256.sig`); expect(result.releaseManifestPath).toBe(`${outputDir}/RELEASE_MANIFEST.json`); const checksumsRaw = await readFile(result.checksumsPath, 'utf8'); const signatureRaw = await readFile(result.signaturePath!, 'utf8'); const bundleManifestRaw = await readFile(result.releaseManifestPath, 'utf8'); const signatureLine = signatureRaw.split('\n').find((line) => line.startsWith('signature=')); expect(signatureLine).toBeTruthy(); expect(signatureRaw).toContain('key_id=test-key'); expect(JSON.parse(bundleManifestRaw)).toMatchObject({ signature: { path: 'CHECKSUMS.sha256.sig', keyId: 'test-key', }, }); const signature = Buffer.from(String(signatureLine).replace('signature=', ''), 'base64'); const verified = verify( 'sha256', Buffer.from(checksumsRaw, 'utf8'), createPublicKey(publicPem), signature, ); expect(verified).toBe(true); await rm(tempDir, { recursive: true, force: true }); }); });