From b67e98f5962e2d120ccbed6e7985f448de7fcad4 Mon Sep 17 00:00:00 2001
From: Emil Laine
Date: Tue, 29 Sep 2026 11:45:42 +0300
Subject: [PATCH 1/5] Add Installing section and prebuilt release binaries
Frontpage Installing section with OS tabs, install.sh, and release CI
that publishes Linux/macOS/Windows binaries (LLVM setup factored into
a shared action). Install docs in README and Getting started, plus
offline tests for the installer and the tab wiring.
---
.github/actions/setup-llvm/action.yml | 189 ++++++++++++++++++++++
.github/workflows/build.yml | 179 +--------------------
.github/workflows/release.yml | 216 ++++++++++++++++++++++++++
README.md | 4 +-
docs/050-hello-world.md | 19 ++-
install.sh | 104 +++++++++++++
website/build-website.sh | 14 ++
website/index.css | 52 ++++++-
website/index.html | 53 ++++++-
website/install.js | 24 +++
website/test-install-sh.sh | 68 ++++++++
website/test-install.mjs | 75 +++++++++
website/top-nav.html | 1 +
13 files changed, 810 insertions(+), 188 deletions(-)
create mode 100644 .github/actions/setup-llvm/action.yml
create mode 100644 .github/workflows/release.yml
create mode 100755 install.sh
create mode 100644 website/install.js
create mode 100644 website/test-install-sh.sh
create mode 100644 website/test-install.mjs
diff --git a/.github/actions/setup-llvm/action.yml b/.github/actions/setup-llvm/action.yml
new file mode 100644
index 00000000..4f8caf40
--- /dev/null
+++ b/.github/actions/setup-llvm/action.yml
@@ -0,0 +1,189 @@
+# Shared toolchain setup for jobs that compile cx: installs LLVM 23 and the
+# link-time dependencies, and exports LLVMDIR/EXTRA_CMAKE_* for the configure
+# step. Used by both build.yml and release.yml so releases can never drift
+# onto a different toolchain than CI tested.
+name: Set up LLVM 23 and link dependencies
+description: Install LLVM 23 plus zlib/zstd/DIA SDK and export CMake paths
+
+runs:
+ using: composite
+ steps:
+ # Cache downloaded .debs (LLVM, SDL build deps, glfw) in a
+ # user-owned dir; the default archive dir is root-owned and the
+ # cache action could not restore into it. Stale entries are safe:
+ # apt re-downloads anything not matching the package index.
+ # The lock file and partial/ dir stay excluded: apt recreates them
+ # as root, and tar cannot read them back as the runner user.
+ - name: Cache apt archives (Ubuntu)
+ if: runner.os == 'Linux'
+ uses: actions/cache@v5
+ with:
+ path: |
+ /tmp/apt-archives
+ !/tmp/apt-archives/lock
+ !/tmp/apt-archives/partial
+ key: apt-archives-llvm-23-sdl-glfw
+
+ - name: Redirect apt archives (Ubuntu)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ mkdir -p /tmp/apt-archives/partial
+ echo 'Dir::Cache::Archives "/tmp/apt-archives";' | sudo tee /etc/apt/apt.conf.d/99-ci-cache
+
+ - name: Download LLVM (Ubuntu)
+ if: runner.os == 'Linux'
+ shell: bash
+ run: |
+ wget https://apt.llvm.org/llvm.sh
+ chmod +x llvm.sh
+ sudo ./llvm.sh 23 all
+ echo "/usr/lib/llvm-23/bin" >> $GITHUB_PATH
+
+ # Bottle downloads are content-addressed, so a static key is safe.
+ # Only the fetch is cached; install/link still runs (cheap, and the
+ # full cellar layout is too version-sensitive to restore blindly).
+ - name: Cache Homebrew downloads (macOS)
+ if: runner.os == 'macOS'
+ uses: actions/cache@v5
+ with:
+ path: ~/Library/Caches/Homebrew
+ key: homebrew-downloads-llvm-23-sdl3-glfw
+
+ - name: Download LLVM (macOS)
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ # Refresh Homebrew first: a stale snapshot installs an older LLVM
+ # bottle (e.g. 23.1.0, whose libclangDriver.a ships an LLVM-bitcode
+ # object that the system linker can't parse).
+ brew update
+ brew install llvm@23
+ echo LLVMDIR=$(brew --prefix llvm@23) >> $GITHUB_ENV
+
+ # Cache the extracted LLVM tree: re-downloading and
+ # double-extracting the tarball costs minutes on every Windows leg.
+ # Bump the key when the URL below changes.
+ - name: Cache LLVM (Windows)
+ if: runner.os == 'Windows'
+ uses: actions/cache@v5
+ with:
+ path: clang+llvm-23.1.1-x86_64-pc-windows-msvc
+ key: llvm-windows-23.1.1
+
+ - name: Download LLVM (Windows)
+ if: runner.os == 'Windows'
+ shell: bash
+ run: |
+ # Reuse the cached tree only when it is complete; a cache entry
+ # saved from a failed extract must trigger a re-download, not a
+ # build against a corrupt tree.
+ if [ ! -f "clang+llvm-23.1.1-x86_64-pc-windows-msvc/bin/clang.exe" ]; then
+ rm -rf clang+llvm-23.1.1-x86_64-pc-windows-msvc
+ curl -L "https://github.com/llvm/llvm-project/releases/download/llvmorg-23.1.1/clang+llvm-23.1.1-x86_64-pc-windows-msvc.tar.xz" --output llvm.tar.xz && 7z x llvm.tar.xz && 7z x llvm.tar
+ fi
+ # The sed below converts the path to a Windows-style path
+ echo LLVMDIR="$(echo "$PWD/clang+llvm-23.1.1-x86_64-pc-windows-msvc" | sed -e 's/^\///' -e 's/\//\\/g' -e 's/^./\0:/')" >> $GITHUB_ENV
+
+ # LLVM 22+ locates the DIA SDK via $VSINSTALLDIR (see llvm/cmake/modules/FindDIASDK.cmake),
+ # which is only set inside a Visual Studio developer environment.
+ # vs-shell is composite (no Node runtime); ilammy/msvc-dev-cmd is abandoned on node20.
+ - name: Set up MSVC environment (Windows)
+ if: runner.os == 'Windows'
+ uses: egor-tensin/vs-shell@v2
+
+ - name: Provide DIA SDK (Windows)
+ if: runner.os == 'Windows'
+ shell: bash
+ run: |
+ # LLVM's LLVMDebugInfoPDB target needs diaguids.lib. Its FindDIASDK
+ # module only looks at $VSINSTALLDIR, which may point at an install
+ # without it, so search wider and pass an explicit path if needed.
+ DIA_CMAKE_DIR=""
+ if [ -f "$VSINSTALLDIR/DIA SDK/lib/amd64/diaguids.lib" ]; then
+ echo "DIA SDK found via VSINSTALLDIR"
+ else
+ VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
+ if [ -f "$VSWHERE" ]; then
+ mapfile -t VS_INSTALLS < <("$VSWHERE" -products '*' -property installationPath)
+ for INSTALL_PATH in "${VS_INSTALLS[@]}"; do
+ INSTALL_PATH_UNIX=$(echo "$INSTALL_PATH" | tr -d '\r' | sed -e 's/\\/\//g')
+ if [ -f "$INSTALL_PATH_UNIX/DIA SDK/lib/amd64/diaguids.lib" ]; then
+ DIA_CMAKE_DIR="$INSTALL_PATH_UNIX/DIA SDK"
+ break
+ fi
+ done
+ fi
+ if [ -z "$DIA_CMAKE_DIR" ]; then
+ # Fall back to the DIA files shipped in the Windows SDK, staged
+ # into the layout FindDIASDK expects.
+ DIA_LIB=$(ls -d "/c/Program Files (x86)/Windows Kits/10/Lib/"*"/um/x64/diaguids.lib" 2>/dev/null | sort -r | head -n 1 || true)
+ DIA_INC=$(echo "$DIA_LIB" | sed -e 's#/Lib/\(.*\)/um/x64/diaguids.lib#/Include/\1/um/dia2.h#')
+ if [ -n "$DIA_LIB" ] && [ -f "$DIA_INC" ]; then
+ DIA_STAGE="$RUNNER_TEMP/dia-sdk"
+ mkdir -p "$DIA_STAGE/include" "$DIA_STAGE/lib/amd64"
+ cp "$DIA_LIB" "$DIA_STAGE/lib/amd64/diaguids.lib"
+ cp "$DIA_INC" "$DIA_STAGE/include/dia2.h"
+ DIA_CMAKE_DIR=$(echo "$DIA_STAGE" | sed -e 's/\\/\//g')
+ fi
+ fi
+ if [ -z "$DIA_CMAKE_DIR" ]; then
+ echo "ERROR: DIA SDK with lib/amd64/diaguids.lib not found in VSINSTALLDIR, any VS instance, or the Windows Kits"
+ exit 1
+ fi
+ echo "DIA SDK found at: $DIA_CMAKE_DIR"
+ echo "DIA_CMAKE_DIR=$DIA_CMAKE_DIR" >> $GITHUB_ENV
+ fi
+
+ # LLVM 22+ links LLVMSupport against the ZLIB::ZLIB and zstd::libzstd_static
+ # imported targets, so findable ZLIB and zstd are required at configure
+ # time. Use the static triplets to match the /MT runtime used with the
+ # prebuilt LLVM.
+ # Note: vcpkg names the static libs zs/zsd, which FindZLIB only
+ # searches for with ZLIB_USE_STATIC_LIBS=ON. Similarly, LLVM's Findzstd
+ # module only defines zstd::libzstd_static for a library literally named
+ # zstd_static.lib, which vcpkg doesn't ship (only zstd.lib), so copy it.
+ # The vcpkg baseline moves with the runner image, so resolve it to
+ # a commit SHA for the cache key. A static key would freeze on the
+ # first binaries saved (actions/cache never re-saves on hit) and
+ # rebuild from source forever after any update. Falls back to a
+ # static key if the install is not a git checkout.
+ - name: Get vcpkg baseline (Windows)
+ if: runner.os == 'Windows'
+ id: vcpkg-baseline
+ shell: bash
+ run: echo "sha=$(git -C \"$VCPKG_INSTALLATION_ROOT\" rev-parse HEAD 2>/dev/null || echo unknown)" >> "$GITHUB_OUTPUT"
+
+ # vcpkg restores the built ports from this binary cache instead of
+ # compiling them; each entry carries an ABI hash, so stale hits are
+ # impossible.
+ - name: Cache vcpkg binaries (Windows)
+ if: runner.os == 'Windows'
+ uses: actions/cache@v5
+ with:
+ path: ${{ runner.temp }}/vcpkg-binary-cache
+ key: vcpkg-${{ steps.vcpkg-baseline.outputs.sha }}-zlib-zstd-static
+
+ - name: Install zlib and zstd (Windows)
+ if: runner.os == 'Windows'
+ shell: bash
+ env:
+ VCPKG_BINARY_SOURCES: "clear;files,${{ runner.temp }}/vcpkg-binary-cache,readwrite"
+ run: |
+ vcpkg install zlib:x64-windows-static zstd:x64-windows-static
+ cp "$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static/lib/zstd.lib" "$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static/lib/zstd_static.lib"
+ ls "$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static/lib"
+ echo "EXTRA_CMAKE_PREFIX=$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static" >> $GITHUB_ENV
+ echo "EXTRA_CMAKE_ARGS=-DZLIB_USE_STATIC_LIBS=ON" >> $GITHUB_ENV
+
+ # The lit/snippet/LSP suites spawn thousands of compiler and linker
+ # processes, each writing temporary objects and executables. Real-time
+ # Defender scanning of every one is a large Windows-only slowdown, so
+ # exclude the build tree, the repo, and the temp directories. The
+ # runner is ephemeral, so narrowing scan coverage here costs nothing.
+ - name: Exclude build and temp paths from Defender (Windows)
+ if: runner.os == 'Windows'
+ shell: powershell
+ continue-on-error: true
+ run: |
+ Add-MpPreference -ExclusionPath "${{ runner.workspace }}", "$env:GITHUB_WORKSPACE", "$env:RUNNER_TEMP", "$env:TEMP"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ae14b7dc..def4242d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,182 +20,8 @@ jobs:
with:
python-version: 3.12
- # Cache downloaded .debs (LLVM, SDL build deps, glfw) in a
- # user-owned dir; the default archive dir is root-owned and the
- # cache action could not restore into it. Stale entries are safe:
- # apt re-downloads anything not matching the package index.
- # The lock file and partial/ dir stay excluded: apt recreates them
- # as root, and tar cannot read them back as the runner user.
- - name: Cache apt archives (Ubuntu)
- if: matrix.os == 'ubuntu-latest'
- uses: actions/cache@v5
- with:
- path: |
- /tmp/apt-archives
- !/tmp/apt-archives/lock
- !/tmp/apt-archives/partial
- key: apt-archives-llvm-23-sdl-glfw
-
- - name: Redirect apt archives (Ubuntu)
- if: matrix.os == 'ubuntu-latest'
- run: |
- mkdir -p /tmp/apt-archives/partial
- echo 'Dir::Cache::Archives "/tmp/apt-archives";' | sudo tee /etc/apt/apt.conf.d/99-ci-cache
-
- - name: Download LLVM (Ubuntu)
- if: matrix.os == 'ubuntu-latest'
- run: |
- wget https://apt.llvm.org/llvm.sh
- chmod +x llvm.sh
- sudo ./llvm.sh 23 all
- echo "/usr/lib/llvm-23/bin" >> $GITHUB_PATH
-
- # Bottle downloads are content-addressed, so a static key is safe.
- # Only the fetch is cached; install/link still runs (cheap, and the
- # full cellar layout is too version-sensitive to restore blindly).
- - name: Cache Homebrew downloads (macOS)
- if: matrix.os == 'macos-latest'
- uses: actions/cache@v5
- with:
- path: ~/Library/Caches/Homebrew
- key: homebrew-downloads-llvm-23-sdl3-glfw
-
- - name: Download LLVM (macOS)
- if: matrix.os == 'macos-latest'
- run: |
- # Refresh Homebrew first: a stale snapshot installs an older LLVM
- # bottle (e.g. 23.1.0, whose libclangDriver.a ships an LLVM-bitcode
- # object that the system linker can't parse).
- brew update
- brew install llvm@23
- echo LLVMDIR=$(brew --prefix llvm@23) >> $GITHUB_ENV
-
- # Cache the extracted LLVM tree: re-downloading and
- # double-extracting the tarball costs minutes on every Windows leg.
- # Bump the key when the URL below changes.
- - name: Cache LLVM (Windows)
- if: matrix.os == 'windows-latest'
- uses: actions/cache@v5
- with:
- path: clang+llvm-23.1.1-x86_64-pc-windows-msvc
- key: llvm-windows-23.1.1
-
- - name: Download LLVM (Windows)
- if: matrix.os == 'windows-latest'
- shell: bash
- run: |
- # Reuse the cached tree only when it is complete; a cache entry
- # saved from a failed extract must trigger a re-download, not a
- # build against a corrupt tree.
- if [ ! -f "clang+llvm-23.1.1-x86_64-pc-windows-msvc/bin/clang.exe" ]; then
- rm -rf clang+llvm-23.1.1-x86_64-pc-windows-msvc
- curl -L "https://github.com/llvm/llvm-project/releases/download/llvmorg-23.1.1/clang+llvm-23.1.1-x86_64-pc-windows-msvc.tar.xz" --output llvm.tar.xz && 7z x llvm.tar.xz && 7z x llvm.tar
- fi
- # The sed below converts the path to a Windows-style path
- echo LLVMDIR="$(echo "$PWD/clang+llvm-23.1.1-x86_64-pc-windows-msvc" | sed -e 's/^\///' -e 's/\//\\/g' -e 's/^./\0:/')" >> $GITHUB_ENV
-
- # LLVM 22+ locates the DIA SDK via $VSINSTALLDIR (see llvm/cmake/modules/FindDIASDK.cmake),
- # which is only set inside a Visual Studio developer environment.
- # vs-shell is composite (no Node runtime); ilammy/msvc-dev-cmd is abandoned on node20.
- - name: Set up MSVC environment (Windows)
- if: matrix.os == 'windows-latest'
- uses: egor-tensin/vs-shell@v2
-
- - name: Provide DIA SDK (Windows)
- if: matrix.os == 'windows-latest'
- shell: bash
- run: |
- # LLVM's LLVMDebugInfoPDB target needs diaguids.lib. Its FindDIASDK
- # module only looks at $VSINSTALLDIR, which may point at an install
- # without it, so search wider and pass an explicit path if needed.
- DIA_CMAKE_DIR=""
- if [ -f "$VSINSTALLDIR/DIA SDK/lib/amd64/diaguids.lib" ]; then
- echo "DIA SDK found via VSINSTALLDIR"
- else
- VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
- if [ -f "$VSWHERE" ]; then
- mapfile -t VS_INSTALLS < <("$VSWHERE" -products '*' -property installationPath)
- for INSTALL_PATH in "${VS_INSTALLS[@]}"; do
- INSTALL_PATH_UNIX=$(echo "$INSTALL_PATH" | tr -d '\r' | sed -e 's/\\/\//g')
- if [ -f "$INSTALL_PATH_UNIX/DIA SDK/lib/amd64/diaguids.lib" ]; then
- DIA_CMAKE_DIR="$INSTALL_PATH_UNIX/DIA SDK"
- break
- fi
- done
- fi
- if [ -z "$DIA_CMAKE_DIR" ]; then
- # Fall back to the DIA files shipped in the Windows SDK, staged
- # into the layout FindDIASDK expects.
- DIA_LIB=$(ls -d "/c/Program Files (x86)/Windows Kits/10/Lib/"*"/um/x64/diaguids.lib" 2>/dev/null | sort -r | head -n 1 || true)
- DIA_INC=$(echo "$DIA_LIB" | sed -e 's#/Lib/\(.*\)/um/x64/diaguids.lib#/Include/\1/um/dia2.h#')
- if [ -n "$DIA_LIB" ] && [ -f "$DIA_INC" ]; then
- DIA_STAGE="$RUNNER_TEMP/dia-sdk"
- mkdir -p "$DIA_STAGE/include" "$DIA_STAGE/lib/amd64"
- cp "$DIA_LIB" "$DIA_STAGE/lib/amd64/diaguids.lib"
- cp "$DIA_INC" "$DIA_STAGE/include/dia2.h"
- DIA_CMAKE_DIR=$(echo "$DIA_STAGE" | sed -e 's/\\/\//g')
- fi
- fi
- if [ -z "$DIA_CMAKE_DIR" ]; then
- echo "ERROR: DIA SDK with lib/amd64/diaguids.lib not found in VSINSTALLDIR, any VS instance, or the Windows Kits"
- exit 1
- fi
- echo "DIA SDK found at: $DIA_CMAKE_DIR"
- echo "DIA_CMAKE_DIR=$DIA_CMAKE_DIR" >> $GITHUB_ENV
- fi
-
- # LLVM 22+ links LLVMSupport against the ZLIB::ZLIB and zstd::libzstd_static
- # imported targets, so findable ZLIB and zstd are required at configure
- # time. Use the static triplets to match the /MT runtime used with the
- # prebuilt LLVM.
- # Note: vcpkg names the static libs zs/zsd, which FindZLIB only
- # searches for with ZLIB_USE_STATIC_LIBS=ON. Similarly, LLVM's Findzstd
- # module only defines zstd::libzstd_static for a library literally named
- # zstd_static.lib, which vcpkg doesn't ship (only zstd.lib), so copy it.
- # The vcpkg baseline moves with the runner image, so resolve it to
- # a commit SHA for the cache key. A static key would freeze on the
- # first binaries saved (actions/cache never re-saves on hit) and
- # rebuild from source forever after any update. Falls back to a
- # static key if the install is not a git checkout.
- - name: Get vcpkg baseline (Windows)
- if: matrix.os == 'windows-latest'
- id: vcpkg-baseline
- shell: bash
- run: echo "sha=$(git -C \"$VCPKG_INSTALLATION_ROOT\" rev-parse HEAD 2>/dev/null || echo unknown)" >> "$GITHUB_OUTPUT"
-
- # vcpkg restores the built ports from this binary cache instead of
- # compiling them; each entry carries an ABI hash, so stale hits are
- # impossible.
- - name: Cache vcpkg binaries (Windows)
- if: matrix.os == 'windows-latest'
- uses: actions/cache@v5
- with:
- path: ${{ runner.temp }}/vcpkg-binary-cache
- key: vcpkg-${{ steps.vcpkg-baseline.outputs.sha }}-zlib-zstd-static
-
- - name: Install zlib and zstd (Windows)
- if: matrix.os == 'windows-latest'
- shell: bash
- env:
- VCPKG_BINARY_SOURCES: "clear;files,${{ runner.temp }}/vcpkg-binary-cache,readwrite"
- run: |
- vcpkg install zlib:x64-windows-static zstd:x64-windows-static
- cp "$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static/lib/zstd.lib" "$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static/lib/zstd_static.lib"
- ls "$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static/lib"
- echo "EXTRA_CMAKE_PREFIX=$VCPKG_INSTALLATION_ROOT/installed/x64-windows-static" >> $GITHUB_ENV
- echo "EXTRA_CMAKE_ARGS=-DZLIB_USE_STATIC_LIBS=ON" >> $GITHUB_ENV
-
- # The lit/snippet/LSP suites spawn thousands of compiler and linker
- # processes, each writing temporary objects and executables. Real-time
- # Defender scanning of every one is a large Windows-only slowdown, so
- # exclude the build tree, the repo, and the temp directories. The
- # runner is ephemeral, so narrowing scan coverage here costs nothing.
- - name: Exclude build and temp paths from Defender (Windows)
- if: matrix.os == 'windows-latest'
- shell: powershell
- continue-on-error: true
- run: |
- Add-MpPreference -ExclusionPath "${{ runner.workspace }}", "$env:GITHUB_WORKSPACE", "$env:RUNNER_TEMP", "$env:TEMP"
+ - name: Set up LLVM 23 and link dependencies
+ uses: ./.github/actions/setup-llvm
- name: Create Build Environment
run: cmake -E make_directory ${{runner.workspace}}/build
@@ -654,6 +480,7 @@ jobs:
run: |
node website/test-bench.mjs
node website/test-cx-mode.mjs
+ node website/test-install.mjs
node wasm/test-editor.mjs
node wasm/test-playground.mjs
node wasm/test-playground-worker.mjs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..207d1c80
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,216 @@
+name: Release
+
+# Prebuilt binaries behind the front-page download links. Pushing a v* tag
+# publishes archives to GitHub Releases; pull requests touching this workflow
+# or the shared LLVM setup still build, package, and smoke-test the archives
+# (uploaded as artifacts) without publishing anything.
+#
+# Linux and Windows link LLVM statically, so their archives are standalone.
+# macOS archives bundle the Homebrew LLVM dylibs next to the binaries
+# (Homebrew ships shared-only libraries), with install names rewritten to
+# @executable_path/@loader_path. The otool assert fails the release if any
+# Homebrew reference survives.
+on:
+ push:
+ tags: ["v[0-9]*"]
+ pull_request:
+ paths:
+ - .github/workflows/release.yml
+ - .github/actions/setup-llvm/**
+ workflow_dispatch:
+
+jobs:
+ package:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Older runner than CI on purpose: binaries must run on older
+ # distros than the build machine (glibc is forward-compatible only).
+ - os: ubuntu-22.04
+ platform: linux-x64
+ - os: macos-latest
+ platform: macos-arm64
+ - os: windows-latest
+ platform: windows-x64
+ env:
+ STAGE_DIR: stage/cx-${{ matrix.platform }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up LLVM 23 and link dependencies
+ uses: ./.github/actions/setup-llvm
+
+ - name: Create Build Environment
+ run: cmake -E make_directory ${{runner.workspace}}/build
+
+ - name: Configure CMake
+ shell: bash
+ working-directory: ${{runner.workspace}}/build
+ run: |
+ extra_args=()
+ if [ "$RUNNER_OS" = "Linux" ]; then
+ # Static libstdc++/libgcc: the archive must run on older distros
+ # than the build runner.
+ extra_args+=("-DCMAKE_EXE_LINKER_FLAGS=-static-libstdc++ -static-libgcc")
+ fi
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ # Static CRT so users need no Visual C++ redistributable. The
+ # dumpbin assert below proves no CRT DLL dependency; if /MT ever
+ # stops applying, the release fails there instead of shipping a
+ # binary that refuses to start.
+ extra_args+=("-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded")
+ fi
+ cmake $GITHUB_WORKSPACE -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$LLVMDIR${EXTRA_CMAKE_PREFIX:+;$EXTRA_CMAKE_PREFIX}" -DCMAKE_UNITY_BUILD=ON $EXTRA_CMAKE_ARGS ${DIA_CMAKE_DIR:+-DMSVC_DIA_SDK_DIR="$DIA_CMAKE_DIR"} "${extra_args[@]}"
+
+ - name: Build cx and cx-lsp
+ working-directory: ${{runner.workspace}}/build
+ shell: bash
+ run: cmake --build . --config Release --target cx cx-lsp
+
+ # The compiler finds its standard library next to the executable
+ # (see getCxRootDir), so the archive is just the two binaries plus
+ # a copy of std/.
+ - name: Stage archive contents
+ shell: bash
+ run: |
+ mkdir -p "$STAGE_DIR"
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ cp "${{runner.workspace}}/build/Release/cx.exe" "${{runner.workspace}}/build/Release/cx-lsp.exe" "$STAGE_DIR/"
+ else
+ cp "${{runner.workspace}}/build/cx" "${{runner.workspace}}/build/cx-lsp" "$STAGE_DIR/"
+ strip "$STAGE_DIR/cx" "$STAGE_DIR/cx-lsp"
+ fi
+ cp -r "$GITHUB_WORKSPACE/std" "$STAGE_DIR/std"
+ cp "$GITHUB_WORKSPACE/LICENSE.txt" "$STAGE_DIR/"
+
+ - name: Bundle macOS libraries
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ lib="$STAGE_DIR/lib"
+ mkdir -p "$lib"
+ # Copy every Homebrew library referenced by the binaries or by
+ # the bundled libraries themselves. Iterates to a fixpoint to
+ # catch transitive deps (z3, zstd, ...).
+ prev="unseeded"
+ while [ "$(ls "$lib" | tr '\n' ' ')" != "$prev" ]; do
+ prev="$(ls "$lib" | tr '\n' ' ')"
+ for f in "$STAGE_DIR"/cx "$STAGE_DIR"/cx-lsp "$lib"/*.dylib; do
+ [ -f "$f" ] || continue
+ otool -L "$f" | tail -n +2 | awk '{print $1}' | while read -r dep; do
+ case "$dep" in
+ /opt/homebrew/* | /usr/local/*) cp -n "$dep" "$lib/" ;;
+ esac
+ done
+ done
+ done
+ rewire() {
+ otool -L "$1" | tail -n +2 | awk '{print $1}' | while read -r dep; do
+ case "$dep" in
+ /opt/homebrew/* | /usr/local/* | @rpath/*)
+ install_name_tool -change "$dep" "$2/$(basename "$dep")" "$1"
+ ;;
+ esac
+ done
+ }
+ for f in "$STAGE_DIR"/cx "$STAGE_DIR"/cx-lsp; do
+ rewire "$f" "@executable_path/lib"
+ # Drop Homebrew rpaths: an absolute fallback could mask a
+ # missed fixup, passing smoke on this machine only.
+ otool -l "$f" | awk '$1 == "path" {print $2}' | while read -r rpath; do
+ case "$rpath" in
+ /opt/homebrew/* | /usr/local/*) install_name_tool -delete_rpath "$rpath" "$f" || true ;;
+ esac
+ done
+ done
+ for d in "$lib"/*.dylib; do
+ rewire "$d" "@loader_path"
+ done
+ codesign --force -s - "$STAGE_DIR"/cx "$STAGE_DIR"/cx-lsp "$lib"/*.dylib
+
+ # Hard fail before anything publishes: an archive that needs LLVM,
+ # libstdc++, Homebrew, or the MSVC redist installed is not a
+ # prebuilt binary.
+ - name: Assert standalone linking
+ shell: bash
+ run: |
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ deps=$(dumpbin /dependents "$STAGE_DIR/cx.exe")
+ echo "$deps"
+ if echo "$deps" | grep -Ei 'vcruntime|msvcp|msvcr'; then
+ echo "ERROR: cx needs the Visual C++ redistributable (/MT did not apply)"
+ exit 1
+ fi
+ elif [ "$RUNNER_OS" = "macOS" ]; then
+ # The asset name promises arm64; fail loudly on runner drift.
+ [ "$(uname -m)" = "arm64" ] || { echo "ERROR: expected arm64 runner for macos-arm64 asset"; exit 1; }
+ otool -L "$STAGE_DIR"/cx "$STAGE_DIR"/cx-lsp "$STAGE_DIR"/lib/*.dylib
+ # Skip each file's own install-ID line (the line after each
+ # "file:" header): IDs are inert once nothing references them.
+ if otool -L "$STAGE_DIR"/cx "$STAGE_DIR"/cx-lsp "$STAGE_DIR"/lib/*.dylib | awk '/:$/{skip=1; next} skip==1{skip=0; next} {print}' | grep -E '/opt/homebrew|/usr/local|@rpath'; then
+ echo "ERROR: macOS binaries still reference Homebrew or @rpath"
+ exit 1
+ fi
+ else
+ ldd "$STAGE_DIR/cx"
+ if ldd "$STAGE_DIR/cx" | grep -Ei 'llvm|clang|libstdc\+\+|libgcc'; then
+ echo "ERROR: cx links LLVM, libstdc++, or libgcc dynamically"
+ exit 1
+ fi
+ fi
+
+ - name: Smoke-test the packaged binaries
+ shell: bash
+ run: |
+ # Run from a different directory than the binaries: this proves the
+ # packaged compiler finds its stdlib by executable path, not cwd.
+ printf 'void main() {\n println("smoke ok");\n}\n' > "$RUNNER_TEMP/hello.cx"
+ cd "$RUNNER_TEMP"
+ pkg="$GITHUB_WORKSPACE/$STAGE_DIR"
+ "$pkg/cx" run hello.cx
+ # Also exercise the native compile+link path (uses the system linker).
+ "$pkg/cx" hello.cx -o hello-native
+ ./hello-native
+ "$pkg/cx-lsp" --help > /dev/null
+
+ - name: Create archive
+ shell: bash
+ run: |
+ name="cx-${{ matrix.platform }}"
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ (cd stage && 7z a "../$name.zip" "$name")
+ else
+ tar -czf "$name.tar.gz" -C stage "$name"
+ fi
+
+ - name: Upload archive artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: binaries-${{ matrix.platform }}
+ path: cx-${{ matrix.platform }}.*
+
+ publish:
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs: [package]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: binaries-*
+ merge-multiple: true
+ path: dist
+
+ - uses: softprops/action-gh-release@v2
+ with:
+ files: dist/*
+ generate_release_notes: true
+ body: |
+ Prebuilt cx binaries for Linux (x64), macOS (Apple Silicon), and Windows (x64). Each archive holds the `cx` compiler, the `cx-lsp` language server, and the standard library. Unpack it anywhere and run `cx` directly; no installer and no dependencies, though compiling to native binaries uses your system C compiler as the linker.
+
+ Fastest install on Linux/macOS: `curl https://cx-language.github.io/cx/install.sh | bash`
+
+ macOS notes: the archive bundles the LLVM libraries (~200MB unpacked), and the binaries are ad-hoc signed. The installer strips the download quarantine for you; if you unpack manually and macOS refuses to run them, right-click the binary and choose Open once.
diff --git a/README.md b/README.md
index e3d2ee92..4ef3b3fc 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,9 @@ The [cx extension for VS Code](https://marketplace.visualstudio.com/items?itemNa
## Building from source
-Compiling cx requires a C++20 compiler, [CMake](https://cmake.org) 3.16 or newer,
+Prebuilt Linux, macOS, and Windows binaries are published on the [releases page](https://github.com/cx-language/cx/releases)
+(the fastest install on Linux/macOS is `curl https://cx-language.github.io/cx/install.sh | bash`).
+Otherwise, compiling cx requires a C++20 compiler, [CMake](https://cmake.org) 3.16 or newer,
and [LLVM](https://llvm.org)/[Clang](https://clang.llvm.org) 23.
### Linux
diff --git a/docs/050-hello-world.md b/docs/050-hello-world.md
index 194e4bbd..971f29b8 100644
--- a/docs/050-hello-world.md
+++ b/docs/050-hello-world.md
@@ -2,8 +2,23 @@
## Installing cx
-Compiling cx requires a C++20 compiler, [CMake](https://cmake.org) 3.16 or newer,
-and [LLVM](https://llvm.org)/[Clang](https://clang.llvm.org) 23.
+The fastest install on Linux and macOS is the install script, which downloads
+the prebuilt binary for your platform into `~/.cx` and smoke-tests it:
+
+```sh
+curl https://cx-language.github.io/cx/install.sh | bash
+```
+
+It adds itself to your `PATH` (bash, zsh, and fish), so new shells pick it up
+automatically; in the current shell, run the `export` it prints first.
+Prebuilt binaries cover Linux (x64), macOS (Apple Silicon), and Windows (x64).
+The Windows archive and manual downloads for all platforms are on the
+[releases page](https://github.com/cx-language/cx/releases); each archive also
+contains the [`cx-lsp` language server](./lsp) and the standard library.
+
+To build from source instead (required on Intel Macs and other platforms
+without a prebuilt binary), you need a C++20 compiler, [CMake](https://cmake.org)
+3.16 or newer, and [LLVM](https://llvm.org)/[Clang](https://clang.llvm.org) 23.
On Ubuntu/Debian, install the prerequisites with:
diff --git a/install.sh b/install.sh
new file mode 100755
index 00000000..fb02bceb
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,104 @@
+#!/bin/sh
+# Installs cx from GitHub Releases into ~/.cx (override with CX_HOME).
+# Usage: curl https://cx-language.github.io/cx/install.sh | bash
+# Pin a version with CX_VERSION=v0.1 (default: latest).
+set -eu
+
+REPO="cx-language/cx"
+VERSION="${CX_VERSION:-latest}"
+DEST="${CX_HOME:-$HOME/.cx}"
+
+fail() {
+ echo "install.sh: $1" >&2
+ exit 1
+}
+
+detect_platform() {
+ os=$(uname -s)
+ arch=$(uname -m)
+ case "$os" in
+ Linux)
+ case "$arch" in
+ x86_64) platform="linux-x64" ;;
+ *) fail "no prebuilt binary for Linux/$arch (only x64); build from source: https://cx-language.github.io/cx/hello-world" ;;
+ esac
+ ;;
+ Darwin)
+ case "$arch" in
+ arm64) platform="macos-arm64" ;;
+ *) fail "no prebuilt binary for Intel Macs; build from source: https://cx-language.github.io/cx/hello-world" ;;
+ esac
+ ;;
+ MINGW* | MSYS* | CYGWIN*)
+ fail "Windows: download cx-windows-x64.zip from https://github.com/$REPO/releases/latest and unpack it"
+ ;;
+ *)
+ fail "unsupported platform $os/$arch; build from source: https://cx-language.github.io/cx/hello-world"
+ ;;
+ esac
+}
+
+release_url() {
+ if [ "$VERSION" = "latest" ]; then
+ echo "https://github.com/$REPO/releases/latest/download/cx-$platform.tar.gz"
+ else
+ echo "https://github.com/$REPO/releases/download/$VERSION/cx-$platform.tar.gz"
+ fi
+}
+
+add_to_path() {
+ case ":$PATH:" in
+ *":$DEST:"*) echo "$DEST is already on your PATH."; return ;;
+ esac
+ line="export PATH=\"$DEST:\$PATH\""
+ case "$(basename "${SHELL:-sh}")" in
+ bash)
+ if [ "$os" = "Darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi
+ ;;
+ zsh) rc="$HOME/.zshrc" ;;
+ fish)
+ rc="$HOME/.config/fish/config.fish"
+ line="fish_add_path \"$DEST\""
+ ;;
+ *)
+ echo "Put $DEST on your PATH, e.g.:"
+ echo " $line"
+ return
+ ;;
+ esac
+ mkdir -p "${rc%/*}"
+ if grep -qF "$DEST" "$rc" 2> /dev/null; then
+ echo "$DEST is already in $rc."
+ else
+ echo "$line" >> "$rc"
+ echo "Added $DEST to your PATH in $rc (takes effect in new shells)."
+ echo "For the current shell, run: $line"
+ fi
+}
+
+command -v curl > /dev/null 2>&1 || fail "curl is required"
+command -v tar > /dev/null 2>&1 || fail "tar is required"
+
+detect_platform
+url=$(release_url)
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT INT TERM
+
+echo "Downloading $url"
+curl -fsSL -o "$tmp/cx.tar.gz" "$url" || fail "download failed: $url"
+
+mkdir -p "$DEST"
+tar -xzf "$tmp/cx.tar.gz" -C "$DEST" --strip-components=1
+
+if [ "$os" = "Darwin" ]; then
+ # Curl-downloaded files are quarantined; the ad-hoc signed binary
+ # would be blocked on first run without this.
+ xattr -dr com.apple.quarantine "$DEST" 2> /dev/null || true
+fi
+
+printf 'void main() {\n println("install ok");\n}\n' > "$tmp/hello.cx"
+"$DEST/cx" run "$tmp/hello.cx" > /dev/null || fail "smoke test failed"
+
+echo "Installed cx to $DEST."
+add_to_path
diff --git a/website/build-website.sh b/website/build-website.sh
index 38e19cec..80b64467 100755
--- a/website/build-website.sh
+++ b/website/build-website.sh
@@ -272,6 +272,17 @@ if "##EXAMPLETABS##" in template:
"##EXAMPLETABS##", '
' + buttons + more + "
"
)
+# Button elements do not survive the pandoc round-trip (only their text
+# does), so the install tabs are injected here, after rendering.
+if "##OSTABS##" in template:
+ template = template.replace(
+ "##OSTABS##",
+ '
'
+ ''
+ ''
+ "
",
+ )
+
# Inline markup inside code does not survive pandoc either, so grey the
# shell prompts here. Only line-leading "$ "/"> " match, which cx code
# never has (interpolation is always mid-line).
@@ -325,6 +336,9 @@ python3 generate_search_index.py || exit
cp -r *.css *.js lib build
+# Installer served at /install.sh so the install command stays short.
+cp ../install.sh build/
+
# Gallery screenshots served from the site root.
cp ../examples/fractal/screenshot.jpg build/fractal-screenshot.jpg
cp ../examples/voxel-game/screenshot.jpg build/voxel-screenshot.jpg
diff --git a/website/index.css b/website/index.css
index d57730ff..f9e56eac 100644
--- a/website/index.css
+++ b/website/index.css
@@ -623,7 +623,8 @@ section {
overflow-x: auto;
}
-.example-tabs button {
+.example-tabs button,
+.os-tabs button {
font: inherit;
font-size: 13px;
background: transparent;
@@ -636,7 +637,9 @@ section {
}
.example-tabs button[aria-pressed="true"],
-.example-tabs button:hover {
+.example-tabs button:hover,
+.os-tabs button[aria-pressed="true"],
+.os-tabs button:hover {
background: var(--background-color-3);
color: var(--text-color);
}
@@ -661,6 +664,12 @@ section {
color: var(--text-color);
}
+.os-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 20px;
+}
+
/* The runnable editor keeps its docs-page look inside the window:
flatten pandoc's wrapper so there is only one box. */
.window div.sourceCode {
@@ -756,7 +765,8 @@ body.frontpage .part {
scroll-margin-top: 84px;
}
-.cards {
+.cards,
+.steps {
display: grid;
grid-gap: 20px;
}
@@ -765,17 +775,24 @@ body.frontpage .part {
grid-template-columns: repeat(2, 1fr);
}
+.steps {
+ grid-template-columns: repeat(3, 1fr);
+ counter-reset: step;
+}
+
@media screen and (max-width: 800px) {
- .cards {
+ .cards,
+ .steps {
grid-template-columns: 1fr;
}
}
-/* Cards with a leading heading come out of pandoc as sections
+/* Cards with a leading heading and steps come out of pandoc as sections
(it sectionizes classed heading-led divs; image-led gallery cards stay
divs), so neutralize the base section margin. */
.card,
-.cards > div {
+.cards > div,
+.step {
min-width: 0;
margin: 0;
}
@@ -817,12 +834,14 @@ body.frontpage .part {
background-color: var(--background-color) !important;
}
-.cards h3 {
+.cards h3,
+.step h3 {
font-size: 1rem;
margin: 0 0 6px;
}
-.cards p {
+.cards p,
+.step p {
font-size: 0.92rem;
margin: 0 0 10px;
}
@@ -872,6 +891,23 @@ pre.snippet {
margin: 0;
}
+.step {
+ padding-top: 12px;
+}
+
+.step h3::before {
+ counter-increment: step;
+ content: counter(step) ". ";
+ color: var(--text-color-subtle);
+}
+
+/* Long commands wrap instead of scrolling; copy-paste is unaffected. */
+.step pre.snippet {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ margin-bottom: 10px;
+}
+
.CodeMirror {
font-family: inherit;
background: transparent !important;
diff --git a/website/index.html b/website/index.html
index a446b219..e04baf4d 100644
--- a/website/index.html
+++ b/website/index.html
@@ -8,8 +8,9 @@
cx is a fast language that gets out of your way
simple C-like syntax, and type-safe constructs as efficient as hand-written C.
Installs into ~/.cx, adds itself to your PATH, and smoke-tests the result.
+
+
+
Write hello.cx
+
void main() {
+ println("Hello world");
+}
+
+
+
Run it
+
$ export PATH="$HOME/.cx:$PATH"
+$ cx run hello.cx
+Hello world
+
New shells get the PATH automatically; this shell needs one export first. Then cx run JIT-compiles and executes in one step. Point your editor at the bundled cx-lsp - see Editor setup.
> Expand-Archive cx-windows-x64.zip
+> cd cx-windows-x64
+
+
+
Write hello.cx
+
void main() {
+ println("Hello world");
+}
+
+
+
Run it
+
> .\cx.exe run hello.cx
+Hello world
+
cx run builds and executes in one step. Point your editor at the bundled cx-lsp.exe - see Editor setup.
+
+
+
+
+
Language overview
diff --git a/website/install.js b/website/install.js
new file mode 100644
index 00000000..06743d2d
--- /dev/null
+++ b/website/install.js
@@ -0,0 +1,24 @@
+// Front-page install tabs: show the instructions matching the visitor's OS.
+// Scripts load synchronously before parses, so wait for the DOM.
+document.addEventListener("DOMContentLoaded", () => {
+ const tabs = document.querySelector(".os-tabs");
+ if (!tabs) return;
+ const panes = document.querySelectorAll(".os-pane");
+
+ const show = (os) => {
+ tabs.querySelectorAll("button").forEach((button) => {
+ button.setAttribute("aria-pressed", button.getAttribute("data-os") === os ? "true" : "false");
+ });
+ panes.forEach((pane) => {
+ pane.hidden = pane.getAttribute("data-os") !== os;
+ });
+ };
+
+ tabs.addEventListener("click", (event) => {
+ const button = event.target.closest("button");
+ if (button) show(button.getAttribute("data-os"));
+ });
+
+ const platform = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "";
+ show(/win/i.test(platform) ? "windows" : "unix");
+});
diff --git a/website/test-install-sh.sh b/website/test-install-sh.sh
new file mode 100644
index 00000000..b1435eb0
--- /dev/null
+++ b/website/test-install-sh.sh
@@ -0,0 +1,68 @@
+#!/bin/sh
+# Offline test for ../install.sh: stubs out curl to serve a fake tarball and
+# installs into a fake HOME, then checks the PATH setup. Usage: sh test-install-sh.sh
+set -eu
+
+cd "$(dirname "$0")"
+
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT INT TERM
+
+os=$(uname -s)
+arch=$(uname -m)
+case "$os-$arch" in
+ Linux-x86_64 | Darwin-arm64) ;;
+ *) echo "skip: no prebuilt platform for $os/$arch"; exit 0 ;;
+esac
+
+mkdir -p "$tmp/pkg/cx-test" "$tmp/fakebin"
+printf '#!/bin/sh\nexit 0\n' > "$tmp/pkg/cx-test/cx"
+chmod +x "$tmp/pkg/cx-test/cx"
+tar -czf "$tmp/cx.tar.gz" -C "$tmp/pkg" cx-test
+
+# Stub curl: copies the fake tarball to the -o target, ignoring the URL.
+cat > "$tmp/fakebin/curl" < /dev/null
+grep -qF "fish_add_path \"$tmp/home2/.cx\"" "$tmp/home2/.config/fish/config.fish" || { echo "FAIL: no fish_add_path"; exit 1; }
+
+# Unknown shell: prints manual instructions instead.
+mkdir -p "$tmp/home3"
+out=$(run_install /bin/tcsh "$tmp/home3")
+echo "$out" | grep -q "Put $tmp/home3/.cx on your PATH" || { echo "FAIL: no manual fallback: $out"; exit 1; }
+
+# cx already on PATH: leaves profiles alone.
+mkdir -p "$tmp/home4"
+PATH="$tmp/home4/.cx:$tmp/fakebin:$PATH" HOME="$tmp/home4" SHELL=/bin/zsh sh ../install.sh > "$tmp/out4"
+grep -q "already on your PATH" "$tmp/out4" || { echo "FAIL: no on-PATH shortcut"; exit 1; }
+[ ! -e "$tmp/home4/.zshrc" ] || { echo "FAIL: touched .zshrc despite cx on PATH"; exit 1; }
+
+echo "PASS"
diff --git a/website/test-install.mjs b/website/test-install.mjs
new file mode 100644
index 00000000..1f3729d8
--- /dev/null
+++ b/website/test-install.mjs
@@ -0,0 +1,75 @@
+// Exercises website/install.js OS-tab wiring with a minimal fake DOM.
+// Run with: node website/test-install.mjs
+import { readFile } from "node:fs/promises";
+import vm from "node:vm";
+
+const repoRoot = new URL("..", import.meta.url).pathname;
+const src = await readFile(repoRoot + "website/install.js", "utf8");
+
+let failures = 0;
+function check(cond, message) {
+ console.log((cond ? "PASS: " : "FAIL: ") + message);
+ if (!cond) failures++;
+}
+
+function runCase(platform) {
+ const pressed = {};
+ const hidden = {};
+ const buttons = ["unix", "windows"].map((os) => ({
+ getAttribute: (k) => (k === "data-os" ? os : null),
+ setAttribute: (k, v) => {
+ if (k === "aria-pressed") pressed[os] = v;
+ },
+ }));
+ const panes = ["unix", "windows"].map((os) => ({
+ getAttribute: (k) => (k === "data-os" ? os : null),
+ set hidden(v) {
+ hidden[os] = v;
+ },
+ }));
+ let clickFn = null;
+ const tabs = {
+ querySelectorAll: () => buttons,
+ addEventListener: (name, fn) => {
+ clickFn = fn;
+ },
+ };
+ const listeners = {};
+ const sandbox = {
+ document: {
+ querySelector: (sel) => (sel === ".os-tabs" ? tabs : null),
+ querySelectorAll: (sel) => (sel === ".os-pane" ? panes : []),
+ addEventListener: (name, fn) => {
+ listeners[name] = fn;
+ },
+ },
+ navigator: { platform, userAgentData: undefined },
+ };
+ vm.createContext(sandbox);
+ vm.runInContext(src, sandbox);
+ return {
+ pressed,
+ hidden,
+ fireLoaded: () => listeners["DOMContentLoaded"](),
+ click: (os) => clickFn({ target: { closest: () => buttons[os === "unix" ? 0 : 1] } }),
+ };
+}
+
+let r = runCase("MacIntel");
+check(Object.keys(r.pressed).length === 0, "nothing happens before DOMContentLoaded");
+r.fireLoaded();
+check(r.pressed.unix === "true" && r.hidden.windows === true, "mac defaults to unix pane");
+r.click("windows");
+check(r.pressed.windows === "true" && r.hidden.unix === true, "manual switch to windows pane");
+
+r = runCase("Win32");
+r.fireLoaded();
+check(r.pressed.windows === "true" && r.hidden.unix === true, "windows detected");
+r.click("unix");
+check(r.pressed.unix === "true" && r.hidden.windows === true, "manual override back to unix");
+
+if (failures > 0) {
+ console.error(failures + " install test(s) failed");
+ process.exit(1);
+}
+console.log("All install tests passed.");
diff --git a/website/top-nav.html b/website/top-nav.html
index f788bb94..704681e1 100644
--- a/website/top-nav.html
+++ b/website/top-nav.html
@@ -3,6 +3,7 @@
+
From d09d00c6a1097f762b0234baf93eec7697df485c Mon Sep 17 00:00:00 2001
From: Emil Laine
Date: Fri, 9 Oct 2026 20:15:19 +0300
Subject: [PATCH 2/5] fix: link Linux prebuilt binaries against static LLVM
apt.llvm.org builds the LLVM dylib, so the release configuration linked
libLLVM shared and would have failed the standalone-linking assert. Add
CX_STATIC_LLVM (enabled on the Linux release leg), which selects the
component archives and rewrites the LLVM dylib edge that dylib-building
distributions export on every Clang archive's link interface. Also alias
the missing zstd spelling when a system zstd provides only one of
zstd::libzstd_static/_shared.
Also fix the macOS bundling step (cp -n exits 1 when the file already
exists, aborting the script), check both binaries in the standalone
asserts, and bump the release workflow actions to the versions
build.yml uses.
---
.github/workflows/release.yml | 27 ++++++++-----
CMakeLists.txt | 75 +++++++++++++++++++++++++++++++++--
2 files changed, 88 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 207d1c80..f31032b4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -37,7 +37,7 @@ jobs:
env:
STAGE_DIR: stage/cx-${{ matrix.platform }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up LLVM 23 and link dependencies
uses: ./.github/actions/setup-llvm
@@ -52,8 +52,10 @@ jobs:
extra_args=()
if [ "$RUNNER_OS" = "Linux" ]; then
# Static libstdc++/libgcc: the archive must run on older distros
- # than the build runner.
+ # than the build runner. Static LLVM/Clang: apt.llvm.org builds
+ # the LLVM dylib, which would otherwise link shared.
extra_args+=("-DCMAKE_EXE_LINKER_FLAGS=-static-libstdc++ -static-libgcc")
+ extra_args+=("-DCX_STATIC_LLVM=ON")
fi
if [ "$RUNNER_OS" = "Windows" ]; then
# Static CRT so users need no Visual C++ redistributable. The
@@ -101,7 +103,12 @@ jobs:
[ -f "$f" ] || continue
otool -L "$f" | tail -n +2 | awk '{print $1}' | while read -r dep; do
case "$dep" in
- /opt/homebrew/* | /usr/local/*) cp -n "$dep" "$lib/" ;;
+ # Copied once: cx and cx-lsp share dependencies, and -n
+ # would exit 1 on the second copy. A real cp failure must
+ # still fail the release, so guard with a test instead.
+ /opt/homebrew/* | /usr/local/*)
+ [ -e "$lib/${dep##*/}" ] || cp "$dep" "$lib/"
+ ;;
esac
done
done
@@ -137,10 +144,10 @@ jobs:
shell: bash
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
- deps=$(dumpbin /dependents "$STAGE_DIR/cx.exe")
+ deps=$(dumpbin /dependents "$STAGE_DIR/cx.exe" "$STAGE_DIR/cx-lsp.exe")
echo "$deps"
if echo "$deps" | grep -Ei 'vcruntime|msvcp|msvcr'; then
- echo "ERROR: cx needs the Visual C++ redistributable (/MT did not apply)"
+ echo "ERROR: binaries need the Visual C++ redistributable (/MT did not apply)"
exit 1
fi
elif [ "$RUNNER_OS" = "macOS" ]; then
@@ -154,9 +161,9 @@ jobs:
exit 1
fi
else
- ldd "$STAGE_DIR/cx"
- if ldd "$STAGE_DIR/cx" | grep -Ei 'llvm|clang|libstdc\+\+|libgcc'; then
- echo "ERROR: cx links LLVM, libstdc++, or libgcc dynamically"
+ ldd "$STAGE_DIR/cx" "$STAGE_DIR/cx-lsp"
+ if ldd "$STAGE_DIR/cx" "$STAGE_DIR/cx-lsp" | grep -Ei 'llvm|clang|libstdc\+\+|libgcc'; then
+ echo "ERROR: binaries link LLVM, libstdc++, or libgcc dynamically"
exit 1
fi
fi
@@ -186,7 +193,7 @@ jobs:
fi
- name: Upload archive artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: binaries-${{ matrix.platform }}
path: cx-${{ matrix.platform }}.*
@@ -198,7 +205,7 @@ jobs:
permissions:
contents: write
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v7
with:
pattern: binaries-*
merge-multiple: true
diff --git a/CMakeLists.txt b/CMakeLists.txt
index bfdc0463..a8a6ee35 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -73,6 +73,11 @@ if(CX_SANITIZE_LEAK)
add_link_options(-fsanitize=leak)
endif()
+# Prebuilt binaries link LLVM and Clang statically so the archives run
+# without an LLVM install. Without this, distributions that build the LLVM
+# dylib (Debian/Ubuntu, Homebrew) link libLLVM/libclang-cpp shared.
+option(CX_STATIC_LLVM "Link the LLVM/Clang static archives instead of the shared dylib" OFF)
+
# Instrumented build for the ASan gate (test/asan/check_asan.py), which runs
# the compiler and repeated LSP analyses under AddressSanitizer. Needs a Clang
# with -fsanitize=address (LLVM Clang or Apple Clang).
@@ -131,7 +136,7 @@ endif()
# --- zstd (real, required via LLVMSupport) ---
find_package(zstd QUIET)
-if(NOT TARGET zstd::libzstd_static)
+if(NOT TARGET zstd::libzstd_static AND NOT TARGET zstd::libzstd_shared)
message(STATUS "zstd not found, fetching zstd from source")
FetchContent_Declare(zstd
GIT_REPOSITORY https://github.com/facebook/zstd.git
@@ -156,11 +161,24 @@ if(NOT TARGET zstd::libzstd_static)
if(NOT TARGET zstd::libzstd_static)
add_library(zstd::libzstd_static ALIAS libzstd_static)
endif()
+ if(NOT TARGET zstd::libzstd_shared)
+ add_library(zstd::libzstd_shared ALIAS libzstd_static)
+ endif()
# As above: keep our unity-build default out of the zstd subbuild.
set_target_properties(libzstd_static PROPERTIES UNITY_BUILD OFF)
set(CMAKE_DISABLE_FIND_PACKAGE_zstd TRUE)
endif()
+# LLVM distributions disagree on the name: Debian/Ubuntu exports reference
+# zstd::libzstd_shared, others zstd::libzstd_static. A system zstd that only
+# provides one spelling aliases the other; both link the same library.
+if(TARGET zstd::libzstd_static AND NOT TARGET zstd::libzstd_shared)
+ add_library(zstd::libzstd_shared ALIAS zstd::libzstd_static)
+endif()
+if(TARGET zstd::libzstd_shared AND NOT TARGET zstd::libzstd_static)
+ add_library(zstd::libzstd_static ALIAS zstd::libzstd_shared)
+endif()
+
# --- LibXml2 (dummy suffices; LLVMWindowsManifest is not linked) ---
find_package(LibXml2 QUIET)
if(NOT TARGET LibXml2::LibXml2)
@@ -281,10 +299,11 @@ endif()
add_executable(cx-lsp src/lsp/main.cpp)
target_link_libraries(cx-lsp libcx)
-if(LLVM_LINK_LLVM_DYLIB)
+llvm_map_components_to_libnames(CX_LLVM_COMPONENT_LIBS core native linker orcjit passes support)
+if(LLVM_LINK_LLVM_DYLIB AND NOT CX_STATIC_LLVM)
set(LLVM_LIBS LLVM)
else()
- llvm_map_components_to_libnames(LLVM_LIBS core native linker orcjit passes support)
+ set(LLVM_LIBS ${CX_LLVM_COMPONENT_LIBS})
endif()
# The LibXml2/DIASDK dummy targets above only satisfy LLVMExports.cmake; they
# carry no library. Fail fast if a linked component ever needs the real one.
@@ -295,7 +314,7 @@ if(CX_DIASDK_DUMMY AND "LLVMDebugInfoPDB" IN_LIST LLVM_LIBS)
message(FATAL_ERROR "LLVMDebugInfoPDB requires the real DIA SDK, but the dummy target was used. Install the DIA SDK (Visual Studio) or point MSVC_DIA_SDK_DIR at it.")
endif()
-if(LLVM_LINK_LLVM_DYLIB AND TARGET clang-cpp)
+if(LLVM_LINK_LLVM_DYLIB AND TARGET clang-cpp AND NOT CX_STATIC_LLVM)
# Shared LLVM build: use only the shared libclang-cpp for the Clang C++
# API. It contains everything the static archives below have, so linking
# both only slows the link and bloats the binary. (Homebrew's static
@@ -305,6 +324,54 @@ if(LLVM_LINK_LLVM_DYLIB AND TARGET clang-cpp)
else()
list(APPEND LLVM_LIBS clangAST clangBasic clangFrontend clangLex clangParse clangSema clangDriver)
endif()
+# Dylib-building distributions (Debian/Ubuntu, Homebrew) export every Clang
+# archive's link interface with a trailing `LLVM` edge onto the shared dylib,
+# which would reintroduce the dylib even though LLVM_LIBS above names the
+# static components. Static builds rewrite that edge to the components,
+# keeping the other edges (transitive Clang archives) intact. The component
+# set already covers every LLVM symbol the Clang archives need: non-dylib
+# builds (Windows) link this exact combination.
+if(CX_STATIC_LLVM AND LLVM_LINK_LLVM_DYLIB)
+ # Walk the Clang link closure so transitive Clang archives (which carry
+ # the same `LLVM` edge) are covered on any LLVM version.
+ set(CX_CLANG_LIBS clangAST clangBasic clangFrontend clangLex clangParse clangSema clangDriver)
+ set(CX_CLANG_SEEN "")
+ set(CX_CLANG_QUEUE ${CX_CLANG_LIBS})
+ while(CX_CLANG_QUEUE)
+ list(POP_FRONT CX_CLANG_QUEUE CX_LIB)
+ if(CX_LIB IN_LIST CX_CLANG_SEEN OR NOT TARGET ${CX_LIB})
+ continue()
+ endif()
+ list(APPEND CX_CLANG_SEEN ${CX_LIB})
+ get_target_property(CX_IFACE ${CX_LIB} INTERFACE_LINK_LIBRARIES)
+ if(CX_IFACE)
+ foreach(dep IN LISTS CX_IFACE)
+ if(dep MATCHES "^clang")
+ list(APPEND CX_CLANG_QUEUE ${dep})
+ elseif(dep MATCHES "^\\$$")
+ list(APPEND CX_CLANG_QUEUE ${CMAKE_MATCH_1})
+ endif()
+ endforeach()
+ endif()
+ endwhile()
+ set(CX_REWRITTEN 0)
+ foreach(clang_lib IN LISTS CX_CLANG_SEEN)
+ get_target_property(clang_iface ${clang_lib} INTERFACE_LINK_LIBRARIES)
+ # The edge spells `LLVM` today; a LINK_ONLY spelling would silently
+ # reintroduce the dylib, so match that too.
+ if("LLVM" IN_LIST clang_iface OR "$" IN_LIST clang_iface)
+ list(REMOVE_ITEM clang_iface LLVM "$")
+ list(APPEND clang_iface ${CX_LLVM_COMPONENT_LIBS})
+ list(REMOVE_DUPLICATES clang_iface)
+ set_target_properties(${clang_lib} PROPERTIES INTERFACE_LINK_LIBRARIES "${clang_iface}")
+ math(EXPR CX_REWRITTEN "${CX_REWRITTEN} + 1")
+ endif()
+ endforeach()
+ if(CX_REWRITTEN EQUAL 0)
+ message(FATAL_ERROR "CX_STATIC_LLVM found no LLVM dylib edge to rewrite; refusing to link shared silently")
+ endif()
+ message(STATUS "CX_STATIC_LLVM: rewrote LLVM dylib edge on ${CX_REWRITTEN} Clang targets")
+endif()
target_link_libraries(libcx ${LLVM_LIBS})
# Tests. Note: tests are run with CTest rather than custom build targets so
From 7f24a9f3552b4fdb6a1e4b177c83473eae8ab4f2 Mon Sep 17 00:00:00 2001
From: Emil Laine
Date: Fri, 9 Oct 2026 20:53:23 +0300
Subject: [PATCH 3/5] fix: repair Linux static link and Windows assert in
release CI
Add the plugins LLVM component: PassPlugin::Load lives in LLVMPlugins,
which non-dylib builds pull through the Clang link interfaces but the
dylib-edge rewrite did not name.
Use dumpbin -dependents: git-bash rewrites a leading /dependents to a
Windows path, failing the assert with LNK1181.
---
.github/workflows/release.yml | 4 +++-
CMakeLists.txt | 6 +++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f31032b4..cd792f40 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -144,7 +144,9 @@ jobs:
shell: bash
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
- deps=$(dumpbin /dependents "$STAGE_DIR/cx.exe" "$STAGE_DIR/cx-lsp.exe")
+ # Dash, not slash: git-bash would rewrite /dependents to a
+ # Windows path (C:\Program Files\Git\dependents).
+ deps=$(dumpbin -dependents "$STAGE_DIR/cx.exe" "$STAGE_DIR/cx-lsp.exe")
echo "$deps"
if echo "$deps" | grep -Ei 'vcruntime|msvcp|msvcr'; then
echo "ERROR: binaries need the Visual C++ redistributable (/MT did not apply)"
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a8a6ee35..c8071df6 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -299,7 +299,11 @@ endif()
add_executable(cx-lsp src/lsp/main.cpp)
target_link_libraries(cx-lsp libcx)
-llvm_map_components_to_libnames(CX_LLVM_COMPONENT_LIBS core native linker orcjit passes support)
+# plugins is only referenced by the Clang archives (PassPlugin::Load in
+# clangFrontend): non-dylib builds pull it through the Clang link interfaces,
+# so it must be named here for the dylib-edge rewrite below to reproduce that
+# closure on dylib-building distributions.
+llvm_map_components_to_libnames(CX_LLVM_COMPONENT_LIBS core native linker orcjit passes support plugins)
if(LLVM_LINK_LLVM_DYLIB AND NOT CX_STATIC_LLVM)
set(LLVM_LIBS LLVM)
else()
From 3400df9a10bd123b83824f337f209d2ab24d7b38 Mon Sep 17 00:00:00 2001
From: Emil Laine
Date: Fri, 9 Oct 2026 21:21:34 +0300
Subject: [PATCH 4/5] fix: bundle lld-link so Windows native builds need no Dev
shell
Without a C compiler on PATH, cx falls back to its embedded Clang,
which shells out to lld-link. The build-time LLVM tools dir baked into
CX_LLVM_TOOLS_DIR doesn't exist on user machines, so native builds
failed with 'program not executable' outside a VS Developer shell.
Stage lld-link.exe into the Windows archive and prefer the copy next
to cx.exe; MSVC/SDK libraries are still found via the registry.
The release smoke test now also links with no compiler on PATH, and
the standalone assert covers lld-link too. Document the C toolchain
prerequisite for native compilation.
---
.github/workflows/release.yml | 14 ++++++++++++--
docs/050-hello-world.md | 3 +++
src/driver/driver.cpp | 13 +++++++++++--
3 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cd792f40..6f67ed6b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -80,6 +80,10 @@ jobs:
mkdir -p "$STAGE_DIR"
if [ "$RUNNER_OS" = "Windows" ]; then
cp "${{runner.workspace}}/build/Release/cx.exe" "${{runner.workspace}}/build/Release/cx-lsp.exe" "$STAGE_DIR/"
+ # lld-link powers native builds on machines with no C compiler on
+ # PATH (the driver prefers it next to cx.exe over the build-time
+ # LLVM tools dir, which doesn't exist on user machines).
+ cp "${LLVMDIR}\\bin\\lld-link.exe" "$STAGE_DIR/"
else
cp "${{runner.workspace}}/build/cx" "${{runner.workspace}}/build/cx-lsp" "$STAGE_DIR/"
strip "$STAGE_DIR/cx" "$STAGE_DIR/cx-lsp"
@@ -146,7 +150,7 @@ jobs:
if [ "$RUNNER_OS" = "Windows" ]; then
# Dash, not slash: git-bash would rewrite /dependents to a
# Windows path (C:\Program Files\Git\dependents).
- deps=$(dumpbin -dependents "$STAGE_DIR/cx.exe" "$STAGE_DIR/cx-lsp.exe")
+ deps=$(dumpbin -dependents "$STAGE_DIR/cx.exe" "$STAGE_DIR/cx-lsp.exe" "$STAGE_DIR/lld-link.exe")
echo "$deps"
if echo "$deps" | grep -Ei 'vcruntime|msvcp|msvcr'; then
echo "ERROR: binaries need the Visual C++ redistributable (/MT did not apply)"
@@ -182,6 +186,12 @@ jobs:
# Also exercise the native compile+link path (uses the system linker).
"$pkg/cx" hello.cx -o hello-native
./hello-native
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ # Bare-machine simulation: with no compiler on PATH, native builds
+ # must still link through the bundled lld-link.
+ PATH="/usr/bin:/bin" "$pkg/cx" hello.cx -o hello-embedded
+ ./hello-embedded
+ fi
"$pkg/cx-lsp" --help > /dev/null
- name: Create archive
@@ -218,7 +228,7 @@ jobs:
files: dist/*
generate_release_notes: true
body: |
- Prebuilt cx binaries for Linux (x64), macOS (Apple Silicon), and Windows (x64). Each archive holds the `cx` compiler, the `cx-lsp` language server, and the standard library. Unpack it anywhere and run `cx` directly; no installer and no dependencies, though compiling to native binaries uses your system C compiler as the linker.
+ Prebuilt cx binaries for Linux (x64), macOS (Apple Silicon), and Windows (x64). Each archive holds `cx`, `cx-lsp`, and the standard library (Windows also bundles the lld linker). No installer, no dependencies: unpack and run. Native compilation needs a C toolchain: `cc` on PATH on Linux/macOS, Visual Studio Build Tools installed on Windows.
Fastest install on Linux/macOS: `curl https://cx-language.github.io/cx/install.sh | bash`
diff --git a/docs/050-hello-world.md b/docs/050-hello-world.md
index 971f29b8..fa04f8aa 100644
--- a/docs/050-hello-world.md
+++ b/docs/050-hello-world.md
@@ -15,6 +15,9 @@ Prebuilt binaries cover Linux (x64), macOS (Apple Silicon), and Windows (x64).
The Windows archive and manual downloads for all platforms are on the
[releases page](https://github.com/cx-language/cx/releases); each archive also
contains the [`cx-lsp` language server](./lsp) and the standard library.
+Native compilation needs a C toolchain as well: `cc` on `PATH` on Linux/macOS,
+or Visual Studio Build Tools installed on Windows (`cx run` JIT-compiles and
+needs nothing).
To build from source instead (required on Intel Macs and other platforms
without a prebuilt binary), you need a C++20 compiler, [CMake](https://cmake.org)
diff --git a/src/driver/driver.cpp b/src/driver/driver.cpp
index 2f8c5e10..493f3194 100644
--- a/src/driver/driver.cpp
+++ b/src/driver/driver.cpp
@@ -902,9 +902,18 @@ int cx::buildModule(Module& mainModule, BuildParams buildParams) {
// Embedded Clang locates the MSVC and Windows SDK libraries itself; pass the CRT
// libraries in -l form so they are found along its search paths, plus the same
// 8MB stack reservation as the MSVC link above.
+ // Prebuilt installs bundle lld-link next to cx.exe: prefer it, since the
+ // build-time LLVM tools directory below doesn't exist on user machines.
+ // (ccArgs borrows from it, so it must outlive the link.)
+ std::string bundledToolsFlag;
+ std::string rootDir = getCxRootDir();
+ if (!rootDir.empty() && llvm::sys::fs::exists(rootDir + "/lld-link.exe")) {
+ bundledToolsFlag = "-B" + rootDir;
+ ccArgs.push_back(bundledToolsFlag.c_str());
+ }
#ifdef CX_LLVM_TOOLS_DIR
- // Point the driver at its own subprograms (lld-link): there are none next to
- // cx.exe, and LLVM's bin directory is not on PATH on a bare machine.
+ // Point the driver at its own subprograms (lld-link): LLVM's bin
+ // directory is not on PATH on a bare machine.
ccArgs.push_back("-B" CX_LLVM_TOOLS_DIR);
#endif
ccArgs.push_back("-l");
From 926146256fb633149ec39e81357c4aec73b00d0a Mon Sep 17 00:00:00 2001
From: Emil Laine
Date: Fri, 9 Oct 2026 21:29:14 +0300
Subject: [PATCH 5/5] fix: keep bundled-tools flag alive for the whole link
bundledToolsFlag was block-scoped while ccArgs borrowed its c_str past
the block, corrupting the link command (use-after-free). Hoist it next
to msvcLinkStorage.
---
src/driver/driver.cpp | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/driver/driver.cpp b/src/driver/driver.cpp
index 493f3194..dc1364ee 100644
--- a/src/driver/driver.cpp
+++ b/src/driver/driver.cpp
@@ -852,6 +852,8 @@ int cx::buildModule(Module& mainModule, BuildParams buildParams) {
// MSVC takes no -L/-l; its translated form is staged here (ccArgs borrows
// from it, so it must outlive the link) and appended after -link below.
std::vector msvcLinkStorage;
+ // Same for the bundled-tools -B flag below.
+ std::string bundledToolsFlag;
if (isMSVC) {
msvcLinkStorage = msvcLinkArgs(librarySearchPaths, libraries);
} else {
@@ -904,8 +906,6 @@ int cx::buildModule(Module& mainModule, BuildParams buildParams) {
// 8MB stack reservation as the MSVC link above.
// Prebuilt installs bundle lld-link next to cx.exe: prefer it, since the
// build-time LLVM tools directory below doesn't exist on user machines.
- // (ccArgs borrows from it, so it must outlive the link.)
- std::string bundledToolsFlag;
std::string rootDir = getCxRootDir();
if (!rootDir.empty() && llvm::sys::fs::exists(rootDir + "/lld-link.exe")) {
bundledToolsFlag = "-B" + rootDir;