| author | |
| committer | |
| log | 03c6685097a3928e3a8286d131311c154bac5002 |
| tree | d0c18d547c4866d7cff65666ccb395a8f1845256 |
| parent | 23afddfbf90e961f9e079042d5612dc7724b057f |
6 files changed, 635 insertions(+), 688 deletions(-)
.github/workflows/release.yml+1-1| ... | @@ -109,7 +109,7 @@ jobs: | ... | @@ -109,7 +109,7 @@ jobs: |
| 109 | --platform "${{ matrix.platform }}" | 109 | --platform "${{ matrix.platform }}" |
| 110 | --target "${{ matrix.target }}" | 110 | --target "${{ matrix.target }}" |
| 111 | --arch "${{ matrix.arch }}" | 111 | --arch "${{ matrix.arch }}" |
| 112 | --version "${{ needs.preflight.outputs.version }}" | 112 | --build-version "${{ needs.preflight.outputs.version }}" |
| 113 | ) | 113 | ) |
| 114 | 114 | ||
| 115 | has_all() { | 115 | has_all() { |
bun.lock+2| ... | @@ -5,6 +5,8 @@ | ... | @@ -5,6 +5,8 @@ |
| 5 | "": { | 5 | "": { |
| 6 | "name": "@t3tools/monorepo", | 6 | "name": "@t3tools/monorepo", |
| 7 | "devDependencies": { | 7 | "devDependencies": { |
| 8 | "@effect/platform-node": "catalog:", | ||
| 9 | "effect": "catalog:", | ||
| 8 | "oxfmt": "^0.35.0", | 10 | "oxfmt": "^0.35.0", |
| 9 | "oxlint": "^1.50.0", | 11 | "oxlint": "^1.50.0", |
| 10 | "turbo": "^2.3.3", | 12 | "turbo": "^2.3.3", |
package.json+8-6| ... | @@ -29,16 +29,18 @@ | ... | @@ -29,16 +29,18 @@ |
| 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", | 29 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 30 | "fmt": "oxfmt", | 30 | "fmt": "oxfmt", |
| 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", | 31 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 32 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.mjs", | 32 | "dist:desktop:artifact": "node scripts/build-desktop-artifact.mts", |
| 33 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg", | 33 | "dist:desktop:dmg": "node scripts/build-desktop-artifact.mts --platform mac --target dmg", |
| 34 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch arm64", | 34 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch arm64", |
| 35 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch x64", | 35 | "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch x64", |
| 36 | "dist:desktop:linux": "node scripts/build-desktop-artifact.mjs --platform linux --target AppImage --arch x64", | 36 | "dist:desktop:linux": "node scripts/build-desktop-artifact.mts --platform linux --target AppImage --arch x64", |
| 37 | "dist:desktop:win": "node scripts/build-desktop-artifact.mjs --platform win --target nsis --arch x64", | 37 | "dist:desktop:win": "node scripts/build-desktop-artifact.mts --platform win --target nsis --arch x64", |
| 38 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", | 38 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", |
| 39 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" | 39 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 40 | }, | 40 | }, |
| 41 | "devDependencies": { | 41 | "devDependencies": { |
| 42 | "@effect/platform-node": "catalog:", | ||
| 43 | "effect": "catalog:", | ||
| 42 | "oxfmt": "^0.35.0", | 44 | "oxfmt": "^0.35.0", |
| 43 | "oxlint": "^1.50.0", | 45 | "oxlint": "^1.50.0", |
| 44 | "turbo": "^2.3.3", | 46 | "turbo": "^2.3.3", |
scripts/build-desktop-artifact.mjs deleted-672| ... | @@ -1,672 +0,0 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import { spawn } from "node:child_process"; | ||
| 4 | import fs from "node:fs/promises"; | ||
| 5 | import os from "node:os"; | ||
| 6 | import path from "node:path"; | ||
| 7 | import { fileURLToPath } from "node:url"; | ||
| 8 | |||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| 10 | const repoRoot = path.resolve(__dirname, ".."); | ||
| 11 | const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); | ||
| 12 | const DIRECT_EXECUTION_PATH = process.argv[1] ? path.resolve(process.argv[1]) : null; | ||
| 13 | |||
| 14 | const PLATFORM_CONFIG = { | ||
| 15 | mac: { | ||
| 16 | hostPlatform: "darwin", | ||
| 17 | description: "macOS", | ||
| 18 | cliFlag: "--mac", | ||
| 19 | defaultTarget: "dmg", | ||
| 20 | targetChoices: ["dmg"], | ||
| 21 | archChoices: ["arm64", "x64", "universal"], | ||
| 22 | }, | ||
| 23 | linux: { | ||
| 24 | hostPlatform: "linux", | ||
| 25 | description: "Linux", | ||
| 26 | cliFlag: "--linux", | ||
| 27 | defaultTarget: "AppImage", | ||
| 28 | targetChoices: ["AppImage"], | ||
| 29 | archChoices: ["x64", "arm64"], | ||
| 30 | }, | ||
| 31 | win: { | ||
| 32 | hostPlatform: "win32", | ||
| 33 | description: "Windows", | ||
| 34 | cliFlag: "--win", | ||
| 35 | defaultTarget: "nsis", | ||
| 36 | targetChoices: ["nsis"], | ||
| 37 | archChoices: ["x64", "arm64"], | ||
| 38 | }, | ||
| 39 | }; | ||
| 40 | |||
| 41 | function printUsage() { | ||
| 42 | console.log(`Build a desktop artifact for T3 Code. | ||
| 43 | |||
| 44 | Usage: | ||
| 45 | node scripts/build-desktop-artifact.mjs [options] | ||
| 46 | |||
| 47 | Options: | ||
| 48 | --platform <mac|linux|win> Target platform (default: current host platform) | ||
| 49 | --target <target> Target format (mac: dmg, linux: AppImage, win: nsis) | ||
| 50 | --arch <arm64|x64|universal> Target arch (platform-dependent default) | ||
| 51 | --version <semver> App version in artifact metadata (default: apps/server version) | ||
| 52 | --output-dir <path> Output folder for final artifacts (default: release) | ||
| 53 | --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) | ||
| 54 | --keep-stage Keep temporary staging directory | ||
| 55 | --signed Enable signing/notarization auto-discovery in electron-builder | ||
| 56 | (Windows uses Azure Trusted Signing) | ||
| 57 | --help Show this message | ||
| 58 | `); | ||
| 59 | } | ||
| 60 | |||
| 61 | function fail(message) { | ||
| 62 | console.error(`[desktop-artifact] ${message}`); | ||
| 63 | process.exit(1); | ||
| 64 | } | ||
| 65 | |||
| 66 | function detectHostBuildPlatform(hostPlatform) { | ||
| 67 | if (hostPlatform === "darwin") return "mac"; | ||
| 68 | if (hostPlatform === "linux") return "linux"; | ||
| 69 | if (hostPlatform === "win32") return "win"; | ||
| 70 | return null; | ||
| 71 | } | ||
| 72 | |||
| 73 | function normalizePlatform(value) { | ||
| 74 | const normalized = value.toLowerCase(); | ||
| 75 | if (normalized === "mac" || normalized === "macos" || normalized === "darwin") return "mac"; | ||
| 76 | if (normalized === "linux") return "linux"; | ||
| 77 | if (normalized === "win" || normalized === "windows" || normalized === "win32") return "win"; | ||
| 78 | return null; | ||
| 79 | } | ||
| 80 | |||
| 81 | function normalizeChoice(inputValue, choices) { | ||
| 82 | const normalized = inputValue.toLowerCase(); | ||
| 83 | for (const choice of choices) { | ||
| 84 | if (choice.toLowerCase() === normalized) { | ||
| 85 | return choice; | ||
| 86 | } | ||
| 87 | } | ||
| 88 | return null; | ||
| 89 | } | ||
| 90 | |||
| 91 | function formatChoices(values) { | ||
| 92 | return values.join(", "); | ||
| 93 | } | ||
| 94 | |||
| 95 | function readRequiredEnvironmentVariable(name, contextMessage) { | ||
| 96 | const value = process.env[name]; | ||
| 97 | if (typeof value !== "string" || value.trim().length === 0) { | ||
| 98 | fail(`Missing ${name}${contextMessage ? ` (${contextMessage})` : ""}.`); | ||
| 99 | } | ||
| 100 | return value.trim(); | ||
| 101 | } | ||
| 102 | |||
| 103 | function readOptionalEnvironmentVariable(name) { | ||
| 104 | const value = process.env[name]; | ||
| 105 | if (typeof value !== "string") return undefined; | ||
| 106 | const trimmed = value.trim(); | ||
| 107 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 108 | } | ||
| 109 | |||
| 110 | function getAzureTrustedSigningOptions() { | ||
| 111 | return { | ||
| 112 | publisherName: readRequiredEnvironmentVariable( | ||
| 113 | "AZURE_TRUSTED_SIGNING_PUBLISHER_NAME", | ||
| 114 | "required for Windows --signed", | ||
| 115 | ), | ||
| 116 | endpoint: readRequiredEnvironmentVariable( | ||
| 117 | "AZURE_TRUSTED_SIGNING_ENDPOINT", | ||
| 118 | "required for Windows --signed", | ||
| 119 | ), | ||
| 120 | certificateProfileName: readRequiredEnvironmentVariable( | ||
| 121 | "AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME", | ||
| 122 | "required for Windows --signed", | ||
| 123 | ), | ||
| 124 | codeSigningAccountName: readRequiredEnvironmentVariable( | ||
| 125 | "AZURE_TRUSTED_SIGNING_ACCOUNT_NAME", | ||
| 126 | "required for Windows --signed", | ||
| 127 | ), | ||
| 128 | fileDigest: readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_FILE_DIGEST") ?? "SHA256", | ||
| 129 | timestampDigest: | ||
| 130 | readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST") ?? "SHA256", | ||
| 131 | timestampRfc3161: | ||
| 132 | readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161") ?? | ||
| 133 | "http://timestamp.acs.microsoft.com", | ||
| 134 | }; | ||
| 135 | } | ||
| 136 | |||
| 137 | function getDefaultArch(platform) { | ||
| 138 | const config = PLATFORM_CONFIG[platform]; | ||
| 139 | if (!config) { | ||
| 140 | return "x64"; | ||
| 141 | } | ||
| 142 | |||
| 143 | if (process.arch === "arm64" && config.archChoices.includes("arm64")) { | ||
| 144 | return "arm64"; | ||
| 145 | } | ||
| 146 | if (process.arch === "x64" && config.archChoices.includes("x64")) { | ||
| 147 | return "x64"; | ||
| 148 | } | ||
| 149 | |||
| 150 | return config.archChoices[0] ?? "x64"; | ||
| 151 | } | ||
| 152 | |||
| 153 | function parseArgs(argv) { | ||
| 154 | const hostPlatform = detectHostBuildPlatform(process.platform); | ||
| 155 | const parsed = { | ||
| 156 | platform: hostPlatform, | ||
| 157 | target: null, | ||
| 158 | arch: null, | ||
| 159 | version: null, | ||
| 160 | skipBuild: false, | ||
| 161 | keepStage: false, | ||
| 162 | signed: false, | ||
| 163 | outputDir: path.join(repoRoot, "release"), | ||
| 164 | help: false, | ||
| 165 | }; | ||
| 166 | |||
| 167 | for (let index = 0; index < argv.length; index += 1) { | ||
| 168 | const arg = argv[index]; | ||
| 169 | |||
| 170 | if (arg === "--help") { | ||
| 171 | parsed.help = true; | ||
| 172 | continue; | ||
| 173 | } | ||
| 174 | if (arg === "--skip-build") { | ||
| 175 | parsed.skipBuild = true; | ||
| 176 | continue; | ||
| 177 | } | ||
| 178 | if (arg === "--keep-stage") { | ||
| 179 | parsed.keepStage = true; | ||
| 180 | continue; | ||
| 181 | } | ||
| 182 | if (arg === "--signed") { | ||
| 183 | parsed.signed = true; | ||
| 184 | continue; | ||
| 185 | } | ||
| 186 | |||
| 187 | if ( | ||
| 188 | arg === "--platform" || | ||
| 189 | arg === "--target" || | ||
| 190 | arg === "--arch" || | ||
| 191 | arg === "--version" || | ||
| 192 | arg === "--output-dir" | ||
| 193 | ) { | ||
| 194 | const value = argv[index + 1]; | ||
| 195 | if (!value || value.startsWith("--")) { | ||
| 196 | fail(`Missing value for ${arg}`); | ||
| 197 | } | ||
| 198 | index += 1; | ||
| 199 | |||
| 200 | if (arg === "--platform") { | ||
| 201 | const platform = normalizePlatform(value); | ||
| 202 | if (!platform) { | ||
| 203 | fail(`Invalid platform '${value}'. Expected one of: mac, linux, win`); | ||
| 204 | } | ||
| 205 | parsed.platform = platform; | ||
| 206 | continue; | ||
| 207 | } | ||
| 208 | |||
| 209 | if (arg === "--target") { | ||
| 210 | parsed.target = value; | ||
| 211 | continue; | ||
| 212 | } | ||
| 213 | |||
| 214 | if (arg === "--arch") { | ||
| 215 | parsed.arch = value; | ||
| 216 | continue; | ||
| 217 | } | ||
| 218 | |||
| 219 | if (arg === "--version") { | ||
| 220 | parsed.version = value; | ||
| 221 | continue; | ||
| 222 | } | ||
| 223 | |||
| 224 | if (arg === "--output-dir") { | ||
| 225 | parsed.outputDir = path.resolve(repoRoot, value); | ||
| 226 | continue; | ||
| 227 | } | ||
| 228 | } | ||
| 229 | |||
| 230 | fail(`Unknown argument: ${arg}`); | ||
| 231 | } | ||
| 232 | |||
| 233 | if (!parsed.platform) { | ||
| 234 | const hosts = ["darwin", "linux", "win32"].join(", "); | ||
| 235 | fail(`Unsupported host platform '${process.platform}'. Supported hosts: ${hosts}`); | ||
| 236 | } | ||
| 237 | |||
| 238 | const config = PLATFORM_CONFIG[parsed.platform]; | ||
| 239 | if (!config) { | ||
| 240 | fail(`Unsupported platform '${parsed.platform}'`); | ||
| 241 | } | ||
| 242 | |||
| 243 | const target = | ||
| 244 | parsed.target === null | ||
| 245 | ? config.defaultTarget | ||
| 246 | : normalizeChoice(parsed.target, config.targetChoices); | ||
| 247 | if (!target) { | ||
| 248 | fail( | ||
| 249 | `Invalid target '${parsed.target}'. Supported targets for ${parsed.platform}: ${formatChoices( | ||
| 250 | config.targetChoices, | ||
| 251 | )}`, | ||
| 252 | ); | ||
| 253 | } | ||
| 254 | |||
| 255 | const arch = parsed.arch === null ? getDefaultArch(parsed.platform) : normalizeChoice(parsed.arch, config.archChoices); | ||
| 256 | if (!arch) { | ||
| 257 | fail( | ||
| 258 | `Invalid arch '${parsed.arch}'. Supported arches for ${parsed.platform}: ${formatChoices( | ||
| 259 | config.archChoices, | ||
| 260 | )}`, | ||
| 261 | ); | ||
| 262 | } | ||
| 263 | |||
| 264 | return { | ||
| 265 | ...parsed, | ||
| 266 | target, | ||
| 267 | arch, | ||
| 268 | }; | ||
| 269 | } | ||
| 270 | |||
| 271 | async function readJson(filePath) { | ||
| 272 | const raw = await fs.readFile(filePath, "utf8"); | ||
| 273 | return JSON.parse(raw); | ||
| 274 | } | ||
| 275 | |||
| 276 | async function exists(filePath) { | ||
| 277 | try { | ||
| 278 | await fs.access(filePath); | ||
| 279 | return true; | ||
| 280 | } catch { | ||
| 281 | return false; | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { | ||
| 286 | return new Promise((resolve, reject) => { | ||
| 287 | const child = spawn(command, args, { | ||
| 288 | cwd, | ||
| 289 | env, | ||
| 290 | stdio, | ||
| 291 | }); | ||
| 292 | |||
| 293 | child.on("error", reject); | ||
| 294 | child.on("exit", (code, signal) => { | ||
| 295 | if (signal) { | ||
| 296 | reject(new Error(`${command} exited via signal ${signal}`)); | ||
| 297 | return; | ||
| 298 | } | ||
| 299 | if (code !== 0) { | ||
| 300 | reject(new Error(`${command} exited with code ${code}`)); | ||
| 301 | return; | ||
| 302 | } | ||
| 303 | resolve(); | ||
| 304 | }); | ||
| 305 | }); | ||
| 306 | } | ||
| 307 | |||
| 308 | async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { | ||
| 309 | const iconsetDir = path.join(tmpRoot, "icon.iconset"); | ||
| 310 | await fs.mkdir(iconsetDir, { recursive: true }); | ||
| 311 | |||
| 312 | const iconSizes = [16, 32, 128, 256, 512]; | ||
| 313 | for (const size of iconSizes) { | ||
| 314 | await run( | ||
| 315 | "sips", | ||
| 316 | [ | ||
| 317 | "-z", | ||
| 318 | String(size), | ||
| 319 | String(size), | ||
| 320 | sourcePng, | ||
| 321 | "--out", | ||
| 322 | path.join(iconsetDir, `icon_${size}x${size}.png`), | ||
| 323 | ], | ||
| 324 | { stdio: "ignore" }, | ||
| 325 | ); | ||
| 326 | |||
| 327 | const retinaSize = size * 2; | ||
| 328 | await run( | ||
| 329 | "sips", | ||
| 330 | [ | ||
| 331 | "-z", | ||
| 332 | String(retinaSize), | ||
| 333 | String(retinaSize), | ||
| 334 | sourcePng, | ||
| 335 | "--out", | ||
| 336 | path.join(iconsetDir, `icon_${size}x${size}@2x.png`), | ||
| 337 | ], | ||
| 338 | { stdio: "ignore" }, | ||
| 339 | ); | ||
| 340 | } | ||
| 341 | |||
| 342 | await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); | ||
| 343 | } | ||
| 344 | |||
| 345 | async function stageMacIcons(stageResourcesDir) { | ||
| 346 | if (!(await exists(PRODUCTION_ICON_SOURCE))) { | ||
| 347 | fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); | ||
| 348 | } | ||
| 349 | |||
| 350 | const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); | ||
| 351 | |||
| 352 | try { | ||
| 353 | const iconPngPath = path.join(stageResourcesDir, "icon.png"); | ||
| 354 | const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); | ||
| 355 | |||
| 356 | await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { | ||
| 357 | stdio: "ignore", | ||
| 358 | }); | ||
| 359 | |||
| 360 | await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); | ||
| 361 | } finally { | ||
| 362 | await fs.rm(tmpRoot, { recursive: true, force: true }); | ||
| 363 | } | ||
| 364 | } | ||
| 365 | |||
| 366 | async function validateBundledClientAssets(clientDir) { | ||
| 367 | const indexPath = path.join(clientDir, "index.html"); | ||
| 368 | const indexHtml = await fs.readFile(indexPath, "utf8"); | ||
| 369 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); | ||
| 370 | const missing = []; | ||
| 371 | |||
| 372 | for (const ref of refs) { | ||
| 373 | const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; | ||
| 374 | if (!normalizedRef) continue; | ||
| 375 | if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; | ||
| 376 | if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; | ||
| 377 | |||
| 378 | const ext = path.extname(normalizedRef); | ||
| 379 | if (!ext) continue; | ||
| 380 | |||
| 381 | const relativePath = normalizedRef.replace(/^\/+/, ""); | ||
| 382 | const assetPath = path.join(clientDir, relativePath); | ||
| 383 | if (!(await exists(assetPath))) { | ||
| 384 | missing.push(normalizedRef); | ||
| 385 | } | ||
| 386 | } | ||
| 387 | |||
| 388 | if (missing.length > 0) { | ||
| 389 | const preview = missing.slice(0, 6).join(", "); | ||
| 390 | const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; | ||
| 391 | fail( | ||
| 392 | `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, | ||
| 393 | ); | ||
| 394 | } | ||
| 395 | } | ||
| 396 | |||
| 397 | function readWorkspaceCatalog(rootPackageJson) { | ||
| 398 | const workspaces = rootPackageJson?.workspaces; | ||
| 399 | if (!workspaces || typeof workspaces !== "object" || Array.isArray(workspaces)) { | ||
| 400 | return {}; | ||
| 401 | } | ||
| 402 | |||
| 403 | const catalog = workspaces.catalog; | ||
| 404 | if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { | ||
| 405 | return {}; | ||
| 406 | } | ||
| 407 | |||
| 408 | return catalog; | ||
| 409 | } | ||
| 410 | |||
| 411 | function resolveCatalogDependencies(dependencies, catalog, dependencySourceLabel) { | ||
| 412 | return Object.fromEntries( | ||
| 413 | Object.entries(dependencies).map(([dependencyName, spec]) => { | ||
| 414 | if (typeof spec !== "string" || !spec.startsWith("catalog:")) { | ||
| 415 | return [dependencyName, spec]; | ||
| 416 | } | ||
| 417 | |||
| 418 | const catalogKey = spec.slice("catalog:".length).trim(); | ||
| 419 | const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; | ||
| 420 | const resolvedSpec = catalog[lookupKey]; | ||
| 421 | if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { | ||
| 422 | fail( | ||
| 423 | `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. ` + | ||
| 424 | `Expected key '${lookupKey}' in root workspace catalog.`, | ||
| 425 | ); | ||
| 426 | } | ||
| 427 | |||
| 428 | return [dependencyName, resolvedSpec]; | ||
| 429 | }), | ||
| 430 | ); | ||
| 431 | } | ||
| 432 | |||
| 433 | function createBuildConfig(platform, target, productName, signed) { | ||
| 434 | const buildConfig = { | ||
| 435 | appId: "com.t3tools.t3code", | ||
| 436 | productName, | ||
| 437 | artifactName: "T3-Code-${version}-${arch}.${ext}", | ||
| 438 | directories: { | ||
| 439 | buildResources: "apps/desktop/resources", | ||
| 440 | }, | ||
| 441 | }; | ||
| 442 | |||
| 443 | if (platform === "mac") { | ||
| 444 | buildConfig.mac = { | ||
| 445 | target: [target], | ||
| 446 | icon: "icon.icns", | ||
| 447 | category: "public.app-category.developer-tools", | ||
| 448 | }; | ||
| 449 | } | ||
| 450 | |||
| 451 | if (platform === "linux") { | ||
| 452 | buildConfig.linux = { | ||
| 453 | target: [target], | ||
| 454 | icon: "icon.png", | ||
| 455 | category: "Development", | ||
| 456 | }; | ||
| 457 | } | ||
| 458 | |||
| 459 | if (platform === "win") { | ||
| 460 | const winConfig = { | ||
| 461 | target: [target], | ||
| 462 | icon: "icon.ico", | ||
| 463 | }; | ||
| 464 | if (signed) { | ||
| 465 | winConfig.azureSignOptions = getAzureTrustedSigningOptions(); | ||
| 466 | } | ||
| 467 | buildConfig.win = winConfig; | ||
| 468 | } | ||
| 469 | |||
| 470 | return buildConfig; | ||
| 471 | } | ||
| 472 | |||
| 473 | async function assertPlatformBuildResources(platform, stageResourcesDir) { | ||
| 474 | if (platform === "mac") { | ||
| 475 | await stageMacIcons(stageResourcesDir); | ||
| 476 | return; | ||
| 477 | } | ||
| 478 | |||
| 479 | if (platform === "linux") { | ||
| 480 | const iconPath = path.join(stageResourcesDir, "icon.png"); | ||
| 481 | if (!(await exists(iconPath))) { | ||
| 482 | fail(`Missing Linux icon at ${iconPath}`); | ||
| 483 | } | ||
| 484 | return; | ||
| 485 | } | ||
| 486 | |||
| 487 | if (platform === "win") { | ||
| 488 | const iconPath = path.join(stageResourcesDir, "icon.ico"); | ||
| 489 | if (!(await exists(iconPath))) { | ||
| 490 | fail(`Missing Windows icon at ${iconPath}`); | ||
| 491 | } | ||
| 492 | } | ||
| 493 | } | ||
| 494 | |||
| 495 | export async function main(argv = process.argv.slice(2)) { | ||
| 496 | const options = parseArgs(argv); | ||
| 497 | if (options.help) { | ||
| 498 | printUsage(); | ||
| 499 | return; | ||
| 500 | } | ||
| 501 | |||
| 502 | const hostBuildPlatform = detectHostBuildPlatform(process.platform); | ||
| 503 | if (!hostBuildPlatform) { | ||
| 504 | fail(`Unsupported host platform '${process.platform}'.`); | ||
| 505 | } | ||
| 506 | |||
| 507 | const platformConfig = PLATFORM_CONFIG[options.platform]; | ||
| 508 | if (!platformConfig) { | ||
| 509 | fail(`Unsupported platform '${options.platform}'.`); | ||
| 510 | } | ||
| 511 | |||
| 512 | if (hostBuildPlatform !== options.platform) { | ||
| 513 | fail( | ||
| 514 | `Platform '${options.platform}' must run on ${platformConfig.description} (${platformConfig.hostPlatform}). ` + | ||
| 515 | `Current host is ${process.platform}.`, | ||
| 516 | ); | ||
| 517 | } | ||
| 518 | |||
| 519 | const rootPkg = await readJson(path.join(repoRoot, "package.json")); | ||
| 520 | const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); | ||
| 521 | const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); | ||
| 522 | |||
| 523 | const electronVersion = desktopPkg.dependencies?.electron; | ||
| 524 | if (typeof electronVersion !== "string" || electronVersion.length === 0) { | ||
| 525 | fail("Could not resolve apps/desktop electron dependency version."); | ||
| 526 | } | ||
| 527 | |||
| 528 | const dependencies = serverPkg.dependencies; | ||
| 529 | if (!dependencies || Object.keys(dependencies).length === 0) { | ||
| 530 | fail("Could not resolve production dependencies from apps/server/package.json."); | ||
| 531 | } | ||
| 532 | |||
| 533 | const resolvedDependencies = resolveCatalogDependencies( | ||
| 534 | dependencies, | ||
| 535 | readWorkspaceCatalog(rootPkg), | ||
| 536 | "apps/server", | ||
| 537 | ); | ||
| 538 | |||
| 539 | const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; | ||
| 540 | const stageRoot = path.join(os.tmpdir(), `t3code-desktop-${options.platform}-stage`); | ||
| 541 | const stageAppDir = path.join(stageRoot, "app"); | ||
| 542 | const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); | ||
| 543 | const distDirs = { | ||
| 544 | desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), | ||
| 545 | desktopResources: path.join(repoRoot, "apps/desktop/resources"), | ||
| 546 | serverDist: path.join(repoRoot, "apps/server/dist"), | ||
| 547 | }; | ||
| 548 | const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); | ||
| 549 | |||
| 550 | if (!options.skipBuild) { | ||
| 551 | console.log("[desktop-artifact] Building desktop/server/web artifacts..."); | ||
| 552 | await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); | ||
| 553 | } | ||
| 554 | |||
| 555 | for (const [label, dir] of Object.entries(distDirs)) { | ||
| 556 | if (!(await exists(dir))) { | ||
| 557 | fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); | ||
| 558 | } | ||
| 559 | } | ||
| 560 | |||
| 561 | if (!(await exists(bundledClientEntry))) { | ||
| 562 | fail(`Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`); | ||
| 563 | } | ||
| 564 | |||
| 565 | await validateBundledClientAssets(path.dirname(bundledClientEntry)); | ||
| 566 | |||
| 567 | await fs.rm(stageRoot, { recursive: true, force: true }); | ||
| 568 | await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); | ||
| 569 | await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); | ||
| 570 | |||
| 571 | console.log("[desktop-artifact] Staging release app..."); | ||
| 572 | await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { | ||
| 573 | recursive: true, | ||
| 574 | }); | ||
| 575 | await fs.cp(distDirs.desktopResources, stageResourcesDir, { | ||
| 576 | recursive: true, | ||
| 577 | }); | ||
| 578 | await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); | ||
| 579 | |||
| 580 | await assertPlatformBuildResources(options.platform, stageResourcesDir); | ||
| 581 | |||
| 582 | const stagePackageJson = { | ||
| 583 | name: "t3-code-desktop", | ||
| 584 | version: appVersion, | ||
| 585 | private: true, | ||
| 586 | description: "T3 Code desktop build", | ||
| 587 | author: "T3 Tools", | ||
| 588 | main: "apps/desktop/dist-electron/main.js", | ||
| 589 | build: createBuildConfig( | ||
| 590 | options.platform, | ||
| 591 | options.target, | ||
| 592 | desktopPkg.productName ?? "T3 Code", | ||
| 593 | options.signed, | ||
| 594 | ), | ||
| 595 | dependencies: resolvedDependencies, | ||
| 596 | devDependencies: { | ||
| 597 | electron: electronVersion, | ||
| 598 | }, | ||
| 599 | }; | ||
| 600 | |||
| 601 | await fs.writeFile( | ||
| 602 | path.join(stageAppDir, "package.json"), | ||
| 603 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | ||
| 604 | "utf8", | ||
| 605 | ); | ||
| 606 | |||
| 607 | console.log("[desktop-artifact] Installing staged production dependencies..."); | ||
| 608 | await run("bun", ["install", "--production"], { cwd: stageAppDir }); | ||
| 609 | |||
| 610 | const buildEnv = { | ||
| 611 | ...process.env, | ||
| 612 | }; | ||
| 613 | if (!options.signed) { | ||
| 614 | buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; | ||
| 615 | } | ||
| 616 | |||
| 617 | const buildArgs = [ | ||
| 618 | "--bun", | ||
| 619 | "electron-builder", | ||
| 620 | platformConfig.cliFlag, | ||
| 621 | options.target, | ||
| 622 | `--${options.arch}`, | ||
| 623 | "--publish", | ||
| 624 | "never", | ||
| 625 | ]; | ||
| 626 | |||
| 627 | console.log( | ||
| 628 | `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, | ||
| 629 | ); | ||
| 630 | await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); | ||
| 631 | |||
| 632 | const stageDistDir = path.join(stageAppDir, "dist"); | ||
| 633 | if (!(await exists(stageDistDir))) { | ||
| 634 | fail(`Build completed but dist directory was not found at ${stageDistDir}`); | ||
| 635 | } | ||
| 636 | |||
| 637 | const stageEntries = await fs.readdir(stageDistDir, { withFileTypes: true }); | ||
| 638 | await fs.mkdir(options.outputDir, { recursive: true }); | ||
| 639 | |||
| 640 | const copiedArtifacts = []; | ||
| 641 | for (const entry of stageEntries) { | ||
| 642 | if (!entry.isFile()) continue; | ||
| 643 | |||
| 644 | const from = path.join(stageDistDir, entry.name); | ||
| 645 | const to = path.join(options.outputDir, entry.name); | ||
| 646 | await fs.copyFile(from, to); | ||
| 647 | copiedArtifacts.push(to); | ||
| 648 | } | ||
| 649 | |||
| 650 | if (copiedArtifacts.length === 0) { | ||
| 651 | fail(`Build completed but no files were produced in ${stageDistDir}`); | ||
| 652 | } | ||
| 653 | |||
| 654 | if (!options.keepStage) { | ||
| 655 | await fs.rm(stageRoot, { recursive: true, force: true }); | ||
| 656 | } | ||
| 657 | |||
| 658 | console.log("[desktop-artifact] Done. Artifacts:"); | ||
| 659 | for (const artifact of copiedArtifacts) { | ||
| 660 | console.log(` - ${artifact}`); | ||
| 661 | } | ||
| 662 | } | ||
| 663 | |||
| 664 | const scriptPath = fileURLToPath(import.meta.url); | ||
| 665 | const isDirectExecution = DIRECT_EXECUTION_PATH === scriptPath; | ||
| 666 | |||
| 667 | if (isDirectExecution) { | ||
| 668 | main().catch((error) => { | ||
| 669 | const message = error instanceof Error ? error.message : String(error); | ||
| 670 | fail(message); | ||
| 671 | }); | ||
| 672 | } | ||
scripts/build-desktop-artifact.mts 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 | 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-dmg.mjs deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import { main } from "./build-desktop-artifact.mjs"; | ||
| 4 | |||
| 5 | main(["--platform", "mac", "--target", "dmg", ...process.argv.slice(2)]).catch((error) => { | ||
| 6 | const message = error instanceof Error ? error.message : String(error); | ||
| 7 | console.error(`[desktop-dmg] ${message}`); | ||
| 8 | process.exit(1); | ||
| 9 | }); | ||