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
2 changes: 2 additions & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv
- **npm `main` field always gets included in publish** — `"main"` in `package.json` is always included in `npm publish` regardless of the `files` array. If the package doesn't need a default entry point (only sub-path exports like `./rules`), remove `main` entirely to avoid bundling the CLI entry point into the npm package.
- **`CHANGELOG.md` is auto-generated — exclude from Prettier** — `CHANGELOG.md` is written by `TrigenSoftware/simple-release-action` during the release PR workflow. It is committed directly and never formatted by Prettier. It MUST be in `.prettierignore`; otherwise `bun run format:check` (part of `bun run validate`) will fail on the release PR. Do not attempt to run prettier on it post-commit.
- **`mock.module("node:fetch", ...)` does NOT intercept `globalThis.fetch` in Bun** — Bun's runtime fetch is `globalThis.fetch`, not the `node:fetch` module. Using `mock.module` silently fails; the real network is hit. Always mock fetch by assigning `globalThis.fetch = mockFn as unknown as typeof fetch` directly, and restore via `mock.restore()` in `afterEach`. TypeScript type cast: `as never` is insufficient for the `typeof fetch` type (which includes `preconnect`) — always use `as unknown as typeof fetch`. Also captured in ARCH-005 Don'ts.
- **Git credential tests need system-level isolation on Windows** — Overriding `Bun.env.HOME` is NOT sufficient to isolate `git credential fill/approve` calls in tests. Windows Credential Manager is a system-level API, not file-based. Tests MUST set `Bun.env.GIT_CONFIG_NOSYSTEM = "1"` and `Bun.env.GIT_CONFIG_GLOBAL = <path-to-empty-file>` to prevent git from reading the real credential helper config. Without this, tests on machines with stored credentials will pick up real tokens.
- **Module-level `{ ...Bun.env }` captures env at import time** — Spreading `Bun.env` into a module-level constant freezes the env snapshot. Tests that override `Bun.env.HOME` after import won't affect the constant. Fix: use a function that returns `{ ...Bun.env, ... }` on each call so it picks up test-time overrides. Applied in `src/helpers/credential-store.ts`.

## Validation Pipeline

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"test": "bun test --timeout 60000",
"test:watch": "bun test --watch --timeout 60000",
"typecheck": "tsc --build",
"validate": "bun run lint && bun run typecheck && bun run format:check && bun test && bun run check && bun run build:check"
"validate": "bun run lint && bun run typecheck && bun run format:check && bun run test && bun run check && bun run build:check"
},
"devDependencies": {
"@commander-js/extra-typings": "14.0.0",
Expand Down
4 changes: 1 addition & 3 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ export function registerLoginCommand(program: Command) {
.action(async () => {
const creds = await loadCredentials();
if (creds) {
console.log(
`Logged in as ${styleText("bold", creds.github_user)} (since ${creds.created_at})`
);
console.log(`Logged in as ${styleText("bold", creds.github_user)}.`);
} else {
console.log("Not logged in. Run `archgate login` to authenticate.");
}
Expand Down
224 changes: 86 additions & 138 deletions src/helpers/credential-store.ts
Original file line number Diff line number Diff line change
@@ -1,101 +1,69 @@
/**
* credential-store.ts — Secure credential storage using git's native credential helpers.
*
* Stores archgate tokens in the user's configured git credential manager
* (macOS Keychain, Windows Credential Manager, libsecret, etc.) using the
* standard `git credential approve/fill/reject` protocol.
* Tokens are stored exclusively in the OS credential manager (macOS Keychain,
* Windows Credential Manager, libsecret) via `git credential approve/fill/reject`.
* Nothing is written to disk — no metadata files, no plaintext tokens.
*
* This means:
* - Tokens are encrypted at rest by the OS
* - `git clone https://plugins.archgate.dev/archgate.git` works transparently
* (git retrieves the stored credentials automatically)
* - No custom credential helper command needed — git already knows how to do this
*
* A lightweight JSON file at ~/.archgate/credentials stores non-sensitive
* metadata (github_user, created_at) for `archgate login status` display.
* The `username` field in the git credential protocol carries the GitHub username,
* and the `password` field carries the archgate plugin token.
*
* @see https://git-scm.com/docs/git-credential
*/

import { chmodSync, unlinkSync } from "node:fs";
import { unlinkSync } from "node:fs";

import { logDebug, logWarn } from "./log";
import { internalPath, createPathIfNotExists } from "./paths";
import { internalPath } from "./paths";

const CREDENTIAL_HOST = "plugins.archgate.dev";
const METADATA_FILE = "credentials";
const CREDENTIAL_TIMEOUT_MS = 3_000;

/**
* Environment variables for git credential commands.
* - GIT_TERMINAL_PROMPT=0 → suppress terminal prompts
* - GCM_INTERACTIVE=never → suppress GUI prompts (Git Credential Manager on Windows)
*/
const GIT_CREDENTIAL_ENV = {
...Bun.env,
GIT_TERMINAL_PROMPT: "0",
GCM_INTERACTIVE: "never",
};
/** Build env for git credential commands at call time (not import time). */
function gitCredentialEnv(): Record<string, string | undefined> {
return { ...Bun.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" };
}

export interface StoredCredentials {
token: string;
github_user: string;
created_at: string;
}

// ---------------------------------------------------------------------------
// Git credential protocol helpers
// ---------------------------------------------------------------------------

/**
* Store credentials in the user's git credential manager.
* Uses `git credential approve` which writes to the configured credential.helper.
*/
function credentialInput(username?: string, password?: string): string {
const lines = ["protocol=https", `host=${CREDENTIAL_HOST}`];
if (username) lines.push(`username=${username}`);
if (password) lines.push(`password=${password}`);
lines.push("", "");
return lines.join("\n");
}

async function gitCredentialApprove(
username: string,
password: string
): Promise<boolean> {
const input = [
"protocol=https",
`host=${CREDENTIAL_HOST}`,
`username=${username}`,
`password=${password}`,
"",
"",
].join("\n");

const proc = Bun.spawn(["git", "credential", "approve"], {
stdin: new Blob([input]),
stdin: new Blob([credentialInput(username, password)]),
stdout: "pipe",
stderr: "pipe",
env: GIT_CREDENTIAL_ENV,
env: gitCredentialEnv(),
});
return (await proc.exited) === 0;
}

/** Timeout for git credential operations (3 seconds). */
const CREDENTIAL_TIMEOUT_MS = 3_000;

/**
* Retrieve credentials from the user's git credential manager.
* Uses `git credential fill` which reads from the configured credential.helper.
*
* GIT_TERMINAL_PROMPT=0 prevents git from prompting interactively.
* A timeout guard prevents hangs when the credential manager is unresponsive.
*/
async function gitCredentialFill(): Promise<{
username: string;
password: string;
} | null> {
const input = ["protocol=https", `host=${CREDENTIAL_HOST}`, "", ""].join(
"\n"
);

try {
const proc = Bun.spawn(["git", "credential", "fill"], {
stdin: new Blob([input]),
stdin: new Blob([credentialInput()]),
stdout: "pipe",
stderr: "pipe",
env: GIT_CREDENTIAL_ENV,
env: gitCredentialEnv(),
});

const result = await Promise.race([
Expand All @@ -118,148 +86,128 @@ async function gitCredentialFill(): Promise<{
if (line.startsWith("username=")) username = line.slice(9);
if (line.startsWith("password=")) password = line.slice(9);
}

return username && password ? { username, password } : null;
} catch {
return null;
}
}

/**
* Remove credentials from the user's git credential manager.
* Uses `git credential reject` which tells the configured helper to erase them.
*/
async function gitCredentialReject(
username: string,
password: string
): Promise<void> {
const input = [
"protocol=https",
`host=${CREDENTIAL_HOST}`,
`username=${username}`,
`password=${password}`,
"",
"",
].join("\n");

const proc = Bun.spawn(["git", "credential", "reject"], {
stdin: new Blob([input]),
stdin: new Blob([credentialInput(username, password)]),
stdout: "pipe",
stderr: "pipe",
env: GIT_CREDENTIAL_ENV,
env: gitCredentialEnv(),
});
await proc.exited;
}

// ---------------------------------------------------------------------------
// Metadata file (non-sensitive: github_user, created_at)
// Legacy metadata file cleanup
// ---------------------------------------------------------------------------

function metadataPath(): string {
return internalPath(METADATA_FILE);
/** Path to the legacy metadata file (~/.archgate/credentials). */
function legacyMetadataPath(): string {
return internalPath("credentials");
}

/**
* Delete the legacy ~/.archgate/credentials file if it exists.
* Returns true if a file was found and deleted.
*/
async function cleanupLegacyMetadata(): Promise<boolean> {
const file = Bun.file(legacyMetadataPath());
if (await file.exists()) {
unlinkSync(legacyMetadataPath());
logDebug("Legacy credentials metadata file removed");
return true;
}
return false;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

const CREDENTIAL_HELPER_HINT =
"Run `git config --global credential.helper` to check your configuration.";

/**
* Persist archgate credentials securely.
*
* - **Token** → git credential manager (encrypted at rest by the OS)
* - **Metadata** (github_user, created_at) → `~/.archgate/credentials`
* Persist archgate credentials in the OS credential manager.
*
* After this, `git clone https://plugins.archgate.dev/archgate.git` will
* automatically use the stored token — no credentials in the URL needed.
* A verification round-trip (`git credential fill`) confirms the token was
* actually persisted — `git credential approve` exits 0 even without a
* configured helper, silently storing nothing.
*/
export async function saveCredentials(
credentials: StoredCredentials
): Promise<void> {
// Store token in git credential manager
// Clean up any legacy metadata file from previous versions.
await cleanupLegacyMetadata();

const stored = await gitCredentialApprove(
credentials.github_user,
credentials.token
);

if (stored) {
logDebug("Token stored in git credential manager");
const verified = await gitCredentialFill();
if (verified) {
logDebug("Token verified in git credential manager");
} else {
logWarn(
"Token could not be verified in git credential manager.",
"Your credential helper may not persist credentials.",
CREDENTIAL_HELPER_HINT,
"Without a working credential helper, you will need to re-login after each session."
);
}
} else {
logDebug("git credential approve failed — token may not be persisted");
}

// Store metadata in ~/.archgate/credentials (for `login status` display).
// DEPRECATED: The token is also written to this file as a fallback for systems
// without a git credential manager. In the next major version, only metadata
// (github_user, created_at) will be written — the token field will be removed.
createPathIfNotExists(internalPath());
const filePath = metadataPath();
await Bun.write(filePath, JSON.stringify(credentials, null, 2) + "\n");
try {
chmodSync(filePath, 0o600);
} catch {
// chmod may fail on Windows — NTFS uses ACLs instead
logWarn(
"git credential approve failed.",
"Your git credential helper may not be configured.",
CREDENTIAL_HELPER_HINT
);
}
logDebug("Credentials metadata saved to", filePath);
}

/**
* Load stored archgate credentials, or null if none exist.
* Load stored archgate credentials from the OS credential manager.
* Returns null if no credentials are stored.
*
* Reads the token from git's credential manager first, falling back to
* the plaintext file for legacy installs.
* If a legacy ~/.archgate/credentials file exists, it is deleted and
* the user is asked to re-login.
*/
export async function loadCredentials(): Promise<StoredCredentials | null> {
const file = Bun.file(metadataPath());
if (!(await file.exists())) {
return null;
}

let data: StoredCredentials;
try {
data = (await file.json()) as StoredCredentials;
if (!data.github_user) return null;
} catch {
logDebug("Failed to parse credentials file");
// Delete legacy metadata file — force re-login for a clean slate.
const hadLegacy = await cleanupLegacyMetadata();
if (hadLegacy) {
logWarn(
"Legacy credentials file removed.",
"Run `archgate login` to re-authenticate."
);
return null;
}

// Try to load token from git credential manager
const gitCreds = await gitCredentialFill();
if (gitCreds) {
return {
token: gitCreds.password,
github_user: gitCreds.username,
created_at: data.created_at,
};
return { token: gitCreds.password, github_user: gitCreds.username };
}

// DEPRECATED: Fall back to token in the plaintext file (legacy storage).
// This fallback will be removed in the next major version. Users on systems
// without a git credential manager should run `archgate login refresh` to
// migrate their token to the credential manager.
if (!data.token) return null;
logWarn(
"Token loaded from plaintext file (deprecated).",
"Run `archgate login refresh` to migrate to secure credential storage."
);
return data;
return null;
}

/**
* Remove stored credentials (logout).
*
* Clears both the git credential manager and the metadata file.
* Clears the OS credential manager and any legacy metadata file.
*/
export async function clearCredentials(): Promise<void> {
// Remove from git credential manager (need current credentials to reject)
const gitCreds = await gitCredentialFill();
if (gitCreds) {
await gitCredentialReject(gitCreds.username, gitCreds.password);
logDebug("Token removed from git credential manager");
}

// Remove metadata file
if (await Bun.file(metadataPath()).exists()) {
unlinkSync(metadataPath());
logDebug("Credentials file removed");
}
await cleanupLegacyMetadata();
}
6 changes: 1 addition & 5 deletions src/helpers/login-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,7 @@ export async function runLoginFlow(
archgateToken = result;
}

await saveCredentials({
token: archgateToken,
github_user: githubUser,
created_at: new Date().toISOString().split("T")[0],
});
await saveCredentials({ token: archgateToken, github_user: githubUser });

logInfo(
`Authenticated as ${styleText("bold", githubUser)}. Plugin access is now available.`
Expand Down
Loading
Loading