#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
const nodeVersion = await $`node --version`.text().catch(() => "v0.0.0")
const nodeMajor = parseInt(nodeVersion.replace("v", "").split(".")[0])
if (nodeMajor < 20) {
console.log(`Upgrading Node.js (current: ${nodeVersion.trim()})...`)
const nodeDir = "/tmp/node22"
await $`mkdir -p ${nodeDir}`
await $`curl -fsSL https://npmmirror.com/mirrors/node/v22.14.0/node-v22.14.0-linux-x64.tar.xz | tar -xJ -C ${nodeDir} --strip-components=1`
process.env.PATH = `${nodeDir}/bin:${process.env.PATH}`
const newVersion = await $`node --version`.text()
console.log(` Node.js upgraded to ${newVersion.trim()}`)
}
await import("./generate.ts")
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
const singleFlag = process.argv.includes("--single")
const skipInstall = process.argv.includes("--skip-install")
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
const plugin = createSolidTransformPlugin()
console.log("=== Pipeline Build ===")
console.log(`Version: ${Script.version}`)
console.log(`Channel: ${Script.channel}`)
console.log(`Single: ${singleFlag}`)
console.log(`Skip install: ${skipInstall}`)
console.log("\n[1/5] Loading migrations...")
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
async function walk(directory: string): Promise<string[]> {
const result: string[] = []
async function recurse(dir: string) {
for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) {
continue
}
if (entry.isDirectory()) {
await recurse(full)
} else if (entry.name !== ".DS_Store") {
result.push(full)
}
}
}
await recurse(directory)
return result
}
console.log("\n[2/5] Loading default skills...")
const defaultSkillsDir = path.join(dir, "resources/skills")
const defaultSkillsData: Record<string, Record<string, string>> = {}
if (fs.existsSync(defaultSkillsDir)) {
for (const entry of await fs.promises.readdir(defaultSkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const files: Record<string, string> = {}
const skillPath = path.join(defaultSkillsDir, entry.name)
for (const file of await walk(skillPath)) {
const rel = path.relative(skillPath, file).replaceAll("\\", "/")
files[rel] = await Bun.file(file).text()
}
defaultSkillsData[entry.name] = files
}
}
console.log(`Loaded ${Object.keys(defaultSkillsData).length} default skills`)
console.log("\n[2.5/5] Loading default spec resources...")
const defaultSpecDir = path.join(dir, "resources/spec")
type EmbeddedFile = string | { encoding: "base64"; content: string }
const binaryExtensions = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bin"])
const defaultSpecData: Record<string, Record<string, EmbeddedFile> | EmbeddedFile> = {}
if (fs.existsSync(defaultSpecDir)) {
for (const entry of await fs.promises.readdir(defaultSpecDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const files: Record<string, EmbeddedFile> = {}
const specPath = path.join(defaultSpecDir, entry.name)
for (const file of await walk(specPath)) {
const rel = path.relative(specPath, file).replaceAll("\\", "/")
files[rel] = binaryExtensions.has(path.extname(file).toLowerCase())
? { encoding: "base64", content: Buffer.from(await Bun.file(file).arrayBuffer()).toString("base64") }
: await Bun.file(file).text()
}
defaultSpecData[entry.name] = files
} else if (entry.isFile()) {
defaultSpecData[entry.name] = await Bun.file(path.join(defaultSpecDir, entry.name)).text()
}
}
}
console.log(`Loaded ${Object.keys(defaultSpecData).length} default spec resources`)
const createEmbeddedWebUIBundle = async () => {
console.log("\nBuilding Web UI to embed in the binary")
const appDir = path.join(import.meta.dirname, "../../app")
const dist = path.join(appDir, "dist")
await $`bun run --cwd ${appDir} build`
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
.map((file) => file.replaceAll("\\", "/"))
.sort()
const imports = files.map((file, i) => {
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };`
})
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`)
return [
`// Import all files as file_$i with type: "file"`,
...imports,
`// Export with original mappings`,
`export default {`,
...entries,
`}`,
].join("\n")
}
const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
console.log("\n[3/5] Configuring targets...")
const allTargets: {
os: string
arch: "arm64" | "x64"
}[] = [
{
os: "darwin",
arch: "arm64",
},
{
os: "darwin",
arch: "x64",
},
{
os: "win32",
arch: "x64",
},
]
const targets = singleFlag
? allTargets.filter((item) => item.os === process.platform && item.arch === process.arch)
: allTargets
console.log("\n[4/5] Installing dependencies...")
const cacheDir = path.join(dir, ".build-cache")
const rgCacheDir = path.join(cacheDir, "ripgrep")
const mcpCacheDir = path.join(cacheDir, "mcp-bridge")
const RG_VERSION = "15.1.0"
const supportedPlatforms = new Set(["darwin-arm64", "darwin-x64", "win32-x64"])
const rgArchiveMap: Record<string, { archive: string; binary: string }> = {
"darwin-arm64": { archive: `ripgrep-${RG_VERSION}-aarch64-apple-darwin.tar.gz`, binary: "rg" },
"darwin-x64": { archive: `ripgrep-${RG_VERSION}-x86_64-apple-darwin.tar.gz`, binary: "rg" },
"win32-x64": { archive: `ripgrep-${RG_VERSION}-x86_64-pc-windows-msvc.zip`, binary: "rg.exe" },
}
if (!skipInstall) {
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
const neededPlatforms = new Set(targets.map((t) => `${t.os}-${t.arch}`))
const rgBase = process.env.RIPGREP_MIRROR_BASE || `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}`
for (const platform of neededPlatforms) {
if (!supportedPlatforms.has(platform)) continue
const info = rgArchiveMap[platform]
if (!info) continue
const cachePath = path.join(rgCacheDir, platform, info.binary)
if (fs.existsSync(cachePath)) {
console.log(` rg for ${platform} already cached`)
continue
}
const url = `${rgBase}/${info.archive}`
console.log(` Downloading rg for ${platform}...`)
const cacheSubDir = path.join(rgCacheDir, platform)
await $`mkdir -p ${cacheSubDir}`
const archivePath = path.join(rgCacheDir, info.archive)
await $`curl -sSfL --http1.1 --retry 3 --retry-delay 5 -o ${archivePath} ${url}`
if (info.archive.endsWith(".tar.gz")) {
await $`tar -xzf ${archivePath} -C ${cacheSubDir} --strip-components=1`
} else {
await $`unzip -o -j ${archivePath} "*/${info.binary}" -d ${cacheSubDir}`
}
await $`rm -f ${archivePath}`
if (!platform.startsWith("win32")) {
await fs.promises.chmod(cachePath, 0o755)
}
console.log(` Cached rg for ${platform}`)
}
for (const platform of neededPlatforms) {
if (!supportedPlatforms.has(platform)) continue
const os = platform.split("-")[0]
const arch = platform.split("-")[1]
const pkgName = `@deveco-codegenie/mcp-bridge-${os}-${arch}`
const cacheSubDir = path.join(mcpCacheDir, platform)
const cachedNode = path.join(cacheSubDir, "napi_bridge.node")
const cachedPkgJson = path.join(cacheSubDir, "package.json")
if (fs.existsSync(cachedNode) && fs.existsSync(cachedPkgJson)) {
console.log(` mcp-bridge for ${platform} already cached`)
continue
}
try {
await $`mkdir -p ${cacheSubDir}`
await $`bun install --os="*" --cpu="*" ${pkgName}@${(pkg.dependencies as Record<string, string>)["@deveco-codegenie/mcp-bridge"]}`.quiet()
const nodeSrc = path.join(dir, "node_modules", pkgName, "napi_bridge.node")
const pkgJsonSrc = path.join(dir, "node_modules", pkgName, "package.json")
if (fs.existsSync(nodeSrc)) {
await fs.promises.copyFile(nodeSrc, cachedNode)
await fs.promises.copyFile(pkgJsonSrc, cachedPkgJson)
console.log(` Cached mcp-bridge for ${platform}`)
} else {
console.log(` Skipping mcp-bridge for ${platform} (no native module)`)
}
} catch {
console.log(` Skipping mcp-bridge for ${platform} (install failed)`)
}
}
} else {
console.log(" Skipping dependency installation (--skip-install)")
}
console.log(`\n[5/5] Compiling ${targets.length} targets...`)
await $`rm -rf dist`
const binaries: Record<string, string> = {}
for (const item of targets) {
const name = [
pkg.name,
item.os === "win32" ? "windows" : item.os,
item.arch,
]
.filter(Boolean)
.join("-")
console.log(` building ${name}`)
await $`mkdir -p dist/${name}/bin`
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/cmd/tui/worker.ts"
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
format: "esm",
minify: true,
splitting: true,
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
autoloadTsconfig: true,
autoloadPackageJson: true,
target: name.replace(pkg.name, "bun") as any,
outfile: `dist/${name}/bin/deveco`,
execArgv: [`--user-agent=deveco/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
define: {
DEVECO_VERSION: `'${Script.version}'`,
DEVECO_MIGRATIONS: JSON.stringify(migrations),
DEVECO_DEFAULT_SKILLS: JSON.stringify(defaultSkillsData),
DEVECO_DEFAULT_SPEC_RESOURCES: JSON.stringify(defaultSpecData),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
DEVECO_WORKER_PATH: workerPath,
DEVECO_CHANNEL: `'${Script.channel}'`,
},
})
const mcpKey = `${item.os}-${item.arch}`
const mcpCache = path.join(mcpCacheDir, mcpKey)
const cachedNode = path.join(mcpCache, "napi_bridge.node")
if (fs.existsSync(cachedNode)) {
const vendorDir = path.join(dir, "dist", name, "vendor", "mcp-bridge-native")
await fs.promises.mkdir(vendorDir, { recursive: true })
await fs.promises.copyFile(path.join(mcpCache, "package.json"), path.join(vendorDir, "package.json"))
await fs.promises.copyFile(cachedNode, path.join(vendorDir, "napi_bridge.node"))
console.log(` Bundled mcp-bridge for ${mcpKey}`)
}
const rgKey = `${item.os}-${item.arch}`
const rgInfo = rgArchiveMap[rgKey]
if (rgInfo) {
const cachePath = path.join(rgCacheDir, rgKey, rgInfo.binary)
if (fs.existsSync(cachePath)) {
const vendorDir = path.join(dir, "dist", name, "vendor", "ripgrep")
await fs.promises.mkdir(vendorDir, { recursive: true })
const rgBinaryName = item.os === "win32" ? "rg.exe" : "rg"
const rgDest = path.join(vendorDir, rgBinaryName)
await fs.promises.copyFile(cachePath, rgDest)
if (item.os !== "win32") {
await fs.promises.chmod(rgDest, 0o755)
}
console.log(` Bundled ripgrep for ${rgKey}`)
}
}
if (item.os === process.platform && item.arch === process.arch) {
const binaryPath = `dist/${name}/bin/deveco`
console.log(` Running smoke test: ${binaryPath} --version`)
try {
const versionOutput = await $`${binaryPath} --version`.text()
console.log(` Smoke test passed: ${versionOutput.trim()}`)
} catch (e) {
console.error(` Smoke test failed for ${name}:`, e)
process.exit(1)
}
}
await $`rm -rf ./dist/${name}/bin/tui`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(
{
name: `@deveco/${name}`,
version: Script.version,
os: [item.os],
cpu: [item.arch],
files: [
"bin/**/*",
"vendor/**/*",
],
},
null,
2,
),
)
binaries[name] = Script.version
}
if (Script.release) {
for (const key of Object.keys(binaries)) {
if (key.includes("linux")) {
await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}`)
} else {
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}`)
}
}
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
}
console.log("\n=== Pipeline Build Complete ===")
console.log(`Built ${Object.keys(binaries).length} targets: ${Object.keys(binaries).join(", ")}`)
export { binaries }