diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 74bc3825..f5cc8672 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,7 @@ jobs: run: deno task check - name: Run tests - run: deno task test + run: deno task test --ignore=test/keyring.integration.test.ts - name: Install linear-cli for skill generation run: deno task install @@ -43,3 +43,29 @@ jobs: git diff skills/ exit 1 fi + + keyring-integration: + strategy: + matrix: + include: + - os: macos-latest + - os: ubuntu-latest + - os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v3 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Set up Secret Service + if: runner.os == 'Linux' + run: | + sudo apt-get update && sudo apt-get install -y gnome-keyring libsecret-tools dbus-x11 + eval "$(dbus-launch --sh-syntax)" + echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> $GITHUB_ENV + echo "test-password" | gnome-keyring-daemon --unlock --components=secrets + + - name: Keyring Integration + run: deno task test test/keyring.integration.test.ts diff --git a/docs/authentication.md b/docs/authentication.md index d697555c..3ce82f2a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -11,7 +11,7 @@ the CLI supports multiple authentication methods with the following precedence: ## stored credentials (recommended) -credentials are stored in `~/.config/linear/credentials.toml` and support multiple workspaces. +API keys are stored in your system's native keyring (macOS Keychain, Linux libsecret, Windows CredentialManager). workspace metadata is stored in `~/.config/linear/credentials.toml`. ### commands @@ -71,10 +71,25 @@ linear -w acme issue create --title "Bug fix" ```toml # ~/.config/linear/credentials.toml default = "acme" -acme = "lin_api_xxx" -side-project = "lin_api_yyy" +workspaces = ["acme", "side-project"] ``` +API keys are not stored in this file. they are stored in the system keyring and loaded at startup. + +### platform requirements + +- **macOS**: uses Keychain via `/usr/bin/security` (built-in) +- **Linux**: requires `secret-tool` from libsecret + - Debian/Ubuntu: `apt install libsecret-tools` + - Arch: `pacman -S libsecret` +- **Windows**: uses Credential Manager via `advapi32.dll` (built-in) + +if the keyring is unavailable, set `LINEAR_API_KEY` as a fallback. + +### migrating from plaintext credentials + +older versions stored API keys directly in the TOML file. if the CLI detects this format, it will continue to work but print a warning. run `linear auth login` for each workspace to migrate keys to the system keyring. + ## environment variable for simpler setups or CI environments, you can use an environment variable: diff --git a/skills/linear-cli/references/auth.md b/skills/linear-cli/references/auth.md index 09a41b39..a9637640 100644 --- a/skills/linear-cli/references/auth.md +++ b/skills/linear-cli/references/auth.md @@ -19,12 +19,13 @@ Options: Commands: - login - Add a workspace credential - logout [workspace] - Remove a workspace credential - list - List configured workspaces - default [workspace] - Set the default workspace - token - Print the configured API token - whoami - Print information about the authenticated user + login - Add a workspace credential + logout [workspace] - Remove a workspace credential + list - List configured workspaces + default [workspace] - Set the default workspace + token - Print the configured API token + whoami - Print information about the authenticated user + migrate - Migrate plaintext credentials to system keyring ``` ## Subcommands @@ -43,9 +44,10 @@ Description: Options: - -h, --help - Show this help. - -w, --workspace - Target workspace (uses credentials) - -k, --key - API key (prompted if not provided) + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + -k, --key - API key (prompted if not provided) + --plaintext - Store API key in credentials file instead of system keyring ``` ### logout @@ -138,3 +140,21 @@ Options: -h, --help - Show this help. -w, --workspace - Target workspace (uses credentials) ``` + +### migrate + +> Migrate plaintext credentials to system keyring + +``` +Usage: linear auth migrate +Version: 1.11.1 + +Description: + + Migrate plaintext credentials to system keyring + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) +``` diff --git a/src/commands/auth/auth-list.ts b/src/commands/auth/auth-list.ts index 94ade2eb..7161c955 100644 --- a/src/commands/auth/auth-list.ts +++ b/src/commands/auth/auth-list.ts @@ -2,12 +2,12 @@ import { Command } from "@cliffy/command" import { unicodeWidth } from "@std/cli" import { gql } from "../../__codegen__/gql.ts" import { - getAllCredentials, + getCredentialApiKey, getDefaultWorkspace, getWorkspaces, } from "../../credentials.ts" import { padDisplay } from "../../utils/display.ts" -import { handleError } from "../../utils/errors.ts" +import { handleError, isClientError } from "../../utils/errors.ts" import { createGraphQLClient } from "../../utils/graphql.ts" const viewerQuery = gql(` @@ -48,11 +48,22 @@ async function fetchWorkspaceInfo( userName: result.viewer.name, email: result.viewer.email, } - } catch { + } catch (error) { + let errorMsg = "unknown error" + if (isClientError(error)) { + const status = error.response?.status + if (status === 401 || status === 403) { + errorMsg = "invalid credentials" + } else { + errorMsg = error.message + } + } else if (error instanceof Error) { + errorMsg = error.message + } return { workspace, isDefault, - error: "invalid credentials", + error: errorMsg, } } } @@ -70,12 +81,19 @@ export const listCommand = new Command() return } - const credentials = getAllCredentials() - // Fetch info for all workspaces in parallel - const infoPromises = workspaces.map((ws) => - fetchWorkspaceInfo(ws, credentials[ws]!) - ) + const infoPromises = workspaces.map((ws) => { + const apiKey = getCredentialApiKey(ws) + if (apiKey == null) { + const info: WorkspaceInfo = { + workspace: ws, + isDefault: getDefaultWorkspace() === ws, + error: "missing credentials", + } + return Promise.resolve(info) + } + return fetchWorkspaceInfo(ws, apiKey) + }) const infos = await Promise.all(infoPromises) // Calculate column widths diff --git a/src/commands/auth/auth-login.ts b/src/commands/auth/auth-login.ts index b3fb1cca..75b90cfd 100644 --- a/src/commands/auth/auth-login.ts +++ b/src/commands/auth/auth-login.ts @@ -1,12 +1,15 @@ import { Command } from "@cliffy/command" -import { Secret } from "@cliffy/prompt" +import { Confirm, Secret } from "@cliffy/prompt" import { yellow } from "@std/fmt/colors" import { gql } from "../../__codegen__/gql.ts" import { addCredential, getWorkspaces, hasWorkspace, + isUsingInlineFormat, + migrateToKeyring, } from "../../credentials.ts" +import * as keyring from "../../keyring/index.ts" import { AuthError, CliError, @@ -32,6 +35,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?.trim() @@ -62,8 +69,20 @@ export const loginCommand = new Command() const org = viewer.organization const workspace = org.urlKey + // Require keyring when not using plaintext and not already in inline format + if (!options.plaintext && !isUsingInlineFormat()) { + const keyringOk = await keyring.isAvailable() + 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: options.plaintext, + }) const existingCount = getWorkspaces().length @@ -80,6 +99,38 @@ export const loginCommand = new Command() console.log(` Set as default workspace`) } + if (!options.plaintext && isUsingInlineFormat()) { + console.log( + yellow( + "Note: Credential stored as plaintext to match existing format.", + ), + ) + } + + // Prompt to migrate inline credentials to keyring + if (isUsingInlineFormat()) { + const keyringOk = await keyring.isAvailable() + if (keyringOk) { + console.log() + console.log( + yellow( + "Your credentials are stored as plaintext in the credentials file.", + ), + ) + const migrate = await Confirm.prompt({ + message: + "Migrate all credentials to the system keyring for better security?", + default: true, + }) + if (migrate) { + const migrated = await migrateToKeyring() + console.log( + `Migrated ${migrated.length} workspace(s) to system keyring.`, + ) + } + } + } + // Warn if LINEAR_API_KEY is set if (Deno.env.get("LINEAR_API_KEY")) { console.log() @@ -94,6 +145,12 @@ export const loginCommand = new Command() ) } } catch (error) { + if ( + error instanceof CliError || error instanceof AuthError || + error instanceof ValidationError + ) { + throw error + } if (error instanceof Error && error.message.includes("401")) { throw new AuthError("Invalid API key", { suggestion: "Check that your API key is correct and not expired.", diff --git a/src/commands/auth/auth-migrate.ts b/src/commands/auth/auth-migrate.ts new file mode 100644 index 00000000..0863301e --- /dev/null +++ b/src/commands/auth/auth-migrate.ts @@ -0,0 +1,37 @@ +import { Command } from "@cliffy/command" +import { isUsingInlineFormat, migrateToKeyring } from "../../credentials.ts" +import * as keyring from "../../keyring/index.ts" +import { CliError, handleError } from "../../utils/errors.ts" + +export const migrateCommand = new Command() + .name("migrate") + .description("Migrate plaintext credentials to system keyring") + .action(async () => { + try { + if (!isUsingInlineFormat()) { + console.log("Credentials are already using the system keyring.") + return + } + + const keyringOk = await keyring.isAvailable() + if (!keyringOk) { + throw new CliError( + "No system keyring found. Cannot migrate credentials.", + ) + } + + const migrated = await migrateToKeyring() + if (migrated.length === 0) { + console.log("No credentials to migrate.") + } else { + console.log( + `Migrated ${migrated.length} workspace(s) to system keyring:`, + ) + for (const ws of migrated) { + console.log(` ${ws}`) + } + } + } catch (error) { + handleError(error, "Failed to migrate credentials") + } + }) diff --git a/src/commands/auth/auth-status.ts b/src/commands/auth/auth-status.ts index 1bf1ed8e..a4b9bedd 100644 --- a/src/commands/auth/auth-status.ts +++ b/src/commands/auth/auth-status.ts @@ -1,5 +1,7 @@ import { Command } from "@cliffy/command" import { gql } from "../../__codegen__/gql.ts" +import { isUsingInlineFormat } from "../../credentials.ts" +import * as keyring from "../../keyring/index.ts" import { handleError } from "../../utils/errors.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { LINEAR_WEB_BASE_URL } from "../../const.ts" @@ -46,6 +48,19 @@ export const statusCommand = new Command() } else if (viewer.guest) { console.log(` Role: guest`) } + + const inline = isUsingInlineFormat() + const keyringOk = await keyring.isAvailable() + console.log( + `Credential storage: ${inline ? "plaintext file" : "system keyring"}`, + ) + if (inline && keyringOk) { + console.log( + ` System keyring is available. Run \`linear auth migrate\` to migrate.`, + ) + } else if (inline && !keyringOk) { + console.log(` System keyring is not available on this system.`) + } } catch (error) { handleError(error, "Failed to get auth status") } diff --git a/src/commands/auth/auth.ts b/src/commands/auth/auth.ts index 13b00ad0..0f800225 100644 --- a/src/commands/auth/auth.ts +++ b/src/commands/auth/auth.ts @@ -4,6 +4,7 @@ import { defaultCommand } from "./auth-default.ts" import { listCommand } from "./auth-list.ts" import { loginCommand } from "./auth-login.ts" import { logoutCommand } from "./auth-logout.ts" +import { migrateCommand } from "./auth-migrate.ts" import { tokenCommand } from "./auth-token.ts" import { whoamiCommand } from "./auth-whoami.ts" @@ -18,3 +19,4 @@ export const authCommand = new Command() .command("default", defaultCommand) .command("token", tokenCommand) .command("whoami", whoamiCommand) + .command("migrate", migrateCommand) diff --git a/src/credentials.ts b/src/credentials.ts index ac17fc55..acbf57d9 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,13 +1,22 @@ import { parse, stringify } from "@std/toml" import { dirname, join } from "@std/path" import { ensureDir } from "@std/fs" +import { yellow } from "@std/fmt/colors" +import { deletePassword, getPassword, setPassword } from "./keyring/index.ts" + +function errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} export interface Credentials { default?: string - [workspace: string]: string | undefined + workspaces: string[] } -let credentials: Credentials = {} +let credentials: Credentials = { workspaces: [] } +let isInlineFormat = false + +const apiKeyCache = new Map() /** * Get the path to the credentials file. @@ -32,28 +41,146 @@ export function getCredentialsPath(): string | null { return null } +interface InlineCredentials { + default?: string + [workspace: string]: string | undefined +} + +// The inline format stores API keys directly in the TOML file as +// `workspace-name = "lin_api_..."`. The keyring format uses a `workspaces` +// array and stores keys in the OS keyring instead. +function hasInlineKeys( + parsed: Record, +): parsed is InlineCredentials { + for (const [key, value] of Object.entries(parsed)) { + if (key === "default") continue + if (key === "workspaces") return false + if (typeof value === "string") return true + } + return false +} + +function parseInlineCredentials(parsed: InlineCredentials): Credentials { + const workspaces: string[] = [] + for (const [key, value] of Object.entries(parsed)) { + if (key === "default") continue + if (typeof value === "string") { + workspaces.push(key) + apiKeyCache.set(key, value) + } + } + return { + default: typeof parsed.default === "string" ? parsed.default : undefined, + workspaces, + } +} + +function parseKeyringCredentials(parsed: Record): Credentials { + const workspaces = Array.isArray(parsed.workspaces) + ? [ + ...new Set((parsed.workspaces as unknown[]).filter((v): v is string => + typeof v === "string" + )), + ] + : [] + + const defaultWs = typeof parsed.default === "string" + ? parsed.default + : undefined + const defaultIsValid = defaultWs != null && workspaces.includes(defaultWs) + + if (defaultWs != null && !defaultIsValid) { + console.error( + yellow( + `Warning: Default workspace "${defaultWs}" is not in the workspaces list. ` + + `Run \`linear auth default \` to set a valid default.`, + ), + ) + } + + return { + default: defaultIsValid ? defaultWs : undefined, + workspaces, + } +} + +async function populateKeyringCache(workspaces: string[]): Promise { + await Promise.all(workspaces.map(async (ws) => { + try { + const key = await getPassword(ws) + if (key != null) { + apiKeyCache.set(ws, key) + } else { + console.error( + yellow( + `Warning: No keyring entry for workspace "${ws}". Run \`linear auth login\` to re-authenticate.`, + ), + ) + } + } catch (error) { + console.error( + yellow( + `Warning: Failed to read keyring for workspace "${ws}": ${ + errorDetail(error) + }`, + ), + ) + } + })) +} + /** * Load credentials from the credentials file. */ export async function loadCredentials(): Promise { const path = getCredentialsPath() if (!path) { - return {} + return { workspaces: [] } } + let file: string try { - const file = await Deno.readTextFile(path) - credentials = parse(file) as Credentials + file = await Deno.readTextFile(path) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return { workspaces: [] } + } + throw new Error( + `Failed to read credentials file at ${path}: ${errorDetail(error)}`, + ) + } + + let parsed: Record + try { + parsed = parse(file) as Record + } catch (error) { + throw new Error( + `Failed to parse credentials file at ${path}. The file may be corrupted.\n` + + `You can delete it and re-authenticate with \`linear auth login\`.\n` + + `Parse error: ${errorDetail(error)}`, + ) + } + + apiKeyCache.clear() + + if (hasInlineKeys(parsed)) { + isInlineFormat = true + credentials = parseInlineCredentials(parsed) return credentials - } catch { - return {} } + + isInlineFormat = false + + 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,42 +192,197 @@ 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 (credentials.default != null) { + ordered.default = credentials.default + } + ordered.workspaces = [...credentials.workspaces].sort() + + 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) { + throw new Error( + `Cannot save inline credentials: API key for workspace "${ws}" is missing from cache`, + ) + } + ordered[ws] = key + } + + await Deno.writeTextFile(path, stringify(ordered)) +} + +/** + * Save all current inline credentials from cache. + * Used when modifying the workspace list (remove, set default) in inline mode. + */ +async function saveAllInlineCredentials(): 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 (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 + if (credentials.default != null) { + ordered.default = credentials.default + } + for (const ws of [...credentials.workspaces].sort()) { + const key = apiKeyCache.get(ws) + if (key == null) { + throw new Error( + `Cannot save inline credentials: API key for workspace "${ws}" is missing from cache`, + ) } + ordered[ws] = key } await Deno.writeTextFile(path, stringify(ordered)) - credentials = creds +} + +/** + * Migrate all inline (plaintext) credentials to the system keyring. + * Returns the list of workspaces that were migrated. + */ +export async function migrateToKeyring(): Promise { + if (!isInlineFormat) { + return [] + } + + const migrated: string[] = [] + for (const ws of credentials.workspaces) { + const key = apiKeyCache.get(ws) + if (key == null) continue + try { + await setPassword(ws, key) + migrated.push(ws) + } catch (error) { + // Roll back already-written keyring entries (best effort) + for (const written of migrated) { + try { + await deletePassword(written) + } catch { + // best effort cleanup + } + } + throw new Error( + `Failed to store API key in system keyring for workspace "${ws}": ${ + errorDetail(error) + }. Rolled back ${migrated.length} already-written entries.`, + ) + } + } + + isInlineFormat = false + await saveCredentials() + return migrated +} + +/** + * Check whether the current credentials file uses inline (plaintext) format. + */ +export function isUsingInlineFormat(): boolean { + return isInlineFormat } /** * 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. + * When not specified, preserves the current credential format. */ export async function addCredential( workspace: string, apiKey: string, + options?: { plaintext?: boolean }, ): Promise { - const creds = { ...credentials } + const useInline = options?.plaintext ?? isInlineFormat + + // When explicitly requesting keyring storage while currently in inline format, + // migrate all existing keys to keyring first to avoid data loss. + if (options?.plaintext === false && isInlineFormat) { + apiKeyCache.set(workspace, apiKey) + const isNew = !credentials.workspaces.includes(workspace) + if (isNew) { + credentials.workspaces.push(workspace) + } + if (isNew && credentials.workspaces.length === 1) { + credentials.default = workspace + } + + // Migrate all keys (including the new one) to keyring + for (const ws of credentials.workspaces) { + const key = apiKeyCache.get(ws) + if (key == null) continue + try { + await setPassword(ws, key) + } catch (error) { + throw new Error( + `Failed to store API key in system keyring for workspace "${ws}": ${ + errorDetail(error) + }`, + ) + } + } + + isInlineFormat = false + await saveCredentials() + return + } + + if (!useInline) { + try { + await setPassword(workspace, apiKey) + } catch (error) { + throw new Error( + `Failed to store API key in system keyring for workspace "${workspace}": ${ + errorDetail(error) + }`, + ) + } + } + + apiKeyCache.set(workspace, apiKey) + + const isNew = !credentials.workspaces.includes(workspace) + if (isNew) { + credentials.workspaces.push(workspace) + } // If this is the first workspace, make it the default - const existingWorkspaces = Object.keys(creds).filter((k) => k !== "default") - if (existingWorkspaces.length === 0) { - creds.default = workspace + if (isNew && credentials.workspaces.length === 1) { + credentials.default = workspace } - creds[workspace] = apiKey - await saveCredentials(creds) + if (useInline) { + await saveInlineCredentials(workspace, apiKey) + } else { + await saveCredentials() + } } /** @@ -108,43 +390,58 @@ 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] + if (!isInlineFormat) { + try { + await deletePassword(workspace) + } catch (error) { + throw new Error( + `Failed to remove API key from system keyring for workspace "${workspace}": ${ + errorDetail(error) + }`, + ) + } + } + apiKeyCache.delete(workspace) + + credentials.workspaces = credentials.workspaces.filter((w) => w !== workspace) // If we removed the default, reassign it - if (creds.default === workspace) { - const remaining = Object.keys(creds).filter((k) => k !== "default") - if (remaining.length > 0) { - creds.default = remaining[0] - } else { - delete creds.default - } + if (credentials.default === workspace) { + credentials.default = credentials.workspaces[0] } - await saveCredentials(creds) + if (isInlineFormat) { + await saveAllInlineCredentials() + } else { + 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 + + if (isInlineFormat) { + await saveAllInlineCredentials() + } else { + 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 +454,17 @@ export function getDefaultWorkspace(): string | undefined { } /** - * Get all configured workspaces (excluding 'default' key). + * Get all configured workspaces. */ export function getWorkspaces(): string[] { - return Object.keys(credentials).filter((k) => k !== "default") + return [...credentials.workspaces] } /** * Check if a workspace is configured. */ export function hasWorkspace(workspace: string): boolean { - return workspace in credentials && workspace !== "default" -} - -/** - * Get all credentials (for listing purposes). - */ -export function getAllCredentials(): Credentials { - return { ...credentials } + return credentials.workspaces.includes(workspace) } // Load credentials at startup diff --git a/src/keyring/index.ts b/src/keyring/index.ts new file mode 100644 index 00000000..66da0704 --- /dev/null +++ b/src/keyring/index.ts @@ -0,0 +1,60 @@ +import { yellow } from "@std/fmt/colors" +import { macosBackend } from "./macos.ts" +import { linuxBackend } from "./linux.ts" +import { windowsBackend } from "./windows.ts" + +export const SERVICE = "linear-cli" + +export interface KeyringBackend { + get(account: string): Promise + set(account: string, password: string): Promise + delete(account: string): Promise + isAvailable(): Promise +} + +let backend: KeyringBackend | null = null + +export function _setBackend(b: KeyringBackend | null): void { + backend = b +} + +function getBackend(): KeyringBackend { + if (backend != null) return backend + switch (Deno.build.os) { + case "darwin": + return macosBackend + case "linux": + return linuxBackend + case "windows": + return windowsBackend + default: + throw new Error(`Unsupported platform: ${Deno.build.os}`) + } +} + +export async function getPassword(account: string): Promise { + return await getBackend().get(account) +} + +export async function setPassword( + account: string, + password: string, +): Promise { + await getBackend().set(account, password) +} + +export async function deletePassword(account: string): Promise { + await getBackend().delete(account) +} + +export async function isAvailable(): Promise { + try { + return await getBackend().isAvailable() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + console.error( + yellow(`Warning: Keyring availability check failed: ${detail}`), + ) + return false + } +} diff --git a/src/keyring/linux.ts b/src/keyring/linux.ts new file mode 100644 index 00000000..f79772df --- /dev/null +++ b/src/keyring/linux.ts @@ -0,0 +1,122 @@ +import type { KeyringBackend } from "./index.ts" +import { SERVICE } from "./index.ts" + +function spawnError(error: unknown): never { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + "Could not run secret-tool. Install libsecret " + + "(e.g. apt install libsecret-tools, pacman -S libsecret).\n" + + "Alternatively, set the LINEAR_API_KEY environment variable.\n" + + ` (${detail})`, + ) +} + +async function secretTool( + args: string[], + options?: { stdin?: string }, +) { + let process: Deno.ChildProcess + try { + process = new Deno.Command("secret-tool", { + args, + stdin: options?.stdin != null ? "piped" : "null", + stdout: "piped", + stderr: "piped", + }).spawn() + } catch (error) { + spawnError(error) + } + + if (options?.stdin != null) { + try { + const writer = process.stdin.getWriter() + await writer.write(new TextEncoder().encode(options.stdin)) + await writer.close() + } catch (error) { + try { + process.kill() + } catch { /* already exited */ } + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to write to stdin of secret-tool: ${detail}`) + } + } + + const result = await process.output() + return { + success: result.success, + code: result.code, + stdout: new TextDecoder().decode(result.stdout).trim(), + stderr: new TextDecoder().decode(result.stderr).trim(), + } +} + +export const linuxBackend: KeyringBackend = { + async 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", + "service", + SERVICE, + "account", + account, + ]) + if (!result.success) { + // secret-tool lookup returns exit 1 when no matching items are found + if (result.code === 1) return null + throw new Error( + `secret-tool lookup failed (exit ${result.code}): ${result.stderr}`, + ) + } + // secret-tool returns empty stdout when the value itself is empty; + // Linear API keys are always non-empty so treat empty as not-found + return result.stdout || null + }, + + async set(account, password) { + const result = await secretTool( + [ + "store", + "--label", + `${SERVICE}: ${account}`, + "service", + SERVICE, + "account", + account, + ], + { stdin: password }, + ) + if (!result.success) { + throw new Error( + `secret-tool store failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, + + async delete(account) { + const result = await secretTool([ + "clear", + "service", + SERVICE, + "account", + account, + ]) + // secret-tool clear returns exit 1 when no matching items are found + if (!result.success && result.code !== 1) { + throw new Error( + `secret-tool clear failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, +} diff --git a/src/keyring/macos.ts b/src/keyring/macos.ts new file mode 100644 index 00000000..aaabd23b --- /dev/null +++ b/src/keyring/macos.ts @@ -0,0 +1,80 @@ +import type { KeyringBackend } from "./index.ts" +import { SERVICE } from "./index.ts" + +async function security(...args: string[]) { + try { + const result = await new Deno.Command("/usr/bin/security", { + args, + stdout: "piped", + stderr: "piped", + }).output() + return { + success: result.success, + code: result.code, + stdout: new TextDecoder().decode(result.stdout).trim(), + stderr: new TextDecoder().decode(result.stderr).trim(), + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Could not run /usr/bin/security. Is this a macOS system?\n (${detail})`, + ) + } +} + +export const macosBackend: KeyringBackend = { + isAvailable: () => Promise.resolve(true), + + async get(account) { + const result = await security( + "find-generic-password", + "-a", + account, + "-s", + SERVICE, + "-w", + ) + if (!result.success) { + // exit 44 = errSecItemNotFound + if (result.code === 44) return null + throw new Error( + `security find-generic-password failed (exit ${result.code}): ${result.stderr}`, + ) + } + return result.stdout || null + }, + + async set(account, password) { + const result = await security( + "add-generic-password", + "-a", + account, + "-s", + SERVICE, + "-w", + password, + "-U", + ) + if (!result.success) { + throw new Error( + `security add-generic-password failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, + + async delete(account) { + const result = await security( + "delete-generic-password", + "-a", + account, + "-s", + SERVICE, + ) + // exit 44 = errSecItemNotFound + if (!result.success && result.code !== 44) { + throw new Error( + `security delete-generic-password failed (exit ${result.code}): ${result.stderr}`, + ) + } + }, +} diff --git a/src/keyring/windows.ts b/src/keyring/windows.ts new file mode 100644 index 00000000..0ac93cdf --- /dev/null +++ b/src/keyring/windows.ts @@ -0,0 +1,162 @@ +import type { KeyringBackend } from "./index.ts" +import { SERVICE } from "./index.ts" + +const ERROR_NOT_FOUND = 1168 +const CRED_TYPE_GENERIC = 1 +const CRED_PERSIST_LOCAL_MACHINE = 2 +// CREDENTIALW struct layout on 64-bit Windows (80 bytes): +// 0: Flags (u32) 4: Type (u32) 8: TargetName (ptr) +// 16: Comment (ptr) 24: LastWritten (i64) 32: CredentialBlobSize (u32) +// 36: (padding) 40: CredentialBlob (ptr) 48: Persist (u32) +// 52: AttributeCount 56: Attributes (ptr) 64: TargetAlias (ptr) +// 72: UserName (ptr) +const CREDENTIAL_SIZE = 80 + +type FfiBuffer = Uint8Array + +function ffiBuffer(size: number): FfiBuffer { + return new Uint8Array(new ArrayBuffer(size)) +} + +function encodeWideString(s: string): FfiBuffer { + const buf = ffiBuffer((s.length + 1) * 2) + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i) + buf[i * 2] = code & 0xff + buf[i * 2 + 1] = (code >> 8) & 0xff + } + return buf +} + +function decodeWideString( + ptr: Deno.PointerObject, + byteLen: number, +): string { + const view = new Deno.UnsafePointerView(ptr) + const buf = new Uint8Array(byteLen) + view.copyInto(buf) + const codes: number[] = [] + for (let i = 0; i < byteLen; i += 2) { + codes.push(buf[i] | (buf[i + 1] << 8)) + } + return String.fromCharCode(...codes) +} + +function ptrToBigInt(ptr: Deno.PointerObject | null): bigint { + if (ptr == null) return 0n + return Deno.UnsafePointer.value(ptr) +} + +function openAdvapi32() { + return Deno.dlopen("advapi32.dll", { + CredReadW: { + parameters: ["buffer", "u32", "u32", "buffer"], + result: "i32", + }, + CredWriteW: { parameters: ["buffer", "u32"], result: "i32" }, + CredDeleteW: { parameters: ["buffer", "u32", "u32"], result: "i32" }, + CredFree: { parameters: ["pointer"], result: "void" }, + }) +} + +function openKernel32() { + return Deno.dlopen("kernel32.dll", { + GetLastError: { parameters: [], result: "u32" }, + }) +} + +let advapi32: ReturnType | null = null +let kernel32: ReturnType | null = null + +function getAdvapi32() { + if (advapi32 != null) return advapi32 + advapi32 = openAdvapi32() + return advapi32 +} + +function getKernel32() { + if (kernel32 != null) return kernel32 + kernel32 = openKernel32() + return kernel32 +} + +function getLastError(): number { + return getKernel32().symbols.GetLastError() +} + +function credGet(account: string): string | null { + const target = encodeWideString(`${SERVICE}:${account}`) + const outBuf = ffiBuffer(8) + const lib = getAdvapi32() + + const ok = lib.symbols.CredReadW(target, CRED_TYPE_GENERIC, 0, outBuf) + if (!ok) { + const err = getLastError() + // Deno's FFI boundary clobbers the Win32 thread-local error before + // GetLastError can read it through a separate dlopen call. Treat 0 + // (no error set) as "not found" since that's the only expected failure. + if (err === ERROR_NOT_FOUND || err === 0) return null + throw new Error(`CredReadW failed (error ${err})`) + } + + const ptrValue = new DataView(outBuf.buffer).getBigUint64(0, true) + const credPtr = Deno.UnsafePointer.create(ptrValue) + if (credPtr == null) { + throw new Error("CredReadW returned null credential pointer") + } + try { + const view = new Deno.UnsafePointerView(credPtr) + const blobSize = view.getUint32(32) + if (blobSize === 0) return null + const blobPtr = view.getPointer(40) + if (blobPtr == null) return null + return decodeWideString(blobPtr, blobSize) + } finally { + lib.symbols.CredFree(credPtr) + } +} + +function credSet(account: string, password: string): void { + const targetBuf = encodeWideString(`${SERVICE}:${account}`) + const userBuf = encodeWideString(account) + const blobBuf = encodeWideString(password) + const blobSize = password.length * 2 + + const struct = ffiBuffer(CREDENTIAL_SIZE) + const dv = new DataView(struct.buffer) + + dv.setUint32(4, CRED_TYPE_GENERIC, true) + dv.setBigUint64(8, ptrToBigInt(Deno.UnsafePointer.of(targetBuf)), true) + dv.setUint32(32, blobSize, true) + dv.setBigUint64(40, ptrToBigInt(Deno.UnsafePointer.of(blobBuf)), true) + dv.setUint32(48, CRED_PERSIST_LOCAL_MACHINE, true) + dv.setBigUint64(72, ptrToBigInt(Deno.UnsafePointer.of(userBuf)), true) + + const lib = getAdvapi32() + const ok = lib.symbols.CredWriteW(struct, 0) + if (!ok) { + const err = getLastError() + throw new Error(`CredWriteW failed (error ${err})`) + } +} + +function credDelete(account: string): void { + const target = encodeWideString(`${SERVICE}:${account}`) + const lib = getAdvapi32() + + const ok = lib.symbols.CredDeleteW(target, CRED_TYPE_GENERIC, 0) + if (!ok) { + const err = getLastError() + // See credGet for why err === 0 is treated as "not found" + if (err === ERROR_NOT_FOUND || err === 0) return + throw new Error(`CredDeleteW failed (error ${err})`) + } +} + +export const windowsBackend: KeyringBackend = { + isAvailable: () => Promise.resolve(true), + + get: (account) => Promise.resolve(credGet(account)), + set: (account, password) => Promise.resolve(credSet(account, password)), + delete: (account) => Promise.resolve(credDelete(account)), +} diff --git a/test/commands/auth/auth-migrate.test.ts b/test/commands/auth/auth-migrate.test.ts new file mode 100644 index 00000000..86018b7a --- /dev/null +++ b/test/commands/auth/auth-migrate.test.ts @@ -0,0 +1,174 @@ +import { assertEquals } from "@std/assert" +import { fromFileUrl } from "@std/path" + +// Testing auth-migrate requires subprocess isolation because the credentials +// module uses top-level await, similar to test/credentials.test.ts. + +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)) +const denoDir = Deno.env.get("DENO_DIR") ?? + (Deno.build.os === "darwin" + ? `${Deno.env.get("HOME")}/Library/Caches/deno` + : `${Deno.env.get("HOME")}/.cache/deno`) + +async function runSubprocess( + tempDir: string, + code: string, +): Promise<{ stdout: string; stderr: string; success: boolean }> { + 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") ?? "", + NO_COLOR: "1", + } + + const command = new Deno.Command("deno", { + args: ["eval", `--config=${denoJsonPath}`, code], + cwd: tempDir, + env, + stdout: "piped", + stderr: "piped", + }) + + const result = await command.output() + return { + stdout: new TextDecoder().decode(result.stdout).trim(), + stderr: new TextDecoder().decode(result.stderr), + success: result.success, + } +} + +Deno.test("auth migrate - already using keyring format prints message", 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"\nworkspaces = ["my-ws"]\n`, + ) + + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + async get(_account: string) { return "lin_api_key" }, + async set(_a: string, _p: string) {}, + async delete(_a: string) {}, + async isAvailable() { return true }, +}); +const { isUsingInlineFormat, migrateToKeyring } = await import("${credentialsUrl}"); +const keyring = await import("${keyringUrl}"); + +if (!isUsingInlineFormat()) { + console.log("Credentials are already using the system keyring."); +} else { + console.log("unexpected: inline format detected"); +} +` + + const { stdout } = await runSubprocess(tempDir, code) + assertEquals(stdout, "Credentials are already using the system keyring.") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("auth migrate - no keyring available throws error", 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 code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + async get(_account: string) { return null }, + async set(_a: string, _p: string) {}, + async delete(_a: string) {}, + async isAvailable() { return false }, +}); +const { isUsingInlineFormat } = await import("${credentialsUrl}"); +const keyring = await import("${keyringUrl}"); + +if (isUsingInlineFormat()) { + const keyringOk = await keyring.isAvailable(); + if (!keyringOk) { + console.log("error:No system keyring found. Cannot migrate credentials."); + } else { + console.log("unexpected: keyring available"); + } +} else { + console.log("unexpected: not inline format"); +} +` + + const { stdout } = await runSubprocess(tempDir, code) + assertEquals( + stdout, + "error:No system keyring found. Cannot migrate credentials.", + ) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("auth migrate - happy path migrates inline credentials to keyring", 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 = ` +import { _setBackend } from "${keyringUrl}"; +const _store = new Map(); +_setBackend({ + 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 { isUsingInlineFormat, migrateToKeyring, getCredentialApiKey } = await import("${credentialsUrl}"); +const keyring = await import("${keyringUrl}"); + +const wasInline = isUsingInlineFormat(); +const keyringOk = await keyring.isAvailable(); +const migrated = await migrateToKeyring(); +console.log(JSON.stringify({ + wasInline, + keyringOk, + migrated: migrated.sort(), + isInlineAfter: isUsingInlineFormat(), + keyA: getCredentialApiKey("ws-a"), + keyB: getCredentialApiKey("ws-b"), +})); +` + + const { stdout } = await runSubprocess(tempDir, code) + const result = JSON.parse(stdout) + + assertEquals(result.wasInline, true) + assertEquals(result.keyringOk, true) + assertEquals(result.migrated, ["ws-a", "ws-b"]) + assertEquals(result.isInlineAfter, false) + assertEquals(result.keyA, "lin_api_a") + assertEquals(result.keyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 674ce249..ff1bceef 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -1,24 +1,46 @@ 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({ + 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}"); +` +} 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 +60,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 +78,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 +89,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 +113,14 @@ Deno.test("credentials - addCredential creates file and sets default", async () try { const code = ` - import { addCredential, getAllCredentials, getDefaultWorkspace } from "${credentialsUrl}"; + ${ + mockBackendAndImport( + "addCredential, getCredentialApiKey, getDefaultWorkspace", + ) + } await addCredential("test-workspace", "lin_api_test123"); - const creds = getAllCredentials(); console.log(JSON.stringify({ - creds, + apiKey: getCredentialApiKey("test-workspace"), default: getDefaultWorkspace() })); ` @@ -102,8 +129,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 +140,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 +153,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 +199,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 +217,38 @@ Deno.test("credentials - removeCredential reassigns default", async () => { } }) +Deno.test("credentials - removeCredential cleans up cache", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${ + mockBackendAndImport( + "addCredential, removeCredential, getCredentialApiKey", + ) + } + await addCredential("workspace-a", "lin_api_a"); + await removeCredential("workspace-a"); + console.log(getCredentialApiKey("workspace-a") ?? "undefined"); + ` + + const output = await runWithCredentials(tempDir, code) + assertEquals(output, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + Deno.test("credentials - setDefaultWorkspace changes default", async () => { const tempDir = await Deno.makeTempDir() try { const code = ` - import { addCredential, setDefaultWorkspace, getDefaultWorkspace } from "${credentialsUrl}"; + ${ + mockBackendAndImport( + "addCredential, setDefaultWorkspace, getDefaultWorkspace", + ) + } await addCredential("workspace-a", "lin_api_a"); await addCredential("workspace-b", "lin_api_b"); await setDefaultWorkspace("workspace-b"); @@ -191,7 +267,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 +284,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 +301,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 +313,29 @@ Deno.test("credentials - getCredentialApiKey returns undefined for unknown works } }) +Deno.test("credentials - getCredentialApiKey reads from cache", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${mockBackendAndImport("addCredential, getCredentialApiKey")} + await addCredential("ws", "lin_api_cached"); + console.log(getCredentialApiKey("ws")); + ` + + const output = await runWithCredentials(tempDir, code) + assertEquals(output, "lin_api_cached") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + Deno.test("credentials - hasWorkspace returns correct boolean", async () => { const tempDir = await Deno.makeTempDir() try { const code = ` - import { addCredential, hasWorkspace } from "${credentialsUrl}"; + ${mockBackendAndImport("addCredential, hasWorkspace")} await addCredential("exists", "lin_api_exists"); console.log(JSON.stringify({ exists: hasWorkspace("exists"), @@ -260,13 +353,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 +365,16 @@ Deno.test("credentials - loadCredentials reads existing file", async () => { ) const code = ` - import { getAllCredentials, getDefaultWorkspace } from "${credentialsUrl}"; + ${ + mockBackendAndImport( + "getDefaultWorkspace, getWorkspaces, getCredentialApiKey", + ) + } console.log(JSON.stringify({ default: getDefaultWorkspace(), - creds: getAllCredentials() + workspaces: getWorkspaces(), + apiKey: getCredentialApiKey("preexisting"), + credApiKey: getCredentialApiKey(), })); ` @@ -285,7 +382,660 @@ Deno.test("credentials - loadCredentials reads existing file", async () => { const result = JSON.parse(output) assertEquals(result.default, "preexisting") - assertEquals(result.creds.preexisting, "lin_api_preexisting") + assertEquals(result.workspaces, ["preexisting"]) + assertEquals(result.apiKey, "lin_api_preexisting") + assertEquals(result.credApiKey, "lin_api_preexisting") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - old format with multiple workspaces", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`, + ) + + const code = ` + ${ + mockBackendAndImport( + "getDefaultWorkspace, getWorkspaces, getCredentialApiKey", + ) + } + console.log(JSON.stringify({ + default: getDefaultWorkspace(), + workspaces: getWorkspaces().sort(), + apiKeyA: getCredentialApiKey("ws-a"), + apiKeyB: getCredentialApiKey("ws-b"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + + assertEquals(result.default, "ws-a") + assertEquals(result.workspaces, ["ws-a", "ws-b"]) + assertEquals(result.apiKeyA, "lin_api_a") + assertEquals(result.apiKeyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - setDefaultWorkspace throws for unknown workspace", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${mockBackendAndImport("addCredential, setDefaultWorkspace")} + await addCredential("workspace-a", "lin_api_a"); + try { + await setDefaultWorkspace("nonexistent"); + console.log("no-error"); + } catch (e) { + console.log("error:" + e.message); + } + ` + + const output = await runWithCredentials(tempDir, code) + assertEquals(output.startsWith("error:"), true) + assertEquals(output.includes("nonexistent"), true) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - addCredential throws when keyring write fails", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + 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 { + await addCredential("ws", "lin_api_key"); + console.log("no-error"); +} catch (e) { + console.log(JSON.stringify({ + error: e.message, + workspaces: getWorkspaces(), + cached: getCredentialApiKey("ws") ?? "undefined", + })); +} + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.error.includes("keyring locked"), true) + assertEquals(result.workspaces, []) + assertEquals(result.cached, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - loadCredentials warns but continues when keyring fails for one workspace", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "ws-ok"\nworkspaces = ["ws-ok", "ws-fail"]\n`, + ) + + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + async get(account: string) { + if (account === "ws-fail") throw new Error("keyring error"); + return "lin_api_ok"; + }, + async set(_a: string, _p: string) {}, + async delete(_a: string) {}, + async isAvailable() { return true }, +}); +const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); +console.log(JSON.stringify({ + workspaces: getWorkspaces(), + okKey: getCredentialApiKey("ws-ok"), + failKey: getCredentialApiKey("ws-fail") ?? "undefined", +})); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.workspaces, ["ws-ok", "ws-fail"]) + assertEquals(result.okKey, "lin_api_ok") + assertEquals(result.failKey, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - removeCredential throws when keyring delete fails", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` +import { _setBackend } from "${keyringUrl}"; +const _store = new Map(); +_setBackend({ + 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"); +try { + await removeCredential("ws"); + console.log("no-error"); +} catch (e) { + console.log(JSON.stringify({ + error: e.message, + workspaces: getWorkspaces(), + cached: getCredentialApiKey("ws") ?? "undefined", + })); +} + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.error.includes("keyring locked"), true) + assertEquals(result.workspaces, ["ws"]) + assertEquals(result.cached, "lin_api_key") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - loadCredentials warns when keyring returns null for workspace", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const configDir = `${tempDir}/linear` + await Deno.mkdir(configDir, { recursive: true }) + await Deno.writeTextFile( + `${configDir}/credentials.toml`, + `default = "ws-a"\nworkspaces = ["ws-a", "ws-missing"]\n`, + ) + + const code = ` +import { _setBackend } from "${keyringUrl}"; +_setBackend({ + async get(account: string) { + if (account === "ws-missing") return null; + return "lin_api_a"; + }, + async set(_a: string, _p: string) {}, + async delete(_a: string) {}, + async isAvailable() { return true }, +}); +const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}"); +console.log(JSON.stringify({ + workspaces: getWorkspaces(), + aKey: getCredentialApiKey("ws-a"), + missingKey: getCredentialApiKey("ws-missing") ?? "undefined", +})); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.workspaces, ["ws-a", "ws-missing"]) + assertEquals(result.aKey, "lin_api_a") + assertEquals(result.missingKey, "undefined") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - addCredential on inline-format file preserves inline 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, getCredentialApiKey", + ) + } + 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"), + oldKeyPreserved: toml.includes("lin_api_old"), + newKeyPresent: toml.includes("lin_api_new"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.hasWorkspacesKey, false) + assertEquals(result.hasInlineKey, true) + assertEquals(result.oldKeyPreserved, true) + assertEquals(result.newKeyPresent, true) + } 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({ + 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({ + 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 }) + } +}) + +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 }) + } +}) + +Deno.test("credentials - migrateToKeyring moves inline keys to keyring", 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( + "migrateToKeyring, isUsingInlineFormat, getCredentialsPath, getCredentialApiKey", + ) + } + const wasinline = isUsingInlineFormat(); + const migrated = await migrateToKeyring(); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(JSON.stringify({ + wasinline, + isInlineAfter: isUsingInlineFormat(), + migrated: migrated.sort(), + hasWorkspacesArray: toml.includes("workspaces"), + hasInlineKey: toml.includes("lin_api"), + keyA: getCredentialApiKey("ws-a"), + keyB: getCredentialApiKey("ws-b"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + + assertEquals(result.wasinline, true) + assertEquals(result.isInlineAfter, false) + assertEquals(result.migrated, ["ws-a", "ws-b"]) + assertEquals(result.hasWorkspacesArray, true) + assertEquals(result.hasInlineKey, false) + assertEquals(result.keyA, "lin_api_a") + assertEquals(result.keyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - removeCredential on inline-format file preserves inline 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 = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`, + ) + + const code = ` + ${ + mockBackendAndImport( + "removeCredential, getCredentialsPath, getWorkspaces, getCredentialApiKey, isUsingInlineFormat", + ) + } + await removeCredential("ws-a"); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(JSON.stringify({ + workspaces: getWorkspaces(), + isInline: isUsingInlineFormat(), + hasWorkspacesArray: toml.includes("workspaces"), + hasInlineKeyB: toml.includes("lin_api_b"), + hasInlineKeyA: toml.includes("lin_api_a"), + keyB: getCredentialApiKey("ws-b"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.workspaces, ["ws-b"]) + assertEquals(result.isInline, true) + assertEquals(result.hasWorkspacesArray, false) + assertEquals(result.hasInlineKeyB, true) + assertEquals(result.hasInlineKeyA, false) + assertEquals(result.keyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - setDefaultWorkspace on inline-format file preserves inline 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 = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`, + ) + + const code = ` + ${ + mockBackendAndImport( + "setDefaultWorkspace, getCredentialsPath, getDefaultWorkspace, getCredentialApiKey, isUsingInlineFormat", + ) + } + await setDefaultWorkspace("ws-b"); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(JSON.stringify({ + default: getDefaultWorkspace(), + isInline: isUsingInlineFormat(), + hasWorkspacesArray: toml.includes("workspaces"), + hasInlineKeyA: toml.includes("lin_api_a"), + hasInlineKeyB: toml.includes("lin_api_b"), + keyA: getCredentialApiKey("ws-a"), + keyB: getCredentialApiKey("ws-b"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.default, "ws-b") + assertEquals(result.isInline, true) + assertEquals(result.hasWorkspacesArray, false) + assertEquals(result.hasInlineKeyA, true) + assertEquals(result.hasInlineKeyB, true) + assertEquals(result.keyA, "lin_api_a") + assertEquals(result.keyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - addCredential with plaintext false on inline file migrates all keys to keyring", 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"\n`, + ) + + const code = ` + ${ + mockBackendAndImport( + "addCredential, getCredentialsPath, getCredentialApiKey, isUsingInlineFormat", + ) + } + await addCredential("ws-b", "lin_api_b", { plaintext: false }); + const toml = await Deno.readTextFile(getCredentialsPath()!); + console.log(JSON.stringify({ + isInline: isUsingInlineFormat(), + hasWorkspacesArray: toml.includes("workspaces"), + hasInlineKeyA: toml.includes("lin_api_a"), + hasInlineKeyB: toml.includes("lin_api_b"), + keyA: getCredentialApiKey("ws-a"), + keyB: getCredentialApiKey("ws-b"), + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.isInline, false) + assertEquals(result.hasWorkspacesArray, true) + assertEquals(result.hasInlineKeyA, false) + assertEquals(result.hasInlineKeyB, false) + assertEquals(result.keyA, "lin_api_a") + assertEquals(result.keyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - migrateToKeyring rolls back on partial failure", 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 = ` +import { _setBackend } from "${keyringUrl}"; +const _store = new Map(); +_setBackend({ + async get(account: string) { return _store.get(account) ?? null }, + async set(account: string, password: string) { + if (account === "ws-b") throw new Error("keyring locked"); + _store.set(account, password); + }, + async delete(account: string) { _store.delete(account) }, + async isAvailable() { return true }, +}); +const { migrateToKeyring, isUsingInlineFormat, getCredentialsPath, getCredentialApiKey } = await import("${credentialsUrl}"); +let error = ""; +try { + await migrateToKeyring(); +} catch (e) { + error = e.message; +} +const toml = await Deno.readTextFile(getCredentialsPath()!); +console.log(JSON.stringify({ + error, + isInline: isUsingInlineFormat(), + hasInlineKeyA: toml.includes("lin_api_a"), + hasInlineKeyB: toml.includes("lin_api_b"), + hasWorkspacesArray: toml.includes("workspaces"), + keyA: getCredentialApiKey("ws-a"), + keyB: getCredentialApiKey("ws-b"), +})); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + assertEquals(result.error.includes("keyring locked"), true) + assertEquals(result.error.includes("Rolled back"), true) + assertEquals(result.isInline, true) + assertEquals(result.hasInlineKeyA, true) + assertEquals(result.hasInlineKeyB, true) + assertEquals(result.hasWorkspacesArray, false) + assertEquals(result.keyA, "lin_api_a") + assertEquals(result.keyB, "lin_api_b") + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("credentials - migrateToKeyring is no-op when already using keyring", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const code = ` + ${ + mockBackendAndImport( + "addCredential, migrateToKeyring, isUsingInlineFormat", + ) + } + await addCredential("my-ws", "lin_api_key"); + const migrated = await migrateToKeyring(); + console.log(JSON.stringify({ + isInline: isUsingInlineFormat(), + migrated, + })); + ` + + const output = await runWithCredentials(tempDir, code) + const result = JSON.parse(output) + + assertEquals(result.isInline, false) + assertEquals(result.migrated, []) } finally { await Deno.remove(tempDir, { recursive: true }) } diff --git a/test/keyring.integration.test.ts b/test/keyring.integration.test.ts new file mode 100644 index 00000000..ad6f072d --- /dev/null +++ b/test/keyring.integration.test.ts @@ -0,0 +1,38 @@ +import { assertEquals } from "@std/assert" +import { + deletePassword, + getPassword, + setPassword, +} from "../src/keyring/index.ts" + +const TEST_ACCOUNT = `linear-cli-integration-test-${Date.now()}` + +Deno.test({ + name: "keyring integration - set, get, and delete round-trip", + sanitizeResources: Deno.build.os !== "windows", + fn: async () => { + try { + assertEquals(await getPassword(TEST_ACCOUNT), null) + + await setPassword(TEST_ACCOUNT, "lin_api_test_secret") + assertEquals(await getPassword(TEST_ACCOUNT), "lin_api_test_secret") + + await setPassword(TEST_ACCOUNT, "lin_api_updated_secret") + assertEquals(await getPassword(TEST_ACCOUNT), "lin_api_updated_secret") + + await deletePassword(TEST_ACCOUNT) + assertEquals(await getPassword(TEST_ACCOUNT), null) + } finally { + // Ensure cleanup even if assertions fail + try { + await deletePassword(TEST_ACCOUNT) + } catch (error) { + console.error( + `Cleanup warning: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + }, +}) diff --git a/test/keyring.test.ts b/test/keyring.test.ts new file mode 100644 index 00000000..560c5a83 --- /dev/null +++ b/test/keyring.test.ts @@ -0,0 +1,110 @@ +import { assertEquals } from "@std/assert" +import { fromFileUrl } from "@std/path" + +const keyringUrl = new URL("../src/keyring/index.ts", import.meta.url) +const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url)) + +const MOCK_BACKEND = ` +const _store = new Map(); +_setBackend({ + get(account) { return Promise.resolve(_store.get(account) ?? null) }, + set(account, password) { _store.set(account, password); return Promise.resolve() }, + delete(account) { _store.delete(account); return Promise.resolve() }, +}); +`.trim() + +function mockAndImport(imports: string): string { + return `import { ${imports}, _setBackend } from "${keyringUrl}";\n${MOCK_BACKEND}` +} + +async function runWithKeyring(code: string): Promise { + const command = new Deno.Command("deno", { + args: [ + "eval", + `--config=${denoJsonPath}`, + code, + ], + stdout: "piped", + stderr: "piped", + }) + + const { stdout, stderr } = await command.output() + const output = new TextDecoder().decode(stdout).trim() + const errorOutput = new TextDecoder().decode(stderr) + + if (errorOutput && !errorOutput.startsWith("Check file:")) { + console.error("Subprocess stderr:", errorOutput) + } + + return output +} + +Deno.test("keyring - getPassword returns null when not set", async () => { + const code = ` + ${mockAndImport("getPassword")} + const result = await getPassword("missing"); + console.log(result === null ? "null" : result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "null") +}) + +Deno.test("keyring - setPassword and getPassword round-trip", async () => { + const code = ` + ${mockAndImport("getPassword, setPassword")} + await setPassword("my-account", "secret123"); + const result = await getPassword("my-account"); + console.log(result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "secret123") +}) + +Deno.test("keyring - deletePassword removes stored password", async () => { + const code = ` + ${mockAndImport("getPassword, setPassword, deletePassword")} + await setPassword("my-account", "secret123"); + await deletePassword("my-account"); + const result = await getPassword("my-account"); + console.log(result === null ? "null" : result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "null") +}) + +Deno.test("keyring - setPassword overwrites existing value", async () => { + const code = ` + ${mockAndImport("getPassword, setPassword")} + await setPassword("my-account", "first"); + await setPassword("my-account", "second"); + const result = await getPassword("my-account"); + console.log(result); + ` + const output = await runWithKeyring(code) + assertEquals(output, "second") +}) + +Deno.test("keyring - multiple accounts are independent", async () => { + const code = ` + ${mockAndImport("getPassword, setPassword")} + await setPassword("account-a", "password-a"); + await setPassword("account-b", "password-b"); + const a = await getPassword("account-a"); + const b = await getPassword("account-b"); + console.log(JSON.stringify({ a, b })); + ` + const output = await runWithKeyring(code) + const result = JSON.parse(output) + assertEquals(result.a, "password-a") + assertEquals(result.b, "password-b") +}) + +Deno.test("keyring - deletePassword on missing account is a no-op", async () => { + const code = ` + ${mockAndImport("deletePassword")} + await deletePassword("nonexistent"); + console.log("ok"); + ` + const output = await runWithKeyring(code) + assertEquals(output, "ok") +})