authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 18:10:46-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 18:18:47-08:00
log56a3ad0f8b62381209be4db0ed7511142509f373
tree3f24b902470942a6b94313e991cee4e39fd6e89d
parent36f484c25a965fd22f42d3834e2cb3a2c38bbc5e

kewl


3 files changed, 630 insertions(+), 630 deletions(-)

package.json+6-6
......@@ -29,12 +29,12 @@
2929 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
3030 "fmt": "oxfmt",
3131 "build:contracts": "turbo run build --filter=@t3tools/contracts",
32 "dist:desktop:artifact": "node scripts/build-desktop-artifact.mts",
33 "dist:desktop:dmg": "node scripts/build-desktop-artifact.mts --platform mac --target dmg",
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.mts --platform mac --target dmg --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.mts --platform win --target nsis --arch x64",
32 "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts",
33 "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg",
34 "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64",
35 "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64",
36 "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64",
37 "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
3838 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
3939 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
4040 },
scripts/build-desktop-artifact.mts deleted-624
......@@ -1,624 +0,0 @@
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-artifact.ts 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
571const 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);