Ctrl K
ComunidadGuías…

commands/init.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { execFileSync } from "node:child_process";
import * as p from "@clack/prompts";
import arg from "arg";
import pc from "picocolors";
import { PULLFROG_API_URL, pullfrogApi } from "./_shared.ts";

function link(text: string, url: string): string {
  return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
}

// ── helpers ──

// active spinner reference so bail/catch can clean up the terminal
let activeSpin: ReturnType<typeof p.spinner> | null = null;

function bail(msg: string): never {
  if (activeSpin) {
    activeSpin.stop(pc.red("failed"));
    activeSpin = null;
  }
  p.cancel(msg);
  process.exit(1);
}

function handleCancel<T>(value: T | symbol): asserts value is T {
  if (p.isCancel(value)) {
    if (activeSpin) {
      activeSpin.stop(pc.red("canceled."));
      activeSpin = null;
    }
    p.cancel("canceled.");
    process.exit(0);
  }
}

function getGhToken(): string {
  let token: string;
  try {
    token = execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim();
  } catch {
    bail(
      `gh cli not found or not authenticated.\n` +
        `  ${pc.dim("install:")} https://cli.github.com\n` +
        `  ${pc.dim("then:")}    gh auth login`
    );
  }
  if (!token) {
    bail(
      `gh cli returned an empty token. try re-authenticating:\n` +
        `  ${pc.dim("run:")} gh auth login`
    );
  }
  return token;
}

type GhApiResult<T = unknown> = { data: T; scopes: string | null };

async function ghApi<T = unknown>(path: string, token: string): Promise<GhApiResult<T>> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 30_000);
  try {
    const response = await fetch(`https://api.github.com${path}`, {
      headers: {
        authorization: `Bearer ${token}`,
        accept: "application/vnd.github+json",
        "x-github-api-version": "2022-11-28",
      },
      signal: controller.signal,
    });

    if (!response.ok) {
      const body = await response.text().catch(() => "");
      throw new Error(`github api ${path} returned ${response.status}: ${body}`);
    }

    const data = (await response.json().catch(() => {
      throw new Error(`github api ${path} returned non-JSON response`);
    })) as T;
    return { data, scopes: response.headers.get("x-oauth-scopes") };
  } finally {
    clearTimeout(timeout);
  }
}

function parseGitRemote(): { owner: string; repo: string } {
  let url: string;
  try {
    url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim();
  } catch {
    bail("not a git repository or no 'origin' remote found.");
  }

  const match = url.match(/github\.com(?::\d+)?[:/]+([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/);
  if (!match) bail(`could not parse github owner/repo from remote: ${url}`);
  return { owner: match[1], repo: match[2] };
}

function openBrowser(url: string) {
  try {
    const platform = process.platform;
    if (platform === "darwin") execFileSync("open", [url], { stdio: "ignore" });
    // rundll32, not `cmd /c start`, which splits an unquoted URL at every `&`
    else if (platform === "win32")
      execFileSync("rundll32.exe", ["url.dll,FileProtocolHandler", url], { stdio: "ignore" });
    else execFileSync("xdg-open", [url], { stdio: "ignore" });
  } catch {
    // headless/SSH — user will open the URL manually
  }
}

// ── Pullfrog API ──

type SecretsApiData = {
  error?: string;
  appSlug?: string;
  installationId?: number | null;
  repositorySelection?: string | null;
  isOrg?: boolean;
};

type InstallationNotFound = {
  appSlug: string;
  installationId: number | null;
  repositorySelection: "all" | "selected" | null;
  isOrg: boolean;
};

type StatusResult =
  | { installed: true; installationId: number | null; isOrg: boolean }
  | ({ installed: false } & InstallationNotFound);

async function fetchStatus(ctx: {
  token: string;
  owner: string;
  repo: string;
}): Promise<StatusResult> {
  const result = await pullfrogApi<SecretsApiData>({
    path: `/api/cli/secrets?owner=${encodeURIComponent(ctx.owner)}&repo=${encodeURIComponent(ctx.repo)}`,
    token: ctx.token,
  });

  if (!result.ok) {
    const errorMsg = result.data.error || "";
    if (result.status === 401) bail("invalid or expired github token.");
    if (result.status === 404) {
      const sel = result.data.repositorySelection;
      if (!result.data.appSlug) bail("server did not return appSlug");
      return {
        installed: false,
        appSlug: result.data.appSlug,
        installationId:
          typeof result.data.installationId === "number" ? result.data.installationId : null,
        repositorySelection: sel === "all" || sel === "selected" ? sel : null,
        isOrg: result.data.isOrg === true,
      };
    }
    bail(errorMsg || `installation check failed (${result.status})`);
  }

  return {
    installed: true,
    isOrg: result.data.isOrg === true,
    installationId:
      typeof result.data.installationId === "number" ? result.data.installationId : null,
  };
}

// ── handoff ──

function installationConfigUrl(ctx: { owner: string; installationId: number; isOrg: boolean }) {
  return ctx.isOrg
    ? `https://github.com/organizations/${ctx.owner}/settings/installations/${ctx.installationId}`
    : `https://github.com/settings/installations/${ctx.installationId}`;
}

/**
 * Where the terminal hands off. `?repo=` is a hint the console consumes two
 * ways: a never-onboarded account gets it pre-selected in the setup wizard,
 * an established one is redirected straight to that repo's page.
 */
function consoleUrl(ctx: { owner: string; repo: string }) {
  return `${PULLFROG_API_URL}/console/${ctx.owner}?repo=${encodeURIComponent(ctx.repo)}`;
}

/**
 * `state` round-trips through github.com back to `/callback/github-app`, which
 * reads this form to land the installer on the console with the repo they ran
 * `init` in already selected. Owner is included so the callback can drop the
 * hint when the user installs on a different account than we detected.
 */
function installUrl(ctx: { appSlug: string; owner: string; repo: string }) {
  const state = encodeURIComponent(`cli:${ctx.owner}/${ctx.repo}`);
  return `https://github.com/apps/${ctx.appSlug}/installations/select_target?state=${state}`;
}

function printLink(url: string) {
  process.stdout.write(`${pc.gray(p.S_BAR)}    ${link(pc.dim(url), url)}\n`);
}

// ── main ──

async function main() {
  p.intro(pc.bgGreen(pc.black(" pullfrog ")));

  const spin = p.spinner();
  activeSpin = spin;

  // 1. authenticate
  spin.start("authenticating with github");
  const token = getGhToken();
  const userResult = await ghApi<{ login: string }>("/user", token);
  const user = userResult.data;

  // gho_ tokens from `gh auth login` expose scopes via x-oauth-scopes header.
  // fine-grained PATs (github_pat_) don't return scopes — they pass this check.
  // split on ", " and match exact scope — .includes("repo") would false-positive on "public_repo"
  //
  // kept after the handoff rewrite even though the only authenticated call left
  // is `GET /api/cli/secrets`: its server-side `getRepo` needs the scope for a
  // PRIVATE repo, and without it that 404 is indistinguishable from "app not
  // installed", so we'd send the user to re-install instead of to `gh auth
  // refresh`. over-strict for a public repo, but `gh auth login` grants `repo`.
  const scopeSet = userResult.scopes !== null ? new Set(userResult.scopes.split(", ")) : null;
  if (scopeSet !== null && !scopeSet.has("repo")) {
    bail(
      `your token is missing the ${pc.bold('"repo"')} scope.\n` +
        `  ${pc.dim("run:")} gh auth refresh --scopes repo\n` +
        `  ${pc.dim("then:")} npx pullfrog init`
    );
  }

  spin.stop(`hello, ${pc.cyan(`@${user.login}`)}`);

  // 2. detect repo
  spin.start("detecting repository");
  const remote = parseGitRemote();
  spin.stop(`detected repo ${pc.cyan(`${remote.owner}/${remote.repo}`)}`);

  // 3. ensure the app is installed, then hand off to the console — every
  // configuration decision (billing mode, provider, model, credential) and the
  // `pullfrog.yml` commit itself live there. The dashboard commits as the
  // signed-in user, so the workflow file is attributed to a human rather than
  // to pullfrog[bot], which is what the CLI's own installation token produced.
  spin.start("checking pullfrog app installation");
  const status = await fetchStatus({ token, owner: remote.owner, repo: remote.repo });
  const handoff = consoleUrl({ owner: remote.owner, repo: remote.repo });

  if (status.installed) {
    spin.stop(`pullfrog app is installed on ${pc.cyan(`@${remote.owner}`)}`);
    spin.start("");
    spin.stop("opening your dashboard");
    printLink(handoff);
    openBrowser(handoff);
    activeSpin = null;
    p.outro(
      `finish setup in your browser — ${pc.cyan(`${remote.owner}/${remote.repo}`)} is ready.`
    );
    return;
  }

  if (status.installationId) {
    const repoRef = pc.bold(`${remote.owner}/${remote.repo}`);
    const configUrl = installationConfigUrl({
      owner: remote.owner,
      installationId: status.installationId,
      isOrg: status.isOrg,
    });
    spin.stop(`pullfrog is installed on selected repos, but ${repoRef} is not included.`);
    p.log.info(
      `add it under "Repository access" on the installation config page.\n  ${pc.dim(configUrl)}`
    );
    const openIt = await p.confirm({ message: "open browser?", active: "yes", inactive: "no" });
    handleCancel(openIt);
    if (openIt) openBrowser(configUrl);
    p.log.info("once the repo is added, finish setup at:");
    printLink(handoff);
    activeSpin = null;
    p.outro("done.");
    return;
  }

  spin.stop("pullfrog app not installed");
  const install = installUrl({
    appSlug: status.appSlug,
    owner: remote.owner,
    repo: remote.repo,
  });
  p.log.info("opening browser to install...");
  printLink(install);
  openBrowser(install);
  activeSpin = null;
  p.outro("GitHub will drop you at your dashboard to finish setup.");
}

interface InitCliParams {
  args: string[];
  prog: string;
  showHelp?: boolean;
}

function printInitUsage(params: { stream: typeof console.log; prog: string }): void {
  params.stream(`usage: ${params.prog} init\n`);
  params.stream("install pullfrog on the current repository and open its dashboard.");
  params.stream("");
  params.stream("options:");
  params.stream("  -h, --help   show help");
}

function parseInitArgs(args: string[]) {
  return arg(
    {
      "--help": Boolean,
      "-h": "--help",
    },
    {
      argv: args,
    }
  );
}

export async function runCli(params: InitCliParams): Promise<void> {
  if (params.showHelp) {
    printInitUsage({ stream: console.log, prog: params.prog });
    return;
  }

  let parsed: ReturnType<typeof parseInitArgs>;
  try {
    parsed = parseInitArgs(params.args);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`${message}\n`);
    printInitUsage({ stream: console.error, prog: params.prog });
    process.exit(1);
  }

  if (parsed["--help"]) {
    printInitUsage({ stream: console.log, prog: params.prog });
    return;
  }

  if (parsed._.length > 0) {
    console.error(`unexpected positional arguments for init: ${parsed._.join(" ")}\n`);
    printInitUsage({ stream: console.error, prog: params.prog });
    process.exit(1);
  }

  await run();
}

export async function run() {
  try {
    await main();
  } catch (error) {
    if (activeSpin) {
      activeSpin.stop(pc.red("failed"));
      activeSpin = null;
    }
    const msg =
      error instanceof Error && error.name === "AbortError"
        ? "request timed out — check your network connection and try again"
        : error instanceof Error
          ? error.message
          : String(error);
    p.log.error(msg);
    process.exit(1);
  }
}