build-node.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. #!/usr/bin/env bun
  2. import { spawnSync } from "node:child_process"
  3. import { createHash } from "node:crypto"
  4. import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
  5. import os from "node:os"
  6. import path from "node:path"
  7. import { build } from "vite"
  8. import { Script } from "@opencode-ai/script"
  9. import pkg from "../package.json"
  10. import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
  11. import { mainConfig } from "../vite.node.config"
  12. import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
  13. import { buildAppArchive } from "./app-assets"
  14. const NODE_VERSION = "26.4.0"
  15. const dir = path.resolve(import.meta.dirname, "..")
  16. const outdir = path.resolve(
  17. dir,
  18. process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
  19. )
  20. if (outdir === dir) throw new Error("--outdir must not be the package directory")
  21. if (outdir === path.join(dir, "dist-node")) {
  22. throw new Error("--outdir must not be dist-node because it contains temporary files")
  23. }
  24. const bundleOnly = process.argv.includes("--bundle-only")
  25. const single = process.argv.includes("--single")
  26. const skipInstall = process.argv.includes("--skip-install")
  27. const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
  28. const allTargets = [
  29. nodeTarget("linux", "arm64"),
  30. nodeTarget("linux", "x64"),
  31. nodeTarget("darwin", "arm64"),
  32. nodeTarget("win32", "arm64"),
  33. nodeTarget("win32", "x64"),
  34. ]
  35. const targets = requested
  36. ? allTargets.filter((target) => targetName(target) === requested)
  37. : single || bundleOnly
  38. ? [nodeTarget(process.platform, process.arch)]
  39. : allTargets
  40. if (targets.length === 0) {
  41. if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64")
  42. throw new Error(`Unknown Node target: ${requested}`)
  43. }
  44. if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) {
  45. throw new Error("Node 26.4 SEA does not support macOS x64")
  46. }
  47. process.chdir(dir)
  48. if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
  49. if (!bundleOnly) await rm(outdir, { recursive: true, force: true })
  50. const builder =
  51. !bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
  52. ? await resolveHostNode()
  53. : undefined
  54. const appArchive = await buildAppArchive(Script.channel)
  55. // Vite silently rewrites text imports of known asset types (.txt) to asset
  56. // URL strings when the raw-text plugin doesn't intercept them first — the
  57. // bundle still builds and `--help` still runs, so only content assertions
  58. // catch it. Guards the models.dev snapshot and the prompt/tool description
  59. // text that ships inside the bundle.
  60. async function assertTextImportsInlined(bundlePath: string) {
  61. const bundle = await readFile(bundlePath, "utf8")
  62. const markers = [
  63. { marker: '"zhipuai"', source: "models-dev snapshot" },
  64. { marker: "/assets/snapshot", source: "models-dev snapshot inlined as asset URL", forbidden: true },
  65. { marker: '="/assets/', source: "text import inlined as asset URL", forbidden: true },
  66. ]
  67. for (const { marker, source, forbidden } of markers) {
  68. const present = bundle.includes(marker)
  69. if (forbidden ? present : !present)
  70. throw new Error(`${bundlePath}: ${source} — text imports are not inlined as content (marker ${marker})`)
  71. }
  72. }
  73. for (const target of targets) {
  74. console.log(`building cli-node-${targetName(target)}`)
  75. const assets = await collectNodeAssets(target)
  76. await rm("dist-node", { recursive: true, force: true })
  77. const assetHash = await hashNodeAssets(assets)
  78. const input = {
  79. version: Script.version,
  80. channel: Script.channel,
  81. assetHash,
  82. target,
  83. appArchive,
  84. }
  85. await copyNodeAssets(assets)
  86. await build(mainConfig(input))
  87. await assertTextImportsInlined("dist-node/opencode.mjs")
  88. const host = target.platform === process.platform && target.arch === process.arch
  89. if (host) {
  90. if (!builder) throw new Error("Node SEA builder is unavailable")
  91. run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--version"])
  92. run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--help"])
  93. }
  94. if (bundleOnly) continue
  95. const name = `cli-node-${targetName(target)}`
  96. const binary = target.platform === "win32" ? "opencode2-node.exe" : "opencode2-node"
  97. const output = path.join(outdir, name, "bin", binary)
  98. if (!builder) throw new Error("Node SEA builder is unavailable")
  99. await mkdir(path.dirname(output), { recursive: true })
  100. const config = {
  101. main: "dist-node/opencode.mjs",
  102. mainFormat: "module",
  103. executable: await resolveTargetNode(target, builder),
  104. output: path.relative(dir, output),
  105. disableExperimentalSEAWarning: true,
  106. useSnapshot: false,
  107. useCodeCache: false,
  108. execArgv: nodeExecArgv,
  109. execArgvExtension: "none",
  110. assets: await seaAssetMap(),
  111. }
  112. await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`)
  113. run(builder, ["--build-sea", "dist-node/sea.json"])
  114. if (target.platform !== "win32") await chmod(output, 0o755)
  115. if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output])
  116. if (target.platform === "darwin" && process.platform !== "darwin") {
  117. console.warn(`${output} must be signed on macOS before it can run`)
  118. }
  119. await writeFile(
  120. path.join(outdir, name, "package.json"),
  121. `${JSON.stringify(
  122. {
  123. name: `@opencode-ai/${name}`,
  124. version: Script.version,
  125. license: pkg.license,
  126. repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
  127. os: [target.platform],
  128. cpu: [target.arch],
  129. },
  130. null,
  131. 2,
  132. )}\n`,
  133. )
  134. if (host) await smoke(output)
  135. }
  136. async function resolveHostNode() {
  137. const candidates = [process.env.NODE_BIN, "node"].filter((item): item is string => Boolean(item))
  138. for (const candidate of candidates) {
  139. const result = spawnSync(
  140. candidate,
  141. ["-p", "JSON.stringify({version:process.versions.node,path:process.execPath})"],
  142. {
  143. encoding: "utf8",
  144. },
  145. )
  146. if (result.status !== 0) continue
  147. const info = JSON.parse(result.stdout) as { version: string; path: string }
  148. if (info.version === NODE_VERSION) return realpath(info.path)
  149. }
  150. return resolveTargetNode(nodeTarget(process.platform, process.arch))
  151. }
  152. async function resolveTargetNode(target: NodeTarget, host?: string) {
  153. if (host && target.platform === process.platform && target.arch === process.arch) return host
  154. const cache = path.resolve(dir, ".cache", "node")
  155. const platform = target.platform === "win32" ? "win" : target.platform
  156. const archiveName = `node-v${NODE_VERSION}-${platform}-${target.arch}`
  157. const targetDirectory = path.join(cache, archiveName)
  158. const executable = path.join(targetDirectory, target.platform === "win32" ? "node.exe" : "bin/node")
  159. if (
  160. (await stat(executable).then(
  161. () => true,
  162. () => false,
  163. )) &&
  164. (await stat(path.join(targetDirectory, ".verified")).then(
  165. () => true,
  166. () => false,
  167. ))
  168. )
  169. return realpath(executable)
  170. await mkdir(cache, { recursive: true })
  171. const extension = target.platform === "win32" ? "zip" : "tar.gz"
  172. const filename = `${archiveName}.${extension}`
  173. const archive = path.join(cache, filename)
  174. const base = `https://nodejs.org/dist/v${NODE_VERSION}`
  175. const [response, sums] = await Promise.all([fetch(`${base}/${filename}`), fetch(`${base}/SHASUMS256.txt`)])
  176. if (!response.ok) throw new Error(`Failed to download Node ${NODE_VERSION}: ${response.status}`)
  177. if (!sums.ok) throw new Error(`Failed to download Node ${NODE_VERSION} checksums: ${sums.status}`)
  178. const data = new Uint8Array(await response.arrayBuffer())
  179. const expected = (await sums.text())
  180. .split("\n")
  181. .find((line) => line.endsWith(` ${filename}`))
  182. ?.split(/\s+/)[0]
  183. if (!expected) throw new Error(`Missing checksum for ${filename}`)
  184. if (createHash("sha256").update(data).digest("hex") !== expected) throw new Error(`Checksum mismatch for ${filename}`)
  185. await writeFile(archive, data)
  186. const temporary = path.join(cache, `${archiveName}.${process.pid}.tmp`)
  187. await rm(temporary, { recursive: true, force: true })
  188. await mkdir(temporary)
  189. if (target.platform !== "win32") run("tar", ["-xzf", archive, "-C", temporary])
  190. if (target.platform === "win32" && process.platform === "win32") {
  191. run(path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe"), ["-xf", archive, "-C", temporary])
  192. }
  193. if (target.platform === "win32" && process.platform !== "win32") run("unzip", ["-q", archive, "-d", temporary])
  194. await rm(targetDirectory, { recursive: true, force: true })
  195. await rename(path.join(temporary, archiveName), targetDirectory)
  196. await writeFile(path.join(targetDirectory, ".verified"), `${expected}\n`)
  197. await rm(temporary, { recursive: true, force: true })
  198. await rm(archive, { force: true })
  199. return realpath(executable)
  200. }
  201. async function smoke(output: string) {
  202. const root = await mkdtemp(path.join(os.tmpdir(), "opencode-node-smoke-"))
  203. const executable = path.join(root, path.basename(output))
  204. await copyFile(output, executable)
  205. if (process.platform !== "win32") await chmod(executable, 0o755)
  206. run(executable, ["--version"], root)
  207. run(executable, ["--help"], root)
  208. await rm(root, { recursive: true, force: true })
  209. }
  210. function targetName(target: NodeTarget) {
  211. return `${target.platform === "win32" ? "windows" : target.platform}-${target.arch}`
  212. }
  213. function run(command: string, args: readonly string[], cwd = dir) {
  214. const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env })
  215. if (result.error) throw result.error
  216. if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
  217. }