diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 4c67073df2..a524ffab67 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -164,3 +164,52 @@ jobs: - name: Build web run: bun run --cwd web build + + desktop-cli-artifacts: + name: desktop-cli-${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-15 + - target: x86_64-apple-darwin + runner: macos-15-intel + - target: x86_64-pc-windows-msvc + runner: windows-latest + - target: x86_64-unknown-linux-gnu + runner: ubuntu-22.04 + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Install production dependency closure + run: bun install --frozen-lockfile --production + + - name: Build and test desktop distribution + run: | + bun run build + bun run desktop:test + + - name: Package native desktop CLI + run: bun run desktop:package -- --target "${{ matrix.target }}" --output-dir .artifacts/desktop-cli --node-executable node + + - name: Upload native archive + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: desktop-cli-${{ matrix.target }} + path: .artifacts/desktop-cli/verboo-cli-* + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee456370ad..7faf378c09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,7 @@ jobs: - name: Run release-critical tests run: | + bun run desktop:test bun test --max-concurrency=1 \ src/commands/effort/effort.verboo.test.ts \ src/commands/logout/logoutState.test.ts \ @@ -197,3 +198,148 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + desktop-cli-artifacts: + name: Build desktop CLI (${{ matrix.target }}) + needs: verify + if: ${{ github.repository == 'verbeux-ai/code' }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-15 + - target: x86_64-apple-darwin + runner: macos-15-intel + - target: x86_64-pc-windows-msvc + runner: windows-latest + - target: x86_64-unknown-linux-gnu + runner: ubuntu-22.04 + permissions: + contents: read + + steps: + - name: Check out release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.verify.outputs.tag }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Install production dependency closure + run: bun install --frozen-lockfile --production + + - name: Build and test desktop distribution + run: | + bun run build + bun run desktop:test + + - name: Package native desktop CLI + run: bun run desktop:package -- --target "${{ matrix.target }}" --output-dir .artifacts/desktop-cli --node-executable node + + - name: Upload native archive + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: desktop-cli-${{ matrix.target }} + path: .artifacts/desktop-cli/verboo-cli-* + if-no-files-found: error + retention-days: 7 + + publish-desktop-cli: + name: Sign and publish desktop CLI assets + needs: + - verify + - desktop-cli-artifacts + if: ${{ github.repository == 'verbeux-ai/code' }} + runs-on: ubuntu-22.04 + environment: + name: release + url: https://github.com/verbeux-ai/code/releases/tag/${{ needs.verify.outputs.tag }} + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERBOO_DESKTOP_MINISIGN_SECRET_KEY_B64: ${{ secrets.VERBOO_DESKTOP_MINISIGN_SECRET_KEY_B64 }} + VERBOO_DESKTOP_MINISIGN_PUBLIC_KEY: ${{ secrets.VERBOO_DESKTOP_MINISIGN_PUBLIC_KEY }} + + steps: + - name: Check out release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.verify.outputs.tag }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Download native archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: desktop-cli-* + path: .artifacts/desktop-cli + merge-multiple: true + + - name: Install Minisign + run: | + sudo apt-get update + sudo apt-get install -y minisign + + - name: Require protected signing configuration + run: | + if [ -z "$VERBOO_DESKTOP_MINISIGN_SECRET_KEY_B64" ] || [ -z "$VERBOO_DESKTOP_MINISIGN_PUBLIC_KEY" ]; then + echo "Protected desktop CLI signing configuration is missing" >&2 + exit 1 + fi + printf '%s' "$VERBOO_DESKTOP_MINISIGN_SECRET_KEY_B64" | base64 --decode > "$RUNNER_TEMP/verboo-desktop-cli.key" + printf '%s\n' "$VERBOO_DESKTOP_MINISIGN_PUBLIC_KEY" > "$RUNNER_TEMP/verboo-desktop-cli.pub" + + - name: Generate, sign, and verify manifest + env: + RELEASE_TAG: ${{ needs.verify.outputs.tag }} + run: | + SOURCE_DATE=$(git show -s --format=%cI "$RELEASE_TAG") + RELEASED_AT=$(node -e 'console.log(new Date(process.argv[1]).toISOString())' "$SOURCE_DATE") + SIGNING_KEY_ID=$(node -e 'const crypto = require("node:crypto"); console.log(crypto.createHash("sha256").update(process.env.VERBOO_DESKTOP_MINISIGN_PUBLIC_KEY.trim(), "utf8").digest("hex").slice(0, 16))') + bun run desktop:manifest -- \ + --assets-dir .artifacts/desktop-cli \ + --repository verbeux-ai/code \ + --tag "$RELEASE_TAG" \ + --released-at "$RELEASED_AT" \ + --signing-key-id "$SIGNING_KEY_ID" + minisign -Sm .artifacts/desktop-cli/verboo-cli-manifest.json \ + -s "$RUNNER_TEMP/verboo-desktop-cli.key" \ + -x .artifacts/desktop-cli/verboo-cli-manifest.minisig \ + -t "Verboo desktop CLI $RELEASE_TAG" + bun run desktop:verify -- \ + --assets-dir .artifacts/desktop-cli \ + --public-key "$RUNNER_TEMP/verboo-desktop-cli.pub" + + - name: Upload signed desktop CLI release assets + env: + RELEASE_TAG: ${{ needs.verify.outputs.tag }} + run: | + gh release upload "$RELEASE_TAG" \ + .artifacts/desktop-cli/verboo-cli-*.tar.gz \ + .artifacts/desktop-cli/verboo-cli-manifest.json \ + .artifacts/desktop-cli/verboo-cli-manifest.minisig + + - name: Remove signing key from runner + if: always() + run: rm -f "$RUNNER_TEMP/verboo-desktop-cli.key" "$RUNNER_TEMP/verboo-desktop-cli.pub" diff --git a/docs/desktop-cli-distribution.md b/docs/desktop-cli-distribution.md new file mode 100644 index 0000000000..29da046279 --- /dev/null +++ b/docs/desktop-cli-distribution.md @@ -0,0 +1,73 @@ +# Signed Verboo Desktop CLI distribution + +Verboo Desktop updates the CLI independently from the desktop application. A published CLI release therefore carries four target-specific archives plus a manifest and its detached Minisign signature. + +## Release assets + +Every eligible `vMAJOR.MINOR.PATCH` release contains exactly: + +- `verboo-cli--aarch64-apple-darwin.tar.gz` +- `verboo-cli--x86_64-apple-darwin.tar.gz` +- `verboo-cli--x86_64-pc-windows-msvc.tar.gz` +- `verboo-cli--x86_64-unknown-linux-gnu.tar.gz` +- `verboo-cli-manifest.json` +- `verboo-cli-manifest.minisig` + +The native jobs materialize `dist/cli.mjs` and the production dependency closure on matching runners. Archives contain no Node.js executable. Verboo Desktop supplies its own pinned Node runtime. + +## One-time protected environment setup + +The repository owner performs these steps. The private key must never be committed, stored in repository variables, uploaded as an Actions artifact, or exposed to pull-request workflows. + +1. On a trusted offline machine, install Minisign and create an unencrypted automation key: + + ```bash + minisign -G -W -p verboo-desktop-cli.pub -s verboo-desktop-cli.key + ``` + +2. Preserve `verboo-desktop-cli.key` in the project's encrypted key backup. + +3. Base64-encode the entire secret-key file as one line: + + ```bash + base64 < verboo-desktop-cli.key | tr -d '\n' + ``` + +4. In the protected GitHub Actions environment named `release`, create: + + - `VERBOO_DESKTOP_MINISIGN_SECRET_KEY_B64`: the one-line base64 value from step 3. + - `VERBOO_DESKTOP_MINISIGN_PUBLIC_KEY`: the complete two-line contents of `verboo-desktop-cli.pub`. + +5. Require maintainer approval for the `release` environment and restrict it to protected release tags. + +The release job fails before signing when either secret is absent. Fork pull requests never receive these values and cannot publish GitHub release assets. + +## Publishing + +1. Ensure `package.json.version` is the intended version and the Git tag is exactly `v`. +2. Publish the GitHub release through the normal project release flow. +3. Wait for `Deploy Release / Sign and publish desktop CLI assets` to finish. +4. Confirm that all six assets above are attached to the same immutable tag. +5. Confirm the job log includes `Verified Verboo CLI ` before the upload step. + +The aggregator recalculates every archive's byte size and SHA-256 instead of trusting matrix-job metadata. It signs the exact bytes of `verboo-cli-manifest.json`, verifies that signature again, inspects archive paths and entry types, and only then uploads the set. + +## Pull-request validation + +Pull requests run the packaging scripts on the same four native runner families. These jobs exercise the platform-specific dependency closure and `--version` smoke but only upload short-lived Actions artifacts. They do not create or mutate a GitHub release. + +The focused local gate is: + +```bash +bun run desktop:test +bun run build +node dist/cli.mjs --version +``` + +The open-source mirror currently has unrelated repository-wide TypeScript errors, so this distribution work relies on the release build, native matrix, and focused tests rather than claiming a clean global `tsc --noEmit` baseline. + +## Key rotation and compromise + +For planned rotation, first ship a desktop application release that trusts both the current and replacement public keys. Only after that desktop release is available should the release environment switch to the replacement secret/public pair. A later desktop release may remove the retired key. + +If the private key may be compromised, stop publishing desktop CLI assets immediately. Ship a desktop trust-root update that rejects the compromised key, then configure a replacement key and resume CLI publication. Do not publish a replacement signature under the old tag or reuse an existing asset name for different bytes. diff --git a/package.json b/package.json index b1b17ce473..f43d6d5d53 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,11 @@ ], "scripts": { "build": "bun run scripts/build.ts", + "desktop:contract:test": "bun test scripts/desktop-release/contract.test.ts", + "desktop:package": "bun run scripts/desktop-release/package.ts", + "desktop:manifest": "bun run scripts/desktop-release/manifest.ts", + "desktop:verify": "bun run scripts/desktop-release/verify-release.ts", + "desktop:test": "bun test scripts/desktop-release/*.test.ts", "postinstall": "node scripts/postinstall.mjs || true", "integrations:generate": "bun run scripts/generate-integrations-artifacts.ts", "integrations:check": "bun run scripts/generate-integrations-artifacts.ts --check", diff --git a/scripts/desktop-release/contract.test.ts b/scripts/desktop-release/contract.test.ts new file mode 100644 index 0000000000..3a8acb0419 --- /dev/null +++ b/scripts/desktop-release/contract.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { + DESKTOP_TARGETS, + artifactName, + manifestBytes, + parseDesktopTarget, + releaseAssetUrl, +} from './contract.js' + +describe('desktop CLI release contract', () => { + test('covers every Verboo Desktop target exactly once', () => { + expect(DESKTOP_TARGETS.map(item => item.target)).toEqual([ + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + 'x86_64-unknown-linux-gnu', + ]) + expect(new Set(DESKTOP_TARGETS.map(item => item.target)).size).toBe(4) + }) + + test('uses target-qualified immutable artifact names', () => { + expect(artifactName('0.15.5', 'aarch64-apple-darwin')).toBe( + 'verboo-cli-0.15.5-aarch64-apple-darwin.tar.gz', + ) + expect( + releaseAssetUrl( + 'verbeux-ai/code', + 'v0.15.5', + 'verboo-cli-0.15.5-aarch64-apple-darwin.tar.gz', + ), + ).toBe( + 'https://github.com/verbeux-ai/code/releases/download/v0.15.5/verboo-cli-0.15.5-aarch64-apple-darwin.tar.gz', + ) + }) + + test('rejects mutable repositories, malformed versions, and unknown targets', () => { + expect(() => artifactName('latest', 'aarch64-apple-darwin')).toThrow( + 'Invalid CLI version', + ) + expect(() => releaseAssetUrl('graseeel/code', 'v0.15.5', 'asset')).toThrow( + 'Unexpected release repository', + ) + expect(() => parseDesktopTarget('arm64')).toThrow('Unsupported desktop target') + }) + + test('serializes the exact signed bytes with stable indentation and one final newline', () => { + const raw = manifestBytes({ schemaVersion: 1 } as never) + expect(new TextDecoder().decode(raw)).toBe('{\n "schemaVersion": 1\n}\n') + }) + + test('keeps release publication out of pull-request jobs and behind upstream signing', async () => { + const repositoryRoot = resolve(import.meta.dir, '..', '..') + const [releaseWorkflow, pullRequestWorkflow] = await Promise.all([ + readFile(resolve(repositoryRoot, '.github/workflows/release.yml'), 'utf8'), + readFile(resolve(repositoryRoot, '.github/workflows/pr-checks.yml'), 'utf8'), + ]) + expect(releaseWorkflow).toContain("github.repository == 'verbeux-ai/code'") + expect(releaseWorkflow).toContain('VERBOO_DESKTOP_MINISIGN_SECRET_KEY_B64') + expect(releaseWorkflow).toContain('VERBOO_DESKTOP_MINISIGN_PUBLIC_KEY') + expect(releaseWorkflow).toContain('gh release upload') + expect(releaseWorkflow).not.toContain('--clobber') + expect(pullRequestWorkflow).not.toContain('gh release upload') + for (const { target } of DESKTOP_TARGETS) { + expect(pullRequestWorkflow).toContain(`target: ${target}`) + expect(releaseWorkflow).toContain(`target: ${target}`) + } + }) +}) diff --git a/scripts/desktop-release/contract.ts b/scripts/desktop-release/contract.ts new file mode 100644 index 0000000000..487bf66603 --- /dev/null +++ b/scripts/desktop-release/contract.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto' + +export type DesktopTarget = + | 'aarch64-apple-darwin' + | 'x86_64-apple-darwin' + | 'x86_64-pc-windows-msvc' + | 'x86_64-unknown-linux-gnu' + +export type DesktopTargetDefinition = { + target: DesktopTarget + runner: 'macos-15' | 'macos-15-intel' | 'windows-latest' | 'ubuntu-22.04' + nodePlatform: 'darwin-arm64' | 'darwin-x64' | 'win-x64' | 'linux-x64' +} + +export const DESKTOP_TARGETS = [ + { + target: 'aarch64-apple-darwin', + runner: 'macos-15', + nodePlatform: 'darwin-arm64', + }, + { + target: 'x86_64-apple-darwin', + runner: 'macos-15-intel', + nodePlatform: 'darwin-x64', + }, + { + target: 'x86_64-pc-windows-msvc', + runner: 'windows-latest', + nodePlatform: 'win-x64', + }, + { + target: 'x86_64-unknown-linux-gnu', + runner: 'ubuntu-22.04', + nodePlatform: 'linux-x64', + }, +] as const satisfies readonly DesktopTargetDefinition[] + +export type DesktopCliArtifact = { + target: DesktopTarget + url: string + size: number + sha256: string + archive: 'tar.gz' +} + +export type DesktopCliManifest = { + schemaVersion: 1 + cliVersion: string + releasedAt: string + desktopProtocol: number + desktopVersion: { + min: string + maxExclusive: string + } + node: { + range: string + modules: string + napi: string + } + signingKeyId: string + artifacts: DesktopCliArtifact[] +} + +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ +const RELEASE_TAG_PATTERN = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ + +export function parseDesktopTarget(value: string): DesktopTarget { + const match = DESKTOP_TARGETS.find(item => item.target === value) + if (!match) { + throw new Error(`Unsupported desktop target: ${value}`) + } + return match.target +} + +export function artifactName(version: string, target: DesktopTarget): string { + if (!SEMVER_PATTERN.test(version)) { + throw new Error(`Invalid CLI version: ${version}`) + } + parseDesktopTarget(target) + return `verboo-cli-${version}-${target}.tar.gz` +} + +export function releaseAssetUrl(repository: string, tag: string, name: string): string { + if (repository !== 'verbeux-ai/code') { + throw new Error(`Unexpected release repository: ${repository}`) + } + if (!RELEASE_TAG_PATTERN.test(tag)) { + throw new Error(`Invalid release tag: ${tag}`) + } + if (!/^verboo-cli-[0-9A-Za-z._-]+\.tar\.gz$/.test(name)) { + throw new Error(`Invalid release asset name: ${name}`) + } + return `https://github.com/${repository}/releases/download/${tag}/${name}` +} + +export function manifestBytes(manifest: DesktopCliManifest): Uint8Array { + return new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`) +} + +export function signingKeyId(publicKeyText: string): string { + const normalized = publicKeyText.trim() + if (!normalized) throw new Error('Minisign public key is empty') + return createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 16) +} diff --git a/scripts/desktop-release/manifest.test.ts b/scripts/desktop-release/manifest.test.ts new file mode 100644 index 0000000000..80eabb3e2a --- /dev/null +++ b/scripts/desktop-release/manifest.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' + +import { DESKTOP_TARGETS } from './contract.js' +import { buildManifest } from './manifest.js' + +describe('desktop CLI manifest generation', () => { + test('sorts every artifact by the canonical target order', () => { + const artifacts = [...DESKTOP_TARGETS] + .reverse() + .map(({ target }, index) => ({ + target, + url: `https://example.invalid/${target}`, + size: 100 + index, + sha256: String(index).padStart(64, '0'), + archive: 'tar.gz' as const, + })) + const manifest = buildManifest({ + version: '0.15.5', + releasedAt: '2026-08-08T12:00:00.000Z', + signingKeyId: '0123456789abcdef', + artifacts, + }) + + expect(manifest.artifacts.map(item => item.target)).toEqual( + DESKTOP_TARGETS.map(item => item.target), + ) + expect(manifest).toMatchObject({ + schemaVersion: 1, + cliVersion: '0.15.5', + desktopProtocol: 1, + desktopVersion: { min: '0.7.0-beta', maxExclusive: '0.8.0' }, + node: { range: '>=24.0.0 <25.0.0', modules: '137', napi: '10' }, + signingKeyId: '0123456789abcdef', + }) + }) + + test('rejects a missing, duplicate, or malformed target record', () => { + const artifacts = DESKTOP_TARGETS.map(({ target }) => ({ + target, + url: `https://example.invalid/${target}`, + size: 1, + sha256: 'a'.repeat(64), + archive: 'tar.gz' as const, + })) + expect(() => + buildManifest({ + version: '0.15.5', + releasedAt: '2026-08-08T12:00:00.000Z', + signingKeyId: '0123456789abcdef', + artifacts: artifacts.slice(1), + }), + ).toThrow('exactly one artifact') + expect(() => + buildManifest({ + version: '0.15.5', + releasedAt: '2026-08-08T12:00:00.000Z', + signingKeyId: '0123456789abcdef', + artifacts: [...artifacts.slice(0, 3), artifacts[0]], + }), + ).toThrow('exactly one artifact') + expect(() => + buildManifest({ + version: '0.15.5', + releasedAt: 'not-a-date', + signingKeyId: 'not-a-key-id', + artifacts, + }), + ).toThrow('Invalid release timestamp') + }) +}) diff --git a/scripts/desktop-release/manifest.ts b/scripts/desktop-release/manifest.ts new file mode 100644 index 0000000000..39ba732d93 --- /dev/null +++ b/scripts/desktop-release/manifest.ts @@ -0,0 +1,160 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' + +import { + DESKTOP_TARGETS, + artifactName, + manifestBytes, + releaseAssetUrl, + type DesktopCliArtifact, + type DesktopCliManifest, +} from './contract.js' + +export type BuildManifestInput = { + version: string + releasedAt: string + signingKeyId: string + artifacts: DesktopCliArtifact[] +} + +export type BuildManifestFromAssetsInput = Omit & { + assetsDir: string + repository: string + tag: string +} + +export function buildManifest(input: BuildManifestInput): DesktopCliManifest { + artifactName(input.version, 'aarch64-apple-darwin') + if (!isCanonicalTimestamp(input.releasedAt)) { + throw new Error(`Invalid release timestamp: ${input.releasedAt}`) + } + if (!/^[a-f0-9]{16}$/.test(input.signingKeyId)) { + throw new Error(`Invalid signing key ID: ${input.signingKeyId}`) + } + + const artifacts = DESKTOP_TARGETS.map(({ target }) => { + const matches = input.artifacts.filter(item => item.target === target) + if (matches.length !== 1) { + throw new Error(`Manifest requires exactly one artifact for ${target}`) + } + const artifact = matches[0] + if (!Number.isSafeInteger(artifact.size) || artifact.size <= 0) { + throw new Error(`Invalid artifact size for ${target}`) + } + if (!/^[a-f0-9]{64}$/.test(artifact.sha256)) { + throw new Error(`Invalid artifact SHA-256 for ${target}`) + } + if (artifact.archive !== 'tar.gz') { + throw new Error(`Unsupported archive format for ${target}`) + } + return { ...artifact } + }) + if (input.artifacts.length !== DESKTOP_TARGETS.length) { + throw new Error('Manifest requires exactly one artifact for every desktop target') + } + + return { + schemaVersion: 1, + cliVersion: input.version, + releasedAt: input.releasedAt, + desktopProtocol: 1, + desktopVersion: { + min: '0.7.0-beta', + maxExclusive: '0.8.0', + }, + node: { + range: '>=24.0.0 <25.0.0', + modules: '137', + napi: '10', + }, + signingKeyId: input.signingKeyId, + artifacts, + } +} + +export async function buildManifestFromAssets( + input: BuildManifestFromAssetsInput, +): Promise { + if (input.tag !== `v${input.version}`) { + throw new Error(`Release tag ${input.tag} does not match CLI version ${input.version}`) + } + const artifacts: DesktopCliArtifact[] = [] + for (const { target } of DESKTOP_TARGETS) { + const name = artifactName(input.version, target) + const archivePath = join(input.assetsDir, name) + const metadataPath = join( + input.assetsDir, + `${name.slice(0, -'.tar.gz'.length)}.metadata.json`, + ) + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as { + version?: string + target?: string + size?: number + sha256?: string + } + if (metadata.version !== input.version || metadata.target !== target) { + throw new Error(`Native metadata does not match ${target}`) + } + const actual = await hashFile(archivePath) + if (metadata.size !== actual.size || metadata.sha256 !== actual.sha256) { + throw new Error(`Native metadata mismatch for ${target}`) + } + artifacts.push({ + target, + url: releaseAssetUrl(input.repository, input.tag, name), + size: actual.size, + sha256: actual.sha256, + archive: 'tar.gz', + }) + } + return buildManifest({ ...input, artifacts }) +} + +async function hashFile(path: string): Promise<{ size: number; sha256: string }> { + const hash = createHash('sha256') + let size = 0 + await new Promise((resolvePromise, rejectPromise) => { + const stream = createReadStream(path) + stream.on('data', chunk => { + const bytes = chunk as Buffer + size += bytes.length + hash.update(bytes) + }) + stream.on('error', rejectPromise) + stream.on('end', resolvePromise) + }) + return { size, sha256: hash.digest('hex') } +} + +function isCanonicalTimestamp(value: string): boolean { + const time = Date.parse(value) + return Number.isFinite(time) && new Date(time).toISOString() === value +} + +function readArgument(name: string): string { + const index = process.argv.indexOf(name) + const value = index >= 0 ? process.argv[index + 1] : undefined + if (!value) throw new Error(`Missing required argument: ${name}`) + return value +} + +if (import.meta.main) { + const repositoryRoot = resolve(import.meta.dir, '..', '..') + const packageJson = JSON.parse( + await readFile(join(repositoryRoot, 'package.json'), 'utf8'), + ) as { version: string } + const assetsDir = resolve(readArgument('--assets-dir')) + const manifest = await buildManifestFromAssets({ + assetsDir, + repository: readArgument('--repository'), + tag: readArgument('--tag'), + version: packageJson.version, + releasedAt: readArgument('--released-at'), + signingKeyId: readArgument('--signing-key-id'), + }) + const destination = join(assetsDir, 'verboo-cli-manifest.json') + await writeFile(destination, manifestBytes(manifest)) + process.stdout.write(`${destination}\n`) +} diff --git a/scripts/desktop-release/package.test.ts b/scripts/desktop-release/package.test.ts new file mode 100644 index 0000000000..3110f5bdd1 --- /dev/null +++ b/scripts/desktop-release/package.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + assertDesktopIntegrationContract, + assertRegularPayloadTree, + materializePayload, + packageDesktopCli, +} from './package.js' + +const temporaryRoots: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'verboo-desktop-package-test-')) + temporaryRoots.push(root) + const source = join(root, 'source') + const output = join(root, 'output') + await mkdir(join(source, 'dist'), { recursive: true }) + await mkdir(join(source, 'node_modules', 'dependency'), { recursive: true }) + await writeFile( + join(source, 'dist', 'cli.mjs'), + "// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nconsole.log('1.2.3 (Verboo Code)')\n", + ) + await writeFile(join(source, 'node_modules', 'dependency', 'index.js'), 'export default 1\n') + await writeFile(join(source, 'LICENSE'), 'MIT\n') + return { root, source, output } +} + +describe('desktop CLI native packaging', () => { + test('rejects a built entrypoint that no longer exposes desktop todo markers', () => { + expect(() => assertDesktopIntegrationContract('TodoWrite todoFeatureEnabled todo_reminder')).toThrow( + 'Desktop integration marker', + ) + expect(() => + assertDesktopIntegrationContract( + 'TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder', + ), + ).not.toThrow() + }) + + test('materializes the entrypoint, dependency tree, metadata, and license without Node', async () => { + const { root, source } = await fixture() + const payload = await materializePayload({ + version: '1.2.3', + target: 'aarch64-apple-darwin', + entrypoint: join(source, 'dist', 'cli.mjs'), + nodeModules: join(source, 'node_modules'), + license: join(source, 'LICENSE'), + stagingRoot: join(root, 'staging'), + }) + + expect(await readFile(join(payload, 'dist', 'cli.mjs'), 'utf8')).toContain('1.2.3') + expect(await readFile(join(payload, 'LICENSE'), 'utf8')).toBe('MIT\n') + expect(await Bun.file(join(payload, 'node')).exists()).toBe(false) + expect(await Bun.file(join(payload, 'node.exe')).exists()).toBe(false) + + const metadata = JSON.parse(await readFile(join(payload, 'package.json'), 'utf8')) + expect(metadata).toMatchObject({ + name: '@verboo/code', + version: '1.2.3', + type: 'module', + verbooDesktop: { schemaVersion: 1, target: 'aarch64-apple-darwin' }, + }) + }) + + test.skipIf(process.platform === 'win32')('dereferences dependency symlinks', async () => { + const { root, source } = await fixture() + await mkdir(join(source, 'node_modules', '.bin'), { recursive: true }) + await symlink( + join(source, 'node_modules', 'dependency', 'index.js'), + join(source, 'node_modules', '.bin', 'dependency'), + ) + + const payload = await materializePayload({ + version: '1.2.3', + target: 'aarch64-apple-darwin', + entrypoint: join(source, 'dist', 'cli.mjs'), + nodeModules: join(source, 'node_modules'), + license: join(source, 'LICENSE'), + stagingRoot: join(root, 'staging'), + }) + + expect((await stat(join(payload, 'node_modules', '.bin', 'dependency'))).isFile()).toBe(true) + await expect(assertRegularPayloadTree(payload)).resolves.toBeUndefined() + }) + + test('creates a hashed archive only after the payload smoke passes', async () => { + const { source, output } = await fixture() + const result = await packageDesktopCli({ + version: '1.2.3', + target: 'aarch64-apple-darwin', + entrypoint: join(source, 'dist', 'cli.mjs'), + nodeModules: join(source, 'node_modules'), + license: join(source, 'LICENSE'), + outputDir: output, + nodeExecutable: process.execPath, + }) + + expect(result.size).toBeGreaterThan(0) + expect(result.sha256).toMatch(/^[a-f0-9]{64}$/) + expect(await Bun.file(result.archivePath).exists()).toBe(true) + expect(JSON.parse(await readFile(result.metadataPath, 'utf8'))).toMatchObject({ + version: '1.2.3', + target: 'aarch64-apple-darwin', + size: result.size, + sha256: result.sha256, + }) + }) + + test('does not leave an archive when the smoke output is for another version', async () => { + const { source, output } = await fixture() + await expect( + packageDesktopCli({ + version: '9.9.9', + target: 'aarch64-apple-darwin', + entrypoint: join(source, 'dist', 'cli.mjs'), + nodeModules: join(source, 'node_modules'), + license: join(source, 'LICENSE'), + outputDir: output, + nodeExecutable: process.execPath, + }), + ).rejects.toThrow('CLI smoke version mismatch') + }) +}) diff --git a/scripts/desktop-release/package.ts b/scripts/desktop-release/package.ts new file mode 100644 index 0000000000..c1db837cbb --- /dev/null +++ b/scripts/desktop-release/package.ts @@ -0,0 +1,280 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { + cp, + lstat, + mkdir, + mkdtemp, + opendir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { spawn } from 'node:child_process' + +import { + artifactName, + parseDesktopTarget, + type DesktopTarget, +} from './contract.js' + +export type MaterializePayloadInput = { + version: string + target: DesktopTarget + entrypoint: string + nodeModules: string + license: string + stagingRoot: string +} + +export type PackageDesktopCliInput = Omit & { + outputDir: string + nodeExecutable: string +} + +export type PackageDesktopCliResult = { + archivePath: string + metadataPath: string + version: string + target: DesktopTarget + size: number + sha256: string +} + +const DESKTOP_INTEGRATION_MARKERS = [ + { name: 'TodoWrite', minimum: 2 }, + { name: 'todoFeatureEnabled', minimum: 1 }, + { name: 'todo_reminder', minimum: 2 }, +] as const + +export function assertDesktopIntegrationContract(entrypoint: string): void { + for (const { name, minimum } of DESKTOP_INTEGRATION_MARKERS) { + const occurrences = entrypoint.split(name).length - 1 + if (occurrences < minimum) { + throw new Error( + `Desktop integration marker ${JSON.stringify(name)} appears ${occurrences} times; expected at least ${minimum}`, + ) + } + } +} + +export async function materializePayload(input: MaterializePayloadInput): Promise { + parseDesktopTarget(input.target) + assertDesktopIntegrationContract(await readFile(input.entrypoint, 'utf8')) + const payload = join( + input.stagingRoot, + `verboo-cli-${input.version}-${input.target}`, + ) + await mkdir(join(payload, 'dist'), { recursive: true }) + await cp(input.entrypoint, join(payload, 'dist', 'cli.mjs'), { + errorOnExist: true, + force: false, + }) + await cp(input.nodeModules, join(payload, 'node_modules'), { + recursive: true, + dereference: true, + errorOnExist: true, + force: false, + }) + await cp(input.license, join(payload, 'LICENSE'), { + errorOnExist: true, + force: false, + }) + await writeFile( + join(payload, 'package.json'), + `${JSON.stringify( + { + name: '@verboo/code', + version: input.version, + type: 'module', + engines: { node: '>=24.0.0 <25.0.0' }, + verbooDesktop: { + schemaVersion: 1, + target: input.target, + }, + }, + null, + 2, + )}\n`, + { flag: 'wx' }, + ) + await assertRegularPayloadTree(payload) + await assertNodeIsNotBundled(payload) + return payload +} + +export async function assertRegularPayloadTree(root: string): Promise { + const visit = async (directory: string): Promise => { + const entries = await opendir(directory) + for await (const entry of entries) { + const path = join(directory, entry.name) + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) { + throw new Error(`Payload contains a symbolic link: ${path}`) + } + if (metadata.isDirectory()) { + await visit(path) + continue + } + if (!metadata.isFile()) { + throw new Error(`Payload contains a non-regular entry: ${path}`) + } + } + } + await visit(root) +} + +export async function packageDesktopCli( + input: PackageDesktopCliInput, +): Promise { + await mkdir(input.outputDir, { recursive: true }) + const stagingRoot = await mkdtemp(join(input.outputDir, '.staging-')) + const archivePath = join(input.outputDir, artifactName(input.version, input.target)) + const metadataPath = `${archivePath.slice(0, -'.tar.gz'.length)}.metadata.json` + + try { + const payload = await materializePayload({ ...input, stagingRoot }) + await smokePayload(input.nodeExecutable, payload, input.version) + await runProcess('tar', [ + '-czf', + archivePath, + '-C', + payload, + 'package.json', + 'LICENSE', + 'dist', + 'node_modules', + ]) + const { size, sha256 } = await hashFile(archivePath) + const result: PackageDesktopCliResult = { + archivePath, + metadataPath, + version: input.version, + target: input.target, + size, + sha256, + } + await writeFile(metadataPath, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' }) + return result + } catch (error) { + await Promise.all([ + rm(archivePath, { force: true }), + rm(metadataPath, { force: true }), + ]) + throw error + } finally { + await rm(stagingRoot, { recursive: true, force: true }) + } +} + +async function assertNodeIsNotBundled(payload: string): Promise { + for (const name of ['node', 'node.exe', 'npm', 'npm.cmd', 'npx', 'npx.cmd']) { + try { + await lstat(join(payload, name)) + throw new Error(`Payload must not bundle the Node runtime: ${name}`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } +} + +async function smokePayload( + nodeExecutable: string, + payload: string, + version: string, +): Promise { + const result = await runProcess( + nodeExecutable, + [join(payload, 'dist', 'cli.mjs'), '--version'], + payload, + ) + const expected = `${version} (Verboo Code)` + if (result.stdout.trim() !== expected) { + throw new Error( + `CLI smoke version mismatch: expected ${JSON.stringify(expected)}, got ${JSON.stringify(result.stdout.trim())}`, + ) + } +} + +async function hashFile(path: string): Promise<{ size: number; sha256: string }> { + const hash = createHash('sha256') + let size = 0 + await new Promise((resolvePromise, rejectPromise) => { + const stream = createReadStream(path) + stream.on('data', chunk => { + const bytes = chunk as Buffer + size += bytes.length + hash.update(bytes) + }) + stream.on('error', rejectPromise) + stream.on('end', resolvePromise) + }) + return { size, sha256: hash.digest('hex') } +} + +async function runProcess( + command: string, + args: string[], + cwd?: string, +): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd, + env: { + ...process.env, + DISABLE_AUTOUPDATER: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', chunk => { + stdout += chunk + }) + child.stderr.on('data', chunk => { + stderr += chunk + }) + child.on('error', rejectPromise) + child.on('close', code => { + if (code === 0) resolvePromise({ stdout, stderr }) + else { + rejectPromise( + new Error( + `${command} exited with ${code ?? 'no status'}${stderr.trim() ? `: ${stderr.trim()}` : ''}`, + ), + ) + } + }) + }) +} + +function readArgument(name: string): string { + const index = process.argv.indexOf(name) + const value = index >= 0 ? process.argv[index + 1] : undefined + if (!value) throw new Error(`Missing required argument: ${name}`) + return value +} + +if (import.meta.main) { + const repositoryRoot = resolve(import.meta.dir, '..', '..') + const packageJson = JSON.parse( + await readFile(join(repositoryRoot, 'package.json'), 'utf8'), + ) as { version: string } + const target = parseDesktopTarget(readArgument('--target')) + const outputDir = resolve(readArgument('--output-dir')) + const nodeExecutable = readArgument('--node-executable') + const result = await packageDesktopCli({ + version: packageJson.version, + target, + entrypoint: join(repositoryRoot, 'dist', 'cli.mjs'), + nodeModules: join(repositoryRoot, 'node_modules'), + license: join(repositoryRoot, 'LICENSE'), + outputDir, + nodeExecutable, + }) + process.stdout.write(`${JSON.stringify(result)}\n`) +} diff --git a/scripts/desktop-release/verify-release.test.ts b/scripts/desktop-release/verify-release.test.ts new file mode 100644 index 0000000000..3908da5c81 --- /dev/null +++ b/scripts/desktop-release/verify-release.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { DESKTOP_TARGETS, manifestBytes } from './contract.js' +import { packageDesktopCli } from './package.js' +import { buildManifestFromAssets } from './manifest.js' +import { verifyReleaseSet } from './verify-release.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +async function releaseFixture() { + const root = await mkdtemp(join(tmpdir(), 'verboo-release-set-test-')) + roots.push(root) + const source = join(root, 'source') + const assetsDir = join(root, 'assets') + const publicKeyText = 'fixture-public-key\n' + const signingKeyId = createHash('sha256') + .update(publicKeyText.trim(), 'utf8') + .digest('hex') + .slice(0, 16) + await mkdir(join(source, 'dist'), { recursive: true }) + await mkdir(join(source, 'node_modules', 'dependency'), { recursive: true }) + await writeFile( + join(source, 'dist', 'cli.mjs'), + "// TodoWrite TodoWrite todoFeatureEnabled todo_reminder todo_reminder\nconsole.log('1.2.3 (Verboo Code)')\n", + ) + await writeFile(join(source, 'node_modules', 'dependency', 'index.js'), 'export default 1\n') + await writeFile(join(source, 'LICENSE'), 'MIT\n') + + for (const { target } of DESKTOP_TARGETS) { + await packageDesktopCli({ + version: '1.2.3', + target, + entrypoint: join(source, 'dist', 'cli.mjs'), + nodeModules: join(source, 'node_modules'), + license: join(source, 'LICENSE'), + outputDir: assetsDir, + nodeExecutable: process.execPath, + }) + } + const manifest = await buildManifestFromAssets({ + assetsDir, + repository: 'verbeux-ai/code', + tag: 'v1.2.3', + version: '1.2.3', + releasedAt: '2026-08-08T12:00:00.000Z', + signingKeyId, + }) + const manifestPath = join(assetsDir, 'verboo-cli-manifest.json') + const signaturePath = join(assetsDir, 'verboo-cli-manifest.minisig') + const publicKeyPath = join(assetsDir, 'test.pub') + await writeFile(manifestPath, manifestBytes(manifest)) + await writeFile(signaturePath, 'fixture-signature\n') + await writeFile(publicKeyPath, publicKeyText) + return { assetsDir, manifestPath, signaturePath, publicKeyPath, manifest } +} + +describe('desktop CLI release verification', () => { + test('verifies the signature before parsing or trusting the manifest', async () => { + const fixture = await releaseFixture() + await writeFile(fixture.manifestPath, '{not-json') + let called = false + await expect( + verifyReleaseSet({ + ...fixture, + verifySignature: async () => { + called = true + throw new Error('signature rejected') + }, + }), + ).rejects.toThrow('signature rejected') + expect(called).toBe(true) + }) + + test('accepts a complete unchanged release set', async () => { + const fixture = await releaseFixture() + await expect( + verifyReleaseSet({ ...fixture, verifySignature: async () => {} }), + ).resolves.toMatchObject({ cliVersion: '1.2.3' }) + }) + + test('rejects one changed archive byte after signature verification', async () => { + const fixture = await releaseFixture() + const archive = fixture.manifest.artifacts[0] + const archivePath = join(fixture.assetsDir, archive.url.split('/').at(-1)!) + const bytes = new Uint8Array(await Bun.file(archivePath).arrayBuffer()) + bytes[0] ^= 1 + await writeFile(archivePath, bytes) + + await expect( + verifyReleaseSet({ ...fixture, verifySignature: async () => {} }), + ).rejects.toThrow(/mismatch/) + }) + + test('rejects a manifest whose signed target set is incomplete', async () => { + const fixture = await releaseFixture() + fixture.manifest.artifacts.pop() + await writeFile(fixture.manifestPath, manifestBytes(fixture.manifest)) + await expect( + verifyReleaseSet({ ...fixture, verifySignature: async () => {} }), + ).rejects.toThrow('exactly one artifact') + }) +}) diff --git a/scripts/desktop-release/verify-release.ts b/scripts/desktop-release/verify-release.ts new file mode 100644 index 0000000000..cd7a3316da --- /dev/null +++ b/scripts/desktop-release/verify-release.ts @@ -0,0 +1,208 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { spawn } from 'node:child_process' + +import { + artifactName, + releaseAssetUrl, + signingKeyId, + type DesktopCliManifest, +} from './contract.js' +import { buildManifest } from './manifest.js' + +export type VerifyReleaseSetInput = { + assetsDir: string + manifestPath: string + signaturePath: string + publicKeyPath: string + verifySignature?: ( + manifestPath: string, + signaturePath: string, + publicKeyPath: string, + ) => Promise +} + +export async function verifyReleaseSet( + input: VerifyReleaseSetInput, +): Promise { + const verifySignature = input.verifySignature ?? verifyWithMinisign + await verifySignature(input.manifestPath, input.signaturePath, input.publicKeyPath) + + const manifest = JSON.parse( + await readFile(input.manifestPath, 'utf8'), + ) as DesktopCliManifest + validateAuthenticatedManifest(manifest) + const publicKeyText = await readFile(input.publicKeyPath, 'utf8') + if (manifest.signingKeyId !== signingKeyId(publicKeyText)) { + throw new Error('Manifest signing key ID does not match the verification key') + } + + for (const artifact of manifest.artifacts) { + const expectedName = artifactName(manifest.cliVersion, artifact.target) + const expectedUrl = releaseAssetUrl( + 'verbeux-ai/code', + `v${manifest.cliVersion}`, + expectedName, + ) + if (artifact.url !== expectedUrl) { + throw new Error(`Unexpected release URL for ${artifact.target}`) + } + const archivePath = join(input.assetsDir, basename(new URL(artifact.url).pathname)) + const actual = await hashFile(archivePath) + if (actual.size !== artifact.size) { + throw new Error(`Size mismatch for ${artifact.target}`) + } + if (actual.sha256 !== artifact.sha256) { + throw new Error(`SHA-256 mismatch for ${artifact.target}`) + } + await inspectArchive(archivePath, manifest.cliVersion, artifact.target) + } + return manifest +} + +function validateAuthenticatedManifest(manifest: DesktopCliManifest): void { + if (!manifest || typeof manifest !== 'object') throw new Error('Manifest must be an object') + if (manifest.schemaVersion !== 1) throw new Error('Unsupported manifest schema') + const expected = buildManifest({ + version: manifest.cliVersion, + releasedAt: manifest.releasedAt, + signingKeyId: manifest.signingKeyId, + artifacts: manifest.artifacts, + }) + if ( + manifest.desktopProtocol !== expected.desktopProtocol || + manifest.desktopVersion?.min !== expected.desktopVersion.min || + manifest.desktopVersion?.maxExclusive !== expected.desktopVersion.maxExclusive || + manifest.node?.range !== expected.node.range || + manifest.node?.modules !== expected.node.modules || + manifest.node?.napi !== expected.node.napi + ) { + throw new Error('Manifest compatibility contract is invalid') + } +} + +async function inspectArchive( + archivePath: string, + version: string, + target: string, +): Promise { + const listing = await runProcess('tar', ['-tzf', archivePath]) + const names = listing.stdout.split(/\r?\n/).filter(Boolean) + if (!names.includes('package.json')) throw new Error(`Archive lacks package.json for ${target}`) + if (!names.includes('dist/cli.mjs')) throw new Error(`Archive lacks dist/cli.mjs for ${target}`) + if (!names.some(name => name.startsWith('node_modules/'))) { + throw new Error(`Archive lacks node_modules for ${target}`) + } + for (const name of names) { + const normalized = name.replace(/\\/g, '/') + if ( + normalized.startsWith('/') || + /^[A-Za-z]:\//.test(normalized) || + normalized.split('/').includes('..') + ) { + throw new Error(`Unsafe archive path for ${target}: ${name}`) + } + if (['node', 'node.exe', 'npm', 'npm.cmd', 'npx', 'npx.cmd'].includes(normalized)) { + throw new Error(`Archive bundles Node for ${target}: ${name}`) + } + } + + const verbose = await runProcess('tar', ['-tvzf', archivePath]) + for (const line of verbose.stdout.split(/\r?\n/).filter(Boolean)) { + if (line[0] !== '-' && line[0] !== 'd') { + throw new Error(`Archive contains a link or device for ${target}`) + } + } + + const packageJson = JSON.parse( + (await runProcess('tar', ['-xOzf', archivePath, 'package.json'])).stdout, + ) as { + version?: string + verbooDesktop?: { schemaVersion?: number; target?: string } + } + if ( + packageJson.version !== version || + packageJson.verbooDesktop?.schemaVersion !== 1 || + packageJson.verbooDesktop?.target !== target + ) { + throw new Error(`Archive package metadata mismatch for ${target}`) + } +} + +async function verifyWithMinisign( + manifestPath: string, + signaturePath: string, + publicKeyPath: string, +): Promise { + await runProcess('minisign', [ + '-Vm', + manifestPath, + '-x', + signaturePath, + '-p', + publicKeyPath, + ]) +} + +async function hashFile(path: string): Promise<{ size: number; sha256: string }> { + const hash = createHash('sha256') + let size = 0 + await new Promise((resolvePromise, rejectPromise) => { + const stream = createReadStream(path) + stream.on('data', chunk => { + const bytes = chunk as Buffer + size += bytes.length + hash.update(bytes) + }) + stream.on('error', rejectPromise) + stream.on('end', resolvePromise) + }) + return { size, sha256: hash.digest('hex') } +} + +async function runProcess( + command: string, + args: string[], +): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', chunk => { + stdout += chunk + }) + child.stderr.on('data', chunk => { + stderr += chunk + }) + child.on('error', rejectPromise) + child.on('close', code => { + if (code === 0) resolvePromise({ stdout, stderr }) + else rejectPromise(new Error(`${command} exited with ${code}: ${stderr.trim()}`)) + }) + }) +} + +function readArgument(name: string): string { + const index = process.argv.indexOf(name) + const value = index >= 0 ? process.argv[index + 1] : undefined + if (!value) throw new Error(`Missing required argument: ${name}`) + return value +} + +if (import.meta.main) { + const assetsDir = resolve(readArgument('--assets-dir')) + const manifest = await verifyReleaseSet({ + assetsDir, + manifestPath: join(assetsDir, 'verboo-cli-manifest.json'), + signaturePath: join(assetsDir, 'verboo-cli-manifest.minisig'), + publicKeyPath: resolve(readArgument('--public-key')), + }) + process.stdout.write(`Verified Verboo CLI ${manifest.cliVersion}\n`) +}