@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 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: string;
17
+ registeredRedirectUri?: string;
16
18
  accessToken: string;
17
19
  refreshToken?: string;
18
20
  tokenType: string;
19
- scopes: string[];
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.
@@ -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
- if (now() + 60_000 < state.expiresAt) {
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
- refreshed = await refreshTokens({
91
- clientId: current.clientId,
92
- clientSecret: current.clientSecret,
93
- refreshToken: current.refreshToken,
94
- }, options.fetchImpl, now);
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 refresh failed: ${sanitizeForLog(message)}. Re-run \`el-linear init oauth\` to re-authorize.`);
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.match(/:(\d+)\//);
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
- if (existing && !options.force) {
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,