perf(extension): lazy-extract bundled npm — Windows install ~30s, not 2-3min (v0.1.3) #50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |