diff --git a/.env.example b/.env.example index 7dad95cb..6674ce20 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ # CLERK_PLATFORM_API_URL=https://api.clerk.com # ── Config / credentials storage ───────────────────────────────────────────── -# CLERK_CONFIG_DIR=~/.clerk +# CLERK_CONFIG_DIR=/path/to/custom/dir # ── Auth callback ──────────────────────────────────────────────────────────── # CLERK_AUTH_TIMEOUT_MS=120000 diff --git a/bun.lock b/bun.lock index 7b61e54b..6a7d86ba 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,9 @@ "name": "marseille", "dependencies": { "@inquirer/prompts": "^8.2.0", + "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.3", + "env-paths": "^4.0.0", "yaml": "^2.8.2", }, "devDependencies": { @@ -53,6 +55,32 @@ "@inquirer/type": ["@inquirer/type@4.0.3", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw=="], + "@napi-rs/keyring": ["@napi-rs/keyring@1.2.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.2.0", "@napi-rs/keyring-darwin-x64": "1.2.0", "@napi-rs/keyring-freebsd-x64": "1.2.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", "@napi-rs/keyring-linux-arm64-musl": "1.2.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-musl": "1.2.0", "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", "@napi-rs/keyring-win32-x64-msvc": "1.2.0" } }, "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.2.0", "", { "os": "linux", "cpu": "none" }, "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.2.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.36.0", "", { "os": "android", "cpu": "arm" }, "sha512-Z4yVHJWx/swHHjtr0dXrBZb6LxS+qNz1qdza222mWwPTUK4L790+5i3LTgjx3KYGBzcYpjaiZBw4vOx94dH7MQ=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.36.0", "", { "os": "android", "cpu": "arm64" }, "sha512-3ElCJRFNPQl7jexf2CAa9XmAm8eC5JPrIDSjc9jSchkVSFTEqyL0NtZinBB2h1a4i4JgP1oGl/5G5n8YR4FN8Q=="], @@ -147,10 +175,14 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "env-paths": ["env-paths@4.0.0", "", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "is-safe-filename": ["is-safe-filename@0.1.1", "", {}, "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "nano-staged": ["nano-staged@0.9.0", "", { "dependencies": { "picocolors": "^1.0.0" }, "bin": { "nano-staged": "lib/bin.js" } }, "sha512-0JfyX4i0Vp5HhC9RDtJ1kp7psz8CFuS3Gya3Z6WZv//QCwA9dPzi1S803VdR0c0P6R7sSvweZ5mSJmYQ/N+loQ=="], diff --git a/package.json b/package.json index 35aca9c7..17a1c2f1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "type": "module", "scripts": { - "build": "bun build ./src/cli.ts --outfile ./dist/cli.js --target node", + "build": "bun build ./src/cli.ts --outfile ./dist/cli.js --target node --external @napi-rs/keyring", "dev": "bun run ./src/cli.ts", "test": "bun test", "lint": "oxlint src/", @@ -17,7 +17,9 @@ }, "dependencies": { "@inquirer/prompts": "^8.2.0", + "@napi-rs/keyring": "^1.2.0", "commander": "^14.0.3", + "env-paths": "^4.0.0", "yaml": "^2.8.2" }, "devDependencies": { diff --git a/src/lib/config.ts b/src/lib/config.ts index 444be98e..adad813e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,5 +1,5 @@ /** - * Config file management for ~/.clerk/config.json. + * Config file management for the Clerk CLI config file. * Stores auth identity and path-keyed project profiles. */ diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9abe264f..64416cb4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,14 +3,16 @@ * Centralizes configuration values that are used across multiple modules. */ -import { homedir } from "node:os"; import { join } from "node:path"; +import envPaths from "env-paths"; // ── File paths ────────────────────────────────────────────────────────────── -export const CLERK_HOME_DIR = process.env.CLERK_CONFIG_DIR ?? join(homedir(), ".clerk"); -export const CONFIG_FILE = join(CLERK_HOME_DIR, "config.json"); -export const CREDENTIALS_FILE = join(CLERK_HOME_DIR, "credentials"); +const clerkConfigDir = process.env.CLERK_CONFIG_DIR; +const paths = envPaths("clerk-cli", { suffix: false }); + +export const CONFIG_FILE = join(clerkConfigDir ?? paths.config, "config.json"); +export const CREDENTIALS_FILE = join(clerkConfigDir ?? paths.data, "credentials"); // ── OAuth ─────────────────────────────────────────────────────────────────── @@ -46,10 +48,5 @@ export const OPENAPI_SPEC_URLS = { // ── Cache ──────────────────────────────────────────────────────────────── -export const CLERK_CACHE_DIR = join(CLERK_HOME_DIR, "cache"); +export const CLERK_CACHE_DIR = clerkConfigDir ? join(clerkConfigDir, "cache") : paths.cache; export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - -// ── Keychain ──────────────────────────────────────────────────────────────── - -export const KEYCHAIN_SERVICE = "clerk-cli"; -export const KEYCHAIN_ACCOUNT = "oauth-access-token"; diff --git a/src/lib/credential-store.test.ts b/src/lib/credential-store.test.ts index 4d57a64b..70460ceb 100644 --- a/src/lib/credential-store.test.ts +++ b/src/lib/credential-store.test.ts @@ -1,5 +1,8 @@ -import { test, expect, describe, beforeEach, afterAll, mock } from "bun:test"; -import { mkdtemp, rm, mkdir, chmod } from "node:fs/promises"; +import { test, expect, describe, beforeEach, afterAll, mock, setDefaultTimeout } from "bun:test"; + +// Keyring initialization can be slow on first access (macOS Keychain, etc.) +setDefaultTimeout(5_000); +import { mkdtemp, rm, mkdir, chmod, unlink } from "node:fs/promises"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; @@ -8,18 +11,26 @@ const tempDir = await mkdtemp(join(tmpdir(), "clerk-cred-test-")); // Redirect file-based credential storage to temp dir via env var process.env.CLERK_CONFIG_DIR = tempDir; -// Re-register real credential-store to override any stale mocks from other test files -const isMacOS = process.platform === "darwin"; -const KEYCHAIN_SERVICE = "clerk-cli"; -const KEYCHAIN_ACCOUNT = "oauth-access-token"; +// Import constants from the source module to avoid duplication +const { KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT } = await import("./credential-store.ts"); const credFile = () => join(process.env.CLERK_CONFIG_DIR ?? join(require("os").homedir(), ".clerk"), "credentials"); +let keyringModule: typeof import("@napi-rs/keyring") | null; +try { + keyringModule = await import("@napi-rs/keyring"); +} catch { + keyringModule = null; +} + mock.module("./credential-store.ts", () => ({ + KEYCHAIN_SERVICE, + KEYCHAIN_ACCOUNT, async storeToken(token: string) { - if (isMacOS) { + if (keyringModule) { try { - await Bun.$`security add-generic-password -a ${KEYCHAIN_ACCOUNT} -s ${KEYCHAIN_SERVICE} -w ${token} -U`.quiet(); + const entry = new keyringModule.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + entry.setPassword(token); return; } catch {} } @@ -29,13 +40,10 @@ mock.module("./credential-store.ts", () => ({ await chmod(f, 0o600); }, async getToken() { - if (isMacOS) { + if (keyringModule) { try { - return ( - await Bun.$`security find-generic-password -a ${KEYCHAIN_ACCOUNT} -s ${KEYCHAIN_SERVICE} -w`.quiet() - ) - .text() - .trim(); + const entry = new keyringModule.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + return entry.getPassword(); } catch {} } const file = Bun.file(credFile()); @@ -44,13 +52,17 @@ mock.module("./credential-store.ts", () => ({ return content.trim() || null; }, async deleteToken() { - if (isMacOS) { + if (keyringModule) { try { - await Bun.$`security delete-generic-password -a ${KEYCHAIN_ACCOUNT} -s ${KEYCHAIN_SERVICE}`.quiet(); + const entry = new keyringModule.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + entry.deletePassword(); } catch {} } - const file = Bun.file(credFile()); - if (await file.exists()) await Bun.write(credFile(), ""); + try { + await unlink(credFile()); + } catch { + // File doesn't exist, nothing to delete + } }, })); @@ -59,8 +71,8 @@ const { storeToken, getToken, deleteToken } = await import("./credential-store.t let savedToken: string | null = null; afterAll(async () => { - // Restore any pre-existing keychain token on macOS - if (process.platform === "darwin" && savedToken !== null) { + // Restore any pre-existing keyring token + if (keyringModule && savedToken !== null) { await storeToken(savedToken); } delete process.env.CLERK_CONFIG_DIR; @@ -69,8 +81,8 @@ afterAll(async () => { describe("credential-store", () => { beforeEach(async () => { - // On first run, save any existing keychain token so we can restore it later - if (process.platform === "darwin" && savedToken === null) { + // On first run, save any existing keyring token so we can restore it later + if (keyringModule && savedToken === null) { savedToken = await getToken(); } await deleteToken(); diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index 87e18ec7..422cf488 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -1,40 +1,57 @@ /** * Credential store for persisting the OAuth access token. - * Uses macOS Keychain as primary, falls back to a plaintext file with chmod 600. + * Uses platform keyring as primary (via @napi-rs/keyring), falls back to a plaintext file with chmod 600. */ import { dirname } from "node:path"; -import { mkdir, chmod } from "node:fs/promises"; -import { CREDENTIALS_FILE, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT } from "./constants.ts"; +import { mkdir, chmod, unlink } from "node:fs/promises"; +import { CREDENTIALS_FILE } from "./constants.ts"; -const isMacOS = process.platform === "darwin"; +export const KEYCHAIN_SERVICE = "clerk-cli"; +export const KEYCHAIN_ACCOUNT = "oauth-access-token"; -async function keychainStore(token: string): Promise { - if (!isMacOS) return false; +let keyringModule: typeof import("@napi-rs/keyring") | null | undefined; + +async function getKeyring(): Promise { + if (keyringModule !== undefined) return keyringModule; try { - // -U flag updates existing entry if present - await Bun.$`security add-generic-password -a ${KEYCHAIN_ACCOUNT} -s ${KEYCHAIN_SERVICE} -w ${token} -U`.quiet(); + keyringModule = await import("@napi-rs/keyring"); + return keyringModule; + } catch { + keyringModule = null; + return null; + } +} + +async function keyringStore(token: string): Promise { + const mod = await getKeyring(); + if (!mod) return false; + try { + const entry = new mod.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + entry.setPassword(token); return true; } catch { return false; } } -async function keychainGet(): Promise { - if (!isMacOS) return null; +async function keyringGet(): Promise { + const mod = await getKeyring(); + if (!mod) return null; try { - const result = - await Bun.$`security find-generic-password -a ${KEYCHAIN_ACCOUNT} -s ${KEYCHAIN_SERVICE} -w`.quiet(); - return result.text().trim(); + const entry = new mod.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + return entry.getPassword(); } catch { return null; } } -async function keychainDelete(): Promise { - if (!isMacOS) return false; +async function keyringDelete(): Promise { + const mod = await getKeyring(); + if (!mod) return false; try { - await Bun.$`security delete-generic-password -a ${KEYCHAIN_ACCOUNT} -s ${KEYCHAIN_SERVICE}`.quiet(); + const entry = new mod.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + entry.deletePassword(); return true; } catch { return false; @@ -55,17 +72,22 @@ async function fileGet(): Promise { } async function fileDelete(): Promise { - const file = Bun.file(CREDENTIALS_FILE); - if (await file.exists()) { - await Bun.write(CREDENTIALS_FILE, ""); + try { + await unlink(CREDENTIALS_FILE); + } catch { + // File doesn't exist, nothing to delete } } export async function storeToken(token: string): Promise { - const stored = await keychainStore(token); - if (!stored) { - await fileStore(token); + const stored = await keyringStore(token); + if (stored) { + // Clean up any stale plaintext credentials from a previous file-based storage + await fileDelete(); + return; } + + await fileStore(token); } let tokenOverride: string | null | undefined; @@ -77,12 +99,12 @@ export function _setTokenOverride(value: string | null | undefined): void { export async function getToken(): Promise { if (tokenOverride !== undefined) return tokenOverride; - const token = await keychainGet(); + const token = await keyringGet(); if (token) return token; return fileGet(); } export async function deleteToken(): Promise { - await keychainDelete(); + await keyringDelete(); await fileDelete(); }