@kya-os/cli
Version:
CLI for MCP-I setup and management
154 lines • 5 kB
JavaScript
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
/**
* Thin wrapper around the GitHub CLI (`gh`). We deliberately shell out to gh
* instead of handling OAuth tokens ourselves: the user's existing gh auth is
* the credential, and it never passes through this process beyond gh's own
* environment.
*/
export const SIGNING_KEY_SCOPE = "admin:ssh_signing_key";
export const SCOPE_REFRESH_COMMAND = `gh auth refresh -h github.com -s ${SIGNING_KEY_SCOPE}`;
export class GhNotInstalledError extends Error {
constructor() {
super("GitHub CLI (gh) is not installed. Install it from https://cli.github.com and run `gh auth login`.");
this.name = "GhNotInstalledError";
}
}
/**
* The default gh token does not include the admin:ssh_signing_key scope, so
* both listing and uploading signing keys fail until the user refreshes.
* GitHub reports the missing scope as HTTP 404 on these endpoints.
*/
export class GhScopeError extends Error {
constructor() {
super(`Your GitHub token is missing the ${SIGNING_KEY_SCOPE} scope. Run: ${SCOPE_REFRESH_COMMAND}`);
this.refreshCommand = SCOPE_REFRESH_COMMAND;
this.name = "GhScopeError";
}
}
async function gh(args) {
try {
const { stdout } = await execFileAsync("gh", args, {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return stdout;
}
catch (error) {
if (error?.code === "ENOENT") {
throw new GhNotInstalledError();
}
throw error;
}
}
function isScopeError(error) {
const err = error;
const message = `${err?.stderr ?? ""} ${err?.message ?? ""}`;
// Deliberately narrow: never key off the bare 403 status, which GitHub
// also uses for rate limits and SSO enforcement where scope-refresh advice
// would be wrong. Only the explicit scope name, HTTP 404, or a "Resource
// not accessible" body count as scope problems.
return (message.includes(SIGNING_KEY_SCOPE) ||
/HTTP 404/.test(message) ||
/Resource not accessible/i.test(message));
}
export async function isGhInstalled() {
try {
await gh(["--version"]);
return true;
}
catch {
return false;
}
}
export async function isGhAuthenticated() {
try {
await gh(["auth", "status", "--hostname", "github.com"]);
return true;
}
catch (error) {
if (error instanceof GhNotInstalledError) {
throw error;
}
return false;
}
}
export async function getGhUser() {
const stdout = await gh([
"api",
"user",
"--jq",
"{login: .login, id: .id, name: .name}",
]);
const parsed = JSON.parse(stdout);
if (!parsed?.login || typeof parsed.id !== "number") {
throw new Error("Unexpected response from `gh api user`");
}
return parsed;
}
/** GitHub's commit-attribution noreply address for the authenticated user. */
export function githubNoreplyEmail(user) {
return `${user.id}+${user.login}@users.noreply.github.com`;
}
export async function listSigningKeys() {
try {
const stdout = await gh(["api", "user/ssh_signing_keys", "--paginate"]);
return JSON.parse(stdout);
}
catch (error) {
if (error instanceof GhNotInstalledError) {
throw error;
}
if (isScopeError(error)) {
throw new GhScopeError();
}
throw error;
}
}
export async function uploadSigningKey(publicKeyLine, title) {
try {
await gh([
"api",
"user/ssh_signing_keys",
"-f",
`key=${publicKeyLine}`,
"-f",
`title=${title}`,
]);
}
catch (error) {
if (error instanceof GhNotInstalledError) {
throw error;
}
if (isScopeError(error)) {
throw new GhScopeError();
}
throw error;
}
}
/**
* Compare the base64 body of an authorized_keys style line against uploaded
* keys. GitHub returns keys without comments, so comparing the full line
* would always miss.
*/
export function signingKeyAlreadyUploaded(keys, publicKeyLine) {
const body = publicKeyLine.split(/\s+/)[1];
if (!body) {
return false;
}
return keys.some((entry) => entry.key.split(/\s+/)[1] === body);
}
/**
* Run `gh auth refresh` interactively so the user can grant the signing key
* scope without leaving the flow. Requires a TTY; the caller must check.
*/
export async function refreshAuthScope() {
const { spawn } = await import("child_process");
return new Promise((resolve) => {
const child = spawn("gh", ["auth", "refresh", "-h", "github.com", "-s", SIGNING_KEY_SCOPE], { stdio: "inherit" });
child.on("error", () => resolve(false));
child.on("close", (code) => resolve(code === 0));
});
}
//# sourceMappingURL=github.js.map