diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.md b/.archgate/adrs/ARCH-004-no-barrel-files.md index 9a3b8250..49ef8f01 100644 --- a/.archgate/adrs/ARCH-004-no-barrel-files.md +++ b/.archgate/adrs/ARCH-004-no-barrel-files.md @@ -1,12 +1,12 @@ --- id: ARCH-004 -title: No Barrel Files +title: No Barrel Files or Re-Exports domain: architecture rules: true files: ["src/**/*.ts"] --- -# No Barrel Files +# No Barrel Files or Re-Exports ## Context @@ -30,7 +30,7 @@ This decision aligns with [ARCH-001 — Command Structure](./ARCH-001-command-st ## Decision -**Barrel files are forbidden.** All imports MUST point directly to the module that defines the symbol. +**Barrel files and re-exports are forbidden.** All imports MUST point directly to the module that defines the symbol. This ADR covers all TypeScript source files under `src/`. It does not cover test files or configuration files. @@ -39,6 +39,8 @@ A barrel file is defined as an `index.ts` file that: - Contains **only** `export`, `export type`, or `import type` statements (re-exports) - Has **no** function definitions, class definitions, variable declarations, or executable logic +A **re-export** is any `export { X } from "./other-module"` or `export type { X } from "./other-module"` statement in any file (not just `index.ts`). Re-exports create the same indirection problems as barrel files: hidden coupling, grep-unfriendly navigation, and obscured dependency graphs. + Files named `index.ts` that contain actual logic are **not** barrel files and are permitted. Examples of permitted `index.ts` files: - `src/commands/adr/index.ts` — defines `registerAdrCommand()` with command group composition logic @@ -57,10 +59,12 @@ Files named `index.ts` that contain actual logic are **not** barrel files and ar ### Don't - **DON'T** create `index.ts` files that only re-export symbols from sibling modules +- **DON'T** re-export symbols from other modules via `export { X } from "./other"` in any file — consumers must import directly from the defining module - **DON'T** import from a directory path (e.g., `from "../formats"`) expecting implicit `index.ts` resolution - **DON'T** use barrel files as a "public API" facade — this project has no external module consumers - **DON'T** add re-export-only statements to an otherwise legitimate `index.ts` — keep composition logic and re-exports separate - **DON'T** create `index.ts` files to "simplify" imports — the verbosity of direct imports is the feature, not a problem +- **DON'T** use a module as a "facade" that re-exports from multiple sources — each import should point to exactly one defining module ## Implementation Pattern diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8c19cd39..92ee3f9e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,11 +1,12 @@ { - "agent": "archgate:developer", "permissions": { - "defaultMode": "bypassPermissions", "allow": [ "Skill(archgate:architect)", "Skill(archgate:quality-manager)", - "Skill(archgate:adr-author)" - ] - } + "Skill(archgate:adr-author)", + "WebSearch" + ], + "defaultMode": "bypassPermissions" + }, + "agent": "archgate:developer" } diff --git a/src/commands/init.ts b/src/commands/init.ts index 57713129..915ed3dc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -4,7 +4,7 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; import inquirer from "inquirer"; -import { loadCredentials } from "../helpers/auth"; +import { loadCredentials } from "../helpers/credential-store"; import { EDITOR_LABELS, initProject } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; import { logError, logInfo, logWarn } from "../helpers/log"; diff --git a/src/commands/login.ts b/src/commands/login.ts index 5af1e0d4..dd4b0aa3 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -2,7 +2,7 @@ import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; -import { loadCredentials, clearCredentials } from "../helpers/auth"; +import { loadCredentials, clearCredentials } from "../helpers/credential-store"; import { logError, logInfo } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; diff --git a/src/commands/plugin/install.ts b/src/commands/plugin/install.ts index a93b50a2..d7a14a03 100644 --- a/src/commands/plugin/install.ts +++ b/src/commands/plugin/install.ts @@ -3,7 +3,7 @@ import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; -import { loadCredentials } from "../../helpers/auth"; +import { loadCredentials } from "../../helpers/credential-store"; import { EDITOR_LABELS } from "../../helpers/init-project"; import { logError, logInfo, logWarn } from "../../helpers/log"; import { findProjectRoot } from "../../helpers/paths"; @@ -43,10 +43,10 @@ export function registerPluginInstallCommand(plugin: Command) { switch (opts.editor) { case "claude": { if (await isClaudeCliAvailable()) { - await installClaudePlugin(credentials); + await installClaudePlugin(); logInfo(`Archgate plugin installed for ${label}.`); } else { - const url = buildMarketplaceUrl(credentials); + const url = buildMarketplaceUrl(); logWarn( "Claude CLI not found. To install the plugin manually, run:" ); @@ -62,10 +62,10 @@ export function registerPluginInstallCommand(plugin: Command) { case "copilot": { if (await isCopilotCliAvailable()) { - await installCopilotPlugin(credentials); + await installCopilotPlugin(); logInfo(`Archgate plugin installed for ${label}.`); } else { - const url = buildMarketplaceUrl(credentials); + const url = buildMarketplaceUrl(); logWarn( "Copilot CLI not found. To install the plugin manually, run:" ); @@ -90,7 +90,7 @@ export function registerPluginInstallCommand(plugin: Command) { } case "vscode": { - const url = buildVscodeMarketplaceUrl(credentials); + const url = buildVscodeMarketplaceUrl(); await configureVscodeSettings( findProjectRoot() ?? process.cwd(), url diff --git a/src/commands/plugin/url.ts b/src/commands/plugin/url.ts index b381fc13..dab8cb85 100644 --- a/src/commands/plugin/url.ts +++ b/src/commands/plugin/url.ts @@ -1,7 +1,6 @@ import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; -import { loadCredentials } from "../../helpers/auth"; import { logError } from "../../helpers/log"; import { buildMarketplaceUrl, @@ -15,25 +14,14 @@ const editorOption = new Option("--editor ", "target editor") export function registerPluginUrlCommand(plugin: Command) { plugin .command("url") - .description( - "Print the authenticated plugin repository URL for manual configuration" - ) + .description("Print the plugin repository URL for manual configuration") .addOption(editorOption) - .action(async (opts) => { + .action((opts) => { try { - const credentials = await loadCredentials(); - if (!credentials) { - logError( - "Not logged in.", - "Run `archgate login` first to authenticate." - ); - process.exit(1); - } - const url = opts.editor === "vscode" - ? buildVscodeMarketplaceUrl(credentials) - : buildMarketplaceUrl(credentials); + ? buildVscodeMarketplaceUrl() + : buildMarketplaceUrl(); console.log(url); } catch (err) { diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index a1ed845c..9ab041b2 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -1,14 +1,11 @@ /** * 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 delegated to credential-store.ts which uses git's native + * credential helpers (macOS Keychain, Windows Credential Manager, libsecret). */ -import { chmodSync, unlinkSync } from "node:fs"; - -import { logDebug } from "./log"; -import { internalPath, createPathIfNotExists } from "./paths"; import { SignupRequiredError, isSignupRequiredError } from "./signup"; // --------------------------------------------------------------------------- @@ -18,7 +15,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,12 +55,6 @@ type DeviceTokenResponse = | DeviceTokenPendingResponse | DeviceTokenErrorResponse; -export interface StoredCredentials { - token: string; - github_user: string; - created_at: string; -} - // --------------------------------------------------------------------------- // GitHub Device Flow // --------------------------------------------------------------------------- @@ -222,62 +212,3 @@ export async function claimArchgateToken(githubToken: string): Promise { } return data.token; } - -// 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"); - } -} diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts new file mode 100644 index 00000000..3a562231 --- /dev/null +++ b/src/helpers/credential-store.ts @@ -0,0 +1,255 @@ +/** + * 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. + * + * 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. + * + * @see https://git-scm.com/docs/git-credential + */ + +import { chmodSync, unlinkSync } from "node:fs"; + +import { logDebug } from "./log"; +import { internalPath, createPathIfNotExists } from "./paths"; + +const CREDENTIAL_HOST = "plugins.archgate.dev"; +const METADATA_FILE = "credentials"; + +/** + * 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", +}; + +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. + */ +async function gitCredentialApprove( + username: string, + password: string +): Promise { + const input = [ + "protocol=https", + `host=${CREDENTIAL_HOST}`, + `username=${username}`, + `password=${password}`, + "", + "", + ].join("\n"); + + const proc = Bun.spawn(["git", "credential", "approve"], { + stdin: new Blob([input]), + stdout: "pipe", + stderr: "pipe", + env: GIT_CREDENTIAL_ENV, + }); + 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]), + stdout: "pipe", + stderr: "pipe", + env: GIT_CREDENTIAL_ENV, + }); + + const result = await Promise.race([ + (async () => { + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return { stdout, exitCode }; + })(), + Bun.sleep(CREDENTIAL_TIMEOUT_MS).then(() => { + proc.kill(); + return null; + }), + ]); + + if (!result || result.exitCode !== 0) return null; + + let username = ""; + let password = ""; + for (const line of result.stdout.split("\n")) { + 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 { + const input = [ + "protocol=https", + `host=${CREDENTIAL_HOST}`, + `username=${username}`, + `password=${password}`, + "", + "", + ].join("\n"); + + const proc = Bun.spawn(["git", "credential", "reject"], { + stdin: new Blob([input]), + stdout: "pipe", + stderr: "pipe", + env: GIT_CREDENTIAL_ENV, + }); + await proc.exited; +} + +// --------------------------------------------------------------------------- +// Metadata file (non-sensitive: github_user, created_at) +// --------------------------------------------------------------------------- + +function metadataPath(): string { + return internalPath(METADATA_FILE); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Persist archgate credentials securely. + * + * - **Token** → git credential manager (encrypted at rest by the OS) + * - **Metadata** (github_user, created_at) → `~/.archgate/credentials` + * + * After this, `git clone https://plugins.archgate.dev/archgate.git` will + * automatically use the stored token — no credentials in the URL needed. + */ +export async function saveCredentials( + credentials: StoredCredentials +): Promise { + // Store token in git credential manager + const stored = await gitCredentialApprove( + credentials.github_user, + credentials.token + ); + if (stored) { + logDebug("Token stored in git credential manager"); + } else { + logDebug("git credential approve failed — token may not be persisted"); + } + + // Store metadata in ~/.archgate/credentials (for `login status` display) + 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 + } + logDebug("Credentials metadata saved to", filePath); +} + +/** + * Load stored archgate credentials, or null if none exist. + * + * Reads the token from git's credential manager first, falling back to + * the plaintext file for legacy installs. + */ +export async function loadCredentials(): Promise { + 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"); + 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, + }; + } + + // Fall back to token in the file (legacy plaintext storage) + if (!data.token) return null; + return data; +} + +/** + * Remove stored credentials (logout). + * + * Clears both the git credential manager and the metadata file. + */ +export async function clearCredentials(): Promise { + // 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"); + } +} diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 2cac1e94..8d52ae5d 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -135,11 +135,11 @@ async function configureEditorSettings( case "cursor": return configureCursorSettings(projectRoot); case "vscode": { - // VS Code: marketplace URL to user settings if logged in - const { loadCredentials } = await import("./auth"); + // VS Code: marketplace URL to user settings (credentials provided by git credential manager) + const { loadCredentials } = await import("./credential-store"); const creds = await loadCredentials(); const marketplaceUrl = creds - ? (await import("./plugin-install")).buildVscodeMarketplaceUrl(creds) + ? (await import("./plugin-install")).buildVscodeMarketplaceUrl() : undefined; return configureVscodeSettings(projectRoot, marketplaceUrl); } @@ -230,7 +230,7 @@ async function tryInstallPlugin( projectRoot: string, editor: EditorTarget ): Promise { - const { loadCredentials } = await import("./auth"); + const { loadCredentials } = await import("./credential-store"); const credentials = await loadCredentials(); if (!credentials) { return { installed: false }; @@ -262,14 +262,14 @@ async function tryInstallPlugin( if (await isCopilotCliAvailable()) { try { - await installCopilotPlugin(credentials); + await installCopilotPlugin(); return { installed: true, autoInstalled: true }; } catch { // Fall through to manual instructions } } - const url = buildMarketplaceUrl(credentials); + const url = buildMarketplaceUrl(); return { installed: true, detail: url }; } @@ -279,13 +279,13 @@ async function tryInstallPlugin( if (await isClaudeCliAvailable()) { try { - await installClaudePlugin(credentials); + await installClaudePlugin(); return { installed: true, autoInstalled: true }; } catch { // Fall through to manual instructions } } - const url = buildMarketplaceUrl(credentials); + const url = buildMarketplaceUrl(); return { installed: true, detail: url }; } diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 0d9e2b0d..900d33a1 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -12,8 +12,8 @@ import { pollForAccessToken, getGitHubUser, claimArchgateToken, - saveCredentials, } from "./auth"; +import { saveCredentials } from "./credential-store"; import { logError, logInfo } from "./log"; import { SignupRequiredError, requestSignup } from "./signup"; diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index c99ee7ad..b4df6d3c 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -10,12 +10,17 @@ import { mkdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import type { StoredCredentials } from "./auth"; import { logDebug } from "./log"; import { resolveCommand } from "./platform"; const PLUGINS_API = "https://plugins.archgate.dev"; +/** Base marketplace URL — credentials are provided by the git credential manager. */ +const MARKETPLACE_URL = "https://plugins.archgate.dev/archgate.git"; +/** Base VS Code marketplace URL — credentials are provided by the git credential manager. */ +const VSCODE_MARKETPLACE_URL = + "https://plugins.archgate.dev/archgate-vscode.git"; + /** * Run a command using Bun.spawn (cross-platform, no shell). * Returns { exitCode, stdout }. @@ -39,25 +44,19 @@ async function run( // --------------------------------------------------------------------------- /** - * Build the authenticated git marketplace URL for Claude Code & Copilot CLI plugin installation. - * Claude Code and Copilot CLI both use the .claude-plugin/ manifest format. + * Get the marketplace URL for Claude Code & Copilot CLI plugin installation. + * Credentials are provided by the git credential manager (no tokens in URLs). */ -export function buildMarketplaceUrl(credentials: StoredCredentials): string { - const user = encodeURIComponent(credentials.github_user); - const token = encodeURIComponent(credentials.token); - return `https://${user}:${token}@plugins.archgate.dev/archgate.git`; +export function buildMarketplaceUrl(): string { + return MARKETPLACE_URL; } /** - * Build the authenticated git marketplace URL for VS Code plugin installation. - * VS Code Copilot uses the .github/plugin/ manifest format, served from a separate repo. + * Get the marketplace URL for VS Code plugin installation. + * Credentials are provided by the git credential manager (no tokens in URLs). */ -export function buildVscodeMarketplaceUrl( - credentials: StoredCredentials -): string { - const user = encodeURIComponent(credentials.github_user); - const token = encodeURIComponent(credentials.token); - return `https://${user}:${token}@plugins.archgate.dev/archgate-vscode.git`; +export function buildVscodeMarketplaceUrl(): string { + return VSCODE_MARKETPLACE_URL; } /** @@ -78,10 +77,8 @@ export async function isClaudeCliAvailable(): Promise { * * Throws on failure so the caller can fall back to manual instructions. */ -export async function installClaudePlugin( - credentials: StoredCredentials -): Promise { - const url = buildMarketplaceUrl(credentials); +export async function installClaudePlugin(): Promise { + const url = buildMarketplaceUrl(); const cmd = (await resolveCommand("claude")) ?? "claude"; logDebug("Adding archgate marketplace to claude CLI"); @@ -171,10 +168,8 @@ export async function isCopilotCliAvailable(): Promise { * * Throws on failure so the caller can fall back to manual instructions. */ -export async function installCopilotPlugin( - credentials: StoredCredentials -): Promise { - const url = buildMarketplaceUrl(credentials); +export async function installCopilotPlugin(): Promise { + const url = buildMarketplaceUrl(); const cmd = (await resolveCommand("copilot")) ?? "copilot"; logDebug("Installing archgate plugin via copilot CLI"); diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts index f7e242bc..72b9467e 100644 --- a/tests/helpers/auth.test.ts +++ b/tests/helpers/auth.test.ts @@ -26,7 +26,7 @@ describe("auth", () => { describe("saveCredentials / loadCredentials", () => { test("round-trips credentials to ~/.archgate/credentials", async () => { const { saveCredentials, loadCredentials } = - await import("../../src/helpers/auth"); + await import("../../src/helpers/credential-store"); await saveCredentials({ token: "ag_beta_abc123", @@ -42,14 +42,16 @@ describe("auth", () => { }); test("returns null when no credentials file exists", async () => { - const { loadCredentials } = await import("../../src/helpers/auth"); + const { loadCredentials } = + await import("../../src/helpers/credential-store"); const result = await loadCredentials(); expect(result).toBeNull(); }); test("returns null when credentials file is invalid JSON", async () => { - const { loadCredentials } = await import("../../src/helpers/auth"); + const { loadCredentials } = + await import("../../src/helpers/credential-store"); const credPath = join(tempDir, ".archgate", "credentials"); const { mkdirSync } = await import("node:fs"); @@ -61,7 +63,8 @@ describe("auth", () => { }); test("returns null when credentials file is missing required fields", async () => { - const { loadCredentials } = await import("../../src/helpers/auth"); + const { loadCredentials } = + await import("../../src/helpers/credential-store"); const credPath = join(tempDir, ".archgate", "credentials"); const { mkdirSync } = await import("node:fs"); @@ -76,7 +79,7 @@ describe("auth", () => { describe("clearCredentials", () => { test("removes credentials file", async () => { const { saveCredentials, clearCredentials, loadCredentials } = - await import("../../src/helpers/auth"); + await import("../../src/helpers/credential-store"); await saveCredentials({ token: "ag_beta_abc123", @@ -90,7 +93,8 @@ describe("auth", () => { }); test("does not throw when no credentials file exists", async () => { - const { clearCredentials } = await import("../../src/helpers/auth"); + const { clearCredentials } = + await import("../../src/helpers/credential-store"); // Should not throw await clearCredentials(); @@ -212,8 +216,8 @@ describe("auth", () => { }); test("throws SignupRequiredError on 403 with no approved signup", async () => { - const { claimArchgateToken, SignupRequiredError } = - await import("../../src/helpers/auth"); + const { claimArchgateToken } = await import("../../src/helpers/auth"); + const { SignupRequiredError } = await import("../../src/helpers/signup"); const originalFetch = globalThis.fetch; mockFetch(() => diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts new file mode 100644 index 00000000..4b46c9ba --- /dev/null +++ b/tests/helpers/credential-store.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("credential-store", () => { + let tempDir: string; + let originalHome: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-credstore-test-")); + originalHome = Bun.env.HOME; + Bun.env.HOME = tempDir; + }); + + afterEach(() => { + Bun.env.HOME = originalHome; + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("saveCredentials", () => { + test("writes metadata file to ~/.archgate/credentials", async () => { + const { saveCredentials } = + await import("../../src/helpers/credential-store"); + + await saveCredentials({ + token: "ag_beta_abc123", + github_user: "testuser", + created_at: "2026-01-15", + }); + + const credPath = join(tempDir, ".archgate", "credentials"); + const file = Bun.file(credPath); + expect(await file.exists()).toBe(true); + + const data = await file.json(); + expect(data.github_user).toBe("testuser"); + expect(data.created_at).toBe("2026-01-15"); + }); + }); + + describe("loadCredentials", () => { + test("returns null when no credentials file exists", async () => { + const { loadCredentials } = + await import("../../src/helpers/credential-store"); + + const result = await loadCredentials(); + expect(result).toBeNull(); + }); + + test("returns null when credentials file is invalid JSON", async () => { + const { loadCredentials } = + await import("../../src/helpers/credential-store"); + + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + await Bun.write(join(tempDir, ".archgate", "credentials"), "not-json"); + + const result = await loadCredentials(); + expect(result).toBeNull(); + }); + + test("returns null when github_user is missing", async () => { + const { loadCredentials } = + await import("../../src/helpers/credential-store"); + + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + await Bun.write( + join(tempDir, ".archgate", "credentials"), + JSON.stringify({ token: "abc", created_at: "2026-01-01" }) + ); + + const result = await loadCredentials(); + expect(result).toBeNull(); + }); + + test("falls back to token in file when git credential fill fails", async () => { + const { loadCredentials } = + await import("../../src/helpers/credential-store"); + + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + await Bun.write( + join(tempDir, ".archgate", "credentials"), + JSON.stringify({ + token: "ag_beta_fallback", + github_user: "testuser", + created_at: "2026-01-15", + }) + ); + + const result = await loadCredentials(); + expect(result).not.toBeNull(); + expect(result!.github_user).toBe("testuser"); + // Token comes from either git credential manager or file fallback + expect(result!.token).toBeTruthy(); + }); + }); + + describe("clearCredentials", () => { + test("removes metadata file", async () => { + const { saveCredentials, clearCredentials } = + await import("../../src/helpers/credential-store"); + + await saveCredentials({ + token: "ag_beta_abc123", + github_user: "testuser", + created_at: "2026-01-15", + }); + + await clearCredentials(); + + const credPath = join(tempDir, ".archgate", "credentials"); + expect(await Bun.file(credPath).exists()).toBe(false); + }); + + test("does not throw when no credentials exist", async () => { + const { clearCredentials } = + await import("../../src/helpers/credential-store"); + + // Should not throw + await clearCredentials(); + }); + }); + + describe("StoredCredentials type", () => { + test("interface has expected shape", async () => { + const mod = await import("../../src/helpers/credential-store"); + // Verify the module exports the expected functions + expect(typeof mod.saveCredentials).toBe("function"); + expect(typeof mod.loadCredentials).toBe("function"); + expect(typeof mod.clearCredentials).toBe("function"); + }); + }); +}); diff --git a/tests/helpers/plugin-install.test.ts b/tests/helpers/plugin-install.test.ts index 24d66bed..02405863 100644 --- a/tests/helpers/plugin-install.test.ts +++ b/tests/helpers/plugin-install.test.ts @@ -9,61 +9,31 @@ import { describe("plugin-install", () => { describe("buildMarketplaceUrl", () => { - test("builds URL with credentials embedded", () => { - const url = buildMarketplaceUrl({ - token: "ag_beta_abc123def456", - github_user: "octocat", - created_at: "2026-01-15", - }); - expect(url).toBe( - "https://octocat:ag_beta_abc123def456@plugins.archgate.dev/archgate.git" - ); + test("returns bare URL without embedded credentials", () => { + const url = buildMarketplaceUrl(); + expect(url).toBe("https://plugins.archgate.dev/archgate.git"); }); - test("uses github_user as the URL username", () => { - const url = buildMarketplaceUrl({ - token: "ag_beta_token", - github_user: "my-handle", - created_at: "2026-02-01", - }); - expect(url).toContain("my-handle:"); - expect(url).toContain(":ag_beta_token@"); + test("does not contain @ (no embedded credentials)", () => { + const url = buildMarketplaceUrl(); + expect(url).not.toContain("@"); }); }); describe("buildVscodeMarketplaceUrl", () => { - test("builds URL pointing to archgate-vscode.git", () => { - const url = buildVscodeMarketplaceUrl({ - token: "ag_beta_abc123def456", - github_user: "octocat", - created_at: "2026-01-15", - }); - expect(url).toBe( - "https://octocat:ag_beta_abc123def456@plugins.archgate.dev/archgate-vscode.git" - ); + test("returns bare URL pointing to archgate-vscode.git", () => { + const url = buildVscodeMarketplaceUrl(); + expect(url).toBe("https://plugins.archgate.dev/archgate-vscode.git"); }); - test("embeds credentials correctly", () => { - const url = buildVscodeMarketplaceUrl({ - token: "ag_beta_token", - github_user: "my-handle", - created_at: "2026-02-01", - }); - expect(url).toContain("my-handle:"); - expect(url).toContain(":ag_beta_token@"); + test("does not contain @ (no embedded credentials)", () => { + const url = buildVscodeMarketplaceUrl(); + expect(url).not.toContain("@"); }); test("uses archgate-vscode.git repo (not archgate.git)", () => { - const vscodeUrl = buildVscodeMarketplaceUrl({ - token: "tok", - github_user: "user", - created_at: "2026-01-01", - }); - const claudeUrl = buildMarketplaceUrl({ - token: "tok", - github_user: "user", - created_at: "2026-01-01", - }); + const vscodeUrl = buildVscodeMarketplaceUrl(); + const claudeUrl = buildMarketplaceUrl(); expect(vscodeUrl).toContain("archgate-vscode.git"); expect(claudeUrl).not.toContain("archgate-vscode.git"); expect(claudeUrl).toContain("archgate.git");