diff --git a/.github/workflows/cli-release-build.yml b/.github/workflows/cli-release-build.yml index d3513d6bf6..758794d880 100644 --- a/.github/workflows/cli-release-build.yml +++ b/.github/workflows/cli-release-build.yml @@ -197,7 +197,10 @@ jobs: if [[ "${{ runner.os }}" == "Windows" ]]; then BINARY_FILE="${{ inputs.binary-name }}.exe" fi - tar -czf ${{ inputs.binary-name }}-${{ matrix.target }}.tar.gz -C cli/bin "$BINARY_FILE" + # Bundle the binary alongside tree-sitter.wasm — the CLI loads + # the wasm as a sibling file at runtime since bun --compile + # asset embedding wasn't reliable on Windows. + tar -czf ${{ inputs.binary-name }}-${{ matrix.target }}.tar.gz -C cli/bin "$BINARY_FILE" tree-sitter.wasm - name: Upload binary artifact uses: actions/upload-artifact@v7 @@ -340,7 +343,9 @@ jobs: shell: bash run: | BINARY_FILE="${{ inputs.binary-name }}.exe" - tar -czf ${{ inputs.binary-name }}-win32-x64.tar.gz -C cli/bin "$BINARY_FILE" + # Bundle tree-sitter.wasm next to the binary; see the + # equivalent matrix-job tar step for context. + tar -czf ${{ inputs.binary-name }}-win32-x64.tar.gz -C cli/bin "$BINARY_FILE" tree-sitter.wasm - name: Upload binary artifact uses: actions/upload-artifact@v7 diff --git a/.github/workflows/freebuff-e2e.yml b/.github/workflows/freebuff-e2e.yml index e88c535fb0..a090ade3ab 100644 --- a/.github/workflows/freebuff-e2e.yml +++ b/.github/workflows/freebuff-e2e.yml @@ -55,6 +55,130 @@ jobs: path: cli/bin/freebuff retention-days: 1 + # Windows-native build + smoke. The full tmux-based e2e matrix below can't + # run here (Windows runners don't have tmux), but the smoke-binary.ts + # check is what would have caught the post-OpenTUI-upgrade tree-sitter + # wasm regression: that bug only manifested on real Windows, while CI was + # Linux-only and macOS dev machines saw it work. Now every push gets a + # real Windows boot test. + build-and-smoke-freebuff-windows: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - uses: ./.github/actions/setup-project + + - name: Ensure CLI dependencies + run: bun install --frozen-lockfile --cwd cli + shell: bash + + # Mirror the symlink fix from cli-release-build.yml's Windows job: bun + # workspace symlinks aren't created reliably on Windows runners, so + # the cli's @opentui imports need explicit junctions to the root + # @opentui packages. + - name: Fix OpenTUI module symlinks + shell: bash + run: | + set -euo pipefail + bun - <<'BUN' + import fs from 'fs'; + import path from 'path'; + + const rootDir = process.cwd(); + const rootOpenTui = path.join(rootDir, 'node_modules', '@opentui'); + const cliNodeModules = path.join(rootDir, 'cli', 'node_modules'); + const cliOpenTui = path.join(cliNodeModules, '@opentui'); + + if (!fs.existsSync(rootOpenTui)) { + console.log('Root @opentui packages missing; skipping fix'); + process.exit(0); + } + + fs.mkdirSync(cliOpenTui, { recursive: true }); + + const packages = ['core', 'react']; + for (const pkg of packages) { + const target = path.join(rootOpenTui, pkg); + const link = path.join(cliOpenTui, pkg); + + if (!fs.existsSync(target)) { + console.log(`Target ${target} missing; skipping ${pkg}`); + continue; + } + + let linkStats = null; + try { + linkStats = fs.lstatSync(link); + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + } + + if (linkStats) { + let alreadyLinked = false; + try { + const actual = fs.realpathSync(link); + alreadyLinked = actual === target; + } catch { + // Broken symlink or unreadable target; we'll replace it. + } + + if (alreadyLinked) { + continue; + } + + fs.rmSync(link, { recursive: true, force: true }); + } + + const type = process.platform === 'win32' ? 'junction' : 'dir'; + try { + fs.symlinkSync(target, link, type); + console.log(`Linked ${link} -> ${target}`); + } catch (error) { + if (error?.code === 'EEXIST') { + fs.rmSync(link, { recursive: true, force: true }); + fs.symlinkSync(target, link, type); + console.log(`Re-linked ${link} -> ${target}`); + } else { + throw error; + } + } + } + BUN + + - name: Set environment variables + env: + SECRETS_CONTEXT: ${{ toJSON(secrets) }} + shell: bash + run: | + VAR_NAMES=$(bun scripts/generate-ci-env.ts --scope client) + echo "$SECRETS_CONTEXT" | jq -r --argjson vars "$VAR_NAMES" ' + to_entries | .[] | select(.key as $k | $vars | index($k)) | .key + "=" + .value + ' >> $GITHUB_ENV + echo "FREEBUFF_MODE=true" >> $GITHUB_ENV + echo "NEXT_PUBLIC_CB_ENVIRONMENT=prod" >> $GITHUB_ENV + echo "CODEBUFF_GITHUB_ACTIONS=true" >> $GITHUB_ENV + + - name: Build Freebuff binary + run: bun freebuff/cli/build.ts 0.0.0-e2e + shell: bash + + - name: Smoke test binary + shell: bash + run: | + # --version exits via commander synchronously and won't see async + # startup failures (e.g. the Parser.init rejection from a broken + # tree-sitter wasm load). + ./cli/bin/freebuff.exe --version + # Run for several seconds so unhandled rejections during module + # init have time to fire — the freebuff 0.0.62 wasm regression + # surfaced through the *late* renderer-cleanup handler, after the + # boot screen had rendered, so a too-short window can miss it. + bun cli/scripts/smoke-binary.ts cli/bin/freebuff.exe + e2e: needs: build-freebuff runs-on: ubuntu-latest diff --git a/cli/release/index.js b/cli/release/index.js index 85c60ff392..f84e6940c8 100644 --- a/cli/release/index.js +++ b/cli/release/index.js @@ -383,6 +383,27 @@ async function downloadBinary(version) { } fs.renameSync(tempBinaryPath, CONFIG.binaryPath) + // Move tree-sitter.wasm next to the binary if the tarball included + // it. The CLI binary loads this at startup; embedding it inside the + // binary itself was unreliable on Windows (bun --compile asset + // bundling silently dropped or unbound it across several attempts), + // so we ship it as a sibling file instead. Older artifacts that + // pre-date this change won't have the wasm and will still install — + // they'll just hit the same crash they had before, which is fine. + const tempWasmPath = path.join(CONFIG.tempDownloadDir, 'tree-sitter.wasm') + if (fs.existsSync(tempWasmPath)) { + const targetWasmPath = path.join( + path.dirname(CONFIG.binaryPath), + 'tree-sitter.wasm', + ) + try { + if (fs.existsSync(targetWasmPath)) fs.unlinkSync(targetWasmPath) + } catch { + // best effort; rename below will surface the real error if it matters + } + fs.renameSync(tempWasmPath, targetWasmPath) + } + // Save version metadata for fast version checking fs.writeFileSync( CONFIG.metadataPath, diff --git a/cli/release/package.json b/cli/release/package.json index cfb51a6817..bc40eabd62 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.654", + "version": "1.0.666", "description": "AI coding agent", "license": "MIT", "bin": { diff --git a/cli/scripts/build-binary.ts b/cli/scripts/build-binary.ts index 44a7dd9570..5888808b41 100644 --- a/cli/scripts/build-binary.ts +++ b/cli/scripts/build-binary.ts @@ -145,11 +145,6 @@ async function main() { patchOpenTuiAssetPaths() await ensureOpenTuiNativeBundle(targetInfo) - const restoreTreeSitterWasmStub = embedTreeSitterWasmAsBase64() - // Restore the stub even on build failure so a developer's git working - // tree doesn't end up with a multi-megabyte modified file. - process.on('exit', restoreTreeSitterWasmStub) - const outputFilename = targetInfo.platform === 'win32' ? `${binaryName}.exe` : binaryName const outputFile = join(binDir, outputFilename) @@ -191,10 +186,18 @@ async function main() { runCommand('bun', buildArgs, { cwd: cliRoot }) - // Build done — restore the stub so a developer's working tree doesn't show - // a multi-megabyte diff. (The exit handler above is a backstop for crashes; - // the eager call here keeps a successful build clean.) - restoreTreeSitterWasmStub() + // Ship tree-sitter.wasm as a sibling file next to the binary. Bun + // --compile asset embedding is unreliable on Windows (every JS-level + // retrieval mechanism we tried — `with { type: 'file' }`, base64 string + // literals, chunked base64, function-wrapped chunked base64 — got + // tree-shaken, minified away, or returned an undefined binding even + // when the bytes were in the binary). The pre-init reads it from + // `dirname(process.execPath)`, which works the same on every platform + // because it's a normal disk read, not a bunfs lookup. + const sourceWasm = findWebTreeSitterWasm() + const siblingWasm = join(binDir, 'tree-sitter.wasm') + writeFileSync(siblingWasm, readFileSync(sourceWasm)) + logAlways(`Copied tree-sitter.wasm sibling: ${sourceWasm} → ${siblingWasm}`) if (targetInfo.platform !== 'win32') { chmodSync(outputFile, 0o755) @@ -215,47 +218,29 @@ main().catch((error: unknown) => { }) /** - * Inline the contents of `web-tree-sitter/tree-sitter.wasm` as a base64 string - * literal in `cli/src/pre-init/tree-sitter-wasm-bytes.ts`. The committed - * file is a stub; this overwrites it with the real bytes immediately before - * `bun build --compile`, so the bytes get baked into the binary's text - * segment instead of being placed at a bunfs path that has to be fs-read at - * runtime. - * - * Returns a function that restores the stub. Always invoke it (success or - * failure) so a developer's working tree doesn't show a multi-MB diff. + * Find web-tree-sitter's tree-sitter.wasm in any plausible node_modules + * layout — bun hoists differently across platforms and `bun install` + * variants, and CI Windows lays it out differently than monorepo-root + * installs. */ -function embedTreeSitterWasmAsBase64(): () => void { - const stubPath = join(cliRoot, 'src', 'pre-init', 'tree-sitter-wasm-bytes.ts') - const originalStub = readFileSync(stubPath, 'utf8') - let restored = false - const restore = (): void => { - if (restored) return - restored = true - try { - writeFileSync(stubPath, originalStub) - } catch (error) { - console.error('Failed to restore tree-sitter-wasm-bytes stub:', error) - } +function findWebTreeSitterWasm(): string { + const candidates = [ + join(cliRoot, 'node_modules', 'web-tree-sitter', 'tree-sitter.wasm'), + join(cliRoot, '..', 'node_modules', 'web-tree-sitter', 'tree-sitter.wasm'), + join(cliRoot, '..', 'sdk', 'node_modules', 'web-tree-sitter', 'tree-sitter.wasm'), + ] + const found = candidates.find((p) => existsSync(p)) + if (found) return found + try { + const cliRequire = createRequire(join(cliRoot, 'package.json')) + return cliRequire.resolve('web-tree-sitter/tree-sitter.wasm') + } catch (err) { + throw new Error( + `Could not locate web-tree-sitter/tree-sitter.wasm. Searched:\n - ` + + candidates.join('\n - ') + + `\nAnd createRequire failed: ${err instanceof Error ? err.message : String(err)}`, + ) } - - // Resolve from the CLI workspace so monorepo hoisting differences don't - // matter — `web-tree-sitter` is an SDK dep, but the CLI imports it - // transitively and the bundler walks it from here. - const cliRequire = createRequire(join(cliRoot, 'package.json')) - const wasmPath = cliRequire.resolve('web-tree-sitter/tree-sitter.wasm') - const wasmBytes = readFileSync(wasmPath) - const base64 = wasmBytes.toString('base64') - - const generated = - `// AUTO-GENERATED by cli/scripts/build-binary.ts during \`bun build --compile\`.\n` + - `// Restored to the empty stub after the build finishes — do not commit a\n` + - `// non-empty value here.\n` + - `export const TREE_SITTER_WASM_BASE64 = ${JSON.stringify(base64)}\n` - - writeFileSync(stubPath, generated) - log(`Embedded tree-sitter.wasm (${wasmBytes.length} bytes → ${base64.length} chars base64)`) - return restore } function patchOpenTuiAssetPaths() { diff --git a/cli/scripts/smoke-binary.ts b/cli/scripts/smoke-binary.ts index e2bf9b779b..2553c87ef2 100644 --- a/cli/scripts/smoke-binary.ts +++ b/cli/scripts/smoke-binary.ts @@ -81,6 +81,39 @@ const FATAL_PATTERNS = [ // the renderer is up). const DEFAULT_RUN_SECONDS = 10 +function runTreeSitterSmoke(binary: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(binary, ['--smoke-tree-sitter'], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' }, + }) + + let captured = '' + const append = (chunk: Buffer): void => { + captured += chunk.toString('utf8') + } + proc.stdout?.on('data', append) + proc.stderr?.on('data', append) + + proc.once('error', reject) + proc.once('exit', (code) => { + if (code === 0 && /tree-sitter smoke ok/.test(captured)) { + resolve() + return + } + + reject( + new Error( + `tree-sitter smoke failed with exit code ${code}\n${captured.slice( + 0, + 8 * 1024, + )}`, + ), + ) + }) + }) +} + async function main(): Promise { const binary = process.argv[2] const runSeconds = Number(process.argv[3] ?? DEFAULT_RUN_SECONDS) @@ -100,6 +133,9 @@ async function main(): Promise { console.log(`smoke-binary: spawning ${binary} for ${runSeconds}s…`) + await runTreeSitterSmoke(binary) + console.log('smoke-binary: tree-sitter init OK.') + const proc = spawn(binary, [], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' }, diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 092fd0d1eb..4eebfa9696 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -186,6 +186,80 @@ function parseArgs(): ParsedArgs { } async function main(): Promise { + // CI gate: ` --smoke-tree-sitter` proves the embedded wasm boots + // through Parser.init end-to-end. Has to live BEFORE commander.parse() — + // an earlier attempt put this in a pre-init module with top-level await, + // and on Windows that didn't actually pause module evaluation (commander + // still ran first and rejected the unknown flag). + if (process.argv.includes('--smoke-tree-sitter')) { + const wasmBinary = ( + globalThis as { __CODEBUFF_TREE_SITTER_WASM_BINARY__?: Uint8Array } + ).__CODEBUFF_TREE_SITTER_WASM_BINARY__ + const wasmPath = process.env.CODEBUFF_TREE_SITTER_WASM_PATH + + // Diagnostic dump so CI logs (and bug reports) show exactly what + // the runtime saw when smoke fails. process.execPath, the + // siblingPath we expect, and what's actually in that directory. + const fs = await import('fs') + const path = await import('path') + const execDir = path.dirname(process.execPath) + const siblingPath = path.join(execDir, 'tree-sitter.wasm') + let dirListing: string[] = [] + try { + dirListing = fs.readdirSync(execDir) + } catch (err) { + dirListing = [``] + } + console.error( + `[smoke diag] execPath=${process.execPath}\n` + + `[smoke diag] execDir=${execDir}\n` + + `[smoke diag] siblingPath=${siblingPath}\n` + + `[smoke diag] siblingExists=${fs.existsSync(siblingPath)}\n` + + `[smoke diag] dir contents (${dirListing.length}): ${dirListing.slice(0, 30).join(', ')}\n` + + `[smoke diag] env.CODEBUFF_TREE_SITTER_WASM_PATH=${wasmPath ?? ''}\n` + + `[smoke diag] globalThis wasmBinary bytes=${wasmBinary?.byteLength ?? 0}\n`, + ) + + try { + const { Parser } = await import('web-tree-sitter') + // Pick the best wasm source available, falling back to the + // sibling-of-execPath lookup if pre-init couldn't reach it. By + // main() time process.execPath has stabilized to the disk path + // even on Windows, where it was the bunfs path during pre-init. + let effectiveBinary = wasmBinary + let effectivePath = wasmPath + if (!effectiveBinary && !effectivePath && fs.existsSync(siblingPath)) { + effectivePath = siblingPath + effectiveBinary = new Uint8Array(fs.readFileSync(siblingPath)) + } + + if (effectiveBinary) { + await Parser.init({ wasmBinary: effectiveBinary }) + // Marker grepped by cli/scripts/smoke-binary.ts — keep this exact text. + console.log( + `tree-sitter smoke ok (wasmBinary, ${effectiveBinary.byteLength} bytes)`, + ) + } else if (effectivePath) { + await Parser.init({ + locateFile: (name: string) => + name === 'tree-sitter.wasm' ? effectivePath! : name, + }) + console.log(`tree-sitter smoke ok (locateFile, path=${effectivePath})`) + } else { + console.error( + 'tree-sitter smoke FAIL: no wasm available — pre-init published ' + + 'nothing and the sibling-of-execPath fallback also missed. See ' + + 'the diag above for paths.', + ) + process.exit(1) + } + process.exit(0) + } catch (err) { + console.error('tree-sitter smoke FAIL:', err) + process.exit(1) + } + } + // Run OSC theme detection BEFORE anything else. // This MUST happen before OpenTUI starts because OSC responses come through stdin, // and OpenTUI also listens to stdin. Running detection here ensures stdin is clean. diff --git a/cli/src/pre-init/tree-sitter-wasm-bytes.ts b/cli/src/pre-init/tree-sitter-wasm-bytes.ts deleted file mode 100644 index 71bf6c2a59..0000000000 --- a/cli/src/pre-init/tree-sitter-wasm-bytes.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Stub committed for dev mode and tests. The real wasm bytes are inlined -// here as base64 by `cli/scripts/build-binary.ts` immediately before -// `bun build --compile`, then restored to the empty stub after the build -// completes. Dev mode and unit tests see the empty stub and fall back to -// path-based resolution in `packages/code-map/src/init-node.ts` (which -// works locally because `node_modules/web-tree-sitter/tree-sitter.wasm` -// exists on the filesystem). -// -// Why a string literal instead of `with { type: 'file' }` + readFileSync: -// the file-import approach left the bytes in bunfs and required a runtime -// fs read, which silently failed on Windows (`fs.readFileSync` for -// `B:\~BUN\root\...` paths) and let the singleton fall through to a -// path-based fallback that also failed there. A base64 string literal in -// the JS source compiles into the bun binary's text segment, with no -// filesystem step on the hot path. -export const TREE_SITTER_WASM_BASE64 = '' diff --git a/cli/src/pre-init/tree-sitter-wasm.ts b/cli/src/pre-init/tree-sitter-wasm.ts index c1f1837cd9..746e7b8d4e 100644 --- a/cli/src/pre-init/tree-sitter-wasm.ts +++ b/cli/src/pre-init/tree-sitter-wasm.ts @@ -1,35 +1,89 @@ -// Embed tree-sitter.wasm into the bun-compile binary so the SDK's tree-sitter -// parser singleton can find it at runtime. Must be the very first import in -// `index.tsx`: subsequent imports (the SDK / code-map) eagerly construct the -// parser, and its init reads what we publish here on `globalThis`. +// Find tree-sitter.wasm so the SDK's tree-sitter parser singleton can load +// it at runtime. Must be the very first import in `index.tsx`: subsequent +// imports (the SDK / code-map) eagerly construct the parser, and its init +// reads what we publish here on `globalThis` and via the env var. // -// Why not `with { type: 'file' }` + a runtime fs read? That's what the prior -// fix tried, and it silently failed on Windows: bun --compile reports the -// embedded asset path as `B:\~BUN\root\...`, and on some Windows configs -// `fs.readFileSync` of that path throws (caught silently), so the SDK fell -// back to path-based resolution that also failed there. +// Final approach after several attempts to embed the wasm into the bun +// --compile binary all failed on Windows (the bytes ended up in the +// binary, but every JS-level retrieval mechanism — `with { type: 'file' }` +// import binding, base64 string literals, chunked base64 in a generated +// module, function-export wrappers — was either tree-shaken, transformed +// by the minifier, or otherwise stripped): // -// The base64 string in `tree-sitter-wasm-bytes.ts` is replaced with the real -// wasm contents by `cli/scripts/build-binary.ts` right before `bun build -// --compile` and restored after. The bytes end up in the binary's text -// segment as a JS string literal — no filesystem step on the hot path. In -// dev / unit tests the stub is empty and code-map falls back to the -// node_modules wasm, which works because the file actually exists locally. +// ship tree-sitter.wasm as a sibling file next to the binary. +// +// It's 200KB, the npm tarball already contains the binary; adding one +// more file is trivial. The build script copies the wasm into `cli/bin/` +// after compile, the release workflow tarballs both, and the freebuff / +// codebuff downloader extracts both into the same directory. At runtime, +// `process.execPath` plus a relative file lookup gets us the wasm with +// zero bundler involvement. + +import { existsSync, readFileSync } from 'fs' +import { dirname, isAbsolute, join, resolve } from 'path' + +// Where to look for the sibling tree-sitter.wasm. We can't just use +// `dirname(process.execPath)`: at pre-init time inside a bun --compile +// binary on Windows, `process.execPath` returns the *bunfs* internal +// path (`B:\~BUN\root\.exe`) rather than the on-disk path of +// the .exe the user invoked. By the time main() runs it switches to +// the disk path, but pre-init has long since bailed out. +// +// Try several sources in order; the first whose sibling .wasm exists +// wins. argv[0] is normally the path the binary was invoked with — +// always a real disk path, never bunfs. execPath is kept as a fallback +// for environments where argv[0] is something exotic. +const candidates = ( + [process.argv[0], process.execPath] as Array +) + .filter((p): p is string => typeof p === 'string' && p.length > 0) + .map((p) => (isAbsolute(p) ? p : resolve(p))) + .map((p) => join(dirname(p), 'tree-sitter.wasm')) -import { TREE_SITTER_WASM_BASE64 } from './tree-sitter-wasm-bytes' +const siblingPath = candidates.find((p) => existsSync(p)) -if (TREE_SITTER_WASM_BASE64.length > 0) { - const buf = Buffer.from(TREE_SITTER_WASM_BASE64, 'base64') - // globalThis is the only cross-bundle channel: the SDK pre-built bundle - // inlines its own copy of `init-node.ts`, so a module-level variable in - // the source package isn't visible to the singleton initialized via the - // SDK. Slice into a fresh Uint8Array view instead of handing over the - // Buffer's shared underlying ArrayBuffer. - ;( - globalThis as { __CODEBUFF_TREE_SITTER_WASM_BINARY__?: Uint8Array } - ).__CODEBUFF_TREE_SITTER_WASM_BINARY__ = new Uint8Array( - buf.buffer, - buf.byteOffset, - buf.byteLength, +// Pre-init diagnostic — only fires when --smoke-tree-sitter is set so we +// don't spam every run. We need to see what argv[0] / execPath looked +// like at this exact phase on Windows: the round-7 main() diag showed +// disk paths, but pre-init silently bailed, meaning module-init time +// gives different values. argv[0] alone wasn't enough to fix it. +if (process.argv.includes('--smoke-tree-sitter')) { + console.error( + `[pre-init diag] argv[0]=${process.argv[0]}\n` + + `[pre-init diag] execPath=${process.execPath}\n` + + `[pre-init diag] candidates=${JSON.stringify(candidates)}\n` + + `[pre-init diag] resolved siblingPath=${siblingPath ?? ''}\n`, ) } + +if (siblingPath) { + // Tell init-node.ts (in code-map / the SDK bundle) where the wasm + // is. The locateFile callback there will hand this path to + // emscripten, which fs.readFile's it. + process.env.CODEBUFF_TREE_SITTER_WASM_PATH = siblingPath + + // Also try the synchronous-bytes path: hand the bytes straight to + // Parser.init({ wasmBinary }) so the SDK doesn't need to round-trip + // through emscripten's path resolution. Both channels feed the same + // tree-sitter init; whichever one trips first wins. + try { + const buf = readFileSync(siblingPath) + ;( + globalThis as { __CODEBUFF_TREE_SITTER_WASM_BINARY__?: Uint8Array } + ).__CODEBUFF_TREE_SITTER_WASM_BINARY__ = new Uint8Array( + buf.buffer, + buf.byteOffset, + buf.byteLength, + ) + } catch (err) { + console.error( + '[tree-sitter pre-init] readFileSync failed for sibling wasm at', + siblingPath, + '—', + err instanceof Error ? err.message : String(err), + ) + } +} + +// `--smoke-tree-sitter` is the deterministic CI gate. The handler lives at +// the top of main() in cli/src/index.tsx (before parseArgs). diff --git a/freebuff/cli/release/index.js b/freebuff/cli/release/index.js index db7fe566a8..044d86ebc5 100644 --- a/freebuff/cli/release/index.js +++ b/freebuff/cli/release/index.js @@ -373,6 +373,27 @@ async function downloadBinary(version) { } fs.renameSync(tempBinaryPath, CONFIG.binaryPath) + // Move tree-sitter.wasm next to the binary if the tarball included + // it. The CLI binary loads this at startup; embedding it inside the + // binary itself was unreliable on Windows (bun --compile asset + // bundling silently dropped or unbound it across several attempts), + // so we ship it as a sibling file instead. Older artifacts that + // pre-date this change won't have the wasm and will still install — + // they'll just hit the same crash they had before, which is fine. + const tempWasmPath = path.join(CONFIG.tempDownloadDir, 'tree-sitter.wasm') + if (fs.existsSync(tempWasmPath)) { + const targetWasmPath = path.join( + path.dirname(CONFIG.binaryPath), + 'tree-sitter.wasm', + ) + try { + if (fs.existsSync(targetWasmPath)) fs.unlinkSync(targetWasmPath) + } catch { + // best effort; rename below will surface the real error if it matters + } + fs.renameSync(tempWasmPath, targetWasmPath) + } + fs.writeFileSync( CONFIG.metadataPath, JSON.stringify({ version }, null, 2), diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index 7df51e5e3a..5c447ced50 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "freebuff", - "version": "0.0.63", + "version": "0.0.74", "description": "The world's strongest free coding agent", "license": "MIT", "bin": { diff --git a/packages/code-map/src/init-node.ts b/packages/code-map/src/init-node.ts index d46793f68c..66ca85fa70 100644 --- a/packages/code-map/src/init-node.ts +++ b/packages/code-map/src/init-node.ts @@ -31,14 +31,40 @@ function getEmbeddedWasmBinary(): Uint8Array | undefined { } function resolveTreeSitterWasm(scriptDir: string): string { + // Only return paths that fs.existsSync confirms — emscripten will + // fs.readFile whatever we hand it, and bunfs internal paths (the + // `B:\~BUN\root\...` form on Windows) ENOENT under that read even + // though they look right. An earlier `isBunEmbeddedPath` shortcut + // assumed those paths were readable; they aren't. + const override = process.env[TREE_SITTER_WASM_ENV_VAR] if (override && fs.existsSync(override)) { return override } - const fallback = path.join(scriptDir, 'tree-sitter.wasm') - if (fs.existsSync(fallback)) { - return fallback + const scriptDirFallback = path.join(scriptDir, 'tree-sitter.wasm') + if (fs.existsSync(scriptDirFallback)) { + return scriptDirFallback + } + + // Sibling file next to the running binary. The CLI ships + // tree-sitter.wasm alongside `freebuff.exe` / `codebuff.exe` because + // bun --compile asset embedding was unreliable on Windows. We do this + // lookup *here* (not in pre-init) on purpose: inside a bun --compile + // binary on Windows, `process.execPath` returns the bunfs internal + // path during early module evaluation and only switches to the disk + // path later. emscripten calls this locateFile callback during + // Parser.init's async work, by which time execPath has stabilized. + try { + const sibling = path.join( + path.dirname(process.execPath), + 'tree-sitter.wasm', + ) + if (fs.existsSync(sibling)) { + return sibling + } + } catch { + // process.execPath may be unavailable in exotic runtimes; fall through. } try { @@ -55,7 +81,7 @@ function resolveTreeSitterWasm(scriptDir: string): string { ? ` (env ${TREE_SITTER_WASM_ENV_VAR}=${override} did not exist)` : '' throw new Error( - `Internal error: tree-sitter.wasm not found (looked at scriptDir=${scriptDir} and via web-tree-sitter package${overrideDiagnostic}). Set ${TREE_SITTER_WASM_ENV_VAR} or ensure the file is included in your deployment bundle.`, + `Internal error: tree-sitter.wasm not found (looked at scriptDir=${scriptDir}, dirname(process.execPath)=${path.dirname(process.execPath)}, and via web-tree-sitter package${overrideDiagnostic}). Set ${TREE_SITTER_WASM_ENV_VAR} or ensure the file is included in your deployment bundle.`, ) }