diff --git a/package.json b/package.json index 053f16f343511177cb6c2f09b9df03c690354872..671adbe0d936cf59b73c3ad54063b7ae02ed7a1f 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,12 @@ "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.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", + "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", + "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", + "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", + "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", + "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", + "dist:desktop:win": "node scripts/build-desktop-artifact.ts --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" }, diff --git a/scripts/build-desktop-artifact.mts b/scripts/build-desktop-artifact.mts deleted file mode 100644 index b137f92a9303829f97d86c5fc3fb5a1260bed0ee..0000000000000000000000000000000000000000 --- a/scripts/build-desktop-artifact.mts +++ /dev/null @@ -1,624 +0,0 @@ -#!/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-artifact.ts b/scripts/build-desktop-artifact.ts new file mode 100644 index 0000000000000000000000000000000000000000..849fea934e74e2809b38d21ed06f403cb2bdfcda --- /dev/null +++ b/scripts/build-desktop-artifact.ts @@ -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 }), + ); +}); + +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, +);