From 6cfc3dba874a5124e5951ad4c308b4a79fc36aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sat, 19 Sep 2026 13:18:05 +0200 Subject: [PATCH 1/4] Fall back to gh auth token when GITHUB_TOKEN is not set Add src/gh-cli.ts: resolveGhAuthToken is a pure, dependency-injected resolution function (unit tested); getGhAuthToken is the sole call site for Bun.which/Bun.spawnSync. Wired into both searchAction and the upgrade subcommand's token lookup, falling back only when GITHUB_TOKEN is unset. Closes #208 --- docs/getting-started/index.md | 2 +- docs/reference/environment.md | 8 +++++- github-code-search.ts | 19 +++++++++++--- src/gh-cli.test.ts | 47 +++++++++++++++++++++++++++++++++++ src/gh-cli.ts | 46 ++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 src/gh-cli.test.ts create mode 100644 src/gh-cli.ts diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 42be454..7d3d576 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). diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 1125d45..d26b745 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 unless the [GitHub CLI](https://cli.github.com/) is installed and authenticated — `gh auth token` is used as a fallback. + ## `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/github-code-search.ts b/github-code-search.ts index bfbb69d..c632991 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.red( + "Error: GITHUB_TOKEN environment variable is not set, and no token could be " + + "retrieved via `gh auth token` (install and authenticate the GitHub CLI, " + + "or set GITHUB_TOKEN directly).", + ), + ); 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..91cf6cf --- /dev/null +++ b/src/gh-cli.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { 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); + }); +}); diff --git a/src/gh-cli.ts b/src/gh-cli.ts new file mode 100644 index 0000000..9e35710 --- /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, +// untested call site for the real `Bun.which` / subprocess spawn, mirroring +// how `render/terminal.ts` is the sole call site for other Bun APIs. + +/** 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() }; + }, + ); +} From b4a706c57b7cf557b7c0fe7940364e695d3593aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sat, 19 Sep 2026 13:25:32 +0200 Subject: [PATCH 2/4] Add tests for getGhAuthToken by mocking Bun.which/Bun.spawnSync Fixes CI coverage-threshold failure: gh-cli.ts was below the 75%/80% lines/functions threshold with only resolveGhAuthToken tested. --- src/gh-cli.test.ts | 40 ++++++++++++++++++++++++++++++++++++++-- src/gh-cli.ts | 6 +++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/gh-cli.test.ts b/src/gh-cli.test.ts index 91cf6cf..70a04d1 100644 --- a/src/gh-cli.test.ts +++ b/src/gh-cli.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "bun:test"; -import { resolveGhAuthToken } from "./gh-cli.ts"; +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" }); @@ -45,3 +45,39 @@ describe("resolveGhAuthToken", () => { 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 index 9e35710..82ee084 100644 --- a/src/gh-cli.ts +++ b/src/gh-cli.ts @@ -3,9 +3,9 @@ // 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, -// untested call site for the real `Bun.which` / subprocess spawn, mirroring -// how `render/terminal.ts` is the sole call site for other Bun APIs. +// 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 { From 0d0eedaabd528646306432dc1b7ea963cf433b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sat, 19 Sep 2026 13:31:15 +0200 Subject: [PATCH 3/4] Restore README/first-search gh auth token mentions dropped by the main rebase These were silently reverted during the rebase onto post-#211 main (never part of this PR's own diff before), since #211 removed the same wording that was accidentally shared with #209. Re-add them here so they're properly attributed to this PR. --- README.md | 1 + docs/getting-started/first-search.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 From 2c5d2855d9574335ad896f0bc5844fb942a4f471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Sat, 19 Sep 2026 13:40:35 +0200 Subject: [PATCH 4/4] Address Copilot review: keep original token error, clarify docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore the exact original 'Error: GITHUB_TOKEN environment variable is not set.' message (unchanged acceptance-criteria contract) instead of replacing it; add the gh CLI hint as a separate dim line. - environment.md: clarify the GITHUB_TOKEN footnote applies to the search commands, and that upgrade never requires a token (uses one only opportunistically for rate limits). - docs/usage/upgrade.md: document the gh auth token fallback for upgrade. - Re-add the 'Already using the GitHub CLI?' tip to docs/getting-started/index.md, silently dropped by an earlier rebase onto main after #211 merged — this is what the prerequisite sentence's 'see below' was supposed to point to. --- docs/getting-started/index.md | 4 ++++ docs/reference/environment.md | 2 +- docs/usage/upgrade.md | 2 +- github-code-search.ts | 8 ++++---- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 7d3d576..bf6667a 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -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 d26b745..67a65b2 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -11,7 +11,7 @@ | `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 unless the [GitHub CLI](https://cli.github.com/) is installed and authenticated — `gh auth token` is used as a fallback. +¹ 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` 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 c632991..a6f583a 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -287,11 +287,11 @@ async function searchAction( // 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.red( - "Error: GITHUB_TOKEN environment variable is not set, and no token could be " + - "retrieved via `gh auth token` (install and authenticate the GitHub CLI, " + - "or set GITHUB_TOKEN directly).", + style.dim( + "Tip: install and authenticate the GitHub CLI (`gh auth login`) to use " + + "`gh auth token` as a fallback.", ), ); process.exit(1);