| 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,8 +75,11 @@ npx t3 |
| 75 | - `bun run build` — Builds contracts, web app, and server through Turbo. | 75 | - `bun run build` — Builds contracts, web app, and server through Turbo. |
| 76 | - `bun run typecheck` — Strict TypeScript checks for all packages. | 76 | - `bun run typecheck` — Strict TypeScript checks for all packages. |
| 77 | - `bun run test` — Runs workspace tests. | 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 | - `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. | 79 | - `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. |
| 79 | - `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. | 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 | ### Desktop `.dmg` packaging notes | 84 | ### Desktop `.dmg` packaging notes |
| 82 | 85 | ||
| ... | @@ -86,6 +89,12 @@ npx t3 | ... | @@ -86,6 +89,12 @@ npx t3 |
| 86 | - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. | 89 | - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. |
| 87 | - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. | 90 | - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. |
| 88 | - To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` | 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 | ### Running multiple dev instances | 99 | ### Running multiple dev instances |
| 91 | 100 | ||
| ... | @@ -122,3 +131,6 @@ Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI | ... | @@ -122,3 +131,6 @@ Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI |
| 122 | ## CI quality gates | 131 | ## CI quality gates |
| 123 | 132 | ||
| 124 | - `.github/workflows/ci.yml` runs `bun run lint`, `bun run typecheck`, and `bun run test` on pull requests and pushes to `main`. | 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,9 +29,12 @@ |
| 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", | 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 30 | "fmt": "oxfmt", | 30 | "fmt": "oxfmt", |
| 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", | 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 32 | "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs", | 32 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.mjs", |
| 33 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64", | 33 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg", |
| 34 | "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64", | 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 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", | 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 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" | 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,392 +1,9 @@ |
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | 2 | ||
| 3 | import { spawn } from "node:child_process"; | 3 | import { main } from "./build-desktop-artifact.mjs"; |
| 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 | 4 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 5 | main(["--platform", "mac", "--target", "dmg", ...process.argv.slice(2)]).catch((error) => { |
| 10 | const repoRoot = path.resolve(__dirname, ".."); | 6 | const message = error instanceof Error ? error.message : String(error); |
| 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) { | ||
| 33 | console.error(`[desktop-dmg] ${message}`); | 7 | console.error(`[desktop-dmg] ${message}`); |
| 34 | process.exit(1); | 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 | }); |