Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,6 +63,7 @@ async function main() {
registerReviewContextCommand(program);
registerSessionContextCommand(program);
registerPluginCommand(program);
registerCredentialHelperCommand(program);
registerUpgradeCommand(program);
registerCleanCommand(program);
registerTelemetryCommand(program);
Expand Down
43 changes: 43 additions & 0 deletions src/commands/credential-helper.ts
Original file line number Diff line number Diff line change
@@ -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=<handle>\npassword=<token>\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");
});
}
76 changes: 9 additions & 67 deletions src/helpers/auth.ts
Original file line number Diff line number Diff line change
@@ -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";

// ---------------------------------------------------------------------------
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -226,58 +217,9 @@ export async function claimArchgateToken(githubToken: string): Promise<string> {
// 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<void> {
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<StoredCredentials | null> {
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<void> {
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";
137 changes: 137 additions & 0 deletions src/helpers/credential-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<StoredCredentials | null> {
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<void> {
// 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");
}
}
43 changes: 42 additions & 1 deletion src/helpers/login-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.`
);
Expand Down Expand Up @@ -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<void> {
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)
);
}
}
Loading