diff --git a/README.md b/README.md index 60cb25d..d3731f9 100644 --- a/README.md +++ b/README.md @@ -163,16 +163,31 @@ npx githits@latest login Browser OAuth is recommended for local development. Credentials are stored in the system keychain by default and refreshed automatically. Useful flags: -- `init --no-browser` or `login --no-browser` prints a login URL for SSH, containers, or headless sessions +- `init --no-browser` or `login --no-browser` prints the login URL instead of launching a browser +- `init --port ` or `login --port ` fixes the loopback callback port - `login --force` re-authenticates even if you are already logged in -- `login --port ` uses a specific local callback port -For CI or non-interactive environments, use an API token: +The OAuth callback always listens on the machine where GitHits is running. +When GitHits runs over SSH and the browser runs locally, forward the selected +port from the browser machine: ```sh -export GITHITS_API_TOKEN=ghi-your-token-here +ssh -N -L 8765:127.0.0.1:8765 user@remote-host ``` +With that tunnel open, run GitHits on the remote machine using the same port: + +```sh +npx githits@latest init --no-browser --port 8765 +``` + +Open the URL printed by GitHits in the local browser. Replace +`user@remote-host` with the SSH destination you normally use. The same flags +work with `githits login` after setup. + +Browser OAuth is interactive. For CI and other unattended environments, supply +`GITHITS_API_TOKEN` through the environment's secret manager. + Inspect auth and runtime state with: ```sh diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index d2cab1d..67a9ca9 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -8,8 +8,9 @@ The CLI exposes setup/auth commands, `doctor`, `example`, `languages`, `feedback | Command | Required Args | Options | Description | |---|---|---|---| -| `init` | — | `-y, --yes`, `--skip-login`, `--no-browser`, `--project`, `--detect-agents`, `--install-agents `, `--json` | Authenticate and set up MCP server for coding agents; interactive setup asks whether to configure user-level or project-level MCP where supported; staged flags support agent-safe non-interactive onboarding | +| `init` | — | `-y, --yes`, `--skip-login`, `--no-browser`, `--port `, `--project`, `--detect-agents`, `--install-agents `, `--json` | Authenticate and set up MCP server for coding agents; interactive setup asks whether to configure user-level or project-level MCP where supported; staged flags support agent-safe non-interactive onboarding | | `init uninstall` | — | `-y, --yes`, `--project` | Remove GitHits MCP server configuration from coding agents or supported project-local MCP files | +| `login` | — | `--no-browser`, `--port `, `--force` | Authenticate with browser OAuth using a loopback callback on the machine running GitHits | | `example ` | `` | `-l, --lang `, `--license `, `--explain`, `--json` | Search for code examples | | `search ` | `--in ` | `--source `, `--kind `, `--category `, `--path-prefix `, `--intent `, `--public`, `--name `, `--lang `, `--allow-partial`, `--limit `, `--offset `, `--wait `, `--json` | Unified indexed search across dependency/repository code, docs, and symbols. Defaults to 10 results. | | `search-status ` | `` | `--json` | Check progress, fetch partial hits, or fetch final results for a prior unified search | @@ -34,6 +35,7 @@ githits init # Interactive: authenticate, s githits init --yes # Interactive shortcut: configure all detected unconfigured agents githits init --skip-login # Skip authentication, configure tools only githits init --no-browser # Print sign-in URL instead of opening a browser +githits init --no-browser --port 8765 # Fix the callback port for an SSH tunnel npx -y githits@latest init --detect-agents # Agent-safe discovery: scan and print detected agent IDs/statuses npx -y githits@latest init --detect-agents --json # Machine-readable discovery output for agents npx -y githits@latest init --install-agents cursor # Agent-safe install: configure only explicit detected IDs @@ -45,7 +47,36 @@ githits init uninstall --project # Explicit project-level unins githits init uninstall --project --yes # Non-interactive project-level uninstall ``` -Authenticates with GitHits (via OAuth in the browser), then scans for available coding agents, checks which are already configured, and sets up unconfigured ones with your confirmation. Use `--no-browser` in SSH or display-less sessions to print the sign-in URL instead of opening a browser on the current machine. All agents are pre-checked before any setup begins, so the status display is fully resolved. CLI agents are considered available only when their executable is on `PATH`; related dot-directories alone do not count. Config-file agents remain filesystem-detected using their known app/config directories. If already authenticated, the login step is skipped automatically. If login fails, the user is prompted to continue with tool setup anyway. If all detected agents are already configured, exits early with a summary. +Authenticates with GitHits (via OAuth in the browser), then scans for available coding agents, checks which are already configured, and sets up unconfigured ones with your confirmation. `--no-browser` suppresses browser launching but leaves the OAuth callback on the GitHits machine. `--port` selects that loopback callback port so an SSH tunnel can be prepared before authentication starts. All agents are pre-checked before any setup begins, so the status display is fully resolved. CLI agents are considered available only when their executable is on `PATH`; related dot-directories alone do not count. Config-file agents remain filesystem-detected using their known app/config directories. If already authenticated, the login step is skipped automatically. If login fails, the user is prompted to continue with tool setup anyway. If all detected agents are already configured, exits early with a summary. + +### Browser authentication over SSH + +The callback listener binds to `127.0.0.1` on the host running GitHits. A +browser on a different host cannot reach that listener through its own +loopback address. Use the same fixed port on both sides of an SSH local +forward. + +On the computer that has the browser: + +```sh +ssh -N -L 8765:127.0.0.1:8765 user@remote-host +``` + +On the remote GitHits host: + +```sh +githits init --no-browser --port 8765 +# or, after setup: +githits login --no-browser --port 8765 +``` + +Keep the tunnel open until sign-in completes. Without an explicit port, the +login flow preserves its existing stored-client or random-port behavior and +prints forwarding instructions for the selected port. CLI port values are +strict integers from 1 to 65535. + +This callback flow requires a person to complete sign-in. CI and other +unattended workloads should use `GITHITS_API_TOKEN` from a secret manager. Interactive MCP setup asks where GitHits should be configured. User-level setup preserves the existing global/user config behavior for all supported tools. Project-level setup is partial because MCP project config conventions differ by tool; GitHits only offers project setup for tools with verified project-local MCP support: Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`), VS Code / Copilot (`.vscode/mcp.json` with `type = "stdio"` server entries), Codex CLI (`.codex/config.toml`), Pi (`.mcp.json` with `pi-mcp-adapter` installed when needed), Gemini CLI (`.gemini/settings.json`), and OpenCode (`opencode.json`). Detected tools without verified project-local MCP support are shown as skipped with a reason. Project config contains no secrets, but it may be committed to source control like other project tooling configuration. Gemini may ignore project settings in untrusted workspaces. diff --git a/docs/implementation/remote-authentication.md b/docs/implementation/remote-authentication.md new file mode 100644 index 0000000..75d55de --- /dev/null +++ b/docs/implementation/remote-authentication.md @@ -0,0 +1,41 @@ +# Remote Browser Authentication + +## CLI behavior + +GitHits authenticates native CLI sessions with an OAuth authorization-code +flow and PKCE. Its temporary callback server is bound to `127.0.0.1` on the +host running the CLI. + +The `init` and `login` commands share one browser-auth option registration and +one login flow: + +- `--no-browser` prevents automatic browser launching. +- `--port ` selects the loopback callback port. +- invalid CLI and programmatic port values are rejected by shared validation; +- manual-browser output identifies the listener and prints an SSH + local-forward command using the effective port. + +When no port is supplied, the login flow continues to reuse a stored client's +redirect URI or select a port in its existing random range. + +## Documentation handoff + +Hosted authentication and command-reference content should distinguish three +cases: + +1. A browser on the GitHits host can use the loopback callback directly. +2. A browser on another computer needs an SSH local forward and the same + explicit port on `init` or `login`. +3. An unattended process should authenticate with `GITHITS_API_TOKEN`, not an + interactive callback. + +The README and CLI command reference in this repository provide the current +commands and wording to carry into hosted documentation. + +## Future tunnel-free flow + +Avoiding SSH forwarding requires an OAuth server flow designed for devices +without a local browser. The standards-based direction is the OAuth 2.0 Device +Authorization Grant (RFC 8628). That work requires authorization-server +endpoints, verification UI, expiring device/user codes, polling semantics, and +abuse controls before the CLI can support it. diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index a49e05d..f59c737 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -477,6 +477,34 @@ async function assertUnauthenticatedBehavior(): Promise { ); assertRootHelpStructure(helpResult.stdout); + for (const command of ["init", "login"] as const) { + const commandHelp = await runCliWithEnv([command, "--help"], env); + assert(commandHelp.exitCode === 0, `${command} help should succeed`); + assert( + commandHelp.stdout.includes("--port "), + `${command} help should expose the callback port option`, + ); + assert( + commandHelp.stdout.includes("--no-browser"), + `${command} help should expose the manual browser option`, + ); + + const invalidPort = await runCliWithEnv( + [command, "--port", "8765extra"], + env, + ); + assert( + invalidPort.exitCode !== 0, + `${command} should reject a partially numeric callback port`, + ); + assert( + `${invalidPort.stderr}\n${invalidPort.stdout}`.includes( + "Port must be an integer between 1 and 65535.", + ), + `${command} should explain the valid callback port range`, + ); + } + const result = await runCliWithEnv(["languages", "python", "--json"], env); assert(result.exitCode !== 0, "unauthenticated languages should fail"); assert( diff --git a/src/commands/init/init.test.ts b/src/commands/init/init.test.ts index d0c2e1b..d51e300 100644 --- a/src/commands/init/init.test.ts +++ b/src/commands/init/init.test.ts @@ -3316,12 +3316,13 @@ describe("initAction", () => { it("prints login URL instead of opening browser with --no-browser", async () => { const fs = createFsWithDetection(["/home/test/.cursor"]); const browserService = createMockBrowserService(); + const authService = createMockAuthService(); const promptService = createMockPromptService({ confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)), }); const createLoginDeps = mock(() => Promise.resolve({ - authService: createMockAuthService(), + authService, authStorage: createMockAuthStorage(), browserService, mcpUrl: "https://mcp.githits.com", @@ -3329,7 +3330,7 @@ describe("initAction", () => { ); await initAction( - { browser: false }, + { browser: false, port: 8765 }, { fileSystemService: fs, promptService, @@ -3340,6 +3341,10 @@ describe("initAction", () => { const logCalls = getLogOutput(); expect(browserService.open).not.toHaveBeenCalled(); + expect(authService.startCallbackServer).toHaveBeenCalledWith( + 8765, + "test-state", + ); expect( logCalls.some((msg) => msg.includes("We'll print a sign-in URL")), ).toBe(true); @@ -3351,6 +3356,11 @@ describe("initAction", () => { msg.includes("https://accounts.githits.com/oauth/authorize"), ), ).toBe(true); + expect( + logCalls.some((msg) => + msg.includes("ssh -N -L 8765:127.0.0.1:8765 user@remote-host"), + ), + ).toBe(true); expect(fs.atomicWriteFile).toHaveBeenCalled(); }); @@ -5869,6 +5879,7 @@ describe("registerInitCommand", () => { expect(optionLongNames).toContain("--guidance"); expect(optionLongNames).toContain("--no-guidance"); expect(optionLongNames).toContain("--no-browser"); + expect(optionLongNames).toContain("--port"); }); it("registers uninstall options as boolean options", () => { diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index 0fde5ff..d765b06 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -28,6 +28,10 @@ import type { LoginOutput, } from "../login.js"; import { loginFlow } from "../login.js"; +import { + addOAuthCallbackOptions, + type OAuthCallbackOptions, +} from "../oauth-callback-options.js"; import { type AgentDefinition, agentDefinitions, @@ -79,13 +83,11 @@ import { } from "./setup-handlers.js"; /** Options for the init command */ -export interface InitOptions { +export interface InitOptions extends OAuthCallbackOptions { /** Skip all prompts, configure all detected agents */ yes?: boolean; /** Skip the login step */ skipLogin?: boolean; - /** Print the login URL instead of opening a browser */ - browser?: boolean; /** Scan supported agents without installing anything */ detectAgents?: boolean; /** Comma-separated agent IDs to install non-interactively */ @@ -2003,7 +2005,10 @@ async function runInitAuthentication( } } - const loginOptions = options.browser === false ? { browser: false } : {}; + const loginOptions: OAuthCallbackOptions = { + ...(options.browser !== undefined ? { browser: options.browser } : {}), + ...(options.port !== undefined ? { port: options.port } : {}), + }; loginResult = await loginFlow( loginOptions, loginDeps, @@ -4013,8 +4018,9 @@ export function registerInitCommand(program: Command) { .summary("Connect GitHits to your coding agents") .description(INIT_DESCRIPTION) .option("-y, --yes", "Skip prompts, configure all detected tools") - .option("--skip-login", "Skip authentication step") - .option("--no-browser", "Print sign-in URL instead of opening browser") + .option("--skip-login", "Skip authentication step"); + + addOAuthCallbackOptions(initCommand) .option("--project", "Configure project-level MCP in the current directory") .option("--guidance", "Install supporting GitHits skill and instructions") .option("--no-guidance", "Install plain MCP without supporting guidance") diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 6166ee5..48f4bc6 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -127,6 +127,13 @@ describe("loginAction", () => { ); expect(browserService.open).not.toHaveBeenCalled(); + const output = consoleSpy.mock.calls + .map((call) => String(call[0])) + .join("\n"); + expect(output).toContain( + "The sign-in callback is listening on 127.0.0.1:8080 on this machine.", + ); + expect(output).toContain("ssh -N -L 8080:127.0.0.1:8080 user@remote-host"); consoleSpy.mockRestore(); }); @@ -905,20 +912,34 @@ describe("loginFlow", () => { consoleSpy.mockRestore(); }); - it("returns failed on invalid port", async () => { - const result = await loginFlow( - { port: -1 }, - { - authService: createMockAuthService(), - authStorage: createMockAuthStorage(), - browserService: createMockBrowserService(), - mcpUrl, - }, - ); + for (const port of [ + -1, + 0, + 1.5, + 65_536, + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + it(`returns failed before authentication for programmatic port ${port}`, async () => { + const authStorage = createMockAuthStorage(); + const authService = createMockAuthService(); + const result = await loginFlow( + { port }, + { + authService, + authStorage, + browserService: createMockBrowserService(), + mcpUrl, + }, + silentLoginOutput, + ); - expect(result.status).toBe("failed"); - expect(result.message).toContain("Invalid port"); - }); + expect(result.status).toBe("failed"); + expect(result.message).toContain("Invalid port"); + expect(authStorage.loadTokens).not.toHaveBeenCalled(); + expect(authService.discoverEndpoints).not.toHaveBeenCalled(); + }); + } it("returns failed when token exchange throws", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); diff --git a/src/commands/login.ts b/src/commands/login.ts index 189b4a7..404bcec 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -7,10 +7,15 @@ import { createAuthCommandDependencies } from "../container.js"; import type { AuthService } from "../services/auth-service.js"; import type { AuthStorage } from "../services/auth-storage.js"; import type { BrowserService } from "../services/browser-service.js"; - -export interface LoginOptions { - browser?: boolean; - port?: number; +import { + addOAuthCallbackOptions, + CALLBACK_PORT_REQUIREMENT, + formatRemoteCallbackInstructions, + isValidOAuthCallbackPort, + type OAuthCallbackOptions, +} from "./oauth-callback-options.js"; + +export interface LoginOptions extends OAuthCallbackOptions { force?: boolean; } @@ -95,6 +100,14 @@ export async function loginFlow( output: LoginOutput = stdoutLoginOutput, ): Promise { const { authService, authStorage, browserService, mcpUrl } = deps; + + if (options.port !== undefined && !isValidOAuthCallbackPort(options.port)) { + return { + status: "failed", + message: `Invalid port number. ${CALLBACK_PORT_REQUIREMENT}`, + }; + } + let existing: Awaited>; try { existing = await authStorage.loadTokens(mcpUrl); @@ -102,17 +115,6 @@ export async function loginFlow( return storageFailure(error); } - // Validate port if provided - if ( - options.port !== undefined && - (Number.isNaN(options.port) || options.port < 1 || options.port > 65535) - ) { - return { - status: "failed", - message: "Invalid port number. Must be between 1 and 65535.", - }; - } - // Check if already logged in if (existing && !options.force) { const isExpired = @@ -239,6 +241,7 @@ export async function loginFlow( if (options.browser === false) { output.write("Open this URL in your browser:\n"); output.write(` ${authUrl}\n`); + output.write(formatRemoteCallbackInstructions(port)); } else { output.write("Opening browser for GitHits sign-in...\n"); try { @@ -466,20 +469,22 @@ OAuth credentials are stored in the system keychain by default. If your machine has no usable keychain, use GITHITS_API_TOKEN or explicitly configure auth.storage = "file". File storage is plaintext on disk. -Use --no-browser in environments without a display (CI, SSH sessions) -to get a URL you can open on another device.`; +Use --no-browser to print the sign-in URL instead of launching a browser. +The callback still listens on this machine. If the browser is on another +computer, choose a fixed --port and forward that port over SSH. +For non-interactive automation, use GITHITS_API_TOKEN instead.`; /** * Register the login command on the given program. * Uses lazy container creation so `--help` doesn't trigger auth. */ export function registerLoginCommand(program: Command) { - program + const command = program .command("login") .summary("Sign in to your GitHits account") - .description(LOGIN_DESCRIPTION) - .option("--no-browser", "Print URL instead of opening browser") - .option("--port ", "Port for local callback server", parseInt) + .description(LOGIN_DESCRIPTION); + + addOAuthCallbackOptions(command) .option("--force", "Re-authenticate even if already logged in") .action(async (options: LoginOptions) => { const deps = await createAuthCommandDependencies(); diff --git a/src/commands/oauth-callback-options.test.ts b/src/commands/oauth-callback-options.test.ts new file mode 100644 index 0000000..7daea07 --- /dev/null +++ b/src/commands/oauth-callback-options.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; +import { Command, InvalidArgumentError } from "commander"; +import { + addOAuthCallbackOptions, + CALLBACK_PORT_REQUIREMENT, + formatRemoteCallbackInstructions, + isValidOAuthCallbackPort, + parseOAuthCallbackPort, +} from "./oauth-callback-options.js"; + +describe("OAuth callback options", () => { + it("registers the shared browser and callback-port flags", () => { + const command = addOAuthCallbackOptions(new Command("test")); + + expect(command.options.map((option) => option.long)).toEqual([ + "--no-browser", + "--port", + ]); + }); + + for (const [raw, expected] of [ + ["1", 1], + [" 8765 ", 8765], + ["65535", 65_535], + ] as const) { + it(`parses callback port ${JSON.stringify(raw)}`, () => { + expect(parseOAuthCallbackPort(raw)).toBe(expected); + }); + } + + for (const raw of [ + "", + "0", + "-1", + "1.5", + "8765extra", + "1e3", + "65536", + "Infinity", + ]) { + it(`rejects invalid callback port ${JSON.stringify(raw)}`, () => { + expect(() => parseOAuthCallbackPort(raw)).toThrow(InvalidArgumentError); + expect(() => parseOAuthCallbackPort(raw)).toThrow( + CALLBACK_PORT_REQUIREMENT, + ); + }); + } + + it("validates ports received from programmatic callers", () => { + expect(isValidOAuthCallbackPort(8765)).toBe(true); + for (const port of [ + 0, + -1, + 1.5, + 65_536, + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + expect(isValidOAuthCallbackPort(port)).toBe(false); + } + }); + + it("formats forwarding instructions for the selected callback port", () => { + expect(formatRemoteCallbackInstructions(8765)).toContain( + "ssh -N -L 8765:127.0.0.1:8765 user@remote-host", + ); + }); +}); diff --git a/src/commands/oauth-callback-options.ts b/src/commands/oauth-callback-options.ts new file mode 100644 index 0000000..b646cdc --- /dev/null +++ b/src/commands/oauth-callback-options.ts @@ -0,0 +1,68 @@ +import { type Command, InvalidArgumentError } from "commander"; + +const MIN_CALLBACK_PORT = 1; +const MAX_CALLBACK_PORT = 65_535; + +export const CALLBACK_PORT_REQUIREMENT = + "Port must be an integer between 1 and 65535."; + +/** Options shared by commands that start the browser OAuth login flow. */ +export interface OAuthCallbackOptions { + /** Print the sign-in URL instead of opening a browser. */ + browser?: boolean; + /** Port for the loopback OAuth callback server. */ + port?: number; +} + +/** + * Register browser OAuth options consistently on commands that use loginFlow. + */ +export function addOAuthCallbackOptions(command: T): T { + command + .option( + "--no-browser", + "Print sign-in URL and callback forwarding instructions", + ) + .option( + "--port ", + "Port for the local sign-in callback", + parseOAuthCallbackPort, + ); + return command; +} + +/** Parse a CLI callback port without accepting partial numeric values. */ +export function parseOAuthCallbackPort(raw: string): number { + const normalized = raw.trim(); + if (!/^\d+$/.test(normalized)) { + throw new InvalidArgumentError(CALLBACK_PORT_REQUIREMENT); + } + + const port = Number(normalized); + if (!isValidOAuthCallbackPort(port)) { + throw new InvalidArgumentError(CALLBACK_PORT_REQUIREMENT); + } + return port; +} + +/** Validate callback ports supplied by programmatic loginFlow callers. */ +export function isValidOAuthCallbackPort(port: number): boolean { + return ( + Number.isInteger(port) && + port >= MIN_CALLBACK_PORT && + port <= MAX_CALLBACK_PORT + ); +} + +/** + * Explain how a browser on another computer can reach the loopback callback. + */ +export function formatRemoteCallbackInstructions(port: number): string { + return [ + `The sign-in callback is listening on 127.0.0.1:${port} on this machine.`, + "If the browser is on another computer, start an SSH tunnel from that computer:", + ` ssh -N -L ${port}:127.0.0.1:${port} user@remote-host`, + "Keep the tunnel open while signing in, and replace user@remote-host with your SSH destination.", + "", + ].join("\n"); +}