diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 74bc3825..f5cc8672 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,29 @@ jobs: git diff skills/ exit 1 fi + + keyring-integration: + strategy: + matrix: + include: + - os: macos-latest + - os: ubuntu-latest + - os: windows-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/docs/authentication.md b/docs/authentication.md index d697555c..3ce82f2a 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 Credential Manager via `advapi32.dll` (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: diff --git a/src/commands/auth/auth-list.ts b/src/commands/auth/auth-list.ts index 94ade2eb..7161c955 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, + getCredentialApiKey, 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 = getCredentialApiKey(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..b48c3760 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,13 +1,21 @@ 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" + +function errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} 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 +40,149 @@ 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 + const defaultIsValid = defaultWs != null && workspaces.includes(defaultWs) + + if (defaultWs != null && !defaultIsValid) { + console.error( + yellow( + `Warning: Default workspace "${defaultWs}" is not in the workspaces list. ` + + `Run \`linear auth default \` to set a valid default.`, + ), + ) + } + + return { + default: defaultIsValid ? 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}": ${ + errorDetail(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}: ${errorDetail(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: ${errorDetail(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 +194,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 +211,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}": ${ + errorDetail(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 +240,47 @@ 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}": ${ + errorDetail(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] - } else { - delete creds.default - } + if (credentials.default === workspace) { + credentials.default = credentials.workspaces[0] } - 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 +293,17 @@ 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" -} - -/** - * Get all credentials (for listing purposes). - */ -export function getAllCredentials(): Credentials { - return { ...credentials } + return credentials.workspaces.includes(workspace) } // Load credentials at startup diff --git a/src/keyring/index.ts b/src/keyring/index.ts new file mode 100644 index 00000000..8d249cec --- /dev/null +++ b/src/keyring/index.ts @@ -0,0 +1,46 @@ +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 + delete(account: string): Promise +} + +let backend: KeyringBackend | null = null + +export function _setBackend(b: KeyringBackend | null): void { + backend = b +} + +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 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..ea4aa6d4 --- /dev/null +++ b/src/keyring/linux.ts @@ -0,0 +1,109 @@ +import type { KeyringBackend } 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 secretTool([ + "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 secretTool( + [ + "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 secretTool([ + "clear", + "service", + SERVICE, + "account", + account, + ]) + // 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/macos.ts b/src/keyring/macos.ts new file mode 100644 index 00000000..80c29ee9 --- /dev/null +++ b/src/keyring/macos.ts @@ -0,0 +1,78 @@ +import type { KeyringBackend } 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 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 || null + }, + + async set(account, password) { + const result = await security( + "add-generic-password", + "-a", + account, + "-s", + SERVICE, + "-w", + password, + "-U", + ) + if (!result.success) { + throw new Error( + `security add-generic-password failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, + + async delete(account) { + const result = await 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..d33fd1b8 --- /dev/null +++ b/src/keyring/windows.ts @@ -0,0 +1,160 @@ +import type { KeyringBackend } from "./index.ts" +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 + +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" }, + }) +} + +function openKernel32() { + return Deno.dlopen("kernel32.dll", { + GetLastError: { parameters: [], result: "u32" }, + }) +} + +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() + // 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})`) + } + + 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() + // 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})`) + } +} + +export const windowsBackend: KeyringBackend = { + get: (account) => Promise.resolve(credGet(account)), + set: (account, password) => Promise.resolve(credSet(account, password)), + delete: (account) => Promise.resolve(credDelete(account)), +} diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 674ce249..909e4d1f 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, getCredentialApiKey, getDefaultWorkspace", + ) + } await addCredential("test-workspace", "lin_api_test123"); - const creds = getAllCredentials(); console.log(JSON.stringify({ - creds, + apiKey: getCredentialApiKey("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, getCredentialApiKey", + ) + } + await addCredential("workspace-a", "lin_api_a"); + await removeCredential("workspace-a"); + console.log(getCredentialApiKey("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 - getCredentialApiKey reads from cache", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${mockBackendAndImport("addCredential, getCredentialApiKey")} + await addCredential("ws", "lin_api_cached"); + console.log(getCredentialApiKey("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, getCredentialApiKey", + ) + } console.log(JSON.stringify({ default: getDefaultWorkspace(), - creds: getAllCredentials() + workspaces: getWorkspaces(), + apiKey: getCredentialApiKey("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, getCredentialApiKey", + ) + } + console.log(JSON.stringify({ + default: getDefaultWorkspace(), + workspaces: getWorkspaces().sort(), + apiKeyA: getCredentialApiKey("ws-a"), + apiKeyB: getCredentialApiKey("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, getCredentialApiKey } = 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: getCredentialApiKey("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, getCredentialApiKey } = await import("${credentialsUrl}"); +console.log(JSON.stringify({ + workspaces: getWorkspaces(), + okKey: getCredentialApiKey("ws-ok"), + failKey: getCredentialApiKey("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, getCredentialApiKey } = 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: getCredentialApiKey("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, getCredentialApiKey } = await import("${credentialsUrl}"); +console.log(JSON.stringify({ + workspaces: getWorkspaces(), + aKey: getCredentialApiKey("ws-a"), + missingKey: getCredentialApiKey("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..ad6f072d --- /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", + sanitizeResources: 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..560c5a83 --- /dev/null +++ b/test/keyring.test.ts @@ -0,0 +1,110 @@ +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)) + +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: [ + "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 = ` + ${mockAndImport("getPassword")} + 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 = ` + ${mockAndImport("getPassword, setPassword")} + 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 = ` + ${mockAndImport("getPassword, setPassword, deletePassword")} + 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 = ` + ${mockAndImport("getPassword, setPassword")} + 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 = ` + ${mockAndImport("getPassword, setPassword")} + 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 = ` + ${mockAndImport("deletePassword")} + await deletePassword("nonexistent"); + console.log("ok"); + ` + const output = await runWithKeyring(code) + assertEquals(output, "ok") +})