@enrichlayer/el-linear 1.44.2 → 1.46.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/README.md +159 -0
- package/claude-skills/linear-operations/SKILL.md +17 -0
- package/dist/auth/oauth-storage.d.ts +5 -3
- package/dist/auth/oauth-token.d.ts +7 -0
- package/dist/auth/oauth-token.js +13 -0
- package/dist/auth/token-resolver.d.ts +2 -0
- package/dist/auth/token-resolver.js +25 -10
- package/dist/commands/init/index.js +17 -1
- package/dist/commands/init/oauth.d.ts +9 -1
- package/dist/commands/init/oauth.js +62 -3
- package/dist/commands/issues.js +244 -1
- package/dist/config/config.d.ts +30 -0
- package/dist/config/config.js +3 -0
- package/dist/config/consent-receipt.d.ts +76 -0
- package/dist/config/consent-receipt.js +219 -0
- package/dist/config/label-advisor.d.ts +53 -0
- package/dist/config/label-advisor.js +245 -0
- package/dist/queries/catalog-types.d.ts +129 -0
- package/dist/queries/catalog-types.js +1 -0
- package/dist/queries/catalog.d.ts +20 -0
- package/dist/queries/catalog.js +140 -0
- package/dist/utils/disk-cache.js +48 -14
- package/dist/utils/graphql-service.d.ts +42 -1
- package/dist/utils/graphql-service.js +267 -14
- package/dist/utils/linear-graphql-error.d.ts +100 -0
- package/dist/utils/linear-graphql-error.js +182 -0
- package/dist/utils/linear-service.d.ts +19 -2
- package/dist/utils/linear-service.js +194 -214
- package/dist/utils/output.d.ts +39 -0
- package/dist/utils/output.js +152 -1
- package/dist/utils/rate-limit-admission.d.ts +33 -0
- package/dist/utils/rate-limit-admission.js +240 -0
- package/package.json +79 -79
package/README.md
CHANGED
|
@@ -111,6 +111,25 @@ el-linear init oauth --actor app
|
|
|
111
111
|
App actor tokens can request `app:assignable` and `app:mentionable`, but not
|
|
112
112
|
`admin`. The authorized app user ID is stored in `oauth.json` as `viewerId`.
|
|
113
113
|
|
|
114
|
+
OAuth apps that have Linear's client-credentials grant enabled can obtain an
|
|
115
|
+
app-user token without a browser. Keep the secret out of argv and source it
|
|
116
|
+
through an environment variable:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
export LINEAR_OAUTH_CLIENT_SECRET="..."
|
|
120
|
+
el-linear init oauth --client-credentials --actor app \
|
|
121
|
+
--client-id your-linear-oauth-client-id
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Use `--client-secret-env NAME` to read a differently named variable and
|
|
125
|
+
`--scopes read,write,issues:create,comments:create` to override the configured
|
|
126
|
+
scope set. The 0600 profile state stores the secret so el-linear can acquire a
|
|
127
|
+
new token before expiry and once after an HTTP 401; client-credentials tokens
|
|
128
|
+
do not have refresh tokens. Keep the scope set stable: Linear revokes an app's
|
|
129
|
+
existing client-credentials tokens when a new token requests different scopes.
|
|
130
|
+
This flow is opt-in. Remote automation can keep using `LINEAR_API_TOKEN`, which
|
|
131
|
+
remains higher precedence than profile OAuth.
|
|
132
|
+
|
|
114
133
|
At runtime, credentials are resolved in this order:
|
|
115
134
|
|
|
116
135
|
1. `--api-token <token>` flag.
|
|
@@ -558,6 +577,83 @@ list-shaped reads — single-issue `issues read DEV-123` is unaffected.
|
|
|
558
577
|
|
|
559
578
|
## Output formats
|
|
560
579
|
|
|
580
|
+
Commands whose request path exposes Linear response headers include aggregate
|
|
581
|
+
quota observations as optional `_rateLimit` metadata in JSON output:
|
|
582
|
+
|
|
583
|
+
```json
|
|
584
|
+
{
|
|
585
|
+
"identifier": "DEV-123",
|
|
586
|
+
"_rateLimit": {
|
|
587
|
+
"limit": 2500,
|
|
588
|
+
"remaining": 2498,
|
|
589
|
+
"resetAt": "2026-08-11T10:00:00.000Z",
|
|
590
|
+
"observedRequests": 2,
|
|
591
|
+
"minimumRemaining": 2498,
|
|
592
|
+
"complexity": {
|
|
593
|
+
"cost": 30,
|
|
594
|
+
"totalCost": 50,
|
|
595
|
+
"limit": 2000000,
|
|
596
|
+
"remaining": 1999950,
|
|
597
|
+
"minimumRemaining": 1999950,
|
|
598
|
+
"resetAt": "2026-08-11T10:00:00.000Z"
|
|
599
|
+
},
|
|
600
|
+
"endpoints": {
|
|
601
|
+
"Issue": {
|
|
602
|
+
"limit": 1000,
|
|
603
|
+
"remaining": 998,
|
|
604
|
+
"minimumRemaining": 998,
|
|
605
|
+
"resetAt": "2026-08-11T10:00:00.000Z",
|
|
606
|
+
"observedRequests": 2
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Summary output renders the same information as an `_rateLimit:` line. With a
|
|
614
|
+
bare-array output such as `--raw`, the line goes to stderr so stdout remains
|
|
615
|
+
valid JSON. Current remaining/reset values come from the most recent response;
|
|
616
|
+
`observedRequests`, `minimumRemaining`, `complexity.totalCost`, and each
|
|
617
|
+
endpoint entry expose the command's aggregate cost and lowest observed
|
|
618
|
+
headroom. Rate-limited error envelopes include the same metadata. Commands
|
|
619
|
+
served entirely from cache omit it.
|
|
620
|
+
|
|
621
|
+
Automation can reserve a request floor before issuing another GraphQL call:
|
|
622
|
+
|
|
623
|
+
```bash
|
|
624
|
+
export EL_LINEAR_RATE_LIMIT_HEADROOM=250
|
|
625
|
+
export EL_LINEAR_QUOTA_KEY=verticalint-shared-linear-user
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
When Linear's last observed remaining count reaches the configured floor,
|
|
629
|
+
`el-linear` refuses the request until the observed reset time instead of
|
|
630
|
+
consuming capacity reserved for higher-priority work. Admission and response
|
|
631
|
+
observations are serialized across local processes. The state filename is a
|
|
632
|
+
SHA-256 digest; neither the credential nor `EL_LINEAR_QUOTA_KEY` is written in
|
|
633
|
+
clear text. Set the same non-secret quota key for API keys belonging to the
|
|
634
|
+
same Linear user, because Linear pools those keys by user. Without an explicit
|
|
635
|
+
key, API keys coordinate by credential and OAuth tokens coordinate by token.
|
|
636
|
+
Profile OAuth uses its stable app/viewer identity so token refreshes keep the
|
|
637
|
+
same local quota state.
|
|
638
|
+
|
|
639
|
+
This file-backed admission is intentionally a same-machine boundary. Separate
|
|
640
|
+
hosts can opt into the companion distributed coordinator:
|
|
641
|
+
|
|
642
|
+
```bash
|
|
643
|
+
export EL_LINEAR_RATE_LIMIT_COORDINATOR_URL=https://el-linear-control-plane.example.workers.dev
|
|
644
|
+
export EL_LINEAR_RATE_LIMIT_COORDINATOR_TOKEN="..."
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
The URL switches admission and observations to the coordinator's atomic
|
|
648
|
+
Durable Object for the hashed quota key. Admission fails closed when that
|
|
649
|
+
service is unavailable; a completed Linear response is never replayed merely
|
|
650
|
+
because its observation could not be persisted. Without the URL, do not
|
|
651
|
+
interpret the file-backed setting as distributed admission.
|
|
652
|
+
|
|
653
|
+
Read-through cache misses for teams, projects, labels, and similar cached lists
|
|
654
|
+
are also single-flighted across local processes, preventing a cold-cache burst
|
|
655
|
+
from issuing the same request once per command.
|
|
656
|
+
|
|
561
657
|
Every command accepts `--format <kind>` at the root:
|
|
562
658
|
|
|
563
659
|
- `--format json` (default) — emits the full structured envelope. Stable
|
|
@@ -661,6 +757,69 @@ el-linear projects list --format summary --fields name,state,progress,lead,teams
|
|
|
661
757
|
|
|
662
758
|
Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, project updates, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
|
|
663
759
|
|
|
760
|
+
### Error envelope
|
|
761
|
+
|
|
762
|
+
Every command reports a failure the same way: **exit code 1**, and a
|
|
763
|
+
single JSON object on **stdout** (the same stream as success, so a caller
|
|
764
|
+
capturing one stream always gets exactly one parseable object).
|
|
765
|
+
|
|
766
|
+
```json
|
|
767
|
+
{
|
|
768
|
+
"error": "Ratelimit exceeded",
|
|
769
|
+
"activeProfile": "work",
|
|
770
|
+
"errorDetail": {
|
|
771
|
+
"httpStatus": 429,
|
|
772
|
+
"code": "RATELIMITED",
|
|
773
|
+
"retryable": true
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
| Field | Always present | Meaning |
|
|
779
|
+
| --------------- | -------------- | -------------------------------------------------------------------- |
|
|
780
|
+
| `error` | yes | The failure message, token-sanitized. |
|
|
781
|
+
| `activeProfile` | yes | Which profile the command ran under — distinguishes "not found" from "wrong workspace". |
|
|
782
|
+
| `errorDetail` | no | Classification of a **Linear GraphQL** failure or quota admission refusal. See below. |
|
|
783
|
+
|
|
784
|
+
`errorDetail` is emitted when the failure came from a request to the
|
|
785
|
+
Linear GraphQL API or the quota admission that precedes it. Its fields:
|
|
786
|
+
|
|
787
|
+
| Field | Type | Meaning |
|
|
788
|
+
| ------------ | ---------------- | ----------------------------------------------------------------------- |
|
|
789
|
+
| `httpStatus` | `number \| null` | HTTP status of Linear's response; `null` when no response arrived. |
|
|
790
|
+
| `code` | `string \| null` | The first GraphQL error's `extensions.code`; `null` when absent. |
|
|
791
|
+
| `retryable` | `boolean` | Whether the failure is transient — retrying after an appropriate wait could succeed. |
|
|
792
|
+
| `resetAt` | optional `string` | Known quota reset or recovery-probe deadline, normalized to a UTC ISO timestamp. |
|
|
793
|
+
|
|
794
|
+
`retryable` is `true` for HTTP 408 / 429 / 5xx, for the `RATELIMITED`,
|
|
795
|
+
`INTERNAL_SERVER_ERROR` and `SERVICE_UNAVAILABLE` GraphQL codes, and for a
|
|
796
|
+
transport failure that never reached a response (`fetch failed`,
|
|
797
|
+
`ECONNRESET`, …). Reading `code` separately matters: Linear can answer a
|
|
798
|
+
rate limit under a status that would otherwise read as permanent, so
|
|
799
|
+
`httpStatus` alone is not the whole verdict.
|
|
800
|
+
|
|
801
|
+
`retryable: true` says the failure is transient — **not** that retrying
|
|
802
|
+
immediately is a good idea. A rate limit is transient and reported as such,
|
|
803
|
+
but its window may be minutes away; the wait is the caller's policy.
|
|
804
|
+
|
|
805
|
+
A local or distributed quota admission refusal uses `code: "RATELIMITED"`,
|
|
806
|
+
`retryable: true` and `httpStatus: null`: no request was sent to Linear.
|
|
807
|
+
When known, `resetAt` comes from the quota state or the existing recovery-probe
|
|
808
|
+
lease. Missing or malformed deadlines are omitted; message text never supplies
|
|
809
|
+
a deadline. These refusals retain the configured headroom and do not trigger an
|
|
810
|
+
immediate retry inside the CLI. The deadline permits a later attempt, not a
|
|
811
|
+
guarantee of capacity or permission.
|
|
812
|
+
|
|
813
|
+
**A missing `errorDetail` is not "not retryable".** It means the failure
|
|
814
|
+
was not a classified Linear request or quota refusal — a bad argument, an unreadable
|
|
815
|
+
`--file`, a missing token. Treat its absence as *unclassified* and apply
|
|
816
|
+
your own policy; emitting a fabricated `retryable: false` there would let
|
|
817
|
+
an argv typo masquerade as a verdict about Linear.
|
|
818
|
+
|
|
819
|
+
Automation that shells out to `el-linear` (a job runner deciding whether
|
|
820
|
+
to retry, say) should branch on `errorDetail.retryable` rather than
|
|
821
|
+
substring-matching `error`.
|
|
822
|
+
|
|
664
823
|
### Windowed metadata (`WindowedMeta`)
|
|
665
824
|
|
|
666
825
|
When a command returns less than its complete result set — because it
|
|
@@ -468,6 +468,23 @@ The label is plain config — set it to anything you want, or skip it entirely.
|
|
|
468
468
|
|
|
469
469
|
---
|
|
470
470
|
|
|
471
|
+
## Label advisor defaults and consent labels (`bot`)
|
|
472
|
+
|
|
473
|
+
A workspace can configure a **label advisor** (`labelAdvisor.command` in personal config, or `EL_LINEAR_LABEL_ADVISOR`): a command `issues create` consults with the proposed issue. Whatever labels it returns are added and reported — a `labels added by advisor: … (<reason>)` warning on stderr and a `labelAdvisor` field in the JSON output. In Enrich Layer's Tools setup the advisor is `el-bot linear-rubric --advise`, the bot-suitability rubric: an issue it marks BOT gets `bot` **by default**, and an EXCLUDE issue is unchanged.
|
|
474
|
+
|
|
475
|
+
- **Opt out for one create** with `--no-label-advisor` (for example, work you intend to do yourself, or an issue whose consent you are not in a position to give).
|
|
476
|
+
- **Read the create output.** `labelAdvisor.added` lists what the advisor added; `labelAdvisor.consent` reports a consent label (`{labels, applied, repo, problem?}`). `applied: false` means the issue exists without that label, and `problem` says why.
|
|
477
|
+
|
|
478
|
+
**Consent labels need a receipt.** Where `validation.consentReceiptGate` is on (Enrich Layer's shared config turns it on), a consent label (`validation.consentLabels`, default `bot`) is only valid with exactly one `<!-- el-intake-decision:v1 {...} -->` receipt in the description that names the issue — bot-layer intake silently skips a `bot` issue without one. So:
|
|
479
|
+
|
|
480
|
+
- **Default route: let the advisor apply it.** When the advisor returns `bot` with its receipt fields, el-linear creates the issue and then, in one update, applies `bot` with a receipt naming the new issue and you (the acting Linear user) as `actor`.
|
|
481
|
+
- **Never pass `--labels bot` on create.** It is refused: the receipt must name an issue that does not exist yet.
|
|
482
|
+
- **Adding `bot` to an existing issue** needs the receipt in the same update, or the update is refused and the error names what is missing. Enrich Layer: run `el-bot linear-consent <ID> --automatic-implementation --reason "<why>"`, which writes the label and a valid receipt together. Elsewhere: `el-linear issues update <ID> --labels bot --description-file <body-ending-with-the-receipt>`.
|
|
483
|
+
|
|
484
|
+
The gate has no override flag and ignores `--skip-validation`: it protects consent, not field hygiene.
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
471
488
|
## User @Mentions
|
|
472
489
|
|
|
473
490
|
Reference team members by name in comments. el-linear resolves both explicit `@name` tokens and bare capitalized references to proper Linear mentions.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OAuthActor } from "./oauth-client.js";
|
|
1
|
+
import type { OAuthActor, OAuthScope } from "./oauth-client.js";
|
|
2
2
|
export declare const OAUTH_STATE_VERSION = 1;
|
|
3
3
|
/**
|
|
4
4
|
* Persisted OAuth state. Mirrors what we got back from Linear's token
|
|
@@ -6,17 +6,19 @@ export declare const OAUTH_STATE_VERSION = 1;
|
|
|
6
6
|
*/
|
|
7
7
|
export interface OAuthState {
|
|
8
8
|
v: typeof OAUTH_STATE_VERSION;
|
|
9
|
+
/** Legacy state omits this and is treated as authorization_code. */
|
|
10
|
+
grantType?: "authorization_code" | "client_credentials";
|
|
9
11
|
/** Linear OAuth actor tied to the access token. Defaults to user for legacy state. */
|
|
10
12
|
actor?: OAuthActor;
|
|
11
13
|
/** viewer.id returned after authorization; for actor=app this is the app user ID. */
|
|
12
14
|
viewerId?: string;
|
|
13
15
|
clientId: string;
|
|
14
16
|
clientSecret?: string;
|
|
15
|
-
registeredRedirectUri
|
|
17
|
+
registeredRedirectUri?: string;
|
|
16
18
|
accessToken: string;
|
|
17
19
|
refreshToken?: string;
|
|
18
20
|
tokenType: string;
|
|
19
|
-
scopes:
|
|
21
|
+
scopes: OAuthScope[];
|
|
20
22
|
/** Unix epoch milliseconds; computed at write time from `expires_in`. */
|
|
21
23
|
expiresAt: number;
|
|
22
24
|
/** When we last fetched a token (for diagnostics). */
|
|
@@ -37,6 +37,11 @@ interface RefreshTokensInput {
|
|
|
37
37
|
clientSecret?: string;
|
|
38
38
|
refreshToken: string;
|
|
39
39
|
}
|
|
40
|
+
interface ClientCredentialsInput {
|
|
41
|
+
clientId: string;
|
|
42
|
+
clientSecret: string;
|
|
43
|
+
scopes: readonly OAuthScope[];
|
|
44
|
+
}
|
|
40
45
|
interface RevokeTokenInput {
|
|
41
46
|
accessToken: string;
|
|
42
47
|
}
|
|
@@ -59,6 +64,8 @@ export declare function exchangeCodeForTokens(input: ExchangeCodeInput, fetchImp
|
|
|
59
64
|
* refresh token, so we plumb both fields through.
|
|
60
65
|
*/
|
|
61
66
|
export declare function refreshTokens(input: RefreshTokensInput, fetchImpl?: FetchLike, now?: () => number): Promise<ExchangeResult>;
|
|
67
|
+
/** Obtain an app-user token without a browser or refresh token. */
|
|
68
|
+
export declare function exchangeClientCredentials(input: ClientCredentialsInput, fetchImpl?: FetchLike, now?: () => number): Promise<ExchangeResult>;
|
|
62
69
|
/**
|
|
63
70
|
* Revoke an access token. Best-effort — we don't throw on transport
|
|
64
71
|
* errors so callers can still clear local state.
|
package/dist/auth/oauth-token.js
CHANGED
|
@@ -121,6 +121,19 @@ export async function refreshTokens(input, fetchImpl = defaultFetch, now = Date.
|
|
|
121
121
|
}
|
|
122
122
|
return tokenResponseToResult(res, now());
|
|
123
123
|
}
|
|
124
|
+
/** Obtain an app-user token without a browser or refresh token. */
|
|
125
|
+
export async function exchangeClientCredentials(input, fetchImpl = defaultFetch, now = Date.now) {
|
|
126
|
+
const res = await postForm(LINEAR_TOKEN_URL, {
|
|
127
|
+
grant_type: "client_credentials",
|
|
128
|
+
client_id: input.clientId,
|
|
129
|
+
client_secret: input.clientSecret,
|
|
130
|
+
scope: input.scopes.join(","),
|
|
131
|
+
}, fetchImpl);
|
|
132
|
+
if (typeof res.access_token !== "string" || res.access_token === "") {
|
|
133
|
+
throw new Error("OAuth client-credentials response missing `access_token`.");
|
|
134
|
+
}
|
|
135
|
+
return tokenResponseToResult(res, now());
|
|
136
|
+
}
|
|
124
137
|
/**
|
|
125
138
|
* Revoke an access token. Best-effort — we don't throw on transport
|
|
126
139
|
* errors so callers can still clear local state.
|
|
@@ -38,6 +38,8 @@ export interface GetActiveAuthOptions {
|
|
|
38
38
|
fetchImpl?: FetchLike;
|
|
39
39
|
/** Test seam: override the wall-clock for refresh expiry checks. */
|
|
40
40
|
now?: () => number;
|
|
41
|
+
/** Internal 401-recovery seam for client-credentials tokens. */
|
|
42
|
+
forceOAuthRenewal?: boolean;
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
43
45
|
* Resolve the credential for this invocation.
|
|
@@ -19,7 +19,7 @@ import { getApiToken } from "../utils/auth.js";
|
|
|
19
19
|
import { sanitizeForLog } from "../utils/sanitize-for-log.js";
|
|
20
20
|
import { withFileLock } from "./oauth-fs.js";
|
|
21
21
|
import { oauthStatePath, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
|
|
22
|
-
import { refreshTokens, } from "./oauth-token.js";
|
|
22
|
+
import { exchangeClientCredentials, refreshTokens, } from "./oauth-token.js";
|
|
23
23
|
/**
|
|
24
24
|
* Resolve the credential for this invocation.
|
|
25
25
|
*
|
|
@@ -67,7 +67,9 @@ export async function getActiveAuth(options = {}) {
|
|
|
67
67
|
export async function ensureFreshAccessToken(state, options = {}) {
|
|
68
68
|
const now = options.now ?? Date.now;
|
|
69
69
|
// Fast path: token is fresh; no lock, no refresh.
|
|
70
|
-
|
|
70
|
+
const forceClientCredentialsRenewal = options.forceOAuthRenewal === true &&
|
|
71
|
+
state.grantType === "client_credentials";
|
|
72
|
+
if (!forceClientCredentialsRenewal && now() + 60_000 < state.expiresAt) {
|
|
71
73
|
return state;
|
|
72
74
|
}
|
|
73
75
|
// Snapshot the target path ONCE so a profile switch between the
|
|
@@ -79,19 +81,32 @@ export async function ensureFreshAccessToken(state, options = {}) {
|
|
|
79
81
|
// Re-read inside the lock — another process may have refreshed
|
|
80
82
|
// while we were waiting. If so, use their result.
|
|
81
83
|
const current = (await readOAuthState(targetPath)) ?? state;
|
|
82
|
-
if (now() + 60_000 < current.expiresAt
|
|
84
|
+
if (now() + 60_000 < current.expiresAt &&
|
|
85
|
+
(!forceClientCredentialsRenewal || current.obtainedAt > state.obtainedAt)) {
|
|
83
86
|
return current;
|
|
84
87
|
}
|
|
85
|
-
if (!current.refreshToken) {
|
|
88
|
+
if (current.grantType !== "client_credentials" && !current.refreshToken) {
|
|
86
89
|
throw new Error("OAuth access token expired and no refresh token is stored. Re-run `el-linear init oauth`.");
|
|
87
90
|
}
|
|
88
91
|
let refreshed;
|
|
89
92
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
if (current.grantType === "client_credentials") {
|
|
94
|
+
if (!current.clientSecret) {
|
|
95
|
+
throw new Error("stored client credentials are missing client_secret");
|
|
96
|
+
}
|
|
97
|
+
refreshed = await exchangeClientCredentials({
|
|
98
|
+
clientId: current.clientId,
|
|
99
|
+
clientSecret: current.clientSecret,
|
|
100
|
+
scopes: current.scopes,
|
|
101
|
+
}, options.fetchImpl, now);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
refreshed = await refreshTokens({
|
|
105
|
+
clientId: current.clientId,
|
|
106
|
+
clientSecret: current.clientSecret,
|
|
107
|
+
refreshToken: current.refreshToken,
|
|
108
|
+
}, options.fetchImpl, now);
|
|
109
|
+
}
|
|
95
110
|
}
|
|
96
111
|
catch (err) {
|
|
97
112
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -99,7 +114,7 @@ export async function ensureFreshAccessToken(state, options = {}) {
|
|
|
99
114
|
// so at source — defense in depth, in case a future caller
|
|
100
115
|
// (or a wrapper that catches+rethrows) inserts an unsanitized
|
|
101
116
|
// stage into the error chain (DEV-4065).
|
|
102
|
-
throw new Error(`OAuth
|
|
117
|
+
throw new Error(`OAuth token renewal failed: ${sanitizeForLog(message)}. Re-run \`el-linear init oauth\` to re-authorize.`);
|
|
103
118
|
}
|
|
104
119
|
const next = {
|
|
105
120
|
...current,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Skip is the default at every prompt. Only `init token` is required for a
|
|
15
15
|
* first-time setup; everything else can be skipped and revisited later.
|
|
16
16
|
*/
|
|
17
|
-
import { validateOAuthActor, } from "../../auth/oauth-client.js";
|
|
17
|
+
import { validateOAuthActor, validateScopes, } from "../../auth/oauth-client.js";
|
|
18
18
|
import { mergeAliasesIntoConfig, runAliasesImport, runAliasesStep, } from "./aliases.js";
|
|
19
19
|
import { runDefaultsStep } from "./defaults.js";
|
|
20
20
|
import { runOAuthRevoke, runOAuthStep } from "./oauth.js";
|
|
@@ -62,6 +62,10 @@ export function setupInitCommands(program) {
|
|
|
62
62
|
.description("Authorize via OAuth 2.0 (PKCE) — alternative to a personal API token")
|
|
63
63
|
.option("--force", "ignore existing tokens; re-authorize unconditionally")
|
|
64
64
|
.option("--actor <actor>", "OAuth actor: user (default) or app for agents/service accounts", validateOAuthActor)
|
|
65
|
+
.option("--client-credentials", "use the browserless OAuth app-user client_credentials grant")
|
|
66
|
+
.option("--client-id <id>", "OAuth client id (non-secret)")
|
|
67
|
+
.option("--client-secret-env <name>", "environment variable containing the OAuth client secret", "LINEAR_OAUTH_CLIENT_SECRET")
|
|
68
|
+
.option("--scopes <scopes>", "comma- or space-separated OAuth scopes", (value) => validateScopes(value.split(/[,\s]+/).filter(Boolean)))
|
|
65
69
|
.option("--revoke", "revoke and remove the stored OAuth tokens")
|
|
66
70
|
.option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
|
|
67
71
|
.option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
|
|
@@ -73,8 +77,20 @@ export function setupInitCommands(program) {
|
|
|
73
77
|
console.log(` ${result.message}`);
|
|
74
78
|
return;
|
|
75
79
|
}
|
|
80
|
+
const clientSecret = options.clientCredentials
|
|
81
|
+
? process.env[options.clientSecretEnv]?.trim()
|
|
82
|
+
: undefined;
|
|
83
|
+
if (options.clientCredentials &&
|
|
84
|
+
!clientSecret &&
|
|
85
|
+
!process.stdin.isTTY) {
|
|
86
|
+
throw new Error(`Client credentials require ${options.clientSecretEnv} in a non-interactive shell.`);
|
|
87
|
+
}
|
|
76
88
|
await runOAuthStep({
|
|
77
89
|
actor: options.actor,
|
|
90
|
+
clientCredentials: options.clientCredentials === true,
|
|
91
|
+
clientId: options.clientId,
|
|
92
|
+
clientSecret,
|
|
93
|
+
scopes: options.scopes,
|
|
78
94
|
force: options.force ?? false,
|
|
79
95
|
// commander's `--no-browser` produces `browser: false`.
|
|
80
96
|
noBrowser: options.browser === false,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* revoke before doing anything else.
|
|
19
19
|
*/
|
|
20
20
|
import { runLocalhostCallback } from "../../auth/oauth-callback.js";
|
|
21
|
-
import { type OAuthActor } from "../../auth/oauth-client.js";
|
|
21
|
+
import { type OAuthActor, type OAuthScope } from "../../auth/oauth-client.js";
|
|
22
22
|
import { type OAuthState } from "../../auth/oauth-storage.js";
|
|
23
23
|
import { type FetchLike } from "../../auth/oauth-token.js";
|
|
24
24
|
interface ViewerResponse {
|
|
@@ -36,6 +36,14 @@ interface ViewerResponse {
|
|
|
36
36
|
export interface OAuthStepOptions {
|
|
37
37
|
/** OAuth actor mode: `user` (default) or `app` for agents/service accounts. */
|
|
38
38
|
actor?: OAuthActor;
|
|
39
|
+
/** Use the browserless app-user client_credentials grant. */
|
|
40
|
+
clientCredentials?: boolean;
|
|
41
|
+
/** Non-secret client id override for client-credentials setup. */
|
|
42
|
+
clientId?: string;
|
|
43
|
+
/** Secret supplied by the command's named environment variable. */
|
|
44
|
+
clientSecret?: string;
|
|
45
|
+
/** Scope override for non-interactive client-credentials setup. */
|
|
46
|
+
scopes?: OAuthScope[];
|
|
39
47
|
/** Force re-authorization even if existing state is valid. */
|
|
40
48
|
force?: boolean;
|
|
41
49
|
/** Skip the localhost listener; use the headless code-paste prompt. */
|
|
@@ -24,7 +24,7 @@ import { DEFAULT_CALLBACK_PATH, runLocalhostCallback, } from "../../auth/oauth-c
|
|
|
24
24
|
import { ALL_SCOPES, buildAuthorizeUrl, DEFAULT_SCOPES, generatePkce, generateState, SCOPE_DESCRIPTIONS, validateActorScopes, validateScopes, } from "../../auth/oauth-client.js";
|
|
25
25
|
import { promptForPastedCode } from "../../auth/oauth-headless.js";
|
|
26
26
|
import { clearOAuthState, OAUTH_STATE_VERSION, readOAuthState, writeOAuthState, } from "../../auth/oauth-storage.js";
|
|
27
|
-
import { exchangeCodeForTokens, revokeToken, } from "../../auth/oauth-token.js";
|
|
27
|
+
import { exchangeClientCredentials, exchangeCodeForTokens, revokeToken, } from "../../auth/oauth-token.js";
|
|
28
28
|
import { GraphQLService } from "../../utils/graphql-service.js";
|
|
29
29
|
import { sanitizeForLog } from "./token.js";
|
|
30
30
|
const DEFAULT_PORT = 8765;
|
|
@@ -117,7 +117,7 @@ async function handleExistingState(existing, options) {
|
|
|
117
117
|
function extractPortFromRedirect(state) {
|
|
118
118
|
if (!state)
|
|
119
119
|
return null;
|
|
120
|
-
const match = state.registeredRedirectUri
|
|
120
|
+
const match = state.registeredRedirectUri?.match(/:(\d+)\//);
|
|
121
121
|
if (!match)
|
|
122
122
|
return null;
|
|
123
123
|
const n = Number.parseInt(match[1], 10);
|
|
@@ -196,6 +196,30 @@ async function resolveRegistration(defaults) {
|
|
|
196
196
|
scopes: teamConfig.scopes,
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
async function resolveClientCredentialsRegistration(options) {
|
|
200
|
+
if (options.actor && options.actor !== "app") {
|
|
201
|
+
throw new Error("--client-credentials requires --actor app.");
|
|
202
|
+
}
|
|
203
|
+
const teamConfig = await readTeamOAuthConfig();
|
|
204
|
+
if (teamConfig && teamConfig.actor !== "app") {
|
|
205
|
+
throw new Error(`${teamConfig.sourcePath} configures actor=user; client credentials require an OAuth app configured for actor=app.`);
|
|
206
|
+
}
|
|
207
|
+
const clientId = options.clientId?.trim() ||
|
|
208
|
+
teamConfig?.clientId ||
|
|
209
|
+
(await input({
|
|
210
|
+
message: "Linear OAuth client_id:",
|
|
211
|
+
validate: (value) => value.trim().length > 0 || "client_id cannot be empty",
|
|
212
|
+
})).trim();
|
|
213
|
+
const clientSecret = options.clientSecret?.trim() ||
|
|
214
|
+
(await password({
|
|
215
|
+
message: "Linear OAuth client_secret (required, hidden):",
|
|
216
|
+
mask: "*",
|
|
217
|
+
validate: (value) => value.trim().length > 0 || "client_secret cannot be empty",
|
|
218
|
+
})).trim();
|
|
219
|
+
const scopes = validateScopes(options.scopes ?? teamConfig?.scopes ?? [...DEFAULT_SCOPES]);
|
|
220
|
+
validateActorScopes("app", scopes);
|
|
221
|
+
return { clientId, clientSecret, scopes };
|
|
222
|
+
}
|
|
199
223
|
/**
|
|
200
224
|
* Default viewer-validation routine. Calls `viewer { ... }` with the new
|
|
201
225
|
* bearer token to confirm Linear accepted it. Reused for both the wizard
|
|
@@ -226,7 +250,14 @@ async function defaultValidateViewer(oauthToken) {
|
|
|
226
250
|
export async function runOAuthStep(options = {}) {
|
|
227
251
|
const validateViewer = options.validateViewer ?? defaultValidateViewer;
|
|
228
252
|
const existing = await readOAuthState();
|
|
229
|
-
|
|
253
|
+
const requestedGrant = options.clientCredentials
|
|
254
|
+
? "client_credentials"
|
|
255
|
+
: "authorization_code";
|
|
256
|
+
const existingGrant = existing?.grantType ?? "authorization_code";
|
|
257
|
+
if (existing && existingGrant !== requestedGrant && !options.force) {
|
|
258
|
+
throw new Error(`This profile already stores ${existingGrant} OAuth state. Re-run with --force to replace it with ${requestedGrant}.`);
|
|
259
|
+
}
|
|
260
|
+
if (existing && existingGrant === requestedGrant && !options.force) {
|
|
230
261
|
const handled = await handleExistingState(existing, options);
|
|
231
262
|
if (handled.kind === "keep") {
|
|
232
263
|
// Validate the existing token actually works; if it's already
|
|
@@ -244,6 +275,33 @@ export async function runOAuthStep(options = {}) {
|
|
|
244
275
|
}
|
|
245
276
|
// Both `reauth` and `revoked` fall through to the re-auth flow.
|
|
246
277
|
}
|
|
278
|
+
if (options.clientCredentials) {
|
|
279
|
+
const reg = await resolveClientCredentialsRegistration(options);
|
|
280
|
+
logLine(TS("Requesting an app-user token with client credentials…"));
|
|
281
|
+
const exchanged = await exchangeClientCredentials({
|
|
282
|
+
clientId: reg.clientId,
|
|
283
|
+
clientSecret: reg.clientSecret,
|
|
284
|
+
scopes: reg.scopes,
|
|
285
|
+
}, options.fetchImpl);
|
|
286
|
+
const newState = {
|
|
287
|
+
v: OAUTH_STATE_VERSION,
|
|
288
|
+
grantType: "client_credentials",
|
|
289
|
+
actor: "app",
|
|
290
|
+
clientId: reg.clientId,
|
|
291
|
+
clientSecret: reg.clientSecret,
|
|
292
|
+
accessToken: exchanged.accessToken,
|
|
293
|
+
tokenType: exchanged.tokenType,
|
|
294
|
+
scopes: exchanged.scopes.length > 0 ? exchanged.scopes : reg.scopes,
|
|
295
|
+
expiresAt: exchanged.expiresAt,
|
|
296
|
+
obtainedAt: Date.now(),
|
|
297
|
+
};
|
|
298
|
+
logLine(TS("Validating against viewer…"));
|
|
299
|
+
const viewer = await validateViewer(newState.accessToken);
|
|
300
|
+
newState.viewerId = viewer.id;
|
|
301
|
+
await writeOAuthState(newState);
|
|
302
|
+
logLine(TS(`✓ Authorized app user ${viewer.displayName} <${viewer.email}> (${viewer.organization.name}).`));
|
|
303
|
+
return { state: newState, viewer };
|
|
304
|
+
}
|
|
247
305
|
const reg = await resolveRegistration({
|
|
248
306
|
actor: options.actor,
|
|
249
307
|
manualPort: options.port ?? extractPortFromRedirect(existing) ?? DEFAULT_PORT,
|
|
@@ -308,6 +366,7 @@ export async function runOAuthStep(options = {}) {
|
|
|
308
366
|
}, options.fetchImpl);
|
|
309
367
|
const newState = {
|
|
310
368
|
v: OAUTH_STATE_VERSION,
|
|
369
|
+
grantType: "authorization_code",
|
|
311
370
|
actor: reg.actor,
|
|
312
371
|
clientId: reg.clientId,
|
|
313
372
|
clientSecret: reg.clientSecret,
|