Files
qqq-cli/scripts/build-binaries.mjs
james.maesandClaude Fable 5.1 1bc4ddd0be release: qqq 1.0.0 (#11)
qqq 1.0.0: the command-line client and the full-screen terminal UI (qqq ui) for QQQ applications over the UI API (/qqq/v1), with @qrun-io/qqq-cli 1.0.0, @qrun-io/qqq-core 1.0.0, @qrun-io/qqq-ui-api-client 1.8.0 and @qrun-io/qqq-ui-api-conformance 1.6.0 going final together. Squash of the release/1.0.0 branch at 79ba68e (the branch is kept with the full signed history; the tags v1.8.0 and cli-v1.0.0 point at that commit, which is what the released binaries were built from). See CHANGELOG.md [1.0.0] and docs/release-notes/1.0.0.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:49:06 -06:00

217 lines
11 KiB
JavaScript

#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Builds single-file executables of the qqq CLI with `bun build --compile`: one per target, written
* to dist/qqq-<os>-<arch>[.exe] with a SHA256SUMS file beside them (source maps go to dist/sourcemaps/,
* so the release glob dist/qqq-* does not upload them).
*
* The bundle is the built CLI (packages/cli/dist/main.js) with the workspace packages
* (@qrun-io/qqq-ui-api-client, @qrun-io/qqq-core and @qrun-io/qqq-ui-api-conformance, from their built
* dist) and every npm dependency inlined. The terminal UI is bundled too unless --cli-only. The operations index
* generated from the OpenAPI document (packages/core/src/api/operations.ts) is compiled in; this
* script first checks that it lists exactly the operations of packages/ui-api-client/openapi/qqq-v1.json,
* so a binary can never ship a stale index. The CLI version comes from packages/cli/package.json
* (it must equal packages/cli/src/version.ts) and the git commit is stamped into `qqq --version`
* through QQQ_BUILD_SHA and QQQ_BUILD_TARGET.
*
* x64 targets use Bun's baseline (pre-AVX2) runtime so the binaries also run on older CPUs and
* under Rosetta 2 or QEMU emulation.
*
* By default the binary also carries the terminal UI (packages/tui: OpenTUI's native library and
* the React renderer): the entry is scripts/binary-with-tui.ts, `qqq ui` runs in the same process,
* and `qqq --version` says "with ui". OpenTUI's native package for every target is installed first
* (bun install --os='*' --cpu='*' in packages/tui); Linux targets pin OPENTUI_LIBC to their libc.
*
* Usage: node scripts/build-binaries.mjs [--targets darwin-arm64,linux-x64,...] [--host] [--musl]
* [--cli-only] [--no-build] [--out dist]
* --targets comma-separated subset of the default targets (and the musl ones)
* --host only the target of this machine
* --musl also build linux-x64-musl and linux-arm64-musl (best effort)
* --cli-only leave the terminal UI out (the default bundles it; --with-tui is accepted for scripts)
* --no-build skip `npm run build` (the workspace packages must already be built)
* Needs Bun 1.4 or later on the PATH; cross-compiling downloads each target's Bun runtime once.
*/
import { execFileSync, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const TARGETS = {
'darwin-arm64': 'bun-darwin-arm64',
'darwin-x64': 'bun-darwin-x64-baseline',
'linux-x64': 'bun-linux-x64-baseline',
'linux-arm64': 'bun-linux-arm64',
'windows-x64': 'bun-windows-x64-baseline',
}
/** OpenTUI's native package per target (not needed with --cli-only). */
const OPENTUI_NATIVE = {
'darwin-arm64': 'core-darwin-arm64', 'darwin-x64': 'core-darwin-x64', 'linux-x64': 'core-linux-x64',
'linux-arm64': 'core-linux-arm64', 'windows-x64': 'core-win32-x64', 'linux-x64-musl': 'core-linux-x64-musl',
'linux-arm64-musl': 'core-linux-arm64-musl',
}
const MUSL_TARGETS = {
'linux-x64-musl': 'bun-linux-x64-musl-baseline',
'linux-arm64-musl': 'bun-linux-arm64-musl',
}
function parseArgs(argv) {
const args = { targets: undefined, host: false, musl: false, tui: true, build: true, out: 'dist' }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--targets') args.targets = argv[++i].split(',').map((t) => t.trim()).filter(Boolean)
else if (a === '--host') args.host = true
else if (a === '--musl') args.musl = true
else if (a === '--with-tui') args.tui = true
else if (a === '--cli-only') args.tui = false
else if (a === '--no-build') args.build = false
else if (a === '--out') args.out = argv[++i]
else if (a === '-h' || a === '--help') {
console.log('Usage: node scripts/build-binaries.mjs [--targets a,b] [--host] [--musl] [--cli-only] [--no-build] [--out dist]')
process.exit(0)
} else fail(`unknown option ${a}`)
}
return args
}
function fail(message) {
console.error(`build-binaries: ${message}`)
process.exit(1)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', ...options })
if (result.error) fail(`${command}: ${result.error.message}`)
if (result.status !== 0) fail(`${command} ${args.join(' ')} exited ${result.status}`)
}
function hostTarget() {
const os = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform]
const arch = { arm64: 'arm64', x64: 'x64' }[process.arch]
if (!os || !arch) fail(`no target for this host (${process.platform}-${process.arch})`)
return `${os}-${arch}`
}
/** The CLI version from package.json, checked against the constant compiled into the CLI. */
function cliVersion() {
const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version
const source = readFileSync(join(root, 'packages/cli/src/version.ts'), 'utf8')
const constant = source.match(/export const CLI_VERSION = '([^']+)'/)?.[1]
if (constant !== version) fail(`packages/cli/src/version.ts says ${constant}, packages/cli/package.json says ${version}`)
return version
}
/** The commit being built, with -dirty when the tree has changes. */
function gitSha() {
try {
const sha = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
const dirty = execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: root, encoding: 'utf8' }).trim()
return dirty ? `${sha}-dirty` : sha
} catch {
return 'unknown'
}
}
/** Fails unless the compiled-in operations index lists exactly the document's operations. */
function checkOperationsIndex() {
const spec = JSON.parse(readFileSync(join(root, 'packages/ui-api-client/openapi/qqq-v1.json'), 'utf8'))
const fromSpec = new Set()
for (const item of Object.values(spec.paths ?? {})) {
for (const op of Object.values(item)) if (op && typeof op === 'object' && op.operationId) fromSpec.add(op.operationId)
}
const index = readFileSync(join(root, 'packages/core/src/api/operations.ts'), 'utf8')
const fromIndex = new Set([...index.matchAll(/\{"id":"([^"]+)"/g)].map((m) => m[1]))
const missing = [...fromSpec].filter((id) => !fromIndex.has(id))
const extra = [...fromIndex].filter((id) => !fromSpec.has(id))
if (missing.length || extra.length) {
fail(`operations index is stale (run npm run generate): missing ${missing.join(', ') || 'none'}; extra ${extra.join(', ') || 'none'}`)
}
return { operations: fromSpec.size, apiVersion: spec.info?.version }
}
function megabytes(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const args = parseArgs(process.argv.slice(2))
const all = { ...TARGETS, ...MUSL_TARGETS }
let names = args.host ? [hostTarget()] : args.targets ?? Object.keys(TARGETS)
if (args.musl && !args.targets) names = [...names, ...Object.keys(MUSL_TARGETS)]
for (const name of names) if (!all[name]) fail(`unknown target ${name} (known: ${Object.keys(all).join(', ')})`)
const bun = spawnSync('bun', ['--version'], { encoding: 'utf8' })
if (bun.status !== 0) fail('bun is not on the PATH (https://bun.sh); it is only needed to build binaries')
const version = cliVersion()
const sha = gitSha()
const spec = checkOperationsIndex()
console.log(`qqq ${version} (${sha}), bun ${bun.stdout.trim()}, UI API document ${spec.apiVersion} with ${spec.operations} operations`)
if (args.build) run('npm', ['run', 'build', '--silent'], { shell: process.platform === 'win32' })
for (const entry of ['packages/ui-api-client/dist/index.js', 'packages/core/dist/index.js', 'packages/cli/dist/main.js']) {
if (!existsSync(join(root, entry))) fail(`${entry} is missing; run npm run build`)
}
if (args.tui) {
// the TUI's own dependencies, with OpenTUI's native package for every platform
run('bun', ['install', '--frozen-lockfile', '--os=*', '--cpu=*'], { cwd: join(root, 'packages/tui'), stdio: ['ignore', 'ignore', 'inherit'] })
for (const name of names) {
const native = join(root, 'packages/tui/node_modules/@opentui', OPENTUI_NATIVE[name])
if (!existsSync(native)) fail(`OpenTUI's native package @opentui/${OPENTUI_NATIVE[name]} is missing for ${name}`)
}
}
const outDir = resolve(root, args.out)
mkdirSync(outDir, { recursive: true })
const built = []
for (const name of names) {
const file = `qqq-${name}${name.startsWith('windows') ? '.exe' : ''}`
const outfile = join(outDir, file)
const started = Date.now()
const tuiDefines = !args.tui ? [] : [
'--define=QQQ_BUILD_UI=true',
...(name.startsWith('linux') ? [`--define=process.env.OPENTUI_LIBC=${JSON.stringify(name.endsWith('-musl') ? 'musl' : 'glibc')}`] : []),
]
run('bun', [
'build', args.tui ? 'scripts/binary-with-tui.ts' : 'packages/cli/dist/main.js',
'--compile', `--target=${all[name]}`, `--outfile=${outfile}`,
'--minify-syntax', '--minify-whitespace', '--sourcemap',
`--define=QQQ_BUILD_SHA=${JSON.stringify(sha)}`,
`--define=QQQ_BUILD_TARGET=${JSON.stringify(name)}`,
...tuiDefines,
], { stdio: ['ignore', 'ignore', 'inherit'] })
// bun writes the source map beside the binary; keep it out of the release files (dist/qqq-*)
if (existsSync(`${outfile}.map`)) {
mkdirSync(join(outDir, 'sourcemaps'), { recursive: true })
renameSync(`${outfile}.map`, join(outDir, 'sourcemaps', `${file}.map`))
}
const bytes = readFileSync(outfile)
built.push({ file, size: statSync(outfile).size, sha256: createHash('sha256').update(bytes).digest('hex'), seconds: (Date.now() - started) / 1000 })
}
// keep the sums of binaries built by an earlier run that this run did not rebuild
const sums = new Map()
const sumsFile = join(outDir, 'SHA256SUMS')
if (existsSync(sumsFile)) {
for (const line of readFileSync(sumsFile, 'utf8').split('\n')) {
const [hash, file] = line.split(/\s+/)
if (hash && file && existsSync(join(outDir, file))) sums.set(file, hash)
}
}
for (const b of built) sums.set(b.file, b.sha256)
writeFileSync(sumsFile, [...sums].sort(([a], [b]) => a.localeCompare(b)).map(([file, hash]) => `${hash} ${file}`).join('\n') + '\n')
console.log('')
for (const b of built) console.log(`${b.file.padEnd(26)} ${megabytes(b.size).padStart(9)} ${b.seconds.toFixed(1)}s sha256 ${b.sha256.slice(0, 16)}`)
console.log(`${args.out}/SHA256SUMS`)
const host = `qqq-${hostTarget()}${process.platform === 'win32' ? '.exe' : ''}`
if (built.some((b) => b.file === host)) {
const smoke = spawnSync(join(outDir, host), ['--version'], { encoding: 'utf8' })
if (smoke.status !== 0 || !smoke.stdout.includes(`qqq ${version}`)) fail(`${host} --version failed: ${smoke.stderr || smoke.stdout}`)
console.log(`\n${host} --version:\n${smoke.stdout.trim()}`)
}