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 <cursoragent@cursor.com>

* increase activity timeout

* fix delegation timeout

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
David Blass
2026-02-12 22:48:29 +00:00
committed by pullfrog[bot]
parent 9071c0ae6c
commit ceadb3120a
8 changed files with 163 additions and 65 deletions
+1
View File
@@ -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;
+6 -1
View File
@@ -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<string> {
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;
+12 -4
View File
@@ -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<Effort, string | null> = {
@@ -95,9 +100,12 @@ type CursorEvent =
| CursorResultEvent;
async function installCursor(): Promise<string> {
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,
});
}
+6 -1
View File
@@ -24,7 +24,7 @@ const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string
// 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" },
} as const;
@@ -85,6 +85,9 @@ type GeminiEvent =
| GeminiToolResultEvent
| GeminiResultEvent;
// pinned CLI version — gemini-cli is installed from GitHub releases, not npm
const GEMINI_CLI_VERSION = "v0.28.2";
// transient API error patterns that warrant a retry.
// these are server-side issues, not client errors.
const TRANSIENT_ERROR_PATTERNS = [
@@ -191,6 +194,7 @@ async function installGemini(githubInstallationToken?: string): Promise<string>
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;
+6 -1
View File
@@ -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<string> {
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();
+59 -50
View File
@@ -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 }
+3 -3
View File
@@ -311,12 +311,12 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
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";
}
+70 -5
View File
@@ -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<string> {
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<string, string> = {};
@@ -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<string, string> = {};
@@ -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<string> {
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