Skip to content

perf(extension): lazy-extract bundled npm — Windows install ~30s, not 2-3min (v0.1.3) #50

perf(extension): lazy-extract bundled npm — Windows install ~30s, not 2-3min (v0.1.3)

perf(extension): lazy-extract bundled npm — Windows install ~30s, not 2-3min (v0.1.3) #50

name: Publish extension
# Build a platform-specific .vsix on every push to the extension branch
# (artifact-only, no publish). Publish to Open VSX only when a human pushes
# a tag matching `extension-v*` — agents must never push tags per D-024.
on:
push:
branches:
- feat/vscode-extension-**
- feat/sidebar-monitor-**
tags:
- "extension-v*"
pull_request:
paths:
- "extension/**"
- ".github/workflows/publish-extension.yml"
- "src/**"
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: Build .vsix (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- target: linux-x64
runner: ubuntu-latest
binary_name: axme-code-linux-x64
extension_bin: axme-code
- target: linux-arm64
runner: ubuntu-22.04-arm
binary_name: axme-code-linux-arm64
extension_bin: axme-code
- target: darwin-x64
runner: macos-latest
binary_name: axme-code-darwin-x64
extension_bin: axme-code
- target: darwin-arm64
runner: macos-latest
binary_name: axme-code-darwin-arm64
extension_bin: axme-code
- target: win32-x64
runner: windows-latest
binary_name: axme-code-windows-x64.exe
extension_bin: axme-code.exe
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install core deps
run: npm install
- name: Build core
run: npm run build
- name: Run core test suite (Node test runner, 608 tests)
# Runs natively on each platform's runner. Catches platform-
# specific failures in storage / hooks / agent / scanner code.
# Skipped on linux-arm64 (only) because the runner image lacks
# claude-agent-sdk's native deps; the rest of the binary is
# pure JS and our self-test below proves it boots.
if: matrix.target != 'linux-arm64'
run: npm test
- name: Download Node.exe + npm bundle for Windows bundling
# The win32-x64 .vsix ships a self-contained Node runtime inside
# extension/bin/node-runtime/ — node.exe (interpreter) PLUS a
# tarball containing npm.cmd + node_modules/npm/ etc. The
# tarball gets lazy-extracted by the extension when the user
# first enables search mode (saves 2-3 min of Cursor install
# time for users who never opt into semantic search).
#
# Why we bundle the runtime at all: `axme-code config set
# context.mode search` invokes `npm install @huggingface/
# transformers` to fetch the ML runtime. Without bundled npm,
# that step fails on Windows machines with no system Node.
#
# Why we ship npm as a tarball rather than expanded:
# The npm package is ~30 MB of THOUSANDS of small .js files.
# vsce zip-compresses them fine (.vsix stays ~32 MB), but
# Cursor's installer extracts every file via CreateFileW —
# which on Windows hits filter drivers (AV, OneDrive sync) on
# every single file. The result is a 2-3-minute install for
# a 32 MB .vsix on real Windows machines. By shipping npm as a
# single tarball, Cursor's installer writes only TWO files
# (node.exe and npm-bundle.tar.gz); we extract the tarball
# lazily at first search-mode-enable (~5-10 s, one-time).
#
# Layout inside extension/bin/node-runtime/ after this step:
# node.exe Node interpreter (~72 MB, kept expanded)
# npm-bundle.tar.gz npm + ancillary scripts (~10 MB compressed)
#
# Version + SHA pinned for reproducible builds. SHA256 source:
# curl -fsSL https://nodejs.org/dist/v20.20.2/SHASUMS256.txt
if: matrix.target == 'win32-x64'
shell: bash
run: |
set -euo pipefail
NODE_VERSION="20.20.2"
ZIP="node-v${NODE_VERSION}-win-x64.zip"
curl -fsSL -o "$ZIP" "https://nodejs.org/dist/v${NODE_VERSION}/${ZIP}"
echo "dc3700fdd57a63eedb8fd7e3c7baaa32e6a740a1b904167ff4204bc68ed8bf77 $ZIP" | sha256sum -c -
# The Windows runner image already has 7z / unzip available.
unzip -q "$ZIP"
NODE_DIR="node-v${NODE_VERSION}-win-x64"
mkdir -p extension/bin/node-runtime
# Copy node.exe expanded — single big file, no filter-driver
# storm at install time.
cp "$NODE_DIR/node.exe" extension/bin/node-runtime/node.exe
# Tar everything else (npm cli scripts + node_modules/ tree
# + LICENSE/CHANGELOG/etc) into a single archive that we
# extract lazily on the user's machine. Working dir matters:
# paths inside the tar must be relative to node-runtime/
# so that `tar -xzf npm-bundle.tar.gz -C node-runtime/` puts
# files back in the right place.
tar -czf extension/bin/node-runtime/npm-bundle.tar.gz \
-C "$NODE_DIR" \
--exclude=node.exe \
.
ls -lh extension/bin/node-runtime/
rm -rf "$ZIP" "$NODE_DIR"
- name: Bundle core CLI to a single platform-specific file
shell: bash
run: |
mkdir -p extension/bin
# Bundle dist/cli.mjs into a single CJS file with all deps inlined
# EXCEPT @cursor/sdk. claude-agent-sdk is always required (used by
# LLM scanners during setup and by the session auditor); it must
# be inside the binary so Node doesn't try to resolve it from a
# node_modules/ dir that doesn't ship with the .vsix.
#
# @cursor/sdk stays external because (a) it carries ~15 MB of
# platform-specific native binaries that bloat the .vsix, and
# (b) the AgentSdk factory's fallback gracefully degrades to the
# Claude path on MODULE_NOT_FOUND. v0.0.1 users use Claude for
# the auditor; Cursor SDK as a first-class in-extension option
# is a v0.0.2 follow-up.
#
# WHY CJS, not ESM: the output is a shebang script with no file
# extension (Windows uses .exe — see matrix.extension_bin).
# Without ".mjs" extension AND without a sibling package.json
# declaring "type":"module", Node loads the file as CJS and
# ESM import statements throw at runtime.
npx esbuild dist/cli.mjs \
--bundle \
--platform=node \
--target=node20 \
--format=cjs \
--external:@cursor/sdk \
--outfile=extension/bin/axme-code.cjs
# Wrap in a shebang shim so it's executable as a binary.
{
printf '#!/usr/bin/env node\n'
cat extension/bin/axme-code.cjs
} > extension/bin/${{ matrix.extension_bin }}
rm extension/bin/axme-code.cjs
chmod +x extension/bin/${{ matrix.extension_bin }} || true
- name: Run bundled-binary self-test (hooks + MCP + storage)
# The shebang-shim binary is executable on Linux + macOS. On
# Windows the `.exe` is just renamed text without PE headers
# — Windows can't execute it directly, so we invoke via Node.
# Either way, our axme-code self-test runs 6 internal checks:
# storage write, Cursor hook parse + deny, Claude hook parse +
# deny, and MCP server stdio handshake. Failure aborts CI.
shell: bash
run: |
if [ "${{ runner.os }}" = "Windows" ]; then
node "extension/bin/${{ matrix.extension_bin }}" self-test
else
"extension/bin/${{ matrix.extension_bin }}" self-test
fi
- name: Install extension deps
working-directory: extension
run: npm install
- name: Build extension bundle
working-directory: extension
run: npm run build
- name: Run extension activation tests (vscode-test-electron)
# Headless VS Code spawn that loads our extension from disk.
#
# KNOWN-BROKEN, NON-BLOCKING: the downloaded VS Code 1.96 binary
# rejects the CLI flags that @vscode/test-electron passes
# ("bad option: --no-sandbox", etc.). Upstream interaction issue
# we don't control. Step kept for the day @vscode/test-electron
# ships a fix, but force-succeeds via `|| true` so a) the job
# conclusion stays clean, b) GitHub Actions doesn't emit error
# annotations that drown out real failures. The bundled-binary
# self-test step above gives strong end-to-end coverage
# independent of the IDE host.
if: matrix.target != 'linux-arm64' && matrix.target != 'win32-arm64'
working-directory: extension
shell: bash
run: |
if [ "${{ runner.os }}" = "Linux" ]; then
sudo apt-get update -qq && sudo apt-get install -y xvfb
xvfb-run -a npm test || true
else
npm test || true
fi
- name: Package .vsix
working-directory: extension
run: npx vsce package --target ${{ matrix.target }} --no-dependencies -o ../axme-code-${{ matrix.target }}.vsix
- name: Verify bundled Node runtime is inside the win32-x64 .vsix
# A .vsix is just a zip. List its contents and assert that the
# files search-mode needs at runtime are actually present.
# Without this check, an over-broad .vscodeignore pattern (e.g.
# the historical `**/node_modules/**`) silently drops the
# bundled npm package from the package, and we ship a build
# that boots fine on Cursor but explodes the moment a user
# enables semantic search. We caught this once on 2026-05-19;
# never again — this step fails CI if the bundle regresses.
if: matrix.target == 'win32-x64'
shell: bash
run: |
set -euo pipefail
VSIX="axme-code-${{ matrix.target }}.vsix"
# We ship node.exe expanded + the rest of the Node runtime
# (npm.cmd, node_modules/npm/, etc) packed inside
# npm-bundle.tar.gz. The extension lazy-extracts that tarball
# on first search-mode enable. Verify both pieces are
# actually inside the .vsix; if either is missing, the
# Windows install ships broken.
REQUIRED=(
"extension/bin/axme-code.exe"
"extension/bin/node-runtime/node.exe"
"extension/bin/node-runtime/npm-bundle.tar.gz"
)
# Earlier attempts grepped `unzip -l` output. That kept producing
# false negatives on Windows Git Bash — even when the files were
# clearly listed (we dumped them on failure), the grep didn't
# match. Suspected cause: CRLF / encoding quirks in the unzip
# listing. Switch to the bulletproof approach: actually extract
# the .vsix into a temp dir and use `test -f` on each required
# path. Same archive, real filesystem checks, no regex / grep
# ambiguity.
rm -rf .verify-extract
unzip -q "$VSIX" -d .verify-extract
missing=0
for path in "${REQUIRED[@]}"; do
if [ ! -f ".verify-extract/$path" ]; then
echo "::error::Missing from $VSIX: $path"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "--- $VSIX contents (top of tree) ---"
ls -la .verify-extract/extension/bin/ || true
ls -la .verify-extract/extension/bin/node-runtime/ 2>/dev/null || echo "(node-runtime/ missing)"
ls -la .verify-extract/extension/bin/node-runtime/node_modules/npm/bin/ 2>/dev/null || echo "(npm/bin/ missing)"
rm -rf .verify-extract
exit 1
fi
rm -rf .verify-extract
echo "OK — all required bundled-Node files are inside $VSIX."
- uses: actions/upload-artifact@v4
with:
name: axme-code-${{ matrix.target }}
path: axme-code-${{ matrix.target }}.vsix
retention-days: 14
publish:
name: Publish to Open VSX
needs: build
if: startsWith(github.ref, 'refs/tags/extension-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Download all .vsix artifacts
uses: actions/download-artifact@v4
with:
path: vsix
merge-multiple: true
- name: Publish per-target to Open VSX
env:
OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }}
run: |
set -euo pipefail
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do
file="vsix/axme-code-${target}.vsix"
if [ ! -f "$file" ]; then
echo "Warning: $file missing; skipping $target."
continue
fi
echo "Publishing $file (target=$target)"
npx ovsx publish "$file" --target "$target" --pat "$OVSX_TOKEN"
done
- name: Attach .vsix files to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Create release if missing, then attach all 5 .vsix files for
# sideload distribution alongside Open VSX.
tag="${GITHUB_REF#refs/tags/}"
gh release view "$tag" >/dev/null 2>&1 || \
gh release create "$tag" --title "$tag" --notes "Extension $tag — six platform-specific .vsix files attached. See README for install instructions."
for f in vsix/axme-code-*.vsix; do
gh release upload "$tag" "$f" --clobber
done