diff --git a/src/cli.ts b/src/cli.ts index 42216ec5..3033354f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { registerPluginCommand } from "./commands/plugin/index"; import { registerReviewContextCommand } from "./commands/review-context"; import { registerSessionContextCommand } from "./commands/session-context/index"; import { registerTelemetryCommand } from "./commands/telemetry"; +import { registerCredentialHelperCommand } from "./commands/credential-helper"; import { registerUpgradeCommand } from "./commands/upgrade"; import { installGit } from "./helpers/git"; import { logError } from "./helpers/log"; @@ -62,6 +63,7 @@ async function main() { registerReviewContextCommand(program); registerSessionContextCommand(program); registerPluginCommand(program); + registerCredentialHelperCommand(program); registerUpgradeCommand(program); registerCleanCommand(program); registerTelemetryCommand(program); diff --git a/src/commands/credential-helper.ts b/src/commands/credential-helper.ts new file mode 100644 index 00000000..83b79cf9 --- /dev/null +++ b/src/commands/credential-helper.ts @@ -0,0 +1,43 @@ +/** + * credential-helper.ts — Git credential helper for archgate plugins. + * + * Git calls this command when it needs credentials for plugins.archgate.dev. + * It reads the archgate token from the OS credential manager (Bun.secrets) + * and outputs it in the git credential helper protocol format. + * + * Usage (configured automatically by `archgate login`): + * git config --global credential.https://plugins.archgate.dev.helper "archgate credential-helper" + * + * Git credential helper protocol: + * - Git calls: `archgate credential-helper get` and passes host info on stdin + * - Helper outputs: `username=\npassword=\n` + * + * @see https://git-scm.com/docs/gitcredentials + */ + +import type { Command } from "@commander-js/extra-typings"; + +import { loadCredentials } from "../helpers/auth"; + +export function registerCredentialHelperCommand(program: Command) { + program + .command("credential-helper") + .description("Git credential helper for archgate plugins (internal)") + .argument("[action]", "credential helper action (get, store, erase)") + .action(async (action) => { + // Only respond to "get" — store and erase are no-ops + if (action !== "get") return; + + const credentials = await loadCredentials(); + if (!credentials) { + // No credentials → exit silently, git will try next helper or prompt + return; + } + + // Output in git credential helper format + // Each field is on its own line, terminated by a blank line + process.stdout.write(`username=${credentials.github_user}\n`); + process.stdout.write(`password=${credentials.token}\n`); + process.stdout.write("\n"); + }); +} diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index a1ed845c..bcb8a1ee 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -1,14 +1,10 @@ /** * auth.ts — GitHub Device Flow authentication and archgate token management. * - * Implements RFC 8628 (OAuth 2.0 Device Authorization Grant) for CLI login, - * plus local storage of the archgate plugin token in ~/.archgate/credentials. + * Implements RFC 8628 (OAuth 2.0 Device Authorization Grant) for CLI login. + * Token storage is handled by credential-store.ts (Bun.secrets + file fallback). */ -import { chmodSync, unlinkSync } from "node:fs"; - -import { logDebug } from "./log"; -import { internalPath, createPathIfNotExists } from "./paths"; import { SignupRequiredError, isSignupRequiredError } from "./signup"; // --------------------------------------------------------------------------- @@ -18,7 +14,6 @@ import { SignupRequiredError, isSignupRequiredError } from "./signup"; const PLUGINS_API = "https://plugins.archgate.dev"; const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; const GITHUB_DEVICE_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const CREDENTIALS_FILE = "credentials"; /** * GitHub OAuth App client ID for the archgate CLI (public client — no secret). @@ -59,11 +54,7 @@ type DeviceTokenResponse = | DeviceTokenPendingResponse | DeviceTokenErrorResponse; -export interface StoredCredentials { - token: string; - github_user: string; - created_at: string; -} +export type { StoredCredentials } from "./credential-store"; // --------------------------------------------------------------------------- // GitHub Device Flow @@ -226,58 +217,9 @@ export async function claimArchgateToken(githubToken: string): Promise { // Re-export for consumers that import from auth.ts export { SignupRequiredError } from "./signup"; -// --------------------------------------------------------------------------- -// Credential Storage -// --------------------------------------------------------------------------- - -function credentialsPath(): string { - return internalPath(CREDENTIALS_FILE); -} - -/** - * Persist archgate credentials to ~/.archgate/credentials (JSON). - */ -export async function saveCredentials( - credentials: StoredCredentials -): Promise { - createPathIfNotExists(internalPath()); - const filePath = credentialsPath(); - await Bun.write(filePath, JSON.stringify(credentials, null, 2) + "\n"); - try { - chmodSync(filePath, 0o600); - } catch { - // chmod may fail on Windows — NTFS uses ACLs instead - } - logDebug("Credentials saved to", filePath); -} - -/** - * Load stored archgate credentials, or null if none exist. - */ -export async function loadCredentials(): Promise { - const file = Bun.file(credentialsPath()); - if (!(await file.exists())) { - return null; - } - - try { - const data = (await file.json()) as StoredCredentials; - if (!data.token || !data.github_user) { - return null; - } - return data; - } catch { - logDebug("Failed to parse credentials file"); - return null; - } -} - -/** - * Remove stored credentials (logout). - */ -export async function clearCredentials(): Promise { - if (await Bun.file(credentialsPath()).exists()) { - unlinkSync(credentialsPath()); - logDebug("Credentials removed"); - } -} +// Re-export credential storage from the dedicated module +export { + saveCredentials, + loadCredentials, + clearCredentials, +} from "./credential-store"; diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts new file mode 100644 index 00000000..fe8fb84c --- /dev/null +++ b/src/helpers/credential-store.ts @@ -0,0 +1,137 @@ +/** + * credential-store.ts — Secure credential storage using OS credential manager. + * + * Tokens are stored securely in the OS credential manager via Bun.secrets + * (macOS Keychain, Windows Credential Manager, Linux libsecret). + * Non-sensitive metadata (github_user, created_at) is stored in a lightweight + * JSON file at ~/.archgate/credentials. + * + * Falls back to the plaintext file if the OS credential manager is unavailable. + */ + +import { chmodSync, unlinkSync } from "node:fs"; + +import { logDebug } from "./log"; +import { internalPath, createPathIfNotExists } from "./paths"; + +const CREDENTIALS_FILE = "credentials"; + +/** Bun.secrets service name — identifies archgate in the OS credential manager. */ +const SECRETS_SERVICE = "dev.archgate.plugins"; +/** Bun.secrets key name for the archgate token. */ +const SECRETS_TOKEN_NAME = "token"; + +export interface StoredCredentials { + token: string; + github_user: string; + created_at: string; +} + +function credentialsPath(): string { + return internalPath(CREDENTIALS_FILE); +} + +/** + * Persist archgate credentials securely. + * + * - **Token** → OS credential manager via `Bun.secrets` (encrypted at rest) + * - **Metadata** (github_user, created_at) → `~/.archgate/credentials` (non-sensitive) + * + * Falls back to the plaintext file if the OS credential manager is unavailable. + */ +export async function saveCredentials( + credentials: StoredCredentials +): Promise { + // Store token securely in OS credential manager + try { + await Bun.secrets.set({ + service: SECRETS_SERVICE, + name: SECRETS_TOKEN_NAME, + value: credentials.token, + }); + logDebug("Token stored in OS credential manager"); + } catch (err) { + logDebug( + "OS credential manager unavailable, falling back to file storage:", + err instanceof Error ? err.message : String(err) + ); + // Fall through — the token will be stored in the metadata file below + } + + // Store metadata (and token as fallback) in ~/.archgate/credentials + createPathIfNotExists(internalPath()); + const filePath = credentialsPath(); + await Bun.write(filePath, JSON.stringify(credentials, null, 2) + "\n"); + try { + chmodSync(filePath, 0o600); + } catch { + // chmod may fail on Windows — NTFS uses ACLs instead + } + logDebug("Credentials metadata saved to", filePath); +} + +/** + * Load stored archgate credentials, or null if none exist. + * + * Reads the token from the OS credential manager first, falling back to the + * plaintext file if the credential manager doesn't have it. + */ +export async function loadCredentials(): Promise { + const file = Bun.file(credentialsPath()); + if (!(await file.exists())) { + return null; + } + + try { + const data = (await file.json()) as StoredCredentials; + if (!data.github_user) { + return null; + } + + // Try to load token from OS credential manager first + try { + const secureToken = await Bun.secrets.get({ + service: SECRETS_SERVICE, + name: SECRETS_TOKEN_NAME, + }); + if (secureToken) { + return { ...data, token: secureToken }; + } + } catch { + logDebug("OS credential manager unavailable, using file fallback"); + } + + // Fall back to token from the file (legacy or fallback) + if (!data.token) { + return null; + } + return data; + } catch { + logDebug("Failed to parse credentials file"); + return null; + } +} + +/** + * Remove stored credentials (logout). + * + * Clears both the OS credential manager and the metadata file. + */ +export async function clearCredentials(): Promise { + // Remove from OS credential manager + try { + await Bun.secrets.delete({ + service: SECRETS_SERVICE, + name: SECRETS_TOKEN_NAME, + }); + logDebug("Token removed from OS credential manager"); + } catch { + // Credential manager may not be available or token may not exist + } + + // Remove metadata file + if (await Bun.file(credentialsPath()).exists()) { + unlinkSync(credentialsPath()); + logDebug("Credentials file removed"); + } +} diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 0d9e2b0d..7461de0f 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -14,7 +14,8 @@ import { claimArchgateToken, saveCredentials, } from "./auth"; -import { logError, logInfo } from "./log"; +import { logDebug, logError, logInfo } from "./log"; +import { resolveCommand } from "./platform"; import { SignupRequiredError, requestSignup } from "./signup"; export interface LoginFlowOptions { @@ -91,6 +92,9 @@ export async function runLoginFlow( created_at: new Date().toISOString().split("T")[0], }); + // Configure git credential helper for plugins.archgate.dev + await configureGitCredentialHelper(); + logInfo( `Authenticated as ${styleText("bold", githubUser)}. Plugin access is now available.` ); @@ -166,3 +170,40 @@ async function runSignupPrompt( logInfo("Claiming archgate plugin token..."); return claimArchgateToken(githubToken); } + +/** + * Configure git to use `archgate credential-helper` for plugins.archgate.dev. + * + * Sets: git config --global credential.https://plugins.archgate.dev.helper "archgate credential-helper" + * + * This allows git to retrieve the archgate token from the OS credential manager + * transparently during clone/fetch operations. + */ +async function configureGitCredentialHelper(): Promise { + const helperCmd = (await resolveCommand("archgate")) ?? "archgate"; + const helperValue = `${helperCmd} credential-helper`; + + try { + const proc = Bun.spawn( + [ + "git", + "config", + "--global", + "credential.https://plugins.archgate.dev.helper", + helperValue, + ], + { stdout: "pipe", stderr: "pipe" } + ); + const exitCode = await proc.exited; + if (exitCode === 0) { + logDebug("Git credential helper configured for plugins.archgate.dev"); + } else { + logDebug(`Git credential helper configuration failed (exit ${exitCode})`); + } + } catch (err) { + logDebug( + "Failed to configure git credential helper:", + err instanceof Error ? err.message : String(err) + ); + } +}