204 lines
5 KiB
JavaScript
204 lines
5 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawnSync } from "node:child_process";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const args = process.argv.slice(2);
|
|
const wrapperDir = dirname(fileURLToPath(import.meta.url));
|
|
const realGh = process.env.BIFROST_REAL_GH || resolveExecutable("gh", wrapperDir);
|
|
const realGhArgs = splitCommandArgs(process.env.BIFROST_REAL_GH_ARGS ?? "");
|
|
const readOnlyTopLevelCommands = new Set([
|
|
"",
|
|
"help",
|
|
"version",
|
|
"status",
|
|
"browse",
|
|
"search",
|
|
]);
|
|
const readOnlyScopedCommands = new Map([
|
|
["auth", new Set(["status"])],
|
|
["issue", new Set(["list", "status", "view"])],
|
|
["pr", new Set(["checks", "diff", "list", "status", "view"])],
|
|
["release", new Set(["download", "list", "view"])],
|
|
["repo", new Set(["list", "view"])],
|
|
["run", new Set(["download", "list", "view", "watch"])],
|
|
["secret", new Set(["list"])],
|
|
["variable", new Set(["list"])],
|
|
["workflow", new Set(["list", "view"])],
|
|
]);
|
|
const globalOptionsWithSeparateValues = new Set([
|
|
"-R",
|
|
"--repo",
|
|
"-h",
|
|
"--hostname",
|
|
]);
|
|
|
|
if (!realGh) {
|
|
process.stderr.write("Bifrost GitHub gate has no real GitHub CLI executable configured.\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (
|
|
process.env.BIFROST_ENFORCE_GITHUB_GATE === "true" &&
|
|
requiresBridgeAuthorization(args) &&
|
|
process.env.BIFROST_GITHUB_MUTATION_AUTHORIZED !== "true"
|
|
) {
|
|
process.stderr.write(
|
|
"Bifrost blocked GitHub CLI mutation or unclassified command in this dispatched turn. Use tools/bifrost-bridge.mjs so GitHub publication is gated and receipted.\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const result = isWindowsCommandScript(realGh)
|
|
? spawnSync("cmd.exe", ["/d", "/s", "/c", realGh, ...realGhArgs, ...args], {
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
windowsHide: true,
|
|
})
|
|
: spawnSync(realGh, [...realGhArgs, ...args], {
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
windowsHide: true,
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
process.exit(result.status ?? 1);
|
|
|
|
function resolveExecutable(command, excludedDir) {
|
|
const locator = process.platform === "win32" ? "where.exe" : "which";
|
|
const result = spawnSync(locator, [command], {
|
|
cwd: excludedDir,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
return "";
|
|
}
|
|
|
|
const matches = `${result.stdout ?? ""}`
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.filter((line) => !isWithinExcludedDir(line, excludedDir));
|
|
|
|
return matches[0] ?? "";
|
|
}
|
|
|
|
function isWithinExcludedDir(candidate, excludedDir) {
|
|
const normalizedCandidate = resolve(candidate).toLowerCase();
|
|
const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`;
|
|
return normalizedCandidate.startsWith(normalizedExcludedDir);
|
|
}
|
|
|
|
function isWindowsCommandScript(command) {
|
|
return /\.cmd$/i.test(command) || /\.bat$/i.test(command);
|
|
}
|
|
|
|
function requiresBridgeAuthorization(input) {
|
|
const { scope, action } = parseCommand(input);
|
|
if (scope === "api") {
|
|
return apiMethod(input) !== "GET";
|
|
}
|
|
|
|
if (readOnlyTopLevelCommands.has(scope)) {
|
|
return false;
|
|
}
|
|
|
|
const readOnlyActions = readOnlyScopedCommands.get(scope);
|
|
if (readOnlyActions?.has(action)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function parseCommand(input) {
|
|
let index = 0;
|
|
while (index < input.length) {
|
|
const token = input[index];
|
|
if (!token) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (token === "--") {
|
|
index += 1;
|
|
break;
|
|
}
|
|
|
|
if (!token.startsWith("-")) {
|
|
break;
|
|
}
|
|
|
|
if (globalOptionsWithSeparateValues.has(token)) {
|
|
index += 2;
|
|
continue;
|
|
}
|
|
|
|
if (token.startsWith("--repo=") || token.startsWith("--hostname=")) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
index += 1;
|
|
}
|
|
|
|
return {
|
|
scope: input[index] ?? "",
|
|
action: input[index + 1] ?? "",
|
|
};
|
|
}
|
|
|
|
function apiMethod(input) {
|
|
for (let index = 0; index < input.length; index += 1) {
|
|
const token = input[index];
|
|
if (token === "--method" || token === "-X") {
|
|
return `${input[index + 1] ?? "GET"}`.trim().toUpperCase();
|
|
}
|
|
|
|
if (token.startsWith("--method=") || token.startsWith("-X=")) {
|
|
const separator = token.includes("=") ? "=" : "";
|
|
const value = separator ? token.slice(token.indexOf("=") + 1) : "";
|
|
return value.trim().toUpperCase();
|
|
}
|
|
|
|
if (
|
|
token === "--input" ||
|
|
token === "-f" ||
|
|
token === "-F" ||
|
|
token === "--field" ||
|
|
token === "--raw-field"
|
|
) {
|
|
return "POST";
|
|
}
|
|
|
|
if (
|
|
token.startsWith("--input=") ||
|
|
token.startsWith("-f=") ||
|
|
token.startsWith("-F=") ||
|
|
token.startsWith("--field=") ||
|
|
token.startsWith("--raw-field=")
|
|
) {
|
|
return "POST";
|
|
}
|
|
}
|
|
|
|
return "GET";
|
|
}
|
|
|
|
function splitCommandArgs(value) {
|
|
if (!value) {
|
|
return [];
|
|
}
|
|
|
|
const matches = value.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
|
|
return matches
|
|
.map((part) => part.trim())
|
|
.filter(Boolean)
|
|
.map((part) => (part.startsWith("\"") && part.endsWith("\"")) ? part.slice(1, -1) : part);
|
|
}
|