import { execFileSync, execSync } from "node:child_process"; import { mkdtempSync, readdirSync, realpathSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ShellPermission } from "../external.ts"; import type { ToolState } from "../toolState.ts"; import { log } from "./cli.ts"; import type { Gitea } from "./gitea.ts"; import { isInsideDocker } from "./globals.ts"; import { $ } from "./shell.ts"; export interface SetupOptions { tempDir: string; } export function createTempDirectory(): string { const sharedTempDir = mkdtempSync(join(tmpdir(), "shockbot-")); process.env.SHOCKBOT_TEMP_DIR = sharedTempDir; // also set PULLFROG_TEMP_DIR for compatibility with files that haven't been updated process.env.PULLFROG_TEMP_DIR = sharedTempDir; log.info(`» created temp dir at ${sharedTempDir}`); return sharedTempDir; } export function wipeRunnerLeakSurface(): void { const runnerTemp = process.env.RUNNER_TEMP; if (!runnerTemp) return; const preserve = new Set(); for (const envVar of [ "GITHUB_OUTPUT", "GITHUB_ENV", "GITHUB_PATH", "GITHUB_STATE", "GITHUB_STEP_SUMMARY", ]) { const path = process.env[envVar]; if (!path) continue; try { preserve.add(realpathSync(path)); } catch { preserve.add(path); } } const wiped: string[] = []; const tryUnlink = (path: string): void => { let resolved = path; try { resolved = realpathSync(path); } catch {} if (preserve.has(resolved) || preserve.has(path)) return; try { unlinkSync(path); wiped.push(path); } catch {} }; const listDir = (dir: string): string[] => { try { return readdirSync(dir); } catch { return []; } }; const fileCommandsDir = join(runnerTemp, "_runner_file_commands"); for (const entry of listDir(fileCommandsDir)) { tryUnlink(join(fileCommandsDir, entry)); } for (const entry of listDir(runnerTemp)) { if (entry.endsWith(".sh") || /^git-credentials-.*\.config$/.test(entry)) { tryUnlink(join(runnerTemp, entry)); } } if (wiped.length > 0) { log.info(`» wiped ${wiped.length} leak-surface file(s) from $RUNNER_TEMP`); log.debug(`» wiped paths: ${wiped.join(", ")}`); } } function envScopedToRepo(): NodeJS.ProcessEnv { const scoped = { ...process.env }; for (const key of Object.keys(scoped)) { if (key.startsWith("GIT_")) delete scoped[key]; } return scoped; } export function removeIncludeIfEntries(repoDir: string): void { const env = envScopedToRepo(); let configOutput: string; try { configOutput = execSync("git config --local --get-regexp -z ^includeif\\.", { cwd: repoDir, encoding: "utf-8", stdio: "pipe", env, }); } catch { log.debug("» no includeIf credential entries to remove"); return; } const seen = new Set(); for (const entry of configOutput.split("\0")) { if (!entry) continue; const nl = entry.indexOf("\n"); const key = nl === -1 ? entry : entry.slice(0, nl); if (!key || seen.has(key)) continue; seen.add(key); try { execFileSync("git", ["config", "--local", "--unset-all", key], { cwd: repoDir, stdio: "pipe", env, }); } catch (error) { log.debug( `» failed to unset ${key}: ${error instanceof Error ? error.message : String(error)}` ); } } if (seen.size > 0) log.info( `» removed ${seen.size} includeIf credential ${seen.size === 1 ? "entry" : "entries"}` ); } export interface GitContext { gitToken: string; owner: string; name: string; gitea: Gitea; toolState: ToolState; shell: ShellPermission; postCheckoutScript: string | null; } export type SetupGitParams = GitContext; export async function setupGit(params: SetupGitParams): Promise { const repoDir = process.cwd(); log.info("» setting up git configuration..."); try { let currentEmail = ""; try { currentEmail = execSync("git config user.email", { cwd: repoDir, stdio: "pipe", encoding: "utf-8", }).trim(); } catch {} const shouldSetDefaults = !currentEmail || currentEmail.includes("github-actions") || currentEmail.includes("pullfrog"); if (shouldSetDefaults) { execSync('git config --local user.email "shockbot@git.shockvpn.com"', { cwd: repoDir, stdio: "pipe", }); execSync('git config --local user.name "shockbot"', { cwd: repoDir, stdio: "pipe", }); log.debug("» git user configured (shockbot defaults)"); } else { log.debug(`» git user already configured (${currentEmail}), skipping`); } if (params.shell === "disabled") { execSync("git config --local core.hooksPath /dev/null", { cwd: repoDir, stdio: "pipe", }); log.debug("» git hooks disabled (shell=disabled)"); } } catch (error) { log.info(`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`); } // Remove credential helpers set by actions/checkout for (const header of [ "http.https://github.com/.extraheader", "http.https://git.shockvpn.com/.extraheader", ]) { try { execSync(`git config --local --unset-all ${header}`, { cwd: repoDir, stdio: "pipe", }); log.info(`» removed existing authentication headers (${header})`); } catch { log.debug(`» no existing authentication headers to remove (${header})`); } } removeIncludeIfEntries(repoDir); const giteaUrl = (process.env.GITEA_URL ?? "https://git.shockvpn.com").replace(/\/$/, ""); const originUrl = `${giteaUrl}/${params.owner}/${params.name}.git`; $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); params.toolState.pushUrl = originUrl; $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); params.toolState.initialHead = captureInitialHead(repoDir); log.info("» git authentication configured"); } export function captureInitialHead( repoDir: string ): { kind: "branch"; name: string } | { kind: "detached"; sha: string } { try { const name = $("git", ["symbolic-ref", "--short", "HEAD"], { cwd: repoDir, log: false, }).trim(); if (name) return { kind: "branch", name }; } catch {} const sha = $("git", ["rev-parse", "HEAD"], { cwd: repoDir, log: false }).trim(); return { kind: "detached", sha }; }