authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 16:28:51-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 18:18:47-08:00
log23afddfbf90e961f9e079042d5612dc7724b057f
tree5c5660d1c918ae40a05d6a1c341113f2bde3ea0b
parente3a2dfb9d0ca049890c10a9f27124be2eb1a5985

Add tagged desktop release pipeline and unified artifact builder

- 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 commands

6 files changed, 978 insertions(+), 389 deletions(-)

.github/workflows/release.yml created+180
...@@ -0,0 +1,180 @@
1name: Release Desktop
2
3on:
4 push:
5 tags:
6 - "v*.*.*"
7
8permissions:
9 contents: write
10
11jobs:
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`.
8083
81### Desktop `.dmg` packaging notes84### Desktop `.dmg` packaging notes
8285
...@@ -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`.
8998
90### Running multiple dev instances99### Running multiple dev instances
91100
...@@ -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 gates131## CI quality gates
123132
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
3This 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
19Use this first to validate the release pipeline.
20
211. Confirm no signing secrets are required for this test.
222. Create a test tag:
23 - `git tag v0.0.0-test.1`
24 - `git push origin v0.0.0-test.1`
253. Wait for `.github/workflows/release.yml` to finish.
264. Verify the GitHub Release contains all platform artifacts.
275. Download each artifact and sanity-check installation on each OS.
28
29## 2) Apple signing + notarization setup (macOS)
30
31Required 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
39Checklist:
40
411. Apple Developer account access:
42 - Team has rights to create Developer ID certificates.
432. Create `Developer ID Application` certificate.
443. Export certificate + private key as `.p12` from Keychain.
454. Base64-encode the `.p12` and store as `CSC_LINK`.
465. Store the `.p12` export password as `CSC_KEY_PASSWORD`.
476. In App Store Connect, create an API key (Team key).
487. 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
528. Re-run a tag release and confirm macOS artifacts are signed/notarized.
53
54Notes:
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
61Required 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
71Checklist:
72
731. Create Azure Trusted Signing account and certificate profile.
742. Record ATS values:
75 - Endpoint
76 - Account name
77 - Certificate profile name
78 - Publisher name
793. Create/choose an Entra app registration (service principal).
804. Grant service principal permissions required by Trusted Signing.
815. Create a client secret for the service principal.
826. Add Azure secrets listed above in GitHub Actions secrets.
837. Re-run a tag release and confirm Windows installer is signed.
84
85## 4) Ongoing release checklist
86
871. Ensure `main` is green in CI.
882. Bump app version as needed.
893. Create release tag: `vX.Y.Z`.
904. Push tag.
915. Verify workflow steps:
92 - preflight passes
93 - all matrix builds pass
94 - release job uploads expected files
956. 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
3import { spawn } from "node:child_process";
4import fs from "node:fs/promises";
5import os from "node:os";
6import path from "node:path";
7import { fileURLToPath } from "node:url";
8
9const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const repoRoot = path.resolve(__dirname, "..");
11const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png");
12const DIRECT_EXECUTION_PATH = process.argv[1] ? path.resolve(process.argv[1]) : null;
13
14const 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
41function printUsage() {
42 console.log(`Build a desktop artifact for T3 Code.
43
44Usage:
45 node scripts/build-desktop-artifact.mjs [options]
46
47Options:
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
61function fail(message) {
62 console.error(`[desktop-artifact] ${message}`);
63 process.exit(1);
64}
65
66function 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
73function 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
81function 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
91function formatChoices(values) {
92 return values.join(", ");
93}
94
95function 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
103function 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
110function 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
137function 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
153function 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
271async function readJson(filePath) {
272 const raw = await fs.readFile(filePath, "utf8");
273 return JSON.parse(raw);
274}
275
276async function exists(filePath) {
277 try {
278 await fs.access(filePath);
279 return true;
280 } catch {
281 return false;
282 }
283}
284
285function 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
308async 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
345async 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
366async 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
397function 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
411function 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
433function 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
473async 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
495export 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
664const scriptPath = fileURLToPath(import.meta.url);
665const isDirectExecution = DIRECT_EXECUTION_PATH === scriptPath;
666
667if (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 node1#!/usr/bin/env node
22
3import { spawn } from "node:child_process";3import { main } from "./build-desktop-artifact.mjs";
4import fs from "node:fs/promises";
5import os from "node:os";
6import path from "node:path";
7import { fileURLToPath } from "node:url";
84
9const __dirname = path.dirname(fileURLToPath(import.meta.url));5main(["--platform", "mac", "--target", "dmg", ...process.argv.slice(2)]).catch((error) => {
10const repoRoot = path.resolve(__dirname, "..");6 const message = error instanceof Error ? error.message : String(error);
11const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png");
12
13const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]);
14
15function printUsage() {
16 console.log(`Build a shareable macOS .dmg for T3 Code desktop.
17
18Usage:
19 node scripts/build-desktop-dmg.mjs [options]
20
21Options:
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
32function fail(message) {
33 console.error(`[desktop-dmg] ${message}`);7 console.error(`[desktop-dmg] ${message}`);
34 process.exit(1);8 process.exit(1);
35}
36
37function 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
87async function readJson(filePath) {
88 const raw = await fs.readFile(filePath, "utf8");
89 return JSON.parse(raw);
90}
91
92async function exists(filePath) {
93 try {
94 await fs.access(filePath);
95 return true;
96 } catch {
97 return false;
98 }
99}
100
101function 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
124async 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
160async 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
178async 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
209function 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
223function 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
245async 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
389main().catch((error) => {
390 const message = error instanceof Error ? error.message : String(error);
391 fail(message);
392});9});