Ctrl K
ComunidadGuías…

commands/_shared.ts

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

Archivo original ↗Preguntar a KiBot
// shared helpers used by `init` and `auth` subcommands. these were originally
// inlined in `init.ts`; pulled out so `auth.ts` can reuse them without
// duplicating gh-auth/pullfrog-api/secret-save logic.

import { execFileSync } from "node:child_process";
import * as p from "@clack/prompts";
import pc from "picocolors";
import {
  CLI_CONTRACT_HEADER,
  CLI_CONTRACT_VERSION,
  CLI_UPGRADE_MESSAGE,
  CliContractError,
} from "../cliContract.ts";
import { vercelBypassSecretFor } from "../utils/vercelBypass.ts";

export const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace(
  /\/+$/,
  ""
);

export const CODEX_AUTH_SECRET = "CODEX_AUTH_JSON";
export const CLAUDE_OAUTH_SECRET = "CLAUDE_CODE_OAUTH_TOKEN";
export const GROK_AUTH_SECRET = "GROK_AUTH_JSON";

/** the credentials `pullfrog auth` mints, derived from the three above so a new provider
 * cannot be wired into `auth` and silently miss the `secret set` guard. a repo's own copy of
 * one takes precedence over the account's AND the chain rotates, so a stale copy dies where it
 * sits. a static API key is deliberately not here: a repo copy shadowing one is what repo
 * scope is for. */
export const SUBSCRIPTION_SECRET_NAMES = [CODEX_AUTH_SECRET, CLAUDE_OAUTH_SECRET, GROK_AUTH_SECRET];

/** the refusal an account-level save earns while repos hold their own copy of `name`, or null
 * when none do. `auth` and `secret set` are two doors to the same write, so they share one
 * message — and it carries the commands rather than running them, because deleting a stored
 * credential is the owner's call. `overrides` is empty for a repo target, so this self-scopes. */
export function shadowRefusal(params: {
  overrides: { name: string; repo: string }[];
  owner: string;
  name: string;
}): string | null {
  const repos = params.overrides
    .filter((override) => override.name === params.name)
    .map((override) => `${params.owner}/${override.repo}`);
  if (!repos.length) return null;
  const bin = process.env.PULLFROG_BIN_NAME || "pullfrog";
  return [
    `${pc.cyan(params.name)} is already set on ${repos.map((repo) => pc.cyan(repo)).join(", ")}.`,
    `a repo's own copy takes precedence, so saving to ${pc.cyan(params.owner)} would not change`,
    `what runs there read.`,
    ``,
    `delete the repo copies, then run this command again:`,
    ...repos.map(
      (repo) => `  ${pc.cyan(`npx ${bin} secret delete ${params.name} --repo ${repo}`)}`
    ),
    ``,
    `${pc.dim("or:")} pass ${pc.cyan("--repo OWNER/REPO")} to replace one repo's own copy instead.`,
  ].join("\n");
}

// active spinner reference so bail/cancel can stop it before exiting. shared
// across init/auth subcommands via this module's singleton scope; whichever
// command starts a spinner sets this so handleCancel/bail can clean up.
let activeSpin: ReturnType<typeof p.spinner> | null = null;

export function setActiveSpin(spin: ReturnType<typeof p.spinner> | null): void {
  activeSpin = spin;
}

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

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

/** the gh token, or null when the cli is missing, unauthenticated, or silent.
 * the non-exiting half of `getGhToken` — `pullfrog mcp` speaks JSON-RPC over
 * stdout, so it cannot use a helper that exits through clack (which prints
 * there and would corrupt the stream). */
export function tryGetGhToken(): string | null {
  try {
    return execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim() || null;
  } catch {
    return null;
  }
}

export const GH_TOKEN_HELP =
  "gh cli not found or not authenticated. install https://cli.github.com, then run `gh auth login`.";

export function getGhToken(): string {
  const token = tryGetGhToken();
  if (!token) {
    bail(
      `gh cli not found, not authenticated, or returned an empty token.\n` +
        `  ${pc.dim("install:")} https://cli.github.com\n` +
        `  ${pc.dim("then:")}    gh auth login`
    );
  }
  return token;
}

/** owner/repo from the `origin` remote, or null when there isn't one we can
 * parse. non-exiting twin of `parseGitRemote`, for the same stdout reason. */
export function tryParseGitRemote(): { owner: string; repo: string } | null {
  let url: string;
  try {
    url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim();
  } catch {
    return null;
  }
  const match = url.match(/github\.com(?::\d+)?[:/]+([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/);
  if (!match) return null;
  return { owner: match[1], repo: match[2] };
}

export function parseGitRemote(): { owner: string; repo: string } {
  const parsed = tryParseGitRemote();
  if (!parsed) {
    bail("not a git repository, no 'origin' remote, or the remote is not a github url.");
  }
  return parsed;
}

// ── Pullfrog API ──

type SecretsApiData = {
  error?: string;
  appSlug?: string;
  installationId?: number | null;
  repositorySelection?: string | null;
  isOrg?: boolean;
  accessible?: boolean;
  repoSecrets?: string[];
  orgSecrets?: string[];
  pullfrogSecrets?: string[];
  repoStatus?: string | null;
  repoModel?: string | null;
  hasRuns?: boolean;
};

type SecretsInfo = {
  isOrg: boolean;
  installationId: number | null;
  secretsAccessible: boolean;
  repoSecrets: string[];
  orgSecrets: string[];
  pullfrogSecrets: string[];
  model: string | null;
  hasRuns: boolean;
};

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

type StatusResult =
  | ({ installed: true } & SecretsInfo)
  | ({ installed: false } & InstallationNotFound);

type ApiResult<T = Record<string, unknown>> = {
  ok: boolean;
  status: number;
  data: T;
};

export async function pullfrogApi<T = Record<string, unknown>>(ctx: {
  path: string;
  token: string;
  method?: string | undefined;
  body?: object | undefined;
}): Promise<ApiResult<T>> {
  const headers: Record<string, string> = {
    authorization: `Bearer ${ctx.token}`,
    [CLI_CONTRACT_HEADER]: CLI_CONTRACT_VERSION,
  };
  const bypassSecret = vercelBypassSecretFor(new URL(PULLFROG_API_URL));
  if (bypassSecret) headers["x-vercel-protection-bypass"] = bypassSecret;
  if (ctx.body) headers["content-type"] = "application/json";
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 30_000);
  try {
    const response = await fetch(`${PULLFROG_API_URL}${ctx.path}`, {
      method: ctx.method || "GET",
      headers,
      body: ctx.body ? JSON.stringify(ctx.body) : null,
      signal: controller.signal,
    });
    if (response.status === 426) throw new CliContractError(CLI_UPGRADE_MESSAGE);
    const data = (await response.json().catch(() => ({}))) as T;
    return { ok: response.ok, status: response.status, data };
  } finally {
    clearTimeout(timeout);
  }
}

export 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 || `secrets check failed (${result.status})`);
  }

  return {
    installed: true,
    isOrg: result.data.isOrg === true,
    installationId:
      typeof result.data.installationId === "number" ? result.data.installationId : null,
    secretsAccessible: result.data.accessible !== false,
    repoSecrets: result.data.repoSecrets || [],
    orgSecrets: result.data.orgSecrets || [],
    pullfrogSecrets: result.data.pullfrogSecrets || [],
    model: result.data.repoModel ?? null,
    hasRuns: result.data.hasRuns === true,
  };
}

// ── secret save ──

export type SecretScope = "account" | "repo";

type PullfrogSecretResult = { saved: boolean; error: string };

export async function setPullfrogSecret(ctx: {
  token: string;
  owner: string;
  repo: string;
  name: string;
  value: string;
  scope: SecretScope;
}): Promise<PullfrogSecretResult> {
  const result = await pullfrogApi<{ success?: boolean; error?: string }>({
    path: "/api/cli/secrets",
    token: ctx.token,
    method: "POST",
    body: {
      owner: ctx.owner,
      repo: ctx.repo,
      name: ctx.name,
      value: ctx.value,
      scope: ctx.scope,
    },
  });
  if (result.ok && result.data.success === true) {
    return { saved: true, error: "" };
  }
  return { saved: false, error: result.data.error || `api returned ${result.status}` };
}

/** where a Pullfrog-stored secret actually lands, for CLI copy — the
 * account-level store is keyed to the repo owner, the repo-level store to the
 * repo itself. */
export function describeSecretTarget(ctx: {
  owner: string;
  repo: string;
  scope: SecretScope;
}): string {
  if (ctx.scope === "account") return `account ${pc.cyan(`@${ctx.owner}`)}`;
  return `repo ${pc.cyan(`${ctx.owner}/${ctx.repo}`)}`;
}

export async function promptScope(ctx: { owner: string; repo: string }): Promise<SecretScope> {
  const scope = await p.select<SecretScope>({
    message: "secret scope",
    options: [
      { value: "account", label: `${ctx.owner} organization`, hint: "shared across repos" },
      { value: "repo", label: `${ctx.owner}/${ctx.repo} only` },
    ],
  });
  handleCancel(scope);
  return scope;
}