// @ts-check import { spawn } from 'child_process' import { flipFuses, FuseVersion, FuseV1Options } from '@electron/fuses' import asar from '@electron/asar' import prompts from 'prompts' import yargs from 'yargs' import chalk from 'chalk' import path from 'path' import fs from 'fs' import os from 'os' const argv = await yargs(process.argv.slice(2)) .usage(`Usage: ${process.argv0} . [options]`) .command('patch', 'Patch HTTP Toolkit using the specified script') .command('restore', 'Restore HTTP Toolkit files to their original state') .command('start', 'Start HTTP Toolkit') .option('path', { type: 'string', describe: 'Path to the HTTP Toolkit installation (the install dir or its resources folder)' }) .demandCommand(1, 'You need at least one command before moving on') .alias('h', 'help') .parse() const isWin = process.platform === 'win32' const isMac = process.platform === 'darwin' const userAgent = process.env.npm_config_user_agent const pm = userAgent ? userAgent.split('/')[0] : 'npm' const installCmd = pm === 'npm' ? 'install' : 'add' // Try to find where HTTP Toolkit is installed const getAppPath = () => { if (argv.path) { return /resources$/i.test(argv.path) ? argv.path : path.join(argv.path, isMac ? 'Contents/Resources' : 'resources') } const localAppData = process.env.LOCALAPPDATA ?? '' const candidates = isWin ? [ path.join(localAppData, 'Programs', 'httptoolkit', 'HTTP Toolkit', 'resources'), // current default path.join(localAppData, 'Programs', 'httptoolkit', 'resources'), // older default path.join(process.env.PROGRAMFILES ?? '', 'HTTP Toolkit', 'resources'), path.join(process.env['PROGRAMFILES(X86)'] ?? '', 'HTTP Toolkit', 'resources') ] : isMac ? ['/Applications/HTTP Toolkit.app/Contents/Resources'] : [ '/opt/HTTP Toolkit/resources', '/opt/httptoolkit/resources', '/usr/lib/httptoolkit' ] for (const p of candidates) { if (p && fs.existsSync(path.join(p, 'app.asar'))) return p } return candidates[0] } const appPath = getAppPath() const exePath = isWin ? path.join(path.dirname(appPath), 'HTTP Toolkit.exe') : isMac ? path.join(path.dirname(appPath), 'MacOS', 'HTTP Toolkit') : path.join(path.dirname(appPath), 'httptoolkit') const isSudo = !isWin && (process.getuid || (() => process.env.SUDO_UID ? 0 : null))() === 0 if (+(process.versions.node.split('.')[0]) < 15) { console.error(chalk.redBright`Node.js version 15 or higher is recommended, you are using version {bold ${process.versions.node}}`) } if (!fs.existsSync(path.join(appPath, 'app.asar'))) { console.error(chalk.redBright`Couldn't find HTTP Toolkit! Make sure it's installed.`) process.exit(1) } console.log(chalk.blueBright`Found HTTP Toolkit at {bold ${path.dirname(appPath)}}`) // Helper to recursively delete directories const rm = dirPath => { if (!fs.existsSync(dirPath)) return if (!fs.lstatSync(dirPath).isDirectory()) return fs.rmSync(dirPath, { force: true }) for (const entry of fs.readdirSync(dirPath)) { const entryPath = path.join(dirPath, entry) if (fs.lstatSync(entryPath).isDirectory()) rm(entryPath) else fs.rmSync(entryPath, { force: true }) } } /** @type {Array} */ const activeProcesses = [] let isCancelled = false const cleanUp = async () => { isCancelled = true console.log(chalk.redBright`Cancelled! Cleaning up...`) if (activeProcesses.length) { console.log(chalk.yellowBright`Stopping active processes...`) for (const proc of activeProcesses) { proc.kill('SIGINT') console.log(chalk.yellowBright`Process {bold ${proc.pid ? process.pid + ' ' : ''}}stopped`) } await new Promise(resolve => setTimeout(resolve, 1000)) } const paths = [ path.resolve(os.tmpdir(), 'httptoolkit-patch'), path.resolve(appPath, 'app') ] try { for (const p of paths) { if (fs.existsSync(p)) { console.log(chalk.yellowBright`Removing {bold ${p}}`) rm(p) } } } catch (e) { console.error(chalk.redBright`Error while cleaning up`, e) } process.exit(1) } const patchApp = async () => { const filePath = path.join(appPath, 'app.asar') const tempPath = path.join(appPath, 'app') if (fs.readFileSync(filePath).includes('Injected by HTTP Toolkit Patcher')) { console.log(chalk.greenBright`App is already patched!`) return } const globalProxy = process.env.PROXY console.log(chalk.blueBright`Starting the patch process...`) if (globalProxy) { if (!globalProxy.match(/^https?:/)) { console.error(chalk.redBright`Global proxy must start with http:// or https://`) process.exit(1) } console.log(chalk.yellowBright`Using custom proxy: {bold ${globalProxy}}`) } console.log(chalk.yellowBright`Extracting app files...`) ;['SIGINT', 'SIGTERM'].forEach(signal => process.on(signal, cleanUp)) try { rm(tempPath) asar.extractAll(filePath, tempPath) } catch (e) { if (!isSudo && /** @type {any} */ (e).errno === -13) { // Permission denied console.error(chalk.redBright`Permission denied${!isWin ? ', try running with sudo' : ', try running node as administrator'}`) process.exit(1) } console.error(chalk.redBright`Error extracting app`, e) process.exit(1) } // Replace @prisma/instrumentation with a stub - it has a broken nested node_modules // reference to @opentelemetry/instrumentation that causes a crash after repacking. // @sentry/node's prisma integration imports from it, so we can't just delete it — // we need a stub that exports the expected symbols as noops. const prismaInstrPath = path.join(tempPath, 'node_modules', '@prisma', 'instrumentation') if (fs.existsSync(prismaInstrPath)) { rm(prismaInstrPath) fs.mkdirSync(prismaInstrPath, { recursive: true }) fs.writeFileSync(path.join(prismaInstrPath, 'package.json'), JSON.stringify({ name: '@prisma/instrumentation', version: '0.0.0-stub', main: 'index.js', type: 'module' }, null, 2)) fs.writeFileSync(path.join(prismaInstrPath, 'index.js'), [ 'export class PrismaInstrumentation {', ' constructor(config) { this._config = config; }', ' instrumentationName = "@prisma/instrumentation";', ' instrumentationVersion = "0.0.0";', ' enable() {}', ' disable() {}', ' setTracerProvider() {}', ' setMeterProvider() {}', ' getConfig() { return this._config || {}; }', '}', 'export default PrismaInstrumentation;', '' ].join('\n') ) console.log(chalk.yellowBright`Replaced broken @prisma/instrumentation with stub`) } const indexPath = path.join(tempPath, 'build', 'index.js') if (!fs.existsSync(indexPath)) { console.error(chalk.redBright`Couldn't find index.js file`) cleanUp() } const data = fs.readFileSync(indexPath, 'utf-8') ;['SIGINT', 'SIGTERM'].forEach(signal => process.off(signal, cleanUp)) const { email } = await prompts({ type: 'text', name: 'email', message: 'Enter an email for the pro plan', validate: value => value.includes('@') || 'Invalid email' }) if (!email || typeof email !== 'string') { console.error(chalk.redBright`No email provided`) cleanUp() } ;['SIGINT', 'SIGTERM'].forEach(signal => process.on(signal, cleanUp)) const patchContent = fs.readFileSync('patch.js', 'utf-8') // Separate imports from the rest of the patch code const patchImportsMatch = patchContent.match(/\/\/ --- Patcher Imports[\s\S]*?\/\/ --- End Patcher Imports ---\n?/) const patchImports = patchImportsMatch ? patchImportsMatch[0] : '' const patchCode = patchContent.replace(patchImports, '') // Find where to put our imports (right after the last existing import) const lastImportMatch = data.match(/^import .+$/gm) const lastImport = lastImportMatch ? lastImportMatch[lastImportMatch.length - 1] : null let patchedData = data // Inject imports at the top if (patchImports && lastImport) { patchedData = patchedData.replace(lastImport, `${lastImport}\n// ------- Patcher Imports -------\n${patchImports.trim()}\n// ------- End Patcher Imports -------`) } // Inject the main patch code where APP_URL is defined patchedData = patchedData .replace('const APP_URL =', `// ------- Injected by HTTP Toolkit Patcher -------\nconst email = \`${email.replace(/`/g, '\\`')}\`\nconst globalProxy = process.env.PROXY ?? \`${globalProxy ? globalProxy.replace(/`/g, '\\`') : ''}\`\n${patchCode}\n// ------- End patched content -------\nconst APP_URL =`) if (data === patchedData || !patchedData) { console.error(chalk.redBright`Patch failed - couldn't find injection point`) cleanUp() } fs.writeFileSync(indexPath, patchedData, 'utf-8') console.log(chalk.greenBright`Patched index.js successfully`) console.log(chalk.yellowBright`Installing dependencies...`) try { const proc = spawn(`${pm} ${installCmd} express https-proxy-agent`, { cwd: tempPath, stdio: 'inherit', shell: true }) activeProcesses.push(proc) await new Promise(resolve => proc.on('close', resolve) ) activeProcesses.splice(activeProcesses.indexOf(proc), 1) if (isCancelled) return } catch (e) { console.error(chalk.redBright`Error installing dependencies`, e) cleanUp() } rm(path.join(tempPath, 'package-lock.json')) rm(path.join(tempPath, 'yarn.lock')) rm(path.join(tempPath, 'pnpm-lock.yaml')) rm(path.join(tempPath, 'bun.lockb')) fs.copyFileSync(filePath, `${filePath}.bak`) console.log(chalk.greenBright`Created backup at {bold ${filePath}.bak}`) console.log(chalk.yellowBright`Repacking the app...`) await asar.createPackage(tempPath, filePath) rm(tempPath) console.log(chalk.greenBright`App patched!`) // Disable ASAR integrity checks so Electron doesn't complain await flipElectronFuses() } const flipElectronFuses = async () => { console.log(chalk.yellowBright`Disabling ASAR integrity checks...`) if (!fs.existsSync(exePath)) { console.log(chalk.yellowBright`Executable not found at ${exePath}, skipping fuse flipping`) return } try { await flipFuses(exePath, { version: FuseVersion.V1, [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: false, [FuseV1Options.OnlyLoadAppFromAsar]: false, }) console.log(chalk.greenBright`Integrity checks disabled successfully`) } catch (e) { console.error(chalk.yellowBright`Failed to flip fuses (might already be done or not supported): ${/** @type {Error} */ (e).message}`) } } switch (argv._[0]) { case 'patch': await patchApp() break case 'restore': try { console.log(chalk.blueBright`Restoring app...`) if (!fs.existsSync(path.join(appPath, 'app.asar.bak'))) console.error(chalk.redBright`Backup file not found - maybe the app wasn't patched?`) else { fs.copyFileSync(path.join(appPath, 'app.asar.bak'), path.join(appPath, 'app.asar')) console.log(chalk.greenBright`App restored successfully`) } rm(path.join(os.tmpdir(), 'httptoolkit-patch')) } catch (e) { if (!isSudo && /** @type {any} */ (e).errno === -13) { // Permission denied console.error(chalk.redBright`Permission denied${!isWin ? ', try running with sudo' : ', try running node as administrator'}`) process.exit(1) } console.error(chalk.redBright`Error restoring app`, e) process.exit(1) } break case 'start': console.log(chalk.blueBright`Starting HTTP Toolkit...`) try { const command = isWin ? `"${path.resolve(appPath, '..', 'HTTP Toolkit.exe')}"` : isMac ? 'open -a "HTTP Toolkit"' : 'httptoolkit' const proc = spawn(command, { stdio: 'inherit', shell: true }) proc.on('close', code => process.exit(code)) } catch (e) { console.error(chalk.redBright`Error starting app`, e) if (isSudo) console.error(chalk.redBright`Try running without sudo`) process.exit(1) } break default: console.error(chalk.redBright`Unknown command`) process.exit(1) } if (!isCancelled) console.log(chalk.greenBright`All done!`)