diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bebd0c6252f7e6b1414368eb469fa933878e8a4..53f2d68ab8af8dfcc480ed506a257e990f06e7fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,7 @@ jobs: --platform "${{ matrix.platform }}" --target "${{ matrix.target }}" --arch "${{ matrix.arch }}" - --version "${{ needs.preflight.outputs.version }}" + --build-version "${{ needs.preflight.outputs.version }}" ) has_all() { diff --git a/bun.lock b/bun.lock index 952f4c09e4f1bf518dcc05d445de1ed801ee79c4..a6731bba3d80dd05e86a3ac184d97a77669ce750 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "@t3tools/monorepo", "devDependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", "turbo": "^2.3.3", diff --git a/package.json b/package.json index 1a32dee5e30107bc5e37f70e5781ddb3e2712cae..053f16f343511177cb6c2f09b9df03c690354872 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,18 @@ "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", "build:contracts": "turbo run build --filter=@t3tools/contracts", - "dist:desktop:artifact": "node scripts/build-desktop-artifact.mjs", - "dist:desktop:dmg": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg", - "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch arm64", - "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mjs --platform mac --target dmg --arch x64", - "dist:desktop:linux": "node scripts/build-desktop-artifact.mjs --platform linux --target AppImage --arch x64", - "dist:desktop:win": "node scripts/build-desktop-artifact.mjs --platform win --target nsis --arch x64", + "dist:desktop:artifact": "node scripts/build-desktop-artifact.mts", + "dist:desktop:dmg": "node scripts/build-desktop-artifact.mts --platform mac --target dmg", + "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch arm64", + "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.mts --platform mac --target dmg --arch x64", + "dist:desktop:linux": "node scripts/build-desktop-artifact.mts --platform linux --target AppImage --arch x64", + "dist:desktop:win": "node scripts/build-desktop-artifact.mts --platform win --target nsis --arch x64", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, "devDependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", "turbo": "^2.3.3", diff --git a/scripts/build-desktop-artifact.mjs b/scripts/build-desktop-artifact.mjs deleted file mode 100644 index 491dd01c30e528d971b498edcded5c05bde1ed07..0000000000000000000000000000000000000000 --- a/scripts/build-desktop-artifact.mjs +++ /dev/null @@ -1,672 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, ".."); -const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); -const DIRECT_EXECUTION_PATH = process.argv[1] ? path.resolve(process.argv[1]) : null; - -const PLATFORM_CONFIG = { - mac: { - hostPlatform: "darwin", - description: "macOS", - cliFlag: "--mac", - defaultTarget: "dmg", - targetChoices: ["dmg"], - archChoices: ["arm64", "x64", "universal"], - }, - linux: { - hostPlatform: "linux", - description: "Linux", - cliFlag: "--linux", - defaultTarget: "AppImage", - targetChoices: ["AppImage"], - archChoices: ["x64", "arm64"], - }, - win: { - hostPlatform: "win32", - description: "Windows", - cliFlag: "--win", - defaultTarget: "nsis", - targetChoices: ["nsis"], - archChoices: ["x64", "arm64"], - }, -}; - -function printUsage() { - console.log(`Build a desktop artifact for T3 Code. - -Usage: - node scripts/build-desktop-artifact.mjs [options] - -Options: - --platform Target platform (default: current host platform) - --target Target format (mac: dmg, linux: AppImage, win: nsis) - --arch Target arch (platform-dependent default) - --version App version in artifact metadata (default: apps/server version) - --output-dir Output folder for final artifacts (default: release) - --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) - --keep-stage Keep temporary staging directory - --signed Enable signing/notarization auto-discovery in electron-builder - (Windows uses Azure Trusted Signing) - --help Show this message -`); -} - -function fail(message) { - console.error(`[desktop-artifact] ${message}`); - process.exit(1); -} - -function detectHostBuildPlatform(hostPlatform) { - if (hostPlatform === "darwin") return "mac"; - if (hostPlatform === "linux") return "linux"; - if (hostPlatform === "win32") return "win"; - return null; -} - -function normalizePlatform(value) { - const normalized = value.toLowerCase(); - if (normalized === "mac" || normalized === "macos" || normalized === "darwin") return "mac"; - if (normalized === "linux") return "linux"; - if (normalized === "win" || normalized === "windows" || normalized === "win32") return "win"; - return null; -} - -function normalizeChoice(inputValue, choices) { - const normalized = inputValue.toLowerCase(); - for (const choice of choices) { - if (choice.toLowerCase() === normalized) { - return choice; - } - } - return null; -} - -function formatChoices(values) { - return values.join(", "); -} - -function readRequiredEnvironmentVariable(name, contextMessage) { - const value = process.env[name]; - if (typeof value !== "string" || value.trim().length === 0) { - fail(`Missing ${name}${contextMessage ? ` (${contextMessage})` : ""}.`); - } - return value.trim(); -} - -function readOptionalEnvironmentVariable(name) { - const value = process.env[name]; - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function getAzureTrustedSigningOptions() { - return { - publisherName: readRequiredEnvironmentVariable( - "AZURE_TRUSTED_SIGNING_PUBLISHER_NAME", - "required for Windows --signed", - ), - endpoint: readRequiredEnvironmentVariable( - "AZURE_TRUSTED_SIGNING_ENDPOINT", - "required for Windows --signed", - ), - certificateProfileName: readRequiredEnvironmentVariable( - "AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME", - "required for Windows --signed", - ), - codeSigningAccountName: readRequiredEnvironmentVariable( - "AZURE_TRUSTED_SIGNING_ACCOUNT_NAME", - "required for Windows --signed", - ), - fileDigest: readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_FILE_DIGEST") ?? "SHA256", - timestampDigest: - readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST") ?? "SHA256", - timestampRfc3161: - readOptionalEnvironmentVariable("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161") ?? - "http://timestamp.acs.microsoft.com", - }; -} - -function getDefaultArch(platform) { - const config = PLATFORM_CONFIG[platform]; - if (!config) { - return "x64"; - } - - if (process.arch === "arm64" && config.archChoices.includes("arm64")) { - return "arm64"; - } - if (process.arch === "x64" && config.archChoices.includes("x64")) { - return "x64"; - } - - return config.archChoices[0] ?? "x64"; -} - -function parseArgs(argv) { - const hostPlatform = detectHostBuildPlatform(process.platform); - const parsed = { - platform: hostPlatform, - target: null, - arch: null, - version: null, - skipBuild: false, - keepStage: false, - signed: false, - outputDir: path.join(repoRoot, "release"), - help: false, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - - if (arg === "--help") { - parsed.help = true; - continue; - } - if (arg === "--skip-build") { - parsed.skipBuild = true; - continue; - } - if (arg === "--keep-stage") { - parsed.keepStage = true; - continue; - } - if (arg === "--signed") { - parsed.signed = true; - continue; - } - - if ( - arg === "--platform" || - arg === "--target" || - arg === "--arch" || - arg === "--version" || - arg === "--output-dir" - ) { - const value = argv[index + 1]; - if (!value || value.startsWith("--")) { - fail(`Missing value for ${arg}`); - } - index += 1; - - if (arg === "--platform") { - const platform = normalizePlatform(value); - if (!platform) { - fail(`Invalid platform '${value}'. Expected one of: mac, linux, win`); - } - parsed.platform = platform; - continue; - } - - if (arg === "--target") { - parsed.target = value; - continue; - } - - if (arg === "--arch") { - parsed.arch = value; - continue; - } - - if (arg === "--version") { - parsed.version = value; - continue; - } - - if (arg === "--output-dir") { - parsed.outputDir = path.resolve(repoRoot, value); - continue; - } - } - - fail(`Unknown argument: ${arg}`); - } - - if (!parsed.platform) { - const hosts = ["darwin", "linux", "win32"].join(", "); - fail(`Unsupported host platform '${process.platform}'. Supported hosts: ${hosts}`); - } - - const config = PLATFORM_CONFIG[parsed.platform]; - if (!config) { - fail(`Unsupported platform '${parsed.platform}'`); - } - - const target = - parsed.target === null - ? config.defaultTarget - : normalizeChoice(parsed.target, config.targetChoices); - if (!target) { - fail( - `Invalid target '${parsed.target}'. Supported targets for ${parsed.platform}: ${formatChoices( - config.targetChoices, - )}`, - ); - } - - const arch = parsed.arch === null ? getDefaultArch(parsed.platform) : normalizeChoice(parsed.arch, config.archChoices); - if (!arch) { - fail( - `Invalid arch '${parsed.arch}'. Supported arches for ${parsed.platform}: ${formatChoices( - config.archChoices, - )}`, - ); - } - - return { - ...parsed, - target, - arch, - }; -} - -async function readJson(filePath) { - const raw = await fs.readFile(filePath, "utf8"); - return JSON.parse(raw); -} - -async function exists(filePath) { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env, - stdio, - }); - - child.on("error", reject); - child.on("exit", (code, signal) => { - if (signal) { - reject(new Error(`${command} exited via signal ${signal}`)); - return; - } - if (code !== 0) { - reject(new Error(`${command} exited with code ${code}`)); - return; - } - resolve(); - }); - }); -} - -async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { - const iconsetDir = path.join(tmpRoot, "icon.iconset"); - await fs.mkdir(iconsetDir, { recursive: true }); - - const iconSizes = [16, 32, 128, 256, 512]; - for (const size of iconSizes) { - await run( - "sips", - [ - "-z", - String(size), - String(size), - sourcePng, - "--out", - path.join(iconsetDir, `icon_${size}x${size}.png`), - ], - { stdio: "ignore" }, - ); - - const retinaSize = size * 2; - await run( - "sips", - [ - "-z", - String(retinaSize), - String(retinaSize), - sourcePng, - "--out", - path.join(iconsetDir, `icon_${size}x${size}@2x.png`), - ], - { stdio: "ignore" }, - ); - } - - await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); -} - -async function stageMacIcons(stageResourcesDir) { - if (!(await exists(PRODUCTION_ICON_SOURCE))) { - fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); - } - - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); - - try { - const iconPngPath = path.join(stageResourcesDir, "icon.png"); - const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); - - await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { - stdio: "ignore", - }); - - await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }); - } -} - -async function validateBundledClientAssets(clientDir) { - const indexPath = path.join(clientDir, "index.html"); - const indexHtml = await fs.readFile(indexPath, "utf8"); - const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); - const missing = []; - - for (const ref of refs) { - const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; - if (!normalizedRef) continue; - if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; - if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; - - const ext = path.extname(normalizedRef); - if (!ext) continue; - - const relativePath = normalizedRef.replace(/^\/+/, ""); - const assetPath = path.join(clientDir, relativePath); - if (!(await exists(assetPath))) { - missing.push(normalizedRef); - } - } - - if (missing.length > 0) { - const preview = missing.slice(0, 6).join(", "); - const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; - fail( - `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, - ); - } -} - -function readWorkspaceCatalog(rootPackageJson) { - const workspaces = rootPackageJson?.workspaces; - if (!workspaces || typeof workspaces !== "object" || Array.isArray(workspaces)) { - return {}; - } - - const catalog = workspaces.catalog; - if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) { - return {}; - } - - return catalog; -} - -function resolveCatalogDependencies(dependencies, catalog, dependencySourceLabel) { - return Object.fromEntries( - Object.entries(dependencies).map(([dependencyName, spec]) => { - if (typeof spec !== "string" || !spec.startsWith("catalog:")) { - return [dependencyName, spec]; - } - - const catalogKey = spec.slice("catalog:".length).trim(); - const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; - const resolvedSpec = catalog[lookupKey]; - if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { - fail( - `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. ` + - `Expected key '${lookupKey}' in root workspace catalog.`, - ); - } - - return [dependencyName, resolvedSpec]; - }), - ); -} - -function createBuildConfig(platform, target, productName, signed) { - const buildConfig = { - appId: "com.t3tools.t3code", - productName, - artifactName: "T3-Code-${version}-${arch}.${ext}", - directories: { - buildResources: "apps/desktop/resources", - }, - }; - - if (platform === "mac") { - buildConfig.mac = { - target: [target], - icon: "icon.icns", - category: "public.app-category.developer-tools", - }; - } - - if (platform === "linux") { - buildConfig.linux = { - target: [target], - icon: "icon.png", - category: "Development", - }; - } - - if (platform === "win") { - const winConfig = { - target: [target], - icon: "icon.ico", - }; - if (signed) { - winConfig.azureSignOptions = getAzureTrustedSigningOptions(); - } - buildConfig.win = winConfig; - } - - return buildConfig; -} - -async function assertPlatformBuildResources(platform, stageResourcesDir) { - if (platform === "mac") { - await stageMacIcons(stageResourcesDir); - return; - } - - if (platform === "linux") { - const iconPath = path.join(stageResourcesDir, "icon.png"); - if (!(await exists(iconPath))) { - fail(`Missing Linux icon at ${iconPath}`); - } - return; - } - - if (platform === "win") { - const iconPath = path.join(stageResourcesDir, "icon.ico"); - if (!(await exists(iconPath))) { - fail(`Missing Windows icon at ${iconPath}`); - } - } -} - -export async function main(argv = process.argv.slice(2)) { - const options = parseArgs(argv); - if (options.help) { - printUsage(); - return; - } - - const hostBuildPlatform = detectHostBuildPlatform(process.platform); - if (!hostBuildPlatform) { - fail(`Unsupported host platform '${process.platform}'.`); - } - - const platformConfig = PLATFORM_CONFIG[options.platform]; - if (!platformConfig) { - fail(`Unsupported platform '${options.platform}'.`); - } - - if (hostBuildPlatform !== options.platform) { - fail( - `Platform '${options.platform}' must run on ${platformConfig.description} (${platformConfig.hostPlatform}). ` + - `Current host is ${process.platform}.`, - ); - } - - const rootPkg = await readJson(path.join(repoRoot, "package.json")); - const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); - const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); - - const electronVersion = desktopPkg.dependencies?.electron; - if (typeof electronVersion !== "string" || electronVersion.length === 0) { - fail("Could not resolve apps/desktop electron dependency version."); - } - - const dependencies = serverPkg.dependencies; - if (!dependencies || Object.keys(dependencies).length === 0) { - fail("Could not resolve production dependencies from apps/server/package.json."); - } - - const resolvedDependencies = resolveCatalogDependencies( - dependencies, - readWorkspaceCatalog(rootPkg), - "apps/server", - ); - - const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; - const stageRoot = path.join(os.tmpdir(), `t3code-desktop-${options.platform}-stage`); - const stageAppDir = path.join(stageRoot, "app"); - const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); - const distDirs = { - desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), - desktopResources: path.join(repoRoot, "apps/desktop/resources"), - serverDist: path.join(repoRoot, "apps/server/dist"), - }; - const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); - - if (!options.skipBuild) { - console.log("[desktop-artifact] Building desktop/server/web artifacts..."); - await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); - } - - for (const [label, dir] of Object.entries(distDirs)) { - if (!(await exists(dir))) { - fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); - } - } - - if (!(await exists(bundledClientEntry))) { - fail(`Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`); - } - - await validateBundledClientAssets(path.dirname(bundledClientEntry)); - - await fs.rm(stageRoot, { recursive: true, force: true }); - await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); - await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); - - console.log("[desktop-artifact] Staging release app..."); - await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { - recursive: true, - }); - await fs.cp(distDirs.desktopResources, stageResourcesDir, { - recursive: true, - }); - await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); - - await assertPlatformBuildResources(options.platform, stageResourcesDir); - - const stagePackageJson = { - name: "t3-code-desktop", - version: appVersion, - private: true, - description: "T3 Code desktop build", - author: "T3 Tools", - main: "apps/desktop/dist-electron/main.js", - build: createBuildConfig( - options.platform, - options.target, - desktopPkg.productName ?? "T3 Code", - options.signed, - ), - dependencies: resolvedDependencies, - devDependencies: { - electron: electronVersion, - }, - }; - - await fs.writeFile( - path.join(stageAppDir, "package.json"), - `${JSON.stringify(stagePackageJson, null, 2)}\n`, - "utf8", - ); - - console.log("[desktop-artifact] Installing staged production dependencies..."); - await run("bun", ["install", "--production"], { cwd: stageAppDir }); - - const buildEnv = { - ...process.env, - }; - if (!options.signed) { - buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; - } - - const buildArgs = [ - "--bun", - "electron-builder", - platformConfig.cliFlag, - options.target, - `--${options.arch}`, - "--publish", - "never", - ]; - - console.log( - `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, - ); - await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); - - const stageDistDir = path.join(stageAppDir, "dist"); - if (!(await exists(stageDistDir))) { - fail(`Build completed but dist directory was not found at ${stageDistDir}`); - } - - const stageEntries = await fs.readdir(stageDistDir, { withFileTypes: true }); - await fs.mkdir(options.outputDir, { recursive: true }); - - const copiedArtifacts = []; - for (const entry of stageEntries) { - if (!entry.isFile()) continue; - - const from = path.join(stageDistDir, entry.name); - const to = path.join(options.outputDir, entry.name); - await fs.copyFile(from, to); - copiedArtifacts.push(to); - } - - if (copiedArtifacts.length === 0) { - fail(`Build completed but no files were produced in ${stageDistDir}`); - } - - if (!options.keepStage) { - await fs.rm(stageRoot, { recursive: true, force: true }); - } - - console.log("[desktop-artifact] Done. Artifacts:"); - for (const artifact of copiedArtifacts) { - console.log(` - ${artifact}`); - } -} - -const scriptPath = fileURLToPath(import.meta.url); -const isDirectExecution = DIRECT_EXECUTION_PATH === scriptPath; - -if (isDirectExecution) { - main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - fail(message); - }); -} diff --git a/scripts/build-desktop-artifact.mts b/scripts/build-desktop-artifact.mts new file mode 100644 index 0000000000000000000000000000000000000000..b137f92a9303829f97d86c5fc3fb5a1260bed0ee --- /dev/null +++ b/scripts/build-desktop-artifact.mts @@ -0,0 +1,624 @@ +#!/usr/bin/env node + +import rootPackageJson from "../package.json" with { type: "json" }; +import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; +import serverPackageJson from "../apps/server/package.json" with { type: "json" }; + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess } from "effect/unstable/process"; + +const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); +const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); + +const RepoRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), +); +const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => + path.join(repoRoot, "assets/macos-icon-1024.png"), +); + +interface PlatformConfig { + readonly cliFlag: "--mac" | "--linux" | "--win"; + readonly defaultTarget: string; + readonly archChoices: ReadonlyArray; +} + +const PLATFORM_CONFIG: Record = { + mac: { + cliFlag: "--mac", + defaultTarget: "dmg", + archChoices: ["arm64", "x64", "universal"], + }, + linux: { + cliFlag: "--linux", + defaultTarget: "AppImage", + archChoices: ["x64", "arm64"], + }, + win: { + cliFlag: "--win", + defaultTarget: "nsis", + archChoices: ["x64", "arm64"], + }, +}; + +interface BuildCliInput { + readonly platform: Option.Option; + readonly target: Option.Option; + readonly arch: Option.Option; + readonly buildVersion: Option.Option; + readonly outputDir: Option.Option; + readonly skipBuild: Option.Option; + readonly keepStage: Option.Option; + readonly signed: Option.Option; + readonly verbose: Option.Option; +} + +function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { + if (hostPlatform === "darwin") return "mac"; + if (hostPlatform === "linux") return "linux"; + if (hostPlatform === "win32") return "win"; + return undefined; +} + +function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.Type { + const config = PLATFORM_CONFIG[platform]; + if (!config) { + return "x64"; + } + + if (process.arch === "arm64" && config.archChoices.includes("arm64")) { + return "arm64"; + } + if (process.arch === "x64" && config.archChoices.includes("x64")) { + return "x64"; + } + + return config.archChoices[0] ?? "x64"; +} + +class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +interface ResolvedBuildOptions { + readonly platform: typeof BuildPlatform.Type; + readonly target: string; + readonly arch: typeof BuildArch.Type; + readonly version: string | undefined; + readonly outputDir: string; + readonly skipBuild: boolean; + readonly keepStage: boolean; + readonly signed: boolean; + readonly verbose: boolean; +} + +interface StagePackageJson { + readonly name: string; + readonly version: string; + readonly private: true; + readonly description: string; + readonly author: string; + readonly main: string; + readonly build: Record; + readonly dependencies: Record; + readonly devDependencies: { + readonly electron: string; + }; +} + +const AzureTrustedSigningOptionsConfig = Config.all({ + publisherName: Config.string("AZURE_TRUSTED_SIGNING_PUBLISHER_NAME"), + endpoint: Config.string("AZURE_TRUSTED_SIGNING_ENDPOINT"), + certificateProfileName: Config.string("AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME"), + codeSigningAccountName: Config.string("AZURE_TRUSTED_SIGNING_ACCOUNT_NAME"), + fileDigest: Config.string("AZURE_TRUSTED_SIGNING_FILE_DIGEST").pipe( + Config.withDefault(() => "SHA256"), + ), + timestampDigest: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_DIGEST").pipe( + Config.withDefault(() => "SHA256"), + ), + timestampRfc3161: Config.string("AZURE_TRUSTED_SIGNING_TIMESTAMP_RFC3161").pipe( + Config.withDefault(() => "http://timestamp.acs.microsoft.com"), + ), +}); + +const BuildEnvConfig = Config.all({ + platform: Config.schema(BuildPlatform, "T3CODE_DESKTOP_PLATFORM").pipe(Config.option), + target: Config.string("T3CODE_DESKTOP_TARGET").pipe(Config.option), + arch: Config.schema(BuildArch, "T3CODE_DESKTOP_ARCH").pipe(Config.option), + version: Config.string("T3CODE_DESKTOP_VERSION").pipe(Config.option), + outputDir: Config.string("T3CODE_DESKTOP_OUTPUT_DIR").pipe(Config.option), + skipBuild: Config.boolean("T3CODE_DESKTOP_SKIP_BUILD").pipe(Config.withDefault(() => false)), + keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(() => false)), + signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(() => false)), + verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(() => false)), +}); + +const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => + Option.getOrElse(Option.filter(flag, Boolean), () => envValue); +const mergeOptions = (a: Option.Option, b: Option.Option, defaultValue: A) => + Option.getOrElse(a, () => Option.getOrElse(b, () => defaultValue)); + +const resolveBuildOptions = Effect.fn(function* (input: BuildCliInput) { + const path = yield* Path.Path; + const repoRoot = yield* RepoRoot; + const env = yield* BuildEnvConfig.asEffect(); + + const platform = mergeOptions( + input.platform, + env.platform, + detectHostBuildPlatform(process.platform), + ); + + if (!platform) { + return yield* new BuildScriptError({ + message: `Unsupported host platform '${process.platform}'.`, + }); + } + + const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); + const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); + const version = mergeOptions(input.buildVersion, env.version, undefined); + const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release")); + + const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild); + const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage); + const signed = resolveBooleanFlag(input.signed, env.signed); + const verbose = resolveBooleanFlag(input.verbose, env.verbose); + + return { + platform, + target, + arch, + version, + outputDir, + skipBuild, + keepStage, + signed, + verbose, + } satisfies ResolvedBuildOptions; +}); + +const commandOutputOptions = (verbose: boolean) => + ({ + stdout: verbose ? "inherit" : "ignore", + stderr: "inherit", + }) as const; + +const runCommand = Effect.fn(function* (command: ChildProcess.Command) { + const exitCode = yield* ChildProcess.exitCode(command); + + if (exitCode !== 0) { + return yield* new BuildScriptError({ + message: "Command exited with non-zero exit code", + cause: command, + }); + } +}); + +function generateMacIconSet( + sourcePng: string, + targetIcns: string, + tmpRoot: string, + path: Path.Path, + verbose: boolean, +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const iconsetDir = path.join(tmpRoot, "icon.iconset"); + yield* fs.makeDirectory(iconsetDir, { recursive: true }); + + const iconSizes = [16, 32, 128, 256, 512] as const; + for (const size of iconSizes) { + yield* runCommand( + ChildProcess.make({ + ...commandOutputOptions(verbose), + })`sips -z ${size} ${size} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}.png`)}`, + ); + + const retinaSize = size * 2; + yield* runCommand( + ChildProcess.make({ + ...commandOutputOptions(verbose), + })`sips -z ${retinaSize} ${retinaSize} ${sourcePng} --out ${path.join(iconsetDir, `icon_${size}x${size}@2x.png`)}`, + ); + } + + yield* runCommand( + ChildProcess.make({ + ...commandOutputOptions(verbose), + })`iconutil -c icns ${iconsetDir} -o ${targetIcns}`, + ); + }); +} + +function stageMacIcons(stageResourcesDir: string, verbose: boolean) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const iconSource = yield* IconSource; + if (!(yield* fs.exists(iconSource))) { + return yield* new BuildScriptError({ + message: `Production icon source is missing at ${iconSource}`, + }); + } + + const tmpRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-icon-build-", + }); + + const iconPngPath = path.join(stageResourcesDir, "icon.png"); + const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); + + yield* runCommand( + ChildProcess.make({ + ...commandOutputOptions(verbose), + })`sips -z 512 512 ${iconSource} --out ${iconPngPath}`, + ); + + yield* generateMacIconSet(iconSource, iconIcnsPath, tmpRoot, path, verbose); + }); +} + +function validateBundledClientAssets(clientDir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const indexPath = path.join(clientDir, "index.html"); + const indexHtml = yield* fs.readFileString(indexPath); + const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map( + (match) => match[1], + ); + const missing: string[] = []; + + for (const ref of refs) { + const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; + if (!normalizedRef) continue; + if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; + if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; + + const ext = path.extname(normalizedRef); + if (!ext) continue; + + const relativePath = normalizedRef.replace(/^\/+/, ""); + const assetPath = path.join(clientDir, relativePath); + if (!(yield* fs.exists(assetPath))) { + missing.push(normalizedRef); + } + } + + if (missing.length > 0) { + const preview = missing.slice(0, 6).join(", "); + const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; + return yield* new BuildScriptError({ + message: `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, + }); + } + }); +} + +function resolveCatalogDependencies( + dependencies: Record, + catalog: Record, + dependencySourceLabel: string, +): Record { + return Object.fromEntries( + Object.entries(dependencies).map(([dependencyName, spec]) => { + if (typeof spec !== "string" || !spec.startsWith("catalog:")) { + return [dependencyName, spec]; + } + + const catalogKey = spec.slice("catalog:".length).trim(); + const lookupKey = catalogKey.length > 0 ? catalogKey : dependencyName; + const resolvedSpec = catalog[lookupKey]; + if (typeof resolvedSpec !== "string" || resolvedSpec.length === 0) { + throw new BuildScriptError({ + message: `Unable to resolve '${spec}' for ${dependencySourceLabel} dependency '${dependencyName}'. Expected key '${lookupKey}' in root workspace catalog.`, + }); + } + + return [dependencyName, resolvedSpec]; + }), + ); +} + +const createBuildConfig = Effect.fn(function* ( + platform: typeof BuildPlatform.Type, + target: string, + productName: string, + signed: boolean, +) { + const buildConfig: Record = { + appId: "com.t3tools.t3code", + productName, + artifactName: "T3-Code-${version}-${arch}.${ext}", + directories: { + buildResources: "apps/desktop/resources", + }, + }; + + if (platform === "mac") { + buildConfig.mac = { + target: [target], + icon: "icon.icns", + category: "public.app-category.developer-tools", + }; + } + + if (platform === "linux") { + buildConfig.linux = { + target: [target], + icon: "icon.png", + category: "Development", + }; + } + + if (platform === "win") { + const winConfig: Record = { + target: [target], + icon: "icon.ico", + }; + if (signed) { + winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; + } + buildConfig.win = winConfig; + } + + return buildConfig; +}); + +const assertPlatformBuildResources = Effect.fn(function* ( + platform: typeof BuildPlatform.Type, + stageResourcesDir: string, + verbose: boolean, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + if (platform === "mac") { + yield* stageMacIcons(stageResourcesDir, verbose); + return; + } + + if (platform === "linux") { + const iconPath = path.join(stageResourcesDir, "icon.png"); + if (!(yield* fs.exists(iconPath))) { + return yield* new BuildScriptError({ + message: `Missing Linux icon at ${iconPath}`, + }); + } + return; + } + + if (platform === "win") { + const iconPath = path.join(stageResourcesDir, "icon.ico"); + if (!(yield* fs.exists(iconPath))) { + return yield* new BuildScriptError({ + message: `Missing Windows icon at ${iconPath}`, + }); + } + } +}); + +const buildDesktopArtifact = Effect.fn(function* (options: ResolvedBuildOptions) { + const repoRoot = yield* RepoRoot; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + + const platformConfig = PLATFORM_CONFIG[options.platform]; + if (!platformConfig) { + return yield* new BuildScriptError({ + message: `Unsupported platform '${options.platform}'.`, + }); + } + + const electronVersion = desktopPackageJson.dependencies.electron; + + const dependencies = serverPackageJson.dependencies; + if (!dependencies || Object.keys(dependencies).length === 0) { + return yield* new BuildScriptError({ + message: "Could not resolve production dependencies from apps/server/package.json.", + }); + } + + const resolvedDependencies = yield* Effect.try({ + try: () => + resolveCatalogDependencies(dependencies, rootPackageJson.workspaces.catalog, "apps/server"), + catch: (cause) => + new BuildScriptError({ + message: "Could not resolve production dependencies from apps/server/package.json.", + cause, + }), + }); + + const appVersion = options.version ?? serverPackageJson.version; + const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; + const stageRoot = yield* mkdir({ + prefix: `t3code-desktop-${options.platform}-stage-`, + }); + + const stageAppDir = path.join(stageRoot, "app"); + const stageResourcesDir = path.join(stageAppDir, "apps/desktop/resources"); + const distDirs = { + desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), + desktopResources: path.join(repoRoot, "apps/desktop/resources"), + serverDist: path.join(repoRoot, "apps/server/dist"), + }; + const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); + + if (!options.skipBuild) { + yield* Effect.log("[desktop-artifact] Building desktop/server/web artifacts..."); + yield* runCommand( + ChildProcess.make({ + cwd: repoRoot, + ...commandOutputOptions(options.verbose), + })`bun run build:desktop`, + ); + } + + for (const [label, dir] of Object.entries(distDirs)) { + if (!(yield* fs.exists(dir))) { + return yield* new BuildScriptError({ + message: `Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`, + }); + } + } + + if (!(yield* fs.exists(bundledClientEntry))) { + return yield* new BuildScriptError({ + message: `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, + }); + } + + yield* validateBundledClientAssets(path.dirname(bundledClientEntry)); + + yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true }); + yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); + + yield* Effect.log("[desktop-artifact] Staging release app..."); + yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); + yield* fs.copy(distDirs.desktopResources, stageResourcesDir); + yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + + yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose); + + const stagePackageJson: StagePackageJson = { + name: "t3-code-desktop", + version: appVersion, + private: true, + description: "T3 Code desktop build", + author: "T3 Tools", + main: "apps/desktop/dist-electron/main.js", + build: yield* createBuildConfig( + options.platform, + options.target, + desktopPackageJson.productName ?? "T3 Code", + options.signed, + ), + dependencies: resolvedDependencies, + devDependencies: { + electron: electronVersion, + }, + }; + + yield* fs.writeFileString( + path.join(stageAppDir, "package.json"), + `${JSON.stringify(stagePackageJson, null, 2)}\n`, + ); + + yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); + yield* runCommand( + ChildProcess.make({ + cwd: stageAppDir, + ...commandOutputOptions(options.verbose), + })`bun install --production`, + ); + + const buildEnv: NodeJS.ProcessEnv = { + ...process.env, + }; + if (!options.signed) { + buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; + } + + yield* Effect.log( + `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, + ); + yield* runCommand( + ChildProcess.make({ + cwd: stageAppDir, + env: buildEnv, + ...commandOutputOptions(options.verbose), + })`bunx --bun electron-builder ${platformConfig.cliFlag} ${options.target} --${options.arch} --publish never`, + ); + + const stageDistDir = path.join(stageAppDir, "dist"); + if (!(yield* fs.exists(stageDistDir))) { + return yield* new BuildScriptError({ + message: `Build completed but dist directory was not found at ${stageDistDir}`, + }); + } + + const stageEntries = yield* fs.readDirectory(stageDistDir); + yield* fs.makeDirectory(options.outputDir, { recursive: true }); + + const copiedArtifacts: string[] = []; + for (const entry of stageEntries) { + const from = path.join(stageDistDir, entry); + const stat = yield* fs.stat(from).pipe(Effect.catch(() => Effect.succeed(null))); + if (!stat || stat.type !== "File") continue; + + const to = path.join(options.outputDir, entry); + yield* fs.copyFile(from, to); + copiedArtifacts.push(to); + } + + if (copiedArtifacts.length === 0) { + return yield* new BuildScriptError({ + message: `Build completed but no files were produced in ${stageDistDir}`, + }); + } + + yield* Effect.log("[desktop-artifact] Done. Artifacts:").pipe( + Effect.annotateLogs({ artifacts: copiedArtifacts }), + ); +}); + +export const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { + platform: Flag.choice("platform", BuildPlatform.literals).pipe( + Flag.withDescription("Build platform (env: T3CODE_DESKTOP_PLATFORM)."), + Flag.optional, + ), + target: Flag.string("target").pipe( + Flag.withDescription( + "Artifact target, for example dmg/AppImage/nsis (env: T3CODE_DESKTOP_TARGET).", + ), + Flag.optional, + ), + arch: Flag.choice("arch", BuildArch.literals).pipe( + Flag.withDescription("Build arch, for example arm64/x64/universal (env: T3CODE_DESKTOP_ARCH)."), + Flag.optional, + ), + buildVersion: Flag.string("build-version").pipe( + Flag.withDescription("Artifact version metadata (env: T3CODE_DESKTOP_VERSION)."), + Flag.optional, + ), + outputDir: Flag.string("output-dir").pipe( + Flag.withDescription("Output directory for artifacts (env: T3CODE_DESKTOP_OUTPUT_DIR)."), + Flag.optional, + ), + skipBuild: Flag.boolean("skip-build").pipe( + Flag.withDescription( + "Skip `bun run build:desktop` and use existing dist artifacts (env: T3CODE_DESKTOP_SKIP_BUILD).", + ), + Flag.optional, + ), + keepStage: Flag.boolean("keep-stage").pipe( + Flag.withDescription("Keep temporary staging files (env: T3CODE_DESKTOP_KEEP_STAGE)."), + Flag.optional, + ), + signed: Flag.boolean("signed").pipe( + Flag.withDescription( + "Enable signing/notarization discovery; Windows uses Azure Trusted Signing (env: T3CODE_DESKTOP_SIGNED).", + ), + Flag.optional, + ), + verbose: Flag.boolean("verbose").pipe( + Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."), + Flag.optional, + ), +}).pipe( + Command.withDescription("Build a desktop artifact for T3 Code."), + Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), +); + +Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(Logger.layer([Logger.consolePretty()])), + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, +); diff --git a/scripts/build-desktop-dmg.mjs b/scripts/build-desktop-dmg.mjs deleted file mode 100644 index 24c61f7734cd8c079276496b2c492698b39e0a4d..0000000000000000000000000000000000000000 --- a/scripts/build-desktop-dmg.mjs +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env node - -import { main } from "./build-desktop-artifact.mjs"; - -main(["--platform", "mac", "--target", "dmg", ...process.argv.slice(2)]).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.error(`[desktop-dmg] ${message}`); - process.exit(1); -});