| author | |
| committer | |
| log | 23afddfbf90e961f9e079042d5612dc7724b057f |
| tree | 5c5660d1c918ae40a05d6a1c341113f2bde3ea0b |
| parent | e3a2dfb9d0ca049890c10a9f27124be2eb1a5985 |
- add GitHub Actions release workflow for v*.*.* tags with preflight checks
- build and publish macOS, Linux, and Windows desktop artifacts in one release
- replace DMG-only script with cross-platform `dist:desktop:artifact` flow
- document release, signing setup, and new desktop dist commands6 files changed, 978 insertions(+), 389 deletions(-)
.github/workflows/release.yml created+180| ... | ... | @@ -0,0 +1,180 @@ |
| 1 | name: Release Desktop | |
| 2 | ||
| 3 | on: | |
| 4 | push: | |
| 5 | tags: | |
| 6 | - "v*.*.*" | |
| 7 | ||
| 8 | permissions: | |
| 9 | contents: write | |
| 10 | ||
| 11 | jobs: | |
| 12 | preflight: | |
| 13 | name: Preflight | |
| 14 | runs-on: ubuntu-24.04 | |
| 15 | outputs: | |
| 16 | version: ${{ steps.version.outputs.value }} | |
| 17 | steps: | |
| 18 | - name: Checkout | |
| 19 | uses: actions/checkout@v4 | |
| 20 | ||
| 21 | - name: Setup Bun | |
| 22 | uses: oven-sh/setup-bun@v2 | |
| 23 | with: | |
| 24 | bun-version-file: package.json | |
| 25 | ||
| 26 | - name: Setup Node | |
| 27 | uses: actions/setup-node@v4 | |
| 28 | with: | |
| 29 | node-version-file: package.json | |
| 30 | ||
| 31 | - name: Install dependencies | |
| 32 | run: bun install --frozen-lockfile | |
| 33 | ||
| 34 | - name: Lint | |
| 35 | run: bun run lint | |
| 36 | ||
| 37 | - name: Typecheck | |
| 38 | run: bun run typecheck | |
| 39 | ||
| 40 | - name: Test | |
| 41 | run: bun run test | |
| 42 | ||
| 43 | - id: version | |
| 44 | name: Resolve release version | |
| 45 | run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" | |
| 46 | ||
| 47 | build: | |
| 48 | name: Build ${{ matrix.label }} | |
| 49 | needs: preflight | |
| 50 | runs-on: ${{ matrix.runner }} | |
| 51 | strategy: | |
| 52 | fail-fast: false | |
| 53 | matrix: | |
| 54 | include: | |
| 55 | - label: macOS arm64 | |
| 56 | runner: macos-14 | |
| 57 | platform: mac | |
| 58 | target: dmg | |
| 59 | arch: arm64 | |
| 60 | - label: macOS x64 | |
| 61 | runner: macos-13 | |
| 62 | platform: mac | |
| 63 | target: dmg | |
| 64 | arch: x64 | |
| 65 | - label: Linux x64 | |
| 66 | runner: ubuntu-24.04 | |
| 67 | platform: linux | |
| 68 | target: AppImage | |
| 69 | arch: x64 | |
| 70 | - label: Windows x64 | |
| 71 | runner: windows-2022 | |
| 72 | platform: win | |
| 73 | target: nsis | |
| 74 | arch: x64 | |
| 75 | steps: | |
| 76 | - name: Checkout | |
| 77 | uses: actions/checkout@v4 | |
| 78 | ||
| 79 | - name: Setup Bun | |
| 80 | uses: oven-sh/setup-bun@v2 | |
| 81 | with: | |
| 82 | bun-version-file: package.json | |
| 83 | ||
| 84 | - name: Setup Node | |
| 85 | uses: actions/setup-node@v4 | |
| 86 | with: | |
| 87 | node-version-file: package.json | |
| 88 | ||
| 89 | - name: Install dependencies | |
| 90 | run: bun install --frozen-lockfile | |
| 91 | ||
| 92 | - name: Build desktop artifact | |
| 93 | shell: bash | |
| 94 | env: | |
| 95 | CSC_LINK: ${{ secrets.CSC_LINK }} | |
| 96 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} | |
| 97 | APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} | |
| 98 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} | |
| 99 | APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} | |
| 100 | AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} | |
| 101 | AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} | |
| 102 | AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| 103 | AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} | |
| 104 | AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} | |
| 105 | AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} | |
| 106 | AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} | |
| 107 | run: | | |
| 108 | args=( | |
| 109 | --platform "${{ matrix.platform }}" | |
| 110 | --target "${{ matrix.target }}" | |
| 111 | --arch "${{ matrix.arch }}" | |
| 112 | --version "${{ needs.preflight.outputs.version }}" | |
| 113 | ) | |
| 114 | ||
| 115 | has_all() { | |
| 116 | for value in "$@"; do | |
| 117 | if [[ -z "$value" ]]; then | |
| 118 | return 1 | |
| 119 | fi | |
| 120 | done | |
| 121 | return 0 | |
| 122 | } | |
| 123 | ||
| 124 | if [[ "${{ matrix.platform }}" == "mac" ]]; then | |
| 125 | if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then | |
| 126 | key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" | |
| 127 | printf '%s' "$APPLE_API_KEY" > "$key_path" | |
| 128 | export APPLE_API_KEY="$key_path" | |
| 129 | echo "macOS signing enabled." | |
| 130 | args+=(--signed) | |
| 131 | else | |
| 132 | echo "macOS signing disabled (missing one or more Apple signing secrets)." | |
| 133 | fi | |
| 134 | elif [[ "${{ matrix.platform }}" == "win" ]]; then | |
| 135 | if has_all \ | |
| 136 | "$AZURE_TENANT_ID" \ | |
| 137 | "$AZURE_CLIENT_ID" \ | |
| 138 | "$AZURE_CLIENT_SECRET" \ | |
| 139 | "$AZURE_TRUSTED_SIGNING_ENDPOINT" \ | |
| 140 | "$AZURE_TRUSTED_SIGNING_ACCOUNT_NAME" \ | |
| 141 | "$AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME" \ | |
| 142 | "$AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"; then | |
| 143 | echo "Windows signing enabled (Azure Trusted Signing)." | |
| 144 | args+=(--signed) | |
| 145 | else | |
| 146 | echo "Windows signing disabled (missing one or more Azure Trusted Signing secrets)." | |
| 147 | fi | |
| 148 | else | |
| 149 | echo "Signing disabled for ${{ matrix.platform }}." | |
| 150 | fi | |
| 151 | ||
| 152 | bun run dist:desktop:artifact -- "${args[@]}" | |
| 153 | ||
| 154 | - name: Upload build artifacts | |
| 155 | uses: actions/upload-artifact@v4 | |
| 156 | with: | |
| 157 | name: desktop-${{ matrix.platform }}-${{ matrix.arch }} | |
| 158 | path: release/* | |
| 159 | if-no-files-found: error | |
| 160 | ||
| 161 | release: | |
| 162 | name: Publish GitHub Release | |
| 163 | needs: [preflight, build] | |
| 164 | runs-on: ubuntu-24.04 | |
| 165 | steps: | |
| 166 | - name: Download all desktop artifacts | |
| 167 | uses: actions/download-artifact@v4 | |
| 168 | with: | |
| 169 | pattern: desktop-* | |
| 170 | merge-multiple: true | |
| 171 | path: release-assets | |
| 172 | ||
| 173 | - name: Publish release | |
| 174 | uses: softprops/action-gh-release@v2 | |
| 175 | with: | |
| 176 | tag_name: ${{ github.ref_name }} | |
| 177 | name: T3 Code v${{ needs.preflight.outputs.version }} | |
| 178 | generate_release_notes: true | |
| 179 | files: release-assets/* | |
| 180 | fail_on_unmatched_files: true |
README.md+12| ... | ... | @@ -75,8 +75,11 @@ npx t3 |
| 75 | 75 | - `bun run build` — Builds contracts, web app, and server through Turbo. |
| 76 | 76 | - `bun run typecheck` — Strict TypeScript checks for all packages. |
| 77 | 77 | - `bun run test` — Runs workspace tests. |
| 78 | - `bun run dist:desktop:artifact -- --platform <mac|linux|win> --target <target> --arch <arch>` — Builds a desktop artifact for a specific platform/target/arch. | |
| 78 | 79 | - `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. |
| 79 | 80 | - `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. |
| 81 | - `bun run dist:desktop:linux` — Builds a Linux AppImage into `./release`. | |
| 82 | - `bun run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. | |
| 80 | 83 | |
| 81 | 84 | ### Desktop `.dmg` packaging notes |
| 82 | 85 | |
| ... | ... | @@ -86,6 +89,12 @@ npx t3 |
| 86 | 89 | - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. |
| 87 | 90 | - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. |
| 88 | 91 | - To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` |
| 92 | - To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. | |
| 93 | - Windows `--signed` uses Azure Trusted Signing and expects: | |
| 94 | `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME`, | |
| 95 | `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, and `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME`. | |
| 96 | - Azure authentication env vars are also required (for example service principal with secret): | |
| 97 | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`. | |
| 89 | 98 | |
| 90 | 99 | ### Running multiple dev instances |
| 91 | 100 | |
| ... | ... | @@ -122,3 +131,6 @@ Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI |
| 122 | 131 | ## CI quality gates |
| 123 | 132 | |
| 124 | 133 | - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. |
| 134 | - `.github/workflows/release.yml` builds macOS (`arm64` and `x64`), Linux (`x64`), and Windows (`x64`) desktop artifacts from a single `v*.*.*` tag and publishes one GitHub release. | |
| 135 | - The release workflow auto-enables signing only when secrets are present: Apple credentials for macOS and Azure Trusted Signing credentials for Windows. Without secrets, it still releases unsigned artifacts. | |
| 136 | - See `docs/release.md` for full release/signing setup checklist. |
docs/release.md created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | # Release Checklist | |
| 2 | ||
| 3 | This document covers how to run desktop releases from one tag, first without signing, then with signing. | |
| 4 | ||
| 5 | ## What the workflow does | |
| 6 | ||
| 7 | - Trigger: push tag matching `v*.*.*`. | |
| 8 | - Runs quality gates first: lint, typecheck, test. | |
| 9 | - Builds four artifacts in parallel: | |
| 10 | - macOS `arm64` DMG | |
| 11 | - macOS `x64` DMG | |
| 12 | - Linux `x64` AppImage | |
| 13 | - Windows `x64` NSIS installer | |
| 14 | - Publishes one GitHub Release with all produced files. | |
| 15 | - Signing is optional and auto-detected per platform from secrets. | |
| 16 | ||
| 17 | ## 1) Dry-run release without signing | |
| 18 | ||
| 19 | Use this first to validate the release pipeline. | |
| 20 | ||
| 21 | 1. Confirm no signing secrets are required for this test. | |
| 22 | 2. Create a test tag: | |
| 23 | - `git tag v0.0.0-test.1` | |
| 24 | - `git push origin v0.0.0-test.1` | |
| 25 | 3. Wait for `.github/workflows/release.yml` to finish. | |
| 26 | 4. Verify the GitHub Release contains all platform artifacts. | |
| 27 | 5. Download each artifact and sanity-check installation on each OS. | |
| 28 | ||
| 29 | ## 2) Apple signing + notarization setup (macOS) | |
| 30 | ||
| 31 | Required secrets used by the workflow: | |
| 32 | ||
| 33 | - `CSC_LINK` | |
| 34 | - `CSC_KEY_PASSWORD` | |
| 35 | - `APPLE_API_KEY` | |
| 36 | - `APPLE_API_KEY_ID` | |
| 37 | - `APPLE_API_ISSUER` | |
| 38 | ||
| 39 | Checklist: | |
| 40 | ||
| 41 | 1. Apple Developer account access: | |
| 42 | - Team has rights to create Developer ID certificates. | |
| 43 | 2. Create `Developer ID Application` certificate. | |
| 44 | 3. Export certificate + private key as `.p12` from Keychain. | |
| 45 | 4. Base64-encode the `.p12` and store as `CSC_LINK`. | |
| 46 | 5. Store the `.p12` export password as `CSC_KEY_PASSWORD`. | |
| 47 | 6. In App Store Connect, create an API key (Team key). | |
| 48 | 7. Add API key values: | |
| 49 | - `APPLE_API_KEY`: contents of the downloaded `.p8` | |
| 50 | - `APPLE_API_KEY_ID`: Key ID | |
| 51 | - `APPLE_API_ISSUER`: Issuer ID | |
| 52 | 8. Re-run a tag release and confirm macOS artifacts are signed/notarized. | |
| 53 | ||
| 54 | Notes: | |
| 55 | ||
| 56 | - `APPLE_API_KEY` is stored as raw key text in secrets. | |
| 57 | - The workflow writes it to a temporary `AuthKey_<id>.p8` file at runtime. | |
| 58 | ||
| 59 | ## 3) Azure Trusted Signing setup (Windows) | |
| 60 | ||
| 61 | Required secrets used by the workflow: | |
| 62 | ||
| 63 | - `AZURE_TENANT_ID` | |
| 64 | - `AZURE_CLIENT_ID` | |
| 65 | - `AZURE_CLIENT_SECRET` | |
| 66 | - `AZURE_TRUSTED_SIGNING_ENDPOINT` | |
| 67 | - `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME` | |
| 68 | - `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME` | |
| 69 | - `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME` | |
| 70 | ||
| 71 | Checklist: | |
| 72 | ||
| 73 | 1. Create Azure Trusted Signing account and certificate profile. | |
| 74 | 2. Record ATS values: | |
| 75 | - Endpoint | |
| 76 | - Account name | |
| 77 | - Certificate profile name | |
| 78 | - Publisher name | |
| 79 | 3. Create/choose an Entra app registration (service principal). | |
| 80 | 4. Grant service principal permissions required by Trusted Signing. | |
| 81 | 5. Create a client secret for the service principal. | |
| 82 | 6. Add Azure secrets listed above in GitHub Actions secrets. | |
| 83 | 7. Re-run a tag release and confirm Windows installer is signed. | |
| 84 | ||
| 85 | ## 4) Ongoing release checklist | |
| 86 | ||
| 87 | 1. Ensure `main` is green in CI. | |
| 88 | 2. Bump app version as needed. | |
| 89 | 3. Create release tag: `vX.Y.Z`. | |
| 90 | 4. Push tag. | |
| 91 | 5. Verify workflow steps: | |
| 92 | - preflight passes | |
| 93 | - all matrix builds pass | |
| 94 | - release job uploads expected files | |
| 95 | 6. Smoke test downloaded artifacts. | |
| 96 | ||
| 97 | ## 5) Troubleshooting | |
| 98 | ||
| 99 | - macOS build unsigned when expected signed: | |
| 100 | - Check all Apple secrets are populated and non-empty. | |
| 101 | - Windows build unsigned when expected signed: | |
| 102 | - Check all Azure ATS and auth secrets are populated and non-empty. | |
| 103 | - Build fails with signing error: | |
| 104 | - Retry with secrets removed to confirm unsigned path still works. | |
| 105 | - Re-check certificate/profile names and tenant/client credentials. |
package.json+6-3| ... | ... | @@ -29,9 +29,12 @@ |
| 29 | 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 30 | 30 | "fmt": "oxfmt", |
| 31 | 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 32 | "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs", | |
| 33 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64", | |
| 34 | "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64", | |
| 32 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.mjs", | |
| 33 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg", | |
| 34 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch arm64", | |
| 35 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch x64", | |
| 36 | "dist:desktop:linux": "node scripts/build-desktop-artifact.mjs --platform linux --target AppImage --arch x64", | |
| 37 | "dist:desktop:win": "node scripts/build-desktop-artifact.mjs --platform win --target nsis --arch x64", | |
| 35 | 38 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", |
| 36 | 39 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 37 | 40 | }, |
scripts/build-desktop-artifact.mjs created+672| ... | ... | @@ -0,0 +1,672 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import { spawn } from "node:child_process"; | |
| 4 | import fs from "node:fs/promises"; | |
| 5 | import os from "node:os"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | const repoRoot = path.resolve(__dirname, ".."); | |
| 11 | const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); | |
| 12 | const DIRECT_EXECUTION_PATH = process.argv[1] ? path.resolve(process.argv[1]) : null; | |
| 13 | ||
| 14 | const PLATFORM_CONFIG = { | |
| 15 | mac: { | |
| 16 | hostPlatform: "darwin", | |
| 17 | description: "macOS", | |
| 18 | cliFlag: "--mac", | |
| 19 | defaultTarget: "dmg", | |
| 20 | targetChoices: ["dmg"], | |
| 21 | archChoices: ["arm64", "x64", "universal"], | |
| 22 | }, | |
| 23 | linux: { | |
| 24 | hostPlatform: "linux", | |
| 25 | description: "Linux", | |
| 26 | cliFlag: "--linux", | |
| 27 | defaultTarget: "AppImage", | |
| 28 | targetChoices: ["AppImage"], | |
| 29 | archChoices: ["x64", "arm64"], | |
| 30 | }, | |
| 31 | win: { | |
| 32 | hostPlatform: "win32", | |
| 33 | description: "Windows", | |
| 34 | cliFlag: "--win", | |
| 35 | defaultTarget: "nsis", | |
| 36 | targetChoices: ["nsis"], | |
| 37 | archChoices: ["x64", "arm64"], | |
| 38 | }, | |
| 39 | }; | |
| 40 | ||
| 41 | function printUsage() { | |
| 42 | console.log(`Build a desktop artifact for T3 Code. | |
| 43 | ||
| 44 | Usage: | |
| 45 | node scripts/build-desktop-artifact.mjs [options] | |
| 46 | ||
| 47 | Options: | |
| 48 | --platform <mac|linux|win> Target platform (default: current host platform) | |
| 49 | --target <target> Target format (mac: dmg, linux: AppImage, win: nsis) | |
| 50 | --arch <arm64|x64|universal> Target arch (platform-dependent default) | |
| 51 | --version <semver> App version in artifact metadata (default: apps/server version) | |
| 52 | --output-dir <path> Output folder for final artifacts (default: release) | |
| 53 | --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) | |
| 54 | --keep-stage Keep temporary staging directory | |
| 55 | --signed Enable signing/notarization auto-discovery in electron-builder | |
| 56 | (Windows uses Azure Trusted Signing) | |
| 57 | --help Show this message | |
| 58 | `); | |
| 59 | } | |
| 60 | ||
| 61 | function fail(message) { | |
| 62 | console.error(`[desktop-artifact] ${message}`); | |
| 63 | process.exit(1); | |
| 64 | } | |
| 65 | ||
| 66 | function detectHostBuildPlatform(hostPlatform) { | |
| 67 | if (hostPlatform === "darwin") return "mac"; | |
| 68 | if (hostPlatform === "linux") return "linux"; | |
| 69 | if (hostPlatform === "win32") return "win"; | |
| 70 | return null; | |
| 71 | } | |
| 72 | ||
| 73 | function normalizePlatform(value) { | |
| 74 | const normalized = value.toLowerCase(); | |
| 75 | if (normalized === "mac" || normalized === "macos" || normalized === "darwin") return "mac"; | |
| 76 | if (normalized === "linux") return "linux"; | |
| 77 | if (normalized === "win" || normalized === "windows" || normalized === "win32") return "win"; | |
| 78 | return null; | |
| 79 | } | |
| 80 | ||
| 81 | function normalizeChoice(inputValue, choices) { | |
| 82 | const normalized = inputValue.toLowerCase(); | |
| 83 | for (const choice of choices) { | |
| 84 | if (choice.toLowerCase() === normalized) { | |
| 85 | return choice; | |
| 86 | } | |
| 87 | } | |
| 88 | return null; | |
| 89 | } | |
| 90 | ||
| 91 | function formatChoices(values) { | |
| 92 | return values.join(", "); | |
| 93 | } | |
| 94 | ||
| 95 | function readRequiredEnvironmentVariable(name, contextMessage) { | |
| 96 | const value = process.env[name]; | |
| 97 | if (typeof value !== "string" || value.trim().length === 0) { | |
| 98 | fail(`Missing ${name}${contextMessage ? ` (${contextMessage})` : ""}.`); | |
| 99 | } | |
| 100 | return value.trim(); | |
| 101 | } | |
| 102 | ||
| 103 | function readOptionalEnvironmentVariable(name) { | |
| 104 | const value = process.env[name]; | |
| 105 | if (typeof value !== "string") return undefined; | |
| 106 | const trimmed = value.trim(); | |
| 107 | return trimmed.length > 0 ? trimmed : undefined; | |
| 108 | } | |
| 109 | ||
| 110 | function getAzureTrustedSigningOptions() { | |
| 111 | return { | |
| 112 | publisherName: readRequiredEnvironmentVariable( | |
| 113 | "AZURE_TRUSTED_SIGNING_PUBLISHER_NAME", | |
| 114 | "required for Windows --signed", | |
| 115 | ), | |
| 116 | endpoint: readRequiredEnvironmentVariable( | |
| 117 | "AZURE_TRUSTED_SIGNING_ENDPOINT", | |
| 118 | "required for Windows --signed", | |
| 119 | ), | |
| 120 | certificateProfileName: readRequiredEnvironmentVariable( | |
| 121 | "AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME", | |
| 122 | "required for Windows --signed", | |
| 123 | ), | |
| 124 | codeSigningAccountName: readRequiredEnvironmentVariable( | |
| 125 | "AZURE_TRUSTED_SIGNING_ACCOUNT_NAME", | |
| 126 | "required for Windows --signed", | |
| 127 | ), | |
| 128 | fileDigest: readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_FILE_DIGEST") ?? "SHA256", | |
| 129 | timestampDigest: | |
| 130 | readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST") ?? "SHA256", | |
| 131 | timestampRfc3161: | |
| 132 | readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161") ?? | |
| 133 | "http://timestamp.acs.microsoft.com", | |
| 134 | }; | |
| 135 | } | |
| 136 | ||
| 137 | function getDefaultArch(platform) { | |
| 138 | const config = PLATFORM_CONFIG[platform]; | |
| 139 | if (!config) { | |
| 140 | return "x64"; | |
| 141 | } | |
| 142 | ||
| 143 | if (process.arch === "arm64" && config.archChoices.includes("arm64")) { | |
| 144 | return "arm64"; | |
| 145 | } | |
| 146 | if (process.arch === "x64" && config.archChoices.includes("x64")) { | |
| 147 | return "x64"; | |
| 148 | } | |
| 149 | ||
| 150 | return config.archChoices[0] ?? "x64"; | |
| 151 | } | |
| 152 | ||
| 153 | function parseArgs(argv) { | |
| 154 | const hostPlatform = detectHostBuildPlatform(process.platform); | |
| 155 | const parsed = { | |
| 156 | platform: hostPlatform, | |
| 157 | target: null, | |
| 158 | arch: null, | |
| 159 | version: null, | |
| 160 | skipBuild: false, | |
| 161 | keepStage: false, | |
| 162 | signed: false, | |
| 163 | outputDir: path.join(repoRoot, "release"), | |
| 164 | help: false, | |
| 165 | }; | |
| 166 | ||
| 167 | for (let index = 0; index < argv.length; index += 1) { | |
| 168 | const arg = argv[index]; | |
| 169 | ||
| 170 | if (arg === "--help") { | |
| 171 | parsed.help = true; | |
| 172 | continue; | |
| 173 | } | |
| 174 | if (arg === "--skip-build") { | |
| 175 | parsed.skipBuild = true; | |
| 176 | continue; | |
| 177 | } | |
| 178 | if (arg === "--keep-stage") { | |
| 179 | parsed.keepStage = true; | |
| 180 | continue; | |
| 181 | } | |
| 182 | if (arg === "--signed") { | |
| 183 | parsed.signed = true; | |
| 184 | continue; | |
| 185 | } | |
| 186 | ||
| 187 | if ( | |
| 188 | arg === "--platform" || | |
| 189 | arg === "--target" || | |
| 190 | arg === "--arch" || | |
| 191 | arg === "--version" || | |
| 192 | arg === "--output-dir" | |
| 193 | ) { | |
| 194 | const value = argv[index + 1]; | |
| 195 | if (!value || value.startsWith("--")) { | |
| 196 | fail(`Missing value for ${arg}`); | |
| 197 | } | |
| 198 | index += 1; | |
| 199 | ||
| 200 | if (arg === "--platform") { | |
| 201 | const platform = normalizePlatform(value); | |
| 202 | if (!platform) { | |
| 203 | fail(`Invalid platform '${value}'. Expected one of: mac, linux, win`); | |
| 204 | } | |
| 205 | parsed.platform = platform; | |
| 206 | continue; | |
| 207 | } | |
| 208 | ||
| 209 | if (arg === "--target") { | |
| 210 | parsed.target = value; | |
| 211 | continue; | |
| 212 | } | |
| 213 | ||
| 214 | if (arg === "--arch") { | |
| 215 | parsed.arch = value; | |
| 216 | continue; | |
| 217 | } | |
| 218 | ||
| 219 | if (arg === "--version") { | |
| 220 | parsed.version = value; | |
| 221 | continue; | |
| 222 | } | |
| 223 | ||
| 224 | if (arg === "--output-dir") { | |
| 225 | parsed.outputDir = path.resolve(repoRoot, value); | |
| 226 | continue; | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | fail(`Unknown argument: ${arg}`); | |
| 231 | } | |
| 232 | ||
| 233 | if (!parsed.platform) { | |
| 234 | const hosts = ["darwin", "linux", "win32"].join(", "); | |
| 235 | fail(`Unsupported host platform '${process.platform}'. Supported hosts: ${hosts}`); | |
| 236 | } | |
| 237 | ||
| 238 | const config = PLATFORM_CONFIG[parsed.platform]; | |
| 239 | if (!config) { | |
| 240 | fail(`Unsupported platform '${parsed.platform}'`); | |
| 241 | } | |
| 242 | ||
| 243 | const target = | |
| 244 | parsed.target === null | |
| 245 | ? config.defaultTarget | |
| 246 | : normalizeChoice(parsed.target, config.targetChoices); | |
| 247 | if (!target) { | |
| 248 | fail( | |
| 249 | `Invalid target '${parsed.target}'. Supported targets for ${parsed.platform}: ${formatChoices( | |
| 250 | config.targetChoices, | |
| 251 | )}`, | |
| 252 | ); | |
| 253 | } | |
| 254 | ||
| 255 | const arch = parsed.arch === null ? getDefaultArch(parsed.platform) : normalizeChoice(parsed.arch, config.archChoices); | |
| 256 | if (!arch) { | |
| 257 | fail( | |
| 258 | `Invalid arch '${parsed.arch}'. Supported arches for ${parsed.platform}: ${formatChoices( | |
| 259 | config.archChoices, | |
| 260 | )}`, | |
| 261 | ); | |
| 262 | } | |
| 263 | ||
| 264 | return { | |
| 265 | ...parsed, | |
| 266 | target, | |
| 267 | arch, | |
| 268 | }; | |
| 269 | } | |
| 270 | ||
| 271 | async function readJson(filePath) { | |
| 272 | const raw = await fs.readFile(filePath, "utf8"); | |
| 273 | return JSON.parse(raw); | |
| 274 | } | |
| 275 | ||
| 276 | async function exists(filePath) { | |
| 277 | try { | |
| 278 | await fs.access(filePath); | |
| 279 | return true; | |
| 280 | } catch { | |
| 281 | return false; | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { | |
| 286 | return new Promise((resolve, reject) => { | |
| 287 | const child = spawn(command, args, { | |
| 288 | cwd, | |
| 289 | env, | |
| 290 | stdio, | |
| 291 | }); | |
| 292 | ||
| 293 | child.on("error", reject); | |
| 294 | child.on("exit", (code, signal) => { | |
| 295 | if (signal) { | |
| 296 | reject(new Error(`${command} exited via signal ${signal}`)); | |
| 297 | return; | |
| 298 | } | |
| 299 | if (code !== 0) { | |
| 300 | reject(new Error(`${command} exited with code ${code}`)); | |
| 301 | return; | |
| 302 | } | |
| 303 | resolve(); | |
| 304 | }); | |
| 305 | }); | |
| 306 | } | |
| 307 | ||
| 308 | async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { | |
| 309 | const iconsetDir = path.join(tmpRoot, "icon.iconset"); | |
| 310 | await fs.mkdir(iconsetDir, { recursive: true }); | |
| 311 | ||
| 312 | const iconSizes = [16, 32, 128, 256, 512]; | |
| 313 | for (const size of iconSizes) { | |
| 314 | await run( | |
| 315 | "sips", | |
| 316 | [ | |
| 317 | "-z", | |
| 318 | String(size), | |
| 319 | String(size), | |
| 320 | sourcePng, | |
| 321 | "--out", | |
| 322 | path.join(iconsetDir, `icon_${size}x${size}.png`), | |
| 323 | ], | |
| 324 | { stdio: "ignore" }, | |
| 325 | ); | |
| 326 | ||
| 327 | const retinaSize = size * 2; | |
| 328 | await run( | |
| 329 | "sips", | |
| 330 | [ | |
| 331 | "-z", | |
| 332 | String(retinaSize), | |
| 333 | String(retinaSize), | |
| 334 | sourcePng, | |
| 335 | "--out", | |
| 336 | path.join(iconsetDir, `icon_${size}x${size}@2x.png`), | |
| 337 | ], | |
| 338 | { stdio: "ignore" }, | |
| 339 | ); | |
| 340 | } | |
| 341 | ||
| 342 | await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); | |
| 343 | } | |
| 344 | ||
| 345 | async function stageMacIcons(stageResourcesDir) { | |
| 346 | if (!(await exists(PRODUCTION_ICON_SOURCE))) { | |
| 347 | fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); | |
| 348 | } | |
| 349 | ||
| 350 | const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); | |
| 351 | ||
| 352 | try { | |
| 353 | const iconPngPath = path.join(stageResourcesDir, "icon.png"); | |
| 354 | const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); | |
| 355 | ||
| 356 | await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { | |
| 357 | stdio: "ignore", | |
| 358 | }); | |
| 359 | ||
| 360 | await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); | |
| 361 | } finally { | |
| 362 | await fs.rm(tmpRoot, { recursive: true, force: true }); | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | async function validateBundledClientAssets(clientDir) { | |
| 367 | const indexPath = path.join(clientDir, "index.html"); | |
| 368 | const indexHtml = await fs.readFile(indexPath, "utf8"); | |
| 369 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); | |
| 370 | const missing = []; | |
| 371 | ||
| 372 | for (const ref of refs) { | |
| 373 | const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; | |
| 374 | if (!normalizedRef) continue; | |
| 375 | if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; | |
| 376 | if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; | |
| 377 | ||
| 378 | const ext = path.extname(normalizedRef); | |
| 379 | if (!ext) continue; | |
| 380 | ||
| 381 | const relativePath = normalizedRef.replace(/^\/+/, ""); | |
| 382 | const assetPath = path.join(clientDir, relativePath); | |
| 383 | if (!(await exists(assetPath))) { | |
| 384 | missing.push(normalizedRef); | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | if (missing.length > 0) { | |
| 389 | const preview = missing.slice(0, 6).join(", "); | |
| 390 | const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; | |
| 391 | fail( | |
| 392 | `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, | |
| 393 | ); | |
| 394 | } | |
| 395 | } | |
| 396 | ||
| 397 | function readWorkspaceCatalog(rootPackageJson) { | |
| 398 | const workspaces = rootPackageJson?.workspaces; | |
| 399 | if (!workspaces || typeof workspaces !== "object" || Array.isArray(workspaces)) { | |
| 400 | return {}; | |
| 401 | } | |
| 402 | ||
| 403 | const catalog = workspaces.catalog; | |
| 404 | if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { | |
| 405 | return {}; | |
| 406 | } | |
| 407 | ||
| 408 | return catalog; | |
| 409 | } | |
| 410 | ||
| 411 | function resolveCatalogDependencies(dependencies, catalog, dependencySourceLabel) { | |
| 412 | return Object.fromEntries( | |
| 413 | Object.entries(dependencies).map(([dependencyName, spec]) => { | |
| 414 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | |
| 415 | return [dependencyName, spec]; | |
| 416 | } | |
| 417 | ||
| 418 | const catalogKey = spec.slice("catalog:".length).trim(); | |
| 419 | const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; | |
| 420 | const resolvedSpec = catalog[lookupKey]; | |
| 421 | if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { | |
| 422 | fail( | |
| 423 | `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. ` + | |
| 424 | `Expected key '${lookupKey}' in root workspace catalog.`, | |
| 425 | ); | |
| 426 | } | |
| 427 | ||
| 428 | return [dependencyName, resolvedSpec]; | |
| 429 | }), | |
| 430 | ); | |
| 431 | } | |
| 432 | ||
| 433 | function createBuildConfig(platform, target, productName, signed) { | |
| 434 | const buildConfig = { | |
| 435 | appId: "com.t3tools.t3code", | |
| 436 | productName, | |
| 437 | artifactName: "T3-Code-${version}-${arch}.${ext}", | |
| 438 | directories: { | |
| 439 | buildResources: "apps/desktop/resources", | |
| 440 | }, | |
| 441 | }; | |
| 442 | ||
| 443 | if (platform === "mac") { | |
| 444 | buildConfig.mac = { | |
| 445 | target: [target], | |
| 446 | icon: "icon.icns", | |
| 447 | category: "public.app-category.developer-tools", | |
| 448 | }; | |
| 449 | } | |
| 450 | ||
| 451 | if (platform === "linux") { | |
| 452 | buildConfig.linux = { | |
| 453 | target: [target], | |
| 454 | icon: "icon.png", | |
| 455 | category: "Development", | |
| 456 | }; | |
| 457 | } | |
| 458 | ||
| 459 | if (platform === "win") { | |
| 460 | const winConfig = { | |
| 461 | target: [target], | |
| 462 | icon: "icon.ico", | |
| 463 | }; | |
| 464 | if (signed) { | |
| 465 | winConfig.azureSignOptions = getAzureTrustedSigningOptions(); | |
| 466 | } | |
| 467 | buildConfig.win = winConfig; | |
| 468 | } | |
| 469 | ||
| 470 | return buildConfig; | |
| 471 | } | |
| 472 | ||
| 473 | async function assertPlatformBuildResources(platform, stageResourcesDir) { | |
| 474 | if (platform === "mac") { | |
| 475 | await stageMacIcons(stageResourcesDir); | |
| 476 | return; | |
| 477 | } | |
| 478 | ||
| 479 | if (platform === "linux") { | |
| 480 | const iconPath = path.join(stageResourcesDir, "icon.png"); | |
| 481 | if (!(await exists(iconPath))) { | |
| 482 | fail(`Missing Linux icon at ${iconPath}`); | |
| 483 | } | |
| 484 | return; | |
| 485 | } | |
| 486 | ||
| 487 | if (platform === "win") { | |
| 488 | const iconPath = path.join(stageResourcesDir, "icon.ico"); | |
| 489 | if (!(await exists(iconPath))) { | |
| 490 | fail(`Missing Windows icon at ${iconPath}`); | |
| 491 | } | |
| 492 | } | |
| 493 | } | |
| 494 | ||
| 495 | export async function main(argv = process.argv.slice(2)) { | |
| 496 | const options = parseArgs(argv); | |
| 497 | if (options.help) { | |
| 498 | printUsage(); | |
| 499 | return; | |
| 500 | } | |
| 501 | ||
| 502 | const hostBuildPlatform = detectHostBuildPlatform(process.platform); | |
| 503 | if (!hostBuildPlatform) { | |
| 504 | fail(`Unsupported host platform '${process.platform}'.`); | |
| 505 | } | |
| 506 | ||
| 507 | const platformConfig = PLATFORM_CONFIG[options.platform]; | |
| 508 | if (!platformConfig) { | |
| 509 | fail(`Unsupported platform '${options.platform}'.`); | |
| 510 | } | |
| 511 | ||
| 512 | if (hostBuildPlatform !== options.platform) { | |
| 513 | fail( | |
| 514 | `Platform '${options.platform}' must run on ${platformConfig.description} (${platformConfig.hostPlatform}). ` + | |
| 515 | `Current host is ${process.platform}.`, | |
| 516 | ); | |
| 517 | } | |
| 518 | ||
| 519 | const rootPkg = await readJson(path.join(repoRoot, "package.json")); | |
| 520 | const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); | |
| 521 | const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); | |
| 522 | ||
| 523 | const electronVersion = desktopPkg.dependencies?.electron; | |
| 524 | if (typeof electronVersion !== "string" || electronVersion.length === 0) { | |
| 525 | fail("Could not resolve apps/desktop electron dependency version."); | |
| 526 | } | |
| 527 | ||
| 528 | const dependencies = serverPkg.dependencies; | |
| 529 | if (!dependencies || Object.keys(dependencies).length === 0) { | |
| 530 | fail("Could not resolve production dependencies from apps/server/package.json."); | |
| 531 | } | |
| 532 | ||
| 533 | const resolvedDependencies = resolveCatalogDependencies( | |
| 534 | dependencies, | |
| 535 | readWorkspaceCatalog(rootPkg), | |
| 536 | "apps/server", | |
| 537 | ); | |
| 538 | ||
| 539 | const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; | |
| 540 | const stageRoot = path.join(os.tmpdir(), `t3code-desktop-${options.platform}-stage`); | |
| 541 | const stageAppDir = path.join(stageRoot, "app"); | |
| 542 | const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); | |
| 543 | const distDirs = { | |
| 544 | desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), | |
| 545 | desktopResources: path.join(repoRoot, "apps/desktop/resources"), | |
| 546 | serverDist: path.join(repoRoot, "apps/server/dist"), | |
| 547 | }; | |
| 548 | const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); | |
| 549 | ||
| 550 | if (!options.skipBuild) { | |
| 551 | console.log("[desktop-artifact] Building desktop/server/web artifacts..."); | |
| 552 | await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); | |
| 553 | } | |
| 554 | ||
| 555 | for (const [label, dir] of Object.entries(distDirs)) { | |
| 556 | if (!(await exists(dir))) { | |
| 557 | fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); | |
| 558 | } | |
| 559 | } | |
| 560 | ||
| 561 | if (!(await exists(bundledClientEntry))) { | |
| 562 | fail(`Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`); | |
| 563 | } | |
| 564 | ||
| 565 | await validateBundledClientAssets(path.dirname(bundledClientEntry)); | |
| 566 | ||
| 567 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 568 | await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); | |
| 569 | await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); | |
| 570 | ||
| 571 | console.log("[desktop-artifact] Staging release app..."); | |
| 572 | await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { | |
| 573 | recursive: true, | |
| 574 | }); | |
| 575 | await fs.cp(distDirs.desktopResources, stageResourcesDir, { | |
| 576 | recursive: true, | |
| 577 | }); | |
| 578 | await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); | |
| 579 | ||
| 580 | await assertPlatformBuildResources(options.platform, stageResourcesDir); | |
| 581 | ||
| 582 | const stagePackageJson = { | |
| 583 | name: "t3-code-desktop", | |
| 584 | version: appVersion, | |
| 585 | private: true, | |
| 586 | description: "T3 Code desktop build", | |
| 587 | author: "T3 Tools", | |
| 588 | main: "apps/desktop/dist-electron/main.js", | |
| 589 | build: createBuildConfig( | |
| 590 | options.platform, | |
| 591 | options.target, | |
| 592 | desktopPkg.productName ?? "T3 Code", | |
| 593 | options.signed, | |
| 594 | ), | |
| 595 | dependencies: resolvedDependencies, | |
| 596 | devDependencies: { | |
| 597 | electron: electronVersion, | |
| 598 | }, | |
| 599 | }; | |
| 600 | ||
| 601 | await fs.writeFile( | |
| 602 | path.join(stageAppDir, "package.json"), | |
| 603 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | |
| 604 | "utf8", | |
| 605 | ); | |
| 606 | ||
| 607 | console.log("[desktop-artifact] Installing staged production dependencies..."); | |
| 608 | await run("bun", ["install", "--production"], { cwd: stageAppDir }); | |
| 609 | ||
| 610 | const buildEnv = { | |
| 611 | ...process.env, | |
| 612 | }; | |
| 613 | if (!options.signed) { | |
| 614 | buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; | |
| 615 | } | |
| 616 | ||
| 617 | const buildArgs = [ | |
| 618 | "--bun", | |
| 619 | "electron-builder", | |
| 620 | platformConfig.cliFlag, | |
| 621 | options.target, | |
| 622 | `--${options.arch}`, | |
| 623 | "--publish", | |
| 624 | "never", | |
| 625 | ]; | |
| 626 | ||
| 627 | console.log( | |
| 628 | `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, | |
| 629 | ); | |
| 630 | await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); | |
| 631 | ||
| 632 | const stageDistDir = path.join(stageAppDir, "dist"); | |
| 633 | if (!(await exists(stageDistDir))) { | |
| 634 | fail(`Build completed but dist directory was not found at ${stageDistDir}`); | |
| 635 | } | |
| 636 | ||
| 637 | const stageEntries = await fs.readdir(stageDistDir, { withFileTypes: true }); | |
| 638 | await fs.mkdir(options.outputDir, { recursive: true }); | |
| 639 | ||
| 640 | const copiedArtifacts = []; | |
| 641 | for (const entry of stageEntries) { | |
| 642 | if (!entry.isFile()) continue; | |
| 643 | ||
| 644 | const from = path.join(stageDistDir, entry.name); | |
| 645 | const to = path.join(options.outputDir, entry.name); | |
| 646 | await fs.copyFile(from, to); | |
| 647 | copiedArtifacts.push(to); | |
| 648 | } | |
| 649 | ||
| 650 | if (copiedArtifacts.length === 0) { | |
| 651 | fail(`Build completed but no files were produced in ${stageDistDir}`); | |
| 652 | } | |
| 653 | ||
| 654 | if (!options.keepStage) { | |
| 655 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 656 | } | |
| 657 | ||
| 658 | console.log("[desktop-artifact] Done. Artifacts:"); | |
| 659 | for (const artifact of copiedArtifacts) { | |
| 660 | console.log(` - ${artifact}`); | |
| 661 | } | |
| 662 | } | |
| 663 | ||
| 664 | const scriptPath = fileURLToPath(import.meta.url); | |
| 665 | const isDirectExecution = DIRECT_EXECUTION_PATH === scriptPath; | |
| 666 | ||
| 667 | if (isDirectExecution) { | |
| 668 | main().catch((error) => { | |
| 669 | const message = error instanceof Error ? error.message : String(error); | |
| 670 | fail(message); | |
| 671 | }); | |
| 672 | } |
scripts/build-desktop-dmg.mjs+3-386| ... | ... | @@ -1,392 +1,9 @@ |
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | |
| 3 | import { spawn } from "node:child_process"; | |
| 4 | import fs from "node:fs/promises"; | |
| 5 | import os from "node:os"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 3 | import { main } from "./build-desktop-artifact.mjs"; | |
| 8 | 4 | |
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | const repoRoot = path.resolve(__dirname, ".."); | |
| 11 | const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); | |
| 12 | ||
| 13 | const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); | |
| 14 | ||
| 15 | function printUsage() { | |
| 16 | console.log(`Build a shareable macOS .dmg for T3 Code desktop. | |
| 17 | ||
| 18 | Usage: | |
| 19 | node scripts/build-desktop-dmg.mjs [options] | |
| 20 | ||
| 21 | Options: | |
| 22 | --arch <arm64|x64|universal> Target arch (default: host arch if supported) | |
| 23 | --version <semver> App version in DMG metadata (default: apps/server version) | |
| 24 | --output-dir <path> Output folder for final artifacts (default: release) | |
| 25 | --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) | |
| 26 | --keep-stage Keep temporary staging directory | |
| 27 | --signed Allow automatic code signing discovery | |
| 28 | --help Show this message | |
| 29 | `); | |
| 30 | } | |
| 31 | ||
| 32 | function fail(message) { | |
| 5 | main(["--platform", "mac", "--target", "dmg", ...process.argv.slice(2)]).catch((error) => { | |
| 6 | const message = error instanceof Error ? error.message : String(error); | |
| 33 | 7 | console.error(`[desktop-dmg] ${message}`); |
| 34 | 8 | process.exit(1); |
| 35 | } | |
| 36 | ||
| 37 | function parseArgs(argv) { | |
| 38 | const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64"; | |
| 39 | const options = { | |
| 40 | arch: defaultArch, | |
| 41 | version: null, | |
| 42 | skipBuild: false, | |
| 43 | keepStage: false, | |
| 44 | signed: false, | |
| 45 | outputDir: path.join(repoRoot, "release"), | |
| 46 | }; | |
| 47 | ||
| 48 | for (let i = 0; i < argv.length; i += 1) { | |
| 49 | const arg = argv[i]; | |
| 50 | if (arg === "--help") { | |
| 51 | options.help = true; | |
| 52 | continue; | |
| 53 | } | |
| 54 | if (arg === "--skip-build") { | |
| 55 | options.skipBuild = true; | |
| 56 | continue; | |
| 57 | } | |
| 58 | if (arg === "--keep-stage") { | |
| 59 | options.keepStage = true; | |
| 60 | continue; | |
| 61 | } | |
| 62 | if (arg === "--signed") { | |
| 63 | options.signed = true; | |
| 64 | continue; | |
| 65 | } | |
| 66 | if (arg === "--arch" || arg === "--version" || arg === "--output-dir") { | |
| 67 | const value = argv[i + 1]; | |
| 68 | if (!value || value.startsWith("--")) { | |
| 69 | fail(`Missing value for ${arg}`); | |
| 70 | } | |
| 71 | i += 1; | |
| 72 | if (arg === "--arch") options.arch = value; | |
| 73 | if (arg === "--version") options.version = value; | |
| 74 | if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value); | |
| 75 | continue; | |
| 76 | } | |
| 77 | fail(`Unknown argument: ${arg}`); | |
| 78 | } | |
| 79 | ||
| 80 | if (!ARCH_CHOICES.has(options.arch)) { | |
| 81 | fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`); | |
| 82 | } | |
| 83 | ||
| 84 | return options; | |
| 85 | } | |
| 86 | ||
| 87 | async function readJson(filePath) { | |
| 88 | const raw = await fs.readFile(filePath, "utf8"); | |
| 89 | return JSON.parse(raw); | |
| 90 | } | |
| 91 | ||
| 92 | async function exists(filePath) { | |
| 93 | try { | |
| 94 | await fs.access(filePath); | |
| 95 | return true; | |
| 96 | } catch { | |
| 97 | return false; | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { | |
| 102 | return new Promise((resolve, reject) => { | |
| 103 | const child = spawn(command, args, { | |
| 104 | cwd, | |
| 105 | env, | |
| 106 | stdio, | |
| 107 | }); | |
| 108 | ||
| 109 | child.on("error", reject); | |
| 110 | child.on("exit", (code, signal) => { | |
| 111 | if (signal) { | |
| 112 | reject(new Error(`${command} exited via signal ${signal}`)); | |
| 113 | return; | |
| 114 | } | |
| 115 | if (code !== 0) { | |
| 116 | reject(new Error(`${command} exited with code ${code}`)); | |
| 117 | return; | |
| 118 | } | |
| 119 | resolve(); | |
| 120 | }); | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { | |
| 125 | const iconsetDir = path.join(tmpRoot, "icon.iconset"); | |
| 126 | await fs.mkdir(iconsetDir, { recursive: true }); | |
| 127 | ||
| 128 | const iconSizes = [16, 32, 128, 256, 512]; | |
| 129 | for (const size of iconSizes) { | |
| 130 | await run( | |
| 131 | "sips", | |
| 132 | [ | |
| 133 | "-z", | |
| 134 | String(size), | |
| 135 | String(size), | |
| 136 | sourcePng, | |
| 137 | "--out", | |
| 138 | path.join(iconsetDir, `icon_${size}x${size}.png`), | |
| 139 | ], | |
| 140 | { stdio: "ignore" }, | |
| 141 | ); | |
| 142 | const retinaSize = size * 2; | |
| 143 | await run( | |
| 144 | "sips", | |
| 145 | [ | |
| 146 | "-z", | |
| 147 | String(retinaSize), | |
| 148 | String(retinaSize), | |
| 149 | sourcePng, | |
| 150 | "--out", | |
| 151 | path.join(iconsetDir, `icon_${size}x${size}@2x.png`), | |
| 152 | ], | |
| 153 | { stdio: "ignore" }, | |
| 154 | ); | |
| 155 | } | |
| 156 | ||
| 157 | await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); | |
| 158 | } | |
| 159 | ||
| 160 | async function stageProductionIcons(stageResourcesDir) { | |
| 161 | if (!(await exists(PRODUCTION_ICON_SOURCE))) { | |
| 162 | fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); | |
| 163 | } | |
| 164 | ||
| 165 | const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); | |
| 166 | try { | |
| 167 | const iconPngPath = path.join(stageResourcesDir, "icon.png"); | |
| 168 | const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); | |
| 169 | await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { | |
| 170 | stdio: "ignore", | |
| 171 | }); | |
| 172 | await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); | |
| 173 | } finally { | |
| 174 | await fs.rm(tmpRoot, { recursive: true, force: true }); | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | async function validateBundledClientAssets(clientDir) { | |
| 179 | const indexPath = path.join(clientDir, "index.html"); | |
| 180 | const indexHtml = await fs.readFile(indexPath, "utf8"); | |
| 181 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); | |
| 182 | const missing = []; | |
| 183 | ||
| 184 | for (const ref of refs) { | |
| 185 | const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; | |
| 186 | if (!normalizedRef) continue; | |
| 187 | if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; | |
| 188 | if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; | |
| 189 | ||
| 190 | const ext = path.extname(normalizedRef); | |
| 191 | if (!ext) continue; | |
| 192 | ||
| 193 | const relativePath = normalizedRef.replace(/^\/+/, ""); | |
| 194 | const assetPath = path.join(clientDir, relativePath); | |
| 195 | if (!(await exists(assetPath))) { | |
| 196 | missing.push(normalizedRef); | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | if (missing.length > 0) { | |
| 201 | const preview = missing.slice(0, 6).join(", "); | |
| 202 | const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; | |
| 203 | fail( | |
| 204 | `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, | |
| 205 | ); | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | function readWorkspaceCatalog(rootPackageJson) { | |
| 210 | const workspaces = rootPackageJson?.workspaces; | |
| 211 | if (!workspaces || typeof workspaces !== "object" || Array.isArray(workspaces)) { | |
| 212 | return {}; | |
| 213 | } | |
| 214 | ||
| 215 | const catalog = workspaces.catalog; | |
| 216 | if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { | |
| 217 | return {}; | |
| 218 | } | |
| 219 | ||
| 220 | return catalog; | |
| 221 | } | |
| 222 | ||
| 223 | function resolveCatalogDependencies(dependencies, catalog, dependencySourceLabel) { | |
| 224 | return Object.fromEntries( | |
| 225 | Object.entries(dependencies).map(([dependencyName, spec]) => { | |
| 226 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | |
| 227 | return [dependencyName, spec]; | |
| 228 | } | |
| 229 | ||
| 230 | const catalogKey = spec.slice("catalog:".length).trim(); | |
| 231 | const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; | |
| 232 | const resolvedSpec = catalog[lookupKey]; | |
| 233 | if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { | |
| 234 | fail( | |
| 235 | `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. ` + | |
| 236 | `Expected key '${lookupKey}' in root workspace catalog.`, | |
| 237 | ); | |
| 238 | } | |
| 239 | ||
| 240 | return [dependencyName, resolvedSpec]; | |
| 241 | }), | |
| 242 | ); | |
| 243 | } | |
| 244 | ||
| 245 | async function main() { | |
| 246 | const options = parseArgs(process.argv.slice(2)); | |
| 247 | if (options.help) { | |
| 248 | printUsage(); | |
| 249 | return; | |
| 250 | } | |
| 251 | ||
| 252 | if (process.platform !== "darwin") { | |
| 253 | fail("This script only builds macOS .dmg artifacts and must run on macOS."); | |
| 254 | } | |
| 255 | ||
| 256 | const rootPkg = await readJson(path.join(repoRoot, "package.json")); | |
| 257 | const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); | |
| 258 | const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); | |
| 259 | ||
| 260 | const electronVersion = desktopPkg.dependencies?.electron; | |
| 261 | if (typeof electronVersion !== "string" || electronVersion.length === 0) { | |
| 262 | fail("Could not resolve apps/desktop electron dependency version."); | |
| 263 | } | |
| 264 | ||
| 265 | const dependencies = serverPkg.dependencies; | |
| 266 | if (!dependencies || Object.keys(dependencies).length === 0) { | |
| 267 | fail("Could not resolve production dependencies from apps/server/package.json."); | |
| 268 | } | |
| 269 | const resolvedDependencies = resolveCatalogDependencies( | |
| 270 | dependencies, | |
| 271 | readWorkspaceCatalog(rootPkg), | |
| 272 | "apps/server", | |
| 273 | ); | |
| 274 | ||
| 275 | const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; | |
| 276 | const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); | |
| 277 | const stageAppDir = path.join(stageRoot, "app"); | |
| 278 | const distDirs = { | |
| 279 | desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), | |
| 280 | desktopResources: path.join(repoRoot, "apps/desktop/resources"), | |
| 281 | serverDist: path.join(repoRoot, "apps/server/dist"), | |
| 282 | }; | |
| 283 | const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); | |
| 284 | ||
| 285 | if (!options.skipBuild) { | |
| 286 | console.log("[desktop-dmg] Building desktop/server/web artifacts..."); | |
| 287 | await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); | |
| 288 | } | |
| 289 | ||
| 290 | for (const [label, dir] of Object.entries(distDirs)) { | |
| 291 | if (!(await exists(dir))) { | |
| 292 | fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); | |
| 293 | } | |
| 294 | } | |
| 295 | if (!(await exists(bundledClientEntry))) { | |
| 296 | fail( | |
| 297 | `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, | |
| 298 | ); | |
| 299 | } | |
| 300 | await validateBundledClientAssets(path.dirname(bundledClientEntry)); | |
| 301 | ||
| 302 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 303 | await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); | |
| 304 | await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); | |
| 305 | ||
| 306 | console.log("[desktop-dmg] Staging release app..."); | |
| 307 | await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { | |
| 308 | recursive: true, | |
| 309 | }); | |
| 310 | await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), { | |
| 311 | recursive: true, | |
| 312 | }); | |
| 313 | await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); | |
| 314 | await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources")); | |
| 315 | ||
| 316 | const stagePackageJson = { | |
| 317 | name: "t3-code-desktop", | |
| 318 | version: appVersion, | |
| 319 | private: true, | |
| 320 | description: "T3 Code desktop build", | |
| 321 | author: "T3 Tools", | |
| 322 | main: "apps/desktop/dist-electron/main.js", | |
| 323 | build: { | |
| 324 | appId: "com.t3tools.t3code", | |
| 325 | productName: desktopPkg.productName ?? "T3 Code", | |
| 326 | artifactName: "T3-Code-${version}-${arch}.${ext}", | |
| 327 | directories: { | |
| 328 | buildResources: "apps/desktop/resources", | |
| 329 | }, | |
| 330 | mac: { | |
| 331 | target: ["dmg"], | |
| 332 | icon: "icon.icns", | |
| 333 | category: "public.app-category.developer-tools", | |
| 334 | }, | |
| 335 | }, | |
| 336 | dependencies: resolvedDependencies, | |
| 337 | devDependencies: { | |
| 338 | electron: electronVersion, | |
| 339 | }, | |
| 340 | }; | |
| 341 | ||
| 342 | await fs.writeFile( | |
| 343 | path.join(stageAppDir, "package.json"), | |
| 344 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | |
| 345 | "utf8", | |
| 346 | ); | |
| 347 | ||
| 348 | console.log("[desktop-dmg] Installing staged production dependencies..."); | |
| 349 | await run("bun", ["install", "--production"], { cwd: stageAppDir }); | |
| 350 | ||
| 351 | const buildEnv = { | |
| 352 | ...process.env, | |
| 353 | }; | |
| 354 | if (!options.signed) { | |
| 355 | buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; | |
| 356 | } | |
| 357 | ||
| 358 | const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`]; | |
| 359 | console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`); | |
| 360 | await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); | |
| 361 | ||
| 362 | const stageDistDir = path.join(stageAppDir, "dist"); | |
| 363 | const stageFiles = await fs.readdir(stageDistDir); | |
| 364 | const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg")); | |
| 365 | if (dmgFiles.length === 0) { | |
| 366 | fail(`Build completed but no .dmg found in ${stageDistDir}`); | |
| 367 | } | |
| 368 | ||
| 369 | await fs.mkdir(options.outputDir, { recursive: true }); | |
| 370 | const copiedArtifacts = []; | |
| 371 | for (const file of stageFiles) { | |
| 372 | if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue; | |
| 373 | const from = path.join(stageDistDir, file); | |
| 374 | const to = path.join(options.outputDir, file); | |
| 375 | await fs.copyFile(from, to); | |
| 376 | copiedArtifacts.push(to); | |
| 377 | } | |
| 378 | ||
| 379 | if (!options.keepStage) { | |
| 380 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 381 | } | |
| 382 | ||
| 383 | console.log("[desktop-dmg] Done. Artifacts:"); | |
| 384 | for (const artifact of copiedArtifacts) { | |
| 385 | console.log(` - ${artifact}`); | |
| 386 | } | |
| 387 | } | |
| 388 | ||
| 389 | main().catch((error) => { | |
| 390 | const message = error instanceof Error ? error.message : String(error); | |
| 391 | fail(message); | |
| 392 | 9 | }); |