linear-sdk-cli 0.7.1 → 0.8.0
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 +17 -0
- package/COVERAGE.md +9 -5
- package/README.md +62 -11
- package/package.json +6 -6
- package/skills/linear-sdk-cli/SKILL.md +38 -11
- package/skills/linear-sdk-cli/references/auth.md +15 -11
- package/src/bin/linear.ts +6 -7
- package/src/cli.ts +88 -12
- package/src/client.ts +46 -18
- package/src/commands/issue.ts +7 -1
- package/src/commands/meta.ts +113 -15
- package/src/config.ts +276 -19
- package/src/index.ts +2 -0
- package/src/lib/authorization.ts +7 -4
- package/src/lib/errors.ts +2 -0
- package/src/oauth.ts +32 -0
- package/src/output/format.ts +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ 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.8.0](https://github.com/zhendalf/linear-sdk-cli/compare/v0.7.2...v0.8.0) (2026-09-29)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **auth:** persist app identity credentials ([#84](https://github.com/zhendalf/linear-sdk-cli/issues/84)) ([7d7ec19](https://github.com/zhendalf/linear-sdk-cli/commit/7d7ec19cb94cf43fb524dc1dd807b4d7d1c67515))
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
- improve CLI onboarding and JSON error consistency ([#59](https://github.com/zhendalf/linear-sdk-cli/issues/59)) ([4444c38](https://github.com/zhendalf/linear-sdk-cli/commit/4444c38f05bc4f442da1e44582eafb35b8e342d1))
|
|
16
|
+
|
|
17
|
+
## [0.7.2](https://github.com/zhendalf/linear-sdk-cli/compare/v0.7.1...v0.7.2) (2026-09-28)
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
- **deps:** complete SDK and formatter upgrades ([#82](https://github.com/zhendalf/linear-sdk-cli/issues/82)) ([9ba7d6f](https://github.com/zhendalf/linear-sdk-cli/commit/9ba7d6ff77a1d7e9d63bc1d4e4957aed5d6a1ed4))
|
|
22
|
+
- **deps:** update compatible dependency versions ([6e3e462](https://github.com/zhendalf/linear-sdk-cli/commit/6e3e46245088eb923886f6c29c3f8ec7d463ffa6))
|
|
23
|
+
|
|
7
24
|
## [0.7.1](https://github.com/zhendalf/linear-sdk-cli/compare/v0.7.0...v0.7.1) (2026-09-20)
|
|
8
25
|
|
|
9
26
|
### Bug Fixes
|
package/COVERAGE.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# SDK Coverage Audit
|
|
2
2
|
|
|
3
|
-
Generated by `bun run audit:coverage`. Total LinearClient members: **
|
|
3
|
+
Generated by `bun run audit:coverage`. Total LinearClient members: **480**.
|
|
4
4
|
|
|
5
5
|
| Status | Count | Meaning |
|
|
6
6
|
| -------- | ----- | ----------------------------------------------- |
|
|
7
7
|
| curated | 107 | Has a first-class CLI command |
|
|
8
|
-
| raw-only |
|
|
9
|
-
| excluded |
|
|
8
|
+
| raw-only | 145 | Reachable via `linear api` (no bespoke command) |
|
|
9
|
+
| excluded | 228 | Out of scope (admin/integration/SDK plumbing) |
|
|
10
10
|
|
|
11
11
|
Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
12
12
|
100% reachable through the raw GraphQL escape hatch; they simply lack a tailored command.
|
|
@@ -125,7 +125,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
125
125
|
- `workflowState` — state
|
|
126
126
|
- `workflowStates` — state
|
|
127
127
|
|
|
128
|
-
## Excluded (
|
|
128
|
+
## Excluded (228)
|
|
129
129
|
|
|
130
130
|
- `agentActivities` — specialized/raw api only
|
|
131
131
|
- `agentActivity` — specialized/raw api only
|
|
@@ -235,7 +235,10 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
235
235
|
- `integrationGithubImportRefresh` — integration/import (raw api only)
|
|
236
236
|
- `integrationGithubRemoveCodeAccess` — integration/import (raw api only)
|
|
237
237
|
- `integrationGitlabConnect` — integration/import (raw api only)
|
|
238
|
+
- `integrationGitlabRotate` — integration/import (raw api only)
|
|
238
239
|
- `integrationGitlabTestConnection` — integration/import (raw api only)
|
|
240
|
+
- `integrationGitlabUpdateRotationSettings` — integration/import (raw api only)
|
|
241
|
+
- `integrationGitlabUpdateToken` — integration/import (raw api only)
|
|
239
242
|
- `integrationGong` — integration/import (raw api only)
|
|
240
243
|
- `integrationGoogleSheets` — integration/import (raw api only)
|
|
241
244
|
- `integrationHasScopes` — integration/import (raw api only)
|
|
@@ -353,7 +356,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
353
356
|
- `userUnlinkFromIdentityProvider` — admin/enterprise (raw api only)
|
|
354
357
|
- `userViewPreferences` — specialized/raw api only
|
|
355
358
|
|
|
356
|
-
## Raw-only (
|
|
359
|
+
## Raw-only (145)
|
|
357
360
|
|
|
358
361
|
<details><summary>Show</summary>
|
|
359
362
|
|
|
@@ -405,6 +408,7 @@ Every member is classified — CI fails otherwise. `raw-only` members are still
|
|
|
405
408
|
- `deleteTeamCycles`
|
|
406
409
|
- `deleteTeamKey`
|
|
407
410
|
- `deleteTeamMembership`
|
|
411
|
+
- `dependencyPackageMetadata`
|
|
408
412
|
- `documentContentHistory`
|
|
409
413
|
- `emailIntakeAddress`
|
|
410
414
|
- `emailIntakeAddressRefreshSesDomainStatus`
|
package/README.md
CHANGED
|
@@ -121,9 +121,10 @@ branch, so most issue commands let you omit the id entirely.
|
|
|
121
121
|
For a person at a terminal, `linear auth login` uses browser Authorization Code + PKCE and the
|
|
122
122
|
default `actor=user`. Existing personal API keys and invocation-scoped OAuth access tokens remain
|
|
123
123
|
supported. Explicit `--api-key` or `--access-token` flags override environment credentials;
|
|
124
|
-
without a credential flag the CLI reads `LINEAR_API_KEY
|
|
125
|
-
|
|
126
|
-
actor.
|
|
124
|
+
without a credential flag the CLI reads `LINEAR_API_KEY`, `LINEAR_ACCESS_TOKEN`, or the pair
|
|
125
|
+
`LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET`. Mixing credential types in the environment fails
|
|
126
|
+
rather than silently choosing a Linear actor. Client credentials must be supplied together;
|
|
127
|
+
explicit credential flags still override all environment credentials.
|
|
127
128
|
|
|
128
129
|
> **Credential trust boundary.** A secret is **never** read from a project-local
|
|
129
130
|
> `.linear.toml` — only non-secret settings live there — so a key can't be committed by accident.
|
|
@@ -159,7 +160,7 @@ store lock. `--admin` explicitly adds the admin scope; it is never implicit.
|
|
|
159
160
|
`--no-browser` is useful over SSH only when the machine running the CLI is also reachable as the
|
|
160
161
|
browser's loopback host; Linear does not currently document a device-code flow. Override a
|
|
161
162
|
separately registered public client's identity with `LINEAR_OAUTH_CLIENT_ID` and its exact callback
|
|
162
|
-
with `LINEAR_OAUTH_REDIRECT_URI`. No OAuth client secret is embedded
|
|
163
|
+
with `LINEAR_OAUTH_REDIRECT_URI`. No OAuth client secret is embedded or used for browser login.
|
|
163
164
|
|
|
164
165
|
For a personal API key, `auth login --key -` reads stdin, validates the viewer/workspace, stores the
|
|
165
166
|
key in the system keyring by default, and writes only a marker to the config. `--plaintext` is an
|
|
@@ -209,8 +210,7 @@ await child.exited;
|
|
|
209
210
|
|
|
210
211
|
Call `tokens.invalidate()` (or `getAccessToken({ forceRefresh: true })`) before one bounded retry
|
|
211
212
|
when Linear rejects the token with `401`. Client-credentials tokens do not have refresh tokens;
|
|
212
|
-
renewal is another authenticated exchange.
|
|
213
|
-
shared token cache or central broker rather than minting a new 30-day token on every invocation.
|
|
213
|
+
renewal is another authenticated exchange. For CI and scheduled automation, request a fresh token at the start of each run, as Linear recommends.
|
|
214
214
|
|
|
215
215
|
For an app installed into multiple workspaces, use Authorization Code with `actor=app` instead and
|
|
216
216
|
store each installation's rotating refresh token encrypted in the host. That hosted app lifecycle
|
|
@@ -223,13 +223,64 @@ webhooks. App actors cannot request the `admin` scope. See Linear's
|
|
|
223
223
|
[app-actor authorization](https://linear.app/developers/oauth-actor-authorization), and
|
|
224
224
|
[agent guide](https://linear.app/developers/agents).
|
|
225
225
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
226
|
+
For persistent app identity on a local machine, enable client credentials in your Linear OAuth
|
|
227
|
+
app, then sign in once:
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
# Supply the secret through stdin from your secret manager.
|
|
231
|
+
linear auth login --app --client-id YOUR_CLIENT_ID --client-secret -
|
|
232
|
+
linear auth status --json
|
|
233
|
+
linear issue list --json
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`--client-credentials` is an alias for `--app`. `--client-secret -` reads stdin until EOF;
|
|
237
|
+
otherwise the client ID and secret default to `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET`.
|
|
238
|
+
The client ID also accepts the existing `LINEAR_OAUTH_CLIENT_ID` fallback during explicit login.
|
|
239
|
+
Login validates the token and workspace before storing credentials.
|
|
240
|
+
|
|
241
|
+
By default, app credentials go into an isolated `app:<workspace>` OS keyring account; config files
|
|
242
|
+
contain only a marker. On headless hosts, explicitly select private-file storage:
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
linear auth login --client-credentials --plaintext --client-id YOUR_CLIENT_ID --client-secret -
|
|
246
|
+
# In a new shell, under the same user and config directory:
|
|
247
|
+
linear auth status --json
|
|
248
|
+
linear issue list --json
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`--plaintext` stores client credentials in `$XDG_CONFIG_HOME/linear/config.toml` (or
|
|
252
|
+
`~/.config/linear/config.toml`) with `0600` permissions, and makes its `linear` directory private
|
|
253
|
+
with `0700` permissions. There is no automatic plaintext fallback for app credentials. The directory
|
|
254
|
+
must be on persistent storage to survive container replacement, and agent subprocesses must have
|
|
255
|
+
access to the same user config. Switching storage removes the previous copy of the app credentials;
|
|
256
|
+
replacing an existing keyring profile requires access to that keyring.
|
|
257
|
+
|
|
258
|
+
Use `--read-only` to request only `read`, or `--scope read,comments:create` for custom app scopes
|
|
259
|
+
(default: `read,write`). App actors cannot request `--admin` or an `admin` scope. Linear must have
|
|
260
|
+
client credentials enabled for the app. Changing scopes can revoke existing app tokens, so use
|
|
261
|
+
the same scopes across hosts.
|
|
262
|
+
|
|
263
|
+
For environment-only authentication, supply `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET` from
|
|
264
|
+
your platform's secret manager, then invoke `linear` directly. Optional `LINEAR_CLIENT_SCOPES`
|
|
265
|
+
configures the comma-separated scopes. This mode overrides stored profiles and never writes
|
|
266
|
+
credentials or tokens to config or cache files. Only an explicit `auth login --client-credentials`
|
|
267
|
+
persists these environment credentials.
|
|
268
|
+
|
|
269
|
+
Each invocation obtains a fresh token on its first API request, caches it in memory, renews before
|
|
270
|
+
expiry, and retries an API request once with a new token after an authentication failure. This
|
|
271
|
+
covers both SDK calls and raw GraphQL queries. Concurrent processes obtain their own tokens and do
|
|
272
|
+
not share a disk token cache; within one process concurrent token exchanges are coalesced. No access
|
|
273
|
+
or refresh token is persisted for app identity. `auth status` reports credential availability
|
|
274
|
+
without making a network request, uses credential type `oauth-app` with source `user`, `keychain`,
|
|
275
|
+
or `env`, and leaves `expiresAt` null because it has no stored token. It does not prove that Linear
|
|
276
|
+
still accepts the client secret. Environment scopes describe configured requests, not independently
|
|
277
|
+
verified grants. `auth token` continues to export personal API keys only. `auth logout` removes
|
|
278
|
+
local app credentials and restores an existing personal API-key profile if present; it does not
|
|
279
|
+
disable the app in Linear. Environment-only credentials must be unset separately.
|
|
229
280
|
|
|
230
281
|
### Multiple workspaces
|
|
231
282
|
|
|
232
|
-
Credentials are stored per **workspace slug**. Browser and API-key login
|
|
283
|
+
Credentials are stored per **workspace slug**. Browser, app, and API-key login derive the slug from
|
|
233
284
|
the authenticated organization; `--workspace` validates that the result matches:
|
|
234
285
|
|
|
235
286
|
```sh
|
|
@@ -243,7 +294,7 @@ linear --workspace other-org issue list # use a specific workspace for one co
|
|
|
243
294
|
linear auth logout --workspace acme # revoke OAuth, then remove one credential
|
|
244
295
|
```
|
|
245
296
|
|
|
246
|
-
Successful browser 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.
|
|
297
|
+
Successful browser, 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.
|
|
247
298
|
|
|
248
299
|
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.
|
|
249
300
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linear-sdk-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Ergonomic command-line interface for Linear, built on @linear/sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -71,18 +71,18 @@
|
|
|
71
71
|
},
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"@inquirer/prompts": "^8.7.2",
|
|
74
|
-
"@linear/sdk": "^
|
|
74
|
+
"@linear/sdk": "^97.0.0",
|
|
75
75
|
"commander": "^15.0.0",
|
|
76
76
|
"graphql": "^17.0.2",
|
|
77
77
|
"picocolors": "^1.1.1",
|
|
78
|
-
"smol-toml": "^1.
|
|
78
|
+
"smol-toml": "^1.9.0"
|
|
79
79
|
},
|
|
80
80
|
"packageManager": "bun@1.4.2",
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@types/bun": "^1.4.2",
|
|
83
|
-
"@types/node": "^26.6.
|
|
84
|
-
"oxfmt": "^0.
|
|
85
|
-
"oxlint": "^1.
|
|
83
|
+
"@types/node": "^26.6.3",
|
|
84
|
+
"oxfmt": "^0.71.0",
|
|
85
|
+
"oxlint": "^1.86.0",
|
|
86
86
|
"typescript": "^7.0.2"
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -66,17 +66,43 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
|
|
|
66
66
|
the 30-day app token in memory, renews before expiry, coalesces concurrent exchanges, and
|
|
67
67
|
supports invalidation or forced renewal for one bounded retry after a `401`. Keep the client
|
|
68
68
|
secret in the host's secret manager and inject only `LINEAR_ACCESS_TOKEN` into CLI children.
|
|
69
|
-
|
|
70
|
-
token for every command.
|
|
69
|
+
For CI and scheduled automation, request a fresh token at the start of each run.
|
|
71
70
|
|
|
72
|
-
3.
|
|
71
|
+
3. **Persistent app identity** with client credentials (enable this grant in the Linear app):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
linear auth login --app --client-id YOUR_CLIENT_ID --client-secret -
|
|
75
|
+
linear auth status --json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`--client-credentials` is an alias for `--app`. Read the secret from stdin until EOF, or
|
|
79
|
+
explicitly log in using `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET`. The default store is
|
|
80
|
+
the OS keyring; on headless hosts use explicit `--plaintext` to store client credentials in
|
|
81
|
+
the user config (`0600`, containing directory `0700`). Keep that directory on persistent
|
|
82
|
+
storage in containers and make it accessible to agent subprocesses under the same user.
|
|
83
|
+
There is no automatic plaintext fallback.
|
|
84
|
+
|
|
85
|
+
Commands obtain a fresh token per invocation and renew before expiry or once on authentication
|
|
86
|
+
failure, including raw GraphQL requests. Tokens remain in memory. Use `--read-only` for read
|
|
87
|
+
access or `--scope read,comments:create` for custom scopes (default `read,write`); app actors
|
|
88
|
+
cannot request `admin`. Keep scopes consistent across hosts to avoid revoking existing tokens.
|
|
89
|
+
Status reports `oauth-app`, the source, and configured scopes without validating credentials
|
|
90
|
+
remotely; expiry is null because there is no persisted token. Logout removes local app
|
|
91
|
+
credentials without disabling the app. `auth token` still exports personal API keys only.
|
|
92
|
+
|
|
93
|
+
Environment-only app auth also works directly: supply `LINEAR_CLIENT_ID` and
|
|
94
|
+
`LINEAR_CLIENT_SECRET` together, optionally `LINEAR_CLIENT_SCOPES`, then run `linear`.
|
|
95
|
+
It writes no config or cache files. Mixing these with environment API keys/access tokens
|
|
96
|
+
is rejected; explicit `--api-key` or `--access-token` flags override environment credentials.
|
|
97
|
+
|
|
98
|
+
4. **`--api-key <key>`** flag (per invocation) or **`LINEAR_API_KEY`** env var — best
|
|
73
99
|
for CI and ephemeral agent runs:
|
|
74
100
|
|
|
75
101
|
```bash
|
|
76
102
|
LINEAR_API_KEY=lin_api_... linear whoami --json
|
|
77
103
|
```
|
|
78
104
|
|
|
79
|
-
|
|
105
|
+
5. **Stored personal API-key credentials** via the explicit compatibility path:
|
|
80
106
|
|
|
81
107
|
```bash
|
|
82
108
|
printf '%s\n' "$LINEAR_API_KEY" | linear auth login --key -
|
|
@@ -85,7 +111,7 @@ The CLI supports distinct human, hosted-app, and personal-key lifecycles:
|
|
|
85
111
|
Passing `--key <value>` also works but exposes it in argv and produces a warning. API keys may
|
|
86
112
|
use `--plaintext`; browser OAuth is always keyring-only.
|
|
87
113
|
|
|
88
|
-
|
|
114
|
+
6. **Multiple workspaces** — store several OAuth or API-key credentials and select one per call with
|
|
89
115
|
`--workspace <slug>`; set a default with `linear auth default <slug>`:
|
|
90
116
|
|
|
91
117
|
```bash
|
|
@@ -127,11 +153,12 @@ stderr), so it is always safe to pipe into `jq`. The envelope is a stable contra
|
|
|
127
153
|
`--debug` the extra detail rides _inside_ that object as `error.detail`, so `--json --debug`
|
|
128
154
|
stays parseable.
|
|
129
155
|
|
|
130
|
-
|
|
131
|
-
`
|
|
132
|
-
`
|
|
133
|
-
|
|
134
|
-
`
|
|
156
|
+
Issue list rows use compact relations: `state: {name,type}`, `assignee: {displayName}`,
|
|
157
|
+
`project: {name}`, and `labels: string[]`. They do not include `team` or `parent`.
|
|
158
|
+
`issue view` returns richer relations with IDs, including `team`, `parent`, and label
|
|
159
|
+
objects. Use `issue view <identifier> --json` when you need those relation IDs;
|
|
160
|
+
inspect `linear commands issue list --json` or `linear commands issue view --json`
|
|
161
|
+
for the exact shape. Both include `archivedAt` and `trashed`.
|
|
135
162
|
|
|
136
163
|
```bash
|
|
137
164
|
linear issue list --json | jq -r '.[].identifier'
|
|
@@ -612,7 +639,7 @@ GRAPHQL
|
|
|
612
639
|
linear api --query-file q.graphql --vars-file vars.json --paginate
|
|
613
640
|
|
|
614
641
|
# Pipe data straight to jq (status notes go to stderr)
|
|
615
|
-
linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.
|
|
642
|
+
linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.issues.nodes[].title'
|
|
616
643
|
```
|
|
617
644
|
|
|
618
645
|
`api` flags: `--query-file <path>` (`-` = stdin), `--var <k=v>` (repeatable string
|
|
@@ -75,17 +75,21 @@ Authenticate and select the workspace for this project
|
|
|
75
75
|
linear auth login [options]
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
| Option
|
|
79
|
-
|
|
|
80
|
-
| `--no-project`
|
|
81
|
-
| `--key <key>`
|
|
82
|
-
| `--plaintext`
|
|
83
|
-
| `--no-browser`
|
|
84
|
-
| `--read-only`
|
|
85
|
-
| `--admin`
|
|
86
|
-
| `--timeout <seconds>`
|
|
87
|
-
| `--
|
|
88
|
-
| `--
|
|
78
|
+
| Option | Description |
|
|
79
|
+
| -------------------------- | ----------------------------------------------------------------------- |
|
|
80
|
+
| `--no-project` | save credentials without changing the project workspace |
|
|
81
|
+
| `--key <key>` | use a personal API key ('-' reads it from stdin) |
|
|
82
|
+
| `--plaintext` | Store API-key or app credentials in the private user config file (0600) |
|
|
83
|
+
| `--no-browser` | print the authorization URL instead of opening it |
|
|
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 |
|
|
87
|
+
| `--app` | sign in as an app using client credentials |
|
|
88
|
+
| `--client-credentials` | alias for --app |
|
|
89
|
+
| `--scope <scopes>` | comma-separated app scopes (default: read,write) |
|
|
90
|
+
| `--client-secret <secret>` | app client secret ('-' reads stdin; defaults to LINEAR_CLIENT_SECRET) |
|
|
91
|
+
| `--client-id <id>` | OAuth client ID (app: LINEAR_CLIENT_ID; browser: packaged CLI app) |
|
|
92
|
+
| `--redirect-uri <uri>` | registered HTTP loopback callback URI |
|
|
89
93
|
|
|
90
94
|
**Output (`--json`)**: a receipt object
|
|
91
95
|
|
package/src/bin/linear.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { CommanderError } from "commander";
|
|
9
|
-
import { createProgram,
|
|
9
|
+
import { createProgram, errorGlobalOptions, usageHint, suppressedHelp } from "../cli.js";
|
|
10
10
|
import { Output } from "../output/format.js";
|
|
11
11
|
import { CliError, ExitCode, normalizeError } from "../lib/errors.js";
|
|
12
12
|
import { commandPath } from "../lib/options.js";
|
|
@@ -31,6 +31,8 @@ program.parseAsync(process.argv).catch((err) => {
|
|
|
31
31
|
if (err.exitCode === 0) process.exit(0);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const globals = errorGlobalOptions(program, process.argv.slice(2));
|
|
35
|
+
|
|
34
36
|
// A group invoked bare (`linear notification`) is commander asking to show
|
|
35
37
|
// that group's help and exit non-zero. It wrote the help to the stderr we
|
|
36
38
|
// route through here, so print exactly that for a human — an envelope
|
|
@@ -38,7 +40,7 @@ program.parseAsync(process.argv).catch((err) => {
|
|
|
38
40
|
// usage error under --json.
|
|
39
41
|
if (err instanceof CommanderError && err.code === "commander.help") {
|
|
40
42
|
const help = suppressedHelp(program);
|
|
41
|
-
if (help &&
|
|
43
|
+
if (help && globals.json !== true) {
|
|
42
44
|
process.stderr.write(help.text);
|
|
43
45
|
process.exit(ExitCode.Usage);
|
|
44
46
|
}
|
|
@@ -46,11 +48,8 @@ program.parseAsync(process.argv).catch((err) => {
|
|
|
46
48
|
|
|
47
49
|
const cliError = err instanceof CommanderError ? fromCommander(err) : normalizeError(err);
|
|
48
50
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// reaches the envelope. Errors go to stderr, so that stream's TTY-ness decides
|
|
52
|
-
// colour, not stdout's.
|
|
53
|
-
const globals = parsedGlobalOptions(program);
|
|
51
|
+
// Recovered flags include trailing options after a value-validation failure.
|
|
52
|
+
// Errors go to stderr, so that stream determines color.
|
|
54
53
|
const json = globals.json === true;
|
|
55
54
|
const out = new Output({
|
|
56
55
|
json,
|
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Kept free of side effects so it can be imported by tests and the bin entry.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { Command } from "commander";
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
7
|
import { addGlobalOptions, globalOptionKeys, unknownCommand, commandPath } from "./lib/options.js";
|
|
8
8
|
import { registerMeta } from "./commands/meta.js";
|
|
9
9
|
import { registerApi } from "./commands/api.js";
|
|
@@ -57,6 +57,21 @@ export function createProgram(): Command {
|
|
|
57
57
|
// 0 arguments but got 2: issues, list." — true, and no help at all.
|
|
58
58
|
.allowExcessArguments();
|
|
59
59
|
|
|
60
|
+
program.addHelpText(
|
|
61
|
+
"after",
|
|
62
|
+
[
|
|
63
|
+
"",
|
|
64
|
+
"Start here:",
|
|
65
|
+
" linear auth status # check your connection",
|
|
66
|
+
" linear auth login # connect if needed",
|
|
67
|
+
" linear team list # find your team key",
|
|
68
|
+
" linear issue list --assignee me # find your work",
|
|
69
|
+
" linear issue view TES-42 # read an issue",
|
|
70
|
+
" linear issue list --help # discover filters and examples",
|
|
71
|
+
" linear commands issue list --json # inspect options and JSON fields",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
);
|
|
74
|
+
|
|
60
75
|
addGlobalOptions(program);
|
|
61
76
|
|
|
62
77
|
// Phase 0: meta + escape hatch + completion.
|
|
@@ -140,17 +155,9 @@ export function createProgram(): Command {
|
|
|
140
155
|
* The global options as commander actually parsed them, read back off the
|
|
141
156
|
* command tree after `parseAsync` has thrown.
|
|
142
157
|
*
|
|
143
|
-
* The error boundary has no Context to ask
|
|
144
|
-
* before
|
|
145
|
-
*
|
|
146
|
-
* short flags (`-jq`) or any other spelling commander accepts, so `linear issue
|
|
147
|
-
* view NOPE-1 -j` printed a plaintext error while `--json` printed the
|
|
148
|
-
* envelope: an unparseable error stream for exactly the callers the envelope
|
|
149
|
-
* exists for. Commander has already parsed whatever it got to before failing
|
|
150
|
-
* (a bad option is reported only after the whole argument list is scanned), so
|
|
151
|
-
* the boundary reads that instead of parsing again. Every global is registered
|
|
152
|
-
* on every command and stored under the same key, so the flag is found
|
|
153
|
-
* wherever on the command path it was given.
|
|
158
|
+
* The error boundary has no Context to ask because an option parser may fail
|
|
159
|
+
* before an action runs. Read explicitly supplied globals across the tree;
|
|
160
|
+
* errorGlobalOptions supplements these when trailing flags were not reached.
|
|
154
161
|
*/
|
|
155
162
|
export function parsedGlobalOptions(program: Command): GlobalOptions {
|
|
156
163
|
const keys = globalOptionKeys();
|
|
@@ -165,6 +172,75 @@ export function parsedGlobalOptions(program: Command): GlobalOptions {
|
|
|
165
172
|
return merged as GlobalOptions;
|
|
166
173
|
}
|
|
167
174
|
|
|
175
|
+
/** Recover output flags after a value parser stops before reaching trailing flags.
|
|
176
|
+
* Parse a side-effect-free copy of the grammar: no actions, hooks, validation,
|
|
177
|
+
* credentials, or API calls. Commander still handles option values, clusters,
|
|
178
|
+
* aliases and `--`, so literal body text cannot turn on JSON mode.
|
|
179
|
+
*/
|
|
180
|
+
export function errorGlobalOptions(program: Command, args: string[]): GlobalOptions {
|
|
181
|
+
class RecoveryOption extends Option {
|
|
182
|
+
private readonly key: string;
|
|
183
|
+
|
|
184
|
+
constructor(source: Option) {
|
|
185
|
+
super(source.flags, source.description);
|
|
186
|
+
this.negate = source.negate;
|
|
187
|
+
this.key = source.attributeName();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override attributeName(): string {
|
|
191
|
+
return this.key;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const copy = (source: Command): Command => {
|
|
196
|
+
const cmd = new Command(source.name())
|
|
197
|
+
.aliases(source.aliases())
|
|
198
|
+
.allowExcessArguments()
|
|
199
|
+
.allowUnknownOption()
|
|
200
|
+
.helpOption(false)
|
|
201
|
+
.exitOverride()
|
|
202
|
+
.configureOutput({ writeOut: () => {}, writeErr: () => {} })
|
|
203
|
+
.action(() => {});
|
|
204
|
+
for (const option of source.options) {
|
|
205
|
+
// Preserve custom storage keys and negation semantics (notably the
|
|
206
|
+
// NoFlagOption globals behind --no-ansi / --no-color / --no-input).
|
|
207
|
+
cmd.addOption(new RecoveryOption(option));
|
|
208
|
+
}
|
|
209
|
+
for (const child of source.commands) cmd.addCommand(copy(child));
|
|
210
|
+
return cmd;
|
|
211
|
+
};
|
|
212
|
+
const grammar = copy(program).enablePositionalOptions();
|
|
213
|
+
try {
|
|
214
|
+
grammar.parse(args, { from: "user" });
|
|
215
|
+
} catch {
|
|
216
|
+
// A malformed or missing option value may still stop parsing; retain
|
|
217
|
+
// whatever was read and recover the boundary-only booleans below.
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// An output flag can itself be consumed as the malformed value of the option
|
|
221
|
+
// before it (`--limit --json`). Recover only the booleans used by the error
|
|
222
|
+
// boundary, stopping at `--` and ignoring `--flag=<literal>` values. A short
|
|
223
|
+
// cluster counts only when every character is one of our boolean aliases.
|
|
224
|
+
const boundary: GlobalOptions = {};
|
|
225
|
+
for (const arg of args) {
|
|
226
|
+
if (arg === "--") break;
|
|
227
|
+
if (arg === "--json") boundary.json = true;
|
|
228
|
+
else if (arg === "--quiet") boundary.quiet = true;
|
|
229
|
+
else if (arg === "--debug") boundary.debug = true;
|
|
230
|
+
else if (arg === "--no-ansi" || arg === "--no-color") boundary.noAnsi = true;
|
|
231
|
+
else if (/^-[jq]+$/.test(arg)) {
|
|
232
|
+
if (arg.includes("j")) boundary.json = true;
|
|
233
|
+
if (arg.includes("q")) boundary.quiet = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
...parsedGlobalOptions(grammar),
|
|
239
|
+
...parsedGlobalOptions(program),
|
|
240
|
+
...boundary,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
168
244
|
function applyGlobalOptionsToAll(cmd: Command): void {
|
|
169
245
|
for (const sub of cmd.commands) {
|
|
170
246
|
// addGlobalOptions skips any global the command already declares itself, so
|
package/src/client.ts
CHANGED
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { LinearClient } from "@linear/sdk";
|
|
6
6
|
import { rotateOAuthCredential, type ResolvedConfig } from "./config.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ClientCredentialsTokenProvider,
|
|
9
|
+
OAuthUserTokenProvider,
|
|
10
|
+
type GetAccessTokenOptions,
|
|
11
|
+
} from "./oauth.js";
|
|
8
12
|
import { authError, normalizeError, CliError } from "./lib/errors.js";
|
|
9
13
|
|
|
10
14
|
export function createClient(config: ResolvedConfig): LinearClient {
|
|
15
|
+
if (config.appCredential) return createRefreshingAppClient(config);
|
|
11
16
|
if (config.oauthCredential) {
|
|
12
17
|
return createRefreshingOAuthClient(config);
|
|
13
18
|
}
|
|
@@ -27,21 +32,56 @@ export function createClient(config: ResolvedConfig): LinearClient {
|
|
|
27
32
|
return new LinearClient({ apiKey: config.apiKey });
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
/** App credentials obtain a new token each invocation and renew before expiry or once on 401. */
|
|
36
|
+
function createRefreshingAppClient(config: ResolvedConfig): LinearClient {
|
|
37
|
+
const provider = new ClientCredentialsTokenProvider(config.appCredential!);
|
|
38
|
+
return installTokenRenewal(
|
|
39
|
+
new LinearClient({ accessToken: "pending-app-token" }),
|
|
40
|
+
async (options) => (await provider.getAccessToken(options)).accessToken,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Both generated SDK requests and raw queries must cross the same token boundary. */
|
|
45
|
+
function installTokenRenewal(
|
|
46
|
+
client: LinearClient,
|
|
47
|
+
getAccessToken: (options?: GetAccessTokenOptions) => Promise<string>,
|
|
48
|
+
): LinearClient {
|
|
49
|
+
const run = async <T>(request: () => Promise<T>): Promise<T> => {
|
|
50
|
+
client.client.setHeader("Authorization", `Bearer ${await getAccessToken()}`);
|
|
51
|
+
try {
|
|
52
|
+
return await request();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (!isAuthenticationFailure(err)) throw err;
|
|
55
|
+
client.client.setHeader(
|
|
56
|
+
"Authorization",
|
|
57
|
+
`Bearer ${await getAccessToken({ forceRefresh: true })}`,
|
|
58
|
+
);
|
|
59
|
+
return request();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const request = client.client.request.bind(client.client);
|
|
63
|
+
const rawRequest = client.client.rawRequest.bind(client.client);
|
|
64
|
+
client.client.request = (...args) => run(() => request(...args));
|
|
65
|
+
client.client.rawRequest = (...args) => run(() => rawRequest(...args));
|
|
66
|
+
return client;
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
function isAuthenticationFailure(err: unknown): boolean {
|
|
31
70
|
const value = err as Record<string, any> | undefined;
|
|
32
71
|
const status = value?.status ?? value?.response?.status ?? value?.raw?.response?.status;
|
|
33
72
|
if (status === 401) return true;
|
|
73
|
+
if (/^authentication(?:error)?$/i.test(String(value?.type ?? ""))) return true;
|
|
34
74
|
const errors = value?.response?.errors ?? value?.errors ?? value?.raw?.response?.errors;
|
|
35
75
|
return (
|
|
36
76
|
Array.isArray(errors) &&
|
|
37
77
|
errors.some((item) => {
|
|
38
|
-
const type = `${item?.extensions?.type ?? ""} ${item?.extensions?.code ?? ""}`;
|
|
78
|
+
const type = `${item?.type ?? item?.extensions?.type ?? ""} ${item?.extensions?.code ?? ""}`;
|
|
39
79
|
const message = `${item?.message ?? ""}`;
|
|
40
80
|
return (
|
|
41
81
|
/^(?:authentication(?:error)?|unauthorized|invalidtoken|invalid_token)$/i.test(
|
|
42
82
|
type.trim(),
|
|
43
83
|
) ||
|
|
44
|
-
/authentication (?:failed|required)|unauthori[sz]ed|invalid (?:access )?token|token (?:expired|invalid)/i.test(
|
|
84
|
+
/unauthenticated|authentication (?:failed|required)|unauthori[sz]ed|invalid (?:access )?token|token (?:expired|invalid)/i.test(
|
|
45
85
|
message,
|
|
46
86
|
)
|
|
47
87
|
);
|
|
@@ -56,21 +96,9 @@ function createRefreshingOAuthClient(config: ResolvedConfig): LinearClient {
|
|
|
56
96
|
credential,
|
|
57
97
|
persist: (previous, next) => rotateOAuthCredential(credential.workspace.urlKey, previous, next),
|
|
58
98
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const accessToken = await provider.getAccessToken();
|
|
63
|
-
client.client.setHeader("Authorization", `Bearer ${accessToken}`);
|
|
64
|
-
try {
|
|
65
|
-
return await original(document, variables, requestHeaders);
|
|
66
|
-
} catch (err) {
|
|
67
|
-
if (!isAuthenticationFailure(err)) throw err;
|
|
68
|
-
const replacement = await provider.getAccessToken({ forceRefresh: true });
|
|
69
|
-
client.client.setHeader("Authorization", `Bearer ${replacement}`);
|
|
70
|
-
return original(document, variables, requestHeaders);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
return client;
|
|
99
|
+
return installTokenRenewal(new LinearClient({ accessToken: credential.accessToken }), (options) =>
|
|
100
|
+
provider.getAccessToken(options),
|
|
101
|
+
);
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
/**
|
package/src/commands/issue.ts
CHANGED
|
@@ -290,7 +290,13 @@ export function registerIssue(program: Command): void {
|
|
|
290
290
|
ctx.limit,
|
|
291
291
|
ctx.defaultTeam,
|
|
292
292
|
);
|
|
293
|
-
ctx.output.list(rows, listColumns(ctx),
|
|
293
|
+
ctx.output.list(rows, listColumns(ctx), {
|
|
294
|
+
jsonRows: rows,
|
|
295
|
+
empty:
|
|
296
|
+
!opts.allStates && !opts.state?.length
|
|
297
|
+
? "No unstarted issues assigned to you match these filters. Use --all-states to include in-progress and other states."
|
|
298
|
+
: undefined,
|
|
299
|
+
});
|
|
294
300
|
}),
|
|
295
301
|
);
|
|
296
302
|
addFilterOptions(mine, { assignee: false }).addOption(
|