From bdee648ee5f8ed11b537653e869329012091ee4c Mon Sep 17 00:00:00 2001 From: Leon Fedotov Date: Wed, 1 Apr 2026 23:12:37 +0300 Subject: [PATCH 1/4] Fix Bun bytecode extraction: fall back to npm source for patchable JS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the native binary contains Bun bytecode (// @bun @bytecode prefix), function bodies are compiled and can't be regex-patched. This affects all tweakcc patches on recent native installs. Fix: after extracting the claude module, detect the bytecode prefix. If found, fetch the matching version's readable cli.js from the npm registry via `npm pack`. When repacking, clear the bytecode field so Bun uses the patched source JS instead of stale compiled bytecode. This fixes #628, #629, #635, #645, #651 — all reporting patch failures on native binary installs. Changes: - nativeInstallation.ts: Add fetchNpmSource(), bytecode detection in extraction, clear bytecode on repack when contents are replaced - nativeInstallationLoader.ts: Pass version parameter through - patches/index.ts: Pass ccInstInfo.version to extraction --- src/nativeInstallation.ts | 93 ++++++++++++++++++++++++++++++++- src/nativeInstallationLoader.ts | 8 ++- src/patches/index.ts | 6 ++- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/nativeInstallation.ts b/src/nativeInstallation.ts index 2ee3eb0f..6212cc71 100644 --- a/src/nativeInstallation.ts +++ b/src/nativeInstallation.ts @@ -3,6 +3,8 @@ */ import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; import { execSync } from 'node:child_process'; import LIEF from 'node-lief'; import { isDebug, debug } from './utils'; @@ -151,6 +153,7 @@ export function resolveNixBinaryWrapper(binaryPath: string): string | null { * - flags: u32 */ const BUN_TRAILER = Buffer.from('\n---- Bun! ----\n'); +const BUN_BYTECODE_PREFIX = '// @bun @bytecode'; // Size constants for binary structures const SIZEOF_OFFSETS = 32; @@ -701,8 +704,63 @@ function getBunData( * real binary path here. This is handled at detection time in * `installationDetection.ts`. */ +/** + * Fetches the readable cli.js source from the npm package for a given CC version. + * Used as fallback when the native binary contains Bun bytecode instead of + * readable JS (bytecode function bodies can't be regex-patched). + * + * Downloads via `npm pack`, extracts cli.js, and returns its content. + * Returns null if the fetch fails (network error, version not on npm, etc.). + */ +function fetchNpmSource(version: string): Buffer | null { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tweakcc-npm-')); + try { + debug(`fetchNpmSource: Downloading @anthropic-ai/claude-code@${version}`); + execSync( + `npm pack @anthropic-ai/claude-code@${version} --pack-destination "${tmpDir}"`, + { stdio: 'pipe', timeout: 30_000, cwd: tmpDir } + ); + + // Find the tarball + const files = fs.readdirSync(tmpDir); + const tgz = files.find(f => f.endsWith('.tgz')); + if (!tgz) { + debug('fetchNpmSource: No .tgz file found after npm pack'); + return null; + } + + // Extract cli.js from the tarball + execSync(`tar xzf "${tgz}" package/cli.js`, { + stdio: 'pipe', + timeout: 30_000, + cwd: tmpDir, + }); + + const cliJsPath = path.join(tmpDir, 'package', 'cli.js'); + if (!fs.existsSync(cliJsPath)) { + debug('fetchNpmSource: cli.js not found in extracted package'); + return null; + } + + const content = fs.readFileSync(cliJsPath); + debug(`fetchNpmSource: Got cli.js, ${content.length} bytes`); + return content; + } catch (error) { + debug('fetchNpmSource: Failed to fetch npm source:', error); + return null; + } finally { + // Clean up temp dir + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +} + export function extractClaudeJsFromNativeInstallation( - nativeInstallationPath: string + nativeInstallationPath: string, + version?: string ): Buffer | null { try { LIEF.logging.disable(); @@ -741,6 +799,34 @@ export function extractClaudeJsFromNativeInstallation( ); if (result) { + // Check if extracted content is Bun bytecode (not patchable with regex) + const head = result.subarray(0, 30).toString('utf8'); + if (head.startsWith(BUN_BYTECODE_PREFIX)) { + debug( + 'extractClaudeJsFromNativeInstallation: Extracted content is Bun bytecode — falling back to npm source' + ); + console.log( + 'Native binary contains Bun bytecode. Fetching readable source from npm...' + ); + + if (version) { + const npmSource = fetchNpmSource(version); + if (npmSource) { + debug( + `extractClaudeJsFromNativeInstallation: Using npm source (${npmSource.length} bytes) instead of bytecode` + ); + return npmSource; + } + debug( + 'extractClaudeJsFromNativeInstallation: npm source fetch failed, returning bytecode content as-is' + ); + } else { + debug( + 'extractClaudeJsFromNativeInstallation: No version provided, cannot fetch npm source' + ); + } + } + return result; } @@ -786,14 +872,17 @@ function rebuildBunData( // Check if this is claude.js and we have modified contents let contentsBytes: Buffer; + let bytecodeBytes: Buffer; if (modifiedClaudeJs && isClaudeModule(moduleName)) { contentsBytes = modifiedClaudeJs; + // Clear bytecode so Bun uses the patched source JS instead of stale bytecode + bytecodeBytes = Buffer.alloc(0); } else { contentsBytes = getStringPointerContent(bunData, module.contents); + bytecodeBytes = getStringPointerContent(bunData, module.bytecode); } const sourcemapBytes = getStringPointerContent(bunData, module.sourcemap); - const bytecodeBytes = getStringPointerContent(bunData, module.bytecode); const moduleInfoBytes = getStringPointerContent(bunData, module.moduleInfo); const bytecodeOriginPathBytes = getStringPointerContent( bunData, diff --git a/src/nativeInstallationLoader.ts b/src/nativeInstallationLoader.ts index 4beab26e..2fa96e3a 100644 --- a/src/nativeInstallationLoader.ts +++ b/src/nativeInstallationLoader.ts @@ -54,13 +54,17 @@ async function tryLoadNativeInstallationModule(): Promise { const mod = await tryLoadNativeInstallationModule(); if (!mod) { return null; } - return mod.extractClaudeJsFromNativeInstallation(nativeInstallationPath); + return mod.extractClaudeJsFromNativeInstallation( + nativeInstallationPath, + version + ); } /** diff --git a/src/patches/index.ts b/src/patches/index.ts index 9df6c879..f63039cc 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -561,8 +561,10 @@ export const applyCustomization = async ( `Extracting claude.js from ${backupExists ? 'backup' : 'native installation'}: ${pathToExtractFrom}` ); - const claudeJsBuffer = - await extractClaudeJsFromNativeInstallation(pathToExtractFrom); + const claudeJsBuffer = await extractClaudeJsFromNativeInstallation( + pathToExtractFrom, + ccInstInfo.version + ); if (!claudeJsBuffer) { throw new Error('Failed to extract claude.js from native installation'); From a44b12735ab472b9364a57d33b8dd156721159a5 Mon Sep 17 00:00:00 2001 From: Leon Fedotov Date: Wed, 1 Apr 2026 23:45:01 +0300 Subject: [PATCH 2/4] Address review: execFileSync, clearBytecode flag, remove comments - Use execFileSync with arg arrays instead of execSync (no shell) - Thread clearBytecode flag so bytecode is only cleared on npm fallback - Remove explanatory comments (repo style) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/installationDetection.ts | 2 +- src/lib/content.ts | 5 +-- src/nativeInstallation.ts | 57 ++++++++++++++------------------- src/nativeInstallationLoader.ts | 15 ++++++--- src/patches/index.ts | 16 +++++---- src/tests/config.test.ts | 22 ++++++------- 6 files changed, 59 insertions(+), 58 deletions(-) diff --git a/src/installationDetection.ts b/src/installationDetection.ts index 1e1da824..aacdf782 100644 --- a/src/installationDetection.ts +++ b/src/installationDetection.ts @@ -380,7 +380,7 @@ async function extractVersionFromJsFile(cliPath: string): Promise { async function extractVersionFromNativeBinary( binaryPath: string ): Promise { - const claudeJsBuffer = + const { data: claudeJsBuffer } = await extractClaudeJsFromNativeInstallation(binaryPath); if (!claudeJsBuffer) { diff --git a/src/lib/content.ts b/src/lib/content.ts index a522ecf0..f1c2f8fc 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -29,7 +29,7 @@ import { Installation } from './types'; */ export async function readContent(installation: Installation): Promise { if (installation.kind === 'native') { - const buffer = await extractClaudeJsFromNativeInstallation( + const { data: buffer } = await extractClaudeJsFromNativeInstallation( installation.path ); if (!buffer) { @@ -61,7 +61,8 @@ export async function writeContent( await repackNativeInstallation( installation.path, modifiedBuffer, - installation.path + installation.path, + false ); } else { await replaceFileBreakingHardLinks(installation.path, content, 'patch'); diff --git a/src/nativeInstallation.ts b/src/nativeInstallation.ts index 6212cc71..0d21e7ea 100644 --- a/src/nativeInstallation.ts +++ b/src/nativeInstallation.ts @@ -5,7 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { execSync } from 'node:child_process'; +import { execSync, execFileSync } from 'node:child_process'; import LIEF from 'node-lief'; import { isDebug, debug } from './utils'; @@ -704,24 +704,21 @@ function getBunData( * real binary path here. This is handled at detection time in * `installationDetection.ts`. */ -/** - * Fetches the readable cli.js source from the npm package for a given CC version. - * Used as fallback when the native binary contains Bun bytecode instead of - * readable JS (bytecode function bodies can't be regex-patched). - * - * Downloads via `npm pack`, extracts cli.js, and returns its content. - * Returns null if the fetch fails (network error, version not on npm, etc.). - */ function fetchNpmSource(version: string): Buffer | null { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tweakcc-npm-')); try { debug(`fetchNpmSource: Downloading @anthropic-ai/claude-code@${version}`); - execSync( - `npm pack @anthropic-ai/claude-code@${version} --pack-destination "${tmpDir}"`, + execFileSync( + 'npm', + [ + 'pack', + `@anthropic-ai/claude-code@${version}`, + '--pack-destination', + tmpDir, + ], { stdio: 'pipe', timeout: 30_000, cwd: tmpDir } ); - // Find the tarball const files = fs.readdirSync(tmpDir); const tgz = files.find(f => f.endsWith('.tgz')); if (!tgz) { @@ -729,8 +726,7 @@ function fetchNpmSource(version: string): Buffer | null { return null; } - // Extract cli.js from the tarball - execSync(`tar xzf "${tgz}" package/cli.js`, { + execFileSync('tar', ['xzf', path.join(tmpDir, tgz), 'package/cli.js'], { stdio: 'pipe', timeout: 30_000, cwd: tmpDir, @@ -749,7 +745,6 @@ function fetchNpmSource(version: string): Buffer | null { debug('fetchNpmSource: Failed to fetch npm source:', error); return null; } finally { - // Clean up temp dir try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { @@ -761,7 +756,7 @@ function fetchNpmSource(version: string): Buffer | null { export function extractClaudeJsFromNativeInstallation( nativeInstallationPath: string, version?: string -): Buffer | null { +): { data: Buffer | null; clearBytecode: boolean } { try { LIEF.logging.disable(); const binary = LIEF.parse(nativeInstallationPath); @@ -780,9 +775,6 @@ export function extractClaudeJsFromNativeInstallation( `extractClaudeJsFromNativeInstallation: Module ${index}: ${moduleName}` ); - // Module name is typically: - // - Unix/macOS: /$bunfs/root/claude - // - Windows: B:/~BUN/root/claude.exe if (!isClaudeModule(moduleName)) return undefined; const moduleContents = getStringPointerContent( @@ -799,15 +791,11 @@ export function extractClaudeJsFromNativeInstallation( ); if (result) { - // Check if extracted content is Bun bytecode (not patchable with regex) const head = result.subarray(0, 30).toString('utf8'); if (head.startsWith(BUN_BYTECODE_PREFIX)) { debug( 'extractClaudeJsFromNativeInstallation: Extracted content is Bun bytecode — falling back to npm source' ); - console.log( - 'Native binary contains Bun bytecode. Fetching readable source from npm...' - ); if (version) { const npmSource = fetchNpmSource(version); @@ -815,7 +803,7 @@ export function extractClaudeJsFromNativeInstallation( debug( `extractClaudeJsFromNativeInstallation: Using npm source (${npmSource.length} bytes) instead of bytecode` ); - return npmSource; + return { data: npmSource, clearBytecode: true }; } debug( 'extractClaudeJsFromNativeInstallation: npm source fetch failed, returning bytecode content as-is' @@ -827,21 +815,21 @@ export function extractClaudeJsFromNativeInstallation( } } - return result; + return { data: result, clearBytecode: false }; } debug( 'extractClaudeJsFromNativeInstallation: claude module not found in any module' ); - return null; + return { data: null, clearBytecode: false }; } catch (error) { debug( 'extractClaudeJsFromNativeInstallation: Error during extraction:', error ); - return null; + return { data: null, clearBytecode: false }; } } @@ -849,7 +837,8 @@ function rebuildBunData( bunData: Buffer, bunOffsets: BunOffsets, modifiedClaudeJs: Buffer | null, - moduleStructSize: number + moduleStructSize: number, + clearBytecode: boolean ): Buffer { // Phase 1: Collect all string data const stringsData: Buffer[] = []; @@ -875,8 +864,9 @@ function rebuildBunData( let bytecodeBytes: Buffer; if (modifiedClaudeJs && isClaudeModule(moduleName)) { contentsBytes = modifiedClaudeJs; - // Clear bytecode so Bun uses the patched source JS instead of stale bytecode - bytecodeBytes = Buffer.alloc(0); + bytecodeBytes = clearBytecode + ? Buffer.alloc(0) + : getStringPointerContent(bunData, module.bytecode); } else { contentsBytes = getStringPointerContent(bunData, module.contents); bytecodeBytes = getStringPointerContent(bunData, module.bytecode); @@ -1481,19 +1471,20 @@ function repackELFOverlay( export function repackNativeInstallation( binPath: string, modifiedClaudeJs: Buffer, - outputPath: string + outputPath: string, + clearBytecode: boolean ): void { LIEF.logging.disable(); const binary = LIEF.parse(binPath); - // Extract Bun data and rebuild with modified claude.js const { bunOffsets, bunData, sectionHeaderSize, moduleStructSize } = getBunData(binary); const newBuffer = rebuildBunData( bunData, bunOffsets, modifiedClaudeJs, - moduleStructSize + moduleStructSize, + clearBytecode ); switch (binary.format) { diff --git a/src/nativeInstallationLoader.ts b/src/nativeInstallationLoader.ts index 2fa96e3a..f8bc026e 100644 --- a/src/nativeInstallationLoader.ts +++ b/src/nativeInstallationLoader.ts @@ -56,10 +56,10 @@ async function tryLoadNativeInstallationModule(): Promise { +): Promise<{ data: Buffer | null; clearBytecode: boolean }> { const mod = await tryLoadNativeInstallationModule(); if (!mod) { - return null; + return { data: null, clearBytecode: false }; } return mod.extractClaudeJsFromNativeInstallation( nativeInstallationPath, @@ -75,9 +75,9 @@ export async function extractClaudeJsFromNativeInstallation( export async function repackNativeInstallation( binPath: string, modifiedClaudeJs: Buffer, - outputPath: string + outputPath: string, + clearBytecode: boolean ): Promise { - // The module should already be cached from a prior extractClaudeJsFromNativeInstallation() call const mod = await tryLoadNativeInstallationModule(); if (!mod) { throw new Error( @@ -85,7 +85,12 @@ export async function repackNativeInstallation( 'This is unexpected - `extractClaudeJsFromNativeInstallation()` should have been called first.' ); } - mod.repackNativeInstallation(binPath, modifiedClaudeJs, outputPath); + mod.repackNativeInstallation( + binPath, + modifiedClaudeJs, + outputPath, + clearBytecode + ); } /** diff --git a/src/patches/index.ts b/src/patches/index.ts index f63039cc..16b122c9 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -539,6 +539,7 @@ export const applyCustomization = async ( patchFilter?: string[] | null ): Promise => { let content: string; + let clearBytecode = false; if (ccInstInfo.nativeInstallationPath) { // For native installations: restore the binary, then extract to memory @@ -561,16 +562,18 @@ export const applyCustomization = async ( `Extracting claude.js from ${backupExists ? 'backup' : 'native installation'}: ${pathToExtractFrom}` ); - const claudeJsBuffer = await extractClaudeJsFromNativeInstallation( - pathToExtractFrom, - ccInstInfo.version - ); + const { data: claudeJsBuffer, clearBytecode: needsClearBytecode } = + await extractClaudeJsFromNativeInstallation( + pathToExtractFrom, + ccInstInfo.version + ); if (!claudeJsBuffer) { throw new Error('Failed to extract claude.js from native installation'); } - // Save original extracted JS for debugging + clearBytecode = needsClearBytecode; + const origPath = path.join(CONFIG_DIR, 'native-claudejs-orig.js'); fsSync.writeFileSync(origPath, claudeJsBuffer); debug(`Saved original extracted JS from native to: ${origPath}`); @@ -906,7 +909,8 @@ export const applyCustomization = async ( await repackNativeInstallation( ccInstInfo.nativeInstallationPath, modifiedBuffer, - ccInstInfo.nativeInstallationPath + ccInstInfo.nativeInstallationPath, + clearBytecode ); } else { // For NPM installations: replace the cli.js file diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts index 58436ab8..30b04898 100644 --- a/src/tests/config.test.ts +++ b/src/tests/config.test.ts @@ -429,7 +429,7 @@ describe('config.ts', () => { vi.spyOn( nativeInstallation, 'extractClaudeJsFromNativeInstallation' - ).mockResolvedValue(mockJsBuffer); + ).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false }); const result = await findClaudeCodeInstallation(mockConfig, { interactive: true, @@ -569,7 +569,7 @@ describe('config.ts', () => { vi.spyOn( nativeInstallation, 'extractClaudeJsFromNativeInstallation' - ).mockResolvedValue(mockJsBuffer); + ).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false }); const result = await findClaudeCodeInstallation(mockConfig, { interactive: true, @@ -693,7 +693,7 @@ describe('config.ts', () => { vi.spyOn( nativeInstallation, 'extractClaudeJsFromNativeInstallation' - ).mockResolvedValue(mockJsBuffer); + ).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false }); const result = await findClaudeCodeInstallation(mockConfig, { interactive: true, @@ -758,7 +758,7 @@ describe('config.ts', () => { vi.spyOn( nativeInstallation, 'extractClaudeJsFromNativeInstallation' - ).mockResolvedValue(mockJsBuffer); + ).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false }); const result = await findClaudeCodeInstallation(mockConfig, { interactive: true, @@ -1131,7 +1131,7 @@ describe('config.ts', () => { vi.spyOn( nativeInstallation, 'extractClaudeJsFromNativeInstallation' - ).mockResolvedValue(mockJsBuffer); + ).mockResolvedValue({ data: mockJsBuffer, clearBytecode: false }); const result = await findClaudeCodeInstallation(mockConfig, { interactive: true, @@ -1323,11 +1323,10 @@ describe('config.ts', () => { // WASMagic reports binary mockMagicInstance.detect.mockReturnValue('application/octet-stream'); - // Mock native extraction to return null (extraction failed) vi.spyOn( nativeInstallation, 'extractClaudeJsFromNativeInstallation' - ).mockResolvedValue(null); + ).mockResolvedValue({ data: null, clearBytecode: false }); vi.spyOn(fs, 'readFile').mockRejectedValue(createEnoent()); @@ -1461,10 +1460,12 @@ describe('config.ts', () => { mockMagicInstance.detect.mockReturnValue('application/octet-stream'); - // Mock extractClaudeJsFromNativeInstallation to return content without VERSION vi.mocked( nativeInstallation.extractClaudeJsFromNativeInstallation - ).mockResolvedValue(Buffer.from('no version here')); + ).mockResolvedValue({ + data: Buffer.from('no version here'), + clearBytecode: false, + }); // Should throw error since no VERSION found await expect( @@ -1513,10 +1514,9 @@ describe('config.ts', () => { mockMagicInstance.detect.mockReturnValue('application/octet-stream'); - // Mock extractClaudeJsFromNativeInstallation to return null (extraction failed) vi.mocked( nativeInstallation.extractClaudeJsFromNativeInstallation - ).mockResolvedValue(null); + ).mockResolvedValue({ data: null, clearBytecode: false }); // Should throw error since extraction failed await expect( From de07c478c56494e7cff3637b6848a9c82fdc3339 Mon Sep 17 00:00:00 2001 From: Leon Fedotov Date: Wed, 1 Apr 2026 23:58:15 +0300 Subject: [PATCH 3/4] fix: thread clearBytecode through lib content API Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands.ts | 16 ++-- src/lib/content.ts | 19 +++-- src/lib/index.ts | 6 +- src/tests/content.test.ts | 152 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 src/tests/content.test.ts diff --git a/src/commands.ts b/src/commands.ts index 3d8472db..8f325d62 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -400,7 +400,7 @@ export async function handleUnpack( `Extracting JS from native binary: ${chalk.cyan(installation.path)} (v${installation.version})` ); - const content = await readContent(installation); + const { content } = await readContent(installation); await fs.writeFile(outputJsPath, content, 'utf8'); @@ -448,7 +448,7 @@ export async function handleRepack( const newJs = await fs.readFile(inputJsPath, 'utf8'); - await writeContent(installation, newJs); + await writeContent(installation, newJs, false); console.log( chalk.green( @@ -471,7 +471,7 @@ async function handleAdhocPatchString( installation: Installation, skipConfirmation = false ): Promise { - const content = await readContent(installation); + const { content, clearBytecode } = await readContent(installation); let modified: string; let count: number; @@ -531,7 +531,7 @@ async function handleAdhocPatchString( return; } - await writeContent(installation, modified); + await writeContent(installation, modified, clearBytecode); console.log( chalk.green( @@ -597,7 +597,7 @@ async function handleAdhocPatchRegex( installation: Installation, skipConfirmation = false ): Promise { - const content = await readContent(installation); + const { content, clearBytecode } = await readContent(installation); let parsed: { pattern: string; flags: string }; try { @@ -671,7 +671,7 @@ async function handleAdhocPatchRegex( return; } - await writeContent(installation, modified); + await writeContent(installation, modified, clearBytecode); console.log( chalk.green( @@ -689,7 +689,7 @@ async function handleAdhocPatchScriptImpl( skipConfirmation = false, dangerousNoScriptSandbox = false ): Promise { - const content = await readContent(installation); + const { content, clearBytecode } = await readContent(installation); const script = await resolveScriptSource(scriptArg); @@ -742,7 +742,7 @@ async function handleAdhocPatchScriptImpl( return; } - await writeContent(installation, modified); + await writeContent(installation, modified, clearBytecode); console.log( chalk.green(`✓ Script patch applied to ${chalk.cyan(installation.path)}`) diff --git a/src/lib/content.ts b/src/lib/content.ts index f1c2f8fc..0c967139 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -27,19 +27,21 @@ import { Installation } from './types'; * @param installation - The installation to read from * @returns The JavaScript content as a string */ -export async function readContent(installation: Installation): Promise { +export async function readContent( + installation: Installation +): Promise<{ content: string; clearBytecode: boolean }> { if (installation.kind === 'native') { - const { data: buffer } = await extractClaudeJsFromNativeInstallation( - installation.path - ); + const { data: buffer, clearBytecode } = + await extractClaudeJsFromNativeInstallation(installation.path); if (!buffer) { throw new Error( `Failed to extract JavaScript from native installation: ${installation.path}` ); } - return buffer.toString('utf8'); + return { content: buffer.toString('utf8'), clearBytecode }; } else { - return fs.readFile(installation.path, { encoding: 'utf8' }); + const content = await fs.readFile(installation.path, { encoding: 'utf8' }); + return { content, clearBytecode: false }; } } @@ -54,7 +56,8 @@ export async function readContent(installation: Installation): Promise { */ export async function writeContent( installation: Installation, - content: string + content: string, + clearBytecode: boolean ): Promise { if (installation.kind === 'native') { const modifiedBuffer = Buffer.from(content, 'utf8'); @@ -62,7 +65,7 @@ export async function writeContent( installation.path, modifiedBuffer, installation.path, - false + clearBytecode ); } else { await replaceFileBreakingHardLinks(installation.path, content, 'patch'); diff --git a/src/lib/index.ts b/src/lib/index.ts index 3c496ba7..7c43d5a3 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -22,9 +22,9 @@ * await backupFile(installation.path, './backup'); * * // Read, patch, write - * let content = await readContent(installation); - * content = content.replace(/something/g, 'something else'); - * await writeContent(installation, content); + * const { content, clearBytecode } = await readContent(installation); + * const modified = content.replace(/something/g, 'something else'); + * await writeContent(installation, modified, clearBytecode); * ``` */ diff --git a/src/tests/content.test.ts b/src/tests/content.test.ts new file mode 100644 index 00000000..920f008f --- /dev/null +++ b/src/tests/content.test.ts @@ -0,0 +1,152 @@ +import fs from 'node:fs/promises'; + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import * as nativeInstallation from '../nativeInstallationLoader'; +import * as misc from '../utils'; +import { readContent, writeContent } from '../lib/content'; +import { Installation } from '../lib/types'; + +vi.mock('node:fs/promises'); +vi.mock('../nativeInstallationLoader', () => ({ + extractClaudeJsFromNativeInstallation: vi.fn(), + repackNativeInstallation: vi.fn(), +})); + +vi.spyOn(misc, 'replaceFileBreakingHardLinks').mockImplementation(async () => { + // no-op +}); + +describe('readContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns content and clearBytecode=true for native installation', async () => { + const jsContent = 'console.log("hello")'; + vi.mocked( + nativeInstallation.extractClaudeJsFromNativeInstallation + ).mockResolvedValue({ + data: Buffer.from(jsContent, 'utf8'), + clearBytecode: true, + }); + + const installation: Installation = { + kind: 'native', + path: '/usr/bin/claude', + version: '1.0.0', + }; + + const result = await readContent(installation); + expect(result).toEqual({ content: jsContent, clearBytecode: true }); + }); + + it('returns content and clearBytecode=false for native installation', async () => { + const jsContent = 'console.log("world")'; + vi.mocked( + nativeInstallation.extractClaudeJsFromNativeInstallation + ).mockResolvedValue({ + data: Buffer.from(jsContent, 'utf8'), + clearBytecode: false, + }); + + const installation: Installation = { + kind: 'native', + path: '/usr/bin/claude', + version: '1.0.0', + }; + + const result = await readContent(installation); + expect(result).toEqual({ content: jsContent, clearBytecode: false }); + }); + + it('throws when native extraction returns null data', async () => { + vi.mocked( + nativeInstallation.extractClaudeJsFromNativeInstallation + ).mockResolvedValue({ + data: null, + clearBytecode: false, + }); + + const installation: Installation = { + kind: 'native', + path: '/usr/bin/claude', + version: '1.0.0', + }; + + await expect(readContent(installation)).rejects.toThrow( + 'Failed to extract JavaScript from native installation' + ); + }); + + it('returns content and clearBytecode=false for npm installation', async () => { + const jsContent = 'module.exports = {}'; + vi.mocked(fs.readFile).mockResolvedValue(jsContent); + + const installation: Installation = { + kind: 'npm', + path: '/usr/lib/node_modules/claude/cli.js', + version: '1.0.0', + }; + + const result = await readContent(installation); + expect(result).toEqual({ content: jsContent, clearBytecode: false }); + }); +}); + +describe('writeContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes clearBytecode to repackNativeInstallation', async () => { + const installation: Installation = { + kind: 'native', + path: '/usr/bin/claude', + version: '1.0.0', + }; + + await writeContent(installation, 'modified content', true); + + expect(nativeInstallation.repackNativeInstallation).toHaveBeenCalledWith( + '/usr/bin/claude', + Buffer.from('modified content', 'utf8'), + '/usr/bin/claude', + true + ); + }); + + it('passes clearBytecode=false to repackNativeInstallation', async () => { + const installation: Installation = { + kind: 'native', + path: '/usr/bin/claude', + version: '1.0.0', + }; + + await writeContent(installation, 'modified content', false); + + expect(nativeInstallation.repackNativeInstallation).toHaveBeenCalledWith( + '/usr/bin/claude', + Buffer.from('modified content', 'utf8'), + '/usr/bin/claude', + false + ); + }); + + it('ignores clearBytecode for npm installation', async () => { + const installation: Installation = { + kind: 'npm', + path: '/usr/lib/node_modules/claude/cli.js', + version: '1.0.0', + }; + + await writeContent(installation, 'modified content', true); + + expect(nativeInstallation.repackNativeInstallation).not.toHaveBeenCalled(); + expect(misc.replaceFileBreakingHardLinks).toHaveBeenCalledWith( + '/usr/lib/node_modules/claude/cli.js', + 'modified content', + 'patch' + ); + }); +}); From 0f7847a50017069653d077b17cf2a9db5092ef90 Mon Sep 17 00:00:00 2001 From: Leon Fedotov Date: Thu, 2 Apr 2026 12:48:33 +0300 Subject: [PATCH 4/4] fix: detect bytecode in repack to clear stale compiled code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands.ts b/src/commands.ts index 8f325d62..8da2ff05 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -447,8 +447,9 @@ export async function handleRepack( ); const newJs = await fs.readFile(inputJsPath, 'utf8'); + const clearBytecode = !newJs.startsWith('// @bun @bytecode'); - await writeContent(installation, newJs, false); + await writeContent(installation, newJs, clearBytecode); console.log( chalk.green(