Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 50 additions & 30 deletions .github/workflows/publish-extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,36 @@ jobs:
if: matrix.target != 'linux-arm64'
run: npm test

- name: Download Node.exe + npm for Windows bundling
- 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) AND
# npm.cmd + node_modules/npm/ (so search-mode runtime install
# works without the user having Node/npm installed).
# 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).
#
# We need npm because `axme-code config set context.mode search`
# invokes `npm install @huggingface/transformers` to fetch the
# ML runtime. Without bundled npm, that step fails with
# "'npm.cmd' is not recognized" (reported 2026-05-19).
# 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.
#
# Layout inside extension/bin/node-runtime/:
# node.exe Node interpreter (~30 MB)
# npm.cmd, npx.cmd npm wrappers (a few KB each)
# node_modules/npm/ npm's actual code (~30 MB)
# (other files like LICENSE, README — kept for legal hygiene)
# Total ~75 MB per win32-x64 .vsix. Open VSX accepts up to
# 256 MB per file.
# 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
# Earlier attempts (PR #136) used the user's own Node or
# Cursor's bundled Electron via ELECTRON_RUN_AS_NODE — both
# fragile and inconsistent on real Windows machines.
if: matrix.target == 'win32-x64'
shell: bash
run: |
Expand All @@ -105,17 +111,27 @@ jobs:
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` works on the runner's Git-Bash environment.
unzip -q "$ZIP"
NODE_DIR="node-v${NODE_VERSION}-win-x64"
mkdir -p extension/bin/node-runtime
# Copy the entire extracted dir into a stable name. npm.cmd
# looks for node.exe and node_modules/npm/ RELATIVE to its
# own dir (via %~dp0), so all three artefacts must be co-
# located. Renaming the whole tree to node-runtime/ keeps
# the layout npm expects without divergent forks.
cp -r "node-v${NODE_VERSION}-win-x64/." extension/bin/node-runtime/
ls -la extension/bin/node-runtime/node.exe extension/bin/node-runtime/npm.cmd
rm -rf "$ZIP" "node-v${NODE_VERSION}-win-x64"

# 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
Expand Down Expand Up @@ -218,12 +234,16 @@ jobs:
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.cmd"
"extension/bin/node-runtime/node_modules/npm/bin/npm-cli.js"
"extension/bin/node-runtime/node_modules/npm/bin/npm-prefix.js"
"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
Expand Down
2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "axme-code",
"displayName": "AXME Code",
"description": "Persistent memory, decisions, and safety guardrails for Cursor, GitHub Copilot, Cline, Continue, Roo Code, Windsurf, and VS Code chat agents",
"version": "0.1.2",
"version": "0.1.3",
"publisher": "AxmeAI",
"repository": {
"type": "git",
Expand Down
122 changes: 122 additions & 0 deletions extension/src/bundled-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Lazy extraction of the bundled Node runtime tarball on Windows.
*
* The win32-x64 .vsix ships node.exe expanded plus a tarball
* (`extension/bin/node-runtime/npm-bundle.tar.gz`) containing the
* npm CLI scripts (npm.cmd, npx.cmd) and the full node_modules/
* tree (npm, corepack, etc) — roughly 30 MB unpacked, thousands of
* small .js files.
*
* Why a tarball: Cursor's installer extracts each .vsix file via
* CreateFileW, which on Windows hits filter drivers (Windows
* Defender, OneDrive sync agent, third-party AV) on every single
* file. With 3000+ npm files this drove install time from ~30 s
* to 2-3 min on real users' machines. Bundling them as a tarball
* lets Cursor write a single ~10 MB file, then we extract on
* demand via Windows' built-in tar.exe — which doesn't go through
* the same per-file filter-driver path and finishes in ~5-10 s.
*
* Extraction is one-time per install: we drop a sentinel file once
* done, and skip on subsequent calls. POSIX platforms ship the
* shebang-shim binary natively and don't need bundled Node at all,
* so this entire module is a no-op there.
*/

import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { log, logError } from "./log.js";

const execFileAsync = promisify(execFile);

let _extensionPath: string | undefined;

/**
* Stash the extension's install path so ensureBundledNpmExtracted()
* can find the bundled runtime tarball without callers having to
* pass vscode.ExtensionContext through every layer. Called once at
* activation from extension.ts (mirrors the setBundledNode pattern
* in spawn-binary.ts).
*/
export function setExtensionPath(p: string): void {
_extensionPath = p;
}

/**
* Ensure the bundled npm runtime is extracted next to the bundled
* node.exe. Idempotent — returns immediately if already extracted
* (or if we're on a non-Windows platform where no bundle ships).
*
* Throws an error if extraction fails. Callers should surface this
* to the user via a toast — without npm extracted, search-mode
* cannot fetch the transformers runtime.
*
* Async (Promise-based) so the extension host event loop stays
* responsive during extraction — earlier versions used execFileSync
* which blocked all UI (button clicks, sidebar re-renders) for the
* 5-10 s of tar extraction. Users saw "frozen" buttons. Callers can
* `await` this from within a `withProgress` block to show a
* meaningful "Extracting bundled runtime..." indicator.
*
* @returns true if extraction ran this call, false if it was
* already extracted (or non-Windows).
*/
export async function ensureBundledNpmExtracted(): Promise<boolean> {
if (process.platform !== "win32") return false;
if (!_extensionPath) {
throw new Error(
"AXME Code: setExtensionPath() was not called before ensureBundledNpmExtracted(). " +
"This is an internal extension wiring bug; please report.",
);
}

const nodeRuntimeDir = join(_extensionPath, "bin", "node-runtime");
// Sentinel: an arbitrary file that's only present after the tarball
// is extracted. npm-cli.js is the file search-install.ts itself
// probes for, so reusing it keeps the contract consistent.
const sentinel = join(nodeRuntimeDir, "node_modules", "npm", "bin", "npm-cli.js");
if (existsSync(sentinel)) {
return false;
}

const tarball = join(nodeRuntimeDir, "npm-bundle.tar.gz");
if (!existsSync(tarball)) {
throw new Error(
`AXME Code: bundled npm runtime tarball missing at ${tarball}. ` +
`The .vsix install appears incomplete — please uninstall and reinstall ` +
`the extension.`,
);
}

const start = Date.now();
log(`Bundled runtime: extracting ${tarball} ...`);

try {
// Use Windows' built-in tar.exe (ships with Windows 10 1803+, 2018).
// bsdtar under the hood. Handles .tar.gz transparently via -z.
// `-C` sets the destination dir. Async wrapper so the extension
// host event loop stays responsive.
await execFileAsync("tar", ["-xzf", tarball, "-C", nodeRuntimeDir], {
windowsHide: true,
});
} catch (err) {
logError("Bundled runtime extraction failed", err);
throw new Error(
`AXME Code: failed to extract bundled npm runtime. Windows tar.exe ` +
`(built-in since Win10 1803) is required for this step. ` +
`Underlying error: ${(err as Error).message}`,
);
}

if (!existsSync(sentinel)) {
throw new Error(
`AXME Code: tarball extraction completed but npm-cli.js still missing ` +
`at ${sentinel} — the bundled tarball may be corrupt.`,
);
}

const elapsedMs = Date.now() - start;
log(`Bundled runtime: extracted in ${elapsedMs}ms`);
return true;
}
2 changes: 2 additions & 0 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { findAxmeBinary, findBundledNode } from "./binary-detect.js";
import { registerMcpServer } from "./mcp-register.js";
import { installUserHooks } from "./hooks-install.js";
import { setBundledNode } from "./spawn-binary.js";
import { setExtensionPath as setBundledRuntimePath } from "./bundled-runtime.js";
import { ensureAuditorAuth } from "./auditor-auth.js";
import { isAxmeInitialized } from "./setup-controller.js";
import { AxmeStatusBar } from "./status-bar.js";
Expand Down Expand Up @@ -126,6 +127,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
// error rather than failing mysteriously with ENOENT.
const bundledNode = findBundledNode(context);
setBundledNode(bundledNode);
setBundledRuntimePath(context.extensionPath);
if (process.platform === "win32") {
log(` Bundled Node: ${bundledNode ?? "(missing — Windows spawns will fail)"}`);
}
Expand Down
41 changes: 35 additions & 6 deletions extension/src/search-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import * as vscode from "vscode";
import { spawnBinary } from "./spawn-binary.js";
import { ensureBundledNpmExtracted } from "./bundled-runtime.js";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
Expand Down Expand Up @@ -120,16 +121,43 @@ export async function enableSearchMode(binary: string, workspaceRoot: string): P
);
if (choice !== "Enable") return;

// Wrap BOTH the lazy-extract step (Windows only, 5-10 s) AND the
// npm-install step (45-90 s) inside a single withProgress block so
// the user sees feedback immediately after clicking Enable. Earlier
// the extract ran synchronously before withProgress fired — the
// button appeared dead for 5-10 s on Windows and the user assumed
// it had broken. Reported @geobelsky 2026-05-19.
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: needsRuntime
? "AXME: installing semantic-search runtime + indexing knowledge base"
: "AXME: reindexing knowledge base",
title: "AXME: enabling semantic search",
cancellable: false,
},
() =>
new Promise<void>((resolve) => {
async (progress) => {
// Step 1 — extract bundled npm runtime tarball (Windows only).
// POSIX: no-op. Returns immediately if already extracted.
progress.report({ message: "Preparing bundled Node runtime..." });
try {
const extracted = await ensureBundledNpmExtracted();
if (extracted) log("search-enable: bundled npm runtime extracted from tarball");
} catch (err) {
logError("search-enable: bundled runtime extraction failed", err);
void vscode.window.showErrorMessage(
`AXME: cannot enable search mode - ${(err as Error).message}`,
"Show output",
).then((c) => { if (c === "Show output") showOutput(); });
return;
}

// Step 2 — run the real flow: spawn the bundled binary, install
// @huggingface/transformers via bundled npm, reindex existing
// memories/decisions. ~45-90 s on a typical machine.
progress.report({
message: needsRuntime
? "Installing semantic-search runtime + indexing..."
: "Reindexing knowledge base...",
});
await new Promise<void>((resolve) => {
const child = spawnBinary(
binary,
["config", "set", "context.mode", "search"],
Expand All @@ -150,7 +178,8 @@ export async function enableSearchMode(binary: string, workspaceRoot: string): P
}
resolve();
});
}),
});
},
);
}

Expand Down
27 changes: 27 additions & 0 deletions src/tools/search-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,37 @@ export interface InstallResult {
* (the node.exe + npm-cli.js form). useShell is true only for the
* .cmd fallbacks, where the caller also shell-quotes arguments.
*/
/**
* Belt-and-braces: if we're being run via the bundled Node from a VS Code
* extension that shipped npm as `npm-bundle.tar.gz` (the lazy-extract
* scheme introduced in extension v0.1.3 to keep Cursor install time
* under 30 s), and the extension hasn't had a chance to extract the
* tarball yet (e.g. user invoked us directly via shell), extract it
* now. Idempotent — returns immediately if the canonical sentinel
* (npm-cli.js) is already in place. Uses Windows' built-in tar.exe,
* the same path the extension uses.
*/
function ensureBundledNpmInPlace(): void {
if (process.platform !== "win32") return;
const nodeDir = dirname(process.execPath);
const npmCli = join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js");
if (existsSync(npmCli)) return;
const tarball = join(nodeDir, "npm-bundle.tar.gz");
if (!existsSync(tarball)) return;
try {
spawnSync("tar", ["-xzf", tarball, "-C", nodeDir], { stdio: "pipe", windowsHide: true });
} catch {
// Swallow — resolveNpm() below will surface the real error if the
// sentinel is still missing. We don't want extraction failure here
// to silently block resolveNpm's normal fallback path.
}
}

function resolveNpm(): { cmd: string; args: string[]; useShell: boolean } {
if (process.platform !== "win32") {
return { cmd: "npm", args: [], useShell: false };
}
ensureBundledNpmInPlace();
// process.execPath = the node.exe running us (the extension's bundled
// bin/node-runtime/node.exe, or the user's global node when standalone).
const nodeDir = dirname(process.execPath);
Expand Down
Loading