feat: add safe GitHub CLI wrapper

This commit is contained in:
Codex
2026-05-20 08:32:29 +00:00
parent a6e21ec201
commit ca74677703
6 changed files with 641 additions and 2 deletions
+618
View File
@@ -0,0 +1,618 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
const DEFAULT_REPO = "pikasTech/unidesk";
const GITHUB_API = "https://api.github.com";
const USER_AGENT = "unidesk-cli-gh";
const PREVIEW_CHARS = 240;
type GitHubDegradedReason =
| "missing-binary"
| "missing-token"
| "egress-failed"
| "permission-denied"
| "repo-not-found"
| "issue-not-found"
| "invalid-response"
| "unsupported-command";
interface GitHubCommandResult {
ok: boolean;
repo: string;
command: string;
degradedReason?: GitHubDegradedReason;
degraded?: GitHubDegradedReason[];
[key: string]: unknown;
}
interface GitHubTokenProbe {
present: boolean;
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
ghFallbackAttempted: boolean;
}
interface GitHubOptions {
repo: string;
dryRun: boolean;
limit: number;
title?: string;
bodyFile?: string;
}
interface GitHubErrorPayload {
ok: false;
degradedReason: GitHubDegradedReason;
status?: number;
message: string;
details?: unknown;
}
interface GitHubIssue {
id: number;
number: number;
title: string;
body: string | null;
state: string;
html_url: string;
comments: number;
user?: { login?: string };
created_at?: string;
updated_at?: string;
}
interface GitHubComment {
id: number;
body: string | null;
html_url: string;
user?: { login?: string };
created_at?: string;
updated_at?: string;
}
interface GitHubPullRequest {
id: number;
number: number;
title: string;
body: string | null;
state: string;
html_url: string;
draft?: boolean;
user?: { login?: string };
head?: { ref?: string; sha?: string };
base?: { ref?: string; sha?: string };
created_at?: string;
updated_at?: string;
}
function optionValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const raw = optionValue(args, name);
if (raw === undefined) return defaultValue;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
return Math.min(value, maxValue);
}
function parseOptions(args: string[]): GitHubOptions {
return {
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
dryRun: hasFlag(args, "--dry-run"),
limit: positiveIntegerOption(args, "--limit", 30, 100),
title: optionValue(args, "--title"),
bodyFile: optionValue(args, "--body-file"),
};
}
function parseNumber(raw: string | undefined, label: string): number {
if (raw === undefined) throw new Error(`${label} requires a number`);
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`);
return value;
}
function readBodyFile(path: string | undefined, command: string): string {
if (path === undefined) throw new Error(`${command} requires --body-file <file>`);
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
return readFileSync(path, "utf8");
}
function tokenFromEnvironment(): GitHubTokenProbe {
if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) {
return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false };
}
if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) {
return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false };
}
return { present: false, source: null, ghFallbackAttempted: false };
}
function ghBinaryPath(): string | null {
try {
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
return output.length > 0 ? output : null;
} catch {
return null;
}
}
function ghAuthToken(): string | null {
try {
const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
return output.length > 0 ? output : null;
} catch {
return null;
}
}
function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
const envProbe = tokenFromEnvironment();
if (envProbe.present) {
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
return { token, probe: envProbe };
}
if (!allowGhFallback) return { token: null, probe: envProbe };
const token = ghAuthToken();
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true } };
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true } };
}
function repoParts(repo: string): { owner: string; name: string } {
const [owner, name, extra] = repo.split("/");
if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format");
return { owner, name };
}
function preview(text: string): string {
return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text;
}
function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> {
return {
repo,
...(title === undefined ? {} : { title }),
bodyChars: body.length,
bodyPreview: preview(body),
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
preservesRawNewlines: body.includes("\n"),
containsLiteralBackslashN: body.includes("\\n"),
containsBackticks: body.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body),
};
}
async function parseGitHubResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (text.length === 0) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return { raw: preview(text) };
}
}
function classifyHttpStatus(status: number, message: string, path: string): GitHubDegradedReason {
if (status === 401 || status === 403) return "permission-denied";
if (status === 404) return path.includes("/issues/") || message.toLowerCase().includes("issue") ? "issue-not-found" : "repo-not-found";
return "invalid-response";
}
async function githubRequest<T>(
token: string,
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
let response: Response;
try {
response = await fetch(`${GITHUB_API}${path}`, {
method,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
return {
ok: false,
degradedReason: "egress-failed",
message: error instanceof Error ? error.message : String(error),
};
}
const parsed = await parseGitHubResponse(response);
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
return {
ok: false,
degradedReason: classifyHttpStatus(response.status, message, path),
status: response.status,
message,
details: parsed,
};
}
return parsed as T;
}
function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
if (!tokenProbe.present) {
return {
ok: false,
command,
repo,
degradedReason: "missing-token",
token: tokenProbe,
};
}
return null;
}
function isGitHubError(value: unknown): value is GitHubErrorPayload {
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value;
}
function issueSummary(issue: GitHubIssue): Record<string, unknown> {
return {
id: issue.id,
number: issue.number,
title: issue.title,
body: issue.body ?? "",
state: issue.state,
url: issue.html_url,
author: issue.user?.login ?? null,
createdAt: issue.created_at ?? null,
updatedAt: issue.updated_at ?? null,
};
}
function commentSummary(comment: GitHubComment): Record<string, unknown> {
return {
id: comment.id,
body: comment.body ?? "",
url: comment.html_url,
author: comment.user?.login ?? null,
createdAt: comment.created_at ?? null,
updatedAt: comment.updated_at ?? null,
};
}
function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
return {
id: pr.id,
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state,
draft: pr.draft ?? false,
url: pr.html_url,
author: pr.user?.login ?? null,
head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
createdAt: pr.created_at ?? null,
updatedAt: pr.updated_at ?? null,
};
}
async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> {
const { owner, name } = repoParts(repo);
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
}
async function issueView(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`);
if (isGitHubError(issue)) return { ok: false, command: "issue view", repo, degradedReason: issue.degradedReason, details: issue };
const comments = await listIssueComments(token, repo, issueNumber);
if (isGitHubError(comments)) return { ok: false, command: "issue view", repo, degradedReason: comments.degradedReason, issue: issueSummary(issue), details: comments };
return {
ok: true,
command: "issue view",
repo,
issue: issueSummary(issue),
comments: comments.map(commentSummary),
};
}
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
if (options.title === undefined) throw new Error("issue create requires --title <title>");
const body = readBodyFile(options.bodyFile, "issue create");
if (options.dryRun) return { ok: true, command: "issue create", repo, dryRun: true, ...dryRunBody(repo, options.title, body) };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, { title: options.title, body });
if (isGitHubError(issue)) return { ok: false, command: "issue create", repo, degradedReason: issue.degradedReason, details: issue };
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), rest: true };
}
async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const body = readBodyFile(options.bodyFile, "issue edit");
if (options.dryRun) {
return {
ok: true,
command: "issue edit",
repo,
dryRun: true,
issueNumber,
...dryRunBody(repo, options.title, body),
wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile },
};
}
const { owner, name } = repoParts(repo);
const payload: Record<string, unknown> = { body };
if (options.title !== undefined) payload.title = options.title;
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload);
if (isGitHubError(issue)) return { ok: false, command: "issue edit", repo, degradedReason: issue.degradedReason, details: issue };
return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true };
}
async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
const body = readBodyFile(options.bodyFile, "issue comment");
if (options.dryRun) return { ok: true, command: "issue comment", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) };
const { owner, name } = repoParts(repo);
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
if (isGitHubError(comment)) return { ok: false, command: "issue comment", repo, degradedReason: comment.degradedReason, details: comment };
return { ok: true, command: "issue comment", repo, comment: commentSummary(comment), rest: true };
}
async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } };
const { owner, name } = repoParts(repo);
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
if (isGitHubError(issue)) return { ok: false, command: state === "closed" ? "issue close" : "issue reopen", repo, degradedReason: issue.degradedReason, details: issue };
return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true };
}
function escapeSnippet(text: string, index: number): string {
const start = Math.max(0, index - 80);
const end = Math.min(text.length, index + 120);
return text.slice(start, end).replace(/\n/g, "\\n");
}
function scanText(text: string, patterns: Array<{ kind: string; pattern: RegExp }>): Array<{ kind: string; snippet: string }> {
const findings: Array<{ kind: string; snippet: string }> = [];
for (const item of patterns) {
item.pattern.lastIndex = 0;
let match = item.pattern.exec(text);
while (match !== null) {
findings.push({ kind: item.kind, snippet: escapeSnippet(text, match.index) });
if (findings.length >= 5) return findings;
match = item.pattern.exec(text);
}
}
return findings;
}
async function issueScanEscape(repo: string, token: string, limit: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`);
if (isGitHubError(issues)) return { ok: false, command: "issue scan-escape", repo, degradedReason: issues.degradedReason, details: issues };
const patterns = [
{ kind: "literal-backslash-n", pattern: /\\n/g },
{ kind: "literal-backslash-t", pattern: /\\t/g },
{ kind: "shell-escaped-newline", pattern: /\\r\\n|\\012|\\x0a/g },
{ kind: "ansi-escape-literal", pattern: /\\u001b|\\033/g },
];
const findings: Array<Record<string, unknown>> = [];
for (const issue of issues) {
for (const finding of scanText(issue.body ?? "", patterns)) {
findings.push({ type: "issue", issueNumber: issue.number, id: issue.id, url: issue.html_url, ...finding });
}
const comments = await listIssueComments(token, repo, issue.number);
if (isGitHubError(comments)) {
findings.push({ type: "comment-scan-error", issueNumber: issue.number, url: issue.html_url, degradedReason: comments.degradedReason, details: comments });
continue;
}
for (const comment of comments) {
for (const finding of scanText(comment.body ?? "", patterns)) {
findings.push({ type: "comment", issueNumber: issue.number, id: comment.id, url: comment.html_url, ...finding });
}
}
}
return {
ok: true,
command: "issue scan-escape",
repo,
scannedIssues: issues.length,
findings,
note: "Read-only scan; no issue or comment content was modified.",
};
}
async function authStatus(repo: string): Promise<GitHubCommandResult> {
const ghPath = ghBinaryPath();
const { token, probe } = resolveToken(ghPath !== null);
const degraded: GitHubDegradedReason[] = [];
if (ghPath === null) degraded.push("missing-binary");
if (!probe.present || token === null) {
degraded.push("missing-token");
return {
ok: false,
command: "auth status",
repo,
degradedReason: "missing-token",
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
};
}
const { owner, name } = repoParts(repo);
const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit");
if (isGitHubError(api)) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: api.degradedReason,
degraded: [...degraded, api.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: api, repo: "skipped", issueRead: "skipped" },
};
}
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
if (isGitHubError(repoProbe)) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: repoProbe.degradedReason,
degraded: [...degraded, repoProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" },
};
}
const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`);
if (isGitHubError(issueProbe)) {
return {
ok: false,
command: "auth status",
repo,
degradedReason: issueProbe.degradedReason,
degraded: [...degraded, issueProbe.degradedReason],
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe },
};
}
return {
ok: true,
command: "auth status",
repo,
degraded,
gh: { binaryFound: ghPath !== null, path: ghPath },
token: probe,
probes: {
restApi: "ok",
repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null },
issueRead: { ok: true, readable: true, sampleCount: issueProbe.length },
},
restFallback: true,
};
}
async function prList(repo: string, token: string, limit: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`);
if (isGitHubError(prs)) return { ok: false, command: "pr list", repo, degradedReason: prs.degradedReason, details: prs };
return {
ok: true,
command: "pr list",
repo,
plannedScope: "first-stage read-only REST support",
pullRequests: prs.map(prSummary),
};
}
async function prView(repo: string, token: string, number: number): Promise<GitHubCommandResult> {
const { owner, name } = repoParts(repo);
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
if (isGitHubError(pr)) return { ok: false, command: "pr view", repo, degradedReason: pr.degradedReason, details: pr };
return {
ok: true,
command: "pr view",
repo,
plannedScope: "first-stage read-only REST support",
pullRequest: prSummary(pr),
};
}
export function ghHelp(): unknown {
return {
command: "gh",
output: "json",
usage: [
"bun scripts/cli.ts gh auth status [--repo owner/name]",
"bun scripts/cli.ts gh issue view <number> [--repo owner/name]",
"bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue comment <number> --body-file <file> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]",
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N]",
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N]",
"bun scripts/cli.ts gh pr view <number> [--repo owner/name]",
],
defaults: { repo: DEFAULT_REPO },
notes: [
"Issue create/edit/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
"Token values are never printed; auth status reports only token source and presence.",
"--body-file is required for mutating Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
"Issue body stdin is intentionally unsupported in the first phase; write generated Markdown to a file and pass --body-file.",
"PR support is first-stage read-only list/view; create/edit/merge are planned and intentionally unsupported.",
],
};
}
export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> {
const [top, sub, third] = args;
if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp();
const options = parseOptions(args);
if (top === "auth" && sub === "status") return authStatus(options.repo);
if (top === "issue") {
if (options.dryRun) {
if (sub === "create") return issueCreate(options.repo, "", options);
if (sub === "edit") return issueEdit(options.repo, "", parseNumber(third, "issue edit"), options);
if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options);
if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true);
if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true);
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"));
if (sub === "create") return issueCreate(options.repo, token, options);
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options);
if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun);
if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun);
if (sub === "scan-escape") return issueScanEscape(options.repo, token, options.limit);
}
if (top === "pr") {
if (sub !== "list" && sub !== "view") {
return {
ok: false,
command: `pr ${sub ?? ""}`.trim(),
repo: options.repo,
degradedReason: "unsupported-command",
planned: true,
message: "PR create/edit/comment/merge are planned for a later phase; first-stage support is read-only pr list/view.",
};
}
const { token, probe } = resolveToken(true);
const missing = authRequired(options.repo, `pr ${sub}`, probe);
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
if (sub === "list") return prList(options.repo, token, options.limit);
return prView(options.repo, token, parseNumber(third, "pr view"));
}
return {
ok: false,
command: args.join(" ") || "gh",
repo: options.repo,
degradedReason: "unsupported-command",
help: ghHelp(),
};
}