Files
pikasTech-agentrun/tools/agentrun-git
T
2026-06-12 05:08:44 +08:00

344 lines
14 KiB
TypeScript
Executable File

#!/usr/bin/env bun
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, stat } from "node:fs/promises";
import path from "node:path";
const defaultTimeoutMs = Number(process.env.AGENTRUN_GIT_DEFAULT_TIMEOUT_MS || 60_000);
const defaultConnectTimeoutSeconds = Number(process.env.AGENTRUN_GIT_CONNECT_TIMEOUT_SECONDS || 10);
const defaultLowSpeedLimit = Number(process.env.GIT_HTTP_LOW_SPEED_LIMIT || 1_024);
const defaultLowSpeedTime = Number(process.env.GIT_HTTP_LOW_SPEED_TIME || 15);
const defaultHttpVersion = process.env.AGENTRUN_GIT_HTTP_VERSION || process.env.GIT_HTTP_VERSION || "HTTP/1.1";
const defaultDirectHosts = ["registry.npmjs.org", "registry.npmmirror.com"];
function help() {
return {
ok: true,
tool: "agentrun-git",
purpose: "bounded GitHub/codeload clone, fetch, ls-remote and download helper for AgentRun runners",
commands: ["ls-remote", "clone", "fetch", "download"],
usage: [
"agentrun-git ls-remote <repo-url> [ref] [--timeout-ms N]",
"agentrun-git clone <repo-url> <target-dir> [--ref REF] [--depth N] [--timeout-ms N]",
"agentrun-git fetch <repo-dir> <ref> [--remote origin] [--depth N] [--timeout-ms N]",
"agentrun-git download <url> <output-file> [--timeout-ms N]",
],
defaults: transportDefaults(),
valuesPrinted: false,
};
}
function transportDefaults() {
return {
timeoutMs: defaultTimeoutMs,
connectTimeoutSeconds: defaultConnectTimeoutSeconds,
httpVersion: defaultHttpVersion,
lowSpeedLimitBytes: defaultLowSpeedLimit,
lowSpeedTimeSeconds: defaultLowSpeedTime,
credentialHelper: "gh-auth-setup-git",
directHosts: directHosts(),
valuesPrinted: false,
};
}
function writeJson(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
function fail(message, details = {}, code = 2) {
writeJson({ ok: false, action: "agentrun-git", failureKind: "schema-invalid", message, ...details, valuesPrinted: false });
process.exit(code);
}
function parseArgs(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) {
positional.push(arg);
continue;
}
const name = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) flags[name] = "true";
else {
flags[name] = next;
i += 1;
}
}
return { positional, flags };
}
function numberFlag(flags, name, fallback) {
const raw = flags[name];
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) fail(`--${name} must be a positive number`, { flag: name });
return Math.trunc(value);
}
function stringFlag(flags, name, fallback = null) {
const raw = flags[name];
return typeof raw === "string" && raw !== "true" && raw.trim().length > 0 ? raw.trim() : fallback;
}
function directHosts() {
const raw = process.env.AGENTRUN_GIT_DIRECT_HOSTS;
if (!raw || raw.trim().length === 0) return [...defaultDirectHosts];
return raw.split(",").map((item) => item.trim()).filter(Boolean);
}
function hostFromTarget(value) {
const raw = String(value || "").trim();
if (/^git@github\.com:/u.test(raw)) return "github.com";
try {
return new URL(raw).hostname;
} catch {
return null;
}
}
function proxyDecision(target) {
const host = hostFromTarget(target);
const hosts = directHosts();
const direct = host ? hosts.includes(host) : false;
const proxyConfigured = Boolean(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.ALL_PROXY || process.env.http_proxy || process.env.https_proxy || process.env.all_proxy);
return {
host,
mode: direct ? "direct-no-proxy" : proxyConfigured ? "env-proxy" : "direct-no-proxy",
reason: direct ? "AGENTRUN_GIT_DIRECT_HOSTS" : proxyConfigured ? "proxy-env" : "no-proxy-env",
proxyConfigured,
valuesPrinted: false,
};
}
function commandEnv(target) {
const decision = proxyDecision(target);
const env = {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_HTTP_VERSION: defaultHttpVersion,
GIT_HTTP_LOW_SPEED_LIMIT: String(defaultLowSpeedLimit),
GIT_HTTP_LOW_SPEED_TIME: String(defaultLowSpeedTime),
};
if (decision.mode === "direct-no-proxy") {
delete env.HTTP_PROXY;
delete env.HTTPS_PROXY;
delete env.ALL_PROXY;
delete env.http_proxy;
delete env.https_proxy;
delete env.all_proxy;
}
const noProxy = new Set(String(env.NO_PROXY || env.no_proxy || "").split(",").map((item) => item.trim()).filter(Boolean));
for (const host of directHosts()) noProxy.add(host);
if (decision.host) noProxy.add(decision.host);
env.NO_PROXY = [...noProxy].join(",");
env.no_proxy = env.NO_PROXY;
return { env, decision };
}
async function runCommand(command, args, options) {
const startedAt = Date.now();
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout = bounded(`${stdout}${chunk}`); });
child.stderr.on("data", (chunk) => { stderr = bounded(`${stderr}${chunk}`); });
let timedOut = false;
let closed = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!closed) child.kill("SIGKILL");
}, 2_000).unref();
}, options.timeoutMs);
const result = await new Promise((resolve, reject) => {
child.on("error", reject);
child.on("close", (code, signal) => {
closed = true;
resolve({ code, signal });
});
}).catch((error) => ({ code: 127, signal: null, spawnError: error instanceof Error ? error.message : String(error) }));
clearTimeout(timeout);
return {
code: result.code,
signal: timedOut ? "SIGTERM" : result.signal,
timedOut,
elapsedMs: Date.now() - startedAt,
stdout: redact(stdout),
stderr: redact(stderr),
spawnError: result.spawnError ? redact(result.spawnError) : null,
};
}
function bounded(text) {
const limit = 12_000;
return text.length > limit ? text.slice(text.length - limit) : text;
}
function redact(text) {
return String(text)
.replace(/\b(ghp_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]{8,})\b/gu, "REDACTED")
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[A-Za-z0-9._~+/=-]+/giu, "$1$2REDACTED")
.replace(/(https?:\/\/[^:\s/@]+:)[^@\s]+(@)/giu, "$1REDACTED$2");
}
function gitArgs(args) {
return [
"-c", `http.version=${defaultHttpVersion}`,
"-c", `http.lowSpeedLimit=${defaultLowSpeedLimit}`,
"-c", `http.lowSpeedTime=${defaultLowSpeedTime}`,
...args,
];
}
async function setupCredentialHelper(env, timeoutMs) {
const tokenPresent = Boolean(env.GH_TOKEN || env.GITHUB_TOKEN);
if (!tokenPresent) return { helper: "gh-auth-setup-git", tokenPresent: false, status: "skipped-no-token", valuesPrinted: false };
const result = await runCommand("gh", ["auth", "setup-git"], { env, timeoutMs: Math.min(timeoutMs, 15_000) });
return { helper: "gh-auth-setup-git", tokenPresent: true, status: result.code === 0 ? "configured" : "failed", exitCode: result.code, stderrTail: result.stderr.slice(-500), valuesPrinted: false };
}
function timeoutBudget(timeoutMs, elapsedMs, timedOut) {
return { timeoutMs, elapsedMs, state: timedOut ? "timed-out" : "terminal", valuesPrinted: false };
}
function pathSummary(value) {
const parts = path.resolve(value).split(/[\\/]+/u).filter(Boolean);
return { absolute: path.isAbsolute(value), basename: parts.at(-1) || null, depth: parts.length, valuesPrinted: false };
}
async function gitRevParse(cwd, env, timeoutMs, ref = "HEAD") {
const result = await runCommand("git", gitArgs(["rev-parse", ref]), { cwd, env, timeoutMs: Math.min(timeoutMs, 10_000) });
return result.code === 0 ? result.stdout.trim().split(/\s+/u)[0] || null : null;
}
async function operationResult(operation, target, timeoutMs, body) {
const { env, decision } = commandEnv(target);
const credential = await setupCredentialHelper(env, timeoutMs);
const result = await body(env, decision, credential);
const ok = result.code === 0 && result.timedOut !== true;
writeJson({
ok,
action: "agentrun-git",
operation,
target: redactedTarget(target),
timeoutBudget: timeoutBudget(timeoutMs, result.elapsedMs, result.timedOut),
protocol: { httpVersion: defaultHttpVersion, terminalPrompt: false, lowSpeedLimitBytes: defaultLowSpeedLimit, lowSpeedTimeSeconds: defaultLowSpeedTime, valuesPrinted: false },
proxyDecision: decision,
credential,
result: result.result ?? {},
failureKind: ok ? null : result.timedOut ? "backend-timeout" : "infra-failed",
exitCode: result.code,
signal: result.signal,
stderrTail: result.stderr.slice(-1200),
valuesPrinted: false,
});
process.exit(ok ? 0 : result.timedOut ? 124 : 1);
}
function redactedTarget(value) {
return redact(String(value));
}
async function lsRemote(args, flags) {
const repoUrl = args[0];
if (!repoUrl) fail("ls-remote requires <repo-url>");
const ref = args[1] || "HEAD";
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
await operationResult("ls-remote", repoUrl, timeoutMs, async (env) => {
const result = await runCommand("git", gitArgs(["ls-remote", repoUrl, ref]), { env, timeoutMs });
const first = result.stdout.trim().split(/\r?\n/u)[0] || "";
return { ...result, result: { ref, firstLine: first.slice(0, 160), valuesPrinted: false } };
});
}
async function cloneRepo(args, flags) {
const repoUrl = args[0];
const targetDir = args[1];
if (!repoUrl || !targetDir) fail("clone requires <repo-url> <target-dir>");
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
const depth = String(numberFlag(flags, "depth", 1));
const ref = stringFlag(flags, "ref");
await mkdir(path.dirname(path.resolve(targetDir)), { recursive: true });
await operationResult("clone", repoUrl, timeoutMs, async (env) => {
let result;
if (ref) {
await mkdir(targetDir, { recursive: true });
const init = await runCommand("git", gitArgs(["init"]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) });
if (init.code !== 0) return init;
const remote = await runCommand("git", gitArgs(["remote", "add", "origin", repoUrl]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) });
if (remote.code !== 0) return remote;
result = await runCommand("git", gitArgs(["fetch", "--depth", depth, "origin", ref]), { cwd: targetDir, env, timeoutMs });
if (result.code === 0) result = await runCommand("git", gitArgs(["checkout", "--detach", "FETCH_HEAD"]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) });
} else {
result = await runCommand("git", gitArgs(["clone", "--depth", depth, repoUrl, targetDir]), { env, timeoutMs });
}
const commitId = result.code === 0 ? await gitRevParse(targetDir, env, timeoutMs) : null;
return { ...result, result: { target: pathSummary(targetDir), ref, depth: Number(depth), commitId, valuesPrinted: false } };
});
}
async function fetchRepo(args, flags) {
const repoDir = args[0];
const ref = args[1];
if (!repoDir || !ref) fail("fetch requires <repo-dir> <ref>");
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
const depth = String(numberFlag(flags, "depth", 1));
const remote = stringFlag(flags, "remote", "origin");
await operationResult("fetch", `${repoDir}:${remote}:${ref}`, timeoutMs, async (env) => {
const result = await runCommand("git", gitArgs(["fetch", "--depth", depth, remote, ref]), { cwd: repoDir, env, timeoutMs });
const fetchHead = result.code === 0 ? await gitRevParse(repoDir, env, timeoutMs, "FETCH_HEAD") : null;
return { ...result, result: { repoDir: pathSummary(repoDir), remote, ref, depth: Number(depth), fetchHead, valuesPrinted: false } };
});
}
async function download(args, flags) {
const url = args[0];
const output = args[1];
if (!url || !output) fail("download requires <url> <output-file>");
const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs);
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
await mkdir(path.dirname(path.resolve(output)), { recursive: true });
await operationResult("download", url, timeoutMs, async (env) => {
const result = await runCommand("curl", ["-L", "--fail", "--max-time", String(seconds), "--connect-timeout", String(defaultConnectTimeoutSeconds), "-o", output, url], { env, timeoutMs: timeoutMs + 1_000 });
let bytes = 0;
let sha256 = null;
if (result.code === 0) {
const file = Bun.file(output);
const buffer = Buffer.from(await file.arrayBuffer());
bytes = buffer.length;
sha256 = createHash("sha256").update(buffer).digest("hex");
} else {
try {
bytes = (await stat(output)).size;
} catch {
bytes = 0;
}
}
return { ...result, result: { output: pathSummary(output), bytes, sha256: sha256 ? sha256.slice(0, 16) : null, valuesPrinted: false } };
});
}
async function main() {
const argv = process.argv.slice(2);
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "help" || argv[0] === "-h") {
writeJson(help());
return;
}
const command = argv[0];
const { positional, flags } = parseArgs(argv.slice(1));
if (command === "ls-remote") return await lsRemote(positional, flags);
if (command === "clone") return await cloneRepo(positional, flags);
if (command === "fetch") return await fetchRepo(positional, flags);
if (command === "download") return await download(positional, flags);
fail(`unsupported command: ${command}`, { commands: help().commands });
}
await main();