linear-sdk-cli 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/MIGRATING.md +11 -3
- package/README.md +38 -39
- package/package.json +1 -1
- package/skills/linear-sdk-cli/SKILL.md +20 -21
- package/skills/linear-sdk-cli/references/auth.md +3 -9
- package/src/commands/meta.ts +63 -164
- package/src/config.ts +6 -6
- package/src/lib/authorization.ts +1 -1
- package/src/lib/keyring.ts +2 -2
- package/src/lib/output-shapes.ts +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ Release Please generates this file from Conventional Commits. Feature and fix pu
|
|
|
4
4
|
not edit it directly; their squash-commit titles are the release-note source of truth. This project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.9.1](https://github.com/zhendalf/linear-sdk-cli/compare/v0.9.0...v0.9.1) (2026-10-22)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **auth:** default to personal API-key login ([#98](https://github.com/zhendalf/linear-sdk-cli/issues/98)) ([0d57f7c](https://github.com/zhendalf/linear-sdk-cli/commit/0d57f7c741bc5e9099787271cde16f436cb21f13))
|
|
12
|
+
|
|
7
13
|
## [0.9.0](https://github.com/zhendalf/linear-sdk-cli/compare/v0.8.3...v0.9.0) (2026-10-22)
|
|
8
14
|
|
|
9
15
|
### Features
|
package/MIGRATING.md
CHANGED
|
@@ -48,13 +48,21 @@ linear auth list # …lists it, Storage: keychain
|
|
|
48
48
|
|
|
49
49
|
If it does not — no keyring on this platform, or you used schpet's plaintext mode (`slug =
|
|
50
50
|
"lin_api_…"` inline in `credentials.toml`, which we deliberately do not read) — pipe the key to
|
|
51
|
-
`linear auth login --key
|
|
52
|
-
|
|
53
|
-
migrate` moves plaintext credentials from our file into the keyring,
|
|
51
|
+
`linear auth login --key -`, or run bare `linear auth login` for a hidden personal API-key prompt.
|
|
52
|
+
Login requires a system keyring unless you explicitly pass `--plaintext` to use our `0600`
|
|
53
|
+
`config.toml` instead. `auth migrate` moves plaintext credentials from our file into the keyring,
|
|
54
|
+
same command name as theirs.
|
|
54
55
|
Because the API-key entry is shared, `auth logout` here removes it for both tools (and drops the
|
|
55
56
|
slug from schpet's list file, so neither reports a workspace whose key is gone). The API key is
|
|
56
57
|
**never** read from a project `.linear.toml`.
|
|
57
58
|
|
|
59
|
+
Earlier versions defaulted to browser OAuth through a bundled client ID. That login flow and its
|
|
60
|
+
callback options have been removed. Existing stored OAuth user profiles still refresh and support
|
|
61
|
+
logout/revocation using their saved client ID. For new authentication, use a personal API key,
|
|
62
|
+
`LINEAR_ACCESS_TOKEN` / `--access-token` on commands (the issuing grant determines user/app identity),
|
|
63
|
+
or `auth login --app` with your own client ID and secret. Supplied access tokens remain temporary;
|
|
64
|
+
the CLI does not persist or refresh them. No hardcoded OAuth client ID is shipped.
|
|
65
|
+
|
|
58
66
|
## 3. Config — same files, same keys
|
|
59
67
|
|
|
60
68
|
We look in every place schpet does, in its order. Walking up from the working directory, each
|
package/README.md
CHANGED
|
@@ -70,9 +70,9 @@ retains their best ideas and provides a careful compatibility and migration path
|
|
|
70
70
|
|
|
71
71
|
## Install
|
|
72
72
|
|
|
73
|
-
Requires [Bun](https://bun.sh) **1.1 or newer**. Authenticate with
|
|
74
|
-
invocation-scoped OAuth access token, or
|
|
75
|
-
directly on Bun — no build step, no bundle, no Node.
|
|
73
|
+
Requires [Bun](https://bun.sh) **1.1 or newer**. Authenticate with a personal API key (the default),
|
|
74
|
+
an invocation-scoped OAuth access token, or your app's client credentials. The CLI ships as
|
|
75
|
+
TypeScript and runs directly on Bun — no build step, no bundle, no Node.
|
|
76
76
|
|
|
77
77
|
```sh
|
|
78
78
|
bun add --global linear-sdk-cli
|
|
@@ -103,7 +103,7 @@ bun run src/bin/linear.ts --help # or: bun run dev -- --help
|
|
|
103
103
|
## Quickstart
|
|
104
104
|
|
|
105
105
|
```sh
|
|
106
|
-
linear auth login #
|
|
106
|
+
linear auth login # securely prompt for a personal API key
|
|
107
107
|
linear whoami # confirm you're connected
|
|
108
108
|
|
|
109
109
|
linear issue list --assignee me --state started # my in-progress work
|
|
@@ -118,9 +118,12 @@ branch, so most issue commands let you omit the id entirely.
|
|
|
118
118
|
|
|
119
119
|
## Authentication
|
|
120
120
|
|
|
121
|
-
For a person at a terminal, `linear auth login`
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
For a person at a terminal, `linear auth login` prompts for a personal API key without echoing it.
|
|
122
|
+
Create a key in [Security & Access](https://linear.app/settings/account/security), choosing its
|
|
123
|
+
permissions and team access there. Workspace administrators may restrict member key creation.
|
|
124
|
+
No OAuth application installation, bundled client ID, or browser callback is required.
|
|
125
|
+
|
|
126
|
+
For commands, explicit `--api-key` or `--access-token` flags override environment credentials;
|
|
124
127
|
without a credential flag the CLI reads `LINEAR_API_KEY`, `LINEAR_ACCESS_TOKEN`, or the pair
|
|
125
128
|
`LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET`. Mixing credential types in the environment fails
|
|
126
129
|
rather than silently choosing a Linear actor. Client credentials must be supplied together;
|
|
@@ -129,14 +132,13 @@ explicit credential flags still override all environment credentials.
|
|
|
129
132
|
> **Credential trust boundary.** A secret is **never** read from a project-local
|
|
130
133
|
> `.linear.toml` — only non-secret settings live there — so a key can't be committed by accident.
|
|
131
134
|
> A project may select one of your already-stored credentials with `workspace = "<slug>"`, but it
|
|
132
|
-
> cannot provide or override the secret.
|
|
133
|
-
> token injected with `--access-token` or `LINEAR_ACCESS_TOKEN` remains invocation-scoped.
|
|
135
|
+
> cannot provide or override the secret. Existing browser OAuth credentials remain keyring-only;
|
|
136
|
+
> a token injected with `--access-token` or `LINEAR_ACCESS_TOKEN` remains invocation-scoped.
|
|
134
137
|
|
|
135
138
|
```sh
|
|
136
|
-
linear auth login #
|
|
137
|
-
linear auth login --
|
|
138
|
-
linear auth login --
|
|
139
|
-
printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key - # personal API-key fallback
|
|
139
|
+
linear auth login # hidden personal API-key prompt
|
|
140
|
+
printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key - # explicit stdin input
|
|
141
|
+
linear auth login --app --client-id YOUR_CLIENT_ID --client-secret -
|
|
140
142
|
linear auth status # credential kind and source (value redacted)
|
|
141
143
|
linear auth logout --workspace acme # revoke OAuth (if used) and remove the profile
|
|
142
144
|
```
|
|
@@ -148,30 +150,27 @@ Linear provides no safe read-only scope introspection for them. `whoami --json`
|
|
|
148
150
|
member's workspace role in `admin` and reports credential authorization separately under
|
|
149
151
|
`authorization`; an admin member can still hold a credential that lacks the `admin` scope.
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
API-key-only compatibility option. API-key entries use `account = <workspace slug>`, preserving
|
|
168
|
-
compatibility with existing keyring profiles. Passing a key as `--key <value>` works but warns
|
|
169
|
-
because argv is visible to other processes. `auth migrate` moves legacy plaintext keys into the
|
|
170
|
-
keyring, and `auth token` exports stored API keys only; OAuth tokens are never printed.
|
|
153
|
+
Both the prompt and `auth login --key -` validate the viewer/workspace before storing the key in
|
|
154
|
+
the system keyring (`service = linear-cli`, `account = <workspace slug>`), with only a marker in
|
|
155
|
+
the user config. If there is no system keyring, login requires explicit `--plaintext` to store
|
|
156
|
+
API-key or app credentials in the private user config (`0600`). Passing `--key <value>` also works
|
|
157
|
+
but warns because argv and shell history expose it. `auth migrate` moves legacy plaintext keys
|
|
158
|
+
into the keyring; `auth token` exports stored API keys only, never OAuth tokens.
|
|
159
|
+
|
|
160
|
+
JSON, `--no-input`, and redirected sessions never prompt. Supply `--key -` explicitly for stdin;
|
|
161
|
+
bare login does not import environment credentials. To authenticate a command without saving
|
|
162
|
+
credentials, set `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN` instead. `--read-only` and `--scope`
|
|
163
|
+
apply only to `--app`; set personal API-key permissions in Linear when creating the key.
|
|
164
|
+
|
|
165
|
+
Browser login and its `--no-browser`, `--redirect-uri`, `--timeout`, and `--admin` options have
|
|
166
|
+
been removed. Existing stored OAuth user profiles still refresh using their own saved client ID,
|
|
167
|
+
retain scope reporting, and support revocation on logout. No embedded client ID is needed to use
|
|
168
|
+
these profiles. New personal logins use API keys.
|
|
171
169
|
|
|
172
170
|
### Apps, agents, and service accounts
|
|
173
171
|
|
|
174
|
-
Use an OAuth access token when the CLI runs inside an integration or agent
|
|
172
|
+
Use an OAuth access token when the CLI runs inside an integration or agent. The token may
|
|
173
|
+
represent a user or app; its issuing grant determines the identity and permissions:
|
|
175
174
|
|
|
176
175
|
```sh
|
|
177
176
|
LINEAR_ACCESS_TOKEN="$access_token" linear whoami --json
|
|
@@ -256,7 +255,7 @@ access to the same user config. Switching storage removes the previous copy of t
|
|
|
256
255
|
replacing an existing keyring profile requires access to that keyring.
|
|
257
256
|
|
|
258
257
|
Use `--read-only` to request only `read`, or `--scope read,comments:create` for custom app scopes
|
|
259
|
-
(default: `read,write`). App actors cannot request
|
|
258
|
+
(default: `read,write`). App actors cannot request an `admin` scope. Linear must have
|
|
260
259
|
client credentials enabled for the app. Changing scopes can revoke existing app tokens, so use
|
|
261
260
|
the same scopes across hosts.
|
|
262
261
|
|
|
@@ -280,13 +279,13 @@ disable the app in Linear. Environment-only credentials must be unset separately
|
|
|
280
279
|
|
|
281
280
|
### Multiple workspaces
|
|
282
281
|
|
|
283
|
-
Credentials are stored per **workspace slug**.
|
|
282
|
+
Credentials are stored per **workspace slug**. App and API-key login derive the slug from
|
|
284
283
|
the authenticated organization; `--workspace` validates that the result matches:
|
|
285
284
|
|
|
286
285
|
```sh
|
|
287
|
-
linear auth login --workspace acme #
|
|
286
|
+
linear auth login --workspace acme # prompt for an API key for "acme"
|
|
288
287
|
linear auth login --workspace other-org # …and another workspace
|
|
289
|
-
linear auth login --workspace acme --key - # personal API
|
|
288
|
+
linear auth login --workspace acme --key - # personal API key via stdin
|
|
290
289
|
linear auth list # show configured workspaces + which is default
|
|
291
290
|
linear auth default acme # choose the default workspace
|
|
292
291
|
linear config set team ENG --user --workspace acme # set its validated default team
|
|
@@ -294,7 +293,7 @@ linear --workspace other-org issue list # use a specific workspace for one co
|
|
|
294
293
|
linear auth logout --workspace acme # revoke OAuth, then remove one credential
|
|
295
294
|
```
|
|
296
295
|
|
|
297
|
-
Successful
|
|
296
|
+
Successful app and API-key login also save `workspace = "<authenticated slug>"` in the discovered project config, preserving other settings and comments. If no config exists, login creates `.linear.toml` at the Git root (or cwd outside Git). Use `auth login --no-project` for credential-only login. Human output reports the association path; JSON includes `projectConfigPath` (null with `--no-project`). Credentials remain in the global credential store. Environment overrides still take precedence over this association.
|
|
298
297
|
|
|
299
298
|
Global defaults are optional: login, credential adoption, and logout never choose a new one. Use `linear auth default <slug>` to explicitly select a global fallback. Removing that workspace clears its default without promoting another workspace. Existing defaults, including defaults imported from the reference CLI, are preserved on upgrade because older files cannot distinguish a user choice from an automatically assigned default. To opt out of a legacy default, remove the top-level `default_workspace` from the user config and, if present, `default` from the reference CLI's `credentials.toml`; retain the workspace entries.
|
|
300
299
|
|
package/package.json
CHANGED
|
@@ -35,22 +35,24 @@ lin --version
|
|
|
35
35
|
|
|
36
36
|
### Authentication
|
|
37
37
|
|
|
38
|
-
The CLI supports
|
|
38
|
+
The CLI supports personal API keys, supplied access tokens, and app credentials. No client ID
|
|
39
|
+
is bundled, and new login never starts browser OAuth:
|
|
39
40
|
|
|
40
|
-
1. **
|
|
41
|
+
1. **Personal API key (default)**:
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
|
-
linear auth login #
|
|
44
|
-
linear auth login --
|
|
45
|
-
linear auth login --no-browser # prints URL; loopback callback still required
|
|
44
|
+
linear auth login # hidden API-key prompt in an interactive terminal
|
|
45
|
+
printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key -
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
is
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
Create a key at https://linear.app/settings/account/security, choosing permissions and team
|
|
49
|
+
access in Linear; workspace policy may restrict member key creation. Never ask for a key in
|
|
50
|
+
chat. The CLI validates the viewer/workspace before storing it in the OS keyring. If no keyring
|
|
51
|
+
is available, login requires explicit `--plaintext` for private user-config storage (`0600`).
|
|
52
|
+
Passing `--key <value>` also works but exposes the key in argv and produces a warning.
|
|
53
|
+
JSON, `--no-input`, and redirected sessions never prompt; use explicit `--key -` for stdin.
|
|
54
|
+
Bare login does not import environment credentials. `--read-only` and `--scope` require
|
|
55
|
+
`--app`; they cannot restrict an existing personal API key.
|
|
54
56
|
|
|
55
57
|
2. **Invocation OAuth access token** via `--access-token <token>` or `LINEAR_ACCESS_TOKEN` — use
|
|
56
58
|
this for hosted Linear apps, agents, and service accounts. Prefer the environment variable so
|
|
@@ -60,6 +62,7 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
|
|
|
60
62
|
LINEAR_ACCESS_TOKEN=... linear whoami --json
|
|
61
63
|
```
|
|
62
64
|
|
|
65
|
+
Tokens may represent a user or app; their issuing grant determines identity and permissions.
|
|
63
66
|
The host application owns OAuth installation, token refresh/client-credentials exchange,
|
|
64
67
|
client-secret storage, and webhooks. These tokens remain invocation-scoped.
|
|
65
68
|
Long-lived hosts can import `ClientCredentialsTokenProvider` from `linear-sdk-cli`; it caches
|
|
@@ -102,16 +105,7 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
|
|
|
102
105
|
LINEAR_API_KEY=lin_api_... linear whoami --json
|
|
103
106
|
```
|
|
104
107
|
|
|
105
|
-
5. **
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key -
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Passing `--key <value>` also works but exposes it in argv and produces a warning. API keys may
|
|
112
|
-
use `--plaintext`; browser OAuth is always keyring-only.
|
|
113
|
-
|
|
114
|
-
6. **Multiple workspaces** — store several OAuth or API-key credentials and select one per call with
|
|
108
|
+
5. **Multiple workspaces** — store several app or API-key credentials and select one per call with
|
|
115
109
|
`--workspace <slug>`; set a default with `linear auth default <slug>`:
|
|
116
110
|
|
|
117
111
|
```bash
|
|
@@ -120,6 +114,11 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
|
|
|
120
114
|
linear issue list --workspace acme --json
|
|
121
115
|
```
|
|
122
116
|
|
|
117
|
+
Existing stored OAuth user profiles remain supported: their own saved client ID is used for
|
|
118
|
+
refresh, scope reporting is retained, and logout revokes the grant (`--local-only` skips
|
|
119
|
+
revocation). New browser login and its `--no-browser`, `--redirect-uri`, `--timeout`, and `--admin`
|
|
120
|
+
options have been removed. Do not tell users to register or install a CLI OAuth application.
|
|
121
|
+
|
|
123
122
|
Login associates the authenticated workspace with the discovered project config, or creates `.linear.toml` at the Git root (cwd outside Git). Use `--no-project` for credential-only login; JSON reports `projectConfigPath` or null. Credentials stay global. Workspace selection is `--workspace` → `LINEAR_WORKSPACE` → project config → optional global default. A sole stored workspace is automatic; multiple unselected workspaces prompt only interactively and fail in JSON/noninteractive mode. Login/adoption/logout never assign a new default. Existing legacy defaults remain valid because their original intent is unknown; `auth default` is an explicit convenience.
|
|
124
123
|
|
|
125
124
|
When a workspace URL slug changes, use `linear auth rename <old-slug> <new-slug> --json`
|
|
@@ -69,7 +69,7 @@ storage: string
|
|
|
69
69
|
|
|
70
70
|
### `linear auth login`
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
Sign in with a personal API key (default) or app credentials
|
|
73
73
|
|
|
74
74
|
```
|
|
75
75
|
linear auth login [options]
|
|
@@ -80,16 +80,12 @@ linear auth login [options]
|
|
|
80
80
|
| `--no-project` | save credentials without changing the project workspace |
|
|
81
81
|
| `--key <key>` | use a personal API key ('-' reads it from stdin) |
|
|
82
82
|
| `--plaintext` | Store API-key or app credentials in the private user config file (0600) |
|
|
83
|
-
| `--
|
|
84
|
-
| `--read-only` | request read-only OAuth access |
|
|
85
|
-
| `--admin` | explicitly add the OAuth admin scope |
|
|
86
|
-
| `--timeout <seconds>` | seconds to wait for the loopback callback |
|
|
83
|
+
| `--read-only` | request read-only access with --app |
|
|
87
84
|
| `--app` | sign in as an app using client credentials |
|
|
88
85
|
| `--client-credentials` | alias for --app |
|
|
89
86
|
| `--scope <scopes>` | comma-separated app scopes (default: read,write) |
|
|
90
87
|
| `--client-secret <secret>` | app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET) |
|
|
91
|
-
| `--client-id <id>` |
|
|
92
|
-
| `--redirect-uri <uri>` | registered HTTP loopback callback URI |
|
|
88
|
+
| `--client-id <id>` | app client ID (defaults to LINEAR_CLIENT_ID) |
|
|
93
89
|
|
|
94
90
|
**Output (`--json`)**: a receipt object
|
|
95
91
|
|
|
@@ -99,8 +95,6 @@ credentialType: string
|
|
|
99
95
|
workspace: string
|
|
100
96
|
user: {id: string, name: string, email: string}
|
|
101
97
|
storage: string
|
|
102
|
-
scopes?: string[]
|
|
103
|
-
expiresAt?: string
|
|
104
98
|
path: string
|
|
105
99
|
projectConfigPath: string | null
|
|
106
100
|
```
|
package/src/commands/meta.ts
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
setDefaultWorkspace,
|
|
19
19
|
listCredentials,
|
|
20
20
|
readOAuthCredential,
|
|
21
|
-
writeOAuthCredential,
|
|
22
21
|
migrateCredentials,
|
|
23
22
|
renameApiKeyProfile,
|
|
24
23
|
profileRenameProjectUpdates,
|
|
@@ -35,12 +34,11 @@ import {
|
|
|
35
34
|
import { keyring } from "../lib/keyring.js";
|
|
36
35
|
import { readStdinSync } from "../lib/body.js";
|
|
37
36
|
import { CliError, authError, normalizeError, usageError } from "../lib/errors.js";
|
|
38
|
-
import { confirmDestructive, promptSelect } from "../lib/prompt.js";
|
|
37
|
+
import { confirmDestructive, promptSecret, promptSelect } from "../lib/prompt.js";
|
|
39
38
|
import { listTeams } from "../services/team.js";
|
|
40
39
|
import { resolveTeam } from "../lib/resolve.js";
|
|
41
40
|
import { ISSUE_SORTS } from "../services/issue.js";
|
|
42
41
|
import { firstTeam, type Context } from "../context.js";
|
|
43
|
-
import { openUrl } from "../lib/open.js";
|
|
44
42
|
import {
|
|
45
43
|
authorizationCapabilities,
|
|
46
44
|
credentialType as resolvedCredentialType,
|
|
@@ -48,19 +46,9 @@ import {
|
|
|
48
46
|
import {
|
|
49
47
|
ClientCredentialsTokenProvider,
|
|
50
48
|
clientCredentialsScopes,
|
|
51
|
-
DEFAULT_OAUTH_SCOPES,
|
|
52
|
-
buildAuthorizationUrl,
|
|
53
|
-
createPkceRequest,
|
|
54
|
-
exchangeAuthorizationCode,
|
|
55
49
|
revokeOAuthToken,
|
|
56
|
-
startLoopbackCallback,
|
|
57
|
-
type OAuthUserCredential,
|
|
58
50
|
} from "../oauth.js";
|
|
59
51
|
|
|
60
|
-
export const DEFAULT_OAUTH_REDIRECT_URI = "http://127.0.0.1:43821/oauth/callback";
|
|
61
|
-
/** Public PKCE client identity. This is not a credential; no client secret is shipped or used. */
|
|
62
|
-
export const DEFAULT_OAUTH_CLIENT_ID = "eda60862e4bb8b8af82cfb3193b65c2f";
|
|
63
|
-
|
|
64
52
|
/**
|
|
65
53
|
* The `whoami` handler, shared by the top-level `linear whoami` and the
|
|
66
54
|
* reference CLI's spelling `linear auth whoami` so the two cannot drift.
|
|
@@ -113,6 +101,12 @@ const defaultAuthValidationClientFactory: AuthValidationClientFactory = (credent
|
|
|
113
101
|
kind === "oauth-access-token" ? { accessToken: credential } : { apiKey: credential },
|
|
114
102
|
) as unknown as AuthValidationClient;
|
|
115
103
|
let authValidationClientFactory = defaultAuthValidationClientFactory;
|
|
104
|
+
let authSecretPrompt = promptSecret;
|
|
105
|
+
|
|
106
|
+
/** Test seam that does not depend on Bun's module-namespace spying behavior. */
|
|
107
|
+
export function setAuthSecretPromptForTests(prompt: typeof promptSecret | undefined): void {
|
|
108
|
+
authSecretPrompt = prompt ?? promptSecret;
|
|
109
|
+
}
|
|
116
110
|
|
|
117
111
|
/** Test seam for command-level auth tests; production always uses LinearClient. */
|
|
118
112
|
export function setAuthValidationClientFactoryForTests(
|
|
@@ -231,17 +225,14 @@ export function registerMeta(program: Command): void {
|
|
|
231
225
|
// login -------------------------------------------------------------------
|
|
232
226
|
auth
|
|
233
227
|
.command("login")
|
|
234
|
-
.description("
|
|
228
|
+
.description("Sign in with a personal API key (default) or app credentials")
|
|
235
229
|
.option("--no-project", "save credentials without changing the project workspace")
|
|
236
230
|
.option("--key <key>", "use a personal API key ('-' reads it from stdin)")
|
|
237
231
|
.option(
|
|
238
232
|
"--plaintext",
|
|
239
233
|
"Store API-key or app credentials in the private user config file (0600)",
|
|
240
234
|
)
|
|
241
|
-
.option("--
|
|
242
|
-
.option("--read-only", "request read-only OAuth access")
|
|
243
|
-
.option("--admin", "explicitly add the OAuth admin scope")
|
|
244
|
-
.option("--timeout <seconds>", "seconds to wait for the loopback callback", "120")
|
|
235
|
+
.option("--read-only", "request read-only access with --app")
|
|
245
236
|
.option("--app", "sign in as an app using client credentials")
|
|
246
237
|
.option("--client-credentials", "alias for --app")
|
|
247
238
|
.option("--scope <scopes>", "comma-separated app scopes (default: read,write)")
|
|
@@ -249,30 +240,18 @@ export function registerMeta(program: Command): void {
|
|
|
249
240
|
"--client-secret <secret>",
|
|
250
241
|
"app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET)",
|
|
251
242
|
)
|
|
252
|
-
.option(
|
|
253
|
-
"--client-id <id>",
|
|
254
|
-
"OAuth client ID (app: LINEAR_CLIENT_ID; browser: packaged CLI app)",
|
|
255
|
-
)
|
|
256
|
-
.option("--redirect-uri <uri>", "registered HTTP loopback callback URI")
|
|
243
|
+
.option("--client-id <id>", "app client ID (defaults to LINEAR_CLIENT_ID)")
|
|
257
244
|
.action(
|
|
258
245
|
action(async (ctx: Context, opts) => {
|
|
259
246
|
if (ctx.options.accessToken !== undefined) {
|
|
260
247
|
throw usageError(
|
|
261
|
-
"`auth login` cannot persist an injected access token.
|
|
248
|
+
"`auth login` cannot persist an injected access token. Use LINEAR_ACCESS_TOKEN or --access-token with a data command, or use `auth login --key -` for a personal API key.",
|
|
262
249
|
);
|
|
263
250
|
}
|
|
264
251
|
if (opts.app || opts.clientCredentials) {
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
ctx.options.apiKey !== undefined ||
|
|
268
|
-
opts.redirectUri ||
|
|
269
|
-
opts.browser === false
|
|
270
|
-
) {
|
|
271
|
-
throw usageError(
|
|
272
|
-
"--app cannot be combined with personal API-key or browser callback options.",
|
|
273
|
-
);
|
|
252
|
+
if (opts.key !== undefined || ctx.options.apiKey !== undefined) {
|
|
253
|
+
throw usageError("--app cannot be combined with --key or --api-key.");
|
|
274
254
|
}
|
|
275
|
-
if (opts.admin) throw usageError("App actors cannot request the admin scope.");
|
|
276
255
|
if (!opts.plaintext && !keyring())
|
|
277
256
|
throw new CliError(
|
|
278
257
|
"App authentication requires a system keyring; use --plaintext for explicit private-file storage.",
|
|
@@ -337,144 +316,64 @@ export function registerMeta(program: Command): void {
|
|
|
337
316
|
);
|
|
338
317
|
return;
|
|
339
318
|
}
|
|
340
|
-
if (
|
|
341
|
-
|
|
319
|
+
if (
|
|
320
|
+
opts.clientId !== undefined ||
|
|
321
|
+
opts.clientSecret !== undefined ||
|
|
322
|
+
opts.scope !== undefined ||
|
|
323
|
+
opts.readOnly
|
|
324
|
+
)
|
|
325
|
+
throw usageError(
|
|
326
|
+
"--client-id, --client-secret, --scope, and --read-only require --app or --client-credentials. Set personal API-key permissions in Linear's Security & Access settings.",
|
|
327
|
+
);
|
|
342
328
|
let key: string | undefined = selectLoginKey(opts.key, ctx.options.apiKey);
|
|
343
|
-
if (key
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
329
|
+
if (key === undefined && !ctx.isTTY)
|
|
330
|
+
throw usageError(
|
|
331
|
+
"Personal API-key login requires interactive input. Pipe a key to `linear auth login --key -`, or use --app for app credentials. To authenticate commands without saving credentials, set LINEAR_API_KEY or LINEAR_ACCESS_TOKEN.",
|
|
332
|
+
);
|
|
333
|
+
if (!opts.plaintext && !keyring())
|
|
334
|
+
throw new CliError(
|
|
335
|
+
"API-key login requires a system keyring; use --plaintext for explicit private-file storage.",
|
|
336
|
+
"runtime",
|
|
337
|
+
);
|
|
338
|
+
if (key === undefined) {
|
|
339
|
+
ctx.output.info(
|
|
340
|
+
"Create a personal API key at https://linear.app/settings/account/security. Choose its permissions and team access in Linear; workspace policy may restrict key creation.",
|
|
341
|
+
);
|
|
342
|
+
key = await authSecretPrompt(ctx, "Linear API key:", { required: true });
|
|
343
|
+
} else {
|
|
353
344
|
if (key === "-") key = readStdinSync();
|
|
354
345
|
else
|
|
355
346
|
ctx.output.warn(
|
|
356
347
|
"An API key on the command line is visible to other processes and shell history; prefer --key - (stdin).",
|
|
357
348
|
);
|
|
358
|
-
key = key.trim();
|
|
359
|
-
if (!key) throw usageError("No API key provided.");
|
|
360
|
-
const identity = await validateAuthCredential(key, ctx.options.workspace);
|
|
361
|
-
const { user: me, organization: org } = identity;
|
|
362
|
-
const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
|
|
363
|
-
plaintext: opts.plaintext === true,
|
|
364
|
-
});
|
|
365
|
-
const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
|
|
366
|
-
const where =
|
|
367
|
-
storage === "keychain"
|
|
368
|
-
? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
|
|
369
|
-
: `Credential saved to ${path} (plaintext, 0600).`;
|
|
370
|
-
ctx.output.emit(
|
|
371
|
-
{
|
|
372
|
-
success: true,
|
|
373
|
-
credentialType: "api-key",
|
|
374
|
-
workspace: org.urlKey,
|
|
375
|
-
user: me,
|
|
376
|
-
storage,
|
|
377
|
-
path,
|
|
378
|
-
projectConfigPath,
|
|
379
|
-
},
|
|
380
|
-
() =>
|
|
381
|
-
ctx.output.success(
|
|
382
|
-
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
383
|
-
),
|
|
384
|
-
);
|
|
385
|
-
return;
|
|
386
349
|
}
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (opts.readOnly && opts.admin) {
|
|
394
|
-
throw usageError("--read-only cannot be combined with --admin.");
|
|
395
|
-
}
|
|
396
|
-
const timeoutSeconds = Number(opts.timeout);
|
|
397
|
-
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
398
|
-
throw usageError("--timeout must be a positive number of seconds.");
|
|
399
|
-
}
|
|
400
|
-
const clientId = String(
|
|
401
|
-
opts.clientId ?? process.env.LINEAR_OAUTH_CLIENT_ID ?? DEFAULT_OAUTH_CLIENT_ID,
|
|
402
|
-
).trim();
|
|
403
|
-
const redirectUri = String(
|
|
404
|
-
opts.redirectUri ?? process.env.LINEAR_OAUTH_REDIRECT_URI ?? DEFAULT_OAUTH_REDIRECT_URI,
|
|
405
|
-
);
|
|
406
|
-
const scopes = opts.readOnly
|
|
407
|
-
? ["read"]
|
|
408
|
-
: opts.admin
|
|
409
|
-
? [...DEFAULT_OAUTH_SCOPES, "admin"]
|
|
410
|
-
: [...DEFAULT_OAUTH_SCOPES];
|
|
411
|
-
const pkce = createPkceRequest();
|
|
412
|
-
const callback = await startLoopbackCallback({
|
|
413
|
-
redirectUri,
|
|
414
|
-
state: pkce.state,
|
|
415
|
-
timeoutMs: timeoutSeconds * 1000,
|
|
350
|
+
key = key.trim();
|
|
351
|
+
if (!key) throw usageError("No API key provided.");
|
|
352
|
+
const identity = await validateAuthCredential(key, ctx.options.workspace);
|
|
353
|
+
const { user: me, organization: org } = identity;
|
|
354
|
+
const { path, storage, keyringLabel } = writeCredential(org.urlKey, key, {
|
|
355
|
+
plaintext: opts.plaintext === true,
|
|
416
356
|
});
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
});
|
|
438
|
-
const identity = await validateAuthCredential(
|
|
439
|
-
token.accessToken,
|
|
440
|
-
ctx.options.workspace,
|
|
441
|
-
"oauth-access-token",
|
|
442
|
-
);
|
|
443
|
-
const credential: OAuthUserCredential = {
|
|
444
|
-
version: 1,
|
|
445
|
-
kind: "oauth-user",
|
|
446
|
-
actor: "user",
|
|
447
|
-
...token,
|
|
448
|
-
clientId,
|
|
449
|
-
workspace: identity.organization,
|
|
450
|
-
user: identity.user,
|
|
451
|
-
};
|
|
452
|
-
const saved = writeOAuthCredential(credential);
|
|
453
|
-
const projectConfigPath = saveLoginProject(
|
|
454
|
-
ctx,
|
|
455
|
-
credential.workspace.urlKey,
|
|
456
|
-
opts.project !== false,
|
|
457
|
-
);
|
|
458
|
-
ctx.output.emit(
|
|
459
|
-
{
|
|
460
|
-
success: true,
|
|
461
|
-
credentialType: "oauth-user",
|
|
462
|
-
workspace: credential.workspace.urlKey,
|
|
463
|
-
user: credential.user,
|
|
464
|
-
storage: saved.storage,
|
|
465
|
-
scopes: credential.scopes,
|
|
466
|
-
expiresAt: new Date(credential.expiresAt).toISOString(),
|
|
467
|
-
path: saved.path,
|
|
468
|
-
projectConfigPath,
|
|
469
|
-
},
|
|
470
|
-
() =>
|
|
471
|
-
ctx.output.success(
|
|
472
|
-
`Authenticated as ${credential.user.name} <${credential.user.email}> for workspace '${credential.workspace.urlKey}'. OAuth credentials saved to the ${saved.keyringLabel}.${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
473
|
-
),
|
|
474
|
-
);
|
|
475
|
-
} finally {
|
|
476
|
-
await callback.close();
|
|
477
|
-
}
|
|
357
|
+
const projectConfigPath = saveLoginProject(ctx, org.urlKey, opts.project !== false);
|
|
358
|
+
const where =
|
|
359
|
+
storage === "keychain"
|
|
360
|
+
? `Credential saved to the ${keyringLabel}; ${path} records the workspace.`
|
|
361
|
+
: `Credential saved to ${path} (plaintext, 0600).`;
|
|
362
|
+
ctx.output.emit(
|
|
363
|
+
{
|
|
364
|
+
success: true,
|
|
365
|
+
credentialType: "api-key",
|
|
366
|
+
workspace: org.urlKey,
|
|
367
|
+
user: me,
|
|
368
|
+
storage,
|
|
369
|
+
path,
|
|
370
|
+
projectConfigPath,
|
|
371
|
+
},
|
|
372
|
+
() =>
|
|
373
|
+
ctx.output.success(
|
|
374
|
+
`Authenticated as ${me.name} <${me.email}> for workspace '${org.urlKey}'. ${where}${projectConfigPath ? ` Project workspace saved to ${projectConfigPath}.` : ""}`,
|
|
375
|
+
),
|
|
376
|
+
);
|
|
478
377
|
}),
|
|
479
378
|
);
|
|
480
379
|
|
package/src/config.ts
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* - Authentication secrets have a STRICTER boundary and are never read from a project file
|
|
15
15
|
* or from the reference CLI's global file (avoids committing secrets):
|
|
16
16
|
* Explicit `--access-token` / `--api-key` flags override their environment counterparts.
|
|
17
|
-
* Invocation OAuth access tokens are stateless (`LINEAR_ACCESS_TOKEN`
|
|
18
|
-
* OAuth sessions, app client credentials, and API keys may come from the OS keyring.
|
|
19
|
-
*
|
|
20
|
-
* browser OAuth stays keyring-only. Environment client credentials stay invocation-scoped.
|
|
17
|
+
* Invocation OAuth access tokens are stateless (`LINEAR_ACCESS_TOKEN` / --access-token).
|
|
18
|
+
* Existing OAuth user sessions, app client credentials, and API keys may come from the OS keyring.
|
|
19
|
+
* New login requires explicit --plaintext to use a private user config file;
|
|
20
|
+
* existing browser OAuth stays keyring-only. Environment client credentials stay invocation-scoped.
|
|
21
21
|
* A project `workspace` may select which already-stored credential to use;
|
|
22
22
|
* it can never provide or override the secret itself.
|
|
23
23
|
*
|
|
@@ -1443,7 +1443,7 @@ export function writeOAuthCredential(credential: OAuthUserCredential): WriteOAut
|
|
|
1443
1443
|
const backend = keyring();
|
|
1444
1444
|
if (!backend) {
|
|
1445
1445
|
throw new CliError(
|
|
1446
|
-
"
|
|
1446
|
+
"Stored OAuth sessions require a usable system keyring; use `linear auth login --key - --plaintext` for a personal API key on this platform.",
|
|
1447
1447
|
"runtime",
|
|
1448
1448
|
);
|
|
1449
1449
|
}
|
|
@@ -1540,7 +1540,7 @@ export function probeKeyringCredential(slug: string): KeyringCredentialProbe {
|
|
|
1540
1540
|
const backend = keyring();
|
|
1541
1541
|
if (!backend) {
|
|
1542
1542
|
throw new CliError(
|
|
1543
|
-
`No usable system keyring is available, so workspace '${sanitize(slug)}' cannot be adopted. Run \`linear auth login --key
|
|
1543
|
+
`No usable system keyring is available, so workspace '${sanitize(slug)}' cannot be adopted. Run \`linear auth login --key - --plaintext\` to read the key from stdin into the private user config instead.`,
|
|
1544
1544
|
"runtime",
|
|
1545
1545
|
);
|
|
1546
1546
|
}
|
package/src/lib/authorization.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function enrichAuthorizationError(error: unknown, config: ResolvedConfig)
|
|
|
76
76
|
const capabilities = authorizationCapabilities(config);
|
|
77
77
|
const remediation =
|
|
78
78
|
capabilities.credentialType === "oauth-user"
|
|
79
|
-
? "
|
|
79
|
+
? "Create a personal API key with admin permission in Linear's Security & Access settings, then run `linear auth login`; member admin status alone is insufficient."
|
|
80
80
|
: "Use a credential authorized for the admin scope; member admin status alone is insufficient.";
|
|
81
81
|
return new CliError(
|
|
82
82
|
normalized.message,
|
package/src/lib/keyring.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* Backends are synchronous (spawnSync) because config resolution is
|
|
11
11
|
* synchronous and runs before any command body. Only macOS Keychain and the
|
|
12
12
|
* Linux Secret Service (`secret-tool`) are implemented; everywhere else — and
|
|
13
|
-
* wherever the tool is missing — `keyring()` returns null
|
|
14
|
-
*
|
|
13
|
+
* wherever the tool is missing — `keyring()` returns null. Login requires explicit
|
|
14
|
+
* --plaintext for private-file storage; low-level legacy API-key writers can fall back.
|
|
15
15
|
*
|
|
16
16
|
* The secret never travels on argv (visible to every process via `ps`): on
|
|
17
17
|
* macOS it goes through `security -i`, which reads its command line from stdin;
|
package/src/lib/output-shapes.ts
CHANGED
|
@@ -391,8 +391,6 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
|
|
|
391
391
|
workspace: "string",
|
|
392
392
|
user: { id: "string", name: "string", email: "string" },
|
|
393
393
|
storage: "string",
|
|
394
|
-
"scopes?": ["string"],
|
|
395
|
-
"expiresAt?": "string",
|
|
396
394
|
path: "string",
|
|
397
395
|
projectConfigPath: "string|null",
|
|
398
396
|
}),
|