Ctrl K
ComunidadGuías…

commands/auth.ts

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

Archivo original ↗Preguntar a KiBot
// `pullfrog auth <provider>` — manage credentials for a configured repo
// without going through the full `init` flow. currently supports:
//
//   pullfrog auth codex    mint a Codex subscription credential and save it
//                          as the `CODEX_AUTH_JSON` Pullfrog secret
//   pullfrog auth claude   mint a Claude Code subscription OAuth token and save
//                          it as the `CLAUDE_CODE_OAUTH_TOKEN` Pullfrog secret
//   pullfrog auth grok     mint a Grok (SuperGrok / X Premium) subscription
//                          credential and save it as `GROK_AUTH_JSON`
//
// the `codex` subcommand runs `codex login` against an isolated `CODEX_HOME`
// (so the user's existing ~/.codex/auth.json is never touched), validates the
// resulting auth.json, and posts it to the Pullfrog secrets API. used both for
// first-time setup of a Codex subscription on a repo and for rotating a stale
// credential. the login is Codex's browser redirect by default and its device
// code under `--device-auth`, which is the one that works when the browser is
// on another machine. see wiki/codex-auth.md.
//
// the `claude` subcommand runs the sign-in `claude setup-token` runs and mints
// the same long-lived OAuth token, so Claude Code need not be installed; with
// `--paste-token` (or no browser on this machine) the user pastes one that
// `claude setup-token` printed instead. either way it goes to the same secrets
// API, which verifies it with Anthropic before storing it. unlike Codex, the
// token is static (no refresh chain), so there's no post-run write-back —
// see wiki/codex-auth.md "Claude sibling".
//
// the `grok` subcommand runs the RFC 8628 device-code grant against xAI
// directly — no second CLI to install, unlike codex. opencode consumes the
// resulting chain natively via its XaiAuthPlugin. like codex (and unlike
// claude) the refresh token rotates on every use, so the blob MUST live in
// Pullfrog's runtime-writable store and gets written back after every run.
// see wiki/grok-auth.md.
//
// everything around the sign-in — scope, authority, add-or-replace, the save
// and what it reports — is one flow shared by all three (`connect`); a provider
// is its `mint` step and the copy that names it.

import { spawn } from "node:child_process";
import * as p from "@clack/prompts";
import arg from "arg";
import pc from "picocolors";
import { signInToClaude } from "../utils/claudeOAuth.ts";
import { mintCodexAuth, refreshCodexAuth } from "../utils/codexAuth.ts";
import type { SubscriptionName } from "../utils/subscriptionCredentials.ts";
import {
  pollXaiDeviceAuth,
  refreshXaiAuthBody,
  startXaiDeviceAuth,
  stringifyXaiAuthBody,
} from "../utils/xaiOAuth.ts";
import { configurationApi, resolveTarget, scopeArgs } from "./_configuration.ts";
import {
  bail,
  CLAUDE_OAUTH_SECRET,
  CODEX_AUTH_SECRET,
  describeSecretTarget,
  fetchStatus,
  GROK_AUTH_SECRET,
  getGhToken,
  handleCancel,
  promptScope,
  type SecretScope,
  setActiveSpin,
} from "./_shared.ts";
import {
  chooseSubscription,
  manageSubscriptions,
  type SubscriptionChoice,
  saveSubscription,
} from "./_subscriptions.ts";
import { secretNamesSchema } from "./secret.ts";

const BIN = process.env.PULLFROG_BIN_NAME || "pullfrog";

/** prefix on `claude setup-token` OAuth tokens (`sk-ant-oat01-…`). a
 * warn-on-mismatch shape check only; whether the token actually WORKS is
 * settled by the secrets API, which refuses a token Anthropic rejects. */
const CLAUDE_OAUTH_TOKEN_PREFIX = "sk-ant-oat";

/** strip CSI ANSI escapes (color, cursor) from a string so callers can re-style
 * the visible text without inheriting the source's formatting. covers what
 * Codex emits during login (mostly `\x1b[<digits>m` color codes).
 */
function stripAnsi(s: string): string {
  // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are control chars by design
  return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
}

/** best-effort cross-platform "open URL in default browser". swallows
 * spawn errors and non-zero exits — the user can always copy-paste the URL
 * already printed. on Linux, falls back to `wslview` when `xdg-open`
 * is missing (covers WSL where xdg-open isn't installed by default).
 */
function openInBrowser(url: string): void {
  const platform = process.platform;
  let cmd: string;
  let args: string[];
  if (platform === "darwin") {
    cmd = "open";
    args = [url];
  } else if (platform === "win32") {
    // not `cmd /c start`: Node quotes no URL without a space, so cmd splits it
    // at every `&`. rundll32 hands it to the default browser verbatim.
    cmd = "rundll32.exe";
    args = ["url.dll,FileProtocolHandler", url];
  } else {
    cmd = "xdg-open";
    args = [url];
  }
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
  child.on("error", () => {
    if (platform !== "linux") return;
    const fallback = spawn("wslview", [url], { stdio: "ignore", detached: true });
    fallback.on("error", () => {});
    fallback.unref();
  });
  child.unref();
}

type Spinner = ReturnType<typeof p.spinner>;

const authArgs = { ...scopeArgs, "--device-auth": Boolean, "--paste-token": Boolean };
type AuthArgs = arg.Result<typeof authArgs>;

interface MintContext {
  args: AuthArgs;
  spin: Spinner;
}

interface Provider {
  id: "codex" | "claude" | "grok";
  secret: SubscriptionName;
  /** what the subcommand does, under its `usage:` line. */
  summary: string;
  /** the option only this provider takes, and its usage line. */
  option: { flag: "--device-auth" | "--paste-token"; usage: string } | null;
  /** how to come back as a different account, shown when the sign-in landed
   * on one that was already connected. */
  switchAccount: string;
  /** the credential to save, or null when the user backed out. */
  mint: (ctx: MintContext) => Promise<string | null>;
}

const providers: Provider[] = [
  {
    id: "codex",
    secret: CODEX_AUTH_SECRET,
    summary: "mint a Codex subscription credential and save it as CODEX_AUTH_JSON.",
    option: {
      flag: "--device-auth",
      usage: "  --device-auth   sign in with a one-time code, for a browser on another machine",
    },
    switchAccount: [
      "to add a different ChatGPT account, sign out of ChatGPT in that browser or open",
      "the sign-in link in a private window, then run this command again.",
    ].join("\n"),
    mint: mintCodex,
  },
  {
    id: "claude",
    secret: CLAUDE_OAUTH_SECRET,
    summary: "mint a Claude subscription OAuth token and save it as CLAUDE_CODE_OAUTH_TOKEN.",
    option: {
      flag: "--paste-token",
      usage:
        "  --paste-token   paste a token from claude setup-token, for a browser on another machine",
    },
    switchAccount: [
      `to add a different Claude account, run ${pc.cyan("claude setup-token")} signed in to it,`,
      "then run this command again and paste that token.",
    ].join("\n"),
    mint: mintClaude,
  },
  {
    id: "grok",
    secret: GROK_AUTH_SECRET,
    summary: "mint a Grok subscription credential and save it as GROK_AUTH_JSON.",
    option: null,
    switchAccount: [
      "to add a different Grok account, sign out of Grok in that browser or open the",
      "sign-in link in a private window, then run this command again.",
    ].join("\n"),
    mint: mintGrok,
  },
];

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

function printAuthUsage(params: { stream: typeof console.log; prog: string }): void {
  params.stream(`usage: ${params.prog} auth <provider>\n`);
  params.stream("manage provider credentials for the current repository.");
  params.stream("");
  params.stream("providers:");
  params.stream("  codex    mint a Codex / ChatGPT subscription credential");
  params.stream("  claude   mint a Claude subscription OAuth token");
  params.stream("  grok     mint a Grok (SuperGrok / X Premium) subscription credential");
  params.stream("  list     list subscriptions and their IDs");
  params.stream("  remove   remove a subscription by binding ID");
  params.stream("  share | unshare   grant or revoke personal subscription access (--from OWNER)");
  params.stream("");
  params.stream("options:");
  params.stream("  --org OWNER | --repo OWNER/REPO   select the credential scope");
  params.stream("  -h, --help   show help");
}

interface ProviderCliParams {
  provider: Provider;
  args: string[];
  prog: string;
}

function printProviderUsage(params: ProviderCliParams & { stream: typeof console.log }): void {
  params.stream(`usage: ${params.prog} auth ${params.provider.id} [options]\n`);
  params.stream(params.provider.summary);
  params.stream("");
  params.stream("options:");
  params.stream("  --org OWNER | --repo OWNER/REPO   select the credential scope");
  if (params.provider.option) params.stream(params.provider.option.usage);
  params.stream("  -h, --help   show help");
}

export async function runCli(params: AuthCliParams): Promise<void> {
  // route `auth --help` (no subcommand) to top-level usage. when the user
  // passes `auth codex --help`, we leave the flag in the rest args so the
  // subcommand's own parser handles it.
  const firstArg = params.args[0];
  const helpAtTopLevel =
    params.showHelp ||
    params.args.length === 0 ||
    (params.args.length === 1 && (firstArg === "--help" || firstArg === "-h"));
  if (helpAtTopLevel) {
    printAuthUsage({ stream: console.log, prog: params.prog });
    return;
  }

  const subcommand = firstArg;
  const rest = params.args.slice(1);
  if (["list", "remove", "share", "unshare"].includes(subcommand)) {
    await manageSubscriptions({ command: subcommand, args: rest });
    return;
  }

  const provider = providers.find((candidate) => candidate.id === subcommand);
  if (provider) {
    await runProvider({ provider, args: rest, prog: params.prog });
    return;
  }

  console.error(`unknown auth provider: ${pc.bold(subcommand)}\n`);
  printAuthUsage({ stream: console.error, prog: params.prog });
  process.exit(1);
}

function parseProviderArgs(params: ProviderCliParams): AuthArgs {
  try {
    const parsed = arg(authArgs, { argv: params.args });
    const foreign = providers
      .flatMap((other) => (other.option ? [other.option.flag] : []))
      .find((flag) => parsed[flag] && flag !== params.provider.option?.flag);
    if (foreign) throw new Error(`unknown or unexpected option: ${foreign}`);
    return parsed;
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`${message}\n`);
    printProviderUsage({ ...params, stream: console.error });
    process.exit(1);
  }
}

async function runProvider(params: ProviderCliParams): Promise<void> {
  const args = parseProviderArgs(params);
  if (args["--help"]) {
    printProviderUsage({ ...params, stream: console.log });
    return;
  }
  if (args._.length) throw new Error("unexpected auth argument");

  p.intro(pc.bgGreen(pc.black(` pullfrog auth ${params.provider.id} `)));
  const spin = p.spinner();
  setActiveSpin(spin);
  try {
    await connect({ provider: params.provider, args, spin });
  } catch (error) {
    // mirror what `bail` does: stop the spinner with a red "failed" glyph
    // before clearing it, otherwise an in-flight spinner keeps animating
    // above the error message we're about to print.
    spin.stop(pc.red("failed"));
    setActiveSpin(null);
    const message = error instanceof Error ? error.message : String(error);
    p.log.error(message);
    process.exit(1);
  }
}

interface ConnectContext extends MintContext {
  provider: Provider;
}

/** where a credential is saved. `repo` is "" under `--org`, which is always account scope. */
interface Destination {
  owner: string;
  repo: string;
  scope: SecretScope;
}

async function resolveDestination(ctx: ConnectContext & { token: string }): Promise<Destination> {
  ctx.spin.start("detecting repository");
  const selectedTarget = resolveTarget({ org: ctx.args["--org"], repo: ctx.args["--repo"] });
  const remote = { owner: selectedTarget.owner, repo: selectedTarget.repo ?? "" };
  ctx.spin.stop(
    `selected ${pc.cyan(remote.repo ? `${remote.owner}/${remote.repo}` : remote.owner)}`
  );

  ctx.spin.start("checking pullfrog app installation");
  const status =
    selectedTarget.repo && ctx.args["--repo"] === undefined
      ? await fetchStatus({ token: ctx.token, owner: remote.owner, repo: selectedTarget.repo })
      : {
          installed: true,
          isOrg: true,
          pullfrogSecrets: secretNamesSchema.parse(
            await configurationApi({
              target: selectedTarget,
              path: "credentials",
              token: ctx.token,
            })
          ).secrets,
        };
  if (!status.installed) {
    ctx.spin.stop(pc.red("pullfrog app not installed on this repo"));
    bail(
      `install pullfrog on ${pc.bold(`${remote.owner}/${remote.repo}`)} before configuring auth.\n` +
        `  ${pc.dim("run:")} ${pc.cyan(`npx pullfrog init`)}`
    );
  }
  ctx.spin.stop(`pullfrog app is installed on ${pc.cyan(`@${remote.owner}`)}`);

  // user-owned repos can only ever be "account" (Pullfrog has no per-repo
  // store for user accounts), so we never bother prompting. on org-owned
  // repos, prompt interactively — matches `init`'s behavior.
  // explicit scopes override the legacy no-flag selection above.
  const scope =
    ctx.args["--org"] !== undefined
      ? "account"
      : ctx.args["--repo"] !== undefined
        ? "repo"
        : status.isOrg
          ? await promptScope({ owner: remote.owner, repo: remote.repo })
          : "account";
  const access = secretNamesSchema.parse(
    await configurationApi({
      target: { owner: remote.owner, repo: scope === "repo" ? remote.repo : undefined },
      path: "credentials",
      token: ctx.token,
    })
  );
  if (!access.writable)
    bail(
      scope === "repo"
        ? "repo admin required to change secrets"
        : "org owner required to change secrets"
    );
  return { owner: remote.owner, repo: remote.repo, scope };
}

type SavedSubscription = Awaited<ReturnType<typeof saveSubscription>>;

/** the binding the save landed on says what happened; the choice only says
 * what was asked for, and the account that signed in can disagree with it. */
function describeOutcome(params: {
  choice: SubscriptionChoice;
  result: SavedSubscription;
  where: string;
}): string {
  const label = pc.cyan(params.result.label);
  const replaced = params.choice.replace;
  if (replaced && replaced.id !== params.result.id)
    return `replaced ${pc.cyan(replaced.label)} with ${label} on ${params.where}`;
  return `${params.result.reconnected ? "reconnected" : "added"} ${label} to ${params.where}`;
}

async function connect(ctx: ConnectContext): Promise<void> {
  ctx.spin.start("authenticating with github");
  const token = getGhToken();
  ctx.spin.stop("github authenticated");

  const destination = await resolveDestination({ ...ctx, token });
  const target = {
    owner: destination.owner,
    repo: destination.scope === "repo" ? destination.repo : undefined,
  };
  const choice = await chooseSubscription({ target, token, name: ctx.provider.secret });
  if (!choice) {
    p.cancel("canceled.");
    return;
  }

  const value = await ctx.provider.mint(ctx);
  if (value === null) {
    p.cancel("canceled.");
    return;
  }

  const where = describeSecretTarget(destination);
  ctx.spin.start(`saving ${pc.cyan(ctx.provider.secret)} to ${where}`);
  const result = await saveSubscription({
    token,
    target,
    name: ctx.provider.secret,
    value,
    replaceId: choice.replace?.id,
  });
  ctx.spin.stop(describeOutcome({ choice, result, where }));
  setActiveSpin(null);

  // "add another" that landed on a connection already here added nothing.
  if (!choice.replace && choice.current.some((row) => row.id === result.id)) {
    p.log.warn(
      [
        `${pc.cyan(result.label)} was already connected here, so no subscription was added.`,
        ctx.provider.switchAccount,
      ].join("\n")
    );
    p.outro("no subscription added.");
    return;
  }
  p.outro("done.");
}

/** the Codex and Claude browser sign-ins redirect to localhost on THIS machine,
 * so they need a browser here: not over SSH, and not on a Linux box with no
 * display (WSL hands the URL to Windows, which reaches back over localhost). */
function lacksLocalBrowser(): boolean {
  if (process.env.SSH_CONNECTION || process.env.SSH_TTY) return true;
  if (process.platform !== "linux" || process.env.WSL_DISTRO_NAME) return false;
  return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
}

function codexLoginCopy(args: AuthArgs) {
  if (!(args["--device-auth"] ?? lacksLocalBrowser()))
    return {
      deviceAuth: false,
      command: "codex login",
      intro: [
        `signing in via the Codex browser login. approve the sign-in in the browser`,
        `window Codex opens, then come back here.`,
        ``,
        `${pc.dim("note:")} if the browser is on another machine, cancel and run`,
        `${pc.cyan(`${BIN} auth codex --device-auth`)} instead.`,
      ],
      timedOut: "browser sign-in timed out — retry?",
      unfinished: "the browser sign-in did not finish — retry?",
      retryHint: "or re-run with --device-auth if the browser is on another machine",
    };
  const rerun = args["--device-auth"] ? "auth codex --device-auth" : "auth codex";
  return {
    deviceAuth: true,
    command: "codex login --device-auth",
    intro: [
      `signing in via Codex device authorization. open the URL Codex prints`,
      `below, enter the one-time code, and approve in your browser.`,
      ``,
      `${pc.dim("note:")} ChatGPT accepts the code only once device-code sign-in is on: in`,
      `${pc.cyan("https://chatgpt.com/#settings/Security")} for a personal account, or turned on by a`,
      `workspace owner at ${pc.cyan("https://chatgpt.com/admin/permissions")}. if the page refuses`,
      `the code, turn it on, then re-run ${pc.cyan(`${BIN} ${rerun}`)}.`,
    ],
    timedOut: "device authorization timed out — retry?",
    unfinished: "no auth.json was written — retry?",
    retryHint: "after enabling device-code auth",
  };
}

async function mintCodex(ctx: MintContext): Promise<string> {
  const copy = codexLoginCopy(ctx.args);
  p.log.info(copy.intro.join("\n"));

  // tracks the most recent exit so the retry prompt can tell the user
  // *why* no auth.json was written (timeout vs. early-exit).
  let lastTimedOut = false;
  const auth = await mintCodexAuth({
    deviceAuth: copy.deviceAuth,
    childStdio: "pipe",
    // pass-through only: Codex's copy changes without notice, so nothing here
    // may branch on, scrape or rewrite what it prints. auth.json is the result.
    onChildLine: (line) => {
      // dim Codex's own colored output (URL/code in cyan, boilerplate in
      // gray) so the user reads it as sub-process noise, not Pullfrog's
      // own prompts. the rail char matches @clack/prompts so the column
      // reads as one continuous flow.
      process.stdout.write(`${pc.gray(p.S_BAR)}  ${pc.dim(stripAnsi(line))}\n`);
    },
    onProgress: (event) => {
      if (event.kind === "start") {
        lastTimedOut = false;
        if (event.attempt > 1) p.log.info(`retry attempt ${event.attempt}`);
        // shell-prompt style header so the user sees what Pullfrog is
        // about to spawn, with the rail to keep the visual column.
        process.stdout.write(`${pc.gray(p.S_BAR)}\n`);
        process.stdout.write(`${pc.gray(p.S_BAR)}  $ ${copy.command}\n`);
      }
      if (event.kind === "exit") {
        if (event.timedOut) lastTimedOut = true;
        // trailing blank rail so the next clack prompt isn't crammed
        // against the last codex output line.
        process.stdout.write(`${pc.gray(p.S_BAR)}\n`);
      }
    },
    shouldRetry: async () => {
      const retry = await p.select({
        message: lastTimedOut ? copy.timedOut : copy.unfinished,
        options: [
          { value: true, label: "retry", hint: copy.retryHint },
          { value: false, label: "cancel" },
        ],
      });
      handleCancel(retry);
      return retry;
    },
  });

  // eager refresh: bump the OAuth chain once before persisting so the
  // saved token is one Pullfrog has used. otherwise the user's laptop's
  // codex CLI could refresh first and strand our copy.
  ctx.spin.start("refreshing token");
  try {
    const refreshed = await refreshCodexAuth(auth);
    ctx.spin.stop("refreshed");
    return refreshed.json;
  } catch (err) {
    ctx.spin.stop(pc.yellow("refresh failed — saving minted token as-is"));
    p.log.warn(err instanceof Error ? err.message : String(err));
    return auth.json;
  }
}

/** the sign-in redirects to localhost here, so a browser elsewhere pastes a
 * token minted there by `claude setup-token` instead. */
async function mintClaude(ctx: MintContext): Promise<string | null> {
  if (ctx.args["--paste-token"] ?? lacksLocalBrowser()) return pasteClaudeToken();
  const minted = await signInToClaude({
    onUrl: (url) => {
      p.log.info(
        [
          `approve the sign-in in your browser, then come back here.`,
          ``,
          `${pc.dim("url:")} ${pc.cyan(url)}`,
          ``,
          `${pc.dim("note:")} if the browser is on another machine, cancel and run`,
          `${pc.cyan(`${BIN} auth claude --paste-token`)} instead.`,
        ].join("\n")
      );
      openInBrowser(url);
      ctx.spin.start("waiting for approval in the browser");
    },
  });
  ctx.spin.stop(`signed in to Claude${minted.email ? ` as ${pc.cyan(minted.email)}` : ""}`);
  return minted.token;
}

async function pasteClaudeToken(): Promise<string | null> {
  p.log.info(
    [
      `mint a long-lived Claude Code OAuth token, then paste it below.`,
      ``,
      `${pc.dim("run:")} ${pc.cyan("claude setup-token")}`,
      `it opens your browser, works with Claude subscriptions, and prints a`,
      `token starting with ${pc.cyan("sk-ant-oat…")}. copy that token.`,
    ].join("\n")
  );

  const oauthToken = await p.password({
    message: `paste your Claude Code OAuth token ${pc.dim("(Enter to cancel)")}`,
    mask: "*",
  });
  handleCancel(oauthToken);
  if (!oauthToken) return null;

  // trim defensively — a trailing newline from a terminal copy breaks GitHub
  // Actions' line-based log masking downstream. the API trims too, but doing
  // it here keeps the prefix sanity check honest.
  const value = oauthToken.trim();
  if (!value.startsWith(CLAUDE_OAUTH_TOKEN_PREFIX)) {
    p.log.warn(
      `that doesn't look like a ${pc.cyan("claude setup-token")} token (expected ${pc.cyan(`${CLAUDE_OAUTH_TOKEN_PREFIX}…`)}). saving it anyway.`
    );
  }
  return value;
}

async function mintGrok(ctx: MintContext): Promise<string> {
  ctx.spin.start("requesting a device code from xAI");
  const device = await startXaiDeviceAuth();
  ctx.spin.stop("device code ready");

  p.log.info(
    [
      `approve the sign-in in your browser, then come back here.`,
      ``,
      `${pc.dim("url: ")}${pc.cyan(device.verificationUrl)}`,
      `${pc.dim("code:")} ${pc.bold(pc.cyan(device.userCode))}`,
      ``,
      `this uses your Grok subscription — no xAI API key and no per-token billing.`,
    ].join("\n")
  );
  openInBrowser(device.verificationUrl);

  ctx.spin.start("waiting for approval in the browser");
  const minted = await pollXaiDeviceAuth(device);
  ctx.spin.stop("signed in to Grok");

  // eager refresh before saving, mirroring `auth codex`. xAI rotates the
  // refresh token on every use, so storing the just-minted chain would hand
  // Pullfrog a token the local process already spent. round-tripping once
  // here means the stored chain is the freshest one in existence.
  ctx.spin.start("rotating the credential before saving");
  const fresh = await refreshXaiAuthBody(minted);
  ctx.spin.stop("credential rotated");
  return stringifyXaiAuthBody(fresh);
}