build.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. #!/usr/bin/env bun
  2. import { $ } from "bun"
  3. import fs from "fs"
  4. import path from "path"
  5. import { fileURLToPath } from "url"
  6. import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
  7. import { treeshakePrepass } from "./treeshake-prepass"
  8. const __filename = fileURLToPath(import.meta.url)
  9. const __dirname = path.dirname(__filename)
  10. const dir = path.resolve(__dirname, "..")
  11. process.chdir(dir)
  12. await import("./generate.ts")
  13. import { Script } from "@opencode-ai/script"
  14. import pkg from "../package.json"
  15. // Load migrations from migration directories
  16. const migrationDirs = (
  17. await fs.promises.readdir(path.join(dir, "migration"), {
  18. withFileTypes: true,
  19. })
  20. )
  21. .filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
  22. .map((entry) => entry.name)
  23. .sort()
  24. const migrations = await Promise.all(
  25. migrationDirs.map(async (name) => {
  26. const file = path.join(dir, "migration", name, "migration.sql")
  27. const sql = await Bun.file(file).text()
  28. const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
  29. const timestamp = match
  30. ? Date.UTC(
  31. Number(match[1]),
  32. Number(match[2]) - 1,
  33. Number(match[3]),
  34. Number(match[4]),
  35. Number(match[5]),
  36. Number(match[6]),
  37. )
  38. : 0
  39. return { sql, timestamp, name }
  40. }),
  41. )
  42. console.log(`Loaded ${migrations.length} migrations`)
  43. const singleFlag = process.argv.includes("--single")
  44. const baselineFlag = process.argv.includes("--baseline")
  45. const skipInstall = process.argv.includes("--skip-install")
  46. const skipTreeshake = process.argv.includes("--skip-treeshake")
  47. const plugin = createSolidTransformPlugin()
  48. const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
  49. // Run Rollup tree-shaking pre-pass on the main entrypoint.
  50. // Bun/esbuild can't tree-shake `export * as X` barrels (evanw/esbuild#1420).
  51. // Rollup can — it does AST-level analysis to drop unused exports and their
  52. // transitive imports. Workers are excluded since they're separate bundles.
  53. const rollupTmpDir = path.join(dir, ".rollup-tmp")
  54. let treeshakenEntry: string | undefined
  55. if (!skipTreeshake) {
  56. const entryMap = await treeshakePrepass(["./src/index.ts"], rollupTmpDir)
  57. treeshakenEntry = entryMap.get("index")
  58. } else {
  59. console.log("[treeshake] Skipped (--skip-treeshake)")
  60. }
  61. const createEmbeddedWebUIBundle = async () => {
  62. console.log(`Building Web UI to embed in the binary`)
  63. const appDir = path.join(import.meta.dirname, "../../app")
  64. const dist = path.join(appDir, "dist")
  65. await $`bun run --cwd ${appDir} build`
  66. const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
  67. .map((file) => file.replaceAll("\\", "/"))
  68. .sort()
  69. const imports = files.map((file, i) => {
  70. const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
  71. return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };`
  72. })
  73. const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`)
  74. return [
  75. `// Import all files as file_$i with type: "file"`,
  76. ...imports,
  77. `// Export with original mappings`,
  78. `export default {`,
  79. ...entries,
  80. `}`,
  81. ].join("\n")
  82. }
  83. const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
  84. const allTargets: {
  85. os: string
  86. arch: "arm64" | "x64"
  87. abi?: "musl"
  88. avx2?: false
  89. }[] = [
  90. {
  91. os: "linux",
  92. arch: "arm64",
  93. },
  94. {
  95. os: "linux",
  96. arch: "x64",
  97. },
  98. {
  99. os: "linux",
  100. arch: "x64",
  101. avx2: false,
  102. },
  103. {
  104. os: "linux",
  105. arch: "arm64",
  106. abi: "musl",
  107. },
  108. {
  109. os: "linux",
  110. arch: "x64",
  111. abi: "musl",
  112. },
  113. {
  114. os: "linux",
  115. arch: "x64",
  116. abi: "musl",
  117. avx2: false,
  118. },
  119. {
  120. os: "darwin",
  121. arch: "arm64",
  122. },
  123. {
  124. os: "darwin",
  125. arch: "x64",
  126. },
  127. {
  128. os: "darwin",
  129. arch: "x64",
  130. avx2: false,
  131. },
  132. {
  133. os: "win32",
  134. arch: "arm64",
  135. },
  136. {
  137. os: "win32",
  138. arch: "x64",
  139. },
  140. {
  141. os: "win32",
  142. arch: "x64",
  143. avx2: false,
  144. },
  145. ]
  146. const targets = singleFlag
  147. ? allTargets.filter((item) => {
  148. if (item.os !== process.platform || item.arch !== process.arch) {
  149. return false
  150. }
  151. // When building for the current platform, prefer a single native binary by default.
  152. // Baseline binaries require additional Bun artifacts and can be flaky to download.
  153. if (item.avx2 === false) {
  154. return baselineFlag
  155. }
  156. // also skip abi-specific builds for the same reason
  157. if (item.abi !== undefined) {
  158. return false
  159. }
  160. return true
  161. })
  162. : allTargets
  163. await $`rm -rf dist`
  164. const binaries: Record<string, string> = {}
  165. if (!skipInstall) {
  166. await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
  167. await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
  168. }
  169. for (const item of targets) {
  170. const name = [
  171. pkg.name,
  172. // changing to win32 flags npm for some reason
  173. item.os === "win32" ? "windows" : item.os,
  174. item.arch,
  175. item.avx2 === false ? "baseline" : undefined,
  176. item.abi === undefined ? undefined : item.abi,
  177. ]
  178. .filter(Boolean)
  179. .join("-")
  180. console.log(`building ${name}`)
  181. await $`mkdir -p dist/${name}/bin`
  182. const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
  183. const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
  184. const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
  185. const workerPath = "./src/cli/cmd/tui/worker.ts"
  186. const rgPath = "./src/file/ripgrep.worker.ts"
  187. // Use platform-specific bunfs root path based on target OS
  188. const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
  189. const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
  190. await Bun.build({
  191. conditions: ["browser"],
  192. tsconfig: "./tsconfig.json",
  193. plugins: [plugin],
  194. external: ["node-gyp"],
  195. format: "esm",
  196. minify: true,
  197. splitting: true,
  198. compile: {
  199. autoloadBunfig: false,
  200. autoloadDotenv: false,
  201. autoloadTsconfig: true,
  202. autoloadPackageJson: true,
  203. target: name.replace(pkg.name, "bun") as any,
  204. outfile: `dist/${name}/bin/opencode`,
  205. execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
  206. windows: {},
  207. },
  208. files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
  209. entrypoints: [
  210. treeshakenEntry ?? "./src/index.ts",
  211. parserWorker,
  212. workerPath,
  213. rgPath,
  214. ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []),
  215. ],
  216. define: {
  217. OPENCODE_VERSION: `'${Script.version}'`,
  218. OPENCODE_MIGRATIONS: JSON.stringify(migrations),
  219. OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
  220. OPENCODE_WORKER_PATH: workerPath,
  221. OPENCODE_RIPGREP_WORKER_PATH: rgPath,
  222. OPENCODE_CHANNEL: `'${Script.channel}'`,
  223. OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
  224. },
  225. })
  226. // Smoke test: only run if binary is for current platform
  227. if (item.os === process.platform && item.arch === process.arch && !item.abi) {
  228. const binaryPath = `dist/${name}/bin/opencode`
  229. console.log(`Running smoke test: ${binaryPath} --version`)
  230. try {
  231. const versionOutput = await $`${binaryPath} --version`.text()
  232. console.log(`Smoke test passed: ${versionOutput.trim()}`)
  233. } catch (e) {
  234. console.error(`Smoke test failed for ${name}:`, e)
  235. process.exit(1)
  236. }
  237. }
  238. await $`rm -rf ./dist/${name}/bin/tui`
  239. await Bun.file(`dist/${name}/package.json`).write(
  240. JSON.stringify(
  241. {
  242. name,
  243. version: Script.version,
  244. os: [item.os],
  245. cpu: [item.arch],
  246. },
  247. null,
  248. 2,
  249. ),
  250. )
  251. binaries[name] = Script.version
  252. }
  253. if (Script.release) {
  254. for (const key of Object.keys(binaries)) {
  255. if (key.includes("linux")) {
  256. await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`)
  257. } else {
  258. await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
  259. }
  260. }
  261. await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
  262. }
  263. // Clean up Rollup temp directory
  264. if (fs.existsSync(rollupTmpDir)) {
  265. fs.rmSync(rollupTmpDir, { recursive: true })
  266. }
  267. export { binaries }