diff --git a/.changeset/calm-spoons-marry.md b/.changeset/calm-spoons-marry.md new file mode 100644 index 0000000..6432a35 --- /dev/null +++ b/.changeset/calm-spoons-marry.md @@ -0,0 +1,5 @@ +--- +"linear-cli": patch +--- + +Add OAuth token support to auth login and require explicit token type for non-interactive login via `--token` or stdin. diff --git a/README.md b/README.md index 8738462..b4424d7 100644 --- a/README.md +++ b/README.md @@ -41,23 +41,34 @@ bun run src/index.ts --help ## Authentication -Get an API token from **Linear Settings > API > Personal API keys**. +Get a personal API token from **Linear Settings > API > Personal API keys**, or use an OAuth token from a Linear app. ```sh # Interactive login linear auth login # With token directly -linear auth login --token +linear auth login --type api --token +linear auth login --type oauth --token -# Via environment variable +# Via API token environment variable export LINEAR_API_TOKEN= +# Via OAuth token environment variable +export LINEAR_OAUTH_TOKEN= + # Via pipe -echo | linear auth login +echo | linear auth login --type api +echo | linear auth login --type oauth ``` -Token is stored in `~/.config/linear-cli/config.json` with `0600` permissions. +Interactive `linear auth login` asks whether the token is an API token or an OAuth token before validating and saving it. + +When using `--token` or stdin, pass `--type api` or `--type oauth`. + +You do not need a special `-` argument for stdin. Piped input is detected automatically. + +Config is stored in `~/.config/linear-cli/config.json` with `0600` permissions. ## Usage @@ -154,6 +165,7 @@ Stored at `~/.config/linear-cli/config.json`: ```json { "apiToken": "...", + "accessToken": "...", "defaultTeamKey": "ENG", "outputFormat": "table" } diff --git a/src/auth.ts b/src/auth.ts index 7397c49..55b5041 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -26,14 +26,28 @@ export interface LoginResult { error?: string; } +export type TokenKind = "api" | "oauth"; + +function formatTokenForApi(token: string, kind: TokenKind): string { + return kind === "oauth" ? `Bearer ${token}` : token; +} + /** * Perform login: validate token and store in config. */ -export async function login(token: string): Promise { +export async function login( + token: string, + kind: TokenKind, +): Promise { try { - const viewer = await fetchViewer(token); + const trimmedToken = token.trim(); + const viewer = await fetchViewer(formatTokenForApi(trimmedToken, kind)); - await updateConfig({ apiToken: token }); + await updateConfig( + kind === "oauth" + ? { accessToken: trimmedToken, apiToken: undefined } + : { apiToken: trimmedToken, accessToken: undefined }, + ); return { success: true, @@ -54,6 +68,7 @@ export async function login(token: string): Promise { export async function logout(): Promise { const config = await loadConfig(); delete config.apiToken; + delete config.accessToken; delete config.defaultTeamKey; await saveConfig(config); } @@ -64,9 +79,18 @@ export async function logout(): Promise { export async function getAuthStatus(): Promise { await checkConfigPermissions(); - const envToken = process.env.LINEAR_API_TOKEN; + const envOauthToken = process.env.LINEAR_OAUTH_TOKEN; + const envApiToken = process.env.LINEAR_API_TOKEN; const config = await loadConfig(); - const token = envToken ?? config.apiToken; + const token = envOauthToken + ? formatTokenForApi(envOauthToken, "oauth") + : envApiToken + ? formatTokenForApi(envApiToken, "api") + : config.accessToken + ? formatTokenForApi(config.accessToken, "oauth") + : config.apiToken + ? formatTokenForApi(config.apiToken, "api") + : undefined; if (!token) { return { @@ -75,7 +99,7 @@ export async function getAuthStatus(): Promise { }; } - const tokenSource = envToken ? "env" : "config"; + const tokenSource = envOauthToken || envApiToken ? "env" : "config"; try { const viewer = await fetchViewer(token); @@ -119,7 +143,7 @@ export async function readTokenFromStdin(): Promise { * Prompt for token interactively. */ export async function promptForToken(): Promise { - process.stdout.write("Enter API token: "); + process.stdout.write("Enter token: "); return new Promise((resolve) => { let input = ""; @@ -134,3 +158,23 @@ export async function promptForToken(): Promise { process.stdin.resume(); }); } + +export async function promptForTokenKind(): Promise { + process.stdout.write("Token type [api/oauth]: "); + + return new Promise((resolve) => { + let input = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk) => { + input += chunk; + if (!input.includes("\n")) { + return; + } + + process.stdin.pause(); + const value = input.trim().toLowerCase(); + resolve(value === "oauth" || value === "o" ? "oauth" : "api"); + }); + process.stdin.resume(); + }); +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..37740c7 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getApiToken, saveConfig } from "./config"; + +describe("getApiToken", () => { + let tempDir: string; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + const originalApiToken = process.env.LINEAR_API_TOKEN; + const originalOauthToken = process.env.LINEAR_OAUTH_TOKEN; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "linear-cli-test-")); + process.env.XDG_CONFIG_HOME = tempDir; + delete process.env.LINEAR_API_TOKEN; + delete process.env.LINEAR_OAUTH_TOKEN; + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + + if (originalApiToken === undefined) { + delete process.env.LINEAR_API_TOKEN; + } else { + process.env.LINEAR_API_TOKEN = originalApiToken; + } + + if (originalOauthToken === undefined) { + delete process.env.LINEAR_OAUTH_TOKEN; + } else { + process.env.LINEAR_OAUTH_TOKEN = originalOauthToken; + } + + await rm(tempDir, { recursive: true, force: true }); + }); + + test("returns oauth env token before api env token", async () => { + process.env.LINEAR_API_TOKEN = "api-token"; + process.env.LINEAR_OAUTH_TOKEN = "oauth-token"; + + await expect(getApiToken()).resolves.toBe("Bearer oauth-token"); + }); + + test("returns api env token when oauth env token is absent", async () => { + process.env.LINEAR_API_TOKEN = "api-token"; + + await expect(getApiToken()).resolves.toBe("api-token"); + }); + + test("returns config access token before config api token", async () => { + await saveConfig({ + accessToken: "oauth-token", + apiToken: "api-token", + outputFormat: "table", + }); + + await expect(getApiToken()).resolves.toBe("Bearer oauth-token"); + }); + + test("returns config api token when access token is absent", async () => { + await saveConfig({ + apiToken: "api-token", + outputFormat: "table", + }); + + await expect(getApiToken()).resolves.toBe("api-token"); + }); +}); diff --git a/src/config.ts b/src/config.ts index 8c900a9..812d158 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,9 +9,12 @@ import { dirname, join } from "node:path"; import { CONFIG_FILE } from "./constants.ts"; export interface Config { - // API token from Linear settings + // Personal API token from Linear settings apiToken?: string; + // OAuth token from a Linear app + accessToken?: string; + // Default team key (e.g. "ENG") defaultTeamKey?: string; @@ -70,6 +73,10 @@ export function validateConfig(data: unknown): Config { config.apiToken = obj.apiToken; } + if (typeof obj.accessToken === "string") { + config.accessToken = obj.accessToken; + } + if (typeof obj.defaultTeamKey === "string") { config.defaultTeamKey = obj.defaultTeamKey; } @@ -148,16 +155,24 @@ export async function updateConfig(updates: Partial): Promise { } /** - * Get the API token, checking env var first, then config file. + * Get the auth token, checking env vars first, then config file. */ export async function getApiToken(): Promise { - // Environment variable takes precedence - const envToken = process.env.LINEAR_API_TOKEN; - if (envToken) { - return envToken; + const envOauthToken = process.env.LINEAR_OAUTH_TOKEN; + if (envOauthToken) { + return `Bearer ${envOauthToken}`; + } + + const envApiToken = process.env.LINEAR_API_TOKEN; + if (envApiToken) { + return envApiToken; } const config = await loadConfig(); + if (config.accessToken) { + return `Bearer ${config.accessToken}`; + } + return config.apiToken; } diff --git a/src/index.ts b/src/index.ts index 2707338..222a4e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { login, logout, promptForToken, + promptForTokenKind, readTokenFromStdin, } from "./auth"; import { parseArgs, printCompletion, printHelp, printVersion } from "./cli"; @@ -126,8 +127,13 @@ async function main(): Promise { } } -function parseAuthArgs(args: string[]): { token?: string; help: boolean } { +function parseAuthArgs(args: string[]): { + token?: string; + type?: "api" | "oauth"; + help: boolean; +} { let token: string | undefined; + let type: "api" | "oauth" | undefined; let help = false; for (let i = 0; i < args.length; i++) { @@ -139,10 +145,21 @@ function parseAuthArgs(args: string[]): { token?: string; help: boolean } { i++; } else if (arg?.startsWith("--token=")) { token = arg.slice(8); + } else if (arg === "--type" && args[i + 1]) { + const value = args[i + 1]; + if (value === "api" || value === "oauth") { + type = value; + } + i++; + } else if (arg?.startsWith("--type=")) { + const value = arg.slice(7); + if (value === "api" || value === "oauth") { + type = value; + } } } - return { token, help }; + return { token, type, help }; } async function handleAuth( @@ -155,13 +172,16 @@ async function handleAuth( case "login": { if (parsed.help) { console.log(` -Usage: linear auth login [--token ] +Usage: linear auth login [--token ] [--type ] -Authenticate with Linear using an API token. +Authenticate with Linear using an API token or an OAuth token. Options: - --token API token (can also be piped via stdin) - -h, --help Show this help + --token Token value + --type Required with --token or stdin + -h, --help Show this help + +Interactive login will ask for the token type first. Get your API token from: Linear Settings > API > Personal API keys @@ -171,21 +191,35 @@ Get your API token from: let token = parsed.token; - if (!token) { + if (token) { + if (!parsed.type) { + console.error("Error: --type is required with --token"); + process.exit(1); + } + } else { + if (!process.stdin.isTTY && !parsed.type) { + console.error( + "Error: --type is required when reading token from stdin", + ); + process.exit(1); + } + token = (await readTokenFromStdin()) ?? undefined; } + const kind = parsed.type ?? (await promptForTokenKind()); + if (!token) { token = await promptForToken(); } if (!token) { - console.error("Error: No API token provided"); + console.error("Error: No token provided"); process.exit(1); } console.log("Validating token..."); - const result = await login(token); + const result = await login(token, kind); if (result.success) { console.log(`Authenticated as: ${result.name}`);