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 @@ -350,6 +350,7 @@ githits example Find real-world implementations from open source
githits languages List or filter supported programming languages
githits feedback Submit feedback about GitHits results
githits doctor Diagnose configuration and auth state
githits settings View and update preferences, privacy, and terms
githits search Explore repository code, dependencies, docs, and symbols
githits search-status Check the status of a previous indexed search
githits code List, read, and grep indexed dependency source
Expand Down
3 changes: 2 additions & 1 deletion docs/implementation/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,15 @@ CLI startup / MCP server start

Per API call (via RefreshingGitHitsService):
└─ TokenProvider.getToken() → get fresh token
└─ on AuthenticationError from API → forceRefresh() → retry once
└─ on AuthenticationError or TERMS_ACCEPTANCE_REQUIRED → forceRefresh() → retry once
```

The MCP server starts without a synchronous auth gate. Tool calls resolve tokens through the shared token provider and return per-tool auth errors when no valid token is available.

## Troubleshooting

- **"Authentication required" from a command or MCP tool** — No valid token found. Run `githits login` or set `GITHITS_API_TOKEN`.
- **"Terms acceptance required"** — Run `githits settings terms accept` or open the environment-specific `acceptance_url` returned by the backend. OAuth sessions refresh after acceptance; static `ghi-*` tokens remain unchanged and are re-evaluated server-side without token refresh.
- **Different auth behavior across terminals or agents** — Run `githits doctor` or `githits doctor --json` to compare redacted runtime, environment, config, and auth-storage diagnostics without exposing token values.
- **"Already logged in."** — Token is still valid. Use `githits login --force` to re-authenticate.
- **Port conflicts on login** — The callback server uses the port from the stored client registration. On first login, a random port (8000–9999) is chosen and saved. Use `--port <port>` to change it (triggers re-registration).
Expand Down
51 changes: 51 additions & 0 deletions docs/implementation/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback
| `languages [query]` | — | `--json` | List or filter supported languages |
| `feedback [solution_id]` | `--accept` or `--reject` | `-m, --message <text>`, `--tool <name>`, `--json` | Submit solution-tied or generic session feedback |
| `doctor` | — | `--json` | Print redacted diagnostics for GitHits runtime, environment, service URLs, config, and auth storage |
| `settings` | — | `--json` | Show canonical preferences, privacy and terms, and account limits |
| `settings show` | — | `--json` | Explicit form of `settings` for showing all account settings |
| `settings get <key>` | setting key | `--json` | Read one writable setting using its public CLI name |
| `settings set <key> <values...>` | setting key and typed value(s) | `--json` | Selectively update one writable account setting |
| `settings clear <key>` | clearable setting key | `--json` | Clear the default language or replace blocked license IDs with an empty list |
| `settings terms` | — | `--json` | Show the current Terms of Service acceptance state |
| `settings terms accept` | — | `--yes`, `--json` | Confirm and accept the current Terms of Service |
| `pkg info <spec>` | package spec | `--verbose`, `--json` | Show a package overview (latest version, downloads, license, vulnerabilities) |
| `pkg vulns <spec>` | package spec (optional `@version`) | `--severity`, `--scope`, `--include-withdrawn`, `--verbose`, `--json` | List known vulnerabilities for a package (npm/pypi/hex/crates/nuget/maven/packagist/rubygems/go/swift) |
| `pkg deps <spec>` | package spec (optional `@version`) | `--lifecycle`, `--depth`, `--verbose`, `--json` | Analyse dependencies: direct runtime deps, structured groups, optional capped transitive graph (npm/pypi/hex/crates/vcpkg/zig/rubygems/go/swift) |
Expand Down Expand Up @@ -94,6 +101,50 @@ For automation, `githits init uninstall --yes` is user-level only and never touc

**File structure:** The init command uses a subdirectory (`src/commands/init/`) because it has distinct submodules (agent definitions, setup handlers, orchestrator). This is an accepted variation for commands with significant internal complexity.

### `githits settings`

```sh
githits settings
githits settings --json
githits settings show
githits settings get license-mode
githits settings set license-mode safe
githits settings set marketing-emails disabled
githits settings set blocked-license-ids 0198a7d0-6750-7ace-a68c-418062117d95 0198a7d0-6750-7ace-a68c-418062117d96
githits settings clear blocked-license-ids
githits settings terms
githits settings terms accept
githits settings terms accept --yes --json
```

Settings calls the self-scoped account API with the active credential. The root
command and `show` display all settings. `get`, `set`, and `clear` use a
whitelisted public key schema: `default-language-id`, `license-mode`,
`blocked-license-ids`, and `marketing-emails`. The schema validates each value
and maps it to the canonical API field, so the CLI does not expose the negative
`marketing_email_opted_out` storage name. `marketing-emails` accepts
`enabled`/`disabled`; `license-mode` accepts `safe`/`yolo`/`custom`; blocked
license IDs are an atomic list replacement. `clear blocked-license-ids` sends
an explicit empty list, while `clear default-language-id` sends null.

JSON overview and update output remains the canonical settings object.
`settings get <key> --json` returns `{key, value}` using the public key and
value. Every mutation sends exactly one selective PATCH. JSON batch input is
intentionally omitted until an atomic multi-setting workflow is required.

Terms acceptance prompts unless `--yes` is supplied. OAuth sessions are
force-refreshed after the write so subsequent requests receive the updated JWT
claim. Static `GITHITS_API_TOKEN` credentials are not refreshed; their terms
state is re-evaluated server-side without token refresh. If acceptance succeeds but
OAuth refresh fails, output reports the saved acceptance and instructs the user
to run `githits login --force`.

Downstream REST and GraphQL clients recognize the structured
`TERMS_ACCEPTANCE_REQUIRED` response, refresh OAuth at most once, and retry at
most once. A still-gated request returns the stable
`githits settings terms accept` remediation plus the authenticated web
acceptance URL; `ghi-*` credentials never enter a refresh loop.

### `githits example`

```
Expand Down
7 changes: 5 additions & 2 deletions docs/implementation/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@

## Purpose

The CLI uses three separate service URLs and supports three authentication modes. Getting these wrong causes subtle failures — wrong URL means auth works but API calls fail, wrong auth mode means some tools work but others silently return errors. This document explains the configuration model so changes are made with full context.
The CLI uses four separate service URLs and supports three authentication modes. Getting these wrong causes subtle failures — wrong URL means auth works but API calls fail, wrong auth mode means some tools work but others silently return errors. This document explains the configuration model so changes are made with full context.

## Background

GitHits separates its MCP server (which handles OAuth discovery and the MCP protocol), REST API (which handles search, languages, and feedback), and package/source service. In production, they use independent endpoints.
GitHits separates its MCP server (which handles OAuth discovery and the MCP protocol), REST API (which handles search, languages, and feedback), account settings API, and package/source service. In production, they use independent endpoints.

## URL Configuration

| URL | Default | Env var | Used for |
|---|---|---|---|
| **MCP URL** | `https://mcp.githits.com` | `GITHITS_MCP_URL` | OAuth discovery (`.well-known`), DCR registration, auth flow |
| **API URL** | `https://api.githits.com` | `GITHITS_API_URL` | REST endpoints (`/search`, `/languages`, `/feedbacks`) |
| **Accounts URL** | `https://accounts.githits.com` | `GITHITS_ACCOUNTS_URL` | Self-scoped settings and Terms of Service acceptance |
| **Package/source URL** | GitHits-managed package/source service | `GITHITS_CODE_NAV_URL` | Package/source service endpoint used by indexed `search` / `pkg` / `docs` / `code` tooling |

> **These are different services.** Override every URL that differs from production when pointing to a non-production backend.
Expand Down Expand Up @@ -41,6 +42,7 @@ The container (`src/container.ts`) resolves authentication in priority order:
| `/search` | Full access | Full access | Blocked |
| `/languages` | Full access | Full access | Blocked |
| `/feedbacks` | Full access | Full access | Blocked |
| `/functions/v1/settings/me` | Full access | Full access | Blocked |

Package/source access uses the package/source service URL from `GITHITS_CODE_NAV_URL`, defaulting to the GitHits-managed endpoint. MCP registration for `search`, `search_status`, `docs_*`, `pkg_*`, `code_files`, `code_read`, and `code_grep` is always on; CLI registration for top-level `search` / `search-status` plus the `githits code`, `githits pkg`, and `githits docs` groups is also always on.

Expand All @@ -51,6 +53,7 @@ Package/source access uses the package/source service URL from `GITHITS_CODE_NAV
| `GITHITS_MCP_URL` | Override MCP server URL | `http://localhost:7071/mcp` |
| `GITHITS_API_URL` | Override REST API URL | `http://localhost:8000` |
| `GITHITS_CODE_NAV_URL` | Override package/source service URL | `http://localhost:4000` |
| `GITHITS_ACCOUNTS_URL` | Override account settings origin | `https://accounts.example.test` |
| `GITHITS_API_TOKEN` | API token for authentication | `ghi-abc123...` |
| `GITHITS_AUTH_STORAGE` | Override OAuth credential storage for the current process (`keychain` or `file`) | `file` |
| `GITHITS_TELEMETRY` | Emit end-of-run timing spans to stderr for local profiling | `1` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
CodeNavigationTargetNotFoundError,
CodeNavigationVersionNotFoundError,
} from "./code-navigation-service.js";
import { TermsAcceptanceRequiredError } from "./githits-service.js";
import { createMockTokenProvider } from "./test-helpers.js";

function mockFetch(impl: () => Promise<Response>) {
Expand All @@ -34,6 +35,42 @@ describe("CodeNavigationServiceImpl", () => {
originalFetch = globalThis.fetch;
});

it("recognises HTTP terms gating and does not loop without a refresh token", async () => {
const fetchFn = mockFetch(() =>
Promise.resolve(
new Response(
JSON.stringify({
errors: [
{
message: "Terms acceptance required",
extensions: {
code: "TERMS_ACCEPTANCE_REQUIRED",
terms_url: "https://githits.com/legal/terms-of-service/",
acceptance_url:
"https://acceptance.example.test/settings/privacy",
},
},
],
}),
{ status: 403 },
),
),
);
const service = new CodeNavigationServiceImpl(
BASE_URL,
createMockTokenProvider({
forceRefresh: mock(() => Promise.resolve(undefined)),
}),
);

await expect(
service.listFiles({
target: { registry: "NPM", packageName: "express" },
}),
).rejects.toBeInstanceOf(TermsAcceptanceRequiredError);
expect(fetchFn).toHaveBeenCalledTimes(1);
});

afterEach(() => {
globalThis.fetch = originalFetch;
if (originalDebug === undefined) delete process.env.GITHITS_DEBUG;
Expand Down
11 changes: 6 additions & 5 deletions packages/core-internal/src/services/code-navigation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { executeWithTokenRefresh } from "./execute-with-token-refresh.js";
import {
AuthenticationError,
isTokenRefreshableError,
SERVER_AUTHENTICATION_REJECTED_MESSAGE,
} from "./githits-service.js";
import type { TokenProvider } from "./token-provider.js";
Expand Down Expand Up @@ -1837,7 +1838,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService {
return executeWithTokenRefresh({
getToken: () => this.tokenProvider.getToken(),
forceRefresh: () => this.tokenProvider.forceRefresh(),
shouldRefresh: (error) => error instanceof AuthenticationError,
shouldRefresh: isTokenRefreshableError,
executeWithToken: (token) => this.executeUnifiedSearch(token, params),
});
}
Expand All @@ -1849,7 +1850,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService {
return executeWithTokenRefresh({
getToken: () => this.tokenProvider.getToken(),
forceRefresh: () => this.tokenProvider.forceRefresh(),
shouldRefresh: (error) => error instanceof AuthenticationError,
shouldRefresh: isTokenRefreshableError,
executeWithToken: (token) =>
this.executeUnifiedSearchStatus(token, searchRef, waitTimeoutMs),
});
Expand Down Expand Up @@ -2449,7 +2450,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService {
return executeWithTokenRefresh({
getToken: () => this.tokenProvider.getToken(),
forceRefresh: () => this.tokenProvider.forceRefresh(),
shouldRefresh: (error) => error instanceof AuthenticationError,
shouldRefresh: isTokenRefreshableError,
executeWithToken: (token) => this.executeListFiles(token, params),
});
}
Expand Down Expand Up @@ -2552,7 +2553,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService {
return executeWithTokenRefresh({
getToken: () => this.tokenProvider.getToken(),
forceRefresh: () => this.tokenProvider.forceRefresh(),
shouldRefresh: (error) => error instanceof AuthenticationError,
shouldRefresh: isTokenRefreshableError,
executeWithToken: (token) => this.executeReadFile(token, params),
});
}
Expand Down Expand Up @@ -2632,7 +2633,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService {
return executeWithTokenRefresh({
getToken: () => this.tokenProvider.getToken(),
forceRefresh: () => this.tokenProvider.forceRefresh(),
shouldRefresh: (error) => error instanceof AuthenticationError,
shouldRefresh: isTokenRefreshableError,
executeWithToken: (token) => this.executeGrepRepo(token, params),
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it, mock } from "bun:test";
import { executeWithTokenRefresh } from "./execute-with-token-refresh.js";
import { TermsAcceptanceRequiredError } from "./githits-service.js";

describe("executeWithTokenRefresh", () => {
it("never calls the refresh hook for an opaque ghi-* token", async () => {
const forceRefresh = mock(() => Promise.resolve("unexpected-token"));
const executeWithToken = mock(() =>
Promise.reject(new TermsAcceptanceRequiredError()),
);

await expect(
executeWithTokenRefresh({
getToken: mock(() => Promise.resolve("ghi-static-token")),
forceRefresh,
executeWithToken,
shouldRefresh: () => true,
}),
).rejects.toBeInstanceOf(TermsAcceptanceRequiredError);

expect(forceRefresh).not.toHaveBeenCalled();
expect(executeWithToken).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export async function executeWithTokenRefresh<T>(
try {
return await options.executeWithToken(token);
} catch (error) {
if (!options.shouldRefresh(error)) {
// Opaque ghi-* credentials are re-evaluated server-side and have no local
// refresh flow. Do not invoke the provider's refresh hook for them.
if (token.startsWith("ghi-") || !options.shouldRefresh(error)) {
throw error;
}

Expand Down
39 changes: 39 additions & 0 deletions packages/core-internal/src/services/githits-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ApiRateLimitError,
AuthenticationError,
GitHitsServiceImpl,
TermsAcceptanceRequiredError,
} from "./githits-service.js";

// Helper to mock global fetch with proper typing
Expand Down Expand Up @@ -79,6 +80,44 @@ describe("GitHitsServiceImpl", () => {
});

describe("search", () => {
it("recognises the canonical terms-required 403 contract", async () => {
mockFetch(() =>
Promise.resolve(
new Response(
JSON.stringify({
reason: "Terms acceptance required",
code: "TERMS_ACCEPTANCE_REQUIRED",
terms_url: "https://githits.com/legal/terms-of-service/",
acceptance_url:
"https://acceptance.example.test/settings/privacy",
}),
{ status: 403 },
),
),
);

await expect(service.search({ query: "test" })).rejects.toMatchObject({
name: TermsAcceptanceRequiredError.name,
termsUrl: "https://githits.com/legal/terms-of-service/",
acceptanceUrl: "https://acceptance.example.test/settings/privacy",
});
});

it("uses the production acceptance page when a legacy response omits it", async () => {
mockFetch(() =>
Promise.resolve(
new Response(JSON.stringify({ code: "TERMS_ACCEPTANCE_REQUIRED" }), {
status: 403,
}),
),
);

await expect(service.search({ query: "test" })).rejects.toMatchObject({
name: TermsAcceptanceRequiredError.name,
acceptanceUrl: "https://app.githits.com/settings/privacy",
});
});

it("sends correct request and returns markdown", async () => {
const fn = mockFetch(() =>
Promise.resolve(new Response("# Result\nCode example here")),
Expand Down
22 changes: 22 additions & 0 deletions packages/core-internal/src/services/githits-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,21 @@ import {
import { parseHttpErrorDetail } from "../shared/http-error-detail.js";
import type { ClientHeaderBuilder } from "../shared/request-headers.js";
import { withTelemetrySpan } from "../shared/telemetry.js";
import {
TermsAcceptanceRequiredError,
throwIfTermsAcceptanceRequired,
} from "../shared/terms-acceptance.js";
import { validateServiceUrl } from "./config.js";

export {
createTermsAcceptanceError,
TERMS_ACCEPTANCE_REQUIRED_CODE,
TERMS_ACCEPTANCE_URL,
TERMS_URL,
type TermsAcceptanceRemediation,
TermsAcceptanceRequiredError,
} from "../shared/terms-acceptance.js";

const DEFAULT_EXAMPLE_REQUEST_TIMEOUT_MS = 240_000;

/**
Expand Down Expand Up @@ -41,6 +54,14 @@ export class AuthenticationError extends Error {
}
}

/** A stale OAuth JWT can be refreshed once for either auth failure signal. */
export function isTokenRefreshableError(error: unknown): boolean {
return (
error instanceof AuthenticationError ||
error instanceof TermsAcceptanceRequiredError
);
}

/**
* Error returned when the REST API asks the client to retry later.
*
Expand Down Expand Up @@ -352,6 +373,7 @@ export class GitHitsServiceImpl implements GitHitsService {
const status = response.status;
const body = await response.text().catch(() => "");
const detail = parseHttpErrorDetail(body, ["detail"]);
throwIfTermsAcceptanceRequired(body);

switch (status) {
case 401:
Expand Down
Loading