diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 12770e5c..35a7105a 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -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 = ` 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 diff --git a/package.json b/package.json index e0edee47..ecfe52cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/login.ts b/src/commands/login.ts index dd4b0aa3..7c7eb02f 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -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."); } diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index abcbf637..597ab590 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -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 { + 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 { - 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([ @@ -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 { - 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 { + 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 { - // 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 { - 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 { - // 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(); } diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 900d33a1..1d703d18 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -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.` diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts index bf35c3a7..a56da959 100644 --- a/tests/helpers/auth.test.ts +++ b/tests/helpers/auth.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,91 +12,90 @@ describe("auth", () => { let tempDir: string; let originalHome: string | undefined; let originalUserProfile: string | undefined; + let originalGitConfigNoSystem: string | undefined; + let originalGitConfigGlobal: string | undefined; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-auth-test-")); originalHome = Bun.env.HOME; originalUserProfile = Bun.env.USERPROFILE; + originalGitConfigNoSystem = Bun.env.GIT_CONFIG_NOSYSTEM; + originalGitConfigGlobal = Bun.env.GIT_CONFIG_GLOBAL; Bun.env.HOME = tempDir; Bun.env.USERPROFILE = tempDir; + // Isolate git credential operations from the system credential store. + Bun.env.GIT_CONFIG_NOSYSTEM = "1"; + const emptyGitConfig = join(tempDir, ".gitconfig"); + writeFileSync(emptyGitConfig, ""); + Bun.env.GIT_CONFIG_GLOBAL = emptyGitConfig; }); afterEach(() => { Bun.env.HOME = originalHome; Bun.env.USERPROFILE = originalUserProfile; + Bun.env.GIT_CONFIG_NOSYSTEM = originalGitConfigNoSystem; + Bun.env.GIT_CONFIG_GLOBAL = originalGitConfigGlobal; rmSync(tempDir, { recursive: true, force: true }); }); describe("saveCredentials / loadCredentials", () => { - test("round-trips credentials to ~/.archgate/credentials", async () => { - const { saveCredentials, loadCredentials } = + test("does not write any file to disk", async () => { + const { saveCredentials } = await import("../../src/helpers/credential-store"); await saveCredentials({ token: "ag_beta_abc123", github_user: "testuser", - created_at: "2026-01-15", }); - const loaded = await loadCredentials(); - expect(loaded).not.toBeNull(); - expect(loaded!.token).toBe("ag_beta_abc123"); - expect(loaded!.github_user).toBe("testuser"); - expect(loaded!.created_at).toBe("2026-01-15"); - }); - - test("returns null when no credentials file exists", async () => { - const { loadCredentials } = - await import("../../src/helpers/credential-store"); - - const result = await loadCredentials(); - expect(result).toBeNull(); + // No credentials file should exist — everything is in git credential manager. + const credPath = join(tempDir, ".archgate", "credentials"); + expect(await Bun.file(credPath).exists()).toBe(false); }); - test("returns null when credentials file is invalid JSON", async () => { + test("returns null when no credentials exist anywhere", async () => { const { loadCredentials } = await import("../../src/helpers/credential-store"); - const credPath = join(tempDir, ".archgate", "credentials"); - const { mkdirSync } = await import("node:fs"); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - await Bun.write(credPath, "not-json"); - const result = await loadCredentials(); expect(result).toBeNull(); }); - test("returns null when credentials file is missing required fields", async () => { + test("returns null and deletes legacy credentials file", async () => { const { loadCredentials } = await import("../../src/helpers/credential-store"); const credPath = join(tempDir, ".archgate", "credentials"); const { mkdirSync } = await import("node:fs"); mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - await Bun.write(credPath, JSON.stringify({ token: "abc" })); + await Bun.write( + credPath, + JSON.stringify({ token: "abc", github_user: "old" }) + ); const result = await loadCredentials(); expect(result).toBeNull(); + expect(await Bun.file(credPath).exists()).toBe(false); }); }); describe("clearCredentials", () => { - test("removes credentials file", async () => { - const { saveCredentials, clearCredentials, loadCredentials } = + test("clears git creds and removes legacy file", async () => { + const { clearCredentials } = await import("../../src/helpers/credential-store"); - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", - created_at: "2026-01-15", - }); + // Create a legacy file + const { mkdirSync } = await import("node:fs"); + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + const credPath = join(tempDir, ".archgate", "credentials"); + await Bun.write(credPath, "{}"); await clearCredentials(); - const loaded = await loadCredentials(); - expect(loaded).toBeNull(); + + expect(await Bun.file(credPath).exists()).toBe(false); }); - test("does not throw when no credentials file exists", async () => { + test("does not throw when no credentials exist", async () => { const { clearCredentials } = await import("../../src/helpers/credential-store"); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 4b46c9ba..50a7e61a 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -1,130 +1,140 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; describe("credential-store", () => { let tempDir: string; let originalHome: string | undefined; + let originalGitConfigNoSystem: string | undefined; + let originalGitConfigGlobal: string | undefined; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-credstore-test-")); originalHome = Bun.env.HOME; + originalGitConfigNoSystem = Bun.env.GIT_CONFIG_NOSYSTEM; + originalGitConfigGlobal = Bun.env.GIT_CONFIG_GLOBAL; Bun.env.HOME = tempDir; + // Isolate git credential operations from the system credential store. + Bun.env.GIT_CONFIG_NOSYSTEM = "1"; + const emptyGitConfig = join(tempDir, ".gitconfig"); + writeFileSync(emptyGitConfig, ""); + Bun.env.GIT_CONFIG_GLOBAL = emptyGitConfig; }); afterEach(() => { Bun.env.HOME = originalHome; + Bun.env.GIT_CONFIG_NOSYSTEM = originalGitConfigNoSystem; + Bun.env.GIT_CONFIG_GLOBAL = originalGitConfigGlobal; rmSync(tempDir, { recursive: true, force: true }); }); describe("saveCredentials", () => { - test("writes metadata file to ~/.archgate/credentials", async () => { + test("does not write any metadata file to disk", async () => { const { saveCredentials } = await import("../../src/helpers/credential-store"); await saveCredentials({ token: "ag_beta_abc123", github_user: "testuser", - created_at: "2026-01-15", }); + // No credentials file should be written — everything is in git credential manager. 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"); + expect(await Bun.file(credPath).exists()).toBe(false); }); - }); - describe("loadCredentials", () => { - test("returns null when no credentials file exists", async () => { - const { loadCredentials } = + test("cleans up legacy metadata file on save", async () => { + const { saveCredentials } = await import("../../src/helpers/credential-store"); - const result = await loadCredentials(); - expect(result).toBeNull(); + // Create a legacy metadata file + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + const credPath = join(tempDir, ".archgate", "credentials"); + await Bun.write( + credPath, + JSON.stringify({ github_user: "old", created_at: "2025-01-01" }) + ); + + await saveCredentials({ + token: "ag_beta_abc123", + github_user: "testuser", + }); + + // Legacy file should be removed. + expect(await Bun.file(credPath).exists()).toBe(false); }); + }); - test("returns null when credentials file is invalid JSON", async () => { + describe("loadCredentials", () => { + test("returns null when no credentials exist anywhere", 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 () => { + test("returns null and deletes legacy metadata file", async () => { const { loadCredentials } = await import("../../src/helpers/credential-store"); + const credPath = join(tempDir, ".archgate", "credentials"); mkdirSync(join(tempDir, ".archgate"), { recursive: true }); await Bun.write( - join(tempDir, ".archgate", "credentials"), - JSON.stringify({ token: "abc", created_at: "2026-01-01" }) + credPath, + JSON.stringify({ + token: "ag_beta_legacy", + github_user: "testuser", + created_at: "2026-01-15", + }) ); + // Legacy file triggers deletion and returns null (re-login required). const result = await loadCredentials(); expect(result).toBeNull(); + expect(await Bun.file(credPath).exists()).toBe(false); }); - test("falls back to token in file when git credential fill fails", async () => { + test("returns null when no git creds and no legacy file", 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", - }) - ); - + // With isolated git config (no credential helper), returns null. 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(); + expect(result).toBeNull(); }); }); describe("clearCredentials", () => { - test("removes metadata file", async () => { - const { saveCredentials, clearCredentials } = + test("does not throw when no credentials exist", async () => { + const { clearCredentials } = await import("../../src/helpers/credential-store"); - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", - created_at: "2026-01-15", - }); - + // Should not throw 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 () => { + test("cleans up legacy metadata file", async () => { const { clearCredentials } = await import("../../src/helpers/credential-store"); - // Should not throw + mkdirSync(join(tempDir, ".archgate"), { recursive: true }); + const credPath = join(tempDir, ".archgate", "credentials"); + await Bun.write( + credPath, + JSON.stringify({ github_user: "testuser", created_at: "2026-01-15" }) + ); + await clearCredentials(); + + expect(await Bun.file(credPath).exists()).toBe(false); }); }); 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/integration/init.test.ts b/tests/integration/init.test.ts index 5098aa17..2cebe76c 100644 --- a/tests/integration/init.test.ts +++ b/tests/integration/init.test.ts @@ -8,7 +8,7 @@ import { runCli, createTempProject } from "./cli-harness"; let tempDir: string; let fakeHome: string; -/** Env overrides to isolate from real credentials in ~/.archgate/credentials */ +/** Env overrides to isolate from real ~/.archgate/ state */ function isolatedEnv(): Record { return { HOME: fakeHome, USERPROFILE: fakeHome }; }