diff --git a/README.md b/README.md index 1437413..0270c72 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ github-code-search query "TODO" --org my-org ``` > [!TIP] +> `GITHUB_TOKEN` falls back to `gh auth token` when unset and the [GitHub CLI](https://cli.github.com/) is installed and authenticated. > Set `GCS_DEFAULT_ORG=my-org` to omit `--org` on every call. See [Environment variables](https://fulll.github.io/github-code-search/reference/environment). ## Features diff --git a/docs/getting-started/first-search.md b/docs/getting-started/first-search.md index 49e35c9..f5032f8 100644 --- a/docs/getting-started/first-search.md +++ b/docs/getting-started/first-search.md @@ -7,7 +7,7 @@ This guide walks through a complete search session from first invocation to stru Make sure you have: - `github-code-search` [installed](/getting-started/installation) -- `GITHUB_TOKEN` set in your environment ([see Prerequisites](/getting-started/)) +- `GITHUB_TOKEN` set in your environment, or the [GitHub CLI](https://cli.github.com/) installed and authenticated ([see Prerequisites](/getting-started/)) ## Run a search diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 42be454..bf6667a 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -1,6 +1,6 @@ # Prerequisites -The only runtime prerequisite is a **GitHub personal access token**. The pre-compiled binary is self-contained and has no runtime dependency — you do not need Bun to run it. +The only runtime prerequisite is a **GitHub personal access token** (or the [GitHub CLI](https://cli.github.com/), see below). The pre-compiled binary is self-contained and has no runtime dependency — you do not need Bun to run it. ::: tip Building from source? If you want to build `github-code-search` from source, you will additionally need [Bun](https://bun.sh) ≥ 1.0. See the [Installation guide](/getting-started/installation#from-source). @@ -35,6 +35,10 @@ Add this to your shell profile (`~/.zshrc`, `~/.bashrc`, `~/.config/fish/config. Never commit your token to version control. Use environment variables or a secrets manager. ::: +::: tip Already using the GitHub CLI? +If `GITHUB_TOKEN` isn't set and [`gh`](https://cli.github.com/) is installed and authenticated (`gh auth login`), `github-code-search` automatically retrieves a token via `gh auth token` — no extra setup needed. Applies to the search commands and the `upgrade` subcommand. +::: + ## Default organization If you mostly search a single organization, set `GCS_DEFAULT_ORG` once to omit `--org` on every call: diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 1125d45..67a65b2 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -6,11 +6,13 @@ | Variable | Required | Default | Description | | ------------------------------ | -------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GITHUB_TOKEN` | ✅ | — | GitHub personal access token. Used to authenticate API calls. See [Prerequisites](/getting-started/). | +| `GITHUB_TOKEN` | ✅¹ | — | GitHub personal access token. Used to authenticate API calls. See [Prerequisites](/getting-started/). | | `GCS_DEFAULT_ORG` | ❌ | — | Default value for `--org` when the flag is omitted. An explicit `--org` always takes precedence. See [CLI options](/reference/cli-options). | | `CI` | ❌ | `false` | Set to `true` to disable the interactive TUI and print results directly to stdout. Automatically set by GitHub Actions, GitLab CI, CircleCI and most CI platforms. | | `GITHUB_CODE_SEARCH_CACHE_DIR` | ❌ | OS-dependent (below) | Override the directory used to cache the team list when `--group-by-team-prefix` is set. | +¹ Required for the search commands, unless the [GitHub CLI](https://cli.github.com/) is installed and authenticated — `gh auth token` is used as a fallback. The `upgrade` subcommand never requires a token; it only uses one opportunistically (higher GitHub API rate limits) when available. + ## `GITHUB_TOKEN` ```bash @@ -25,6 +27,10 @@ Add this to your shell profile (`~/.zshrc`, `~/.bashrc`, `~/.config/fish/config. | `public_repo` | Searching public repositories only | | `read:org` | Using [`--group-by-team-prefix`](/usage/team-grouping) | +::: tip Already using the GitHub CLI? +If `GITHUB_TOKEN` isn't set and [`gh`](https://cli.github.com/) is installed and authenticated (`gh auth login`), `github-code-search` automatically retrieves a token via `gh auth token` — no extra setup needed. Applies to the search commands and the `upgrade` subcommand. +::: + ## `GCS_DEFAULT_ORG` ```bash diff --git a/docs/usage/upgrade.md b/docs/usage/upgrade.md index b2b8cbd..a372d72 100644 --- a/docs/usage/upgrade.md +++ b/docs/usage/upgrade.md @@ -30,7 +30,7 @@ Successfully upgraded to v1.3.0. ## Token requirement -The `upgrade` subcommand works without a `GITHUB_TOKEN`. A token is used only if the `GITHUB_TOKEN` environment variable is already set (to avoid GitHub API rate limiting on the release fetch). +The `upgrade` subcommand works without a `GITHUB_TOKEN`. A token is used only if the `GITHUB_TOKEN` environment variable is set, or otherwise retrieved via `gh auth token` when the [GitHub CLI](https://cli.github.com/) is installed and authenticated (to avoid GitHub API rate limiting on the release fetch). ## Checking the current version diff --git a/github-code-search.ts b/github-code-search.ts index bfbb69d..a6f583a 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -9,7 +9,8 @@ * github-code-search query --org [options] * * Requirements: - * GITHUB_TOKEN env var must be set (for search; optional for upgrade). + * GITHUB_TOKEN env var must be set (for search; optional for upgrade), + * or the GitHub CLI (`gh`) installed and authenticated as a fallback. */ import { Command, Option, program } from "commander"; @@ -33,6 +34,7 @@ import { import { checkForUpdate } from "./src/upgrade.ts"; import { runInteractive } from "./src/tui.ts"; import { generateCompletion, detectShell } from "./src/completions.ts"; +import { getGhAuthToken } from "./src/gh-cli.ts"; import { buildApiQuery, detectPathWildcardLimitation, @@ -281,9 +283,17 @@ async function searchAction( }, ): Promise { // ─── GitHub API token ─────────────────────────────────────────────────────── - const GITHUB_TOKEN = process.env.GITHUB_TOKEN; + // Falls back to `gh auth token` when GITHUB_TOKEN isn't set and the GitHub + // CLI is installed and authenticated — see src/gh-cli.ts. + const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? getGhAuthToken(); if (!GITHUB_TOKEN) { console.error(style.red("Error: GITHUB_TOKEN environment variable is not set.")); + console.error( + style.dim( + "Tip: install and authenticate the GitHub CLI (`gh auth login`) to use " + + "`gh auth token` as a fallback.", + ), + ); process.exit(1); } @@ -590,7 +600,8 @@ program .option("--debug", "Print debug information for troubleshooting") .action(async (opts: { debug?: boolean }) => { const { performUpgrade } = await import("./src/upgrade.ts"); - const token = process.env.GITHUB_TOKEN; + // Falls back to `gh auth token` — same as the search commands, see src/gh-cli.ts. + const token = process.env.GITHUB_TOKEN ?? getGhAuthToken(); // Fix: in some Bun versions, process.execPath returns the Bun runtime path // (e.g. ~/.bun/bin/bun) or an internal /$bunfs/ path instead of the compiled // binary path — which causes the mv to fail or replace the wrong file. diff --git a/src/gh-cli.test.ts b/src/gh-cli.test.ts new file mode 100644 index 0000000..70a04d1 --- /dev/null +++ b/src/gh-cli.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { getGhAuthToken, resolveGhAuthToken } from "./gh-cli.ts"; + +const ghIsAvailable = () => true; +const ghAuthTokenSucceeds = () => ({ exitCode: 0, stdout: "token-value" }); + +describe("resolveGhAuthToken", () => { + it("returns undefined when gh is not installed", () => { + const result = resolveGhAuthToken( + () => false, + () => { + throw new Error("runAuthToken must not be called when gh isn't installed"); + }, + ); + expect(result).toBeUndefined(); + }); + + it("returns the trimmed token when gh auth token succeeds", () => { + const result = resolveGhAuthToken( + () => true, + () => ({ exitCode: 0, stdout: "ghp_abc123\n" }), + ); + expect(result).toBe("ghp_abc123"); + }); + + it("returns undefined when gh auth token exits with a non-zero code", () => { + const result = resolveGhAuthToken( + () => true, + () => ({ exitCode: 1, stdout: "" }), + ); + expect(result).toBeUndefined(); + }); + + it("returns undefined when gh auth token succeeds but prints only whitespace", () => { + const result = resolveGhAuthToken( + () => true, + () => ({ exitCode: 0, stdout: " \n" }), + ); + expect(result).toBeUndefined(); + }); + + it("is pure — calling it twice with the same inputs yields the same result", () => { + const first = resolveGhAuthToken(ghIsAvailable, ghAuthTokenSucceeds); + const second = resolveGhAuthToken(ghIsAvailable, ghAuthTokenSucceeds); + expect(first).toBe(second); + }); +}); + +describe("getGhAuthToken", () => { + const originalWhich = Bun.which; + const originalSpawnSync = Bun.spawnSync; + + afterEach(() => { + Bun.which = originalWhich; + Bun.spawnSync = originalSpawnSync; + }); + + it("returns undefined when gh is not on PATH (Bun.spawnSync never called)", () => { + Bun.which = (() => null) as typeof Bun.which; + Bun.spawnSync = (() => { + throw new Error("spawnSync must not be called when gh isn't on PATH"); + }) as unknown as typeof Bun.spawnSync; + expect(getGhAuthToken()).toBeUndefined(); + }); + + it("returns the trimmed token when gh is on PATH and gh auth token succeeds", () => { + Bun.which = (() => "/usr/local/bin/gh") as typeof Bun.which; + Bun.spawnSync = ((..._args: unknown[]) => ({ + exitCode: 0, + stdout: Buffer.from("ghp_real123\n"), + })) as unknown as typeof Bun.spawnSync; + expect(getGhAuthToken()).toBe("ghp_real123"); + }); + + it("returns undefined when gh auth token exits non-zero (not authenticated)", () => { + Bun.which = (() => "/usr/local/bin/gh") as typeof Bun.which; + Bun.spawnSync = ((..._args: unknown[]) => ({ + exitCode: 1, + stdout: Buffer.from(""), + })) as unknown as typeof Bun.spawnSync; + expect(getGhAuthToken()).toBeUndefined(); + }); +}); diff --git a/src/gh-cli.ts b/src/gh-cli.ts new file mode 100644 index 0000000..82ee084 --- /dev/null +++ b/src/gh-cli.ts @@ -0,0 +1,46 @@ +// ─── gh CLI token fallback ───────────────────────────────────────────────────── +// +// Fallback for GITHUB_TOKEN: when the env var isn't set, detect whether the +// GitHub CLI (`gh`) is installed and, if so, retrieve a token via +// `gh auth token`. Pure decision logic lives in `resolveGhAuthToken` (unit +// tested via injected `which`/`runAuthToken`); `getGhAuthToken` is the sole +// call site for the real `Bun.which` / subprocess spawn, tested by +// reassigning those globals rather than spawning a real `gh` process. + +/** Result of a single subprocess invocation, abstracted for testability. */ +export interface ExecResult { + exitCode: number; + stdout: string; +} + +/** + * Returns the token from `gh auth token` when `gh` is installed and the + * command succeeds with non-empty output, `undefined` otherwise (not + * installed, non-zero exit code, or blank output). Pure — `which` and + * `runAuthToken` are injected so this can be unit tested without spawning a + * real subprocess. + */ +export function resolveGhAuthToken( + which: (cmd: string) => boolean, + runAuthToken: () => ExecResult, +): string | undefined { + if (!which("gh")) return undefined; + const result = runAuthToken(); + if (result.exitCode !== 0) return undefined; + const token = result.stdout.trim(); + return token.length > 0 ? token : undefined; +} + +/** + * Real Bun call site: checks for `gh` on PATH and, if present, runs + * `gh auth token` synchronously to retrieve a token. + */ +export function getGhAuthToken(): string | undefined { + return resolveGhAuthToken( + (cmd) => Bun.which(cmd) !== null, + () => { + const proc = Bun.spawnSync(["gh", "auth", "token"]); + return { exitCode: proc.exitCode, stdout: proc.stdout.toString() }; + }, + ); +}