authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 18:08:53-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 18:18:47-08:00
log03c6685097a3928e3a8286d131311c154bac5002
treed0c18d547c4866d7cff65666ccb395a8f1845256
parent23afddfbf90e961f9e079042d5612dc7724b057f

kewl


6 files changed, 635 insertions(+), 688 deletions(-)

.github/workflows/release.yml+1-1
...@@ -109,7 +109,7 @@ jobs:...@@ -109,7 +109,7 @@ jobs:
109 --platform "${{ matrix.platform }}"109 --platform "${{ matrix.platform }}"
110 --target "${{ matrix.target }}"110 --target "${{ matrix.target }}"
111 --arch "${{ matrix.arch }}"111 --arch "${{ matrix.arch }}"
112 --version "${{ needs.preflight.outputs.version }}"112 --build-version "${{ needs.preflight.outputs.version }}"
113 )113 )
114114
115 has_all() {115 has_all() {
bun.lock+2
...@@ -5,6 +5,8 @@...@@ -5,6 +5,8 @@
5 "": {5 "": {
6 "name": "@t3tools/monorepo",6 "name": "@t3tools/monorepo",
7 "devDependencies": {7 "devDependencies": {
8 "@effect/platform-node": "catalog:",
9 "effect": "catalog:",
8 "oxfmt": "^0.35.0",10 "oxfmt": "^0.35.0",
9 "oxlint": "^1.50.0",11 "oxlint": "^1.50.0",
10 "turbo": "^2.3.3",12 "turbo": "^2.3.3",
package.json+8-6
...@@ -29,16 +29,18 @@...@@ -29,16 +29,18 @@
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:artifact": "node scripts/build-desktop-artifact.mjs",32 "dist:desktop:artifact": "node scripts/build-desktop-artifact.mts",
33 "dist:desktop:dmg": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg",33 "dist:desktop:dmg": "node scripts/build-desktop-artifact.mts --platform mac --target dmg",
34 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch arm64",34 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch arm64",
35 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch x64",35 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch x64",
36 "dist:desktop:linux": "node scripts/build-desktop-artifact.mjs --platform linux --target AppImage --arch x64",36 "dist:desktop:linux": "node scripts/build-desktop-artifact.mts --platform linux --target AppImage --arch x64",
37 "dist:desktop:win": "node scripts/build-desktop-artifact.mjs --platform win --target nsis --arch x64",37 "dist:desktop:win": "node scripts/build-desktop-artifact.mts --platform win --target nsis --arch x64",
38 "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",
39 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"39 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
40 },40 },
41 "devDependencies": {41 "devDependencies": {
42 "@effect/platform-node": "catalog:",
43 "effect": "catalog:",
42 "oxfmt": "^0.35.0",44 "oxfmt": "^0.35.0",
43 "oxlint": "^1.50.0",45 "oxlint": "^1.50.0",
44 "turbo": "^2.3.3",46 "turbo": "^2.3.3",
scripts/build-desktop-artifact.mjs deleted-672
...@@ -1,672 +0,0 @@
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-artifact.mts created+624
...@@ -0,0 +1,624 @@
1#!/usr/bin/env node
2
3import rootPackageJson from "../package.json" with { type: "json" };
4import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" };
5import serverPackageJson from "../apps/server/package.json" with { type: "json" };
6
7import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect";
10import { Command, Flag } from "effect/unstable/cli";
11import { ChildProcess } from "effect/unstable/process";
12
13const BuildPlatform = Schema.Literals(["mac", "linux", "win"]);
14const BuildArch = Schema.Literals(["arm64", "x64", "universal"]);
15
16const RepoRoot = Effect.service(Path.Path).pipe(
17 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
18);
19const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) =>
20 path.join(repoRoot, "assets/macos-icon-1024.png"),
21);
22
23interface PlatformConfig {
24 readonly cliFlag: "--mac" | "--linux" | "--win";
25 readonly defaultTarget: string;
26 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;
27}
28
29const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {
30 mac: {
31 cliFlag: "--mac",
32 defaultTarget: "dmg",
33 archChoices: ["arm64", "x64", "universal"],
34 },
35 linux: {
36 cliFlag: "--linux",
37 defaultTarget: "AppImage",
38 archChoices: ["x64", "arm64"],
39 },
40 win: {
41 cliFlag: "--win",
42 defaultTarget: "nsis",
43 archChoices: ["x64", "arm64"],
44 },
45};
46
47interface BuildCliInput {
48 readonly platform: Option.Option<typeof BuildPlatform.Type>;
49 readonly target: Option.Option<string>;
50 readonly arch: Option.Option<typeof BuildArch.Type>;
51 readonly buildVersion: Option.Option<string>;
52 readonly outputDir: Option.Option<string>;
53 readonly skipBuild: Option.Option<boolean>;
54 readonly keepStage: Option.Option<boolean>;
55 readonly signed: Option.Option<boolean>;
56 readonly verbose: Option.Option<boolean>;
57}
58
59function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
60 if (hostPlatform === "darwin") return "mac";
61 if (hostPlatform === "linux") return "linux";
62 if (hostPlatform === "win32") return "win";
63 return undefined;
64}
65
66function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type {
67 const config = PLATFORM_CONFIG[platform];
68 if (!config) {
69 return "x64";
70 }
71
72 if (process.arch === "arm64" && config.archChoices.includes("arm64")) {
73 return "arm64";
74 }
75 if (process.arch === "x64" && config.archChoices.includes("x64")) {
76 return "x64";
77 }
78
79 return config.archChoices[0] ?? "x64";
80}
81
82class BuildScriptError extends Data.TaggedError("BuildScriptError")<{
83 readonly message: string;
84 readonly cause?: unknown;
85}> {}
86
87interface ResolvedBuildOptions {
88 readonly platform: typeof BuildPlatform.Type;
89 readonly target: string;
90 readonly arch: typeof BuildArch.Type;
91 readonly version: string | undefined;
92 readonly outputDir: string;
93 readonly skipBuild: boolean;
94 readonly keepStage: boolean;
95 readonly signed: boolean;
96 readonly verbose: boolean;
97}
98
99interface StagePackageJson {
100 readonly name: string;
101 readonly version: string;
102 readonly private: true;
103 readonly description: string;
104 readonly author: string;
105 readonly main: string;
106 readonly build: Record<string, unknown>;
107 readonly dependencies: Record<string, unknown>;
108 readonly devDependencies: {
109 readonly electron: string;
110 };
111}
112
113const AzureTrustedSigningOptionsConfig = Config.all({
114 publisherName: Config.string("AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"),
115 endpoint: Config.string("AZURE_TRUSTED_SIGNING_ENDPOINT"),
116 certificateProfileName: Config.string("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"),
117 codeSigningAccountName: Config.string("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"),
118 fileDigest: Config.string("AZURE_TRUSTED_SIGNING_FILE_DIGEST").pipe(
119 Config.withDefault(() => "SHA256"),
120 ),
121 timestampDigest: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST").pipe(
122 Config.withDefault(() => "SHA256"),
123 ),
124 timestampRfc3161: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161").pipe(
125 Config.withDefault(() => "http://timestamp.acs.microsoft.com"),
126 ),
127});
128
129const BuildEnvConfig = Config.all({
130 platform: Config.schema(BuildPlatform, "T3CODE_DESKTOP_PLATFORM").pipe(Config.option),
131 target: Config.string("T3CODE_DESKTOP_TARGET").pipe(Config.option),
132 arch: Config.schema(BuildArch, "T3CODE_DESKTOP_ARCH").pipe(Config.option),
133 version: Config.string("T3CODE_DESKTOP_VERSION").pipe(Config.option),
134 outputDir: Config.string("T3CODE_DESKTOP_OUTPUT_DIR").pipe(Config.option),
135 skipBuild: Config.boolean("T3CODE_DESKTOP_SKIP_BUILD").pipe(Config.withDefault(() => false)),
136 keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(() => false)),
137 signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(() => false)),
138 verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(() => false)),
139});
140
141const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
142 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
143const mergeOptions = <A,>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) =>
144 Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue));
145
146const resolveBuildOptions = Effect.fn(function* (input: BuildCliInput) {
147 const path = yield* Path.Path;
148 const repoRoot = yield* RepoRoot;
149 const env = yield* BuildEnvConfig.asEffect();
150
151 const platform = mergeOptions(
152 input.platform,
153 env.platform,
154 detectHostBuildPlatform(process.platform),
155 );
156
157 if (!platform) {
158 return yield* new BuildScriptError({
159 message: `Unsupported host platform '${process.platform}'.`,
160 });
161 }
162
163 const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget);
164 const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform));
165 const version = mergeOptions(input.buildVersion, env.version, undefined);
166 const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release"));
167
168 const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild);
169 const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage);
170 const signed = resolveBooleanFlag(input.signed, env.signed);
171 const verbose = resolveBooleanFlag(input.verbose, env.verbose);
172
173 return {
174 platform,
175 target,
176 arch,
177 version,
178 outputDir,
179 skipBuild,
180 keepStage,
181 signed,
182 verbose,
183 } satisfies ResolvedBuildOptions;
184});
185
186const commandOutputOptions = (verbose: boolean) =>
187 ({
188 stdout: verbose ? "inherit" : "ignore",
189 stderr: "inherit",
190 }) as const;
191
192const runCommand = Effect.fn(function* (command: ChildProcess.Command) {
193 const exitCode = yield* ChildProcess.exitCode(command);
194
195 if (exitCode !== 0) {
196 return yield* new BuildScriptError({
197 message: "Command exited with non-zero exit code",
198 cause: command,
199 });
200 }
201});
202
203function generateMacIconSet(
204 sourcePng: string,
205 targetIcns: string,
206 tmpRoot: string,
207 path: Path.Path,
208 verbose: boolean,
209) {
210 return Effect.gen(function* () {
211 const fs = yield* FileSystem.FileSystem;
212 const iconsetDir = path.join(tmpRoot, "icon.iconset");
213 yield* fs.makeDirectory(iconsetDir, { recursive: true });
214
215 const iconSizes = [16, 32, 128, 256, 512] as const;
216 for (const size of iconSizes) {
217 yield* runCommand(
218 ChildProcess.make({
219 ...commandOutputOptions(verbose),
220 })`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`,
221 );
222
223 const retinaSize = size * 2;
224 yield* runCommand(
225 ChildProcess.make({
226 ...commandOutputOptions(verbose),
227 })`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`,
228 );
229 }
230
231 yield* runCommand(
232 ChildProcess.make({
233 ...commandOutputOptions(verbose),
234 })`iconutil -c icns ${iconsetDir} -o ${targetIcns}`,
235 );
236 });
237}
238
239function stageMacIcons(stageResourcesDir: string, verbose: boolean) {
240 return Effect.gen(function* () {
241 const fs = yield* FileSystem.FileSystem;
242 const path = yield* Path.Path;
243 const iconSource = yield* IconSource;
244 if (!(yield* fs.exists(iconSource))) {
245 return yield* new BuildScriptError({
246 message: `Production icon source is missing at ${iconSource}`,
247 });
248 }
249
250 const tmpRoot = yield* fs.makeTempDirectoryScoped({
251 prefix: "t3code-icon-build-",
252 });
253
254 const iconPngPath = path.join(stageResourcesDir, "icon.png");
255 const iconIcnsPath = path.join(stageResourcesDir, "icon.icns");
256
257 yield* runCommand(
258 ChildProcess.make({
259 ...commandOutputOptions(verbose),
260 })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`,
261 );
262
263 yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose);
264 });
265}
266
267function validateBundledClientAssets(clientDir: string) {
268 return Effect.gen(function* () {
269 const fs = yield* FileSystem.FileSystem;
270 const path = yield* Path.Path;
271 const indexPath = path.join(clientDir, "index.html");
272 const indexHtml = yield* fs.readFileString(indexPath);
273 const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map(
274 (match) => match[1],
275 );
276 const missing: string[] = [];
277
278 for (const ref of refs) {
279 const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? "";
280 if (!normalizedRef) continue;
281 if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue;
282 if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue;
283
284 const ext = path.extname(normalizedRef);
285 if (!ext) continue;
286
287 const relativePath = normalizedRef.replace(/^\/+/, "");
288 const assetPath = path.join(clientDir, relativePath);
289 if (!(yield* fs.exists(assetPath))) {
290 missing.push(normalizedRef);
291 }
292 }
293
294 if (missing.length > 0) {
295 const preview = missing.slice(0, 6).join(", ");
296 const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : "";
297 return yield* new BuildScriptError({
298 message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`,
299 });
300 }
301 });
302}
303
304function resolveCatalogDependencies(
305 dependencies: Record<string, unknown>,
306 catalog: Record<string, unknown>,
307 dependencySourceLabel: string,
308): Record<string, unknown> {
309 return Object.fromEntries(
310 Object.entries(dependencies).map(([dependencyName, spec]) => {
311 if (typeof spec !== "string" || !spec.startsWith("catalog:")) {
312 return [dependencyName, spec];
313 }
314
315 const catalogKey = spec.slice("catalog:".length).trim();
316 const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName;
317 const resolvedSpec = catalog[lookupKey];
318 if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) {
319 throw new BuildScriptError({
320 message: `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. Expected key '${lookupKey}' in root workspace catalog.`,
321 });
322 }
323
324 return [dependencyName, resolvedSpec];
325 }),
326 );
327}
328
329const createBuildConfig = Effect.fn(function* (
330 platform: typeof BuildPlatform.Type,
331 target: string,
332 productName: string,
333 signed: boolean,
334) {
335 const buildConfig: Record<string, unknown> = {
336 appId: "com.t3tools.t3code",
337 productName,
338 artifactName: "T3-Code-${version}-${arch}.${ext}",
339 directories: {
340 buildResources: "apps/desktop/resources",
341 },
342 };
343
344 if (platform === "mac") {
345 buildConfig.mac = {
346 target: [target],
347 icon: "icon.icns",
348 category: "public.app-category.developer-tools",
349 };
350 }
351
352 if (platform === "linux") {
353 buildConfig.linux = {
354 target: [target],
355 icon: "icon.png",
356 category: "Development",
357 };
358 }
359
360 if (platform === "win") {
361 const winConfig: Record<string, unknown> = {
362 target: [target],
363 icon: "icon.ico",
364 };
365 if (signed) {
366 winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;
367 }
368 buildConfig.win = winConfig;
369 }
370
371 return buildConfig;
372});
373
374const assertPlatformBuildResources = Effect.fn(function* (
375 platform: typeof BuildPlatform.Type,
376 stageResourcesDir: string,
377 verbose: boolean,
378) {
379 const fs = yield* FileSystem.FileSystem;
380 const path = yield* Path.Path;
381
382 if (platform === "mac") {
383 yield* stageMacIcons(stageResourcesDir, verbose);
384 return;
385 }
386
387 if (platform === "linux") {
388 const iconPath = path.join(stageResourcesDir, "icon.png");
389 if (!(yield* fs.exists(iconPath))) {
390 return yield* new BuildScriptError({
391 message: `Missing Linux icon at ${iconPath}`,
392 });
393 }
394 return;
395 }
396
397 if (platform === "win") {
398 const iconPath = path.join(stageResourcesDir, "icon.ico");
399 if (!(yield* fs.exists(iconPath))) {
400 return yield* new BuildScriptError({
401 message: `Missing Windows icon at ${iconPath}`,
402 });
403 }
404 }
405});
406
407const buildDesktopArtifact = Effect.fn(function* (options: ResolvedBuildOptions) {
408 const repoRoot = yield* RepoRoot;
409 const path = yield* Path.Path;
410 const fs = yield* FileSystem.FileSystem;
411
412 const platformConfig = PLATFORM_CONFIG[options.platform];
413 if (!platformConfig) {
414 return yield* new BuildScriptError({
415 message: `Unsupported platform '${options.platform}'.`,
416 });
417 }
418
419 const electronVersion = desktopPackageJson.dependencies.electron;
420
421 const dependencies = serverPackageJson.dependencies;
422 if (!dependencies || Object.keys(dependencies).length === 0) {
423 return yield* new BuildScriptError({
424 message: "Could not resolve production dependencies from apps/server/package.json.",
425 });
426 }
427
428 const resolvedDependencies = yield* Effect.try({
429 try: () =>
430 resolveCatalogDependencies(dependencies, rootPackageJson.workspaces.catalog, "apps/server"),
431 catch: (cause) =>
432 new BuildScriptError({
433 message: "Could not resolve production dependencies from apps/server/package.json.",
434 cause,
435 }),
436 });
437
438 const appVersion = options.version ?? serverPackageJson.version;
439 const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
440 const stageRoot = yield* mkdir({
441 prefix: `t3code-desktop-${options.platform}-stage-`,
442 });
443
444 const stageAppDir = path.join(stageRoot, "app");
445 const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources");
446 const distDirs = {
447 desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"),
448 desktopResources: path.join(repoRoot, "apps/desktop/resources"),
449 serverDist: path.join(repoRoot, "apps/server/dist"),
450 };
451 const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html");
452
453 if (!options.skipBuild) {
454 yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts...");
455 yield* runCommand(
456 ChildProcess.make({
457 cwd: repoRoot,
458 ...commandOutputOptions(options.verbose),
459 })`bun run build:desktop`,
460 );
461 }
462
463 for (const [label, dir] of Object.entries(distDirs)) {
464 if (!(yield* fs.exists(dir))) {
465 return yield* new BuildScriptError({
466 message: `Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`,
467 });
468 }
469 }
470
471 if (!(yield* fs.exists(bundledClientEntry))) {
472 return yield* new BuildScriptError({
473 message: `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`,
474 });
475 }
476
477 yield* validateBundledClientAssets(path.dirname(bundledClientEntry));
478
479 yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true });
480 yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true });
481
482 yield* Effect.log("[desktop-artifact] Staging release app...");
483 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
484 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
485 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
486
487 yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose);
488
489 const stagePackageJson: StagePackageJson = {
490 name: "t3-code-desktop",
491 version: appVersion,
492 private: true,
493 description: "T3 Code desktop build",
494 author: "T3 Tools",
495 main: "apps/desktop/dist-electron/main.js",
496 build: yield* createBuildConfig(
497 options.platform,
498 options.target,
499 desktopPackageJson.productName ?? "T3 Code",
500 options.signed,
501 ),
502 dependencies: resolvedDependencies,
503 devDependencies: {
504 electron: electronVersion,
505 },
506 };
507
508 yield* fs.writeFileString(
509 path.join(stageAppDir, "package.json"),
510 `${JSON.stringify(stagePackageJson, null, 2)}\n`,
511 );
512
513 yield* Effect.log("[desktop-artifact] Installing staged production dependencies...");
514 yield* runCommand(
515 ChildProcess.make({
516 cwd: stageAppDir,
517 ...commandOutputOptions(options.verbose),
518 })`bun install --production`,
519 );
520
521 const buildEnv: NodeJS.ProcessEnv = {
522 ...process.env,
523 };
524 if (!options.signed) {
525 buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
526 }
527
528 yield* Effect.log(
529 `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`,
530 );
531 yield* runCommand(
532 ChildProcess.make({
533 cwd: stageAppDir,
534 env: buildEnv,
535 ...commandOutputOptions(options.verbose),
536 })`bunx --bun electron-builder ${platformConfig.cliFlag} ${options.target} --${options.arch} --publish never`,
537 );
538
539 const stageDistDir = path.join(stageAppDir, "dist");
540 if (!(yield* fs.exists(stageDistDir))) {
541 return yield* new BuildScriptError({
542 message: `Build completed but dist directory was not found at ${stageDistDir}`,
543 });
544 }
545
546 const stageEntries = yield* fs.readDirectory(stageDistDir);
547 yield* fs.makeDirectory(options.outputDir, { recursive: true });
548
549 const copiedArtifacts: string[] = [];
550 for (const entry of stageEntries) {
551 const from = path.join(stageDistDir, entry);
552 const stat = yield* fs.stat(from).pipe(Effect.catch(() => Effect.succeed(null)));
553 if (!stat || stat.type !== "File") continue;
554
555 const to = path.join(options.outputDir, entry);
556 yield* fs.copyFile(from, to);
557 copiedArtifacts.push(to);
558 }
559
560 if (copiedArtifacts.length === 0) {
561 return yield* new BuildScriptError({
562 message: `Build completed but no files were produced in ${stageDistDir}`,
563 });
564 }
565
566 yield* Effect.log("[desktop-artifact] Done. Artifacts:").pipe(
567 Effect.annotateLogs({ artifacts: copiedArtifacts }),
568 );
569});
570
571export const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
572 platform: Flag.choice("platform", BuildPlatform.literals).pipe(
573 Flag.withDescription("Build platform (env: T3CODE_DESKTOP_PLATFORM)."),
574 Flag.optional,
575 ),
576 target: Flag.string("target").pipe(
577 Flag.withDescription(
578 "Artifact target, for example dmg/AppImage/nsis (env: T3CODE_DESKTOP_TARGET).",
579 ),
580 Flag.optional,
581 ),
582 arch: Flag.choice("arch", BuildArch.literals).pipe(
583 Flag.withDescription("Build arch, for example arm64/x64/universal (env: T3CODE_DESKTOP_ARCH)."),
584 Flag.optional,
585 ),
586 buildVersion: Flag.string("build-version").pipe(
587 Flag.withDescription("Artifact version metadata (env: T3CODE_DESKTOP_VERSION)."),
588 Flag.optional,
589 ),
590 outputDir: Flag.string("output-dir").pipe(
591 Flag.withDescription("Output directory for artifacts (env: T3CODE_DESKTOP_OUTPUT_DIR)."),
592 Flag.optional,
593 ),
594 skipBuild: Flag.boolean("skip-build").pipe(
595 Flag.withDescription(
596 "Skip `bun run build:desktop` and use existing dist artifacts (env: T3CODE_DESKTOP_SKIP_BUILD).",
597 ),
598 Flag.optional,
599 ),
600 keepStage: Flag.boolean("keep-stage").pipe(
601 Flag.withDescription("Keep temporary staging files (env: T3CODE_DESKTOP_KEEP_STAGE)."),
602 Flag.optional,
603 ),
604 signed: Flag.boolean("signed").pipe(
605 Flag.withDescription(
606 "Enable signing/notarization discovery; Windows uses Azure Trusted Signing (env: T3CODE_DESKTOP_SIGNED).",
607 ),
608 Flag.optional,
609 ),
610 verbose: Flag.boolean("verbose").pipe(
611 Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."),
612 Flag.optional,
613 ),
614}).pipe(
615 Command.withDescription("Build a desktop artifact for T3 Code."),
616 Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)),
617);
618
619Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe(
620 Effect.scoped,
621 Effect.provide(Logger.layer([Logger.consolePretty()])),
622 Effect.provide(NodeServices.layer),
623 NodeRuntime.runMain,
624);
scripts/build-desktop-dmg.mjs deleted-9
...@@ -1,9 +0,0 @@
1#!/usr/bin/env node
2
3import { main } from "./build-desktop-artifact.mjs";
4
5main(["--platform", "mac", "--target", "dmg", ...process.argv.slice(2)]).catch((error) => {
6 const message = error instanceof Error ? error.message : String(error);
7 console.error(`[desktop-dmg] ${message}`);
8 process.exit(1);
9});