267 lines
9.1 KiB
TypeScript
267 lines
9.1 KiB
TypeScript
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<string, unknown> {
|
|
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 <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 <ip> 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<string, unknown> {
|
|
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 <ip> only when target-host DNS cannot resolve GitHub SSH names; HostKeyAlias remains ssh.github.com.",
|
|
],
|
|
options: {
|
|
"--repo <owner/name>": "GitHub repository. Defaults to parsing origin.",
|
|
"--branch <branch>": "Local branch/ref to push. Defaults to the checked-out branch.",
|
|
"--cwd <path>": "Git worktree. Defaults to the UniDesk repo root.",
|
|
"--host-name <host-or-ip>": "SSH HostName override. Defaults to ssh.github.com.",
|
|
"--port <n>": "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<string, unknown> | 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 <known GitHub SSH IP>",
|
|
};
|
|
}
|
|
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;
|
|
}
|