diff --git a/.changeset/khaki-badgers-sip.md b/.changeset/khaki-badgers-sip.md new file mode 100644 index 0000000..bbe9f74 --- /dev/null +++ b/.changeset/khaki-badgers-sip.md @@ -0,0 +1,9 @@ +--- +"linear-cli": minor +--- + +Remove OAuth authentication support and legacy auth parsing. + +The CLI now accepts API keys only. Authentication resolves from `LINEAR_API_TOKEN` or `workspaces..apiToken`. + +Top-level legacy auth fields and OAuth fields in config are now ignored and never written back. diff --git a/README.md b/README.md index 7b464b5..a2f92a8 100644 --- a/README.md +++ b/README.md @@ -41,42 +41,30 @@ bun run src/index.ts --help ## Authentication -Get a personal API token from **Linear Settings > API > Personal API keys**, or use an OAuth token from a Linear app. +Get a personal API token from **Linear Settings > API > Personal API keys**. ```sh # Interactive login linear auth login # With token directly -linear auth login --type api --token -linear auth login --type oauth --token +linear auth login --token # Save under a specific workspace profile name -linear auth login --type api --token --workspace personal - -# With OAuth refresh token and expiry (optional, enables automatic token refresh) -linear auth login --type oauth --token \ - --refresh-token \ - --expires-at +linear auth login --token --workspace personal # Via API token environment variable export LINEAR_API_TOKEN= -# Via OAuth token environment variable -export LINEAR_OAUTH_TOKEN= - # Workspace selection for config-stored credentials export LINEAR_WORKSPACE=acme linear --workspace personal issue list # Via pipe -echo | linear auth login --type api -echo | linear auth login --type oauth +echo | linear auth login ``` -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`. +Interactive `linear auth login` prompts for your API token and validates it before saving. You do not need a special `-` argument for stdin. Piped input is detected automatically. @@ -103,13 +91,7 @@ linear auth use "outputFormat": "table" }, "personal": { - "oauth": { - "access": "...", - "refresh": "...", - "expiresAt": "2026-01-01T00:00:00.000Z", - "clientId": "...", - "clientSecret": "..." - }, + "apiToken": "...", "orgName": "Personal Workspace" } }, @@ -120,19 +102,8 @@ linear auth use Credentials are stored per workspace profile under `workspaces.`. The CLI auto-detects workspace profile name from Linear organization `urlKey` during login. Use `--workspace` to override the profile name. -Legacy flat config fields are still read for backward compatibility. On first run after upgrading, the CLI now automatically migrates legacy config to workspace format and creates a backup named `config..json` next to `config.json`. - The CLI writes and updates `$schema` automatically, pointing to the schema file for the CLI version used to write the config. -### Automatic OAuth token refresh - -When a config-stored OAuth token has an `oauth.refresh` value, the CLI will automatically refresh it on a 401 response and retry the request once. - -Refresh credentials are resolved in this order: -1. `LINEAR_CLIENT_ID` + `LINEAR_CLIENT_SECRET` from the environment -2. `oauth.clientId` + `oauth.clientSecret` from `~/.config/linear-cli/config.json` - -> Note: storing `oauth.clientSecret` in the config file is convenient, but it is still a secret. The CLI stores the config with `0600` permissions. ## Usage @@ -252,13 +223,7 @@ Stored at `~/.config/linear-cli/config.json`: "orgName": "Acme Inc" }, "personal": { - "oauth": { - "access": "...", - "refresh": "...", - "expiresAt": "2026-01-01T00:00:00.000Z", - "clientId": "...", - "clientSecret": "..." - }, + "apiToken": "...", "orgName": "Personal Workspace" } }, diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 25b0589..b8caa62 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -22,77 +22,28 @@ "additionalProperties": false, "properties": { "apiToken": { - "type": "string" - }, - "oauth": { - "type": "object", - "additionalProperties": false, - "properties": { - "access": { - "type": "string" - }, - "refresh": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "format": "date-time" - }, - "clientId": { - "type": "string" - }, - "clientSecret": { - "type": "string" - } - } + "type": "string", + "description": "Linear personal API key for this workspace." }, "orgName": { - "type": "string" + "type": "string", + "description": "Display name of the Linear organization." }, "defaultTeamKey": { - "type": "string" + "type": "string", + "description": "Default Linear team key for this workspace, e.g. ENG." }, "outputFormat": { "type": "string", "enum": [ "json", "table" - ] + ], + "description": "Default output format for this workspace." } } } }, - "apiToken": { - "type": "string", - "description": "Linear personal API key." - }, - "oauth": { - "type": "object", - "additionalProperties": false, - "properties": { - "access": { - "type": "string", - "description": "OAuth access token." - }, - "refresh": { - "type": "string", - "description": "OAuth refresh token." - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "description": "ISO timestamp for access token expiry." - }, - "clientId": { - "type": "string", - "description": "OAuth client ID used for refresh." - }, - "clientSecret": { - "type": "string", - "description": "OAuth client secret used for refresh." - } - } - }, "defaultTeamKey": { "type": "string", "description": "Default Linear team key, e.g. ENG." diff --git a/src/api.test.ts b/src/api.test.ts index 3df40bd..ac71695 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,14 +1,9 @@ /** - * Tests for graphql() OAuth refresh-on-401 behavior. + * Tests for graphql() and API helpers. */ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { afterEach, describe, expect, mock, test } from "bun:test"; import { fetchOrganization, graphql } from "./api.ts"; -import type { ResolvedAuth } from "./config.ts"; -import { loadConfig, saveConfig } from "./config.ts"; function makeJsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -21,334 +16,10 @@ const SUCCESS_BODY = { data: { viewer: { id: "1", name: "Test", email: "t@t.com" } }, }; -let tempDir: string; -const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; -const originalClientId = process.env.LINEAR_CLIENT_ID; -const originalClientSecret = process.env.LINEAR_CLIENT_SECRET; - -beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "linear-cli-api-test-")); - process.env.XDG_CONFIG_HOME = tempDir; - process.env.LINEAR_CLIENT_ID = "test-client-id"; - process.env.LINEAR_CLIENT_SECRET = "test-client-secret"; -}); - -afterEach(async () => { - if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; - else process.env.XDG_CONFIG_HOME = originalXdgConfigHome; - - if (originalClientId === undefined) delete process.env.LINEAR_CLIENT_ID; - else process.env.LINEAR_CLIENT_ID = originalClientId; - - if (originalClientSecret === undefined) - delete process.env.LINEAR_CLIENT_SECRET; - else process.env.LINEAR_CLIENT_SECRET = originalClientSecret; - - await rm(tempDir, { recursive: true, force: true }); +afterEach(() => { mock.restore(); }); -describe("graphql() with config OAuth token + refreshToken on 401", () => { - test("refreshes token, retries, and updates config on 401", async () => { - await saveConfig({ - defaultWorkspace: "acme", - workspaces: { - acme: { - accessToken: "old-access-token", - refreshToken: "my-refresh-token", - }, - }, - outputFormat: "table", - }); - - const auth: ResolvedAuth = { - header: "Bearer old-access-token", - token: "old-access-token", - source: "config", - kind: "oauth", - workspace: "acme", - refreshToken: "my-refresh-token", - }; - - let callCount = 0; - const fetchMock = mock(async (url: string) => { - if (typeof url === "string" && url.includes("/oauth/token")) { - return makeJsonResponse({ - access_token: "new-access-token", - refresh_token: "new-refresh-token", - expires_in: 3600, - }); - } - callCount++; - if (callCount === 1) { - return new Response("Unauthorized", { status: 401 }); - } - return makeJsonResponse(SUCCESS_BODY); - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { - const result = await graphql<(typeof SUCCESS_BODY)["data"]>( - auth, - "query { viewer { id name email } }", - ); - expect(result.viewer.name).toBe("Test"); - expect(callCount).toBe(2); - - const config = await loadConfig(); - expect(config.workspaces?.acme?.accessToken).toBe("new-access-token"); - expect(config.workspaces?.acme?.refreshToken).toBe("new-refresh-token"); - expect(config.workspaces?.acme?.accessTokenExpiresAt).toBeDefined(); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("refreshes using oauth client credentials from config when env vars are absent", async () => { - delete process.env.LINEAR_CLIENT_ID; - delete process.env.LINEAR_CLIENT_SECRET; - - await saveConfig({ - defaultWorkspace: "acme", - workspaces: { - acme: { - accessToken: "old-access-token", - refreshToken: "my-refresh-token", - oauthClientId: "config-client-id", - oauthClientSecret: "config-client-secret", - }, - }, - outputFormat: "table", - }); - - const auth: ResolvedAuth = { - header: "Bearer old-access-token", - token: "old-access-token", - source: "config", - kind: "oauth", - workspace: "acme", - refreshToken: "my-refresh-token", - }; - - let sawBasicAuth = false; - let callCount = 0; - const fetchMock = mock(async (url: string, init?: RequestInit) => { - if (typeof url === "string" && url.includes("/oauth/token")) { - sawBasicAuth = - init?.headers instanceof Headers - ? init.headers.get("Authorization") === - `Basic ${Buffer.from("config-client-id:config-client-secret").toString("base64")}` - : (init?.headers as Record | undefined) - ?.Authorization === - `Basic ${Buffer.from("config-client-id:config-client-secret").toString("base64")}`; - return makeJsonResponse({ - access_token: "new-access-token", - refresh_token: "new-refresh-token", - expires_in: 3600, - }); - } - callCount++; - if (callCount === 1) { - return new Response("Unauthorized", { status: 401 }); - } - return makeJsonResponse(SUCCESS_BODY); - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { - const result = await graphql<(typeof SUCCESS_BODY)["data"]>( - auth, - "query { viewer { id name email } }", - ); - expect(result.viewer.name).toBe("Test"); - expect(sawBasicAuth).toBe(true); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not refresh if no refreshToken in auth", async () => { - const auth: ResolvedAuth = { - header: "Bearer no-refresh-token", - token: "no-refresh-token", - source: "config", - kind: "oauth", - workspace: "acme", - }; - - let callCount = 0; - const fetchMock = mock(async () => { - callCount++; - return new Response("Unauthorized", { status: 401 }); - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { - await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( - "Invalid API token", - ); - expect(callCount).toBe(1); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not refresh env OAuth token on 401", async () => { - const auth: ResolvedAuth = { - header: "Bearer env-oauth-token", - token: "env-oauth-token", - source: "env", - kind: "oauth", - refreshToken: "should-not-use", - }; - - let callCount = 0; - const fetchMock = mock(async () => { - callCount++; - return new Response("Unauthorized", { status: 401 }); - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { - await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( - "Invalid API token", - ); - expect(callCount).toBe(1); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not refresh config api token on 401", async () => { - const auth: ResolvedAuth = { - header: "api-key", - token: "api-key", - source: "config", - kind: "api", - }; - - let callCount = 0; - const fetchMock = mock(async () => { - callCount++; - return new Response("Unauthorized", { status: 401 }); - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { - await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( - "Invalid API token", - ); - expect(callCount).toBe(1); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("does not loop: only retries once after refresh", async () => { - await saveConfig({ - defaultWorkspace: "acme", - workspaces: { - acme: { - accessToken: "old-access-token", - refreshToken: "my-refresh-token", - }, - }, - outputFormat: "table", - }); - - const auth: ResolvedAuth = { - header: "Bearer old-access-token", - token: "old-access-token", - source: "config", - kind: "oauth", - workspace: "acme", - refreshToken: "my-refresh-token", - }; - - let graphqlCallCount = 0; - const fetchMock = mock(async (url: string) => { - if (typeof url === "string" && url.includes("/oauth/token")) { - return makeJsonResponse({ - access_token: "new-access-token", - expires_in: 3600, - }); - } - graphqlCallCount++; - return new Response("Unauthorized", { status: 401 }); - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock as unknown as typeof fetch; - - try { - await expect(graphql(auth, "query { viewer { id } }")).rejects.toThrow( - "Invalid API token", - ); - expect(graphqlCallCount).toBe(2); - } finally { - globalThis.fetch = originalFetch; - } - }); - - test("persists rotated refresh token from response", async () => { - await saveConfig({ - defaultWorkspace: "acme", - workspaces: { - acme: { - accessToken: "old-token", - refreshToken: "old-refresh", - }, - }, - outputFormat: "table", - }); - - const auth: ResolvedAuth = { - header: "Bearer old-token", - token: "old-token", - source: "config", - kind: "oauth", - workspace: "acme", - refreshToken: "old-refresh", - }; - - let gqlCalls = 0; - const fetchMock2 = mock(async (url: string) => { - if (typeof url === "string" && url.includes("/oauth/token")) { - return makeJsonResponse({ - access_token: "rotated-access", - refresh_token: "rotated-refresh", - expires_in: 7200, - }); - } - gqlCalls++; - if (gqlCalls === 1) return new Response("Unauthorized", { status: 401 }); - return makeJsonResponse(SUCCESS_BODY); - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock2 as unknown as typeof fetch; - - try { - await graphql(auth, "query { viewer { id } }"); - const config = await loadConfig(); - expect(config.workspaces?.acme?.accessToken).toBe("rotated-access"); - expect(config.workspaces?.acme?.refreshToken).toBe("rotated-refresh"); - } finally { - globalThis.fetch = originalFetch; - } - }); -}); - describe("fetchOrganization", () => { test("returns organization metadata", async () => { const fetchMock = mock(async () => @@ -362,7 +33,7 @@ describe("fetchOrganization", () => { globalThis.fetch = fetchMock as unknown as typeof fetch; try { - const org = await fetchOrganization("Bearer string-token"); + const org = await fetchOrganization("api-token"); expect(org.id).toBe("org-1"); expect(org.name).toBe("Acme Inc"); expect(org.urlKey).toBe("acme"); @@ -380,7 +51,7 @@ describe("graphql() with plain string token", () => { try { const result = await graphql<(typeof SUCCESS_BODY)["data"]>( - "Bearer string-token", + "api-token", "query { viewer { id name email } }", ); expect(result.viewer.name).toBe("Test"); @@ -388,6 +59,22 @@ describe("graphql() with plain string token", () => { globalThis.fetch = originalFetch; } }); + + test("returns invalid API token on 401", async () => { + const fetchMock = mock( + async () => new Response("Unauthorized", { status: 401 }), + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await expect( + graphql("api-token", "query { viewer { id } }"), + ).rejects.toThrow("Invalid API token"); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe("graphql() error handling", () => { @@ -404,12 +91,12 @@ describe("graphql() error handling", () => { try { await expect( - graphql("Bearer test-token", "query { viewer { id } }"), + graphql("api-token", "query { viewer { id } }"), ).rejects.toThrow( /Linear API request failed with 503 Service Unavailable: x{50}/, ); await expect( - graphql("Bearer test-token", "query { viewer { id } }"), + graphql("api-token", "query { viewer { id } }"), ).rejects.not.toThrow(/x{250}/); } finally { globalThis.fetch = originalFetch; @@ -428,7 +115,7 @@ describe("graphql() error handling", () => { try { await expect( graphql( - "Bearer test-token", + "api-token", "query Project($id: String!) { project(id: $id) { id } }", { id: "project-123", diff --git a/src/api.ts b/src/api.ts index 3e6c6bb..b9dc613 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,7 +5,6 @@ import type { ResolvedAuth } from "./config.ts"; import { LINEAR_API_URL } from "./constants.ts"; import { CliError } from "./errors.ts"; -import { refreshOAuthToken } from "./oauth.ts"; export type { ResolvedAuth }; @@ -47,38 +46,14 @@ function truncateDetail(value: string, maxLength = 200): string { /** * Execute a GraphQL request against the Linear API and return the raw GraphQL response. - * - * When passed a ResolvedAuth from config with a refreshToken, a 401 response - * will trigger a single token-refresh attempt followed by a retry. - * Tokens from environment variables are never refreshed automatically. */ export async function graphqlRequest( auth: string | ResolvedAuth, query: string, variables?: Record, ): Promise> { - let currentHeader = authHeader(auth); - let response = await doGraphQL(currentHeader, query, variables); - - if ( - response.status === 401 && - typeof auth !== "string" && - auth.source === "config" && - auth.kind === "oauth" && - auth.refreshToken - ) { - if (!auth.workspace) { - throw new CliError( - "Cannot refresh OAuth token without a workspace context.", - { - suggestion: - "Run 'linear auth login' to migrate credentials to a workspace profile.", - }, - ); - } - currentHeader = await refreshOAuthToken(auth.refreshToken, auth.workspace); - response = await doGraphQL(currentHeader, query, variables); - } + const currentHeader = authHeader(auth); + const response = await doGraphQL(currentHeader, query, variables); if (!response.ok) { if (response.status === 401) { diff --git a/src/auth.ts b/src/auth.ts index d426f94..0b3c6c2 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -13,9 +13,6 @@ import { saveConfig, } from "./config.ts"; import { CliError } from "./errors.ts"; -import { refreshOAuthToken } from "./oauth.ts"; - -export { refreshOAuthToken }; export interface AuthStatus { authenticated: boolean; @@ -36,24 +33,16 @@ 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. - * For OAuth logins, optionally persist refreshToken and accessTokenExpiresAt. */ export async function login( token: string, - kind: TokenKind, - options?: { refreshToken?: string; expiresAt?: string; workspace?: string }, + options?: { workspace?: string }, ): Promise { try { const trimmedToken = token.trim(); - const authHeader = formatTokenForApi(trimmedToken, kind); + const authHeader = trimmedToken; const viewer = await fetchViewer(authHeader); @@ -102,52 +91,17 @@ export async function login( const workspaces = { ...(config.workspaces ?? {}) }; const currentProfile = workspaces[workspace] ?? {}; const hadWorkspaces = Object.keys(workspaces).length > 0; - const isImplicitLegacyDefault = - config.defaultWorkspace === "default" && - workspace !== "default" && - Object.keys(config.workspaces ?? {}).length === 1 && - (config.apiToken !== undefined || - config.accessToken !== undefined || - config.refreshToken !== undefined); - - if (kind === "oauth") { - const { apiToken: _unusedApiToken, ...restProfile } = currentProfile; - workspaces[workspace] = { - ...restProfile, - orgName: organization?.name ?? currentProfile.orgName, - accessToken: trimmedToken, - ...(options?.refreshToken !== undefined - ? { refreshToken: options.refreshToken } - : {}), - ...(options?.expiresAt !== undefined - ? { accessTokenExpiresAt: options.expiresAt } - : {}), - }; - } else { - const { - accessToken: _unusedAccessToken, - refreshToken: _unusedRefreshToken, - accessTokenExpiresAt: _unusedExpiresAt, - ...restProfile - } = currentProfile; - workspaces[workspace] = { - ...restProfile, - orgName: organization?.name ?? currentProfile.orgName, - apiToken: trimmedToken, - }; - } - if (isImplicitLegacyDefault) { - delete workspaces.default; - } + workspaces[workspace] = { + ...currentProfile, + orgName: organization?.name ?? currentProfile.orgName, + apiToken: trimmedToken, + }; await saveConfig({ ...config, workspaces, - defaultWorkspace: - !hadWorkspaces || isImplicitLegacyDefault - ? workspace - : config.defaultWorkspace, + defaultWorkspace: !hadWorkspaces ? workspace : config.defaultWorkspace, }); return { @@ -166,7 +120,7 @@ export async function login( } /** - * Perform logout: remove token and related OAuth fields from config. + * Perform logout: remove workspace from config. */ export async function logout(workspace?: string): Promise { const config = await loadConfig(); @@ -222,12 +176,7 @@ export async function logout(workspace?: string): Promise { return; } - // Legacy fallback - delete config.apiToken; - delete config.accessToken; - delete config.refreshToken; - delete config.accessTokenExpiresAt; - delete config.defaultWorkspace; + // No workspace to remove; re-serialize config to drop ignored legacy fields. await saveConfig(config); } @@ -313,19 +262,3 @@ export async function promptForToken(): Promise { return String(value).trim(); } - -export async function promptForTokenKind(): Promise { - const value = await p.select({ - message: "Token type", - options: [ - { value: "api", label: "API token" }, - { value: "oauth", label: "OAuth token" }, - ], - }); - - if (p.isCancel(value)) { - return "api"; - } - - return value === "oauth" ? "oauth" : "api"; -} diff --git a/src/commands/graphql.test.ts b/src/commands/graphql.test.ts index 6b3d6f3..4deeb7e 100644 --- a/src/commands/graphql.test.ts +++ b/src/commands/graphql.test.ts @@ -48,7 +48,11 @@ describe("parseVariables", () => { describe("runGraphql", () => { test("sends parsed variables to the GraphQL API and prints the response", async () => { - await saveConfig({ apiToken: "test-api-token", outputFormat: "table" }); + await saveConfig({ + defaultWorkspace: "default", + workspaces: { default: { apiToken: "test-api-token" } }, + outputFormat: "table", + }); let requestBody: | { query?: string; variables?: Record } diff --git a/src/config.test.ts b/src/config.test.ts index 0c81602..c792579 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -8,7 +8,6 @@ import { getConfigSchemaUrl, loadConfig, loadMergedConfig, - migrateConfigOnStartup, resolveAuth, saveConfig, saveWorkspaceProfile, @@ -38,29 +37,52 @@ describe("validateConfig", () => { expect(result.workspaces?.acme?.orgName).toBe("Acme Inc"); }); - test("legacy flat config is exposed as implicit default workspace", () => { + test("ignores top-level legacy auth fields entirely", () => { const result = validateConfig({ - apiToken: "legacy-api", + apiToken: "should-be-ignored", oauth: { - access: "legacy-oauth", - refresh: "legacy-refresh", - expiresAt: "2099-01-01T00:00:00.000Z", + access: "ignored-oauth", + refresh: "ignored-refresh", }, + accessToken: "also-ignored", + outputFormat: "table", }); - expect(result.workspaces?.default?.apiToken).toBe("legacy-api"); - expect(result.workspaces?.default?.accessToken).toBe("legacy-oauth"); - expect(result.workspaces?.default?.refreshToken).toBe("legacy-refresh"); - expect(result.defaultWorkspace).toBe("default"); + // No workspace is synthesised from legacy fields + expect(result.workspaces).toBeUndefined(); + expect(result.defaultWorkspace).toBeUndefined(); + // Non-auth defaults still parsed + expect(result.outputFormat).toBe("table"); }); test("strips unknown fields", () => { const result = validateConfig({ unknownField: "value" }); expect((result as Record).unknownField).toBeUndefined(); }); + + test("workspace oauth sub-object is ignored (not parsed into profile)", () => { + const result = validateConfig({ + defaultWorkspace: "acme", + workspaces: { + acme: { + apiToken: "api-acme", + oauth: { + access: "should-be-stripped", + refresh: "also-stripped", + }, + }, + }, + }); + + const profile = result.workspaces?.acme; + expect(profile?.apiToken).toBe("api-acme"); + // oauth fields must not bleed into the profile type + expect((profile as Record)?.oauth).toBeUndefined(); + expect((profile as Record)?.accessToken).toBeUndefined(); + }); }); -describe("saveConfig migration + schema", () => { +describe("saveConfig + schema", () => { let tempDir: string; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; @@ -79,37 +101,55 @@ describe("saveConfig migration + schema", () => { await rm(tempDir, { recursive: true, force: true }); }); - test("saveConfig writes new workspace-keyed format and schema", async () => { + test("saveConfig writes workspace-keyed format with $schema", async () => { await saveConfig({ - apiToken: "legacy-api", + defaultWorkspace: "acme", + workspaces: { acme: { apiToken: "api-acme", orgName: "Acme Inc" } }, outputFormat: "table", }); const content = await Bun.file(getConfigPath()).text(); - const parsed = JSON.parse(content) as { - $schema?: string; - apiToken?: string; - defaultWorkspace?: string; - workspaces?: Record; - }; + const parsed = JSON.parse(content) as Record; expect(parsed.$schema).toBe(getConfigSchemaUrl()); + expect(parsed.defaultWorkspace).toBe("acme"); + expect( + (parsed.workspaces as Record)?.acme + ?.apiToken, + ).toBe("api-acme"); + // No top-level auth fields should be written expect(parsed.apiToken).toBeUndefined(); - expect(parsed.defaultWorkspace).toBe("default"); - expect(parsed.workspaces?.default?.apiToken).toBe("legacy-api"); + expect(parsed.oauth).toBeUndefined(); + }); + + test("saveConfig never writes oauth blocks in workspace profiles", async () => { + await saveConfig({ + defaultWorkspace: "acme", + workspaces: { + acme: { apiToken: "api-acme" }, + }, + outputFormat: "table", + }); + + const content = await Bun.file(getConfigPath()).text(); + const parsed = JSON.parse(content) as Record; + const workspaces = parsed.workspaces as Record< + string, + Record + >; + + expect(workspaces?.acme?.oauth).toBeUndefined(); + expect(workspaces?.acme?.accessToken).toBeUndefined(); }); - test("round-trips workspace profiles", async () => { + test("round-trips workspace profile non-auth fields", async () => { await saveConfig({ defaultWorkspace: "acme", workspaces: { acme: { - accessToken: "oauth", - refreshToken: "refresh", - accessTokenExpiresAt: "2099-01-01T00:00:00.000Z", - oauthClientId: "cid", - oauthClientSecret: "secret", + apiToken: "api-acme", orgName: "Acme Inc", + defaultTeamKey: "ENG", outputFormat: "json", }, }, @@ -118,51 +158,37 @@ describe("saveConfig migration + schema", () => { const loaded = await loadConfig(); expect(loaded.defaultWorkspace).toBe("acme"); - expect(loaded.workspaces?.acme?.accessToken).toBe("oauth"); - expect(loaded.workspaces?.acme?.refreshToken).toBe("refresh"); - expect(loaded.workspaces?.acme?.oauthClientId).toBe("cid"); - expect(loaded.workspaces?.acme?.oauthClientSecret).toBe("secret"); + expect(loaded.workspaces?.acme?.apiToken).toBe("api-acme"); expect(loaded.workspaces?.acme?.orgName).toBe("Acme Inc"); + expect(loaded.workspaces?.acme?.defaultTeamKey).toBe("ENG"); + expect(loaded.workspaces?.acme?.outputFormat).toBe("json"); }); - test("migrates legacy config on startup and creates versioned backup", async () => { + test("top-level auth fields in config file are ignored on load", async () => { const configPath = getConfigPath(); + const configDir = join(tempDir, "linear-cli"); + await mkdir(configDir, { recursive: true }); + // Write a file with legacy top-level auth fields await Bun.write( configPath, JSON.stringify( { - apiToken: "legacy-api", - outputFormat: "table", + apiToken: "top-level-ignored", + oauth: { access: "ignored-access" }, + outputFormat: "json", }, null, 2, ), ); - await migrateConfigOnStartup(); - - const migratedContent = JSON.parse( - await Bun.file(configPath).text(), - ) as Record; - - expect(migratedContent.apiToken).toBeUndefined(); - expect( - (migratedContent.workspaces as Record) - ?.default?.apiToken, - ).toBe("legacy-api"); - - const configDir = join(tempDir, "linear-cli"); - const entries = await readdir(configDir); - const backup = entries.find( - (entry) => entry.startsWith("config.") && entry !== "config.json", - ); - - expect(backup).toBeDefined(); - const backupContent = JSON.parse( - await Bun.file(join(configDir, backup as string)).text(), - ) as { apiToken?: string }; - expect(backupContent.apiToken).toBe("legacy-api"); + const loaded = await loadConfig(); + // Top-level auth fields must be ignored + expect(loaded.workspaces).toBeUndefined(); + expect(loaded.defaultWorkspace).toBeUndefined(); + // Non-auth defaults still parsed + expect(loaded.outputFormat).toBe("json"); }); }); @@ -171,7 +197,6 @@ describe("resolveAuth + workspace resolution", () => { let projectDir: string; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; const originalApiToken = process.env.LINEAR_API_TOKEN; - const originalOauthToken = process.env.LINEAR_OAUTH_TOKEN; const originalWorkspace = process.env.LINEAR_WORKSPACE; const originalCwd = process.cwd(); @@ -180,7 +205,6 @@ describe("resolveAuth + workspace resolution", () => { projectDir = await mkdtemp(join(tmpdir(), "linear-cli-project-")); process.env.XDG_CONFIG_HOME = tempDir; delete process.env.LINEAR_API_TOKEN; - delete process.env.LINEAR_OAUTH_TOKEN; delete process.env.LINEAR_WORKSPACE; }); @@ -193,9 +217,6 @@ describe("resolveAuth + workspace resolution", () => { 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; - if (originalWorkspace === undefined) delete process.env.LINEAR_WORKSPACE; else process.env.LINEAR_WORKSPACE = originalWorkspace; @@ -203,19 +224,19 @@ describe("resolveAuth + workspace resolution", () => { await rm(projectDir, { recursive: true, force: true }); }); - test("env tokens override workspace selection", async () => { + test("env api token overrides workspace selection", async () => { await saveConfig({ defaultWorkspace: "acme", workspaces: { acme: { apiToken: "cfg-api" } }, outputFormat: "table", }); - process.env.LINEAR_OAUTH_TOKEN = "env-oauth"; + process.env.LINEAR_API_TOKEN = "env-api"; const auth = await resolveAuth({ workspace: "acme" }); expect(auth?.source).toBe("env"); - expect(auth?.kind).toBe("oauth"); - expect(auth?.header).toBe("Bearer env-oauth"); + expect(auth?.kind).toBe("api"); + expect(auth?.header).toBe("env-api"); }); test("selects explicit workspace", async () => { @@ -223,15 +244,15 @@ describe("resolveAuth + workspace resolution", () => { defaultWorkspace: "acme", workspaces: { acme: { apiToken: "api-acme" }, - personal: { accessToken: "oauth-personal", refreshToken: "r1" }, + personal: { apiToken: "api-personal" }, }, outputFormat: "table", }); const auth = await resolveAuth({ workspace: "personal" }); expect(auth?.workspace).toBe("personal"); - expect(auth?.kind).toBe("oauth"); - expect(auth?.refreshToken).toBe("r1"); + expect(auth?.kind).toBe("api"); + expect(auth?.header).toBe("api-personal"); }); test("uses local config workspace before defaultWorkspace", async () => { @@ -312,6 +333,17 @@ describe("resolveAuth + workspace resolution", () => { const auth = await resolveAuth(); expect(auth?.workspace).toBe("acme"); }); + + test("returns undefined when no workspace has apiToken", async () => { + await saveConfig({ + defaultWorkspace: "acme", + workspaces: { acme: { orgName: "Acme Inc" } }, + outputFormat: "table", + }); + + const auth = await resolveAuth(); + expect(auth).toBeUndefined(); + }); }); describe("workspace helpers + local config parsing", () => { @@ -393,13 +425,11 @@ 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 () => { @@ -409,28 +439,24 @@ describe("getApiToken", () => { 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 () => { + test("returns 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"); + await expect(getApiToken()).resolves.toBe("api-token"); }); test("returns workspace config token", async () => { await saveConfig({ defaultWorkspace: "acme", workspaces: { - acme: { accessToken: "oauth-token" }, + acme: { apiToken: "api-token" }, }, outputFormat: "table", }); - await expect(getApiToken()).resolves.toBe("Bearer oauth-token"); + await expect(getApiToken()).resolves.toBe("api-token"); }); }); diff --git a/src/config.ts b/src/config.ts index 06e5bdc..d05616f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,25 +11,6 @@ import { CliError } from "./errors.ts"; export interface WorkspaceProfile { apiToken?: string; - accessToken?: string; - refreshToken?: string; - accessTokenExpiresAt?: string; - oauthClientId?: string; - oauthClientSecret?: string; - orgName?: string; - defaultTeamKey?: string; - outputFormat?: "json" | "table"; -} - -interface WorkspaceFileProfile { - apiToken?: string; - oauth?: { - access?: string; - refresh?: string; - expiresAt?: string; - clientId?: string; - clientSecret?: string; - }; orgName?: string; defaultTeamKey?: string; outputFormat?: "json" | "table"; @@ -40,36 +21,16 @@ export interface Config { workspaces?: Record; defaultWorkspace?: string; - // Active workspace credentials (resolved from workspace or legacy) - apiToken?: string; - accessToken?: string; - refreshToken?: string; - accessTokenExpiresAt?: string; - oauthClientId?: string; - oauthClientSecret?: string; - // Defaults defaultTeamKey?: string; outputFormat?: "json" | "table"; localConfigPaths?: string[]; } -export interface ConfigFileShape { +interface ConfigFileShape { $schema?: string; - - // New workspace-keyed config - workspaces?: Record; + workspaces?: Record; defaultWorkspace?: string; - - // Legacy top-level fields (kept for backward compatibility) - apiToken?: string; - oauth?: { - access?: string; - refresh?: string; - expiresAt?: string; - clientId?: string; - clientSecret?: string; - }; defaultTeamKey?: string; outputFormat?: "json" | "table"; localConfigPaths?: string[]; @@ -92,14 +53,10 @@ export interface ResolvedAuth { token: string; /** Whether token came from env var or config file */ source: "env" | "config"; - /** Whether this is an oauth or api token */ - kind: "oauth" | "api"; + /** Token kind (API keys only) */ + kind: "api"; /** Workspace profile used (config tokens only) */ workspace?: string; - /** Refresh token (config oauth only) */ - refreshToken?: string; - /** ISO expiry timestamp (config oauth only) */ - accessTokenExpiresAt?: string; } const DEFAULT_CONFIG: Config = { @@ -112,57 +69,23 @@ const DEFAULT_LOCAL_CONFIG_PATHS = [ ".linear/config.json", ]; -export interface ConfigMigration { - name: string; - shouldRun: (data: Record) => boolean; - run: (data: Record) => Record; -} - -function parseWorkspaceFileProfile(data: unknown): WorkspaceProfile { +function parseWorkspaceProfile(data: unknown): WorkspaceProfile { if (!data || typeof data !== "object") { return {}; } const obj = data as Record; - const oauth = - obj.oauth && typeof obj.oauth === "object" - ? (obj.oauth as Record) - : undefined; - const profile: WorkspaceProfile = {}; if (typeof obj.apiToken === "string") { profile.apiToken = obj.apiToken; } - - if (typeof oauth?.access === "string") { - profile.accessToken = oauth.access; - } - - if (typeof oauth?.refresh === "string") { - profile.refreshToken = oauth.refresh; - } - - if (typeof oauth?.expiresAt === "string") { - profile.accessTokenExpiresAt = oauth.expiresAt; - } - - if (typeof oauth?.clientId === "string") { - profile.oauthClientId = oauth.clientId; - } - - if (typeof oauth?.clientSecret === "string") { - profile.oauthClientSecret = oauth.clientSecret; - } - if (typeof obj.orgName === "string") { profile.orgName = obj.orgName; } - if (typeof obj.defaultTeamKey === "string") { profile.defaultTeamKey = obj.defaultTeamKey; } - if (obj.outputFormat === "json" || obj.outputFormat === "table") { profile.outputFormat = obj.outputFormat; } @@ -170,264 +93,10 @@ function parseWorkspaceFileProfile(data: unknown): WorkspaceProfile { return profile; } -function workspaceProfileToFile( - profile: WorkspaceProfile, -): WorkspaceFileProfile { - const fileProfile: WorkspaceFileProfile = {}; - - if (profile.apiToken) { - fileProfile.apiToken = profile.apiToken; - } - - if ( - profile.accessToken || - profile.refreshToken || - profile.accessTokenExpiresAt || - profile.oauthClientId || - profile.oauthClientSecret - ) { - fileProfile.oauth = {}; - if (profile.accessToken) { - fileProfile.oauth.access = profile.accessToken; - } - if (profile.refreshToken) { - fileProfile.oauth.refresh = profile.refreshToken; - } - if (profile.accessTokenExpiresAt) { - fileProfile.oauth.expiresAt = profile.accessTokenExpiresAt; - } - if (profile.oauthClientId) { - fileProfile.oauth.clientId = profile.oauthClientId; - } - if (profile.oauthClientSecret) { - fileProfile.oauth.clientSecret = profile.oauthClientSecret; - } - } - - if (profile.orgName) { - fileProfile.orgName = profile.orgName; - } - - if (profile.defaultTeamKey) { - fileProfile.defaultTeamKey = profile.defaultTeamKey; - } - - if (profile.outputFormat) { - fileProfile.outputFormat = profile.outputFormat; - } - - return fileProfile; -} - -function hasCredentials(profile: WorkspaceProfile): boolean { - return Boolean(profile.accessToken || profile.apiToken); -} - -function parseSemverParts( - version: string, -): [number, number, number] | undefined { - const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim()); - if (!match) return undefined; - - const major = Number.parseInt(match[1] ?? "0", 10); - const minor = Number.parseInt(match[2] ?? "0", 10); - const patch = Number.parseInt(match[3] ?? "0", 10); - - if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) { - return undefined; - } - - return [major, minor, patch]; -} - -function getPreviousCliVersion(version: string): string { - const parts = parseSemverParts(version); - if (!parts) return version; - - let [major, minor, patch] = parts; - - if (patch > 0) { - patch -= 1; - } else if (minor > 0) { - minor -= 1; - patch = 0; - } else if (major > 0) { - major -= 1; - minor = 0; - patch = 0; - } - - return `${major}.${minor}.${patch}`; -} - -function hasLegacyAuthFields(data: Record): boolean { - const oauth = - data.oauth && typeof data.oauth === "object" - ? (data.oauth as Record) - : undefined; - - return ( - typeof data.apiToken === "string" || - typeof data.accessToken === "string" || - typeof data.refreshToken === "string" || - typeof data.accessTokenExpiresAt === "string" || - typeof data.oauthClientId === "string" || - typeof data.oauthClientSecret === "string" || - typeof oauth?.access === "string" || - typeof oauth?.refresh === "string" || - typeof oauth?.expiresAt === "string" || - typeof oauth?.clientId === "string" || - typeof oauth?.clientSecret === "string" - ); -} - -const CONFIG_MIGRATIONS: ConfigMigration[] = [ - { - name: "legacy-flat-auth-to-workspaces", - shouldRun: (data) => { - const hasWorkspaces = - data.workspaces !== undefined && - data.workspaces !== null && - typeof data.workspaces === "object"; - return !hasWorkspaces && hasLegacyAuthFields(data); - }, - run: (data) => { - const next: Record = { ...data }; - - const oauth = - next.oauth && typeof next.oauth === "object" - ? ({ ...(next.oauth as Record) } as Record< - string, - unknown - >) - : undefined; - - const workspaceProfile: Record = {}; - - if (typeof next.apiToken === "string") { - workspaceProfile.apiToken = next.apiToken; - } - - const access = - typeof next.accessToken === "string" - ? next.accessToken - : typeof oauth?.access === "string" - ? oauth.access - : undefined; - - const refresh = - typeof next.refreshToken === "string" - ? next.refreshToken - : typeof oauth?.refresh === "string" - ? oauth.refresh - : undefined; - - const expiresAt = - typeof next.accessTokenExpiresAt === "string" - ? next.accessTokenExpiresAt - : typeof oauth?.expiresAt === "string" - ? oauth.expiresAt - : undefined; - - const clientId = - typeof next.oauthClientId === "string" - ? next.oauthClientId - : typeof oauth?.clientId === "string" - ? oauth.clientId - : undefined; - - const clientSecret = - typeof next.oauthClientSecret === "string" - ? next.oauthClientSecret - : typeof oauth?.clientSecret === "string" - ? oauth.clientSecret - : undefined; - - if ( - access !== undefined || - refresh !== undefined || - expiresAt !== undefined || - clientId !== undefined || - clientSecret !== undefined - ) { - const oauthProfile: Record = {}; - if (access !== undefined) oauthProfile.access = access; - if (refresh !== undefined) oauthProfile.refresh = refresh; - if (expiresAt !== undefined) oauthProfile.expiresAt = expiresAt; - if (clientId !== undefined) oauthProfile.clientId = clientId; - if (clientSecret !== undefined) - oauthProfile.clientSecret = clientSecret; - workspaceProfile.oauth = oauthProfile; - } - - next.workspaces = { default: workspaceProfile }; - next.defaultWorkspace = - typeof next.defaultWorkspace === "string" - ? next.defaultWorkspace - : "default"; - - delete next.apiToken; - delete next.accessToken; - delete next.refreshToken; - delete next.accessTokenExpiresAt; - delete next.oauthClientId; - delete next.oauthClientSecret; - delete next.oauth; - - return next; - }, - }, -]; - function getWorkspaceNames(config: Config): string[] { return Object.keys(config.workspaces ?? {}); } -function buildWorkspaceMapForSave(config: Config): { - workspaces: Record; - defaultWorkspace?: string; -} { - const workspaces: Record = { - ...(config.workspaces ?? {}), - }; - - // Legacy migration: if no explicit workspace profiles exist, move legacy auth - // into a default workspace profile on save. - if (Object.keys(workspaces).length === 0) { - const legacyProfile: WorkspaceProfile = {}; - - if (config.apiToken) { - legacyProfile.apiToken = config.apiToken; - } - if (config.accessToken) { - legacyProfile.accessToken = config.accessToken; - } - if (config.refreshToken) { - legacyProfile.refreshToken = config.refreshToken; - } - if (config.accessTokenExpiresAt) { - legacyProfile.accessTokenExpiresAt = config.accessTokenExpiresAt; - } - if (config.oauthClientId) { - legacyProfile.oauthClientId = config.oauthClientId; - } - if (config.oauthClientSecret) { - legacyProfile.oauthClientSecret = config.oauthClientSecret; - } - - if (hasCredentials(legacyProfile) || legacyProfile.refreshToken) { - const profileName = config.defaultWorkspace ?? "default"; - workspaces[profileName] = legacyProfile; - return { workspaces, defaultWorkspace: profileName }; - } - } - - return { - workspaces, - defaultWorkspace: config.defaultWorkspace, - }; -} - function resolveWorkspaceName( config: Config, local: LocalConfig | undefined, @@ -512,6 +181,7 @@ export function getConfigSchemaUrl(): string { /** * Validate config object structure. * Returns a valid Config object, stripping unknown fields. + * Top-level auth fields (apiToken, oauth, accessToken, etc.) are ignored entirely. */ export function validateConfig(data: unknown): Config { if (!data || typeof data !== "object") { @@ -519,11 +189,6 @@ export function validateConfig(data: unknown): Config { } const obj = data as Record; - const oauth = - obj.oauth && typeof obj.oauth === "object" - ? (obj.oauth as Record) - : undefined; - const config: Config = { ...DEFAULT_CONFIG }; if (typeof obj.defaultWorkspace === "string") { @@ -536,7 +201,7 @@ export function validateConfig(data: unknown): Config { for (const [name, value] of Object.entries(workspaceObj)) { if (!name) continue; - parsedWorkspaces[name] = parseWorkspaceFileProfile(value); + parsedWorkspaces[name] = parseWorkspaceProfile(value); } if (Object.keys(parsedWorkspaces).length > 0) { @@ -544,41 +209,6 @@ export function validateConfig(data: unknown): Config { } } - // Parse legacy flat fields for backward compatibility. - if (typeof obj.apiToken === "string") { - config.apiToken = obj.apiToken; - } - - if (typeof obj.accessToken === "string") { - config.accessToken = obj.accessToken; - } else if (typeof oauth?.access === "string") { - config.accessToken = oauth.access; - } - - if (typeof obj.refreshToken === "string") { - config.refreshToken = obj.refreshToken; - } else if (typeof oauth?.refresh === "string") { - config.refreshToken = oauth.refresh; - } - - if (typeof obj.accessTokenExpiresAt === "string") { - config.accessTokenExpiresAt = obj.accessTokenExpiresAt; - } else if (typeof oauth?.expiresAt === "string") { - config.accessTokenExpiresAt = oauth.expiresAt; - } - - if (typeof obj.oauthClientId === "string") { - config.oauthClientId = obj.oauthClientId; - } else if (typeof oauth?.clientId === "string") { - config.oauthClientId = oauth.clientId; - } - - if (typeof obj.oauthClientSecret === "string") { - config.oauthClientSecret = obj.oauthClientSecret; - } else if (typeof oauth?.clientSecret === "string") { - config.oauthClientSecret = oauth.clientSecret; - } - if (typeof obj.defaultTeamKey === "string") { config.defaultTeamKey = obj.defaultTeamKey; } @@ -594,90 +224,9 @@ export function validateConfig(data: unknown): Config { config.localConfigPaths = obj.localConfigPaths as string[]; } - // Implicit legacy migration in memory only: expose a default workspace profile - // so workspace-aware reads still work before the next save. - if ( - !config.workspaces && - (config.apiToken || config.accessToken || config.refreshToken) - ) { - config.workspaces = { - default: { - apiToken: config.apiToken, - accessToken: config.accessToken, - refreshToken: config.refreshToken, - accessTokenExpiresAt: config.accessTokenExpiresAt, - oauthClientId: config.oauthClientId, - oauthClientSecret: config.oauthClientSecret, - }, - }; - config.defaultWorkspace = config.defaultWorkspace ?? "default"; - } - return config; } -async function backupConfigForMigration(configPath: string): Promise { - const previousVersion = getPreviousCliVersion(VERSION); - const backupPath = join( - dirname(configPath), - `config.${previousVersion}.json`, - ); - - const backupFile = Bun.file(backupPath); - if (await backupFile.exists()) { - return; - } - - const source = Bun.file(configPath); - const content = await source.text(); - await Bun.write(backupPath, content); - await chmod(backupPath, 0o600); -} - -async function migrateConfigFileIfNeeded(configPath: string): Promise { - const file = Bun.file(configPath); - if (!(await file.exists())) { - return; - } - - let data: unknown; - try { - data = JSON.parse(await file.text()); - } catch { - return; - } - - if (!data || typeof data !== "object") { - return; - } - - let current = data as Record; - let changed = false; - - for (const migration of CONFIG_MIGRATIONS) { - if (!migration.shouldRun(current)) { - continue; - } - - if (!changed) { - await backupConfigForMigration(configPath); - } - - current = migration.run(current); - changed = true; - } - - if (!changed) { - return; - } - - await saveConfig(validateConfig(current)); -} - -export async function migrateConfigOnStartup(): Promise { - await migrateConfigFileIfNeeded(getConfigPath()); -} - /** * Load config from disk. * Returns default config if file doesn't exist. @@ -685,8 +234,6 @@ export async function migrateConfigOnStartup(): Promise { export async function loadConfig(): Promise { const configPath = getConfigPath(); - await migrateConfigFileIfNeeded(configPath); - try { const file = Bun.file(configPath); const exists = await file.exists(); @@ -713,6 +260,7 @@ export async function loadConfig(): Promise { /** * Save config to disk. * Creates directory with 0700 and file with 0600 permissions. + * Only writes workspace-keyed apiToken shape; no oauth blocks ever. */ export async function saveConfig(config: Config): Promise { const configPath = getConfigPath(); @@ -721,21 +269,25 @@ export async function saveConfig(config: Config): Promise { // Create directory with restricted permissions (owner only) await mkdir(configDir, { recursive: true, mode: 0o700 }); - const normalized = buildWorkspaceMapForSave(config); - const fileShape: ConfigFileShape = { $schema: getConfigSchemaUrl(), outputFormat: config.outputFormat, }; - if (normalized.defaultWorkspace) { - fileShape.defaultWorkspace = normalized.defaultWorkspace; + if (config.defaultWorkspace) { + fileShape.defaultWorkspace = config.defaultWorkspace; } - if (Object.keys(normalized.workspaces).length > 0) { + if (config.workspaces && Object.keys(config.workspaces).length > 0) { fileShape.workspaces = {}; - for (const [name, profile] of Object.entries(normalized.workspaces)) { - fileShape.workspaces[name] = workspaceProfileToFile(profile); + for (const [name, profile] of Object.entries(config.workspaces)) { + const fileProfile: WorkspaceProfile = {}; + if (profile.apiToken) fileProfile.apiToken = profile.apiToken; + if (profile.orgName) fileProfile.orgName = profile.orgName; + if (profile.defaultTeamKey) + fileProfile.defaultTeamKey = profile.defaultTeamKey; + if (profile.outputFormat) fileProfile.outputFormat = profile.outputFormat; + fileShape.workspaces[name] = fileProfile; } } @@ -801,25 +353,12 @@ export async function setDefaultWorkspace(name: string): Promise { /** * Resolve auth credentials, checking env vars first then config. * Precedence: - * 1. LINEAR_OAUTH_TOKEN (env, oauth) - * 2. LINEAR_API_TOKEN (env, api) - * 3. workspace profile oauth token - * 4. workspace profile api token - * 5. legacy config accessToken/apiToken (backward compat) + * 1. LINEAR_API_TOKEN (env) + * 2. selected workspace profile apiToken */ export async function resolveAuth(context?: { workspace?: string; }): Promise { - const envOauthToken = process.env.LINEAR_OAUTH_TOKEN; - if (envOauthToken) { - return { - header: `Bearer ${envOauthToken}`, - token: envOauthToken, - source: "env", - kind: "oauth", - }; - } - const envApiToken = process.env.LINEAR_API_TOKEN; if (envApiToken) { return { @@ -837,18 +376,6 @@ export async function resolveAuth(context?: { if (workspace) { const profile = config.workspaces?.[workspace]; - if (profile?.accessToken) { - return { - header: `Bearer ${profile.accessToken}`, - token: profile.accessToken, - source: "config", - kind: "oauth", - workspace, - refreshToken: profile.refreshToken, - accessTokenExpiresAt: profile.accessTokenExpiresAt, - }; - } - if (profile?.apiToken) { return { header: profile.apiToken, @@ -862,27 +389,6 @@ export async function resolveAuth(context?: { return undefined; } - // Legacy fallback for old flat config. - if (config.accessToken) { - return { - header: `Bearer ${config.accessToken}`, - token: config.accessToken, - source: "config", - kind: "oauth", - refreshToken: config.refreshToken, - accessTokenExpiresAt: config.accessTokenExpiresAt, - }; - } - - if (config.apiToken) { - return { - header: config.apiToken, - token: config.apiToken, - source: "config", - kind: "api", - }; - } - return undefined; } @@ -895,44 +401,6 @@ export async function getApiToken(): Promise { return auth?.header; } -/** - * Resolve OAuth client credentials for token refresh. - * Environment variables take precedence over config file values. - */ -export async function resolveOAuthClientCredentials(context?: { - workspace?: string; -}): Promise<{ clientId: string; clientSecret: string } | undefined> { - const clientId = process.env.LINEAR_CLIENT_ID; - const clientSecret = process.env.LINEAR_CLIENT_SECRET; - - if (clientId && clientSecret) { - return { clientId, clientSecret }; - } - - const config = await loadConfig(); - const local = await loadLocalConfig(config.localConfigPaths); - const workspace = resolveWorkspaceName(config, local, context); - - if (workspace) { - const profile = config.workspaces?.[workspace]; - if (profile?.oauthClientId && profile.oauthClientSecret) { - return { - clientId: profile.oauthClientId, - clientSecret: profile.oauthClientSecret, - }; - } - } - - if (config.oauthClientId && config.oauthClientSecret) { - return { - clientId: config.oauthClientId, - clientSecret: config.oauthClientSecret, - }; - } - - return undefined; -} - /** * Check file permissions and warn if too open. * Returns true if permissions are safe (owner-only). @@ -1065,24 +533,6 @@ export async function loadMergedConfig(context?: { if (profile.outputFormat !== undefined) { merged.outputFormat = profile.outputFormat; } - if (profile.apiToken !== undefined) { - merged.apiToken = profile.apiToken; - } - if (profile.accessToken !== undefined) { - merged.accessToken = profile.accessToken; - } - if (profile.refreshToken !== undefined) { - merged.refreshToken = profile.refreshToken; - } - if (profile.accessTokenExpiresAt !== undefined) { - merged.accessTokenExpiresAt = profile.accessTokenExpiresAt; - } - if (profile.oauthClientId !== undefined) { - merged.oauthClientId = profile.oauthClientId; - } - if (profile.oauthClientSecret !== undefined) { - merged.oauthClientSecret = profile.oauthClientSecret; - } } } diff --git a/src/index.ts b/src/index.ts index e6a7718..d1a02fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ import { login, logout, promptForToken, - promptForTokenKind, readTokenFromStdin, } from "./auth"; import { printCompletion } from "./cli"; @@ -114,12 +113,7 @@ import { type MeOptions, me, } from "./commands/user"; -import { - getConfigPath, - loadConfig, - migrateConfigOnStartup, - setDefaultWorkspace, -} from "./config"; +import { getConfigPath, loadConfig, setDefaultWorkspace } from "./config"; import { APP_NAME, VERSION } from "./constants"; import { printCliError } from "./errors"; @@ -177,8 +171,6 @@ function detectCompletionShell(args: string[]): string | null { } async function run(): Promise { - await migrateConfigOnStartup(); - const rawArgs = process.argv.slice(2); const completionShell = detectCompletionShell(rawArgs); if (completionShell) { @@ -226,33 +218,19 @@ function registerAuth(program: Command): void { ); strict(addWorkspaceOption(auth.command("login"))) - .description("Authenticate with API token or OAuth token") - .addOption( - new Option("--type ", "Token type").choices(["api", "oauth"]), - ) + .description("Authenticate with API token") .option("--token ", "Token value") - .option("--refresh-token ", "OAuth refresh token") - .option("--expires-at ", "Access token expiry as ISO timestamp") .action(async (options) => { const workspace = options.workspace ?? getWorkspaceContext(); let token: string | undefined = options.token; - if (token && !options.type) { - throw new Error("--type is required with --token"); - } - if (!token) { - if (!process.stdin.isTTY && !options.type) { - throw new Error( - "--type is required when reading token from stdin", - ); - } token = (await readTokenFromStdin()) ?? undefined; } - const kind = - (options.type as "api" | "oauth" | undefined) ?? - (await promptForTokenKind()); + if (!token && !process.stdin.isTTY) { + throw new Error("No token provided"); + } if (!token) { token = await promptForToken(); @@ -267,16 +245,7 @@ function registerAuth(program: Command): void { isSilent: !process.stderr.isTTY, }).start(); - const loginOptions = - kind === "oauth" - ? { - refreshToken: options.refreshToken as string | undefined, - expiresAt: options.expiresAt as string | undefined, - workspace, - } - : { workspace }; - - const result = await login(token, kind, loginOptions); + const result = await login(token, { workspace }); if (!result.success) { spinner.fail("Login failed"); @@ -353,11 +322,7 @@ function registerAuth(program: Command): void { } const rows = entries.map(([name, profile]) => { - const type = profile.accessToken - ? "oauth" - : profile.apiToken - ? "api" - : "-"; + const type = profile.apiToken ? "api" : "-"; const marker = config.defaultWorkspace === name ? "*" : ""; return [name, profile.orgName ?? "-", type, marker]; }); diff --git a/src/oauth.ts b/src/oauth.ts deleted file mode 100644 index 7febd7e..0000000 --- a/src/oauth.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * OAuth token refresh utilities for linear-cli. - * Kept in a separate module to avoid circular dependencies between api.ts and auth.ts. - */ - -import { - loadConfig, - resolveOAuthClientCredentials, - saveWorkspaceProfile, -} from "./config.ts"; -import { CliError } from "./errors.ts"; -/** - * Exchange a refresh token for a new access token using Linear's OAuth endpoint. - * Uses LINEAR_CLIENT_ID / LINEAR_CLIENT_SECRET from env when present, - * otherwise falls back to oauth.clientId / oauth.clientSecret from config. - * Persists the new tokens to config and returns the new Authorization header value. - */ -export async function refreshOAuthToken( - currentRefreshToken: string, - workspace: string, -): Promise { - const credentials = await resolveOAuthClientCredentials({ workspace }); - - if (!credentials) { - throw new Error( - "OAuth token refresh requires LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET, or oauth.clientId and oauth.clientSecret in config.", - ); - } - - const { clientId, clientSecret } = credentials; - - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: currentRefreshToken, - }); - - const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString( - "base64", - ); - - const response = await fetch("https://api.linear.app/oauth/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Basic ${basicAuth}`, - }, - body: body.toString(), - }); - - if (!response.ok) { - const text = await response.text().catch(() => response.statusText); - throw new Error(`OAuth token refresh failed: ${text}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token?: string; - expires_in?: number; - }; - - if (!data.access_token) { - throw new Error("OAuth token refresh response missing access_token"); - } - - const accessTokenExpiresAt = - typeof data.expires_in === "number" - ? new Date(Date.now() + data.expires_in * 1000).toISOString() - : undefined; - - const config = await loadConfig(); - const currentProfile = config.workspaces?.[workspace]; - if (!currentProfile) { - throw new CliError( - `Cannot refresh OAuth token: workspace "${workspace}" not found in config.`, - { - suggestion: - "Run 'linear auth login --workspace " + - workspace + - "' to re-authenticate this workspace.", - }, - ); - } - - await saveWorkspaceProfile(workspace, { - ...currentProfile, - accessToken: data.access_token, - refreshToken: data.refresh_token ?? currentRefreshToken, - accessTokenExpiresAt, - }); - - return `Bearer ${data.access_token}`; -}