| author | |
| committer | |
| log | 56a3ad0f8b62381209be4db0ed7511142509f373 |
| tree | 3f24b902470942a6b94313e991cee4e39fd6e89d |
| parent | 36f484c25a965fd22f42d3834e2cb3a2c38bbc5e |
3 files changed, 630 insertions(+), 630 deletions(-)
package.json+6-6| ... | ... | @@ -29,12 +29,12 @@ |
| 29 | 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 30 | 30 | "fmt": "oxfmt", |
| 31 | 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 32 | "dist:desktop: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", | |
| 38 | 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 | 39 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 40 | 40 | }, |
scripts/build-desktop-artifact.mts deleted-624| ... | ... | @@ -1,624 +0,0 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import rootPackageJson from "../package.json" with { type: "json" }; | |
| 4 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; | |
| 5 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; | |
| 6 | ||
| 7 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 9 | import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; | |
| 10 | import { Command, Flag } from "effect/unstable/cli"; | |
| 11 | import { ChildProcess } from "effect/unstable/process"; | |
| 12 | ||
| 13 | const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); | |
| 14 | const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); | |
| 15 | ||
| 16 | const RepoRoot = Effect.service(Path.Path).pipe( | |
| 17 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | |
| 18 | ); | |
| 19 | const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => | |
| 20 | path.join(repoRoot, "assets/macos-icon-1024.png"), | |
| 21 | ); | |
| 22 | ||
| 23 | interface PlatformConfig { | |
| 24 | readonly cliFlag: "--mac" | "--linux" | "--win"; | |
| 25 | readonly defaultTarget: string; | |
| 26 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; | |
| 27 | } | |
| 28 | ||
| 29 | const 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 | ||
| 47 | interface 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 | ||
| 59 | function 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 | ||
| 66 | function 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 | ||
| 82 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ | |
| 83 | readonly message: string; | |
| 84 | readonly cause?: unknown; | |
| 85 | }> {} | |
| 86 | ||
| 87 | interface 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 | ||
| 99 | interface 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 | ||
| 113 | const 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 | ||
| 129 | const 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 | ||
| 141 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | |
| 142 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | |
| 143 | const mergeOptions = <A,>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) => | |
| 144 | Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); | |
| 145 | ||
| 146 | const 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 | ||
| 186 | const commandOutputOptions = (verbose: boolean) => | |
| 187 | ({ | |
| 188 | stdout: verbose ? "inherit" : "ignore", | |
| 189 | stderr: "inherit", | |
| 190 | }) as const; | |
| 191 | ||
| 192 | const 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 | ||
| 203 | function 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 | ||
| 239 | function 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 | ||
| 267 | function 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 | ||
| 304 | function 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 | ||
| 329 | const 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 | ||
| 374 | const 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 | ||
| 407 | const 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 | ||
| 571 | export 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 | ||
| 619 | Command.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 | ||
| 3 | import rootPackageJson from "../package.json" with { type: "json" }; | |
| 4 | import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; | |
| 5 | import serverPackageJson from "../apps/server/package.json" with { type: "json" }; | |
| 6 | ||
| 7 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 9 | import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; | |
| 10 | import { Command, Flag } from "effect/unstable/cli"; | |
| 11 | import { ChildProcess } from "effect/unstable/process"; | |
| 12 | ||
| 13 | const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); | |
| 14 | const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); | |
| 15 | ||
| 16 | const RepoRoot = Effect.service(Path.Path).pipe( | |
| 17 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | |
| 18 | ); | |
| 19 | const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => | |
| 20 | path.join(repoRoot, "assets/macos-icon-1024.png"), | |
| 21 | ); | |
| 22 | ||
| 23 | interface PlatformConfig { | |
| 24 | readonly cliFlag: "--mac" | "--linux" | "--win"; | |
| 25 | readonly defaultTarget: string; | |
| 26 | readonly archChoices: ReadonlyArray<typeof BuildArch.Type>; | |
| 27 | } | |
| 28 | ||
| 29 | const 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 | ||
| 47 | interface 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 | ||
| 59 | function 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 | ||
| 66 | function 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 | ||
| 82 | class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ | |
| 83 | readonly message: string; | |
| 84 | readonly cause?: unknown; | |
| 85 | }> {} | |
| 86 | ||
| 87 | interface 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 | ||
| 99 | interface 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 | ||
| 113 | const 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 | ||
| 129 | const 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 | ||
| 141 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | |
| 142 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | |
| 143 | const mergeOptions = <A,>(a: Option.Option<A>, b: Option.Option<A>, defaultValue: A) => | |
| 144 | Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); | |
| 145 | ||
| 146 | const 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 | ||
| 186 | const commandOutputOptions = (verbose: boolean) => | |
| 187 | ({ | |
| 188 | stdout: verbose ? "inherit" : "ignore", | |
| 189 | stderr: "inherit", | |
| 190 | }) as const; | |
| 191 | ||
| 192 | const 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 | ||
| 203 | function 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 | ||
| 239 | function 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 | ||
| 267 | function 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 | ||
| 304 | function 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 | ||
| 329 | const 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 | ||
| 374 | const 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 | ||
| 407 | const 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 | ||
| 571 | 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 | ||
| 619 | Command.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 | ); |