From bb095086410219e9dce44a28beaaa8b77dcf23c2 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Tue, 24 Mar 2026 12:35:48 +0100 Subject: [PATCH] feat(auth): add multi-workspace profiles and startup config migrations --- .changeset/little-bottles-train.md | 5 + README.md | 84 +++- schemas/config.schema.json | 49 ++ src/api.test.ts | 88 +++- src/api.ts | 21 +- src/auth.ts | 190 +++++++- src/cli.ts | 33 +- src/commands/shared.ts | 22 +- src/config.test.ts | 446 +++++++++++++----- src/config.ts | 721 ++++++++++++++++++++++++++--- src/index.ts | 251 +++++++--- src/oauth.ts | 27 +- 12 files changed, 1627 insertions(+), 310 deletions(-) create mode 100644 .changeset/little-bottles-train.md diff --git a/.changeset/little-bottles-train.md b/.changeset/little-bottles-train.md new file mode 100644 index 0000000..e3606d3 --- /dev/null +++ b/.changeset/little-bottles-train.md @@ -0,0 +1,5 @@ +--- +"linear-cli": minor +--- + +Add multi-workspace auth profiles with `--workspace`, `auth list`, and `auth use`; add startup config migration with automatic backup to `config..json`; and make auth/config resolution workspace-aware across login, status, logout, and OAuth refresh. \ No newline at end of file diff --git a/README.md b/README.md index 99165c7..7b464b5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ linear auth login linear auth login --type api --token linear auth login --type oauth --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 \ @@ -62,6 +65,10 @@ 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 @@ -75,25 +82,45 @@ You do not need a special `-` argument for stdin. Piped input is detected automa Config is stored in `~/.config/linear-cli/config.json` with `0600` permissions. +List and switch workspace profiles: + +```sh +linear auth list +linear auth use +``` + ### Config format ```json { "$schema": "https://raw.githubusercontent.com/aliou/linear-cli/v0.2.2/schemas/config.schema.json", - "apiToken": "...", - "oauth": { - "access": "...", - "refresh": "...", - "expiresAt": "2025-01-01T00:00:00.000Z", - "clientId": "...", - "clientSecret": "..." + "defaultWorkspace": "acme", + "workspaces": { + "acme": { + "apiToken": "...", + "orgName": "Acme Inc", + "defaultTeamKey": "ENG", + "outputFormat": "table" + }, + "personal": { + "oauth": { + "access": "...", + "refresh": "...", + "expiresAt": "2026-01-01T00:00:00.000Z", + "clientId": "...", + "clientSecret": "..." + }, + "orgName": "Personal Workspace" + } }, "defaultTeamKey": "ENG", "outputFormat": "table" } ``` -`apiToken` is used for a regular personal API key. OAuth credentials are stored under `oauth.access`, `oauth.refresh`, and `oauth.expiresAt`. All fields are optional and the file is backward compatible. +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. @@ -117,7 +144,7 @@ linear [flags] | Command | Subcommands | Description | |---|---|---| -| `auth` | `login`, `logout`, `status` | Manage authentication | +| `auth` | `login`, `logout`, `status`, `list`, `use` | Manage authentication | | `issue` | `list`, `get`, `create`, `update`, `close` | Issue operations | | `team` | `list`, `get` | Team operations | | `project` | `list`, `get` | Project operations | @@ -171,6 +198,10 @@ linear project list --status started # JSON output linear issue list --json +# List and switch workspace profiles +linear auth list +linear auth use personal + # Run an arbitrary GraphQL query linear graphql 'query { viewer { id name email } }' @@ -185,10 +216,11 @@ echo 'query { viewer { id name } }' | linear graphql ### Global flags ``` --h, --help Show help --v, --version Show version ---json Output as JSON (available on all commands) ---completion Generate shell completion (bash, zsh, fish) +-h, --help Show help +-v, --version Show version +-w, --workspace Use a specific workspace profile +--json Output as JSON (available on all commands) +--completion Generate shell completion (bash, zsh, fish) ``` ## Shell completion @@ -213,13 +245,22 @@ Stored at `~/.config/linear-cli/config.json`: ```json { "$schema": "https://raw.githubusercontent.com/aliou/linear-cli/v0.2.2/schemas/config.schema.json", - "apiToken": "...", - "oauth": { - "access": "...", - "refresh": "...", - "expiresAt": "2025-01-01T00:00:00.000Z", - "clientId": "...", - "clientSecret": "..." + "defaultWorkspace": "acme", + "workspaces": { + "acme": { + "apiToken": "...", + "orgName": "Acme Inc" + }, + "personal": { + "oauth": { + "access": "...", + "refresh": "...", + "expiresAt": "2026-01-01T00:00:00.000Z", + "clientId": "...", + "clientSecret": "..." + }, + "orgName": "Personal Workspace" + } }, "defaultTeamKey": "ENG", "outputFormat": "table" @@ -236,12 +277,13 @@ Place a config file in your project directory to set defaults per-project. Searc ```json { + "workspace": "acme", "defaultTeamKey": "ENG", "outputFormat": "json" } ``` -Local config overrides global config for `defaultTeamKey` and `outputFormat`. +Local config overrides global/workspace defaults for `defaultTeamKey` and `outputFormat`, and can select the active profile via `workspace`. ## Agent Delegation diff --git a/schemas/config.schema.json b/schemas/config.schema.json index c49b54d..1365d00 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -10,6 +10,55 @@ "type": "string", "format": "uri" }, + "defaultWorkspace": { + "type": "string", + "description": "Name of the default workspace profile." + }, + "workspaces": { + "type": "object", + "description": "Named workspace profiles.", + "additionalProperties": { + "type": "object", + "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" + } + } + }, + "orgName": { + "type": "string" + }, + "defaultTeamKey": { + "type": "string" + }, + "outputFormat": { + "type": "string", + "enum": ["json", "table"] + } + } + } + }, "apiToken": { "type": "string", "description": "Linear personal API key." diff --git a/src/api.test.ts b/src/api.test.ts index 223bc02..3df40bd 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -6,12 +6,10 @@ 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 { graphql } from "./api.ts"; +import { fetchOrganization, graphql } from "./api.ts"; import type { ResolvedAuth } from "./config.ts"; import { loadConfig, saveConfig } from "./config.ts"; -// ---- helpers ---------------------------------------------------------------- - function makeJsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -23,8 +21,6 @@ const SUCCESS_BODY = { data: { viewer: { id: "1", name: "Test", email: "t@t.com" } }, }; -// ---- test setup ------------------------------------------------------------- - let tempDir: string; const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; const originalClientId = process.env.LINEAR_CLIENT_ID; @@ -52,13 +48,16 @@ afterEach(async () => { mock.restore(); }); -// ---- tests ------------------------------------------------------------------ - describe("graphql() with config OAuth token + refreshToken on 401", () => { test("refreshes token, retries, and updates config on 401", async () => { await saveConfig({ - accessToken: "old-access-token", - refreshToken: "my-refresh-token", + defaultWorkspace: "acme", + workspaces: { + acme: { + accessToken: "old-access-token", + refreshToken: "my-refresh-token", + }, + }, outputFormat: "table", }); @@ -67,6 +66,7 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { token: "old-access-token", source: "config", kind: "oauth", + workspace: "acme", refreshToken: "my-refresh-token", }; @@ -98,9 +98,9 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { expect(callCount).toBe(2); const config = await loadConfig(); - expect(config.accessToken).toBe("new-access-token"); - expect(config.refreshToken).toBe("new-refresh-token"); - expect(config.accessTokenExpiresAt).toBeDefined(); + 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; } @@ -111,10 +111,15 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { delete process.env.LINEAR_CLIENT_SECRET; await saveConfig({ - accessToken: "old-access-token", - refreshToken: "my-refresh-token", - oauthClientId: "config-client-id", - oauthClientSecret: "config-client-secret", + defaultWorkspace: "acme", + workspaces: { + acme: { + accessToken: "old-access-token", + refreshToken: "my-refresh-token", + oauthClientId: "config-client-id", + oauthClientSecret: "config-client-secret", + }, + }, outputFormat: "table", }); @@ -123,6 +128,7 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { token: "old-access-token", source: "config", kind: "oauth", + workspace: "acme", refreshToken: "my-refresh-token", }; @@ -171,6 +177,7 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { token: "no-refresh-token", source: "config", kind: "oauth", + workspace: "acme", }; let callCount = 0; @@ -248,11 +255,23 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { }); 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", }; @@ -283,8 +302,13 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { test("persists rotated refresh token from response", async () => { await saveConfig({ - accessToken: "old-token", - refreshToken: "old-refresh", + defaultWorkspace: "acme", + workspaces: { + acme: { + accessToken: "old-token", + refreshToken: "old-refresh", + }, + }, outputFormat: "table", }); @@ -293,6 +317,7 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { token: "old-token", source: "config", kind: "oauth", + workspace: "acme", refreshToken: "old-refresh", }; @@ -316,8 +341,31 @@ describe("graphql() with config OAuth token + refreshToken on 401", () => { try { await graphql(auth, "query { viewer { id } }"); const config = await loadConfig(); - expect(config.accessToken).toBe("rotated-access"); - expect(config.refreshToken).toBe("rotated-refresh"); + 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 () => + makeJsonResponse({ + data: { + organization: { id: "org-1", name: "Acme Inc", urlKey: "acme" }, + }, + }), + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + const org = await fetchOrganization("Bearer string-token"); + expect(org.id).toBe("org-1"); + expect(org.name).toBe("Acme Inc"); + expect(org.urlKey).toBe("acme"); } finally { globalThis.fetch = originalFetch; } diff --git a/src/api.ts b/src/api.ts index f3a7960..3e6c6bb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -67,7 +67,16 @@ export async function graphqlRequest( auth.kind === "oauth" && auth.refreshToken ) { - currentHeader = await refreshOAuthToken(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); } @@ -139,3 +148,13 @@ export async function fetchViewer( return data.viewer; } + +export async function fetchOrganization( + auth: string | ResolvedAuth, +): Promise<{ id: string; name: string; urlKey: string }> { + const data = await graphql<{ + organization: { id: string; name: string; urlKey: string }; + }>(auth, `query { organization { id name urlKey } }`); + + return data.organization; +} diff --git a/src/auth.ts b/src/auth.ts index 9175a9d..a56a78c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,14 +3,15 @@ * Handles token storage, validation, and auth status. */ -import { fetchViewer } from "./api.ts"; +import { fetchOrganization, fetchViewer } from "./api.ts"; import { checkConfigPermissions, loadConfig, + type ResolvedAuth, resolveAuth, saveConfig, - updateConfig, } from "./config.ts"; +import { CliError } from "./errors.ts"; import { refreshOAuthToken } from "./oauth.ts"; export { refreshOAuthToken }; @@ -19,6 +20,8 @@ export interface AuthStatus { authenticated: boolean; name?: string; email?: string; + workspace?: string; + orgName?: string; tokenSource?: "env" | "config"; error?: string; } @@ -27,6 +30,8 @@ export interface LoginResult { success: boolean; name?: string; email?: string; + workspace?: string; + orgName?: string; error?: string; } @@ -43,32 +48,113 @@ function formatTokenForApi(token: string, kind: TokenKind): string { export async function login( token: string, kind: TokenKind, - options?: { refreshToken?: string; expiresAt?: string }, + options?: { refreshToken?: string; expiresAt?: string; workspace?: string }, ): Promise { try { const trimmedToken = token.trim(); - const viewer = await fetchViewer(formatTokenForApi(trimmedToken, kind)); + const authHeader = formatTokenForApi(trimmedToken, kind); + + const viewer = await fetchViewer(authHeader); + + const explicitWorkspace = options?.workspace?.trim(); + if (options?.workspace !== undefined && !explicitWorkspace) { + return { + success: false, + error: "Workspace name cannot be empty.", + }; + } + + let organization: + | { + id: string; + name: string; + urlKey: string; + } + | undefined; + + try { + organization = await fetchOrganization(authHeader); + } catch { + // Some tokens validate viewer but may not allow organization query. + // Keep login working and fall back to explicit/default workspace naming. + organization = undefined; + } + + const config = await loadConfig(); + + let workspace = explicitWorkspace ?? organization?.urlKey; + if (!workspace) { + const names = Object.keys(config.workspaces ?? {}); + if (names.length === 1) { + workspace = names[0]; + } + } + + if (!workspace) { + return { + success: false, + error: + "Could not auto-detect workspace. Re-run with --workspace .", + }; + } + + 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") { - await updateConfig({ + const { apiToken: _unusedApiToken, ...restProfile } = currentProfile; + workspaces[workspace] = { + ...restProfile, + orgName: organization?.name ?? currentProfile.orgName, accessToken: trimmedToken, - apiToken: undefined, - refreshToken: options?.refreshToken, - accessTokenExpiresAt: options?.expiresAt, - }); + ...(options?.refreshToken !== undefined + ? { refreshToken: options.refreshToken } + : {}), + ...(options?.expiresAt !== undefined + ? { accessTokenExpiresAt: options.expiresAt } + : {}), + }; } else { - await updateConfig({ + const { + accessToken: _unusedAccessToken, + refreshToken: _unusedRefreshToken, + accessTokenExpiresAt: _unusedExpiresAt, + ...restProfile + } = currentProfile; + workspaces[workspace] = { + ...restProfile, + orgName: organization?.name ?? currentProfile.orgName, apiToken: trimmedToken, - accessToken: undefined, - refreshToken: undefined, - accessTokenExpiresAt: undefined, - }); + }; + } + + if (isImplicitLegacyDefault) { + delete workspaces.default; } + await saveConfig({ + ...config, + workspaces, + defaultWorkspace: + !hadWorkspaces || isImplicitLegacyDefault + ? workspace + : config.defaultWorkspace, + }); + return { success: true, name: viewer.name, email: viewer.email, + workspace, + orgName: organization?.name, }; } catch (error) { return { @@ -81,23 +167,84 @@ export async function login( /** * Perform logout: remove token and related OAuth fields from config. */ -export async function logout(): Promise { +export async function logout(workspace?: string): Promise { const config = await loadConfig(); + + const workspaceNames = Object.keys(config.workspaces ?? {}); + + if (!workspace) { + if ( + config.defaultWorkspace && + workspaceNames.includes(config.defaultWorkspace) + ) { + workspace = config.defaultWorkspace; + } else if (workspaceNames.length === 1) { + workspace = workspaceNames[0]; + } + } + + if (!workspace && workspaceNames.length > 1) { + throw new CliError( + "Multiple workspaces configured. Specify one with --workspace or set a default with 'linear auth use '.", + { + suggestion: `Available workspaces: ${workspaceNames.join(", ")}`, + }, + ); + } + + if ( + workspace && + !config.workspaces?.[workspace] && + workspaceNames.length > 0 + ) { + throw new CliError(`Workspace "${workspace}" not found in config.`, { + suggestion: `Run 'linear auth list' to see available workspaces: ${workspaceNames.join(", ")}`, + }); + } + + if (workspace && config.workspaces?.[workspace]) { + const workspaces = { ...config.workspaces }; + delete workspaces[workspace]; + + const nextDefault = + config.defaultWorkspace === workspace || + (config.defaultWorkspace !== undefined && + !workspaceNames.includes(config.defaultWorkspace)) + ? undefined + : config.defaultWorkspace; + + await saveConfig({ + ...config, + workspaces, + defaultWorkspace: nextDefault, + }); + return; + } + + // Legacy fallback delete config.apiToken; delete config.accessToken; delete config.refreshToken; delete config.accessTokenExpiresAt; - delete config.defaultTeamKey; + delete config.defaultWorkspace; await saveConfig(config); } /** * Check current authentication status. */ -export async function getAuthStatus(): Promise { +export async function getAuthStatus(workspace?: string): Promise { await checkConfigPermissions(); - const auth = await resolveAuth(); + let auth: ResolvedAuth | undefined; + try { + auth = await resolveAuth({ workspace }); + } catch (error) { + return { + authenticated: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } if (!auth) { return { @@ -108,10 +255,17 @@ export async function getAuthStatus(): Promise { try { const viewer = await fetchViewer(auth); + const config = await loadConfig(); + const orgName = auth.workspace + ? config.workspaces?.[auth.workspace]?.orgName + : undefined; + return { authenticated: true, name: viewer.name, email: viewer.email, + workspace: auth.workspace, + orgName, tokenSource: auth.source, }; } catch (error) { diff --git a/src/cli.ts b/src/cli.ts index 9c0cb51..28ab0ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ export interface CliOptions { help: boolean; version: boolean; completion: string | null; + workspace: string | null; command: string | null; subcommand: string | null; args: string[]; @@ -17,6 +18,7 @@ export function parseArgs(args: string[]): CliOptions { help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, completion: { type: "string" }, + workspace: { type: "string", short: "w" }, }, strict: false, allowPositionals: true, @@ -48,6 +50,13 @@ export function parseArgs(args: string[]): CliOptions { if (arg === "--completion" || arg.startsWith("--completion=")) { continue; } + if (arg === "-w" || arg === "--workspace") { + i++; + continue; + } + if (arg.startsWith("--workspace=")) { + continue; + } subcommandArgs.push(arg); } } @@ -59,6 +68,10 @@ export function parseArgs(args: string[]): CliOptions { typeof result.values.completion === "string" ? result.values.completion : null, + workspace: + typeof result.values.workspace === "string" + ? result.values.workspace + : null, command, subcommand, args: subcommandArgs, @@ -80,7 +93,7 @@ ${bold("Usage:")} ${APP_NAME} [subcommand] [flags] ${bold("Commands:")} - auth Manage authentication ${dim("(login, logout, status)")} + auth Manage authentication ${dim("(login, logout, status, list, use)")} issue Issue operations ${dim("(list, get, create, update, close)")} team Team operations ${dim("(list, get)")} project Project operations ${dim("(list, get)")} @@ -96,9 +109,10 @@ ${bold("Commands:")} graphql Run arbitrary GraphQL ${dim("(query or mutation)")} ${bold("Flags:")} - -h, --help Show help - -v, --version Show version - --completion Generate shell completion ${dim("(bash, zsh, fish)")} + -h, --help Show help + -v, --version Show version + -w, --workspace Use a specific workspace profile + --completion Generate shell completion ${dim("(bash, zsh, fish)")} ${bold("Examples:")} ${APP_NAME} auth login @@ -128,7 +142,7 @@ _${APP_NAME}_completions() { prev="\${COMP_WORDS[COMP_CWORD-1]}" local commands="auth issue team project cycle comment document label milestone initiative user state search graphql" - local auth_cmds="login logout status" + local auth_cmds="login logout status list use" local issue_cmds="list get create update close" local team_cmds="list get" local project_cmds="list get" @@ -144,7 +158,7 @@ _${APP_NAME}_completions() { case "\${COMP_CWORD}" in 1) - COMPREPLY=( $(compgen -W "\${commands} --help --version --completion" -- "\${cur}") ) + COMPREPLY=( $(compgen -W "\${commands} --help --version --completion --workspace" -- "\${cur}") ) ;; 2) case "\${prev}" in @@ -225,7 +239,7 @@ _${APP_NAME}() { 'graphql:Run arbitrary GraphQL' ) - auth_cmds=('login:Authenticate with API token' 'logout:Remove stored credentials' 'status:Show auth status') + auth_cmds=('login:Authenticate with API token' 'logout:Remove stored credentials' 'status:Show auth status' 'list:List workspace profiles' 'use:Set default workspace') issue_cmds=('list:List issues' 'get:Get issue by identifier' 'create:Create an issue' 'update:Update an issue' 'close:Close an issue') team_cmds=('list:List teams' 'get:Get team by key') project_cmds=('list:List projects' 'get:Get project by ID') @@ -245,6 +259,8 @@ _${APP_NAME}() { '-v[Show version]' \\ '--version[Show version]' \\ '--completion[Generate completion]:shell:(bash zsh fish)' \\ + '-w[Use a specific workspace profile]:workspace name:' \\ + '--workspace[Use a specific workspace profile]:workspace name:' \\ '1:command:->command' \\ '2:subcommand:->subcommand' \\ '*::args:->args' @@ -287,6 +303,7 @@ complete -c ${APP_NAME} -f # Global flags complete -c ${APP_NAME} -s h -l help -d 'Show help' complete -c ${APP_NAME} -s v -l version -d 'Show version' +complete -c ${APP_NAME} -s w -l workspace -d 'Use a specific workspace profile' -r complete -c ${APP_NAME} -l completion -d 'Generate completion' -xa 'bash zsh fish' # Commands @@ -309,6 +326,8 @@ complete -c ${APP_NAME} -n __fish_use_subcommand -a graphql -d 'Run arbitrary Gr complete -c ${APP_NAME} -n '__fish_seen_subcommand_from auth' -a login -d 'Authenticate with API token' complete -c ${APP_NAME} -n '__fish_seen_subcommand_from auth' -a logout -d 'Remove stored credentials' complete -c ${APP_NAME} -n '__fish_seen_subcommand_from auth' -a status -d 'Show auth status' +complete -c ${APP_NAME} -n '__fish_seen_subcommand_from auth' -a list -d 'List workspace profiles' +complete -c ${APP_NAME} -n '__fish_seen_subcommand_from auth' -a use -d 'Set default workspace' # issue subcommands complete -c ${APP_NAME} -n '__fish_seen_subcommand_from issue' -a list -d 'List issues' diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 2b9fb70..d1133b8 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -2,12 +2,26 @@ import type { ResolvedAuth } from "../api.ts"; import { graphql } from "../api.ts"; import type { ParsedArgs } from "../args.ts"; import { getBoolean } from "../args.ts"; -import { loadConfig, resolveAuth } from "../config.ts"; +import { loadMergedConfig, resolveAuth } from "../config.ts"; export type { ResolvedAuth }; +let workspaceOverride: string | undefined; + +export function setWorkspaceContext(workspace: string | undefined): void { + workspaceOverride = workspace; +} + +export function getWorkspaceContext(): string | undefined { + return workspaceOverride; +} + +export function resetWorkspaceContext(): void { + workspaceOverride = undefined; +} + export async function requireAuth(): Promise { - const auth = await resolveAuth(); + const auth = await resolveAuth({ workspace: getWorkspaceContext() }); if (!auth) { console.error("Error: Not authenticated. Run 'linear auth login' first."); process.exit(1); @@ -203,7 +217,9 @@ export async function resolveDelegate( export async function useJson(parsed: ParsedArgs): Promise { if (getBoolean(parsed, "json")) return true; - const config = await loadConfig(); + const { config } = await loadMergedConfig({ + workspace: getWorkspaceContext(), + }); return config.outputFormat === "json"; } diff --git a/src/config.test.ts b/src/config.test.ts index 65da1be..0c81602 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,70 +1,72 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getApiToken, getConfigPath, getConfigSchemaUrl, + loadConfig, + loadMergedConfig, + migrateConfigOnStartup, resolveAuth, saveConfig, + saveWorkspaceProfile, + setDefaultWorkspace, validateConfig, + validateLocalConfig, } from "./config"; describe("validateConfig", () => { - test("preserves refreshToken and accessTokenExpiresAt", () => { + test("parses new workspace-keyed shape", () => { const result = validateConfig({ - accessToken: "tok", - refreshToken: "ref", - accessTokenExpiresAt: "2099-01-01T00:00:00.000Z", + defaultWorkspace: "acme", + workspaces: { + acme: { + apiToken: "api-acme", + defaultTeamKey: "ENG", + outputFormat: "json", + orgName: "Acme Inc", + }, + }, }); - expect(result.refreshToken).toBe("ref"); - expect(result.accessTokenExpiresAt).toBe("2099-01-01T00:00:00.000Z"); + + expect(result.defaultWorkspace).toBe("acme"); + expect(result.workspaces?.acme?.apiToken).toBe("api-acme"); + expect(result.workspaces?.acme?.defaultTeamKey).toBe("ENG"); + expect(result.workspaces?.acme?.outputFormat).toBe("json"); + expect(result.workspaces?.acme?.orgName).toBe("Acme Inc"); }); - test("parses nested oauth config shape", () => { + test("legacy flat config is exposed as implicit default workspace", () => { const result = validateConfig({ - apiToken: "api-token", + apiToken: "legacy-api", oauth: { - access: "oauth-access", - refresh: "oauth-refresh", + access: "legacy-oauth", + refresh: "legacy-refresh", expiresAt: "2099-01-01T00:00:00.000Z", - clientId: "client-id", - clientSecret: "client-secret", }, }); - expect(result.apiToken).toBe("api-token"); - expect(result.accessToken).toBe("oauth-access"); - expect(result.refreshToken).toBe("oauth-refresh"); - expect(result.accessTokenExpiresAt).toBe("2099-01-01T00:00:00.000Z"); - expect(result.oauthClientId).toBe("client-id"); - expect(result.oauthClientSecret).toBe("client-secret"); + + 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"); }); test("strips unknown fields", () => { const result = validateConfig({ unknownField: "value" }); expect((result as Record).unknownField).toBeUndefined(); }); - - test("backward compatible: parses existing configs without new fields", () => { - const result = validateConfig({ apiToken: "abc", outputFormat: "table" }); - expect(result.apiToken).toBe("abc"); - expect(result.refreshToken).toBeUndefined(); - expect(result.accessTokenExpiresAt).toBeUndefined(); - }); }); -describe("getApiToken", () => { +describe("saveConfig migration + schema", () => { 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 () => { @@ -74,161 +76,361 @@ describe("getApiToken", () => { 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 () => { + test("saveConfig writes new workspace-keyed format and schema", async () => { await saveConfig({ - accessToken: "oauth-token", - apiToken: "api-token", + apiToken: "legacy-api", outputFormat: "table", }); - await expect(getApiToken()).resolves.toBe("Bearer oauth-token"); + const content = await Bun.file(getConfigPath()).text(); + const parsed = JSON.parse(content) as { + $schema?: string; + apiToken?: string; + defaultWorkspace?: string; + workspaces?: Record; + }; + + expect(parsed.$schema).toBe(getConfigSchemaUrl()); + expect(parsed.apiToken).toBeUndefined(); + expect(parsed.defaultWorkspace).toBe("default"); + expect(parsed.workspaces?.default?.apiToken).toBe("legacy-api"); }); - test("returns config api token when access token is absent", async () => { + test("round-trips workspace profiles", async () => { await saveConfig({ - apiToken: "api-token", + defaultWorkspace: "acme", + workspaces: { + acme: { + accessToken: "oauth", + refreshToken: "refresh", + accessTokenExpiresAt: "2099-01-01T00:00:00.000Z", + oauthClientId: "cid", + oauthClientSecret: "secret", + orgName: "Acme Inc", + outputFormat: "json", + }, + }, outputFormat: "table", }); - await expect(getApiToken()).resolves.toBe("api-token"); + 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?.orgName).toBe("Acme Inc"); }); - test("reads nested oauth config shape from disk and still supports apiToken", async () => { - const configPath = join(tempDir, "linear-cli", "config.json"); + test("migrates legacy config on startup and creates versioned backup", async () => { + const configPath = getConfigPath(); + await Bun.write( configPath, JSON.stringify( { + apiToken: "legacy-api", outputFormat: "table", - oauth: { - access: "oauth-token", - refresh: "refresh-token", - }, - apiToken: "api-token", }, null, 2, ), ); - await expect(getApiToken()).resolves.toBe("Bearer oauth-token"); - }); + await migrateConfigOnStartup(); - test("saveConfig writes $schema for the current CLI version", async () => { - await saveConfig({ - apiToken: "api-token", - outputFormat: "table", - }); + const migratedContent = JSON.parse( + await Bun.file(configPath).text(), + ) as Record; - const content = await Bun.file(getConfigPath()).text(); - const parsed = JSON.parse(content) as { $schema?: string }; - expect(parsed.$schema).toBe(getConfigSchemaUrl()); + 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"); }); }); -describe("resolveAuth", () => { +describe("resolveAuth + workspace resolution", () => { let tempDir: string; + 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(); beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "linear-cli-test-")); + 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; }); 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; - } + process.chdir(originalCwd); + + 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; + + if (originalWorkspace === undefined) delete process.env.LINEAR_WORKSPACE; + else process.env.LINEAR_WORKSPACE = originalWorkspace; + await rm(tempDir, { recursive: true, force: true }); + await rm(projectDir, { recursive: true, force: true }); }); - test("env oauth token: source=env, kind=oauth, no refreshToken", async () => { + test("env tokens override workspace selection", async () => { + await saveConfig({ + defaultWorkspace: "acme", + workspaces: { acme: { apiToken: "cfg-api" } }, + outputFormat: "table", + }); + process.env.LINEAR_OAUTH_TOKEN = "env-oauth"; - const auth = await resolveAuth(); - expect(auth).toBeDefined(); + + 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?.refreshToken).toBeUndefined(); }); - test("env api token: source=env, kind=api", async () => { - process.env.LINEAR_API_TOKEN = "env-api"; - const auth = await resolveAuth(); - expect(auth?.source).toBe("env"); - expect(auth?.kind).toBe("api"); - expect(auth?.header).toBe("env-api"); + test("selects explicit workspace", async () => { + await saveConfig({ + defaultWorkspace: "acme", + workspaces: { + acme: { apiToken: "api-acme" }, + personal: { accessToken: "oauth-personal", refreshToken: "r1" }, + }, + outputFormat: "table", + }); + + const auth = await resolveAuth({ workspace: "personal" }); + expect(auth?.workspace).toBe("personal"); + expect(auth?.kind).toBe("oauth"); + expect(auth?.refreshToken).toBe("r1"); }); - test("config oauth token includes refreshToken and accessTokenExpiresAt", async () => { + test("uses local config workspace before defaultWorkspace", async () => { await saveConfig({ - accessToken: "cfg-oauth", - refreshToken: "cfg-refresh", - accessTokenExpiresAt: "2099-01-01T00:00:00.000Z", + defaultWorkspace: "acme", + workspaces: { + acme: { apiToken: "api-acme", outputFormat: "table" }, + personal: { apiToken: "api-personal", outputFormat: "json" }, + }, outputFormat: "table", }); + + await mkdir(join(projectDir, ".linear"), { recursive: true }); + await Bun.write( + join(projectDir, ".linear.json"), + JSON.stringify({ workspace: "personal" }), + ); + + process.chdir(projectDir); const auth = await resolveAuth(); - expect(auth?.source).toBe("config"); - expect(auth?.kind).toBe("oauth"); - expect(auth?.header).toBe("Bearer cfg-oauth"); - expect(auth?.refreshToken).toBe("cfg-refresh"); - expect(auth?.accessTokenExpiresAt).toBe("2099-01-01T00:00:00.000Z"); + expect(auth?.workspace).toBe("personal"); + expect(auth?.header).toBe("api-personal"); + }); + + test("throws on unknown workspace", async () => { + await saveConfig({ + defaultWorkspace: "acme", + workspaces: { acme: { apiToken: "api-acme" } }, + outputFormat: "table", + }); + + await expect(resolveAuth({ workspace: "missing" })).rejects.toThrow( + 'Workspace "missing" not found in config.', + ); }); - test("config api token: source=config, kind=api, no refreshToken", async () => { - await saveConfig({ apiToken: "cfg-api", outputFormat: "table" }); + test("throws when multiple workspaces and no default selected", async () => { + await saveConfig({ + workspaces: { + acme: { apiToken: "api-acme" }, + personal: { apiToken: "api-personal" }, + }, + outputFormat: "table", + }); + + const config = await loadConfig(); + await saveConfig({ ...config, defaultWorkspace: undefined }); + + await expect(resolveAuth()).rejects.toThrow( + "Multiple workspaces configured", + ); + }); + + test("single workspace auto-select", async () => { + await saveConfig({ + workspaces: { + acme: { apiToken: "api-acme" }, + }, + outputFormat: "table", + }); + + const config = await loadConfig(); + await saveConfig({ ...config, defaultWorkspace: undefined }); + const auth = await resolveAuth(); - expect(auth?.source).toBe("config"); - expect(auth?.kind).toBe("api"); - expect(auth?.refreshToken).toBeUndefined(); + expect(auth?.workspace).toBe("acme"); }); - test("returns undefined when no token is configured", async () => { + test("ignores stale defaultWorkspace and falls back when one profile exists", async () => { + await saveConfig({ + defaultWorkspace: "missing", + workspaces: { + acme: { apiToken: "api-acme" }, + }, + outputFormat: "table", + }); + const auth = await resolveAuth(); - expect(auth).toBeUndefined(); + expect(auth?.workspace).toBe("acme"); + }); +}); + +describe("workspace helpers + local config parsing", () => { + let tempDir: string; + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "linear-cli-test-")); + process.env.XDG_CONFIG_HOME = tempDir; + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + await rm(tempDir, { recursive: true, force: true }); + }); + + test("saveWorkspaceProfile and setDefaultWorkspace", async () => { + await saveConfig({ outputFormat: "table" }); + await saveWorkspaceProfile("acme", { + apiToken: "api-acme", + orgName: "Acme Inc", + }); + await setDefaultWorkspace("acme"); + + const cfg = await loadConfig(); + expect(cfg.workspaces?.acme?.apiToken).toBe("api-acme"); + expect(cfg.defaultWorkspace).toBe("acme"); + }); + + test("validateLocalConfig parses workspace", () => { + const local = validateLocalConfig({ + workspace: "acme", + outputFormat: "json", + unknown: true, + }); + + expect(local.workspace).toBe("acme"); + expect(local.outputFormat).toBe("json"); + expect((local as Record).unknown).toBeUndefined(); + }); + + test("loadMergedConfig applies workspace defaults then local overrides", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "linear-cli-project-")); + const originalCwd = process.cwd(); + + try { + await saveConfig({ + defaultWorkspace: "acme", + outputFormat: "table", + workspaces: { + acme: { + apiToken: "api-acme", + defaultTeamKey: "ENG", + outputFormat: "json", + }, + }, + }); + + await Bun.write( + join(projectDir, ".linear.json"), + JSON.stringify({ outputFormat: "table", defaultTeamKey: "OPS" }), + ); + + process.chdir(projectDir); + const merged = await loadMergedConfig(); + + expect(merged.workspaceName).toBe("acme"); + expect(merged.config.outputFormat).toBe("table"); + expect(merged.config.defaultTeamKey).toBe("OPS"); + } finally { + process.chdir(originalCwd); + await rm(projectDir, { recursive: true, force: true }); + } + }); +}); + +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 workspace config token", async () => { + await saveConfig({ + defaultWorkspace: "acme", + workspaces: { + acme: { accessToken: "oauth-token" }, + }, + outputFormat: "table", + }); + + await expect(getApiToken()).resolves.toBe("Bearer oauth-token"); }); }); diff --git a/src/config.ts b/src/config.ts index 687609f..06e5bdc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,36 +7,61 @@ import { chmod, mkdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { CONFIG_FILE, VERSION } from "./constants.ts"; +import { CliError } from "./errors.ts"; -export interface Config { - // Personal API token from Linear settings +export interface WorkspaceProfile { apiToken?: string; - - // OAuth token from a Linear app accessToken?: string; - - // OAuth refresh token (config-stored OAuth only) refreshToken?: string; - - // ISO timestamp at which the access token expires accessTokenExpiresAt?: string; - - // OAuth client credentials for token refresh oauthClientId?: string; oauthClientSecret?: string; - - // Default team key (e.g. "ENG") + orgName?: string; defaultTeamKey?: string; + outputFormat?: "json" | "table"; +} - // Output preferences +interface WorkspaceFileProfile { + apiToken?: string; + oauth?: { + access?: string; + refresh?: string; + expiresAt?: string; + clientId?: string; + clientSecret?: string; + }; + orgName?: string; + defaultTeamKey?: string; outputFormat?: "json" | "table"; +} + +export interface Config { + // Workspace profiles + workspaces?: Record; + defaultWorkspace?: string; - // Additional paths to search for local config files + // 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 { $schema?: string; + + // New workspace-keyed config + workspaces?: Record; + defaultWorkspace?: string; + + // Legacy top-level fields (kept for backward compatibility) apiToken?: string; oauth?: { access?: string; @@ -51,6 +76,7 @@ export interface ConfigFileShape { } export interface LocalConfig { + workspace?: string; defaultTeamKey?: string; defaultProject?: string; outputFormat?: "json" | "table"; @@ -68,6 +94,8 @@ export interface ResolvedAuth { source: "env" | "config"; /** Whether this is an oauth or api token */ kind: "oauth" | "api"; + /** Workspace profile used (config tokens only) */ + workspace?: string; /** Refresh token (config oauth only) */ refreshToken?: string; /** ISO expiry timestamp (config oauth only) */ @@ -84,6 +112,383 @@ 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 { + 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; + } + + 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, + context?: { workspace?: string }, +): string | undefined { + const workspaceNames = getWorkspaceNames(config); + + const explicitWorkspace = + context?.workspace ?? process.env.LINEAR_WORKSPACE ?? local?.workspace; + + if (explicitWorkspace) { + if (!workspaceNames.includes(explicitWorkspace)) { + throw new CliError( + `Workspace "${explicitWorkspace}" not found in config.`, + { + suggestion: + "Run 'linear auth list' to see available workspaces, or 'linear auth login --workspace " + + explicitWorkspace + + "' to add one.", + }, + ); + } + return explicitWorkspace; + } + + if ( + config.defaultWorkspace && + workspaceNames.includes(config.defaultWorkspace) + ) { + return config.defaultWorkspace; + } + + if (workspaceNames.length === 1) { + return workspaceNames[0]; + } + + if ( + config.defaultWorkspace && + !workspaceNames.includes(config.defaultWorkspace) && + workspaceNames.length > 1 + ) { + throw new CliError( + "Configured default workspace is missing and multiple workspaces are available.", + { + suggestion: `Run 'linear auth use ' to pick a default. Available workspaces: ${workspaceNames.join(", ")}`, + }, + ); + } + + if (workspaceNames.length > 1) { + throw new CliError( + "Multiple workspaces configured. Specify one with --workspace or set a default with 'linear auth use '.", + { + suggestion: `Available workspaces: ${workspaceNames.join(", ")}`, + }, + ); + } + + return undefined; +} + /** * Get the path to the config directory. * Respects XDG_CONFIG_HOME, falls back to ~/.config @@ -118,8 +523,28 @@ export function validateConfig(data: unknown): Config { obj.oauth && typeof obj.oauth === "object" ? (obj.oauth as Record) : undefined; + const config: Config = { ...DEFAULT_CONFIG }; + if (typeof obj.defaultWorkspace === "string") { + config.defaultWorkspace = obj.defaultWorkspace; + } + + if (obj.workspaces && typeof obj.workspaces === "object") { + const workspaceObj = obj.workspaces as Record; + const parsedWorkspaces: Record = {}; + + for (const [name, value] of Object.entries(workspaceObj)) { + if (!name) continue; + parsedWorkspaces[name] = parseWorkspaceFileProfile(value); + } + + if (Object.keys(parsedWorkspaces).length > 0) { + config.workspaces = parsedWorkspaces; + } + } + + // Parse legacy flat fields for backward compatibility. if (typeof obj.apiToken === "string") { config.apiToken = obj.apiToken; } @@ -142,11 +567,15 @@ export function validateConfig(data: unknown): Config { config.accessTokenExpiresAt = oauth.expiresAt; } - if (typeof oauth?.clientId === "string") { + if (typeof obj.oauthClientId === "string") { + config.oauthClientId = obj.oauthClientId; + } else if (typeof oauth?.clientId === "string") { config.oauthClientId = oauth.clientId; } - if (typeof oauth?.clientSecret === "string") { + if (typeof obj.oauthClientSecret === "string") { + config.oauthClientSecret = obj.oauthClientSecret; + } else if (typeof oauth?.clientSecret === "string") { config.oauthClientSecret = oauth.clientSecret; } @@ -165,9 +594,90 @@ 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. @@ -175,6 +685,8 @@ export function validateConfig(data: unknown): Config { export async function loadConfig(): Promise { const configPath = getConfigPath(); + await migrateConfigFileIfNeeded(configPath); + try { const file = Bun.file(configPath); const exists = await file.exists(); @@ -209,37 +721,21 @@ 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 (config.apiToken) { - fileShape.apiToken = config.apiToken; + if (normalized.defaultWorkspace) { + fileShape.defaultWorkspace = normalized.defaultWorkspace; } - if ( - config.accessToken || - config.refreshToken || - config.accessTokenExpiresAt || - config.oauthClientId || - config.oauthClientSecret - ) { - fileShape.oauth = {}; - if (config.accessToken) { - fileShape.oauth.access = config.accessToken; - } - if (config.refreshToken) { - fileShape.oauth.refresh = config.refreshToken; - } - if (config.accessTokenExpiresAt) { - fileShape.oauth.expiresAt = config.accessTokenExpiresAt; - } - if (config.oauthClientId) { - fileShape.oauth.clientId = config.oauthClientId; - } - if (config.oauthClientSecret) { - fileShape.oauth.clientSecret = config.oauthClientSecret; + if (Object.keys(normalized.workspaces).length > 0) { + fileShape.workspaces = {}; + for (const [name, profile] of Object.entries(normalized.workspaces)) { + fileShape.workspaces[name] = workspaceProfileToFile(profile); } } @@ -269,15 +765,51 @@ export async function updateConfig(updates: Partial): Promise { return updated; } +export async function saveWorkspaceProfile( + name: string, + profile: WorkspaceProfile, +): Promise { + const config = await loadConfig(); + const workspaces = { ...(config.workspaces ?? {}) }; + workspaces[name] = profile; + + await saveConfig({ + ...config, + workspaces, + }); +} + +export async function setDefaultWorkspace(name: string): Promise { + const config = await loadConfig(); + const workspaces = config.workspaces ?? {}; + + if (!workspaces[name]) { + throw new CliError(`Workspace "${name}" not found in config.`, { + suggestion: + "Run 'linear auth list' to see available workspaces, or 'linear auth login --workspace " + + name + + "' to add one.", + }); + } + + await saveConfig({ + ...config, + defaultWorkspace: name, + }); +} + /** * Resolve auth credentials, checking env vars first then config. * Precedence: * 1. LINEAR_OAUTH_TOKEN (env, oauth) * 2. LINEAR_API_TOKEN (env, api) - * 3. config.accessToken (config, oauth) - * 4. config.apiToken (config, api) + * 3. workspace profile oauth token + * 4. workspace profile api token + * 5. legacy config accessToken/apiToken (backward compat) */ -export async function resolveAuth(): Promise { +export async function resolveAuth(context?: { + workspace?: string; +}): Promise { const envOauthToken = process.env.LINEAR_OAUTH_TOKEN; if (envOauthToken) { return { @@ -299,7 +831,38 @@ export async function resolveAuth(): Promise { } 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?.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, + token: profile.apiToken, + source: "config", + kind: "api", + workspace, + }; + } + + return undefined; + } + + // Legacy fallback for old flat config. if (config.accessToken) { return { header: `Bearer ${config.accessToken}`, @@ -336,9 +899,9 @@ export async function getApiToken(): Promise { * Resolve OAuth client credentials for token refresh. * Environment variables take precedence over config file values. */ -export async function resolveOAuthClientCredentials(): Promise< - { clientId: string; clientSecret: string } | undefined -> { +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; @@ -347,6 +910,19 @@ export async function resolveOAuthClientCredentials(): Promise< } 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, @@ -396,6 +972,10 @@ export function validateLocalConfig(data: unknown): LocalConfig { const obj = data as Record; const config: LocalConfig = {}; + if (typeof obj.workspace === "string") { + config.workspace = obj.workspace; + } + if (typeof obj.defaultTeamKey === "string") { config.defaultTeamKey = obj.defaultTeamKey; } @@ -459,24 +1039,61 @@ export async function loadLocalConfig( } /** - * Load global config and local config, merging local overrides. - * Local config values override global config values (except apiToken). + * Load global config and local config, applying workspace and local overrides. + * Merge order: + * global defaults < workspace profile defaults < local overrides */ -export async function loadMergedConfig(): Promise<{ +export async function loadMergedConfig(context?: { + workspace?: string; +}): Promise<{ config: Config; local: LocalConfig | undefined; + workspaceName?: string; }> { const config = await loadConfig(); const local = await loadLocalConfig(config.localConfigPaths); + const workspaceName = resolveWorkspaceName(config, local, context); + + const merged: Config = { ...config }; + + if (workspaceName) { + const profile = config.workspaces?.[workspaceName]; + if (profile) { + if (profile.defaultTeamKey !== undefined) { + merged.defaultTeamKey = profile.defaultTeamKey; + } + 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; + } + } + } if (local) { if (local.defaultTeamKey !== undefined) { - config.defaultTeamKey = local.defaultTeamKey; + merged.defaultTeamKey = local.defaultTeamKey; } if (local.outputFormat !== undefined) { - config.outputFormat = local.outputFormat; + merged.outputFormat = local.outputFormat; } } - return { config, local }; + return { config: merged, local, workspaceName }; } diff --git a/src/index.ts b/src/index.ts index 84417e8..363215f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,13 +56,25 @@ import { searchIssues, searchProjects, } from "./commands/search"; +import { + getWorkspaceContext, + resetWorkspaceContext, + setWorkspaceContext, +} from "./commands/shared"; import { listStates } from "./commands/state"; import { getTeam, listTeams } from "./commands/team"; import { getUser, listUsers, me } from "./commands/user"; -import { getConfigPath } from "./config"; +import { + getConfigPath, + loadConfig, + migrateConfigOnStartup, + setDefaultWorkspace, +} from "./config"; import { printCliError } from "./errors"; async function main(): Promise { + await migrateConfigOnStartup(); + const args = process.argv.slice(2); const options = parseArgs(args); @@ -82,59 +94,65 @@ async function main(): Promise { process.exit(options.help ? 0 : 1); } + setWorkspaceContext(options.workspace ?? undefined); + // Pass help flag to subcommand handlers const subcommandArgs = options.help ? ["--help", ...options.args] : options.args; - // Route to command handlers - switch (options.command) { - case "auth": - await handleAuth(options.subcommand, subcommandArgs); - break; - case "issue": - await handleIssue(options.subcommand, subcommandArgs); - break; - case "team": - await handleTeam(options.subcommand, subcommandArgs); - break; - case "project": - await handleProject(options.subcommand, subcommandArgs); - break; - case "cycle": - await handleCycle(options.subcommand, subcommandArgs); - break; - case "comment": - await handleComment(options.subcommand, subcommandArgs); - break; - case "document": - await handleDocument(options.subcommand, subcommandArgs); - break; - case "label": - await handleLabel(options.subcommand, subcommandArgs); - break; - case "milestone": - await handleMilestone(options.subcommand, subcommandArgs); - break; - case "initiative": - await handleInitiative(options.subcommand, subcommandArgs); - break; - case "user": - await handleUser(options.subcommand, subcommandArgs); - break; - case "state": - await handleState(options.subcommand, subcommandArgs); - break; - case "search": - await handleSearch(options.subcommand, subcommandArgs); - break; - case "graphql": - await handleGraphql(getGraphqlArgs(options)); - break; - default: - console.error(`Unknown command: ${options.command}`); - printHelp(); - process.exit(1); + try { + // Route to command handlers + switch (options.command) { + case "auth": + await handleAuth(options.subcommand, subcommandArgs); + break; + case "issue": + await handleIssue(options.subcommand, subcommandArgs); + break; + case "team": + await handleTeam(options.subcommand, subcommandArgs); + break; + case "project": + await handleProject(options.subcommand, subcommandArgs); + break; + case "cycle": + await handleCycle(options.subcommand, subcommandArgs); + break; + case "comment": + await handleComment(options.subcommand, subcommandArgs); + break; + case "document": + await handleDocument(options.subcommand, subcommandArgs); + break; + case "label": + await handleLabel(options.subcommand, subcommandArgs); + break; + case "milestone": + await handleMilestone(options.subcommand, subcommandArgs); + break; + case "initiative": + await handleInitiative(options.subcommand, subcommandArgs); + break; + case "user": + await handleUser(options.subcommand, subcommandArgs); + break; + case "state": + await handleState(options.subcommand, subcommandArgs); + break; + case "search": + await handleSearch(options.subcommand, subcommandArgs); + break; + case "graphql": + await handleGraphql(getGraphqlArgs(options)); + break; + default: + console.error(`Unknown command: ${options.command}`); + printHelp(); + process.exit(1); + } + } finally { + resetWorkspaceContext(); } } @@ -143,13 +161,17 @@ function parseAuthArgs(args: string[]): { type?: "api" | "oauth"; refreshToken?: string; expiresAt?: string; + workspace?: string; + positionals: string[]; help: boolean; } { let token: string | undefined; let type: "api" | "oauth" | undefined; let refreshToken: string | undefined; let expiresAt: string | undefined; + let workspace: string | undefined; let help = false; + const positionals: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -181,10 +203,17 @@ function parseAuthArgs(args: string[]): { i++; } else if (arg?.startsWith("--expires-at=")) { expiresAt = arg.slice(13); + } else if (arg === "-w" || arg === "--workspace") { + workspace = args[i + 1]; + i++; + } else if (arg?.startsWith("--workspace=")) { + workspace = arg.slice(12); + } else if (arg && !arg.startsWith("-")) { + positionals.push(arg); } } - return { token, type, refreshToken, expiresAt, help }; + return { token, type, refreshToken, expiresAt, workspace, positionals, help }; } async function handleAuth( @@ -192,12 +221,13 @@ async function handleAuth( args: string[], ): Promise { const parsed = parseAuthArgs(args); + const workspace = parsed.workspace ?? getWorkspaceContext(); switch (subcommand) { case "login": { if (parsed.help) { console.log(` -Usage: linear auth login [--token ] [--type ] +Usage: linear auth login [--token ] [--type ] [--workspace ] Authenticate with Linear using an API token or an OAuth token. @@ -206,6 +236,7 @@ Options: --type Required with --token or stdin --refresh-token OAuth refresh token (only with --type oauth) --expires-at Access token expiry as ISO timestamp (only with --type oauth) + -w, --workspace Save credentials to a specific workspace profile -h, --help Show this help Interactive login will ask for the token type first. @@ -251,14 +282,21 @@ Get your API token from: ? { refreshToken: parsed.refreshToken, expiresAt: parsed.expiresAt, + workspace, } - : undefined; + : { + workspace, + }; const result = await login(token, kind, loginOptions); if (result.success) { - console.log(`Authenticated as: ${result.name}`); - if (result.email) { - console.log(`Email: ${result.email}`); + console.log( + `Authenticated as: ${result.name}${result.email ? ` (${result.email})` : ""}`, + ); + if (result.workspace) { + console.log( + `Workspace: ${result.orgName ?? result.workspace} (${result.workspace})`, + ); } console.log(`Token saved to: ${getConfigPath()}`); } else { @@ -271,35 +309,43 @@ Get your API token from: case "logout": { if (parsed.help) { console.log(` -Usage: linear auth logout +Usage: linear auth logout [--workspace ] Remove stored API token from config file. Options: - -h, --help Show this help + -w, --workspace Workspace profile to remove + -h, --help Show this help `); return; } - await logout(); - console.log("Logged out. Token removed from config."); + await logout(workspace); + if (workspace) { + console.log( + `Logged out from workspace "${workspace}". Token removed from config.`, + ); + } else { + console.log("Logged out. Token removed from config."); + } break; } case "status": { if (parsed.help) { console.log(` -Usage: linear auth status +Usage: linear auth status [--workspace ] Show current authentication status. Options: - -h, --help Show this help + -w, --workspace Workspace profile to inspect + -h, --help Show this help `); return; } - const status = await getAuthStatus(); + const status = await getAuthStatus(workspace); if (status.authenticated) { console.log("Status: Authenticated"); @@ -307,6 +353,11 @@ Options: if (status.email) { console.log(`Email: ${status.email}`); } + if (status.workspace) { + console.log( + `Workspace: ${status.orgName ?? status.workspace} (${status.workspace})`, + ); + } console.log( `Token source: ${status.tokenSource === "env" ? "environment variable" : "config file"}`, ); @@ -325,9 +376,83 @@ Options: break; } + case "list": { + if (parsed.help) { + console.log(` +Usage: linear auth list + +List configured workspace profiles. + +Options: + -h, --help Show this help +`); + return; + } + + const config = await loadConfig(); + const entries = Object.entries(config.workspaces ?? {}); + + if (entries.length === 0) { + console.log("No workspace profiles configured."); + return; + } + + const rows = entries.map(([name, profile]) => { + const type = profile.accessToken + ? "oauth" + : profile.apiToken + ? "api" + : "-"; + const marker = config.defaultWorkspace === name ? "*" : ""; + return [name, profile.orgName ?? "-", type, marker]; + }); + + const headers = ["NAME", "ORG", "TYPE", "DEFAULT"]; + const widths = headers.map((header, index) => + Math.max( + header.length, + ...rows.map((row) => (row[index] ?? "").length), + ), + ); + + console.log(headers.map((h, i) => h.padEnd(widths[i] ?? 0)).join(" ")); + for (const row of rows) { + console.log( + row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" "), + ); + } + break; + } + + case "use": { + if (parsed.help) { + console.log(` +Usage: linear auth use + +Set default workspace profile. + +Options: + -h, --help Show this help +`); + return; + } + + const name = parsed.positionals[0]; + if (!name) { + console.error( + "Error: Missing workspace name. Usage: linear auth use ", + ); + process.exit(1); + } + + await setDefaultWorkspace(name); + console.log(`Default workspace set to "${name}".`); + break; + } + default: console.error( - `Usage: linear auth \n\nSubcommands:\n login Authenticate with API token\n logout Remove stored credentials\n status Show authentication status`, + `Usage: linear auth \n\nSubcommands:\n login Authenticate with API token\n logout Remove stored credentials\n status Show authentication status\n list List workspace profiles\n use Set default workspace`, ); process.exit(1); } diff --git a/src/oauth.ts b/src/oauth.ts index dc863b3..7febd7e 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -3,7 +3,12 @@ * Kept in a separate module to avoid circular dependencies between api.ts and auth.ts. */ -import { resolveOAuthClientCredentials, updateConfig } from "./config.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, @@ -12,8 +17,9 @@ import { resolveOAuthClientCredentials, updateConfig } from "./config.ts"; */ export async function refreshOAuthToken( currentRefreshToken: string, + workspace: string, ): Promise { - const credentials = await resolveOAuthClientCredentials(); + const credentials = await resolveOAuthClientCredentials({ workspace }); if (!credentials) { throw new Error( @@ -61,7 +67,22 @@ export async function refreshOAuthToken( ? new Date(Date.now() + data.expires_in * 1000).toISOString() : undefined; - await updateConfig({ + 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, -- 2.51.2