diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75bbc915cd..84594be2e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,19 +59,17 @@ jobs: matrix: include: # Each target builds ONE binary in parallel (~5 min each) - # Index matches allTargets array in build.ts + # Index matches allTargets array in build.ts. Targets without a + # @altimateai/altimate-core NAPI prebuild (linux-*-musl, win32-arm64) + # are intentionally excluded — see allTargets in build.ts. - { index: 0, name: "linux-arm64" } - { index: 1, name: "linux-x64" } - { index: 2, name: "linux-x64-baseline" } - - { index: 3, name: "linux-arm64-musl" } - - { index: 4, name: "linux-x64-musl" } - - { index: 5, name: "linux-x64-baseline-musl" } - - { index: 6, name: "darwin-arm64" } - - { index: 7, name: "darwin-x64" } - - { index: 8, name: "darwin-x64-baseline" } - - { index: 9, name: "win32-arm64" } - - { index: 10, name: "win32-x64" } - - { index: 11, name: "win32-x64-baseline" } + - { index: 3, name: "darwin-arm64" } + - { index: 4, name: "darwin-x64" } + - { index: 5, name: "darwin-x64-baseline" } + - { index: 6, name: "win32-x64" } + - { index: 7, name: "win32-x64-baseline" } steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -106,17 +104,22 @@ jobs: - name: Smoke test binary if: matrix.name == 'linux-x64' run: | - BINARY=$(find packages/opencode/dist -name altimate -type f | head -1) + # Resolve to an absolute path before we cd away from the workspace. + BINARY=$(find "$(pwd)/packages/opencode/dist" -name altimate -type f | head -1) if [ -z "$BINARY" ]; then echo "::error::No binary found in dist/" exit 1 fi chmod +x "$BINARY" - # Set NODE_PATH so the binary can resolve external NAPI modules - # (mirrors what the npm bin wrapper does at runtime) - NODE_PATH="$(pwd)/packages/opencode/node_modules:$(pwd)/node_modules" "$BINARY" --version - echo "Smoke test passed: binary starts and prints version" + # Run with NO pre-set NODE_PATH AND from a directory with no + # node_modules anywhere upward. Bun's compiled binary would + # otherwise walk the workspace tree for node_modules and resolve + # altimate-core that way — the test would pass for the wrong + # reason if the staged shim ever silently misses. + cd "${RUNNER_TEMP:-/tmp}" + env -u NODE_PATH "$BINARY" --version + echo "Smoke test passed: standalone binary starts hermetically" - name: Upload build artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -256,13 +259,18 @@ jobs: # compile-time checks miss (e.g. missing NAPI externals like v0.5.10). - name: Pre-publish smoke test run: | - BINARY=$(find packages/opencode/dist -path '*altimate-code-linux-x64/bin/altimate' -type f | head -1) + # Resolve to an absolute path before we cd away from the workspace. + BINARY=$(find "$(pwd)/packages/opencode/dist" -path '*altimate-code-linux-x64/bin/altimate' -type f | head -1) if [ -z "$BINARY" ]; then echo "::error::No linux-x64 binary found in artifacts — cannot verify release" exit 1 else chmod +x "$BINARY" - NODE_PATH="$(pwd)/packages/opencode/node_modules:$(pwd)/node_modules" "$BINARY" --version + # No NODE_PATH AND hermetic cwd: see the build-time smoke test + # comment for why this matters. The binary must start without + # walking the workspace for node_modules. + cd "${RUNNER_TEMP:-/tmp}" + env -u NODE_PATH "$BINARY" --version echo "Pre-publish smoke test passed" fi diff --git a/install b/install index 1f425ed049..e748eb1be5 100755 --- a/install +++ b/install @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -APP=altimate-code +APP=altimate MUTED='\033[0;2m' RED='\033[0;31m' @@ -22,7 +22,7 @@ Options: Examples: curl -fsSL https://altimate.ai/install | bash curl -fsSL https://altimate.ai/install | bash -s -- --version 1.0.180 - ./install --binary /path/to/altimate-code + ./install --binary /path/to/altimate EOF } @@ -65,7 +65,7 @@ while [[ $# -gt 0 ]]; do esac done -INSTALL_DIR=$HOME/.altimate-code/bin +INSTALL_DIR=$HOME/.altimate/bin mkdir -p "$INSTALL_DIR" # If --binary is provided, skip all download/detection logic @@ -121,12 +121,30 @@ else fi if command -v ldd >/dev/null 2>&1; then - if ldd --version 2>&1 | grep -qi musl; then + # ldd --version exits non-zero on musl by design. With `set -o pipefail` + # (line 2), a pipeline `ldd --version 2>&1 | grep -qi musl` would inherit + # ldd's failure and the if-block would never fire — defeating musl + # detection on every non-Alpine musl distro (Void, Adelie, custom builds). + # Capture output first, then grep. + ldd_out="$(ldd --version 2>&1 || true)" + if printf '%s' "$ldd_out" | grep -qi musl; then is_musl=true fi fi fi + # @altimateai/altimate-core has no NAPI prebuild for musl. Without a + # musl-shaped archive, the install would 404 silently (without --fail + # curl writes the 404 HTML to disk, and tar then dies "not in gzip + # format"). Fail fast with an actionable message instead. + if [ "$is_musl" = "true" ]; then + echo -e "${RED}Alpine Linux (musl) is not currently supported by the standalone install.${NC}" + echo -e "${MUTED}altimate-core has no NAPI prebuild for musl yet. Workarounds:${NC}" + echo -e " • apk add gcompat ${MUTED}# run glibc binaries on Alpine${NC}" + echo -e " • Use npm: npm install -g altimate-code" + exit 1 + fi + needs_baseline=false if [ "$arch" = "x64" ]; then if [ "$os" = "linux" ]; then @@ -161,11 +179,14 @@ else if [ "$needs_baseline" = "true" ]; then target="$target-baseline" fi - if [ "$is_musl" = "true" ]; then - target="$target-musl" - fi + # is_musl=true would have exited above — no musl target suffix is constructed. filename="$APP-$target$archive_ext" + # Windows release archives ship altimate.exe, every other target ships altimate. + binary_name="$APP" + if [ "$os" = "windows" ]; then + binary_name="$APP.exe" + fi if [ "$os" = "linux" ]; then @@ -219,11 +240,11 @@ print_message() { } check_version() { - if command -v altimate-code >/dev/null 2>&1; then - altimate_code_path=$(which altimate-code) + if command -v altimate >/dev/null 2>&1; then + altimate_path=$(which altimate) ## Check the installed version - installed_version=$(altimate-code --version 2>/dev/null || echo "") + installed_version=$(altimate --version 2>/dev/null || echo "") if [[ "$installed_version" != "$specific_version" ]]; then print_message info "${MUTED}Installed version: ${NC}$installed_version." @@ -275,7 +296,7 @@ download_with_progress() { fi local tmp_dir=${TMPDIR:-/tmp} - local basename="${tmp_dir}/altimate_code_install_$$" + local basename="${tmp_dir}/altimate_install_$$" local tracefile="${basename}.trace" rm -f "$tracefile" @@ -287,7 +308,9 @@ download_with_progress() { trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN ( - curl --trace-ascii "$tracefile" -s -L -o "$output" "$url" + # --fail so HTTP errors (e.g. 404 on an unknown target archive) become + # a non-zero exit instead of silently writing the error page to disk. + curl --fail --trace-ascii "$tracefile" -s -L -o "$output" "$url" ) & local curl_pid=$! @@ -325,13 +348,14 @@ download_with_progress() { } download_and_install() { - print_message info "\n${MUTED}Installing ${NC}altimate-code ${MUTED}version: ${NC}$specific_version" - local tmp_dir="${TMPDIR:-/tmp}/altimate_code_install_$$" + print_message info "\n${MUTED}Installing ${NC}altimate ${MUTED}version: ${NC}$specific_version" + local tmp_dir="${TMPDIR:-/tmp}/altimate_install_$$" mkdir -p "$tmp_dir" if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then - # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails - curl -# -L -o "$tmp_dir/$filename" "$url" + # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails. + # --fail so 404s exit non-zero instead of writing the error page to $tmp_dir/$filename. + curl --fail -# -L -o "$tmp_dir/$filename" "$url" fi if [ "$os" = "linux" ]; then @@ -340,15 +364,19 @@ download_and_install() { unzip -q "$tmp_dir/$filename" -d "$tmp_dir" fi - mv "$tmp_dir/altimate-code" "$INSTALL_DIR" - chmod 755 "${INSTALL_DIR}/altimate-code" + mv "$tmp_dir/$binary_name" "${INSTALL_DIR}/$binary_name" + chmod 755 "${INSTALL_DIR}/$binary_name" rm -rf "$tmp_dir" } install_from_binary() { - print_message info "\n${MUTED}Installing ${NC}altimate-code ${MUTED}from: ${NC}$binary_path" - cp "$binary_path" "${INSTALL_DIR}/altimate-code" - chmod 755 "${INSTALL_DIR}/altimate-code" + # --binary may point to either altimate or altimate.exe; preserve the + # caller's basename so the installed file matches what was supplied. + local dest_name + dest_name=$(basename "$binary_path") + print_message info "\n${MUTED}Installing ${NC}$dest_name ${MUTED}from: ${NC}$binary_path" + cp "$binary_path" "${INSTALL_DIR}/$dest_name" + chmod 755 "${INSTALL_DIR}/$dest_name" } if [ -n "$binary_path" ]; then @@ -366,9 +394,9 @@ add_to_path() { if grep -Fxq "$command" "$config_file"; then print_message info "Command already exists in $config_file, skipping write." elif [[ -w $config_file ]]; then - echo -e "\n# altimate-code" >> "$config_file" + echo -e "\n# altimate" >> "$config_file" echo "$command" >> "$config_file" - print_message info "${MUTED}Successfully added ${NC}altimate-code ${MUTED}to \$PATH in ${NC}$config_file" + print_message info "${MUTED}Successfully added ${NC}altimate ${MUTED}to \$PATH in ${NC}$config_file" else print_message warning "Manually add the directory to $config_file (or similar):" print_message info " $command" @@ -448,8 +476,8 @@ echo -e "" echo -e "" echo -e "${MUTED}To start:${NC}" echo -e "" -echo -e "cd ${MUTED}# Open your project directory${NC}" -echo -e "altimate-code ${MUTED}# Launch the interactive TUI${NC}" +echo -e "cd ${MUTED}# Open directory${NC}" +echo -e "altimate ${MUTED}# Run command${NC}" echo -e "" echo -e "${MUTED}For more information visit ${NC}https://altimate.ai" echo -e "" diff --git a/packages/opencode/bin/altimate b/packages/opencode/bin/altimate index b441d008db..3ae3597799 100755 --- a/packages/opencode/bin/altimate +++ b/packages/opencode/bin/altimate @@ -153,42 +153,51 @@ function supportsAvx2() { return false } +function isMusl() { + if (platform !== "linux") return false + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch { + // ignore + } + try { + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() + if (text.includes("musl")) return true + } catch { + // ignore + } + return false +} + +// @altimateai/altimate-core has no NAPI prebuild for musl or win32-arm64, +// and the altimate binary embeds altimate-core's .node file at build time. +// Hard-error early instead of letting findBinary() walk the whole tree and +// emit a misleading "package manager failed to install the right version" +// message — the right diagnosis is that these platforms aren't built. +if (isMusl()) { + console.error("altimate-code is not currently supported on Alpine Linux (musl).") + console.error("Workarounds:") + console.error(" • apk add gcompat # run glibc binaries on Alpine") + console.error(" • Use a glibc-based container (debian/ubuntu/alpine+gcompat)") + process.exit(1) +} +if (platform === "windows" && arch === "arm64") { + console.error("altimate-code is not currently built for Windows on ARM64.") + console.error("Run the x64 build under Windows ARM's x64 emulation, or use WSL.") + process.exit(1) +} + const names = (() => { const avx2 = supportsAvx2() const baseline = arch === "x64" && !avx2 if (platform === "linux") { - const musl = (() => { - try { - if (fs.existsSync("/etc/alpine-release")) return true - } catch { - // ignore - } - - try { - const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) - const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() - if (text.includes("musl")) return true - } catch { - // ignore - } - - return false - })() - - if (musl) { - if (arch === "x64") { - if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] - return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] - } - return [`${base}-musl`, base] - } - if (arch === "x64") { - if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] - return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + if (baseline) return [`${base}-baseline`, base] + return [base, `${base}-baseline`] } - return [base, `${base}-musl`] + return [base] } if (arch === "x64") { diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index e9cc135b17..8dad623d7d 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -4,6 +4,7 @@ import { $ } from "bun" import fs from "fs" import path from "path" import { fileURLToPath } from "url" +import { createRequire } from "node:module" import solidPlugin from "@opentui/solid/bun-plugin" const __filename = fileURLToPath(import.meta.url) @@ -89,6 +90,17 @@ const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") +// Build targets are limited to the platforms for which @altimateai/altimate-core +// publishes a NAPI prebuild (see +// https://www.npmjs.com/package/@altimateai/altimate-core?activeTab=dependencies). +// Each per-target build embeds that prebuild's .node file directly into the Bun +// single-file executable so the release archive ships a single self-contained +// binary — no companion node_modules, no NODE_PATH wrapper. +// +// Combinations with no altimate-core prebuild are intentionally excluded: +// • linux-*-musl (no @altimateai/altimate-core-linux-*-musl) +// • win32-arm64 (no @altimateai/altimate-core-win32-arm64-msvc) +// If/when altimate-core ships prebuilds for those, add them back here. const allTargets: { os: string arch: "arm64" | "x64" @@ -108,22 +120,6 @@ const allTargets: { arch: "x64", avx2: false, }, - { - os: "linux", - arch: "arm64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - avx2: false, - }, { os: "darwin", arch: "arm64", @@ -137,10 +133,6 @@ const allTargets: { arch: "x64", avx2: false, }, - { - os: "win32", - arch: "arm64", - }, { os: "win32", arch: "x64", @@ -191,15 +183,59 @@ const targets = targetIndexFlag !== undefined ? allTargets.filter(t => targetsFlag.includes(t.os)) : allTargets +// Defense in depth: refuse to produce no artifacts at all, and refuse to build +// the glibc target on a musl host where the binary would crash at startup. +// +// Why it matters: +// - `--target-index=N` for an index that no longer exists (after the +// musl/win32-arm64 cull) silently yields an empty `targets` array. Without +// this guard the build "succeeds" with zero output and CI proceeds. +// - `--single` only filters on os/arch, not libc. On Alpine that matches +// `linux-x64` (glibc), produces a glibc binary that the musl host can't +// load, and dies later with a cryptic linker error. +if (targets.length === 0) { + const reason = targetIndexFlag !== undefined + ? `--target-index=${targetIndexFlag} is out of range (allTargets has ${allTargets.length} entries — musl/win32-arm64 were removed).` + : singleFlag + ? `--single found no entry in allTargets matching ${process.platform}/${process.arch} (host may be excluded — see allTargets at the top of build.ts).` + : targetsFlag + ? `--targets=${targetsFlag.join(",")} matched nothing in allTargets.` + : "allTargets is empty." + console.error(`error: no build targets selected. ${reason}`) + process.exit(1) +} + +if (singleFlag && process.platform === "linux") { + const isMuslHost = (() => { + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch {} + try { + const { spawnSync } = require("node:child_process") as typeof import("node:child_process") + const r = spawnSync("ldd", ["--version"], { encoding: "utf8" }) + const text = ((r.stdout ?? "") + (r.stderr ?? "")).toLowerCase() + if (text.includes("musl")) return true + } catch {} + return false + })() + if (isMuslHost) { + console.error("error: --single on a musl-linux host would build the glibc target and produce a binary the host cannot run.") + console.error(" altimate-core has no NAPI prebuild for musl yet. Build on a glibc host, or install via `apk add gcompat` + the npm wrapper.") + process.exit(1) + } +} + await $`rm -rf dist` // Packages excluded from the compiled binary — must be resolvable from -// node_modules at runtime. Split into required (must ship with the wrapper -// package) and optional (user installs on demand). -const requiredExternals = [ - // NAPI native module — cannot be embedded in Bun single-file executable. - "@altimateai/altimate-core", -] +// node_modules at runtime. +// +// NOTE: @altimateai/altimate-core is intentionally NOT external. We replace +// its NAPI-RS loader with a one-line shim per target (see below) so Bun +// statically sees a single `require('./altimate-core..node')` and +// embeds that one .node file into bunfs. This keeps the binary self-contained +// without bloating it with 5 platforms' worth of native addons. +const requiredExternals: string[] = [] const optionalExternals = [ // Database drivers — native addons, users install on demand per warehouse "pg", "snowflake-sdk", "@google-cloud/bigquery", "@databricks/sql", @@ -212,6 +248,79 @@ const binaries: Record = {} if (!skipInstall) { await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}` await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}` + // Ensure every @altimateai/altimate-core platform prebuild is resolvable in + // node_modules. Each per-target build below picks one and embeds its .node + // file into the Bun binary. + await $`bun install --os="*" --cpu="*" @altimateai/altimate-core@${pkg.dependencies["@altimateai/altimate-core"]}` +} + +// Map a build target to the altimate-core NAPI prebuild package name and the +// matching `.node` file name. The mapping mirrors the lines in altimate-core's +// NAPI-RS-generated loader (e.g. `require('./altimate-core.linux-x64-gnu.node')`). +// Baseline variants share the same prebuild as their non-baseline counterpart — +// "baseline" is a Bun-binary distinction, not a NAPI one. +function altimateCorePlatformFor(item: { os: string; arch: "arm64" | "x64"; abi?: "musl" }): { + pkg: string + nodeFile: string + platformTag: string +} { + if (item.abi === "musl") { + throw new Error(`No @altimateai/altimate-core prebuild for linux-${item.arch}-musl; this target should not be in allTargets.`) + } + if (item.os === "darwin") { + const tag = `darwin-${item.arch}` + return { pkg: `@altimateai/altimate-core-${tag}`, nodeFile: `altimate-core.${tag}.node`, platformTag: tag } + } + if (item.os === "linux") { + const tag = `linux-${item.arch}-gnu` + return { pkg: `@altimateai/altimate-core-${tag}`, nodeFile: `altimate-core.${tag}.node`, platformTag: tag } + } + if (item.os === "win32") { + if (item.arch === "x64") { + const tag = "win32-x64-msvc" + return { pkg: `@altimateai/altimate-core-${tag}`, nodeFile: `altimate-core.${tag}.node`, platformTag: tag } + } + throw new Error(`No @altimateai/altimate-core prebuild for win32-${item.arch}; this target should not be in allTargets.`) + } + throw new Error(`Unsupported build target: ${item.os}-${item.arch}`) +} + +// Resolve the loader package once up-front. Real path (not the bun symlink in +// node_modules/.bun) — we copy from this for each per-target staging dir. +const altimateCoreLoaderPkgJson = fileURLToPath(import.meta.resolve("@altimateai/altimate-core/package.json")) +const altimateCoreLoaderDir = fs.realpathSync(path.dirname(altimateCoreLoaderPkgJson)) + +// A `require` rooted at the loader's index.js so we can resolve sibling +// `@altimateai/altimate-core-` packages without hand-walking bun's +// `.bun/` flat layout. Node's resolution walks parent node_modules from the +// require base, which (in bun's hoisted layout used by this project) reaches +// the top-level `node_modules/@altimateai/altimate-core-` symlinks. +const altimateCoreLoaderRequire = createRequire(path.join(altimateCoreLoaderDir, "index.js")) + +// Extract the `_requiredExports` literal from the upstream NAPI-RS loader so +// the generated single-platform shim can keep the same correctness check +// (catches a stale or truncated .node file at startup with a clear error +// instead of a confusing "method is not a function" later). Pin the exact +// shape we expect — if the loader format changes, abort the build rather +// than silently shipping a shim with no validation. +const altimateCoreLoaderSource = fs.readFileSync(path.join(altimateCoreLoaderDir, "index.js"), "utf8") +const requiredExportsMatch = altimateCoreLoaderSource.match(/const _requiredExports = (\[[\s\S]*?\])/) +if (!requiredExportsMatch) { + throw new Error( + "build.ts: could not extract _requiredExports from @altimateai/altimate-core/index.js. " + + "The upstream NAPI-RS loader format changed — update the regex (see script/build.ts).", + ) +} +const altimateCoreRequiredExportsLiteral = requiredExportsMatch[1] + +// Locate the on-disk dir for an @altimateai/altimate-core- NAPI +// prebuild. Use createRequire rooted at the loader's index.js — Node's +// require.resolve walks parent node_modules from the require base, which +// reaches both bun's hoisted top-level @altimateai/altimate-core- +// symlinks and any nested layout. +function locatePlatformPackageDir(pkgName: string): string { + const pkgJsonPath = altimateCoreLoaderRequire.resolve(`${pkgName}/package.json`) + return fs.realpathSync(path.dirname(pkgJsonPath)) } for (const item of targets) { const name = [ @@ -235,10 +344,76 @@ for (const item of targets) { const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/" const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/") + // ------------------------------------------------------------------------- + // Stage a per-target copy of @altimateai/altimate-core so we can embed the + // target's NAPI prebuild into the Bun single-file executable. + // + // The upstream NAPI-RS loader (index.js) dispatches at runtime across every + // supported platform — referencing each `./altimate-core..node` + // and `@altimateai/altimate-core-` from `require()`. If we hand + // that loader to Bun.build as-is, Bun statically resolves every branch and + // either bloats the binary with 5 platforms' worth of .node files or fails + // when a non-target platform package isn't on disk. + // + // Instead we replace the loader with a one-line shim: + // + // module.exports = require('./altimate-core..node') + // + // and drop the matching .node file next to it. Bun sees a single static + // require() and embeds that one .node into bunfs. Result: self-contained + // ~80–100 MB binary, no companion files, no NODE_PATH. + // ------------------------------------------------------------------------- + const platform = altimateCorePlatformFor(item) + const platformPkgDir = locatePlatformPackageDir(platform.pkg) + const platformNodeSrc = path.join(platformPkgDir, platform.nodeFile) + if (!fs.existsSync(platformNodeSrc)) { + throw new Error(`Expected NAPI prebuild not found: ${platformNodeSrc}. Did 'bun install --os=* --cpu=*' run?`) + } + + const stagedAltimateCoreDir = path.join(dir, "dist", name, ".altimate-core-staged", "@altimateai", "altimate-core") + await $`mkdir -p ${stagedAltimateCoreDir}` + // Keep index.d.ts + package.json so typecheck and resolution stay happy. + fs.copyFileSync(path.join(altimateCoreLoaderDir, "package.json"), path.join(stagedAltimateCoreDir, "package.json")) + if (fs.existsSync(path.join(altimateCoreLoaderDir, "index.d.ts"))) { + fs.copyFileSync(path.join(altimateCoreLoaderDir, "index.d.ts"), path.join(stagedAltimateCoreDir, "index.d.ts")) + } + // The shim — single static require() of the target's .node file plus the + // same _requiredExports correctness check the upstream NAPI-RS loader does. + fs.writeFileSync( + path.join(stagedAltimateCoreDir, "index.js"), + `// Generated by packages/opencode/script/build.ts for ${name}.\n` + + `// Replaces the multi-platform NAPI-RS loader so Bun embeds exactly one .node.\n` + + `const nativeBinding = require('./${platform.nodeFile}')\n` + + `const _requiredExports = ${altimateCoreRequiredExportsLiteral}\n` + + `const _missing = _requiredExports.filter((n) => typeof nativeBinding[n] !== 'function')\n` + + `if (_missing.length > 0) {\n` + + ` throw new Error(\n` + + ` '@altimateai/altimate-core: embedded NAPI binary missing ' + _missing.length + ' export(s): ' +\n` + + ` _missing.slice(0, 5).join(', ') + (_missing.length > 5 ? '...' : '')\n` + + ` )\n` + + `}\n` + + `module.exports = nativeBinding\n`, + ) + // The actual native binding, co-located so the shim's relative require() resolves. + fs.copyFileSync(platformNodeSrc, path.join(stagedAltimateCoreDir, platform.nodeFile)) + + // Bun.build plugin: rewrite @altimateai/altimate-core imports to the staged + // shim. Without this, Bun resolves the import via the workspace + // node_modules and we'd be back to the full multi-platform loader. + const stagedShimAbs = path.join(stagedAltimateCoreDir, "index.js") + const altimateCoreResolverPlugin = { + name: "altimate-core-staged-resolver", + setup(build: any) { + build.onResolve({ filter: /^@altimateai\/altimate-core$/ }, () => ({ + path: stagedShimAbs, + })) + }, + } + await Bun.build({ conditions: ["browser"], tsconfig: "./tsconfig.json", - plugins: [solidPlugin], + plugins: [solidPlugin, altimateCoreResolverPlugin], sourcemap: "external", // IMPORTANT: Without code splitting, Bun inlines dynamic import() targets // into the main chunk. Any external require() in those targets will fail @@ -269,9 +444,15 @@ for (const item of targets) { }, }) - // Create backward-compatible altimate-code alias - // Use hard copy instead of symlink — npm publish and Docker COPY can strip symlinks, - // causing "Binary not found" in Verdaccio sanity tests. + // Staging dir is no longer needed once Bun has embedded the shim + .node. + await $`rm -rf dist/${name}/.altimate-core-staged` + + // Create backward-compatible altimate-code alias inside the platform package. + // The npm wrapper (`packages/opencode/bin/altimate`) looks for `bin/altimate-code` + // (or .exe) when locating the platform binary, so this must exist for the + // `npm i -g` flow. The release archive below ships only `altimate`. + // Use a hard copy instead of a symlink — npm publish and Docker COPY can + // strip symlinks, causing "Binary not found" in Verdaccio sanity tests. if (item.os === "win32") { await $`cp dist/${name}/bin/altimate.exe dist/${name}/bin/altimate-code.exe`.nothrow() } else { @@ -327,12 +508,20 @@ for (const item of targets) { if (Script.release) { for (const key of Object.keys(binaries)) { - const archiveName = key.replace(/^@altimateai\//, "") + // Archive name maps the platform package name (`@altimateai/altimate-code-`) + // to a standalone-archive prefix (`altimate-`). The curl-install + // script (`install` at repo root) expects this prefix and unpacks a single + // binary named `altimate` — matching the primary npm bin entry. + const archiveName = key.replace(/^@altimateai\/altimate-code-/, "altimate-") const archivePath = path.resolve("dist", archiveName) + // Name construction at line 283 substitutes `win32 → windows`, so the key + // contains "windows", not "win32". Matching the wrong substring here would + // archive a non-existent `altimate` file on Windows targets. + const binaryName = key.includes("windows") ? "altimate.exe" : "altimate" if (key.includes("linux")) { - await $`tar -czf ${archivePath}.tar.gz *`.cwd(`dist/${key}/bin`) + await $`tar -czf ${archivePath}.tar.gz ${binaryName}`.cwd(`dist/${key}/bin`) } else { - await $`zip -r ${archivePath}.zip *`.cwd(`dist/${key}/bin`) + await $`zip ${archivePath}.zip ${binaryName}`.cwd(`dist/${key}/bin`) } } } diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 98d13e60c5..ee22894c42 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -47,8 +47,49 @@ function detectPlatformAndArch() { return { platform, arch } } +function isMuslPlatform() { + if (os.platform() !== "linux") return false + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch { + // ignore + } + try { + // Mirror the detection in packages/opencode/bin/altimate: on musl + // systems `ldd --version` exits non-zero and prints to stderr. + // execSync would throw AND only return stdout — silently missing every + // non-Alpine musl distro. spawnSync gives both streams regardless of + // exit code. + const { spawnSync } = require("child_process") + const result = spawnSync("ldd", ["--version"], { encoding: "utf8" }) + const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() + if (text.includes("musl")) return true + } catch { + // ignore — ldd may not exist at all + } + return false +} + function findBinary() { const { platform, arch } = detectPlatformAndArch() + + // @altimateai/altimate-core has no NAPI prebuild for musl or win32-arm64, + // and the altimate binary embeds altimate-core's .node at build time. Emit + // a clear, actionable error here rather than the generic + // "Could not find package" message that would otherwise fall out below. + if (isMuslPlatform()) { + throw new Error( + "altimate-code is not currently supported on Alpine Linux (musl). " + + "Run 'apk add gcompat' to execute glibc binaries on Alpine, or use a glibc-based base image.", + ) + } + if (platform === "windows" && arch === "arm64") { + throw new Error( + "altimate-code is not currently built for Windows on ARM64. " + + "Run the x64 build under Windows ARM's x64 emulation, or use WSL.", + ) + } + const packageName = `@altimateai/altimate-code-${platform}-${arch}` const binaryName = platform === "windows" ? "altimate-code.exe" : "altimate-code" diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 516fdda868..dd44c49c12 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -201,13 +201,14 @@ try { // registries if (!Script.preview) { - // Calculate SHA values - const arm64Sha = await $`sha256sum ./dist/altimate-code-linux-arm64.tar.gz | cut -d' ' -f1` + // Calculate SHA values. Archive names follow the standalone-binary scheme + // (`altimate-.{tar.gz,zip}`) — see build.ts archive step. + const arm64Sha = await $`sha256sum ./dist/altimate-linux-arm64.tar.gz | cut -d' ' -f1` .text() .then((x) => x.trim()) - const x64Sha = await $`sha256sum ./dist/altimate-code-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) - const macX64Sha = await $`sha256sum ./dist/altimate-code-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) - const macArm64Sha = await $`sha256sum ./dist/altimate-code-darwin-arm64.zip | cut -d' ' -f1` + const x64Sha = await $`sha256sum ./dist/altimate-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) + const macX64Sha = await $`sha256sum ./dist/altimate-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const macArm64Sha = await $`sha256sum ./dist/altimate-darwin-arm64.zip | cut -d' ' -f1` .text() .then((x) => x.trim()) @@ -231,10 +232,10 @@ if (!Script.preview) { "conflicts=('altimate-code')", "depends=('ripgrep')", "", - `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/AltimateAI/altimate-code/releases/download/v\${pkgver}\${_subver}/altimate-code-linux-arm64.tar.gz")`, + `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/AltimateAI/altimate-code/releases/download/v\${pkgver}\${_subver}/altimate-linux-arm64.tar.gz")`, `sha256sums_aarch64=('${arm64Sha}')`, - `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/AltimateAI/altimate-code/releases/download/v\${pkgver}\${_subver}/altimate-code-linux-x64.tar.gz")`, + `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/AltimateAI/altimate-code/releases/download/v\${pkgver}\${_subver}/altimate-linux-x64.tar.gz")`, `sha256sums_x86_64=('${x64Sha}')`, "", "package() {", @@ -280,7 +281,7 @@ if (!Script.preview) { "", " on_macos do", " if Hardware::CPU.intel?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-code-darwin-x64.zip"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-darwin-x64.zip"`, ` sha256 "${macX64Sha}"`, "", " def install", @@ -289,7 +290,7 @@ if (!Script.preview) { " end", " end", " if Hardware::CPU.arm?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-code-darwin-arm64.zip"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-darwin-arm64.zip"`, ` sha256 "${macArm64Sha}"`, "", " def install", @@ -301,7 +302,7 @@ if (!Script.preview) { "", " on_linux do", " if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-code-linux-x64.tar.gz"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-linux-x64.tar.gz"`, ` sha256 "${x64Sha}"`, " def install", ' bin.install "altimate"', @@ -309,7 +310,7 @@ if (!Script.preview) { " end", " end", " if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-code-linux-arm64.tar.gz"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${Script.version}/altimate-linux-arm64.tar.gz"`, ` sha256 "${arm64Sha}"`, " def install", ' bin.install "altimate"', diff --git a/packages/opencode/test/branding/branding.test.ts b/packages/opencode/test/branding/branding.test.ts index 69357c0ef0..95b8a7b698 100644 --- a/packages/opencode/test/branding/branding.test.ts +++ b/packages/opencode/test/branding/branding.test.ts @@ -91,16 +91,22 @@ describe("CLI Branding", () => { describe("Installation Script", () => { const installContent = readText(join(repoRoot, "install")) - test("APP variable is altimate-code", () => { - expect(installContent).toContain("APP=altimate-code") + test("APP variable is altimate", () => { + // APP is the standalone-archive prefix AND the installed binary name — + // matches the primary `altimate` npm bin, not the legacy `altimate-code` + // alias. The GitHub repo URL stays `AltimateAI/altimate-code` (covered by + // the next test) — only the binary name and archive prefix changed. + expect(installContent).toContain("APP=altimate") + expect(installContent).not.toContain("APP=altimate-code") }) test("GitHub release URL references AltimateAI/altimate-code", () => { expect(installContent).toContain("github.com/AltimateAI/altimate-code/releases") }) - test("install dir is .altimate-code", () => { - expect(installContent).toContain(".altimate-code/bin") + test("install dir is .altimate/bin", () => { + expect(installContent).toContain(".altimate/bin") + expect(installContent).not.toContain(".altimate-code/bin") }) test("no references to opencode.ai domain", () => { diff --git a/packages/opencode/test/install/brew-formula.test.ts b/packages/opencode/test/install/brew-formula.test.ts index e2d3c5b480..0108919755 100644 --- a/packages/opencode/test/install/brew-formula.test.ts +++ b/packages/opencode/test/install/brew-formula.test.ts @@ -37,7 +37,7 @@ function generateBrewFormula(opts: { "", " on_macos do", " if Hardware::CPU.intel?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-code-darwin-x64.zip"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-darwin-x64.zip"`, ` sha256 "${opts.macX64Sha}"`, "", " def install", @@ -46,7 +46,7 @@ function generateBrewFormula(opts: { " end", " end", " if Hardware::CPU.arm?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-code-darwin-arm64.zip"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-darwin-arm64.zip"`, ` sha256 "${opts.macArm64Sha}"`, "", " def install", @@ -58,7 +58,7 @@ function generateBrewFormula(opts: { "", " on_linux do", " if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-code-linux-x64.tar.gz"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-linux-x64.tar.gz"`, ` sha256 "${opts.x64Sha}"`, " def install", ' bin.install "altimate"', @@ -66,7 +66,7 @@ function generateBrewFormula(opts: { " end", " end", " if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-code-linux-arm64.tar.gz"`, + ` url "https://github.com/AltimateAI/altimate-code/releases/download/v${opts.version}/altimate-linux-arm64.tar.gz"`, ` sha256 "${opts.arm64Sha}"`, " def install", ' bin.install "altimate"', @@ -104,10 +104,10 @@ describe("brew formula template", () => { }) test("all 4 platform variants are present", () => { - expect(formula).toContain("altimate-code-darwin-x64.zip") - expect(formula).toContain("altimate-code-darwin-arm64.zip") - expect(formula).toContain("altimate-code-linux-x64.tar.gz") - expect(formula).toContain("altimate-code-linux-arm64.tar.gz") + expect(formula).toContain("altimate-darwin-x64.zip") + expect(formula).toContain("altimate-darwin-arm64.zip") + expect(formula).toContain("altimate-linux-x64.tar.gz") + expect(formula).toContain("altimate-linux-arm64.tar.gz") }) test("macOS uses .zip format", () => { @@ -184,9 +184,9 @@ describe("publish.ts brew formula template matches test template", () => { test("publish.ts uses Script.version in URL construction", () => { // URLs must use v${Script.version} — not vv or raw tag - expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-code-darwin-x64.zip") - expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-code-darwin-arm64.zip") - expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-code-linux-x64.tar.gz") - expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-code-linux-arm64.tar.gz") + expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-darwin-x64.zip") + expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-darwin-arm64.zip") + expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-linux-x64.tar.gz") + expect(PUBLISH_SCRIPT).toContain("v${Script.version}/altimate-linux-arm64.tar.gz") }) }) diff --git a/packages/opencode/test/install/smoke-test-binary.test.ts b/packages/opencode/test/install/smoke-test-binary.test.ts index a86660fb6b..73ecdf3da2 100644 --- a/packages/opencode/test/install/smoke-test-binary.test.ts +++ b/packages/opencode/test/install/smoke-test-binary.test.ts @@ -2,20 +2,24 @@ * Smoke tests for compiled binaries. * * These tests build a local binary (--single) and verify it actually starts - * with the required external NAPI modules resolvable via NODE_PATH. + * — both with NODE_PATH set (matches the npm bin wrapper environment) and + * with NODE_PATH cleared (matches the curl-install / Homebrew / AUR / GitHub + * release archive environment). * - * This is the test that would have caught the v0.5.10 regression where - * @altimateai/altimate-core was marked external but missing from standalone - * distributions, causing an immediate crash on startup. + * The "NODE_PATH cleared" test is the regression guard for the v0.7.x + * curl-install crash: the Bun-compiled binary now embeds altimate-core's + * NAPI .node into bunfs, so the standalone binary must start without any + * companion files. * * Run: bun test test/install/smoke-test-binary.test.ts * * NOTE: Requires a local build first: bun run build:local */ import { describe, test, expect } from "bun:test" -import { spawnSync } from "child_process" +import { spawnSync, execFileSync } from "child_process" import path from "path" import fs from "fs" +import { tmpdir } from "../fixture/fixture" const PKG_DIR = path.resolve(import.meta.dir, "../..") const REPO_ROOT = path.resolve(PKG_DIR, "../..") @@ -90,32 +94,75 @@ describe("compiled binary smoke test", () => { expect(result.stderr).not.toContain("Cannot find module") }) - runTest("binary fails gracefully without NODE_PATH (standalone mode)", () => { - // Simulate standalone distribution — no node_modules available. - // The binary should NOT crash with an unhandled error; it should - // either degrade gracefully or show a clear error message. + runTest("binary succeeds with NODE_PATH cleared (standalone mode)", async () => { + // The Bun-compiled binary embeds @altimateai/altimate-core's NAPI .node + // directly into bunfs (see script/build.ts — staged shim + resolver + // plugin). It MUST start without any external NODE_PATH or companion + // node_modules. This is the regression guard for the v0.7.x curl-install + // crash where altimate-core was marked `external` and the standalone + // archive shipped without it. + // + // Hermeticity: cwd is a freshly-created tmp dir so the binary cannot walk + // upward and discover the worktree's node_modules. Without this, Bun's + // compiled binary falls back to filesystem resolution from process.execPath + // and the test passes even if the staged-shim onResolve silently misses. + // + // Uses the repo's tmpdir() fixture for auto-cleanup via `await using`. + await using tmp = await tmpdir() const result = spawnSync(binary!, ["--version"], { + cwd: tmp.path, encoding: "utf-8", timeout: 15_000, env: { PATH: process.env.PATH, HOME: process.env.HOME, OPENCODE_DISABLE_TELEMETRY: "1", - // Explicitly clear NODE_PATH to simulate standalone + // Explicitly clear NODE_PATH to simulate the curl-install layout NODE_PATH: "", }, }) - // Process must have exited (not been killed by timeout) - expect(result.status).not.toBeNull() - - // If it fails, the error should mention the missing module clearly if (result.status !== 0) { - const output = (result.stdout ?? "") + (result.stderr ?? "") - expect(output).toContain("altimate-core") + console.error("STDOUT:", result.stdout) + console.error("STDERR:", result.stderr) + } + expect(result.status).toBe(0) + const output = (result.stdout ?? "") + (result.stderr ?? "") + expect(output).not.toContain("Cannot find module") + }) + + // Content-level assertion: independent of any runtime resolution path, + // require that the compiled binary contains exactly one altimate-core .node + // reference. If the staged-shim onResolve ever silently fails to redirect + // and Bun pulls in the upstream multi-platform loader, every platform's + // .node name leaks into bunfs and this test fires. Pairs with the + // hermetic --version test above. + runTest("binary embeds exactly one altimate-core .node", () => { + if (process.platform === "win32") { + // `strings` isn't available on a stock Windows runner. The other tests + // already exercise the runtime path; this content-level check covers + // Linux + macOS CI which is where the build matrix actually runs. + return + } + const stringsOut = execFileSync("strings", [binary!], { + encoding: "utf-8", + maxBuffer: 256 * 1024 * 1024, + }) + // Strip the bunfs hash suffix Bun appends to embedded resources + // (e.g. "altimate-core.darwin-arm64-ptxrnv5e.node" → "altimate-core.darwin-arm64.node") + // so the require() string and the bunfs entry collapse to the same name. + // Bun uses an alphanumeric (not hex) hash of 7+ chars; real platform + // last-segments (arm64/x64/gnu/msvc) are all <=5 chars, so a length-bound + // of {6,} unambiguously matches the hash. + const refs = [...stringsOut.matchAll(/altimate-core\.(?:darwin|linux|win32)-[a-z0-9-]+\.node/g)] + .map((m) => m[0]) + .map((r) => r.replace(/-[a-z0-9]{6,}(?=\.node$)/, "")) + const distinct = new Set(refs) + if (distinct.size !== 1) { + console.error("altimate-core .node references found in binary:", [...distinct]) } - // Either way, it should not segfault (exit code > 128 means signal) - expect(result.status!).toBeLessThanOrEqual(128) + expect(distinct.size).toBeGreaterThanOrEqual(1) + expect(distinct.size).toBe(1) }) runTest("binary responds to --help", () => {