From ceadb3120a4c292c307760461f3c01342a09be2a Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 12 Feb 2026 22:48:29 +0000 Subject: [PATCH] pin all CLI installations to explicit versions and use pro models by default (#283) * pin all CLI installations to explicit versions and use pro models by default - codex: pin @openai/codex to 0.101.0 (was "latest") - opencode: pin opencode-ai to 1.1.56 (was "latest") - gemini: pin gemini-cli to v0.28.2 via new tag param on installFromGithub - cursor: pin to 2026.01.28-fd13201 via direct tarball download (replaces curl install script) - add installFromDirectTarball to install.ts for versioned tarball URLs - gemini auto effort now uses pro-preview instead of flash-preview - test runner model overrides updated to use pro-preview consistently Co-authored-by: Cursor * increase activity timeout * fix delegation timeout --------- Co-authored-by: Cursor --- agents/claude.ts | 1 + agents/codex.ts | 7 ++- agents/cursor.ts | 16 +++++-- agents/gemini.ts | 7 ++- agents/opencode.ts | 7 ++- entry | 109 ++++++++++++++++++++++++--------------------- test/run.ts | 6 +-- utils/install.ts | 75 ++++++++++++++++++++++++++++--- 8 files changed, 163 insertions(+), 65 deletions(-) diff --git a/agents/claude.ts b/agents/claude.ts index 6a44db4..b41e2c5 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -139,6 +139,7 @@ export const claude = agent({ cwd: process.cwd(), env: process.env, stdio: ["ignore", "pipe", "pipe"], + activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput += chunk; diff --git a/agents/codex.ts b/agents/codex.ts index b8979d9..f1a1ed4 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -14,6 +14,10 @@ import { spawn } from "../utils/subprocess.ts"; import { ThinkingTimer } from "../utils/timer.ts"; import { type AgentRunContext, agent } from "./shared.ts"; +// pinned CLI version — no 1-1 package.json dependency for the CLI package +// (package.json has @openai/codex-sdk which is the SDK, not the CLI) +const CODEX_CLI_VERSION = "0.101.0"; + // configuration based on effort level // https://developers.openai.com/codex/models/ type ModelReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -124,7 +128,7 @@ async function installCodex(): Promise { const cliPath = await installFromNpmTarball({ packageName: "@openai/codex", - version: "latest", + version: CODEX_CLI_VERSION, executablePath: "bin/codex.js", installDependencies: true, }); @@ -225,6 +229,7 @@ export const codex = agent({ cwd: process.cwd(), env, stdio: ["ignore", "pipe", "pipe"], + activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput += chunk; diff --git a/agents/cursor.ts b/agents/cursor.ts index f10f29d..aaee874 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -10,10 +10,15 @@ import type { Effort } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts"; import { markActivity } from "../utils/activity.ts"; import { log } from "../utils/cli.ts"; -import { installFromCurl } from "../utils/install.ts"; +import { installFromDirectTarball } from "../utils/install.ts"; import { ThinkingTimer } from "../utils/timer.ts"; import { type AgentRunContext, agent } from "./shared.ts"; +// pinned CLI version — cursor-agent is downloaded as a tarball from downloads.cursor.com. +// the version format is {date}-{commit_hash}. update by inspecting the install script: +// curl -fsSL https://cursor.com/install | grep DOWNLOAD_URL +const CURSOR_CLI_VERSION = "2026.01.28-fd13201"; + // effort configuration for Cursor // only "max" overrides the model; mini/auto use default ("auto") const cursorEffortModels: Record = { @@ -95,9 +100,12 @@ type CursorEvent = | CursorResultEvent; async function installCursor(): Promise { - return await installFromCurl({ - installUrl: "https://cursor.com/install", - executableName: "cursor-agent", + const os = process.platform === "darwin" ? "darwin" : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + return await installFromDirectTarball({ + url: `https://downloads.cursor.com/lab/${CURSOR_CLI_VERSION}/${os}/${arch}/agent-cli-package.tar.gz`, + executablePath: "cursor-agent", + stripComponents: 1, }); } diff --git a/agents/gemini.ts b/agents/gemini.ts index f72464d..c18a9ce 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -24,7 +24,7 @@ const geminiEffortConfig: Record return await installFromGithub({ owner: "google-gemini", repo: "gemini-cli", + tag: GEMINI_CLI_VERSION, assetName: "gemini.js", ...(githubInstallationToken && { githubInstallationToken }), }); @@ -231,6 +235,7 @@ export const gemini = agent({ cmd: "node", args: [cliPath, ...args], env: process.env, + activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { const text = chunk.toString(); finalOutput += text; diff --git a/agents/opencode.ts b/agents/opencode.ts index f0a25b0..c393d09 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -12,6 +12,10 @@ import { spawn } from "../utils/subprocess.ts"; import { ThinkingTimer } from "../utils/timer.ts"; import { type AgentRunContext, agent } from "./shared.ts"; +// pinned CLI version — no 1-1 package.json dependency for the CLI package +// (package.json has @opencode-ai/sdk which is the SDK, not the CLI) +const OPENCODE_CLI_VERSION = "1.1.56"; + // known provider error patterns in stderr (from --print-logs output). // when OpenCode encounters these, it often goes silent on stdout (Issue #752), // so we surface them prominently instead of burying them in debug warnings. @@ -37,7 +41,7 @@ function detectProviderError(text: string): string | null { async function installOpencode(): Promise { return await installFromNpmTarball({ packageName: "opencode-ai", - version: "latest", + version: OPENCODE_CLI_VERSION, executablePath: "bin/opencode", installDependencies: true, }); @@ -114,6 +118,7 @@ export const opencode = agent({ cwd: repoDir, env, timeout: 600000, // 10 minutes timeout to prevent infinite hangs + activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) stdio: ["ignore", "pipe", "pipe"], onStdout: async (chunk) => { const text = chunk.toString(); diff --git a/entry b/entry index 957079d..aefba59 100755 --- a/entry +++ b/entry @@ -144427,7 +144427,7 @@ ${permalinkTip}` var modes = computeModes(); // agents/claude.ts -import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync7 } from "node:fs"; +import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync7 } from "node:fs"; import { join as join9 } from "node:path"; // package.json @@ -144519,7 +144519,7 @@ var package_default = { // utils/install.ts import { spawnSync as spawnSync4 } from "node:child_process"; -import { chmodSync, createWriteStream, existsSync as existsSync5 } from "node:fs"; +import { chmodSync, createWriteStream, existsSync as existsSync5, mkdirSync as mkdirSync3 } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join as join8 } from "node:path"; @@ -144622,7 +144622,7 @@ async function fetchWithRetry(url4, headers, errorMessage) { } async function installFromGithub(params) { log.info(`\xBB installing ${params.owner}/${params.repo} from GitHub releases...`); - const releaseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`; + const releaseUrl = params.tag ? `https://api.github.com/repos/${params.owner}/${params.repo}/releases/tags/${params.tag}` : `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`; log.debug(`\xBB fetching release from ${releaseUrl}...`); const headers = {}; if (params.githubInstallationToken) { @@ -144660,47 +144660,40 @@ async function installFromGithub(params) { log.info(`\xBB installed from GitHub release at ${cliPath}`); return cliPath; } -async function installFromCurl(params) { - log.info(`\xBB installing ${params.executableName}...`); +async function installFromDirectTarball(params) { + log.info(`\xBB downloading tarball from ${params.url}...`); const tempDir = process.env.PULLFROG_TEMP_DIR; - const installScriptPath = join8(tempDir, "install.sh"); - log.debug(`\xBB downloading install script from ${params.installUrl}...`); - const installScriptResponse = await fetch(params.installUrl); - if (!installScriptResponse.ok) { - throw new Error(`Failed to download install script: ${installScriptResponse.status}`); + const tarballPath = join8(tempDir, "direct-package.tgz"); + const response = await fetch(params.url); + if (!response.ok) { + throw new Error(`failed to download tarball: ${response.status} ${response.statusText}`); } - if (!installScriptResponse.body) throw new Error("Response body is null"); - const fileStream = createWriteStream(installScriptPath); - await pipeline(installScriptResponse.body, fileStream); - log.debug(`\xBB downloaded install script to ${installScriptPath}`); - chmodSync(installScriptPath, 493); - log.debug(`\xBB installing to temp directory at ${tempDir}...`); - const installResult = spawnSync4("bash", [installScriptPath], { - cwd: tempDir, - env: { - // Run the install script with HOME set to temp directory - // ensuring a fresh install for each run - HOME: tempDir, - // XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place - XDG_CONFIG_HOME: join8(tempDir, ".config"), - SHELL: process.env.SHELL, - USER: process.env.USER - }, + if (!response.body) throw new Error("response body is null"); + const fileStream = createWriteStream(tarballPath); + await pipeline(response.body, fileStream); + log.debug(`\xBB downloaded tarball to ${tarballPath}`); + const extractDir = join8(tempDir, "direct-package"); + mkdirSync3(extractDir, { recursive: true }); + const tarArgs = ["-xzf", tarballPath, "-C", extractDir]; + if (params.stripComponents) { + tarArgs.push(`--strip-components=${params.stripComponents}`); + } + log.debug(`\xBB extracting tarball...`); + const extractResult = spawnSync4("tar", tarArgs, { stdio: "pipe", encoding: "utf-8" }); - if (installResult.status !== 0) { - const errorOutput = installResult.stderr || installResult.stdout || "No output"; + if (extractResult.status !== 0) { throw new Error( - `Failed to install ${params.executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}` + `failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "unknown error"}` ); } - const cliPath = join8(tempDir, ".local", "bin", params.executableName); + const cliPath = join8(extractDir, params.executablePath); if (!existsSync5(cliPath)) { - throw new Error(`Executable not found at ${cliPath}`); + throw new Error(`executable not found in extracted tarball at ${cliPath}`); } chmodSync(cliPath, 493); - log.info(`\xBB ${params.executableName} installed at ${cliPath}`); + log.info(`\xBB installed at ${cliPath}`); return cliPath; } @@ -144798,7 +144791,7 @@ function buildDisallowedTools(ctx) { } function writeMcpConfig(ctx) { const configDir = join9(ctx.tmpdir, ".claude"); - mkdirSync3(configDir, { recursive: true }); + mkdirSync4(configDir, { recursive: true }); const configPath = join9(configDir, "mcp.json"); const mcpConfig = { mcpServers: { @@ -144861,6 +144854,8 @@ var claude = agent({ cwd: process.cwd(), env: process.env, stdio: ["ignore", "pipe", "pipe"], + activityTimeout: 0, + // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput2 += chunk; stdoutBuffer += chunk; @@ -144997,8 +144992,9 @@ var messageHandlers = { }; // agents/codex.ts -import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync8 } from "node:fs"; +import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "node:fs"; import { join as join10 } from "node:path"; +var CODEX_CLI_VERSION = "0.101.0"; var PREFERRED_MODEL = "gpt-5.3-codex"; var FALLBACK_MODEL = "gpt-5.2-codex"; function getCodexEffortConfig(model) { @@ -145038,7 +145034,7 @@ async function resolveModel(apiKey) { } function writeCodexConfig(ctx) { const codexDir = join10(ctx.tmpdir, ".codex"); - mkdirSync4(codexDir, { recursive: true }); + mkdirSync5(codexDir, { recursive: true }); const configPath = join10(codexDir, "config.toml"); log.info(`\xBB adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`); const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}] @@ -145077,7 +145073,7 @@ async function installCodex() { if (cachedCliPath) return cachedCliPath; const cliPath = await installFromNpmTarball({ packageName: "@openai/codex", - version: "latest", + version: CODEX_CLI_VERSION, executablePath: "bin/codex.js", installDependencies: true }); @@ -145140,6 +145136,8 @@ var codex = agent({ cwd: process.cwd(), env: env2, stdio: ["ignore", "pipe", "pipe"], + activityTimeout: 0, + // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { finalOutput2 += chunk; stdoutBuffer += chunk; @@ -145275,10 +145273,11 @@ var messageHandlers2 = { // agents/cursor.ts import { spawn as spawn3 } from "node:child_process"; -import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync9 } from "node:fs"; +import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync9 } from "node:fs"; import { homedir } from "node:os"; import { join as join11 } from "node:path"; import { performance as performance6 } from "node:perf_hooks"; +var CURSOR_CLI_VERSION = "2026.01.28-fd13201"; var cursorEffortModels = { mini: null, // use default (auto) @@ -145287,9 +145286,12 @@ var cursorEffortModels = { max: "opus-4.5-thinking" }; async function installCursor() { - return await installFromCurl({ - installUrl: "https://cursor.com/install", - executableName: "cursor-agent" + const os = process.platform === "darwin" ? "darwin" : "linux"; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + return await installFromDirectTarball({ + url: `https://downloads.cursor.com/lab/${CURSOR_CLI_VERSION}/${os}/${arch}/agent-cli-package.tar.gz`, + executablePath: "cursor-agent", + stripComponents: 1 }); } var cursor = agent({ @@ -145485,7 +145487,7 @@ function getCursorConfigDir() { function configureCursorMcpServers(ctx) { const cursorConfigDir = getCursorConfigDir(); const mcpConfigPath = join11(cursorConfigDir, "mcp.json"); - mkdirSync5(cursorConfigDir, { recursive: true }); + mkdirSync6(cursorConfigDir, { recursive: true }); const mcpServers = { [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } }; @@ -145495,7 +145497,7 @@ function configureCursorMcpServers(ctx) { function configureCursorTools(ctx) { const cursorConfigDir = getCursorConfigDir(); const cliConfigPath = join11(cursorConfigDir, "cli-config.json"); - mkdirSync5(cursorConfigDir, { recursive: true }); + mkdirSync6(cursorConfigDir, { recursive: true }); const bash = ctx.payload.bash; const deny = []; if (ctx.payload.search === "disabled") deny.push("WebSearch"); @@ -145518,7 +145520,7 @@ function configureCursorTools(ctx) { } // agents/gemini.ts -import { mkdirSync as mkdirSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync10 } from "node:fs"; +import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync10 } from "node:fs"; import { homedir as homedir2 } from "node:os"; import { join as join12 } from "node:path"; var geminiEffortConfig = { @@ -145527,9 +145529,10 @@ var geminiEffortConfig = { // pass the model directly it works if we ever did need to do something like this, // we could write to .gemini/settings.json mini: { model: "gemini-3-flash-preview", thinkingLevel: "LOW" }, - auto: { model: "gemini-3-flash-preview", thinkingLevel: "HIGH" }, + auto: { model: "gemini-3-pro-preview", thinkingLevel: "HIGH" }, max: { model: "gemini-3-pro-preview", thinkingLevel: "HIGH" } }; +var GEMINI_CLI_VERSION = "v0.28.2"; var TRANSIENT_ERROR_PATTERNS = [ "INTERNAL", "status: 500", @@ -145620,6 +145623,7 @@ async function installGemini(githubInstallationToken) { return await installFromGithub({ owner: "google-gemini", repo: "gemini-cli", + tag: GEMINI_CLI_VERSION, assetName: "gemini.js", ...githubInstallationToken && { githubInstallationToken } }); @@ -145651,6 +145655,8 @@ var gemini = agent({ cmd: "node", args: [cliPath, ...args2], env: process.env, + activityTimeout: 0, + // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) onStdout: async (chunk) => { const text = chunk.toString(); finalOutput2 += text; @@ -145732,7 +145738,7 @@ function configureGeminiSettings(ctx) { const realHome = homedir2(); const geminiConfigDir = join12(realHome, ".gemini"); const settingsPath = join12(geminiConfigDir, "settings.json"); - mkdirSync6(geminiConfigDir, { recursive: true }); + mkdirSync7(geminiConfigDir, { recursive: true }); let existingSettings = {}; try { const content = readFileSync6(settingsPath, "utf-8"); @@ -145777,9 +145783,10 @@ function configureGeminiSettings(ctx) { } // agents/opencode.ts -import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync11 } from "node:fs"; +import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync11 } from "node:fs"; import { join as join13 } from "node:path"; import { performance as performance7 } from "node:perf_hooks"; +var OPENCODE_CLI_VERSION = "1.1.56"; var PROVIDER_ERROR_PATTERNS = [ { pattern: "429", label: "rate limited (429)" }, { pattern: "RESOURCE_EXHAUSTED", label: "quota exhausted" }, @@ -145800,7 +145807,7 @@ function detectProviderError(text) { async function installOpencode() { return await installFromNpmTarball({ packageName: "opencode-ai", - version: "latest", + version: OPENCODE_CLI_VERSION, executablePath: "bin/opencode", installDependencies: true }); @@ -145812,7 +145819,7 @@ var opencode = agent({ const cliPath = await installOpencode(); const tempHome = ctx.tmpdir; const configDir = join13(tempHome, ".config", "opencode"); - mkdirSync7(configDir, { recursive: true }); + mkdirSync8(configDir, { recursive: true }); configureOpenCode(ctx); const args2 = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; const modelOverride = process.env.OPENCODE_MODEL; @@ -145850,6 +145857,8 @@ var opencode = agent({ env: env2, timeout: 6e5, // 10 minutes timeout to prevent infinite hangs + activityTimeout: 0, + // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation) stdio: ["ignore", "pipe", "pipe"], onStdout: async (chunk) => { const text = chunk.toString(); @@ -145975,7 +145984,7 @@ ${stderrContext}` }); function configureOpenCode(ctx) { const configDir = join13(ctx.tmpdir, ".config", "opencode"); - mkdirSync7(configDir, { recursive: true }); + mkdirSync8(configDir, { recursive: true }); const configPath = join13(configDir, "opencode.json"); const opencodeMcpServers = { [ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl } diff --git a/test/run.ts b/test/run.ts index 14fa90e..90524d6 100644 --- a/test/run.ts +++ b/test/run.ts @@ -311,12 +311,12 @@ async function runTestForAgent(ctx: RunContext): Promise { env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup; } - // opencode: set model override so tests use a model with quota (avoids default picking one without quota) + // opencode: override to google/gemini-3-pro-preview to avoid flash's tight RPD quota limits if (ctx.agent === "opencode") { - env.OPENCODE_MODEL = "google/gemini-3-flash-preview"; + env.OPENCODE_MODEL = "google/gemini-3-pro-preview"; } - // gemini: use pro model for tests to avoid flash's tight RPD quota limits + // gemini: override to pro for all tests (including mini-effort) to avoid flash's tight RPD quota limits if (ctx.agent === "gemini") { env.GEMINI_MODEL = "gemini-3-pro-preview"; } diff --git a/utils/install.ts b/utils/install.ts index 0a90ba1..8bf98b4 100644 --- a/utils/install.ts +++ b/utils/install.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { chmodSync, createWriteStream, existsSync } from "node:fs"; +import { chmodSync, createWriteStream, existsSync, mkdirSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,9 +18,16 @@ export interface InstallFromCurlParams { executableName: string; } +export interface InstallFromDirectTarballParams { + url: string; + executablePath: string; + stripComponents?: number; +} + export interface InstallFromGithubParams { owner: string; repo: string; + tag?: string; assetName?: string; executablePath?: string; githubInstallationToken?: string; @@ -29,6 +36,7 @@ export interface InstallFromGithubParams { export interface InstallFromGithubTarballParams { owner: string; repo: string; + tag?: string; assetNamePattern: string; executablePath: string; githubInstallationToken?: string; @@ -181,8 +189,10 @@ async function fetchWithRetry( export async function installFromGithub(params: InstallFromGithubParams): Promise { log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`); - // fetch release from GitHub API (latest) - const releaseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`; + // fetch release from GitHub API (pinned tag or latest) + const releaseUrl = params.tag + ? `https://api.github.com/repos/${params.owner}/${params.repo}/releases/tags/${params.tag}` + : `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`; log.debug(`» fetching release from ${releaseUrl}...`); const headers: Record = {}; @@ -261,8 +271,10 @@ export async function installFromGithubTarball( const arch = process.arch === "arm64" ? "arm64" : "x64"; const assetName = params.assetNamePattern.replace("{os}", os).replace("{arch}", arch); - // fetch release from GitHub API (latest) - const releaseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`; + // fetch release from GitHub API (pinned tag or latest) + const releaseUrl = params.tag + ? `https://api.github.com/repos/${params.owner}/${params.repo}/releases/tags/${params.tag}` + : `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`; log.info(`» fetching release from ${releaseUrl}...`); const headers: Record = {}; @@ -328,6 +340,59 @@ export async function installFromGithubTarball( return cliPath; } +/** + * Install a CLI tool from a direct tarball URL. + * Downloads the tarball, extracts it to a temp directory, and returns the path to the CLI executable. + */ +export async function installFromDirectTarball( + params: InstallFromDirectTarballParams +): Promise { + log.info(`» downloading tarball from ${params.url}...`); + + const tempDir = process.env.PULLFROG_TEMP_DIR!; + const tarballPath = join(tempDir, "direct-package.tgz"); + + const response = await fetch(params.url); + if (!response.ok) { + throw new Error(`failed to download tarball: ${response.status} ${response.statusText}`); + } + if (!response.body) throw new Error("response body is null"); + + const fileStream = createWriteStream(tarballPath); + await pipeline(response.body, fileStream); + log.debug(`» downloaded tarball to ${tarballPath}`); + + // always extract into a dedicated directory + const extractDir = join(tempDir, "direct-package"); + mkdirSync(extractDir, { recursive: true }); + + const tarArgs = ["-xzf", tarballPath, "-C", extractDir]; + if (params.stripComponents) { + tarArgs.push(`--strip-components=${params.stripComponents}`); + } + + log.debug(`» extracting tarball...`); + const extractResult = spawnSync("tar", tarArgs, { + stdio: "pipe", + encoding: "utf-8", + }); + if (extractResult.status !== 0) { + throw new Error( + `failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "unknown error"}` + ); + } + + const cliPath = join(extractDir, params.executablePath); + if (!existsSync(cliPath)) { + throw new Error(`executable not found in extracted tarball at ${cliPath}`); + } + + chmodSync(cliPath, 0o755); + log.info(`» installed at ${cliPath}`); + + return cliPath; +} + /** * Install a CLI tool from a curl-based install script * Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable