From c9ade3d098b04fde721c183591014c411682a1b2 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 20:54:29 -0800 Subject: [PATCH 01/15] feat: store API keys in the system keyring --- .github/workflows/ci.yaml | 27 +- src/commands/auth/auth-list.ts | 36 ++- src/credentials.ts | 242 +++++++++++++---- src/keyring/index.ts | 96 +++++++ src/keyring/linux.ts | 64 +++++ src/keyring/macos.ts | 62 +++++ src/keyring/windows.ts | 66 +++++ test/credentials.test.ts | 429 +++++++++++++++++++++++++++++-- test/keyring.integration.test.ts | 38 +++ test/keyring.test.ts | 133 ++++++++++ 10 files changed, 1104 insertions(+), 89 deletions(-) create mode 100644 src/keyring/index.ts create mode 100644 src/keyring/linux.ts create mode 100644 src/keyring/macos.ts create mode 100644 src/keyring/windows.ts create mode 100644 test/keyring.integration.test.ts create mode 100644 test/keyring.test.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 74bc3825..dcdb5388 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,7 @@ jobs: run: deno task check - name: Run tests - run: deno task test + run: deno task test --ignore=test/keyring.integration.test.ts - name: Install linear-cli for skill generation run: deno task install @@ -43,3 +43,28 @@ jobs: git diff skills/ exit 1 fi + + keyring-integration: + strategy: + matrix: + include: + - os: macos-latest + - os: ubuntu-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Set up Secret Service + if: runner.os == 'Linux' + run: | + sudo apt-get update && sudo apt-get install -y gnome-keyring libsecret-tools dbus-x11 + eval "$(dbus-launch --sh-syntax)" + echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> $GITHUB_ENV + echo "test-password" | gnome-keyring-daemon --unlock --components=secrets + + - name: Keyring Integration + run: deno task test test/keyring.integration.test.ts diff --git a/src/commands/auth/auth-list.ts b/src/commands/auth/auth-list.ts index 94ade2eb..dd0a77e2 100644 --- a/src/commands/auth/auth-list.ts +++ b/src/commands/auth/auth-list.ts @@ -2,12 +2,12 @@ import { Command } from "@cliffy/command" import { unicodeWidth } from "@std/cli" import { gql } from "../../__codegen__/gql.ts" import { - getAllCredentials, + getApiKeyForWorkspace, getDefaultWorkspace, getWorkspaces, } from "../../credentials.ts" import { padDisplay } from "../../utils/display.ts" -import { handleError } from "../../utils/errors.ts" +import { handleError, isClientError } from "../../utils/errors.ts" import { createGraphQLClient } from "../../utils/graphql.ts" const viewerQuery = gql(` @@ -48,11 +48,22 @@ async function fetchWorkspaceInfo( userName: result.viewer.name, email: result.viewer.email, } - } catch { + } catch (error) { + let errorMsg = "unknown error" + if (isClientError(error)) { + const status = error.response?.status + if (status === 401 || status === 403) { + errorMsg = "invalid credentials" + } else { + errorMsg = error.message + } + } else if (error instanceof Error) { + errorMsg = error.message + } return { workspace, isDefault, - error: "invalid credentials", + error: errorMsg, } } } @@ -70,12 +81,19 @@ export const listCommand = new Command() return } - const credentials = getAllCredentials() - // Fetch info for all workspaces in parallel - const infoPromises = workspaces.map((ws) => - fetchWorkspaceInfo(ws, credentials[ws]!) - ) + const infoPromises = workspaces.map((ws) => { + const apiKey = getApiKeyForWorkspace(ws) + if (apiKey == null) { + const info: WorkspaceInfo = { + workspace: ws, + isDefault: getDefaultWorkspace() === ws, + error: "missing credentials", + } + return Promise.resolve(info) + } + return fetchWorkspaceInfo(ws, apiKey) + }) const infos = await Promise.all(infoPromises) // Calculate column widths diff --git a/src/credentials.ts b/src/credentials.ts index ac17fc55..c69245d7 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,13 +1,17 @@ import { parse, stringify } from "@std/toml" import { dirname, join } from "@std/path" import { ensureDir } from "@std/fs" +import { yellow } from "@std/fmt/colors" +import { deletePassword, getPassword, setPassword } from "./keyring/index.ts" export interface Credentials { default?: string - [workspace: string]: string | undefined + workspaces: string[] } -let credentials: Credentials = {} +let credentials: Credentials = { workspaces: [] } + +const apiKeyCache = new Map() /** * Get the path to the credentials file. @@ -32,28 +36,154 @@ export function getCredentialsPath(): string | null { return null } +interface InlineCredentials { + default?: string + [workspace: string]: string | undefined +} + +// The inline format stores API keys directly in the TOML file as +// `workspace-name = "lin_api_..."`. The keyring format uses a `workspaces` +// array and stores keys in the OS keyring instead. +function hasInlineKeys( + parsed: Record, +): parsed is InlineCredentials { + for (const [key, value] of Object.entries(parsed)) { + if (key === "default") continue + if (key === "workspaces") return false + if (typeof value === "string") return true + } + return false +} + +function parseInlineCredentials(parsed: InlineCredentials): Credentials { + const workspaces: string[] = [] + for (const [key, value] of Object.entries(parsed)) { + if (key === "default") continue + if (typeof value === "string") { + workspaces.push(key) + apiKeyCache.set(key, value) + } + } + return { + default: typeof parsed.default === "string" ? parsed.default : undefined, + workspaces, + } +} + +function parseKeyringCredentials(parsed: Record): Credentials { + const workspaces = Array.isArray(parsed.workspaces) + ? [ + ...new Set((parsed.workspaces as unknown[]).filter((v): v is string => + typeof v === "string" + )), + ] + : [] + + const defaultWs = typeof parsed.default === "string" + ? parsed.default + : undefined + + if (defaultWs != null && !workspaces.includes(defaultWs)) { + console.error( + yellow( + `Warning: Default workspace "${defaultWs}" is not in the workspaces list. ` + + `Run \`linear auth default \` to set a valid default.`, + ), + ) + } + + return { + default: defaultWs != null && workspaces.includes(defaultWs) + ? defaultWs + : undefined, + workspaces, + } +} + +async function populateKeyringCache(workspaces: string[]): Promise { + await Promise.all(workspaces.map(async (ws) => { + try { + const key = await getPassword(ws) + if (key != null) { + apiKeyCache.set(ws, key) + } else { + console.error( + yellow( + `Warning: No keyring entry for workspace "${ws}". Run \`linear auth login\` to re-authenticate.`, + ), + ) + } + } catch (error) { + console.error( + yellow( + `Warning: Failed to read keyring for workspace "${ws}": ${ + error instanceof Error ? error.message : String(error) + }`, + ), + ) + } + })) +} + /** * Load credentials from the credentials file. */ export async function loadCredentials(): Promise { const path = getCredentialsPath() if (!path) { - return {} + return { workspaces: [] } + } + + let file: string + try { + file = await Deno.readTextFile(path) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return { workspaces: [] } + } + throw new Error( + `Failed to read credentials file at ${path}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) } + let parsed: Record try { - const file = await Deno.readTextFile(path) - credentials = parse(file) as Credentials + parsed = parse(file) as Record + } catch (error) { + throw new Error( + `Failed to parse credentials file at ${path}. The file may be corrupted.\n` + + `You can delete it and re-authenticate with \`linear auth login\`.\n` + + `Parse error: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + apiKeyCache.clear() + + if (hasInlineKeys(parsed)) { + credentials = parseInlineCredentials(parsed) + console.error( + yellow( + "Warning: Credentials file uses inline plaintext format. " + + "Run `linear auth login` for each workspace to migrate to the system keyring.", + ), + ) return credentials - } catch { - return {} } + + credentials = parseKeyringCredentials(parsed) + await populateKeyringCache(credentials.workspaces) + + return credentials } /** * Save credentials to the credentials file. */ -export async function saveCredentials(creds: Credentials): Promise { +async function saveCredentials(): Promise { const path = getCredentialsPath() if (!path) { throw new Error("Could not determine credentials path") @@ -65,22 +195,13 @@ export async function saveCredentials(creds: Credentials): Promise { // Build a clean object for serialization // Put default first, then workspaces in alphabetical order - const ordered: Record = {} - if (creds.default) { - ordered.default = creds.default - } - const workspaces = Object.keys(creds) - .filter((k) => k !== "default") - .sort() - for (const ws of workspaces) { - const value = creds[ws] - if (value) { - ordered[ws] = value - } + const ordered: Record = {} + if (credentials.default != null) { + ordered.default = credentials.default } + ordered.workspaces = [...credentials.workspaces].sort() await Deno.writeTextFile(path, stringify(ordered)) - credentials = creds } /** @@ -91,16 +212,28 @@ export async function addCredential( workspace: string, apiKey: string, ): Promise { - const creds = { ...credentials } + try { + await setPassword(workspace, apiKey) + } catch (error) { + throw new Error( + `Failed to store API key in system keyring for workspace "${workspace}": ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + apiKeyCache.set(workspace, apiKey) + + const isNew = !credentials.workspaces.includes(workspace) + if (isNew) { + credentials.workspaces.push(workspace) + } // If this is the first workspace, make it the default - const existingWorkspaces = Object.keys(creds).filter((k) => k !== "default") - if (existingWorkspaces.length === 0) { - creds.default = workspace + if (isNew && credentials.workspaces.length === 1) { + credentials.default = workspace } - creds[workspace] = apiKey - await saveCredentials(creds) + await saveCredentials() } /** @@ -108,43 +241,51 @@ export async function addCredential( * If removing the default, reassign to another workspace or clear. */ export async function removeCredential(workspace: string): Promise { - const creds = { ...credentials } - delete creds[workspace] + try { + await deletePassword(workspace) + } catch (error) { + throw new Error( + `Failed to remove API key from system keyring for workspace "${workspace}": ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + apiKeyCache.delete(workspace) + + credentials.workspaces = credentials.workspaces.filter((w) => w !== workspace) // If we removed the default, reassign it - if (creds.default === workspace) { - const remaining = Object.keys(creds).filter((k) => k !== "default") - if (remaining.length > 0) { - creds.default = remaining[0] + if (credentials.default === workspace) { + if (credentials.workspaces.length > 0) { + credentials.default = credentials.workspaces[0] } else { - delete creds.default + credentials.default = undefined } } - await saveCredentials(creds) + await saveCredentials() } /** * Set the default workspace. */ export async function setDefaultWorkspace(workspace: string): Promise { - if (!credentials[workspace]) { + if (!credentials.workspaces.includes(workspace)) { throw new Error(`Workspace "${workspace}" not found in credentials`) } - const creds = { ...credentials } - creds.default = workspace - await saveCredentials(creds) + credentials.default = workspace + await saveCredentials() } /** * Get the API key for a workspace, or the default if not specified. */ export function getCredentialApiKey(workspace?: string): string | undefined { - if (workspace) { - return credentials[workspace] + if (workspace != null) { + return apiKeyCache.get(workspace) } - if (credentials.default) { - return credentials[credentials.default] + if (credentials.default != null) { + return apiKeyCache.get(credentials.default) } return undefined } @@ -157,24 +298,23 @@ export function getDefaultWorkspace(): string | undefined { } /** - * Get all configured workspaces (excluding 'default' key). + * Get all configured workspaces. */ export function getWorkspaces(): string[] { - return Object.keys(credentials).filter((k) => k !== "default") + return [...credentials.workspaces] } /** * Check if a workspace is configured. */ export function hasWorkspace(workspace: string): boolean { - return workspace in credentials && workspace !== "default" + return credentials.workspaces.includes(workspace) } -/** - * Get all credentials (for listing purposes). - */ -export function getAllCredentials(): Credentials { - return { ...credentials } +export function getApiKeyForWorkspace( + workspace: string, +): string | undefined { + return apiKeyCache.get(workspace) } // Load credentials at startup diff --git a/src/keyring/index.ts b/src/keyring/index.ts new file mode 100644 index 00000000..dceeaf33 --- /dev/null +++ b/src/keyring/index.ts @@ -0,0 +1,96 @@ +import { macosBackend } from "./macos.ts" +import { linuxBackend } from "./linux.ts" +import { windowsBackend } from "./windows.ts" + +export interface KeyringBackend { + get(account: string): Promise + set(account: string, password: string): Promise + delete(account: string): Promise +} + +let backend: KeyringBackend | null = null + +export function _setBackend(b: KeyringBackend | null): void { + backend = b +} + +function platformHint(): string { + switch (Deno.build.os) { + case "darwin": + return "Could not find /usr/bin/security. Is this a macOS system?" + case "linux": + return "Could not find secret-tool. Install libsecret (e.g. apt install libsecret-tools, pacman -S libsecret).\n" + + "Alternatively, set the LINEAR_API_KEY environment variable." + case "windows": + return "Could not run PowerShell. Ensure PowerShell and the CredentialManager module are available." + default: + return `Unsupported platform: ${Deno.build.os}` + } +} + +function getBackend(): KeyringBackend { + if (backend != null) return backend + switch (Deno.build.os) { + case "darwin": + return macosBackend + case "linux": + return linuxBackend + case "windows": + return windowsBackend + default: + throw new Error(`Unsupported platform: ${Deno.build.os}`) + } +} + +export async function run( + cmd: string[], + options?: { stdin?: string }, +): Promise<{ success: boolean; code: number; stdout: string; stderr: string }> { + let process: Deno.ChildProcess + try { + const command = new Deno.Command(cmd[0], { + args: cmd.slice(1), + stdin: options?.stdin != null ? "piped" : "null", + stdout: "piped", + stderr: "piped", + }) + process = command.spawn() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`${platformHint()}\n (${detail})`) + } + + if (options?.stdin != null) { + try { + const writer = process.stdin.getWriter() + await writer.write(new TextEncoder().encode(options.stdin)) + await writer.close() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to write to stdin of ${cmd[0]}: ${detail}`) + } + } + + const { success, code, stdout, stderr } = await process.output() + return { + success, + code, + stdout: new TextDecoder().decode(stdout).trim(), + stderr: new TextDecoder().decode(stderr).trim(), + } +} + +export async function getPassword(account: string): Promise { + return await getBackend().get(account) +} + +export async function setPassword( + account: string, + password: string, +): Promise { + await getBackend().set(account, password) +} + +export async function deletePassword(account: string): Promise { + await getBackend().delete(account) +} diff --git a/src/keyring/linux.ts b/src/keyring/linux.ts new file mode 100644 index 00000000..fa995fc4 --- /dev/null +++ b/src/keyring/linux.ts @@ -0,0 +1,64 @@ +import type { KeyringBackend } from "./index.ts" +import { run } from "./index.ts" + +const SERVICE = "linear-cli" + +export const linuxBackend: KeyringBackend = { + async get(account) { + const result = await run([ + "secret-tool", + "lookup", + "service", + SERVICE, + "account", + account, + ]) + if (!result.success) { + // secret-tool lookup returns exit 1 when no matching items are found + if (result.code === 1) return null + throw new Error( + `secret-tool lookup failed (exit ${result.code}): ${result.stderr}`, + ) + } + // secret-tool returns empty stdout when the value itself is empty; + // Linear API keys are always non-empty so treat empty as not-found + return result.stdout || null + }, + + async set(account, password) { + const result = await run( + [ + "secret-tool", + "store", + "--label", + `${SERVICE}: ${account}`, + "service", + SERVICE, + "account", + account, + ], + { stdin: password }, + ) + if (!result.success) { + throw new Error( + `secret-tool store failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, + + async delete(account) { + const result = await run([ + "secret-tool", + "clear", + "service", + SERVICE, + "account", + account, + ]) + if (!result.success) { + throw new Error( + `secret-tool clear failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, +} diff --git a/src/keyring/macos.ts b/src/keyring/macos.ts new file mode 100644 index 00000000..14576294 --- /dev/null +++ b/src/keyring/macos.ts @@ -0,0 +1,62 @@ +import type { KeyringBackend } from "./index.ts" +import { run } from "./index.ts" + +const SERVICE = "linear-cli" + +export const macosBackend: KeyringBackend = { + async get(account) { + const result = await run([ + "/usr/bin/security", + "find-generic-password", + "-a", + account, + "-s", + SERVICE, + "-w", + ]) + if (!result.success) { + // exit 44 = errSecItemNotFound + if (result.code === 44) return null + throw new Error( + `security find-generic-password failed (exit ${result.code}): ${result.stderr}`, + ) + } + return result.stdout + }, + + async set(account, password) { + const result = await run([ + "/usr/bin/security", + "add-generic-password", + "-a", + account, + "-s", + SERVICE, + "-w", + password, + "-U", // update the item if it already exists + ]) + if (!result.success) { + throw new Error( + `security add-generic-password failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, + + async delete(account) { + const result = await run([ + "/usr/bin/security", + "delete-generic-password", + "-a", + account, + "-s", + SERVICE, + ]) + // exit 44 = errSecItemNotFound + if (!result.success && result.code !== 44) { + throw new Error( + `security delete-generic-password failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, +} diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts new file mode 100644 index 00000000..8d05a3d1 --- /dev/null +++ b/src/keyring/windows.ts @@ -0,0 +1,66 @@ +import type { KeyringBackend } from "./index.ts" +import { run } from "./index.ts" + +const SERVICE = "linear-cli" + +function escapePowerShell(s: string): string { + return s.replace(/'/g, "''") +} + +export const windowsBackend: KeyringBackend = { + async get(account) { + const target = escapePowerShell(`${SERVICE}:${account}`) + const script = + `Import-Module CredentialManager; $c = Get-StoredCredential -Target '${target}'; if ($c) { $c.GetNetworkCredential().Password }` + const result = await run([ + "powershell", + "-NoProfile", + "-Command", + script, + ]) + if (!result.success) { + throw new Error( + `PowerShell Get-StoredCredential failed (exit ${result.code}): ${result.stderr}`, + ) + } + // PowerShell returns empty stdout when no credential exists; + // Linear API keys are always non-empty so treat empty as not-found + return result.stdout || null + }, + + async set(account, password) { + const target = escapePowerShell(`${SERVICE}:${account}`) + const escapedAccount = escapePowerShell(account) + const escapedPassword = escapePowerShell(password) + const script = + `Import-Module CredentialManager; New-StoredCredential -Target '${target}' -UserName '${escapedAccount}' -Password '${escapedPassword}' -Type Generic -Persist LocalMachine` + const result = await run([ + "powershell", + "-NoProfile", + "-Command", + script, + ]) + if (!result.success) { + throw new Error( + `PowerShell New-StoredCredential failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, + + async delete(account) { + const target = escapePowerShell(`${SERVICE}:${account}`) + const script = + `Import-Module CredentialManager; Remove-StoredCredential -Target '${target}'` + const result = await run([ + "powershell", + "-NoProfile", + "-Command", + script, + ]) + if (!result.success) { + throw new Error( + `PowerShell Remove-StoredCredential failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, +} diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 674ce249..262fe80c 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -1,24 +1,45 @@ import { assertEquals } from "@std/assert" import { fromFileUrl } from "@std/path" -// Note: Testing the credentials module requires running subprocesses -// because credentials are loaded at module initialization via top-level await +// Testing the credentials module requires running subprocesses because +// credentials are loaded at module initialization via top-level await. const credentialsUrl = new URL("../src/credentials.ts", import.meta.url) +const keyringUrl = new URL("../src/keyring/index.ts", import.meta.url) const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url)) +// Pass DENO_DIR so subprocesses reuse the cached dependency graph +// instead of re-downloading and compiling on every test run. +const denoDir = Deno.env.get("DENO_DIR") ?? + (Deno.build.os === "darwin" + ? `${Deno.env.get("HOME")}/Library/Caches/deno` + : `${Deno.env.get("HOME")}/.cache/deno`) + +function mockBackendAndImport(imports: string): string { + return ` +import { _setBackend } from "${keyringUrl}"; +const _store = new Map(); +_setBackend({ + get(account: string) { return Promise.resolve(_store.get(account) ?? null) }, + set(account: string, password: string) { _store.set(account, password); return Promise.resolve() }, + delete(account: string) { _store.delete(account); return Promise.resolve() }, +}); +const { ${imports} } = await import("${credentialsUrl}"); +` +} async function runWithCredentials( tempDir: string, code: string, ): Promise { const isWindows = Deno.build.os === "windows" - // On Unix, set XDG_CONFIG_HOME to tempDir so credentials go to tempDir/linear/ - // This overrides HOME-based path and ensures isolation in CI + // On Unix, set XDG_CONFIG_HOME to tempDir so credentials go to tempDir/linear/. + // This overrides HOME-based path and ensures isolation in CI. const env: Record = isWindows ? { APPDATA: tempDir, SystemRoot: Deno.env.get("SystemRoot") ?? "" } : { HOME: tempDir, XDG_CONFIG_HOME: tempDir, + DENO_DIR: denoDir, PATH: Deno.env.get("PATH") ?? "", } @@ -38,7 +59,7 @@ async function runWithCredentials( const output = new TextDecoder().decode(stdout).trim() const errorOutput = new TextDecoder().decode(stderr) - if (errorOutput && !errorOutput.includes("Check")) { + if (errorOutput && !errorOutput.startsWith("Check file:")) { console.error("Subprocess stderr:", errorOutput) } @@ -56,7 +77,7 @@ Deno.test("credentials - getCredentialsPath returns correct path", async () => { : `${tempDir}/linear/credentials.toml` const code = ` - import { getCredentialsPath } from "${credentialsUrl}"; + ${mockBackendAndImport("getCredentialsPath")} console.log(getCredentialsPath()); ` @@ -67,18 +88,20 @@ Deno.test("credentials - getCredentialsPath returns correct path", async () => { } }) -Deno.test("credentials - loadCredentials returns empty object when no file", async () => { +Deno.test("credentials - loadCredentials returns empty when no file", async () => { const tempDir = await Deno.makeTempDir() try { const code = ` - import { loadCredentials } from "${credentialsUrl}"; + ${mockBackendAndImport("loadCredentials")} const creds = await loadCredentials(); console.log(JSON.stringify(creds)); ` const output = await runWithCredentials(tempDir, code) - assertEquals(output, "{}") + const result = JSON.parse(output) + assertEquals(result.workspaces, []) + assertEquals(result.default, undefined) } finally { await Deno.remove(tempDir, { recursive: true }) } @@ -89,11 +112,14 @@ Deno.test("credentials - addCredential creates file and sets default", async () try { const code = ` - import { addCredential, getAllCredentials, getDefaultWorkspace } from "${credentialsUrl}"; + ${ + mockBackendAndImport( + "addCredential, getApiKeyForWorkspace, getDefaultWorkspace", + ) + } await addCredential("test-workspace", "lin_api_test123"); - const creds = getAllCredentials(); console.log(JSON.stringify({ - creds, + apiKey: getApiKeyForWorkspace("test-workspace"), default: getDefaultWorkspace() })); ` @@ -102,8 +128,7 @@ Deno.test("credentials - addCredential creates file and sets default", async () const result = JSON.parse(output) assertEquals(result.default, "test-workspace") - assertEquals(result.creds["test-workspace"], "lin_api_test123") - assertEquals(result.creds.default, "test-workspace") + assertEquals(result.apiKey, "lin_api_test123") } finally { await Deno.remove(tempDir, { recursive: true }) } @@ -114,7 +139,7 @@ Deno.test("credentials - addCredential preserves existing default", async () => try { const code = ` - import { addCredential, getDefaultWorkspace } from "${credentialsUrl}"; + ${mockBackendAndImport("addCredential, getDefaultWorkspace")} await addCredential("first-workspace", "lin_api_first"); await addCredential("second-workspace", "lin_api_second"); console.log(getDefaultWorkspace()); @@ -127,12 +152,32 @@ Deno.test("credentials - addCredential preserves existing default", async () => } }) +Deno.test("credentials - TOML file does not contain API keys after addCredential", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${mockBackendAndImport("addCredential, getCredentialsPath")} + await addCredential("my-workspace", "lin_api_secret"); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(toml); + ` + + const output = await runWithCredentials(tempDir, code) + assertEquals(output.includes("lin_api_secret"), false) + assertEquals(output.includes("my-workspace"), true) + assertEquals(output.includes("workspaces"), true) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + Deno.test("credentials - removeCredential deletes workspace", async () => { const tempDir = await Deno.makeTempDir() try { const code = ` - import { addCredential, removeCredential, getWorkspaces } from "${credentialsUrl}"; + ${mockBackendAndImport("addCredential, removeCredential, getWorkspaces")} await addCredential("workspace-a", "lin_api_a"); await addCredential("workspace-b", "lin_api_b"); await removeCredential("workspace-a"); @@ -153,7 +198,11 @@ Deno.test("credentials - removeCredential reassigns default", async () => { try { const code = ` - import { addCredential, removeCredential, getDefaultWorkspace } from "${credentialsUrl}"; + ${ + mockBackendAndImport( + "addCredential, removeCredential, getDefaultWorkspace", + ) + } await addCredential("workspace-a", "lin_api_a"); await addCredential("workspace-b", "lin_api_b"); await removeCredential("workspace-a"); @@ -167,12 +216,38 @@ Deno.test("credentials - removeCredential reassigns default", async () => { } }) +Deno.test("credentials - removeCredential cleans up cache", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${ + mockBackendAndImport( + "addCredential, removeCredential, getApiKeyForWorkspace", + ) + } + await addCredential("workspace-a", "lin_api_a"); + await removeCredential("workspace-a"); + console.log(getApiKeyForWorkspace("workspace-a") ?? "undefined"); + ` + + const output = await runWithCredentials(tempDir, code) + assertEquals(output, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + Deno.test("credentials - setDefaultWorkspace changes default", async () => { const tempDir = await Deno.makeTempDir() try { const code = ` - import { addCredential, setDefaultWorkspace, getDefaultWorkspace } from "${credentialsUrl}"; + ${ + mockBackendAndImport( + "addCredential, setDefaultWorkspace, getDefaultWorkspace", + ) + } await addCredential("workspace-a", "lin_api_a"); await addCredential("workspace-b", "lin_api_b"); await setDefaultWorkspace("workspace-b"); @@ -191,7 +266,7 @@ Deno.test("credentials - getCredentialApiKey returns key for workspace", async ( try { const code = ` - import { addCredential, getCredentialApiKey } from "${credentialsUrl}"; + ${mockBackendAndImport("addCredential, getCredentialApiKey")} await addCredential("my-workspace", "lin_api_mykey"); console.log(getCredentialApiKey("my-workspace")); ` @@ -208,7 +283,7 @@ Deno.test("credentials - getCredentialApiKey returns default when no workspace s try { const code = ` - import { addCredential, getCredentialApiKey } from "${credentialsUrl}"; + ${mockBackendAndImport("addCredential, getCredentialApiKey")} await addCredential("default-workspace", "lin_api_default"); console.log(getCredentialApiKey()); ` @@ -225,7 +300,7 @@ Deno.test("credentials - getCredentialApiKey returns undefined for unknown works try { const code = ` - import { addCredential, getCredentialApiKey } from "${credentialsUrl}"; + ${mockBackendAndImport("addCredential, getCredentialApiKey")} await addCredential("known-workspace", "lin_api_known"); console.log(getCredentialApiKey("unknown-workspace") ?? "undefined"); ` @@ -237,12 +312,29 @@ Deno.test("credentials - getCredentialApiKey returns undefined for unknown works } }) +Deno.test("credentials - getApiKeyForWorkspace reads from cache", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${mockBackendAndImport("addCredential, getApiKeyForWorkspace")} + await addCredential("ws", "lin_api_cached"); + console.log(getApiKeyForWorkspace("ws")); + ` + + const output = await runWithCredentials(tempDir, code) + assertEquals(output, "lin_api_cached") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + Deno.test("credentials - hasWorkspace returns correct boolean", async () => { const tempDir = await Deno.makeTempDir() try { const code = ` - import { addCredential, hasWorkspace } from "${credentialsUrl}"; + ${mockBackendAndImport("addCredential, hasWorkspace")} await addCredential("exists", "lin_api_exists"); console.log(JSON.stringify({ exists: hasWorkspace("exists"), @@ -260,13 +352,11 @@ Deno.test("credentials - hasWorkspace returns correct boolean", async () => { } }) -Deno.test("credentials - loadCredentials reads existing file", async () => { +Deno.test("credentials - old format TOML backward compatibility", async () => { const tempDir = await Deno.makeTempDir() try { - // With XDG_CONFIG_HOME set to tempDir, credentials are at tempDir/linear/ const configDir = `${tempDir}/linear` - await Deno.mkdir(configDir, { recursive: true }) await Deno.writeTextFile( `${configDir}/credentials.toml`, @@ -274,10 +364,16 @@ Deno.test("credentials - loadCredentials reads existing file", async () => { ) const code = ` - import { getAllCredentials, getDefaultWorkspace } from "${credentialsUrl}"; + ${ + mockBackendAndImport( + "getDefaultWorkspace, getWorkspaces, getApiKeyForWorkspace, getCredentialApiKey", + ) + } console.log(JSON.stringify({ default: getDefaultWorkspace(), - creds: getAllCredentials() + workspaces: getWorkspaces(), + apiKey: getApiKeyForWorkspace("preexisting"), + credApiKey: getCredentialApiKey(), })); ` @@ -285,7 +381,284 @@ Deno.test("credentials - loadCredentials reads existing file", async () => { const result = JSON.parse(output) assertEquals(result.default, "preexisting") - assertEquals(result.creds.preexisting, "lin_api_preexisting") + assertEquals(result.workspaces, ["preexisting"]) + assertEquals(result.apiKey, "lin_api_preexisting") + assertEquals(result.credApiKey, "lin_api_preexisting") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - old format with multiple workspaces", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`, + ) + + const code = ` + ${ + mockBackendAndImport( + "getDefaultWorkspace, getWorkspaces, getApiKeyForWorkspace", + ) + } + console.log(JSON.stringify({ + default: getDefaultWorkspace(), + workspaces: getWorkspaces().sort(), + apiKeyA: getApiKeyForWorkspace("ws-a"), + apiKeyB: getApiKeyForWorkspace("ws-b"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + + assertEquals(result.default, "ws-a") + assertEquals(result.workspaces, ["ws-a", "ws-b"]) + assertEquals(result.apiKeyA, "lin_api_a") + assertEquals(result.apiKeyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - setDefaultWorkspace throws for unknown workspace", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${mockBackendAndImport("addCredential, setDefaultWorkspace")} + await addCredential("workspace-a", "lin_api_a"); + try { + await setDefaultWorkspace("nonexistent"); + console.log("no-error"); + } catch (e) { + console.log("error:" + e.message); + } + ` + + const output = await runWithCredentials(tempDir, code) + assertEquals(output.startsWith("error:"), true) + assertEquals(output.includes("nonexistent"), true) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - addCredential throws when keyring write fails", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + get(_account: string) { return Promise.resolve(null) }, + set(_account: string, _password: string) { return Promise.reject(new Error("keyring locked")) }, + delete(_account: string) { return Promise.resolve() }, +}); +const { addCredential, getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +try { + await addCredential("ws", "lin_api_key"); + console.log("no-error"); +} catch (e) { + console.log(JSON.stringify({ + error: e.message, + workspaces: getWorkspaces(), + cached: getApiKeyForWorkspace("ws") ?? "undefined", + })); +} + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.error.includes("keyring locked"), true) + assertEquals(result.workspaces, []) + assertEquals(result.cached, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - loadCredentials warns but continues when keyring fails for one workspace", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "ws-ok"\nworkspaces = ["ws-ok", "ws-fail"]\n`, + ) + + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + get(account: string) { + if (account === "ws-fail") return Promise.reject(new Error("keyring error")); + return Promise.resolve("lin_api_ok"); + }, + set(_a: string, _p: string) { return Promise.resolve() }, + delete(_a: string) { return Promise.resolve() }, +}); +const { getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +console.log(JSON.stringify({ + workspaces: getWorkspaces(), + okKey: getApiKeyForWorkspace("ws-ok"), + failKey: getApiKeyForWorkspace("ws-fail") ?? "undefined", +})); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.workspaces, ["ws-ok", "ws-fail"]) + assertEquals(result.okKey, "lin_api_ok") + assertEquals(result.failKey, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - removeCredential throws when keyring delete fails", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` +import { _setBackend } from "${keyringUrl}"; +const _store = new Map(); +_setBackend({ + get(account: string) { return Promise.resolve(_store.get(account) ?? null) }, + set(account: string, password: string) { _store.set(account, password); return Promise.resolve() }, + delete(_account: string) { return Promise.reject(new Error("keyring locked")) }, +}); +const { addCredential, removeCredential, getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +await addCredential("ws", "lin_api_key"); +try { + await removeCredential("ws"); + console.log("no-error"); +} catch (e) { + console.log(JSON.stringify({ + error: e.message, + workspaces: getWorkspaces(), + cached: getApiKeyForWorkspace("ws") ?? "undefined", + })); +} + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.error.includes("keyring locked"), true) + assertEquals(result.workspaces, ["ws"]) + assertEquals(result.cached, "lin_api_key") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - loadCredentials warns when keyring returns null for workspace", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "ws-a"\nworkspaces = ["ws-a", "ws-missing"]\n`, + ) + + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + get(account: string) { + if (account === "ws-missing") return Promise.resolve(null); + return Promise.resolve("lin_api_a"); + }, + set(_a: string, _p: string) { return Promise.resolve() }, + delete(_a: string) { return Promise.resolve() }, +}); +const { getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +console.log(JSON.stringify({ + workspaces: getWorkspaces(), + aKey: getApiKeyForWorkspace("ws-a"), + missingKey: getApiKeyForWorkspace("ws-missing") ?? "undefined", +})); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.workspaces, ["ws-a", "ws-missing"]) + assertEquals(result.aKey, "lin_api_a") + assertEquals(result.missingKey, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - addCredential on inline-format file rewrites to keyring format", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "old-ws"\nold-ws = "lin_api_old"\n`, + ) + + const code = ` + ${ + mockBackendAndImport("addCredential, getCredentialsPath, getWorkspaces") + } + await addCredential("new-ws", "lin_api_new"); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(JSON.stringify({ + workspaces: getWorkspaces(), + hasWorkspacesKey: toml.includes("workspaces"), + hasInlineKey: toml.includes("lin_api"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.hasWorkspacesKey, true) + assertEquals(result.hasInlineKey, false) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - dangling default is dropped on load", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "ghost"\nworkspaces = ["real"]\n`, + ) + + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + get(_account: string) { return Promise.resolve("lin_api_real") }, + set(_a: string, _p: string) { return Promise.resolve() }, + delete(_a: string) { return Promise.resolve() }, +}); +const { getDefaultWorkspace, getWorkspaces } = await import("${credentialsUrl}"); +console.log(JSON.stringify({ + default: getDefaultWorkspace() ?? "undefined", + workspaces: getWorkspaces(), +})); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.default, "undefined") + assertEquals(result.workspaces, ["real"]) } finally { await Deno.remove(tempDir, { recursive: true }) } diff --git a/test/keyring.integration.test.ts b/test/keyring.integration.test.ts new file mode 100644 index 00000000..a404aeab --- /dev/null +++ b/test/keyring.integration.test.ts @@ -0,0 +1,38 @@ +import { assertEquals } from "@std/assert" +import { + deletePassword, + getPassword, + setPassword, +} from "../src/keyring/index.ts" + +const TEST_ACCOUNT = `linear-cli-integration-test-${Date.now()}` + +Deno.test({ + name: "keyring integration - set, get, and delete round-trip", + ignore: Deno.build.os === "windows", + fn: async () => { + try { + assertEquals(await getPassword(TEST_ACCOUNT), null) + + await setPassword(TEST_ACCOUNT, "lin_api_test_secret") + assertEquals(await getPassword(TEST_ACCOUNT), "lin_api_test_secret") + + await setPassword(TEST_ACCOUNT, "lin_api_updated_secret") + assertEquals(await getPassword(TEST_ACCOUNT), "lin_api_updated_secret") + + await deletePassword(TEST_ACCOUNT) + assertEquals(await getPassword(TEST_ACCOUNT), null) + } finally { + // Ensure cleanup even if assertions fail + try { + await deletePassword(TEST_ACCOUNT) + } catch (error) { + console.error( + `Cleanup warning: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + }, +}) diff --git a/test/keyring.test.ts b/test/keyring.test.ts new file mode 100644 index 00000000..30c071a2 --- /dev/null +++ b/test/keyring.test.ts @@ -0,0 +1,133 @@ +import { assertEquals } from "@std/assert" +import { fromFileUrl } from "@std/path" + +const keyringUrl = new URL("../src/keyring/index.ts", import.meta.url) +const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url)) + +async function runWithKeyring(code: string): Promise { + const command = new Deno.Command("deno", { + args: [ + "eval", + `--config=${denoJsonPath}`, + code, + ], + stdout: "piped", + stderr: "piped", + }) + + const { stdout, stderr } = await command.output() + const output = new TextDecoder().decode(stdout).trim() + const errorOutput = new TextDecoder().decode(stderr) + + if (errorOutput && !errorOutput.startsWith("Check file:")) { + console.error("Subprocess stderr:", errorOutput) + } + + return output +} + +Deno.test("keyring - getPassword returns null when not set", async () => { + const code = ` + import { getPassword, _setBackend } from "${keyringUrl}"; + _setBackend({ + store: new Map(), + get(account) { return Promise.resolve(this.store.get(account) ?? null) }, + set(account, password) { this.store.set(account, password); return Promise.resolve() }, + delete(account) { this.store.delete(account); return Promise.resolve() }, + }); + const result = await getPassword("missing"); + console.log(result === null ? "null" : result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "null") +}) + +Deno.test("keyring - setPassword and getPassword round-trip", async () => { + const code = ` + import { getPassword, setPassword, _setBackend } from "${keyringUrl}"; + _setBackend({ + store: new Map(), + get(account) { return Promise.resolve(this.store.get(account) ?? null) }, + set(account, password) { this.store.set(account, password); return Promise.resolve() }, + delete(account) { this.store.delete(account); return Promise.resolve() }, + }); + await setPassword("my-account", "secret123"); + const result = await getPassword("my-account"); + console.log(result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "secret123") +}) + +Deno.test("keyring - deletePassword removes stored password", async () => { + const code = ` + import { getPassword, setPassword, deletePassword, _setBackend } from "${keyringUrl}"; + _setBackend({ + store: new Map(), + get(account) { return Promise.resolve(this.store.get(account) ?? null) }, + set(account, password) { this.store.set(account, password); return Promise.resolve() }, + delete(account) { this.store.delete(account); return Promise.resolve() }, + }); + await setPassword("my-account", "secret123"); + await deletePassword("my-account"); + const result = await getPassword("my-account"); + console.log(result === null ? "null" : result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "null") +}) + +Deno.test("keyring - setPassword overwrites existing value", async () => { + const code = ` + import { getPassword, setPassword, _setBackend } from "${keyringUrl}"; + _setBackend({ + store: new Map(), + get(account) { return Promise.resolve(this.store.get(account) ?? null) }, + set(account, password) { this.store.set(account, password); return Promise.resolve() }, + delete(account) { this.store.delete(account); return Promise.resolve() }, + }); + await setPassword("my-account", "first"); + await setPassword("my-account", "second"); + const result = await getPassword("my-account"); + console.log(result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "second") +}) + +Deno.test("keyring - multiple accounts are independent", async () => { + const code = ` + import { getPassword, setPassword, _setBackend } from "${keyringUrl}"; + _setBackend({ + store: new Map(), + get(account) { return Promise.resolve(this.store.get(account) ?? null) }, + set(account, password) { this.store.set(account, password); return Promise.resolve() }, + delete(account) { this.store.delete(account); return Promise.resolve() }, + }); + await setPassword("account-a", "password-a"); + await setPassword("account-b", "password-b"); + const a = await getPassword("account-a"); + const b = await getPassword("account-b"); + console.log(JSON.stringify({ a, b })); + ` + const output = await runWithKeyring(code) + const result = JSON.parse(output) + assertEquals(result.a, "password-a") + assertEquals(result.b, "password-b") +}) + +Deno.test("keyring - deletePassword on missing account is a no-op", async () => { + const code = ` + import { deletePassword, _setBackend } from "${keyringUrl}"; + _setBackend({ + store: new Map(), + get(account) { return Promise.resolve(this.store.get(account) ?? null) }, + set(account, password) { this.store.set(account, password); return Promise.resolve() }, + delete(account) { this.store.delete(account); return Promise.resolve() }, + }); + await deletePassword("nonexistent"); + console.log("ok"); + ` + const output = await runWithKeyring(code) + assertEquals(output, "ok") +}) From 98a512a3bc8842a2dc7d4d57b321685ce4b2bd89 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:37:54 -0800 Subject: [PATCH 02/15] refactor: extract errorDetail helper in credentials --- src/credentials.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/credentials.ts b/src/credentials.ts index c69245d7..0d834178 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -4,6 +4,10 @@ import { ensureDir } from "@std/fs" import { yellow } from "@std/fmt/colors" import { deletePassword, getPassword, setPassword } from "./keyring/index.ts" +function errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export interface Credentials { default?: string workspaces: string[] @@ -117,7 +121,7 @@ async function populateKeyringCache(workspaces: string[]): Promise { console.error( yellow( `Warning: Failed to read keyring for workspace "${ws}": ${ - error instanceof Error ? error.message : String(error) + errorDetail(error) }`, ), ) @@ -142,9 +146,7 @@ export async function loadCredentials(): Promise { return { workspaces: [] } } throw new Error( - `Failed to read credentials file at ${path}: ${ - error instanceof Error ? error.message : String(error) - }`, + `Failed to read credentials file at ${path}: ${errorDetail(error)}`, ) } @@ -155,9 +157,7 @@ export async function loadCredentials(): Promise { throw new Error( `Failed to parse credentials file at ${path}. The file may be corrupted.\n` + `You can delete it and re-authenticate with \`linear auth login\`.\n` + - `Parse error: ${ - error instanceof Error ? error.message : String(error) - }`, + `Parse error: ${errorDetail(error)}`, ) } @@ -217,7 +217,7 @@ export async function addCredential( } catch (error) { throw new Error( `Failed to store API key in system keyring for workspace "${workspace}": ${ - error instanceof Error ? error.message : String(error) + errorDetail(error) }`, ) } @@ -246,7 +246,7 @@ export async function removeCredential(workspace: string): Promise { } catch (error) { throw new Error( `Failed to remove API key from system keyring for workspace "${workspace}": ${ - error instanceof Error ? error.message : String(error) + errorDetail(error) }`, ) } From 8971d11ffd9a96428e1c159933968c9d27c2a19e Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:38:20 -0800 Subject: [PATCH 03/15] refactor: consolidate SERVICE constant to keyring index --- src/keyring/index.ts | 2 ++ src/keyring/linux.ts | 4 +--- src/keyring/macos.ts | 4 +--- src/keyring/windows.ts | 4 +--- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/keyring/index.ts b/src/keyring/index.ts index dceeaf33..98c3effd 100644 --- a/src/keyring/index.ts +++ b/src/keyring/index.ts @@ -2,6 +2,8 @@ import { macosBackend } from "./macos.ts" import { linuxBackend } from "./linux.ts" import { windowsBackend } from "./windows.ts" +export const SERVICE = "linear-cli" + export interface KeyringBackend { get(account: string): Promise set(account: string, password: string): Promise diff --git a/src/keyring/linux.ts b/src/keyring/linux.ts index fa995fc4..0f8338dd 100644 --- a/src/keyring/linux.ts +++ b/src/keyring/linux.ts @@ -1,7 +1,5 @@ import type { KeyringBackend } from "./index.ts" -import { run } from "./index.ts" - -const SERVICE = "linear-cli" +import { run, SERVICE } from "./index.ts" export const linuxBackend: KeyringBackend = { async get(account) { diff --git a/src/keyring/macos.ts b/src/keyring/macos.ts index 14576294..3c60246a 100644 --- a/src/keyring/macos.ts +++ b/src/keyring/macos.ts @@ -1,7 +1,5 @@ import type { KeyringBackend } from "./index.ts" -import { run } from "./index.ts" - -const SERVICE = "linear-cli" +import { run, SERVICE } from "./index.ts" export const macosBackend: KeyringBackend = { async get(account) { diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index 8d05a3d1..60506d59 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -1,7 +1,5 @@ import type { KeyringBackend } from "./index.ts" -import { run } from "./index.ts" - -const SERVICE = "linear-cli" +import { run, SERVICE } from "./index.ts" function escapePowerShell(s: string): string { return s.replace(/'/g, "''") From 47d65b6c25b086e6b5339627a3bb01e1f1eab56b Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:38:32 -0800 Subject: [PATCH 04/15] refactor: simplify default workspace validation --- src/credentials.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/credentials.ts b/src/credentials.ts index 0d834178..0d66e4cd 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -86,8 +86,9 @@ function parseKeyringCredentials(parsed: Record): Credentials { const defaultWs = typeof parsed.default === "string" ? parsed.default : undefined + const defaultIsValid = defaultWs != null && workspaces.includes(defaultWs) - if (defaultWs != null && !workspaces.includes(defaultWs)) { + if (defaultWs != null && !defaultIsValid) { console.error( yellow( `Warning: Default workspace "${defaultWs}" is not in the workspaces list. ` + @@ -97,9 +98,7 @@ function parseKeyringCredentials(parsed: Record): Credentials { } return { - default: defaultWs != null && workspaces.includes(defaultWs) - ? defaultWs - : undefined, + default: defaultIsValid ? defaultWs : undefined, workspaces, } } From 1c9d1ad499c6414c56cfae0ed55507c46e046047 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:38:44 -0800 Subject: [PATCH 05/15] refactor: simplify removeCredential default reassignment --- src/credentials.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/credentials.ts b/src/credentials.ts index 0d66e4cd..3235e5a7 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -255,11 +255,7 @@ export async function removeCredential(workspace: string): Promise { // If we removed the default, reassign it if (credentials.default === workspace) { - if (credentials.workspaces.length > 0) { - credentials.default = credentials.workspaces[0] - } else { - credentials.default = undefined - } + credentials.default = credentials.workspaces[0] } await saveCredentials() From 1f2920066fa752cd4014877c7f24f31ae7a1465d Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:39:34 -0800 Subject: [PATCH 06/15] refactor: remove redundant getApiKeyForWorkspace export --- src/commands/auth/auth-list.ts | 4 ++-- src/credentials.ts | 6 ----- test/credentials.test.ts | 44 +++++++++++++++++----------------- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/src/commands/auth/auth-list.ts b/src/commands/auth/auth-list.ts index dd0a77e2..7161c955 100644 --- a/src/commands/auth/auth-list.ts +++ b/src/commands/auth/auth-list.ts @@ -2,7 +2,7 @@ import { Command } from "@cliffy/command" import { unicodeWidth } from "@std/cli" import { gql } from "../../__codegen__/gql.ts" import { - getApiKeyForWorkspace, + getCredentialApiKey, getDefaultWorkspace, getWorkspaces, } from "../../credentials.ts" @@ -83,7 +83,7 @@ export const listCommand = new Command() // Fetch info for all workspaces in parallel const infoPromises = workspaces.map((ws) => { - const apiKey = getApiKeyForWorkspace(ws) + const apiKey = getCredentialApiKey(ws) if (apiKey == null) { const info: WorkspaceInfo = { workspace: ws, diff --git a/src/credentials.ts b/src/credentials.ts index 3235e5a7..b48c3760 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -306,11 +306,5 @@ export function hasWorkspace(workspace: string): boolean { return credentials.workspaces.includes(workspace) } -export function getApiKeyForWorkspace( - workspace: string, -): string | undefined { - return apiKeyCache.get(workspace) -} - // Load credentials at startup await loadCredentials() diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 262fe80c..909e4d1f 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -114,12 +114,12 @@ Deno.test("credentials - addCredential creates file and sets default", async () const code = ` ${ mockBackendAndImport( - "addCredential, getApiKeyForWorkspace, getDefaultWorkspace", + "addCredential, getCredentialApiKey, getDefaultWorkspace", ) } await addCredential("test-workspace", "lin_api_test123"); console.log(JSON.stringify({ - apiKey: getApiKeyForWorkspace("test-workspace"), + apiKey: getCredentialApiKey("test-workspace"), default: getDefaultWorkspace() })); ` @@ -223,12 +223,12 @@ Deno.test("credentials - removeCredential cleans up cache", async () => { const code = ` ${ mockBackendAndImport( - "addCredential, removeCredential, getApiKeyForWorkspace", + "addCredential, removeCredential, getCredentialApiKey", ) } await addCredential("workspace-a", "lin_api_a"); await removeCredential("workspace-a"); - console.log(getApiKeyForWorkspace("workspace-a") ?? "undefined"); + console.log(getCredentialApiKey("workspace-a") ?? "undefined"); ` const output = await runWithCredentials(tempDir, code) @@ -312,14 +312,14 @@ Deno.test("credentials - getCredentialApiKey returns undefined for unknown works } }) -Deno.test("credentials - getApiKeyForWorkspace reads from cache", async () => { +Deno.test("credentials - getCredentialApiKey reads from cache", async () => { const tempDir = await Deno.makeTempDir() try { const code = ` - ${mockBackendAndImport("addCredential, getApiKeyForWorkspace")} + ${mockBackendAndImport("addCredential, getCredentialApiKey")} await addCredential("ws", "lin_api_cached"); - console.log(getApiKeyForWorkspace("ws")); + console.log(getCredentialApiKey("ws")); ` const output = await runWithCredentials(tempDir, code) @@ -366,13 +366,13 @@ Deno.test("credentials - old format TOML backward compatibility", async () => { const code = ` ${ mockBackendAndImport( - "getDefaultWorkspace, getWorkspaces, getApiKeyForWorkspace, getCredentialApiKey", + "getDefaultWorkspace, getWorkspaces, getCredentialApiKey", ) } console.log(JSON.stringify({ default: getDefaultWorkspace(), workspaces: getWorkspaces(), - apiKey: getApiKeyForWorkspace("preexisting"), + apiKey: getCredentialApiKey("preexisting"), credApiKey: getCredentialApiKey(), })); ` @@ -403,14 +403,14 @@ Deno.test("credentials - old format with multiple workspaces", async () => { const code = ` ${ mockBackendAndImport( - "getDefaultWorkspace, getWorkspaces, getApiKeyForWorkspace", + "getDefaultWorkspace, getWorkspaces, getCredentialApiKey", ) } console.log(JSON.stringify({ default: getDefaultWorkspace(), workspaces: getWorkspaces().sort(), - apiKeyA: getApiKeyForWorkspace("ws-a"), - apiKeyB: getApiKeyForWorkspace("ws-b"), + apiKeyA: getCredentialApiKey("ws-a"), + apiKeyB: getCredentialApiKey("ws-b"), })); ` @@ -460,7 +460,7 @@ _setBackend({ set(_account: string, _password: string) { return Promise.reject(new Error("keyring locked")) }, delete(_account: string) { return Promise.resolve() }, }); -const { addCredential, getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +const { addCredential, getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); try { await addCredential("ws", "lin_api_key"); console.log("no-error"); @@ -468,7 +468,7 @@ try { console.log(JSON.stringify({ error: e.message, workspaces: getWorkspaces(), - cached: getApiKeyForWorkspace("ws") ?? "undefined", + cached: getCredentialApiKey("ws") ?? "undefined", })); } ` @@ -504,11 +504,11 @@ _setBackend({ set(_a: string, _p: string) { return Promise.resolve() }, delete(_a: string) { return Promise.resolve() }, }); -const { getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); console.log(JSON.stringify({ workspaces: getWorkspaces(), - okKey: getApiKeyForWorkspace("ws-ok"), - failKey: getApiKeyForWorkspace("ws-fail") ?? "undefined", + okKey: getCredentialApiKey("ws-ok"), + failKey: getCredentialApiKey("ws-fail") ?? "undefined", })); ` @@ -534,7 +534,7 @@ _setBackend({ set(account: string, password: string) { _store.set(account, password); return Promise.resolve() }, delete(_account: string) { return Promise.reject(new Error("keyring locked")) }, }); -const { addCredential, removeCredential, getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +const { addCredential, removeCredential, getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); await addCredential("ws", "lin_api_key"); try { await removeCredential("ws"); @@ -543,7 +543,7 @@ try { console.log(JSON.stringify({ error: e.message, workspaces: getWorkspaces(), - cached: getApiKeyForWorkspace("ws") ?? "undefined", + cached: getCredentialApiKey("ws") ?? "undefined", })); } ` @@ -579,11 +579,11 @@ _setBackend({ set(_a: string, _p: string) { return Promise.resolve() }, delete(_a: string) { return Promise.resolve() }, }); -const { getWorkspaces, getApiKeyForWorkspace } = await import("${credentialsUrl}"); +const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); console.log(JSON.stringify({ workspaces: getWorkspaces(), - aKey: getApiKeyForWorkspace("ws-a"), - missingKey: getApiKeyForWorkspace("ws-missing") ?? "undefined", + aKey: getCredentialApiKey("ws-a"), + missingKey: getCredentialApiKey("ws-missing") ?? "undefined", })); ` From bdc1d2953617391bb6ecfc8a1343ca31a62a7336 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:39:48 -0800 Subject: [PATCH 07/15] fix: tolerate not-found on keyring delete for Linux and Windows --- src/keyring/linux.ts | 3 ++- src/keyring/windows.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/keyring/linux.ts b/src/keyring/linux.ts index 0f8338dd..8b643314 100644 --- a/src/keyring/linux.ts +++ b/src/keyring/linux.ts @@ -53,7 +53,8 @@ export const linuxBackend: KeyringBackend = { "account", account, ]) - if (!result.success) { + // secret-tool clear returns exit 1 when no matching items are found + if (!result.success && result.code !== 1) { throw new Error( `secret-tool clear failed (exit ${result.code}): ${result.stderr}`, ) diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index 60506d59..cde8cafc 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -48,7 +48,7 @@ export const windowsBackend: KeyringBackend = { async delete(account) { const target = escapePowerShell(`${SERVICE}:${account}`) const script = - `Import-Module CredentialManager; Remove-StoredCredential -Target '${target}'` + `Import-Module CredentialManager; $c = Get-StoredCredential -Target '${target}'; if ($c) { Remove-StoredCredential -Target '${target}' }` const result = await run([ "powershell", "-NoProfile", From 8ef988abe179e30bcf0f945e47756f2d02f00d08 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:39:58 -0800 Subject: [PATCH 08/15] fix: return null for empty stdout in macOS keyring get --- src/keyring/macos.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/keyring/macos.ts b/src/keyring/macos.ts index 3c60246a..72e119e5 100644 --- a/src/keyring/macos.ts +++ b/src/keyring/macos.ts @@ -19,7 +19,7 @@ export const macosBackend: KeyringBackend = { `security find-generic-password failed (exit ${result.code}): ${result.stderr}`, ) } - return result.stdout + return result.stdout || null }, async set(account, password) { From c20693c05eefa017e22250b2cd6e17da612c2e16 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:40:10 -0800 Subject: [PATCH 09/15] fix: kill child process on stdin write failure in run() --- src/keyring/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/keyring/index.ts b/src/keyring/index.ts index 98c3effd..4d73ca00 100644 --- a/src/keyring/index.ts +++ b/src/keyring/index.ts @@ -68,6 +68,9 @@ export async function run( await writer.write(new TextEncoder().encode(options.stdin)) await writer.close() } catch (error) { + try { + process.kill() + } catch { /* already exited */ } const detail = error instanceof Error ? error.message : String(error) throw new Error(`Failed to write to stdin of ${cmd[0]}: ${detail}`) } From bbbc3afe685e9c51ee8a9b584c8863a8294a81d3 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:41:53 -0800 Subject: [PATCH 10/15] docs: update authentication docs for keyring storage --- docs/authentication.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index d697555c..cd727774 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -11,7 +11,7 @@ the CLI supports multiple authentication methods with the following precedence: ## stored credentials (recommended) -credentials are stored in `~/.config/linear/credentials.toml` and support multiple workspaces. +API keys are stored in your system's native keyring (macOS Keychain, Linux libsecret, Windows CredentialManager). workspace metadata is stored in `~/.config/linear/credentials.toml`. ### commands @@ -71,10 +71,25 @@ linear -w acme issue create --title "Bug fix" ```toml # ~/.config/linear/credentials.toml default = "acme" -acme = "lin_api_xxx" -side-project = "lin_api_yyy" +workspaces = ["acme", "side-project"] ``` +API keys are not stored in this file. they are stored in the system keyring and loaded at startup. + +### platform requirements + +- **macOS**: uses Keychain via `/usr/bin/security` (built-in) +- **Linux**: requires `secret-tool` from libsecret + - Debian/Ubuntu: `apt install libsecret-tools` + - Arch: `pacman -S libsecret` +- **Windows**: uses CredentialManager via PowerShell (built-in) + +if the keyring is unavailable, set `LINEAR_API_KEY` as a fallback. + +### migrating from plaintext credentials + +older versions stored API keys directly in the TOML file. if the CLI detects this format, it will continue to work but print a warning. run `linear auth login` for each workspace to migrate keys to the system keyring. + ## environment variable for simpler setups or CI environments, you can use an environment variable: From aa78f69eb1b5f0d2e25c82668148f75c4dca1c54 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 21:55:20 -0800 Subject: [PATCH 11/15] ci: add Windows keyring integration test --- .github/workflows/ci.yaml | 1 + src/keyring/index.ts | 2 +- src/keyring/windows.ts | 103 +++++++++++++++++++++++++++---- test/keyring.integration.test.ts | 1 - 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dcdb5388..f5cc8672 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,6 +50,7 @@ jobs: include: - os: macos-latest - os: ubuntu-latest + - os: windows-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/src/keyring/index.ts b/src/keyring/index.ts index 4d73ca00..1e21c841 100644 --- a/src/keyring/index.ts +++ b/src/keyring/index.ts @@ -24,7 +24,7 @@ function platformHint(): string { return "Could not find secret-tool. Install libsecret (e.g. apt install libsecret-tools, pacman -S libsecret).\n" + "Alternatively, set the LINEAR_API_KEY environment variable." case "windows": - return "Could not run PowerShell. Ensure PowerShell and the CredentialManager module are available." + return "Could not run PowerShell. Ensure PowerShell is available." default: return `Unsupported platform: ${Deno.build.os}` } diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index cde8cafc..f977d3b9 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -5,24 +5,101 @@ function escapePowerShell(s: string): string { return s.replace(/'/g, "''") } +// Win32 Credential Manager P/Invoke helper compiled at runtime via Add-Type. +// This avoids any dependency on the external CredentialManager PowerShell module. +const CRED_MANAGER_CS = ` +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class CredManager { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL { + public uint Flags; + public uint Type; + public string TargetName; + public string Comment; + public long LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CredWrite(ref CREDENTIAL credential, uint flags); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CredDelete(string target, uint type, uint flags); + + [DllImport("advapi32.dll")] + private static extern void CredFree(IntPtr credential); + + public static string Get(string target) { + IntPtr ptr; + if (!CredRead(target, 1, 0, out ptr)) return null; + try { + var cred = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL)); + if (cred.CredentialBlobSize == 0) return null; + return Marshal.PtrToStringUni(cred.CredentialBlob, (int)(cred.CredentialBlobSize / 2)); + } finally { + CredFree(ptr); + } + } + + public static void Set(string target, string user, string password) { + byte[] blob = Encoding.Unicode.GetBytes(password); + var cred = new CREDENTIAL { + Type = 1, + TargetName = target, + UserName = user, + CredentialBlobSize = (uint)blob.Length, + Persist = 2 + }; + cred.CredentialBlob = Marshal.AllocHGlobal(blob.Length); + try { + Marshal.Copy(blob, 0, cred.CredentialBlob, blob.Length); + if (!CredWrite(ref cred, 0)) + throw new Exception("CredWrite failed: error " + Marshal.GetLastWin32Error()); + } finally { + Marshal.FreeHGlobal(cred.CredentialBlob); + } + } + + public static bool Delete(string target) { + return CredDelete(target, 1, 0); + } +} +`.replaceAll("\n", " ") + +function credScript(code: string): string { + return `Add-Type -TypeDefinition '${CRED_MANAGER_CS}'; ${code}` +} + export const windowsBackend: KeyringBackend = { async get(account) { const target = escapePowerShell(`${SERVICE}:${account}`) - const script = - `Import-Module CredentialManager; $c = Get-StoredCredential -Target '${target}'; if ($c) { $c.GetNetworkCredential().Password }` + const script = credScript( + `$r = [CredManager]::Get('${target}'); if ($r -ne $null) { $r }`, + ) const result = await run([ "powershell", "-NoProfile", + "-NonInteractive", "-Command", script, ]) if (!result.success) { throw new Error( - `PowerShell Get-StoredCredential failed (exit ${result.code}): ${result.stderr}`, + `PowerShell credential read failed (exit ${result.code}): ${result.stderr}`, ) } - // PowerShell returns empty stdout when no credential exists; - // Linear API keys are always non-empty so treat empty as not-found return result.stdout || null }, @@ -30,34 +107,38 @@ export const windowsBackend: KeyringBackend = { const target = escapePowerShell(`${SERVICE}:${account}`) const escapedAccount = escapePowerShell(account) const escapedPassword = escapePowerShell(password) - const script = - `Import-Module CredentialManager; New-StoredCredential -Target '${target}' -UserName '${escapedAccount}' -Password '${escapedPassword}' -Type Generic -Persist LocalMachine` + const script = credScript( + `[CredManager]::Set('${target}', '${escapedAccount}', '${escapedPassword}')`, + ) const result = await run([ "powershell", "-NoProfile", + "-NonInteractive", "-Command", script, ]) if (!result.success) { throw new Error( - `PowerShell New-StoredCredential failed (exit ${result.code}): ${result.stderr}`, + `PowerShell credential write failed (exit ${result.code}): ${result.stderr}`, ) } }, async delete(account) { const target = escapePowerShell(`${SERVICE}:${account}`) - const script = - `Import-Module CredentialManager; $c = Get-StoredCredential -Target '${target}'; if ($c) { Remove-StoredCredential -Target '${target}' }` + const script = credScript( + `[CredManager]::Delete('${target}') | Out-Null`, + ) const result = await run([ "powershell", "-NoProfile", + "-NonInteractive", "-Command", script, ]) if (!result.success) { throw new Error( - `PowerShell Remove-StoredCredential failed (exit ${result.code}): ${result.stderr}`, + `PowerShell credential delete failed (exit ${result.code}): ${result.stderr}`, ) } }, diff --git a/test/keyring.integration.test.ts b/test/keyring.integration.test.ts index a404aeab..b3941d36 100644 --- a/test/keyring.integration.test.ts +++ b/test/keyring.integration.test.ts @@ -9,7 +9,6 @@ const TEST_ACCOUNT = `linear-cli-integration-test-${Date.now()}` Deno.test({ name: "keyring integration - set, get, and delete round-trip", - ignore: Deno.build.os === "windows", fn: async () => { try { assertEquals(await getPassword(TEST_ACCOUNT), null) From 6ca30d68f597a472379fd1f71e07488f437969c5 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Fri, 13 Feb 2026 08:36:49 -0800 Subject: [PATCH 12/15] refactor: replace PowerShell credential backend with Deno FFI Replace the Windows keyring backend's PowerShell subprocess + runtime C# compilation approach with direct FFI calls to advapi32.dll via Deno.dlopen. This is the standard approach taken by every comparable CLI tool (gh, docker-credential-helpers, keytar, python-keyring, etc.) and eliminates the unusual pattern of shelling out to powershell.exe and compiling C# at runtime via Add-Type. The new implementation: - Lazy-loads advapi32.dll (CredReadW, CredWriteW, CredDeleteW, CredFree) and kernel32.dll (GetLastError) on first use so the module import doesn't fail on macOS/Linux - Encodes/decodes strings as null-terminated UTF-16LE for Win32 W-suffix functions - Packs the 80-byte CREDENTIALW struct (64-bit layout) via DataView with pointer fields at correct offsets using Deno.UnsafePointer - Uses GetLastError to distinguish ERROR_NOT_FOUND (1168) from real failures, matching the existing behavior of returning null for missing credentials and tolerating not-found on delete - Works around a TS 5.9 Uint8Array generic variance issue by constructing buffers with explicit ArrayBuffer backing What's removed: escapePowerShell(), the CRED_MANAGER_CS C# source string, credScript(), all powershell subprocess invocations, and the run() import. The KeyringBackend interface is unchanged so all consumers, tests, and the CI Windows integration test are unaffected. --- src/keyring/index.ts | 2 +- src/keyring/windows.ts | 275 +++++++++++++++++++++-------------------- 2 files changed, 141 insertions(+), 136 deletions(-) diff --git a/src/keyring/index.ts b/src/keyring/index.ts index 1e21c841..c3669702 100644 --- a/src/keyring/index.ts +++ b/src/keyring/index.ts @@ -24,7 +24,7 @@ function platformHint(): string { return "Could not find secret-tool. Install libsecret (e.g. apt install libsecret-tools, pacman -S libsecret).\n" + "Alternatively, set the LINEAR_API_KEY environment variable." case "windows": - return "Could not run PowerShell. Ensure PowerShell is available." + return "Could not load advapi32.dll. Is this a Windows system?" default: return `Unsupported platform: ${Deno.build.os}` } diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index f977d3b9..f905fc02 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -1,145 +1,150 @@ import type { KeyringBackend } from "./index.ts" -import { run, SERVICE } from "./index.ts" +import { SERVICE } from "./index.ts" -function escapePowerShell(s: string): string { - return s.replace(/'/g, "''") +const ERROR_NOT_FOUND = 1168 +const CRED_TYPE_GENERIC = 1 +const CRED_PERSIST_LOCAL_MACHINE = 2 +const CREDENTIAL_SIZE = 80 + +type FfiBuffer = Uint8Array + +function ffiBuffer(size: number): FfiBuffer { + return new Uint8Array(new ArrayBuffer(size)) +} + +function encodeWideString(s: string): FfiBuffer { + const buf = ffiBuffer((s.length + 1) * 2) + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i) + buf[i * 2] = code & 0xff + buf[i * 2 + 1] = (code >> 8) & 0xff + } + return buf +} + +function decodeWideString( + ptr: Deno.PointerObject, + byteLen: number, +): string { + const view = new Deno.UnsafePointerView(ptr) + const buf = new Uint8Array(byteLen) + view.copyInto(buf) + const codes: number[] = [] + for (let i = 0; i < byteLen; i += 2) { + codes.push(buf[i] | (buf[i + 1] << 8)) + } + return String.fromCharCode(...codes) +} + +function ptrToBigInt(ptr: Deno.PointerObject | null): bigint { + if (ptr == null) return 0n + return Deno.UnsafePointer.value(ptr) +} + +function openAdvapi32() { + return Deno.dlopen("advapi32.dll", { + CredReadW: { + parameters: ["buffer", "u32", "u32", "buffer"], + result: "i32", + }, + CredWriteW: { parameters: ["buffer", "u32"], result: "i32" }, + CredDeleteW: { parameters: ["buffer", "u32", "u32"], result: "i32" }, + CredFree: { parameters: ["pointer"], result: "void" }, + }) } -// Win32 Credential Manager P/Invoke helper compiled at runtime via Add-Type. -// This avoids any dependency on the external CredentialManager PowerShell module. -const CRED_MANAGER_CS = ` -using System; -using System.Runtime.InteropServices; -using System.Text; - -public static class CredManager { - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct CREDENTIAL { - public uint Flags; - public uint Type; - public string TargetName; - public string Comment; - public long LastWritten; - public uint CredentialBlobSize; - public IntPtr CredentialBlob; - public uint Persist; - public uint AttributeCount; - public IntPtr Attributes; - public string TargetAlias; - public string UserName; - } - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern bool CredWrite(ref CREDENTIAL credential, uint flags); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern bool CredDelete(string target, uint type, uint flags); - - [DllImport("advapi32.dll")] - private static extern void CredFree(IntPtr credential); - - public static string Get(string target) { - IntPtr ptr; - if (!CredRead(target, 1, 0, out ptr)) return null; - try { - var cred = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL)); - if (cred.CredentialBlobSize == 0) return null; - return Marshal.PtrToStringUni(cred.CredentialBlob, (int)(cred.CredentialBlobSize / 2)); - } finally { - CredFree(ptr); - } - } - - public static void Set(string target, string user, string password) { - byte[] blob = Encoding.Unicode.GetBytes(password); - var cred = new CREDENTIAL { - Type = 1, - TargetName = target, - UserName = user, - CredentialBlobSize = (uint)blob.Length, - Persist = 2 - }; - cred.CredentialBlob = Marshal.AllocHGlobal(blob.Length); - try { - Marshal.Copy(blob, 0, cred.CredentialBlob, blob.Length); - if (!CredWrite(ref cred, 0)) - throw new Exception("CredWrite failed: error " + Marshal.GetLastWin32Error()); - } finally { - Marshal.FreeHGlobal(cred.CredentialBlob); - } - } - - public static bool Delete(string target) { - return CredDelete(target, 1, 0); - } +function openKernel32() { + return Deno.dlopen("kernel32.dll", { + GetLastError: { parameters: [], result: "u32" }, + }) } -`.replaceAll("\n", " ") -function credScript(code: string): string { - return `Add-Type -TypeDefinition '${CRED_MANAGER_CS}'; ${code}` +let advapi32: ReturnType | null = null +let kernel32: ReturnType | null = null + +function getAdvapi32() { + if (advapi32 != null) return advapi32 + advapi32 = openAdvapi32() + return advapi32 +} + +function getKernel32() { + if (kernel32 != null) return kernel32 + kernel32 = openKernel32() + return kernel32 +} + +function getLastError(): number { + return getKernel32().symbols.GetLastError() +} + +function credGet(account: string): string | null { + const target = encodeWideString(`${SERVICE}:${account}`) + const outBuf = ffiBuffer(8) + const lib = getAdvapi32() + + const ok = lib.symbols.CredReadW(target, CRED_TYPE_GENERIC, 0, outBuf) + if (!ok) { + const err = getLastError() + if (err === ERROR_NOT_FOUND) return null + throw new Error(`CredReadW failed (error ${err})`) + } + + const ptrValue = new DataView(outBuf.buffer).getBigUint64(0, true) + const credPtr = Deno.UnsafePointer.create(ptrValue) + if (credPtr == null) { + throw new Error("CredReadW returned null credential pointer") + } + try { + const view = new Deno.UnsafePointerView(credPtr) + const blobSize = view.getUint32(32) + if (blobSize === 0) return null + const blobPtr = view.getPointer(40) + if (blobPtr == null) return null + return decodeWideString(blobPtr, blobSize) + } finally { + lib.symbols.CredFree(credPtr) + } +} + +function credSet(account: string, password: string): void { + const targetBuf = encodeWideString(`${SERVICE}:${account}`) + const userBuf = encodeWideString(account) + const blobBuf = encodeWideString(password) + const blobSize = password.length * 2 + + const struct = ffiBuffer(CREDENTIAL_SIZE) + const dv = new DataView(struct.buffer) + + dv.setUint32(4, CRED_TYPE_GENERIC, true) + dv.setBigUint64(8, ptrToBigInt(Deno.UnsafePointer.of(targetBuf)), true) + dv.setUint32(32, blobSize, true) + dv.setBigUint64(40, ptrToBigInt(Deno.UnsafePointer.of(blobBuf)), true) + dv.setUint32(48, CRED_PERSIST_LOCAL_MACHINE, true) + dv.setBigUint64(72, ptrToBigInt(Deno.UnsafePointer.of(userBuf)), true) + + const lib = getAdvapi32() + const ok = lib.symbols.CredWriteW(struct, 0) + if (!ok) { + const err = getLastError() + throw new Error(`CredWriteW failed (error ${err})`) + } +} + +function credDelete(account: string): void { + const target = encodeWideString(`${SERVICE}:${account}`) + const lib = getAdvapi32() + + const ok = lib.symbols.CredDeleteW(target, CRED_TYPE_GENERIC, 0) + if (!ok) { + const err = getLastError() + if (err === ERROR_NOT_FOUND) return + throw new Error(`CredDeleteW failed (error ${err})`) + } } export const windowsBackend: KeyringBackend = { - async get(account) { - const target = escapePowerShell(`${SERVICE}:${account}`) - const script = credScript( - `$r = [CredManager]::Get('${target}'); if ($r -ne $null) { $r }`, - ) - const result = await run([ - "powershell", - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ]) - if (!result.success) { - throw new Error( - `PowerShell credential read failed (exit ${result.code}): ${result.stderr}`, - ) - } - return result.stdout || null - }, - - async set(account, password) { - const target = escapePowerShell(`${SERVICE}:${account}`) - const escapedAccount = escapePowerShell(account) - const escapedPassword = escapePowerShell(password) - const script = credScript( - `[CredManager]::Set('${target}', '${escapedAccount}', '${escapedPassword}')`, - ) - const result = await run([ - "powershell", - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ]) - if (!result.success) { - throw new Error( - `PowerShell credential write failed (exit ${result.code}): ${result.stderr}`, - ) - } - }, - - async delete(account) { - const target = escapePowerShell(`${SERVICE}:${account}`) - const script = credScript( - `[CredManager]::Delete('${target}') | Out-Null`, - ) - const result = await run([ - "powershell", - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ]) - if (!result.success) { - throw new Error( - `PowerShell credential delete failed (exit ${result.code}): ${result.stderr}`, - ) - } - }, + get: (account) => Promise.resolve(credGet(account)), + set: (account, password) => Promise.resolve(credSet(account, password)), + delete: (account) => Promise.resolve(credDelete(account)), } From 930b333eb0a89a3b18254c6524f50736fe5480f8 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Fri, 13 Feb 2026 08:45:05 -0800 Subject: [PATCH 13/15] fix: tolerate GetLastError returning 0 in Windows FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deno's FFI boundary clobbers the Win32 thread-local error code before GetLastError can be called through a separate dlopen call. Go, Python, and .NET capture it atomically inside their syscall trampolines; Deno's dlopen does not. Treat GetLastError() == 0 as not-found for CredReadW and CredDeleteW — the credential doesn't exist and the real ERROR_NOT_FOUND (1168) was cleared by the FFI layer. --- src/keyring/windows.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index f905fc02..f1647b26 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -86,7 +86,7 @@ function credGet(account: string): string | null { const ok = lib.symbols.CredReadW(target, CRED_TYPE_GENERIC, 0, outBuf) if (!ok) { const err = getLastError() - if (err === ERROR_NOT_FOUND) return null + if (err === ERROR_NOT_FOUND || err === 0) return null throw new Error(`CredReadW failed (error ${err})`) } @@ -138,7 +138,7 @@ function credDelete(account: string): void { const ok = lib.symbols.CredDeleteW(target, CRED_TYPE_GENERIC, 0) if (!ok) { const err = getLastError() - if (err === ERROR_NOT_FOUND) return + if (err === ERROR_NOT_FOUND || err === 0) return throw new Error(`CredDeleteW failed (error ${err})`) } } From d562098cad6b516b71bf4419ec7ad9608a8e288f Mon Sep 17 00:00:00 2001 From: bendrucker Date: Fri, 13 Feb 2026 09:11:20 -0800 Subject: [PATCH 14/15] fix: disable resource sanitizer for keyring integration test The Windows FFI backend opens advapi32.dll and kernel32.dll as process-lifetime singletons via Deno.dlopen. Deno's test runner flags these as resource leaks. Disable the resource sanitizer since these handles are intentionally never closed. --- test/keyring.integration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/keyring.integration.test.ts b/test/keyring.integration.test.ts index b3941d36..ad6f072d 100644 --- a/test/keyring.integration.test.ts +++ b/test/keyring.integration.test.ts @@ -9,6 +9,7 @@ const TEST_ACCOUNT = `linear-cli-integration-test-${Date.now()}` Deno.test({ name: "keyring integration - set, get, and delete round-trip", + sanitizeResources: Deno.build.os !== "windows", fn: async () => { try { assertEquals(await getPassword(TEST_ACCOUNT), null) From 85eea5f68d4b10774d65fc3fbb36b6ef8e9392ef Mon Sep 17 00:00:00 2001 From: bendrucker Date: Fri, 13 Feb 2026 11:01:21 -0800 Subject: [PATCH 15/15] refactor: inline subprocess helpers into platform backends Each backend now owns its subprocess logic instead of importing a shared run() from index.ts. This removes the circular-feeling dependency and makes spawn error messages specific to the actual binary (security, secret-tool) rather than a generic platformHint. Also: document CREDENTIALW struct layout and GetLastError FFI limitation in windows.ts, extract mock backend in keyring tests, fix stale PowerShell reference in docs. --- docs/authentication.md | 2 +- src/keyring/index.ts | 55 ------------------------------------- src/keyring/linux.ts | 60 ++++++++++++++++++++++++++++++++++++----- src/keyring/macos.ts | 40 +++++++++++++++++++-------- src/keyring/windows.ts | 10 +++++++ test/keyring.test.ts | 61 +++++++++++++----------------------------- 6 files changed, 112 insertions(+), 116 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index cd727774..3ce82f2a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -82,7 +82,7 @@ API keys are not stored in this file. they are stored in the system keyring and - **Linux**: requires `secret-tool` from libsecret - Debian/Ubuntu: `apt install libsecret-tools` - Arch: `pacman -S libsecret` -- **Windows**: uses CredentialManager via PowerShell (built-in) +- **Windows**: uses Credential Manager via `advapi32.dll` (built-in) if the keyring is unavailable, set `LINEAR_API_KEY` as a fallback. diff --git a/src/keyring/index.ts b/src/keyring/index.ts index c3669702..8d249cec 100644 --- a/src/keyring/index.ts +++ b/src/keyring/index.ts @@ -16,20 +16,6 @@ export function _setBackend(b: KeyringBackend | null): void { backend = b } -function platformHint(): string { - switch (Deno.build.os) { - case "darwin": - return "Could not find /usr/bin/security. Is this a macOS system?" - case "linux": - return "Could not find secret-tool. Install libsecret (e.g. apt install libsecret-tools, pacman -S libsecret).\n" + - "Alternatively, set the LINEAR_API_KEY environment variable." - case "windows": - return "Could not load advapi32.dll. Is this a Windows system?" - default: - return `Unsupported platform: ${Deno.build.os}` - } -} - function getBackend(): KeyringBackend { if (backend != null) return backend switch (Deno.build.os) { @@ -44,47 +30,6 @@ function getBackend(): KeyringBackend { } } -export async function run( - cmd: string[], - options?: { stdin?: string }, -): Promise<{ success: boolean; code: number; stdout: string; stderr: string }> { - let process: Deno.ChildProcess - try { - const command = new Deno.Command(cmd[0], { - args: cmd.slice(1), - stdin: options?.stdin != null ? "piped" : "null", - stdout: "piped", - stderr: "piped", - }) - process = command.spawn() - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - throw new Error(`${platformHint()}\n (${detail})`) - } - - if (options?.stdin != null) { - try { - const writer = process.stdin.getWriter() - await writer.write(new TextEncoder().encode(options.stdin)) - await writer.close() - } catch (error) { - try { - process.kill() - } catch { /* already exited */ } - const detail = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to write to stdin of ${cmd[0]}: ${detail}`) - } - } - - const { success, code, stdout, stderr } = await process.output() - return { - success, - code, - stdout: new TextDecoder().decode(stdout).trim(), - stderr: new TextDecoder().decode(stderr).trim(), - } -} - export async function getPassword(account: string): Promise { return await getBackend().get(account) } diff --git a/src/keyring/linux.ts b/src/keyring/linux.ts index 8b643314..ea4aa6d4 100644 --- a/src/keyring/linux.ts +++ b/src/keyring/linux.ts @@ -1,10 +1,58 @@ import type { KeyringBackend } from "./index.ts" -import { run, SERVICE } from "./index.ts" +import { SERVICE } from "./index.ts" + +function spawnError(error: unknown): never { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + "Could not run secret-tool. Install libsecret " + + "(e.g. apt install libsecret-tools, pacman -S libsecret).\n" + + "Alternatively, set the LINEAR_API_KEY environment variable.\n" + + ` (${detail})`, + ) +} + +async function secretTool( + args: string[], + options?: { stdin?: string }, +) { + let process: Deno.ChildProcess + try { + process = new Deno.Command("secret-tool", { + args, + stdin: options?.stdin != null ? "piped" : "null", + stdout: "piped", + stderr: "piped", + }).spawn() + } catch (error) { + spawnError(error) + } + + if (options?.stdin != null) { + try { + const writer = process.stdin.getWriter() + await writer.write(new TextEncoder().encode(options.stdin)) + await writer.close() + } catch (error) { + try { + process.kill() + } catch { /* already exited */ } + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to write to stdin of secret-tool: ${detail}`) + } + } + + const result = await process.output() + return { + success: result.success, + code: result.code, + stdout: new TextDecoder().decode(result.stdout).trim(), + stderr: new TextDecoder().decode(result.stderr).trim(), + } +} export const linuxBackend: KeyringBackend = { async get(account) { - const result = await run([ - "secret-tool", + const result = await secretTool([ "lookup", "service", SERVICE, @@ -24,9 +72,8 @@ export const linuxBackend: KeyringBackend = { }, async set(account, password) { - const result = await run( + const result = await secretTool( [ - "secret-tool", "store", "--label", `${SERVICE}: ${account}`, @@ -45,8 +92,7 @@ export const linuxBackend: KeyringBackend = { }, async delete(account) { - const result = await run([ - "secret-tool", + const result = await secretTool([ "clear", "service", SERVICE, diff --git a/src/keyring/macos.ts b/src/keyring/macos.ts index 72e119e5..80c29ee9 100644 --- a/src/keyring/macos.ts +++ b/src/keyring/macos.ts @@ -1,17 +1,37 @@ import type { KeyringBackend } from "./index.ts" -import { run, SERVICE } from "./index.ts" +import { SERVICE } from "./index.ts" + +async function security(...args: string[]) { + try { + const result = await new Deno.Command("/usr/bin/security", { + args, + stdout: "piped", + stderr: "piped", + }).output() + return { + success: result.success, + code: result.code, + stdout: new TextDecoder().decode(result.stdout).trim(), + stderr: new TextDecoder().decode(result.stderr).trim(), + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Could not run /usr/bin/security. Is this a macOS system?\n (${detail})`, + ) + } +} export const macosBackend: KeyringBackend = { async get(account) { - const result = await run([ - "/usr/bin/security", + const result = await security( "find-generic-password", "-a", account, "-s", SERVICE, "-w", - ]) + ) if (!result.success) { // exit 44 = errSecItemNotFound if (result.code === 44) return null @@ -23,8 +43,7 @@ export const macosBackend: KeyringBackend = { }, async set(account, password) { - const result = await run([ - "/usr/bin/security", + const result = await security( "add-generic-password", "-a", account, @@ -32,8 +51,8 @@ export const macosBackend: KeyringBackend = { SERVICE, "-w", password, - "-U", // update the item if it already exists - ]) + "-U", + ) if (!result.success) { throw new Error( `security add-generic-password failed (exit ${result.code}): ${result.stderr}`, @@ -42,14 +61,13 @@ export const macosBackend: KeyringBackend = { }, async delete(account) { - const result = await run([ - "/usr/bin/security", + const result = await security( "delete-generic-password", "-a", account, "-s", SERVICE, - ]) + ) // exit 44 = errSecItemNotFound if (!result.success && result.code !== 44) { throw new Error( diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index f1647b26..d33fd1b8 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -4,6 +4,12 @@ import { SERVICE } from "./index.ts" const ERROR_NOT_FOUND = 1168 const CRED_TYPE_GENERIC = 1 const CRED_PERSIST_LOCAL_MACHINE = 2 +// CREDENTIALW struct layout on 64-bit Windows (80 bytes): +// 0: Flags (u32) 4: Type (u32) 8: TargetName (ptr) +// 16: Comment (ptr) 24: LastWritten (i64) 32: CredentialBlobSize (u32) +// 36: (padding) 40: CredentialBlob (ptr) 48: Persist (u32) +// 52: AttributeCount 56: Attributes (ptr) 64: TargetAlias (ptr) +// 72: UserName (ptr) const CREDENTIAL_SIZE = 80 type FfiBuffer = Uint8Array @@ -86,6 +92,9 @@ function credGet(account: string): string | null { const ok = lib.symbols.CredReadW(target, CRED_TYPE_GENERIC, 0, outBuf) if (!ok) { const err = getLastError() + // Deno's FFI boundary clobbers the Win32 thread-local error before + // GetLastError can read it through a separate dlopen call. Treat 0 + // (no error set) as "not found" since that's the only expected failure. if (err === ERROR_NOT_FOUND || err === 0) return null throw new Error(`CredReadW failed (error ${err})`) } @@ -138,6 +147,7 @@ function credDelete(account: string): void { const ok = lib.symbols.CredDeleteW(target, CRED_TYPE_GENERIC, 0) if (!ok) { const err = getLastError() + // See credGet for why err === 0 is treated as "not found" if (err === ERROR_NOT_FOUND || err === 0) return throw new Error(`CredDeleteW failed (error ${err})`) } diff --git a/test/keyring.test.ts b/test/keyring.test.ts index 30c071a2..560c5a83 100644 --- a/test/keyring.test.ts +++ b/test/keyring.test.ts @@ -4,6 +4,19 @@ import { fromFileUrl } from "@std/path" const keyringUrl = new URL("../src/keyring/index.ts", import.meta.url) const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url)) +const MOCK_BACKEND = ` +const _store = new Map(); +_setBackend({ + get(account) { return Promise.resolve(_store.get(account) ?? null) }, + set(account, password) { _store.set(account, password); return Promise.resolve() }, + delete(account) { _store.delete(account); return Promise.resolve() }, +}); +`.trim() + +function mockAndImport(imports: string): string { + return `import { ${imports}, _setBackend } from "${keyringUrl}";\n${MOCK_BACKEND}` +} + async function runWithKeyring(code: string): Promise { const command = new Deno.Command("deno", { args: [ @@ -28,13 +41,7 @@ async function runWithKeyring(code: string): Promise { Deno.test("keyring - getPassword returns null when not set", async () => { const code = ` - import { getPassword, _setBackend } from "${keyringUrl}"; - _setBackend({ - store: new Map(), - get(account) { return Promise.resolve(this.store.get(account) ?? null) }, - set(account, password) { this.store.set(account, password); return Promise.resolve() }, - delete(account) { this.store.delete(account); return Promise.resolve() }, - }); + ${mockAndImport("getPassword")} const result = await getPassword("missing"); console.log(result === null ? "null" : result); ` @@ -44,13 +51,7 @@ Deno.test("keyring - getPassword returns null when not set", async () => { Deno.test("keyring - setPassword and getPassword round-trip", async () => { const code = ` - import { getPassword, setPassword, _setBackend } from "${keyringUrl}"; - _setBackend({ - store: new Map(), - get(account) { return Promise.resolve(this.store.get(account) ?? null) }, - set(account, password) { this.store.set(account, password); return Promise.resolve() }, - delete(account) { this.store.delete(account); return Promise.resolve() }, - }); + ${mockAndImport("getPassword, setPassword")} await setPassword("my-account", "secret123"); const result = await getPassword("my-account"); console.log(result); @@ -61,13 +62,7 @@ Deno.test("keyring - setPassword and getPassword round-trip", async () => { Deno.test("keyring - deletePassword removes stored password", async () => { const code = ` - import { getPassword, setPassword, deletePassword, _setBackend } from "${keyringUrl}"; - _setBackend({ - store: new Map(), - get(account) { return Promise.resolve(this.store.get(account) ?? null) }, - set(account, password) { this.store.set(account, password); return Promise.resolve() }, - delete(account) { this.store.delete(account); return Promise.resolve() }, - }); + ${mockAndImport("getPassword, setPassword, deletePassword")} await setPassword("my-account", "secret123"); await deletePassword("my-account"); const result = await getPassword("my-account"); @@ -79,13 +74,7 @@ Deno.test("keyring - deletePassword removes stored password", async () => { Deno.test("keyring - setPassword overwrites existing value", async () => { const code = ` - import { getPassword, setPassword, _setBackend } from "${keyringUrl}"; - _setBackend({ - store: new Map(), - get(account) { return Promise.resolve(this.store.get(account) ?? null) }, - set(account, password) { this.store.set(account, password); return Promise.resolve() }, - delete(account) { this.store.delete(account); return Promise.resolve() }, - }); + ${mockAndImport("getPassword, setPassword")} await setPassword("my-account", "first"); await setPassword("my-account", "second"); const result = await getPassword("my-account"); @@ -97,13 +86,7 @@ Deno.test("keyring - setPassword overwrites existing value", async () => { Deno.test("keyring - multiple accounts are independent", async () => { const code = ` - import { getPassword, setPassword, _setBackend } from "${keyringUrl}"; - _setBackend({ - store: new Map(), - get(account) { return Promise.resolve(this.store.get(account) ?? null) }, - set(account, password) { this.store.set(account, password); return Promise.resolve() }, - delete(account) { this.store.delete(account); return Promise.resolve() }, - }); + ${mockAndImport("getPassword, setPassword")} await setPassword("account-a", "password-a"); await setPassword("account-b", "password-b"); const a = await getPassword("account-a"); @@ -118,13 +101,7 @@ Deno.test("keyring - multiple accounts are independent", async () => { Deno.test("keyring - deletePassword on missing account is a no-op", async () => { const code = ` - import { deletePassword, _setBackend } from "${keyringUrl}"; - _setBackend({ - store: new Map(), - get(account) { return Promise.resolve(this.store.get(account) ?? null) }, - set(account, password) { this.store.set(account, password); return Promise.resolve() }, - delete(account) { this.store.delete(account); return Promise.resolve() }, - }); + ${mockAndImport("deletePassword")} await deletePassword("nonexistent"); console.log("ok"); `