diff --git a/src/commands.ts b/src/commands.ts index 3d8472db..8da2ff05 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'); @@ -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); + await writeContent(installation, newJs, clearBytecode); console.log( chalk.green( @@ -471,7 +472,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 +532,7 @@ async function handleAdhocPatchString( return; } - await writeContent(installation, modified); + await writeContent(installation, modified, clearBytecode); console.log( chalk.green( @@ -597,7 +598,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 +672,7 @@ async function handleAdhocPatchRegex( return; } - await writeContent(installation, modified); + await writeContent(installation, modified, clearBytecode); console.log( chalk.green( @@ -689,7 +690,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 +743,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/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..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 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,14 +56,16 @@ 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'); await repackNativeInstallation( installation.path, modifiedBuffer, - installation.path + installation.path, + 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/nativeInstallation.ts b/src/nativeInstallation.ts index 2ee3eb0f..0d21e7ea 100644 --- a/src/nativeInstallation.ts +++ b/src/nativeInstallation.ts @@ -3,7 +3,9 @@ */ import fs from 'node:fs'; -import { execSync } from 'node:child_process'; +import path from 'node:path'; +import os from 'node:os'; +import { execSync, execFileSync } 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,9 +704,59 @@ function getBunData( * real binary path here. This is handled at detection time in * `installationDetection.ts`. */ +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}`); + execFileSync( + 'npm', + [ + 'pack', + `@anthropic-ai/claude-code@${version}`, + '--pack-destination', + tmpDir, + ], + { stdio: 'pipe', timeout: 30_000, cwd: tmpDir } + ); + + 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; + } + + execFileSync('tar', ['xzf', path.join(tmpDir, 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 { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +} + export function extractClaudeJsFromNativeInstallation( - nativeInstallationPath: string -): Buffer | null { + nativeInstallationPath: string, + version?: string +): { data: Buffer | null; clearBytecode: boolean } { try { LIEF.logging.disable(); const binary = LIEF.parse(nativeInstallationPath); @@ -722,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( @@ -741,21 +791,45 @@ export function extractClaudeJsFromNativeInstallation( ); if (result) { - return result; + 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' + ); + + if (version) { + const npmSource = fetchNpmSource(version); + if (npmSource) { + debug( + `extractClaudeJsFromNativeInstallation: Using npm source (${npmSource.length} bytes) instead of bytecode` + ); + return { data: npmSource, clearBytecode: true }; + } + debug( + 'extractClaudeJsFromNativeInstallation: npm source fetch failed, returning bytecode content as-is' + ); + } else { + debug( + 'extractClaudeJsFromNativeInstallation: No version provided, cannot fetch npm source' + ); + } + } + + 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 }; } } @@ -763,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[] = []; @@ -786,14 +861,18 @@ 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; + bytecodeBytes = clearBytecode + ? Buffer.alloc(0) + : getStringPointerContent(bunData, module.bytecode); } 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, @@ -1392,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 4beab26e..f8bc026e 100644 --- a/src/nativeInstallationLoader.ts +++ b/src/nativeInstallationLoader.ts @@ -54,13 +54,17 @@ async function tryLoadNativeInstallationModule(): Promise { + nativeInstallationPath: string, + version?: string +): Promise<{ data: Buffer | null; clearBytecode: boolean }> { const mod = await tryLoadNativeInstallationModule(); if (!mod) { - return null; + return { data: null, clearBytecode: false }; } - return mod.extractClaudeJsFromNativeInstallation(nativeInstallationPath); + return mod.extractClaudeJsFromNativeInstallation( + nativeInstallationPath, + version + ); } /** @@ -71,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( @@ -81,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 9df6c879..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,14 +562,18 @@ export const applyCustomization = async ( `Extracting claude.js from ${backupExists ? 'backup' : 'native installation'}: ${pathToExtractFrom}` ); - const claudeJsBuffer = - await extractClaudeJsFromNativeInstallation(pathToExtractFrom); + 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}`); @@ -904,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( 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' + ); + }); +});