Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/first-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docs/getting-started/index.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/usage/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 14 additions & 3 deletions github-code-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* github-code-search query <query> --org <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";
Expand All @@ -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,
Expand Down Expand Up @@ -281,9 +283,17 @@ async function searchAction(
},
): Promise<void> {
// ─── 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.",
),
);
Comment thread
shouze marked this conversation as resolved.
process.exit(1);
}

Expand Down Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions src/gh-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions src/gh-cli.ts
Original file line number Diff line number Diff line change
@@ -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() };
},
);
}
Loading