Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,25 @@ deno task install

## setup

1. create an API key at [linear.app/settings/account/security](https://linear.app/settings/account/security)[^1]

2. authenticate with the CLI:
1. choose an auth mode:

```sh
# API key (traditional mode)
# create one at https://linear.app/settings/account/security
linear auth login

# managed relay + mTLS (no local API key)
linear auth login --managed --account-id <id> --relay-base-url <url>
```

3. configure your project:
2. configure your project:

```sh
cd my-project-repo
linear config
```

see [docs/authentication.md](docs/authentication.md) for multi-workspace support and other authentication options.
for managed relay auth, set `LINEAR_MTLS_SHARED_SECRET` and `LINEAR_SANDBOX_ID` in the environment before running the CLI. see [docs/authentication.md](docs/authentication.md) for multi-workspace support and the full auth matrix.

the CLI works with both git and jj version control systems:

Expand Down
45 changes: 40 additions & 5 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@ the CLI supports multiple authentication methods with the following precedence:
1. `--api-key` flag (explicit key for single command)
2. `LINEAR_API_KEY` environment variable
3. `api_key` in project `.linear.toml` config
4. `--workspace` / `-w` flag → stored credentials lookup
5. project's `workspace` config → stored credentials lookup
6. default workspace from stored credentials
4. `--workspace` / `-w` flag → stored credentials lookup (API key or managed relay)
5. project's `workspace` config → stored credentials lookup (API key or managed relay)
6. managed relay environment (`LINEAR_RELAY_BASE_URL` + `LINEAR_RELAY_ACCOUNT_ID`)
7. default workspace from stored credentials

## stored credentials (recommended)

API keys are stored in your system's native keyring (macOS Keychain, Linux libsecret, Windows CredentialManager). workspace metadata is stored in `~/.config/linear/credentials.toml`.
API keys are stored in your system's native keyring (macOS Keychain, Linux libsecret, Windows CredentialManager). workspace metadata, including managed relay bindings, is stored in `~/.config/linear/credentials.toml`.

### commands

```bash
linear auth login # add a workspace (prompts for API key)
linear auth login --key <key> # add with key directly (for scripts)
linear auth login --managed --account-id <id> --relay-base-url <url>
linear auth list # list configured workspaces
linear auth default # interactively set default workspace
linear auth default <slug> # set default workspace directly
Expand Down Expand Up @@ -55,6 +57,35 @@ $ linear auth list

the `*` indicates the default workspace.

### managed relay auth (mTLS)

for sandboxed/managed environments, the CLI can skip local API keys and authenticate through a relay that trusts your mTLS sandbox identity. this is designed for setups similar to the google-cli and slack managed flows.

required environment:

```bash
export LINEAR_MTLS_SHARED_SECRET="..."
export LINEAR_SANDBOX_ID="sandbox-123"
```

to log in and persist the relay binding locally:

```bash
linear auth login \
--managed \
--account-id acc-123 \
--relay-base-url https://relay.example
```

or set the binding via env for ephemeral sessions:

```bash
export LINEAR_RELAY_BASE_URL="https://relay.example"
export LINEAR_RELAY_ACCOUNT_ID="acc-123"
```

managed auth stores the workspace slug, relay base URL, and account id locally, but it does **not** store an API key. `linear auth token` therefore only works for API-key workspaces.

### switching workspaces

```bash
Expand All @@ -72,9 +103,13 @@ linear -w acme issue create --title "Bug fix"
# ~/.config/linear/credentials.toml
default = "acme"
workspaces = ["acme", "side-project"]

[managed.acme]
account_id = "acc-123"
relay_base_url = "https://relay.example"
```

API keys are not stored in this file. they are stored in the system keyring and loaded at startup.
API keys are not stored in this file. they are stored in the system keyring and loaded at startup. managed relay bindings are stored here because they only contain routing metadata, not secrets.

### platform requirements

Expand Down
42 changes: 25 additions & 17 deletions src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import {
Type,
ValidationError,
} from "@cliffy/command"
import denoConfig from "../../deno.json" with { type: "json" }
import { getGraphQLEndpoint, getResolvedApiKey } from "../utils/graphql.ts"
import { getResolvedGraphQLRequest } from "../utils/graphql.ts"
import {
CliError,
handleError,
Expand Down Expand Up @@ -53,33 +52,40 @@ export const apiCommand = new Command()
options.variable,
options.variablesJson,
)

const apiKey = getResolvedApiKey()
if (!apiKey) {
throw new AppValidationError(
"No API key configured",
{
suggestion:
"Set LINEAR_API_KEY, add api_key to .linear.toml, or run `linear auth login`.",
},
)
let request
try {
request = getResolvedGraphQLRequest()
} catch (error) {
if (
error instanceof Error &&
error.message.startsWith("No authentication configured")
) {
throw new AppValidationError(
"No API key configured",
{
suggestion:
"Set LINEAR_API_KEY, add api_key to .linear.toml, or run `linear auth login`.",
},
)
}
throw error
}

const headers = {
"Content-Type": "application/json",
Authorization: apiKey,
"User-Agent": `schpet-linear-cli/${denoConfig.version}`,
...request.headers,
}

if (options.paginate) {
await executePaginated(
request.endpoint,
resolvedQuery,
variables,
headers,
options.silent ?? false,
)
} else {
await executeSingle(
request.endpoint,
resolvedQuery,
variables,
headers,
Expand All @@ -92,6 +98,7 @@ export const apiCommand = new Command()
})

async function executeSingle(
endpoint: string,
query: string,
variables: Record<string, unknown>,
headers: Record<string, string>,
Expand All @@ -102,7 +109,7 @@ async function executeSingle(
body.variables = variables
}

const response = await fetch(getGraphQLEndpoint(), {
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(body),
Expand Down Expand Up @@ -136,6 +143,7 @@ async function executeSingle(
}

async function executePaginated(
endpoint: string,
query: string,
variables: Record<string, unknown>,
headers: Record<string, string>,
Expand All @@ -152,7 +160,7 @@ async function executePaginated(
body.variables = vars
}

const response = await fetch(getGraphQLEndpoint(), {
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(body),
Expand Down
35 changes: 19 additions & 16 deletions src/commands/auth/auth-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { Command } from "@cliffy/command"
import { unicodeWidth } from "@std/cli"
import { gql } from "../../__codegen__/gql.ts"
import {
getCredentialApiKey,
getDefaultWorkspace,
getCredentialApiKey,
getManagedCredential,
getWorkspaces,
} from "../../credentials.ts"
import { padDisplay } from "../../utils/display.ts"
import { handleError, isClientError } from "../../utils/errors.ts"
import { createGraphQLClient } from "../../utils/graphql.ts"
import {
createGraphQLClient,
createManagedGraphQLClient,
} from "../../utils/graphql.ts"

const viewerQuery = gql(`
query AuthListViewer {
Expand All @@ -34,10 +38,20 @@ interface WorkspaceInfo {

async function fetchWorkspaceInfo(
workspace: string,
apiKey: string,
): Promise<WorkspaceInfo> {
const isDefault = getDefaultWorkspace() === workspace
const client = createGraphQLClient(apiKey)
const managed = getManagedCredential(workspace)
const apiKey = getCredentialApiKey(workspace)
if (!managed && apiKey == null) {
return {
workspace,
isDefault,
error: "missing credentials",
}
}
const client = managed
? createManagedGraphQLClient(managed)
: createGraphQLClient(apiKey!)

try {
const result = await client.request(viewerQuery)
Expand Down Expand Up @@ -82,18 +96,7 @@ export const listCommand = new Command()
}

// Fetch info for all workspaces in parallel
const infoPromises = workspaces.map((ws) => {
const apiKey = getCredentialApiKey(ws)
if (apiKey == null) {
const info: WorkspaceInfo = {
workspace: ws,
isDefault: getDefaultWorkspace() === ws,
error: "missing credentials",
}
return Promise.resolve(info)
}
return fetchWorkspaceInfo(ws, apiKey)
})
const infoPromises = workspaces.map((ws) => fetchWorkspaceInfo(ws))
const infos = await Promise.all(infoPromises)

// Calculate column widths
Expand Down
92 changes: 91 additions & 1 deletion src/commands/auth/auth-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { yellow } from "@std/fmt/colors"
import { gql } from "../../__codegen__/gql.ts"
import {
addCredential,
addManagedCredential,
getWorkspaces,
hasWorkspace,
isUsingInlineFormat,
Expand All @@ -16,7 +17,10 @@ import {
handleError,
ValidationError,
} from "../../utils/errors.ts"
import { createGraphQLClient } from "../../utils/graphql.ts"
import {
createGraphQLClient,
createManagedGraphQLClient,
} from "../../utils/graphql.ts"

const viewerQuery = gql(`
query AuthLoginViewer {
Expand All @@ -35,12 +39,98 @@ export const loginCommand = new Command()
.name("login")
.description("Add a workspace credential")
.option("-k, --key <key:string>", "API key (prompted if not provided)")
.option("--managed", "Use managed relay auth instead of an API key")
.option(
"--account-id <id:string>",
"Managed relay account id (defaults to LINEAR_RELAY_ACCOUNT_ID)",
)
.option(
"--relay-base-url <url:string>",
"Managed relay base URL (defaults to LINEAR_RELAY_BASE_URL)",
)
.option(
"--plaintext",
"Store API key in credentials file instead of system keyring",
)
.action(async (options) => {
try {
if (options.managed) {
if (options.plaintext) {
throw new ValidationError(
"`--plaintext` cannot be used with `--managed`.",
{
suggestion:
"Managed auth stores only workspace metadata locally.",
},
)
}
if (options.key) {
throw new ValidationError("`--key` cannot be used with `--managed`.")
}

const accountId = options.accountId?.trim() ??
Deno.env.get("LINEAR_RELAY_ACCOUNT_ID")?.trim()
const relayBaseUrl = options.relayBaseUrl?.trim() ??
Deno.env.get("LINEAR_RELAY_BASE_URL")?.trim()

if (!accountId) {
throw new ValidationError("No managed account id provided", {
suggestion:
"Pass `--account-id` or set LINEAR_RELAY_ACCOUNT_ID.",
})
}

if (!relayBaseUrl) {
throw new ValidationError("No managed relay base URL provided", {
suggestion:
"Pass `--relay-base-url` or set LINEAR_RELAY_BASE_URL.",
})
}

const client = createManagedGraphQLClient({
accountId,
relayBaseUrl,
})
const result = await client.request(viewerQuery)
const viewer = result.viewer
const org = viewer.organization
const workspace = org.urlKey
const alreadyExists = hasWorkspace(workspace)

await addManagedCredential(workspace, {
accountId,
relayBaseUrl,
})

const existingCount = getWorkspaces().length
if (alreadyExists) {
console.log(
`Updated credentials for workspace: ${org.name} (${workspace})`,
)
} else {
console.log(`Logged in to workspace: ${org.name} (${workspace})`)
}
console.log(` User: ${viewer.name} <${viewer.email}>`)

if (existingCount === 1) {
console.log(` Set as default workspace`)
}

if (Deno.env.get("LINEAR_API_KEY")) {
console.log()
console.log(
yellow("Warning: LINEAR_API_KEY environment variable is set."),
)
console.log(yellow("It takes precedence over stored credentials."))
console.log(
yellow(
"Remove it from your shell config to use managed workspace auth.",
),
)
}
return
}

let apiKey = options.key?.trim()

if (!apiKey) {
Expand Down
Loading
Loading