import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { repoRoot } from "./config"; interface GitHubPushFallbackOptions { repo: string | null; branch: string | null; cwd: string; hostName: string; port: number; confirm: boolean; } export function runGitToolsCommand(args: string[]): Record { const [action] = args; if (action === undefined || action === "help" || action === "--help" || action === "-h") return gitToolsHelp(); if (action !== "github-push-fallback") throw new Error(`unsupported git command: ${action}`); const options = parseGitHubPushFallbackOptions(args.slice(1)); const repo = options.repo ?? deriveGitHubRepo(options.cwd); const branch = options.branch ?? currentGitBranch(options.cwd); if (repo === null) throw new Error("git github-push-fallback requires --repo owner/name, or an origin remote that points at GitHub"); if (branch === null) throw new Error("git github-push-fallback requires --branch , or a checked-out branch"); validateGitHubRepo(repo); validatePushRef(branch); const remoteUrl = `ssh://git@ssh.github.com:${options.port}/${repo}.git`; const sshCommand = [ "ssh", "-o", `HostName=${options.hostName}`, "-o", `Port=${options.port}`, "-o", "HostKeyAlias=ssh.github.com", "-o", "StrictHostKeyChecking=accept-new", ].join(" "); const argv = ["git", "push", remoteUrl, branch]; const base = { ok: true, command: "git github-push-fallback", cwd: options.cwd, mutation: options.confirm, repository: repo, branch, remoteUrl, ssh: { hostName: options.hostName, port: options.port, hostKeyAlias: "ssh.github.com", strictHostKeyChecking: "accept-new", }, env: { GIT_SSH_COMMAND: sshCommand, valuesPrinted: false, }, argv, boundary: "does not edit git remotes; intended for GitHub DNS/port-22 reachability fallback only", }; if (!options.confirm) { return { ...base, executed: false, dryRun: true, next: [ "rerun with --confirm to execute this one push without changing origin", "use --host-name only when both github.com and ssh.github.com DNS are broken on the target host", ], }; } const result = spawnSync("git", ["push", remoteUrl, branch], { cwd: options.cwd, env: { ...process.env, GIT_SSH_COMMAND: sshCommand }, encoding: "utf8", timeout: 120_000, }); const stdout = result.stdout ?? ""; const stderr = result.stderr ?? ""; const ok = result.status === 0; return { ...base, ok, executed: true, dryRun: false, exitCode: result.status, signal: result.signal, stdoutTail: tail(stdout, 2000), stderrTail: tail(stderr, 4000), diagnostic: classifyGitPushFailure(`${stdout}\n${stderr}`), }; } function gitToolsHelp(): Record { return { ok: true, command: "git", usage: [ "bun scripts/cli.ts git github-push-fallback --repo pikasTech/HWLAB --branch fix/name", "bun scripts/cli.ts git github-push-fallback --repo pikasTech/HWLAB --branch fix/name --host-name 140.82.116.36 --confirm", ], behavior: [ "Plans or executes a one-shot GitHub push through ssh.github.com:443 without editing git remotes.", "Default output is a dry-run plan. Add --confirm to execute the push.", "Use --host-name only when target-host DNS cannot resolve GitHub SSH names; HostKeyAlias remains ssh.github.com.", ], options: { "--repo ": "GitHub repository. Defaults to parsing origin.", "--branch ": "Local branch/ref to push. Defaults to the checked-out branch.", "--cwd ": "Git worktree. Defaults to the UniDesk repo root.", "--host-name ": "SSH HostName override. Defaults to ssh.github.com.", "--port ": "SSH port. Defaults to 443.", "--confirm": "Execute the push. Without this flag, only prints a plan.", }, }; } function parseGitHubPushFallbackOptions(args: string[]): GitHubPushFallbackOptions { const options: GitHubPushFallbackOptions = { repo: null, branch: null, cwd: repoRoot, hostName: "ssh.github.com", port: 443, confirm: false, }; for (let index = 0; index < args.length; index += 1) { const arg = args[index] ?? ""; const next = args[index + 1]; if (arg === "--repo") { options.repo = requireValue(arg, next); index += 1; continue; } if (arg.startsWith("--repo=")) { options.repo = requireValue("--repo", arg.slice("--repo=".length)); continue; } if (arg === "--branch") { options.branch = requireValue(arg, next); index += 1; continue; } if (arg.startsWith("--branch=")) { options.branch = requireValue("--branch", arg.slice("--branch=".length)); continue; } if (arg === "--cwd") { options.cwd = resolve(requireValue(arg, next)); index += 1; continue; } if (arg.startsWith("--cwd=")) { options.cwd = resolve(requireValue("--cwd", arg.slice("--cwd=".length))); continue; } if (arg === "--host-name" || arg === "--host") { options.hostName = requireValue(arg, next); index += 1; continue; } if (arg.startsWith("--host-name=")) { options.hostName = requireValue("--host-name", arg.slice("--host-name=".length)); continue; } if (arg === "--host-ip") { options.hostName = requireValue(arg, next); index += 1; continue; } if (arg === "--port") { options.port = parsePort(requireValue(arg, next)); index += 1; continue; } if (arg.startsWith("--port=")) { options.port = parsePort(requireValue("--port", arg.slice("--port=".length))); continue; } if (arg === "--confirm") { options.confirm = true; continue; } if (arg === "--dry-run") continue; throw new Error(`unsupported git github-push-fallback option: ${arg}`); } validateHostName(options.hostName); return options; } function deriveGitHubRepo(cwd: string): string | null { const result = gitCapture(cwd, ["remote", "get-url", "origin"]); if (!result.ok) return null; const remote = result.stdout.trim(); const patterns = [ /^git@github\.com:([^/\s]+\/[^/\s]+?)(?:\.git)?$/u, /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?$/u, /^ssh:\/\/git@(?:github\.com|ssh\.github\.com)(?::\d+)?\/([^/\s]+\/[^/\s]+?)(?:\.git)?$/u, ]; for (const pattern of patterns) { const match = remote.match(pattern); if (match?.[1] !== undefined) return match[1]; } return null; } function currentGitBranch(cwd: string): string | null { const result = gitCapture(cwd, ["branch", "--show-current"]); const branch = result.ok ? result.stdout.trim() : ""; return branch.length > 0 ? branch : null; } function gitCapture(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } { const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 10_000 }); return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", }; } function classifyGitPushFailure(output: string): Record | null { if (/Could not resolve hostname github\.com|Temporary failure in name resolution|Name or service not known/iu.test(output)) { return { kind: "github-dns-or-name-resolution", next: "retry with git github-push-fallback; if ssh.github.com DNS also fails, pass --host-name ", }; } if (/connect to host github\.com port 22|Connection timed out|Network is unreachable/iu.test(output)) { return { kind: "github-port-22-or-network-blocked", next: "retry through ssh.github.com:443 with git github-push-fallback", }; } if (/Permission denied \(publickey\)|Authentication failed|Repository not found/iu.test(output)) { return { kind: "auth-or-repository-access", next: "do not keep retrying network fallbacks; inspect SSH key, deploy key, repository name, and GitHub access", }; } return null; } function requireValue(name: string, value: string | undefined): string { if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`); return value; } function parsePort(value: string): number { const port = Number(value); if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error("--port must be an integer from 1 to 65535"); return port; } function validateGitHubRepo(value: string): void { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error("--repo must be owner/name"); } function validatePushRef(value: string): void { if (!/^[A-Za-z0-9_./:-]+$/u.test(value) || value.includes("..") || value.startsWith("-")) throw new Error("--branch must be a simple git ref without whitespace or shell characters"); } function validateHostName(value: string): void { if (!/^[A-Za-z0-9_.:-]+$/u.test(value) || value.startsWith("-")) throw new Error("--host-name must be a hostname or IP literal"); } function tail(value: string, maxChars: number): string { return value.length > maxChars ? value.slice(-maxChars) : value; }