From c9ade3d098b04fde721c183591014c411682a1b2 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Thu, 12 Feb 2026 20:54:29 -0800 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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"); ` From 3cf4b7c79c5a00a8d84b699ed44eae4c17703578 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Wed, 4 Mar 2026 21:33:16 -0800 Subject: [PATCH 16/22] feat: backward-compatible credential storage with keyring opt-in Read path silently accepts inline (plaintext TOML) credentials without printing a migration warning. Write path feature-detects the keyring and defaults to it when available. Users without a keyring get an explicit error directing them to --plaintext or LINEAR_API_KEY. - Remove inline-format migration warning from loadCredentials - Add isAvailable() to KeyringBackend interface and all platforms - Add --plaintext flag to auth login command - Support plaintext option in addCredential for inline TOML storage --- src/commands/auth/auth-login.ts | 17 +++++++- src/credentials.ts | 72 +++++++++++++++++++++++++-------- src/keyring/index.ts | 9 +++++ src/keyring/linux.ts | 13 ++++++ src/keyring/macos.ts | 4 ++ src/keyring/windows.ts | 4 ++ 6 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/commands/auth/auth-login.ts b/src/commands/auth/auth-login.ts index 1b630d3f..b6fc2473 100644 --- a/src/commands/auth/auth-login.ts +++ b/src/commands/auth/auth-login.ts @@ -7,6 +7,7 @@ import { getWorkspaces, hasWorkspace, } from "../../credentials.ts" +import { isKeyringAvailable } from "../../keyring/index.ts" import { AuthError, CliError, @@ -32,6 +33,10 @@ export const loginCommand = new Command() .name("login") .description("Add a workspace credential") .option("-k, --key ", "API key (prompted if not provided)") + .option( + "--plaintext", + "Store API key in credentials file instead of system keyring", + ) .action(async (options) => { try { let apiKey = options.key @@ -58,8 +63,18 @@ export const loginCommand = new Command() const org = viewer.organization const workspace = org.urlKey + let plaintext = options.plaintext ?? false + if (!plaintext) { + const keyringOk = await isKeyringAvailable() + if (!keyringOk) { + throw new CliError( + "No system keyring found. Use `--plaintext` to store credentials in the config file, or set `LINEAR_API_KEY`.", + ) + } + } + const alreadyExists = hasWorkspace(workspace) - await addCredential(workspace, apiKey) + await addCredential(workspace, apiKey, { plaintext }) const existingCount = getWorkspaces().length diff --git a/src/credentials.ts b/src/credentials.ts index b48c3760..0cbf596b 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -2,7 +2,12 @@ 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" +import { + deletePassword, + getPassword, + isKeyringAvailable, + setPassword, +} from "./keyring/index.ts" function errorDetail(error: unknown): string { return error instanceof Error ? error.message : String(error) @@ -164,12 +169,6 @@ export async function loadCredentials(): Promise { 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 } @@ -203,23 +202,60 @@ async function saveCredentials(): Promise { await Deno.writeTextFile(path, stringify(ordered)) } +/** + * Save credentials in inline (plaintext) format, storing the API key + * directly in the TOML file rather than in the system keyring. + */ +async function saveInlineCredentials( + workspace: string, + apiKey: string, +): Promise { + const path = getCredentialsPath() + if (!path) { + throw new Error("Could not determine credentials path") + } + + const dir = dirname(path) + await ensureDir(dir) + + const ordered: Record = {} + if (credentials.default != null) { + ordered.default = credentials.default + } + for (const ws of [...credentials.workspaces].sort()) { + const key = ws === workspace ? apiKey : apiKeyCache.get(ws) + if (key != null) { + ordered[ws] = key + } + } + + await Deno.writeTextFile(path, stringify(ordered)) +} + /** * Add or update a credential. * If this is the first workspace, it becomes the default. + * When `plaintext` is true, the key is stored directly in the TOML file. */ export async function addCredential( workspace: string, apiKey: string, + options?: { plaintext?: boolean }, ): Promise { - try { - await setPassword(workspace, apiKey) - } catch (error) { - throw new Error( - `Failed to store API key in system keyring for workspace "${workspace}": ${ - errorDetail(error) - }`, - ) + const plaintext = options?.plaintext ?? false + + if (!plaintext) { + 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) @@ -232,7 +268,11 @@ export async function addCredential( credentials.default = workspace } - await saveCredentials() + if (plaintext) { + await saveInlineCredentials(workspace, apiKey) + } else { + await saveCredentials() + } } /** diff --git a/src/keyring/index.ts b/src/keyring/index.ts index 8d249cec..a0c3cca7 100644 --- a/src/keyring/index.ts +++ b/src/keyring/index.ts @@ -8,6 +8,7 @@ export interface KeyringBackend { get(account: string): Promise set(account: string, password: string): Promise delete(account: string): Promise + isAvailable(): Promise } let backend: KeyringBackend | null = null @@ -44,3 +45,11 @@ export async function setPassword( export async function deletePassword(account: string): Promise { await getBackend().delete(account) } + +export async function isKeyringAvailable(): Promise { + try { + return await getBackend().isAvailable() + } catch { + return false + } +} diff --git a/src/keyring/linux.ts b/src/keyring/linux.ts index ea4aa6d4..f79772df 100644 --- a/src/keyring/linux.ts +++ b/src/keyring/linux.ts @@ -51,6 +51,19 @@ async function secretTool( } export const linuxBackend: KeyringBackend = { + async isAvailable() { + try { + const result = await new Deno.Command("secret-tool", { + args: ["--version"], + stdout: "piped", + stderr: "piped", + }).output() + return result.success + } catch { + return false + } + }, + async get(account) { const result = await secretTool([ "lookup", diff --git a/src/keyring/macos.ts b/src/keyring/macos.ts index 80c29ee9..90d89b42 100644 --- a/src/keyring/macos.ts +++ b/src/keyring/macos.ts @@ -23,6 +23,10 @@ async function security(...args: string[]) { } export const macosBackend: KeyringBackend = { + async isAvailable() { + return true + }, + async get(account) { const result = await security( "find-generic-password", diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index d33fd1b8..c08c86b5 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -154,6 +154,10 @@ function credDelete(account: string): void { } export const windowsBackend: KeyringBackend = { + async isAvailable() { + return true + }, + get: (account) => Promise.resolve(credGet(account)), set: (account, password) => Promise.resolve(credSet(account, password)), delete: (account) => Promise.resolve(credDelete(account)), From 904f1cfbe12a57720dc584f5b7808ed257c2fad7 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Wed, 4 Mar 2026 21:36:55 -0800 Subject: [PATCH 17/22] test: add plaintext credential storage tests, fix lint issues - Test inline credentials load without stderr warning - Test addCredential with plaintext writes key to TOML - Test addCredential without plaintext uses keyring format - Convert mock backends from Promise.resolve to async functions - Fix lint: const instead of let, remove unused import --- src/commands/auth/auth-login.ts | 2 +- src/credentials.ts | 7 +- src/keyring/macos.ts | 4 +- src/keyring/windows.ts | 4 +- test/credentials.test.ts | 156 +++++++++++++++++++++++++++----- 5 files changed, 138 insertions(+), 35 deletions(-) diff --git a/src/commands/auth/auth-login.ts b/src/commands/auth/auth-login.ts index b6fc2473..f7ba7fc2 100644 --- a/src/commands/auth/auth-login.ts +++ b/src/commands/auth/auth-login.ts @@ -63,7 +63,7 @@ export const loginCommand = new Command() const org = viewer.organization const workspace = org.urlKey - let plaintext = options.plaintext ?? false + const plaintext = options.plaintext ?? false if (!plaintext) { const keyringOk = await isKeyringAvailable() if (!keyringOk) { diff --git a/src/credentials.ts b/src/credentials.ts index 0cbf596b..4dcacae2 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -2,12 +2,7 @@ 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, - isKeyringAvailable, - setPassword, -} from "./keyring/index.ts" +import { deletePassword, getPassword, setPassword } from "./keyring/index.ts" function errorDetail(error: unknown): string { return error instanceof Error ? error.message : String(error) diff --git a/src/keyring/macos.ts b/src/keyring/macos.ts index 90d89b42..aaabd23b 100644 --- a/src/keyring/macos.ts +++ b/src/keyring/macos.ts @@ -23,9 +23,7 @@ async function security(...args: string[]) { } export const macosBackend: KeyringBackend = { - async isAvailable() { - return true - }, + isAvailable: () => Promise.resolve(true), async get(account) { const result = await security( diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts index c08c86b5..0ac93cdf 100644 --- a/src/keyring/windows.ts +++ b/src/keyring/windows.ts @@ -154,9 +154,7 @@ function credDelete(account: string): void { } export const windowsBackend: KeyringBackend = { - async isAvailable() { - return true - }, + isAvailable: () => Promise.resolve(true), get: (account) => Promise.resolve(credGet(account)), set: (account, password) => Promise.resolve(credSet(account, password)), diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 909e4d1f..8bbe78bb 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -19,9 +19,10 @@ function mockBackendAndImport(imports: string): string { 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() }, + async get(account: string) { return _store.get(account) ?? null }, + async set(account: string, password: string) { _store.set(account, password) }, + async delete(account: string) { _store.delete(account) }, + async isAvailable() { return true }, }); const { ${imports} } = await import("${credentialsUrl}"); ` @@ -456,9 +457,10 @@ Deno.test("credentials - addCredential throws when keyring write fails", async ( 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() }, + async get(_account: string) { return null }, + async set(_account: string, _password: string) { throw new Error("keyring locked") }, + async delete(_account: string) {}, + async isAvailable() { return true }, }); const { addCredential, getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); try { @@ -497,12 +499,13 @@ Deno.test("credentials - loadCredentials warns but continues when keyring fails 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"); + async get(account: string) { + if (account === "ws-fail") throw new Error("keyring error"); + return "lin_api_ok"; }, - set(_a: string, _p: string) { return Promise.resolve() }, - delete(_a: string) { return Promise.resolve() }, + async set(_a: string, _p: string) {}, + async delete(_a: string) {}, + async isAvailable() { return true }, }); const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); console.log(JSON.stringify({ @@ -530,9 +533,10 @@ Deno.test("credentials - removeCredential throws when keyring delete fails", asy 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")) }, + async get(account: string) { return _store.get(account) ?? null }, + async set(account: string, password: string) { _store.set(account, password) }, + async delete(_account: string) { throw new Error("keyring locked") }, + async isAvailable() { return true }, }); const { addCredential, removeCredential, getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); await addCredential("ws", "lin_api_key"); @@ -572,12 +576,13 @@ Deno.test("credentials - loadCredentials warns when keyring returns null for wor const code = ` import { _setBackend } from "${keyringUrl}"; _setBackend({ - get(account: string) { - if (account === "ws-missing") return Promise.resolve(null); - return Promise.resolve("lin_api_a"); + async get(account: string) { + if (account === "ws-missing") return null; + return "lin_api_a"; }, - set(_a: string, _p: string) { return Promise.resolve() }, - delete(_a: string) { return Promise.resolve() }, + async set(_a: string, _p: string) {}, + async delete(_a: string) {}, + async isAvailable() { return true }, }); const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); console.log(JSON.stringify({ @@ -644,9 +649,10 @@ Deno.test("credentials - dangling default is dropped on load", async () => { 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() }, + async get(_account: string) { return "lin_api_real" }, + async set(_a: string, _p: string) {}, + async delete(_a: string) {}, + async isAvailable() { return true }, }); const { getDefaultWorkspace, getWorkspaces } = await import("${credentialsUrl}"); console.log(JSON.stringify({ @@ -663,3 +669,109 @@ console.log(JSON.stringify({ await Deno.remove(tempDir, { recursive: true }) } }) + +Deno.test("credentials - loading inline credentials does not print warning to stderr", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "my-ws"\nmy-ws = "lin_api_key"\n`, + ) + + const isWindows = Deno.build.os === "windows" + 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") ?? "", + } + + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + async get(_account: string) { return null }, + async set(_account: string, _password: string) {}, + async delete(_account: string) {}, + async isAvailable() { return true }, +}); +const { getCredentialApiKey } = await import("${credentialsUrl}"); +console.log(getCredentialApiKey("my-ws")); + ` + + const command = new Deno.Command("deno", { + args: ["eval", `--config=${denoJsonPath}`, code], + cwd: tempDir, + env, + stdout: "piped", + stderr: "piped", + }) + + const { stdout, stderr } = await command.output() + const output = new TextDecoder().decode(stdout).trim() + const errorOutput = new TextDecoder().decode(stderr) + + assertEquals(output, "lin_api_key") + assertEquals(errorOutput.includes("Warning"), false) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - addCredential with plaintext writes key to TOML file", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${ + mockBackendAndImport( + "addCredential, getCredentialsPath, getCredentialApiKey", + ) + } + await addCredential("my-ws", "lin_api_plain", { plaintext: true }); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(JSON.stringify({ + apiKey: getCredentialApiKey("my-ws"), + hasInlineKey: toml.includes("lin_api_plain"), + hasWorkspacesArray: toml.includes("workspaces"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + + assertEquals(result.apiKey, "lin_api_plain") + assertEquals(result.hasInlineKey, true) + assertEquals(result.hasWorkspacesArray, false) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - addCredential without plaintext uses keyring", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${mockBackendAndImport("addCredential, getCredentialsPath")} + await addCredential("my-ws", "lin_api_secret"); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(JSON.stringify({ + hasInlineKey: toml.includes("lin_api_secret"), + hasWorkspacesArray: toml.includes("workspaces"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + + assertEquals(result.hasInlineKey, false) + assertEquals(result.hasWorkspacesArray, true) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) From bdbafbe0d4a1b9cc6f6eb2526ba513997e938108 Mon Sep 17 00:00:00 2001 From: bendrucker Date: Fri, 6 Mar 2026 06:24:20 -0800 Subject: [PATCH 18/22] docs: re-render skill docs --- skills/linear-cli/SKILL.md | 4 +- skills/linear-cli/references/api.md | 2 +- skills/linear-cli/references/auth.md | 14 ++-- skills/linear-cli/references/commands.md | 3 +- skills/linear-cli/references/config.md | 2 +- skills/linear-cli/references/cycle.md | 64 +++++++++++++++ skills/linear-cli/references/document.md | 12 +-- .../references/initiative-update.md | 6 +- skills/linear-cli/references/initiative.md | 20 ++--- skills/linear-cli/references/issue.md | 78 ++++++++++++------- skills/linear-cli/references/label.md | 8 +- skills/linear-cli/references/milestone.md | 12 +-- .../linear-cli/references/project-update.md | 6 +- skills/linear-cli/references/project.md | 66 +++++++++++++--- skills/linear-cli/references/schema.md | 2 +- skills/linear-cli/references/team.md | 14 ++-- 16 files changed, 225 insertions(+), 88 deletions(-) create mode 100644 skills/linear-cli/references/cycle.md diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index 230bc702..42f01aa3 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -8,7 +8,7 @@ allowed-tools: Bash(linear:*), Bash(curl:*) A CLI to manage Linear issues from the command line, with git and jj integration. -Generated from linear CLI v1.9.1 +Generated from linear CLI v1.11.0 ## Prerequisites @@ -67,6 +67,7 @@ linear issue # Manage Linear issues linear team # Manage Linear teams linear project # Manage Linear projects linear project-update # Manage project status updates +linear cycle # Manage Linear team cycles linear milestone # Manage Linear project milestones linear initiative # Manage Linear initiatives linear initiative-update # Manage initiative status updates (timeline posts) @@ -84,6 +85,7 @@ linear api # Make a raw GraphQL API request - [team](references/team.md) - Manage Linear teams - [project](references/project.md) - Manage Linear projects - [project-update](references/project-update.md) - Manage project status updates +- [cycle](references/cycle.md) - Manage Linear team cycles - [milestone](references/milestone.md) - Manage Linear project milestones - [initiative](references/initiative.md) - Manage Linear initiatives - [initiative-update](references/initiative-update.md) - Manage initiative status updates (timeline posts) diff --git a/skills/linear-cli/references/api.md b/skills/linear-cli/references/api.md index 4bae939e..b5fb2219 100644 --- a/skills/linear-cli/references/api.md +++ b/skills/linear-cli/references/api.md @@ -6,7 +6,7 @@ ``` Usage: linear api [query] -Version: 1.9.1 +Version: 1.11.0 Description: diff --git a/skills/linear-cli/references/auth.md b/skills/linear-cli/references/auth.md index 0ad554a7..01378afd 100644 --- a/skills/linear-cli/references/auth.md +++ b/skills/linear-cli/references/auth.md @@ -6,7 +6,7 @@ ``` Usage: linear auth -Version: 1.9.1 +Version: 1.11.0 Description: @@ -35,7 +35,7 @@ Commands: ``` Usage: linear auth login -Version: 1.9.1 +Version: 1.11.0 Description: @@ -54,7 +54,7 @@ Options: ``` Usage: linear auth logout [workspace] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -73,7 +73,7 @@ Options: ``` Usage: linear auth list -Version: 1.9.1 +Version: 1.11.0 Description: @@ -91,7 +91,7 @@ Options: ``` Usage: linear auth default [workspace] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -109,7 +109,7 @@ Options: ``` Usage: linear auth token -Version: 1.9.1 +Version: 1.11.0 Description: @@ -127,7 +127,7 @@ Options: ``` Usage: linear auth whoami -Version: 1.9.1 +Version: 1.11.0 Description: diff --git a/skills/linear-cli/references/commands.md b/skills/linear-cli/references/commands.md index e9fc570c..8a394687 100644 --- a/skills/linear-cli/references/commands.md +++ b/skills/linear-cli/references/commands.md @@ -1,6 +1,6 @@ # Linear CLI Command Reference -Generated from linear CLI v1.9.1 +Generated from linear CLI v1.11.0 ## Commands @@ -9,6 +9,7 @@ Generated from linear CLI v1.9.1 - [team](./team.md) - Manage Linear teams - [project](./project.md) - Manage Linear projects - [project-update](./project-update.md) - Manage project status updates +- [cycle](./cycle.md) - Manage Linear team cycles - [milestone](./milestone.md) - Manage Linear project milestones - [initiative](./initiative.md) - Manage Linear initiatives - [initiative-update](./initiative-update.md) - Manage initiative status updates (timeline posts) diff --git a/skills/linear-cli/references/config.md b/skills/linear-cli/references/config.md index 9dd9a799..b9465a03 100644 --- a/skills/linear-cli/references/config.md +++ b/skills/linear-cli/references/config.md @@ -6,7 +6,7 @@ ``` Usage: linear config -Version: 1.9.1 +Version: 1.11.0 Description: diff --git a/skills/linear-cli/references/cycle.md b/skills/linear-cli/references/cycle.md new file mode 100644 index 00000000..4665acbd --- /dev/null +++ b/skills/linear-cli/references/cycle.md @@ -0,0 +1,64 @@ +# cycle + +> Manage Linear team cycles + +## Usage + +``` +Usage: linear cycle +Version: 1.11.0 + +Description: + + Manage Linear team cycles + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + +Commands: + + list - List cycles for a team + view, v - View cycle details +``` + +## Subcommands + +### list + +> List cycles for a team + +``` +Usage: linear cycle list +Version: 1.11.0 + +Description: + + List cycles for a team + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + --team - Team key (defaults to current team) +``` + +### view + +> View cycle details + +``` +Usage: linear cycle view +Version: 1.11.0 + +Description: + + View cycle details + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + --team - Team key (defaults to current team) +``` diff --git a/skills/linear-cli/references/document.md b/skills/linear-cli/references/document.md index 2a547bac..41dfcbd4 100644 --- a/skills/linear-cli/references/document.md +++ b/skills/linear-cli/references/document.md @@ -6,7 +6,7 @@ ``` Usage: linear document -Version: 1.9.1 +Version: 1.11.0 Description: @@ -34,7 +34,7 @@ Commands: ``` Usage: linear document list -Version: 1.9.1 +Version: 1.11.0 Description: @@ -56,7 +56,7 @@ Options: ``` Usage: linear document view -Version: 1.9.1 +Version: 1.11.0 Description: @@ -77,7 +77,7 @@ Options: ``` Usage: linear document create -Version: 1.9.1 +Version: 1.11.0 Description: @@ -102,7 +102,7 @@ Options: ``` Usage: linear document update -Version: 1.9.1 +Version: 1.11.0 Description: @@ -125,7 +125,7 @@ Options: ``` Usage: linear document delete [documentId] -Version: 1.9.1 +Version: 1.11.0 Description: diff --git a/skills/linear-cli/references/initiative-update.md b/skills/linear-cli/references/initiative-update.md index 07264f96..d131f5f3 100644 --- a/skills/linear-cli/references/initiative-update.md +++ b/skills/linear-cli/references/initiative-update.md @@ -6,7 +6,7 @@ ``` Usage: linear initiative-update -Version: 1.9.1 +Version: 1.11.0 Description: @@ -31,7 +31,7 @@ Commands: ``` Usage: linear initiative-update create -Version: 1.9.1 +Version: 1.11.0 Description: @@ -53,7 +53,7 @@ Options: ``` Usage: linear initiative-update list -Version: 1.9.1 +Version: 1.11.0 Description: diff --git a/skills/linear-cli/references/initiative.md b/skills/linear-cli/references/initiative.md index 32c35d3d..2fc50564 100644 --- a/skills/linear-cli/references/initiative.md +++ b/skills/linear-cli/references/initiative.md @@ -6,7 +6,7 @@ ``` Usage: linear initiative -Version: 1.9.1 +Version: 1.11.0 Description: @@ -38,7 +38,7 @@ Commands: ``` Usage: linear initiative list -Version: 1.9.1 +Version: 1.11.0 Description: @@ -63,7 +63,7 @@ Options: ``` Usage: linear initiative view -Version: 1.9.1 +Version: 1.11.0 Description: @@ -84,7 +84,7 @@ Options: ``` Usage: linear initiative create -Version: 1.9.1 +Version: 1.11.0 Description: @@ -110,7 +110,7 @@ Options: ``` Usage: linear initiative archive [initiativeId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -132,7 +132,7 @@ Options: ``` Usage: linear initiative update -Version: 1.9.1 +Version: 1.11.0 Description: @@ -158,7 +158,7 @@ Options: ``` Usage: linear initiative unarchive -Version: 1.9.1 +Version: 1.11.0 Description: @@ -177,7 +177,7 @@ Options: ``` Usage: linear initiative delete [initiativeId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -199,7 +199,7 @@ Options: ``` Usage: linear initiative add-project -Version: 1.9.1 +Version: 1.11.0 Description: @@ -218,7 +218,7 @@ Options: ``` Usage: linear initiative remove-project -Version: 1.9.1 +Version: 1.11.0 Description: diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index a631d9ac..89ac84da 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -6,7 +6,7 @@ ``` Usage: linear issue -Version: 1.9.1 +Version: 1.11.0 Description: @@ -44,7 +44,7 @@ Commands: ``` Usage: linear issue id -Version: 1.9.1 +Version: 1.11.0 Description: @@ -62,7 +62,7 @@ Options: ``` Usage: linear issue list -Version: 1.9.1 +Version: 1.11.0 Description: @@ -81,6 +81,7 @@ Options: --sort - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: "manual", "priority") --team - Team to list issues for (if not your default team) --project - Filter by project name + --cycle - Filter by cycle name, number, or 'active' --limit - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: 50) -w, --web - Open in web browser -a, --app - Open in Linear.app @@ -93,7 +94,7 @@ Options: ``` Usage: linear issue title [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -111,7 +112,7 @@ Options: ``` Usage: linear issue start [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -133,7 +134,7 @@ Options: ``` Usage: linear issue view [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -157,7 +158,7 @@ Options: ``` Usage: linear issue url [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -175,7 +176,7 @@ Options: ``` Usage: linear issue describe [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -194,7 +195,7 @@ Options: ``` Usage: linear issue commits [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -212,7 +213,7 @@ Options: ``` Usage: linear issue pull-request [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -235,7 +236,7 @@ Options: ``` Usage: linear issue delete [issueId] -Version: 1.9.1 +Version: 1.11.0 Description: @@ -257,7 +258,7 @@ Options: ``` Usage: linear issue create -Version: 1.9.1 +Version: 1.11.0 Description: @@ -270,15 +271,17 @@ Options: --start - Start the issue after creation -a, --assignee - Assign the issue to 'self' or someone (by username or name) --due-date - Due date of the issue - -p, --parent - Parent issue (if any) as a team_number code - --priority - Priority of the issue (1-4, descending priority) + --parent - Parent issue (if any) as a team_number code + -p, --priority - Priority of the issue (1-4, descending priority) --estimate - Points estimate of the issue -d, --description - Description of the issue --description-file - Read description from a file (preferred for markdown content) -l, --label