From 527a730beca20e76b560f030038402b7fefb02b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 23:00:54 -0400 Subject: [PATCH] refactor(build-desktop): split 397-line script into env/config/runner modules (P1-15) --- .gitignore | 2 +- scripts/build/emit-builder-config.mjs | 117 +++++++++++++++++++++++++ scripts/build/prepare-env.mjs | 93 ++++++++++++++++++++ scripts/build/run-electron-builder.mjs | 94 ++++++++++++++++++++ 4 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 scripts/build/emit-builder-config.mjs create mode 100644 scripts/build/prepare-env.mjs create mode 100644 scripts/build/run-electron-builder.mjs diff --git a/.gitignore b/.gitignore index 307ab038..49cc5689 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ coverage/ win-unpacked .blockmap .claude -build +/build .omc .omx .planning/* diff --git a/scripts/build/emit-builder-config.mjs b/scripts/build/emit-builder-config.mjs new file mode 100644 index 00000000..69dece20 --- /dev/null +++ b/scripts/build/emit-builder-config.mjs @@ -0,0 +1,117 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +function validateBuildVersion(currentVersion) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(currentVersion); + if (!match) { + throw new Error(`[build-desktop] Unsupported package version format: ${currentVersion}`); + } + return currentVersion; +} + +export async function resolveBuildVersion(packageJsonPath, override) { + if (override?.trim()) return validateBuildVersion(override.trim()); + const raw = await readFile(packageJsonPath, "utf8"); + const pkg = JSON.parse(raw); + return validateBuildVersion(pkg.version); +} + +export function hasExplicitArchArg(args) { + return args.some((arg) => + arg === "--arm64" || + arg === "--x64" || + arg === "--ia32" || + arg === "--universal" || + arg.startsWith("--arm64=") || + arg.startsWith("--x64=") || + arg.startsWith("--ia32=") || + arg.startsWith("--universal="), + ); +} + +function resolveCurrentMacArch(env, extraArgs) { + if (process.platform !== "darwin" || env.AGENTCOMPANY_MAC_ALL_ARCHES === "true" || hasExplicitArchArg(extraArgs)) { + return null; + } + return process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null; +} + +export async function writeRuntimeBuilderConfig({ + rootDir, + buildDir, + runtimeConfigPath, + buildVersion, + extraArgs, + prepared, + customMacSignPath, + localElectronDistPath, +}) { + const { env, resolvedUpdateUrl, hasR2PublishConfig, hasMacSigningMaterial, strictRelease, isDirBuild, explicitElectronDistPath, r2 } = prepared; + const forcedMacArch = resolveCurrentMacArch(env, extraArgs); + const isUnsignedLocalMacBuild = ( + process.platform === "darwin" && + !hasMacSigningMaterial && + env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" + ); + + const releaseDir = join(buildDir, "release"); + const runtimeConfig = { + extends: join(rootDir, "electron-builder.yml"), + directories: { output: releaseDir }, + extraMetadata: { version: buildVersion }, + }; + + if (explicitElectronDistPath) { + runtimeConfig.electronDist = explicitElectronDistPath; + } else if (isDirBuild) { + runtimeConfig.electronDist = localElectronDistPath; + } + + if (hasR2PublishConfig) { + runtimeConfig.publish = [{ + provider: "s3", + bucket: r2.bucket, + endpoint: r2.endpoint, + region: r2.region, + path: r2.path || undefined, + acl: null, + publishAutoUpdate: true, + }]; + } else if (resolvedUpdateUrl) { + runtimeConfig.publish = [{ provider: "generic", url: resolvedUpdateUrl }]; + } + + if (isUnsignedLocalMacBuild) { + if (strictRelease) { + throw new Error("[build-desktop] Strict release mode requires macOS signing/notarization material."); + } + runtimeConfig.mac = { hardenedRuntime: false, identity: null }; + if (!isDirBuild) { + runtimeConfig.mac.target = forcedMacArch + ? [{ target: "zip", arch: [forcedMacArch] }] + : ["zip"]; + } + } else if (process.platform === "darwin" && !hasMacSigningMaterial) { + runtimeConfig.mac = { hardenedRuntime: false, identity: null }; + } else if (process.platform === "darwin") { + runtimeConfig.mac = { hardenedRuntime: true }; + if (hasMacSigningMaterial) { + runtimeConfig.mac.sign = customMacSignPath; + } + } + + if (process.platform === "darwin" && !isDirBuild && forcedMacArch && !isUnsignedLocalMacBuild) { + runtimeConfig.mac ??= {}; + runtimeConfig.mac.target = [ + { target: "dmg", arch: [forcedMacArch] }, + { target: "zip", arch: [forcedMacArch] }, + ]; + } + + if (strictRelease && !resolvedUpdateUrl) { + throw new Error("[build-desktop] Strict release mode requires AGENTCOMPANY_UPDATE_URL or an R2 public URL to be configured."); + } + + await writeFile(runtimeConfigPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8"); + return { forcedMacArch, isUnsignedLocalMacBuild }; +} diff --git a/scripts/build/prepare-env.mjs b/scripts/build/prepare-env.mjs new file mode 100644 index 00000000..d9d76570 --- /dev/null +++ b/scripts/build/prepare-env.mjs @@ -0,0 +1,93 @@ +function normalizeBaseUrl(url) { + return url.replace(/\/+$/, ""); +} + +function normalizePublishPath(targetPath) { + return targetPath.replace(/^\/+|\/+$/g, ""); +} + +export function prepareBuildEnv(sourceEnv, extraArgs) { + const env = { ...sourceEnv }; + const noDeprecationFlag = "--no-deprecation"; + const requestedUpdateUrl = env.AGENTCOMPANY_UPDATE_URL?.trim() ?? ""; + const strictRelease = env.AGENTCOMPANY_STRICT_RELEASE === "true"; + const hasMacSigningMaterial = Boolean( + env.CSC_LINK?.trim() || + env.CSC_NAME?.trim() || + env.APPLE_API_KEY?.trim(), + ); + const isDirBuild = extraArgs.includes("--dir"); + const r2AccountId = env.AGENTCOMPANY_R2_ACCOUNT_ID?.trim() ?? env.R2_ACCOUNT_ID?.trim() ?? ""; + const r2Bucket = env.AGENTCOMPANY_R2_BUCKET?.trim() ?? env.R2_BUCKET?.trim() ?? ""; + const r2PublicUrl = env.AGENTCOMPANY_R2_PUBLIC_URL?.trim() ?? env.R2_PUBLIC_BASE_URL?.trim() ?? ""; + const r2Path = env.AGENTCOMPANY_R2_PATH?.trim() ?? env.AGENTCOMPANY_RELEASE_PREFIX?.trim() ?? "desktop"; + const r2Region = env.AGENTCOMPANY_R2_REGION?.trim() || "auto"; + const r2Endpoint = env.AGENTCOMPANY_R2_ENDPOINT?.trim() || (r2AccountId ? `https://${r2AccountId}.r2.cloudflarestorage.com` : ""); + + const resolvedUpdateUrl = (() => { + if (requestedUpdateUrl && !requestedUpdateUrl.includes("example.invalid")) { + return normalizeBaseUrl(requestedUpdateUrl); + } + if (!r2PublicUrl) return ""; + const baseUrl = normalizeBaseUrl(r2PublicUrl); + const publishPath = normalizePublishPath(r2Path); + return publishPath ? `${baseUrl}/${publishPath}` : baseUrl; + })(); + + const hasR2PublishConfig = Boolean( + r2Bucket && + r2Endpoint && + resolvedUpdateUrl && + (env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim() || env.AWS_ACCESS_KEY_ID?.trim()) && + (env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim() || env.AWS_SECRET_ACCESS_KEY?.trim()), + ); + + if (!env.AWS_ACCESS_KEY_ID && (env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim())) { + env.AWS_ACCESS_KEY_ID = env.AGENTCOMPANY_R2_ACCESS_KEY_ID?.trim() || env.R2_ACCESS_KEY_ID?.trim(); + } + if (!env.AWS_SECRET_ACCESS_KEY && (env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim())) { + env.AWS_SECRET_ACCESS_KEY = env.AGENTCOMPANY_R2_SECRET_ACCESS_KEY?.trim() || env.R2_SECRET_ACCESS_KEY?.trim(); + } + if (!env.AWS_REGION) env.AWS_REGION = r2Region; + if (!env.AWS_DEFAULT_REGION) env.AWS_DEFAULT_REGION = env.AWS_REGION; + if (!env.AWS_EC2_METADATA_DISABLED) env.AWS_EC2_METADATA_DISABLED = "true"; + if (!env.AGENTCOMPANY_UPDATE_URL && resolvedUpdateUrl) { + env.AGENTCOMPANY_UPDATE_URL = resolvedUpdateUrl; + } + + if (env.CSC_IDENTITY_AUTO_DISCOVERY === undefined) { + env.CSC_IDENTITY_AUTO_DISCOVERY = hasMacSigningMaterial ? "true" : "false"; + } + + const existingNodeOptions = env.NODE_OPTIONS?.trim(); + if (!existingNodeOptions) { + env.NODE_OPTIONS = noDeprecationFlag; + } else if (!existingNodeOptions.includes(noDeprecationFlag)) { + env.NODE_OPTIONS = `${existingNodeOptions} ${noDeprecationFlag}`; + } + + return { + env, + resolvedUpdateUrl, + hasR2PublishConfig, + hasMacSigningMaterial, + strictRelease, + isDirBuild, + explicitElectronDistPath: env.AGENTCOMPANY_ELECTRON_DIST?.trim() || "", + r2: { bucket: r2Bucket, endpoint: r2Endpoint, region: r2Region, path: normalizePublishPath(r2Path) }, + }; +} + +export function resolvePublishMode(env) { + const allowedPublishModes = new Set(["always", "never", "onTag", "onTagOrDraft"]); + const requested = env.AGENTCOMPANY_ELECTRON_PUBLISH?.trim(); + if (!requested) return "never"; + if (!allowedPublishModes.has(requested)) { + console.warn( + `[build-desktop] Ignoring invalid AGENTCOMPANY_ELECTRON_PUBLISH="${requested}". ` + + "Falling back to --publish never.", + ); + return "never"; + } + return requested; +} diff --git a/scripts/build/run-electron-builder.mjs b/scripts/build/run-electron-builder.mjs new file mode 100644 index 00000000..d4f22cb5 --- /dev/null +++ b/scripts/build/run-electron-builder.mjs @@ -0,0 +1,94 @@ +import { spawn } from "node:child_process"; +import { hasExplicitArchArg } from "./emit-builder-config.mjs"; + +const filteredUnsignedMacWarningSnippets = [ + "skipped macOS code signing reason=identity explicitly is set to null", + "arm64 requires signing, but identity is set to null and signing is being skipped", +]; + +function resolveCurrentPlatformArg() { + switch (process.platform) { + case "darwin": return "--mac"; + case "win32": return "--win"; + default: return "--linux"; + } +} + +function resolveCurrentArchArg() { + switch (process.arch) { + case "arm64": return "--arm64"; + case "x64": return "--x64"; + default: return null; + } +} + +function shouldSuppressBuildLine(line) { + return filteredUnsignedMacWarningSnippets.some((snippet) => line.includes(snippet)); +} + +function forwardChildOutput(stream, writer) { + if (!stream) return; + let pending = ""; + stream.on("data", (chunk) => { + pending += chunk.toString(); + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ""; + for (const line of lines) { + if (!shouldSuppressBuildLine(line)) writer.write(`${line}\n`); + } + }); + stream.on("end", () => { + if (!pending) return; + if (!shouldSuppressBuildLine(pending)) writer.write(`${pending}\n`); + }); +} + +export function buildElectronBuilderArgs({ runtimeConfigPath, extraArgs, publishMode, prepared }) { + const explicitPublishArg = extraArgs.some((arg) => arg === "--publish" || arg.startsWith("--publish=")); + const explicitPlatformArg = extraArgs.some((arg) => + arg === "--mac" || arg === "--win" || arg === "--linux" || + arg === "-m" || arg === "-w" || arg === "-l" || + arg.startsWith("--mac=") || arg.startsWith("--win=") || arg.startsWith("--linux="), + ); + const explicitArchArg = hasExplicitArchArg(extraArgs); + const { env, hasR2PublishConfig, resolvedUpdateUrl, hasMacSigningMaterial, isDirBuild } = prepared; + const shouldForceLocalMacZip = ( + process.platform === "darwin" && + !hasMacSigningMaterial && + env.AGENTCOMPANY_ENABLE_LOCAL_DMG !== "true" && + !explicitPlatformArg && + !isDirBuild + ); + + if (publishMode !== "never" && !hasR2PublishConfig && !resolvedUpdateUrl) { + throw new Error( + "[build-desktop] Publishing requires either AGENTCOMPANY_UPDATE_URL (generic feed only) " + + "or full Cloudflare R2 publish configuration.", + ); + } + + const args = ["exec", "electron-builder", "--config", runtimeConfigPath]; + if (!explicitPublishArg) args.push("--publish", publishMode); + if (!explicitPlatformArg) args.push(resolveCurrentPlatformArg()); + if (!explicitArchArg) { + const currentArchArg = resolveCurrentArchArg(); + if (currentArchArg) args.push(currentArchArg); + } + if (shouldForceLocalMacZip) args.push("zip"); + args.push(...extraArgs); + return args; +} + +export function runElectronBuilder(builderArgs, env) { + return new Promise((resolve, reject) => { + const child = spawn("pnpm", builderArgs, { + stdio: ["ignore", "pipe", "pipe"], + env, + shell: process.platform === "win32", + }); + forwardChildOutput(child.stdout, process.stdout); + forwardChildOutput(child.stderr, process.stderr); + child.on("error", reject); + child.on("close", (code, signal) => resolve({ code: code ?? 1, signal })); + }); +}