@enrichlayer/el-linear 1.10.0 → 1.15.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.
Files changed (106) hide show
  1. package/README.md +126 -10
  2. package/claude-skills/linear-operations/SKILL.md +41 -1
  3. package/dist/auth/linear-credential.d.ts +27 -0
  4. package/dist/auth/linear-credential.js +1 -0
  5. package/dist/auth/oauth-app-config.d.ts +4 -3
  6. package/dist/auth/oauth-app-config.js +13 -2
  7. package/dist/auth/oauth-callback.d.ts +2 -3
  8. package/dist/auth/oauth-callback.js +2 -2
  9. package/dist/auth/oauth-client.d.ts +8 -2
  10. package/dist/auth/oauth-client.js +26 -0
  11. package/dist/auth/oauth-fs.d.ts +2 -1
  12. package/dist/auth/oauth-headless.d.ts +2 -1
  13. package/dist/auth/oauth-storage.d.ts +5 -1
  14. package/dist/auth/oauth-storage.js +1 -1
  15. package/dist/auth/oauth-token.d.ts +4 -3
  16. package/dist/auth/oauth-token.js +16 -4
  17. package/dist/auth/token-resolver.d.ts +14 -5
  18. package/dist/auth/token-resolver.js +6 -1
  19. package/dist/commands/batch.js +18 -21
  20. package/dist/commands/comments.js +5 -5
  21. package/dist/commands/config.js +178 -5
  22. package/dist/commands/init/aliases.js +1 -1
  23. package/dist/commands/init/defaults.d.ts +2 -1
  24. package/dist/commands/init/index.js +45 -35
  25. package/dist/commands/init/oauth.d.ts +4 -1
  26. package/dist/commands/init/oauth.js +22 -4
  27. package/dist/commands/init/shared.d.ts +24 -2
  28. package/dist/commands/init/shared.js +35 -4
  29. package/dist/commands/init/token.d.ts +3 -3
  30. package/dist/commands/init/token.js +5 -24
  31. package/dist/commands/init/workspace.d.ts +2 -1
  32. package/dist/commands/init/workspace.js +1 -1
  33. package/dist/commands/introspect.d.ts +27 -0
  34. package/dist/commands/introspect.js +178 -0
  35. package/dist/commands/issues/branch.js +9 -1
  36. package/dist/commands/issues/relations.d.ts +3 -14
  37. package/dist/commands/issues/relations.js +3 -3
  38. package/dist/commands/issues.js +222 -43
  39. package/dist/commands/labels.js +2 -1
  40. package/dist/commands/profile.js +1 -0
  41. package/dist/commands/projects.d.ts +2 -0
  42. package/dist/commands/projects.js +91 -7
  43. package/dist/commands/read-shortcut.d.ts +1 -1
  44. package/dist/commands/read-shortcut.js +28 -8
  45. package/dist/commands/refs.js +67 -8
  46. package/dist/commands/search.js +30 -5
  47. package/dist/commands/users.js +4 -2
  48. package/dist/config/config.d.ts +99 -1
  49. package/dist/config/config.js +264 -52
  50. package/dist/config/error-enrichment.d.ts +62 -0
  51. package/dist/config/error-enrichment.js +417 -0
  52. package/dist/config/issue-validation.d.ts +37 -0
  53. package/dist/config/issue-validation.js +63 -1
  54. package/dist/config/paths.d.ts +2 -8
  55. package/dist/config/paths.js +4 -2
  56. package/dist/config/resolver.d.ts +8 -1
  57. package/dist/config/resolver.js +9 -2
  58. package/dist/main.js +13 -1
  59. package/dist/queries/comments-types.d.ts +15 -9
  60. package/dist/queries/common.d.ts +2 -2
  61. package/dist/queries/common.js +8 -0
  62. package/dist/queries/documents-types.d.ts +4 -3
  63. package/dist/queries/introspect-types.d.ts +8 -7
  64. package/dist/queries/issues-types.d.ts +92 -27
  65. package/dist/queries/issues.d.ts +49 -10
  66. package/dist/queries/issues.js +125 -5
  67. package/dist/queries/labels-types.d.ts +7 -6
  68. package/dist/queries/project-milestones-types.d.ts +5 -4
  69. package/dist/queries/project-milestones.d.ts +1 -1
  70. package/dist/queries/projects-types.d.ts +8 -7
  71. package/dist/queries/releases-types.d.ts +5 -4
  72. package/dist/queries/search-types.d.ts +28 -12
  73. package/dist/queries/templates-types.d.ts +3 -2
  74. package/dist/types/linear.d.ts +13 -1
  75. package/dist/utils/auto-link-references.d.ts +3 -3
  76. package/dist/utils/auto-link-references.js +1 -10
  77. package/dist/utils/extract-field.d.ts +19 -0
  78. package/dist/utils/extract-field.js +99 -0
  79. package/dist/utils/file-service.d.ts +6 -13
  80. package/dist/utils/file-service.js +0 -2
  81. package/dist/utils/formatters/summary.js +6 -1
  82. package/dist/utils/graphql-issues-service.d.ts +101 -45
  83. package/dist/utils/graphql-issues-service.js +252 -39
  84. package/dist/utils/graphql-service.d.ts +10 -12
  85. package/dist/utils/graphql-service.js +0 -3
  86. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  87. package/dist/utils/issue-reference-extractor.js +5 -3
  88. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  89. package/dist/utils/issues-service-bootstrap.js +27 -0
  90. package/dist/utils/linear-service.d.ts +21 -14
  91. package/dist/utils/linear-service.js +73 -11
  92. package/dist/utils/markdown-prosemirror.js +12 -12
  93. package/dist/utils/mention-resolver.js +1 -1
  94. package/dist/utils/output.d.ts +81 -3
  95. package/dist/utils/output.js +61 -6
  96. package/dist/utils/project-slug.d.ts +21 -0
  97. package/dist/utils/project-slug.js +45 -0
  98. package/dist/utils/protected-ranges.d.ts +14 -0
  99. package/dist/utils/protected-ranges.js +88 -2
  100. package/dist/utils/sanitize-for-log.d.ts +24 -0
  101. package/dist/utils/sanitize-for-log.js +38 -0
  102. package/dist/utils/table-formatter.js +24 -0
  103. package/dist/utils/validators.d.ts +7 -2
  104. package/dist/utils/validators.js +6 -0
  105. package/dist/utils/workspace-url.js +20 -4
  106. package/package.json +2 -2
package/README.md CHANGED
@@ -68,7 +68,7 @@ every team ends up writing themselves:
68
68
  | **Term enforcement** | Catch misspellings of brand and project names in issue titles and descriptions ("EnrichLayer" → "Enrich Layer"). |
69
69
  | **Status defaults** | "No project? → Triage. Has assignee+project? → Todo." Per-workspace, configurable. |
70
70
  | **Auto-link & relate** | When you write `EMW-258` in a description, el-linear wraps it as a markdown link **and** creates the corresponding sidebar relation. Prose like "blocked by EMW-258" infers the relation type. |
71
- | **`--claude` flag** | Tag issues delegated to [Claude Code](https://claude.ai/code) for autonomous work. The label is plain config; the flag is muscle memory. |
71
+ | **Agent delegation** | Use Linear's native `delegate` field for agent work while keeping the human `assignee` responsible. `issues start` moves work to the team's first started status. |
72
72
  | **GraphQL escape hatch** | Anything not covered by built-in commands: `el-linear graphql '{ viewer { id } }'`. Schema introspection included. |
73
73
  | **Bundled Claude skill** | The published tarball includes a `claude-skills/linear-operations/` directory you can symlink into your project's `.claude/skills/`. |
74
74
 
@@ -89,6 +89,7 @@ or pointing `EL_LINEAR_OAUTH_CONFIG` at one:
89
89
  ```json
90
90
  {
91
91
  "linearOAuth": {
92
+ "actor": "user",
92
93
  "clientId": "your-linear-oauth-client-id",
93
94
  "redirectPort": 8765,
94
95
  "scopes": ["read", "write", "issues:create", "comments:create"],
@@ -101,6 +102,15 @@ or pointing `EL_LINEAR_OAUTH_CONFIG` at one:
101
102
  not execute password-manager commands from it. Do not put a `client_secret` in
102
103
  this shared file. The OAuth flow uses PKCE.
103
104
 
105
+ For agent/service-account OAuth, authorize the app actor:
106
+
107
+ ```bash
108
+ el-linear init oauth --actor app
109
+ ```
110
+
111
+ App actor tokens can request `app:assignable` and `app:mentionable`, but not
112
+ `admin`. The authorized app user ID is stored in `oauth.json` as `viewerId`.
113
+
104
114
  At runtime, credentials are resolved in this order:
105
115
 
106
116
  1. `--api-token <token>` flag.
@@ -250,6 +260,89 @@ network call (offline use, perf, `--no-validate`), provide it via any of:
250
260
 
251
261
  When any of these is set, no GraphQL request is made.
252
262
 
263
+ ### Shared team config
264
+
265
+ Teams can check a shared config file into a repository (e.g. a Tools repo) and
266
+ have every member load it automatically. The team file holds things that are the
267
+ same for everyone — member aliases, label maps, term rules, validation settings —
268
+ while personal config holds individual preferences and tokens.
269
+
270
+ **Merge order (lowest → highest priority):**
271
+
272
+ 1. Built-in defaults
273
+ 2. Team config file
274
+ 3. Personal `~/.config/el-linear/config.json` (or profile config)
275
+
276
+ Personal config wins on scalar conflicts. Arrays (`terms`, `defaultLabels`) are
277
+ **concatenated** — personal entries are appended to team entries, so you extend
278
+ the team rules rather than replace them.
279
+
280
+ **Set the team config path** with the `config team` subcommand — it
281
+ resolves relative / `~/...` paths, validates the file before writing, and
282
+ performs an atomic file-locked update:
283
+
284
+ ```bash
285
+ el-linear config team set-path /path/to/tools-repo/.el-linear/config.json
286
+ el-linear config team show # confirm path + the keys it contributes
287
+ el-linear config team clear # remove the pointer
288
+ ```
289
+
290
+ The command writes `teamConfigPath` into your personal `config.json`, so
291
+ the setting persists across shells. To override the persistent setting
292
+ per-invocation, use the `EL_LINEAR_TEAM_CONFIG` env var (highest
293
+ priority):
294
+
295
+ ```bash
296
+ EL_LINEAR_TEAM_CONFIG=/path/to/tools-repo/.el-linear/config.json el-linear issues create ...
297
+ ```
298
+
299
+ If you prefer to edit the personal config by hand, the field looks like:
300
+
301
+ ```json
302
+ { "teamConfigPath": "/path/to/tools-repo/.el-linear/config.json" }
303
+ ```
304
+
305
+ — but the CLI path is preferred (it catches missing-file and invalid-JSON
306
+ mistakes before they silently fall back to no team layer).
307
+
308
+ **Auto-discovery from an onboarding marker.** As a third fallback, when
309
+ neither the env var nor `teamConfigPath` is set, `el-linear` reads
310
+ `~/.config/el-tools-root` (written by EL onboarding's `link-project.sh`)
311
+ and uses `<root>/config/el-linear.shared.json` if it exists. This means
312
+ developers who've already run onboarding get the team layer for free —
313
+ no extra `set-path` step. The marker is the opt-in consent signal; users
314
+ without it (the OSS audience) see no behavior change. Resolution order
315
+ in full:
316
+
317
+ 1. `EL_LINEAR_TEAM_CONFIG` env var
318
+ 2. `teamConfigPath` in personal `config.json`
319
+ 3. `~/.config/el-tools-root` marker → `<root>/config/el-linear.shared.json`
320
+
321
+ `el-linear config team show` reports which of these resolved (`source`
322
+ field: `env` / `personal` / `marker` / `null`).
323
+
324
+ **The team config file** is a standard `config.json` fragment — any `ElLinearConfig`
325
+ field is valid except `teamConfigPath` itself. Keep it free of tokens and personal
326
+ preferences; those stay in personal config. Example team file:
327
+
328
+ ```json
329
+ {
330
+ "members": {
331
+ "aliases": { "alice": "Alice Anderson", "bob": "Bob Barnes" },
332
+ "uuids": { "Alice Anderson": "<uuid>", "Bob Barnes": "<uuid>" }
333
+ },
334
+ "teams": { "ENG": "<uuid>", "OPS": "<uuid>" },
335
+ "labels": { "workspace": { "claude": "<uuid>" } },
336
+ "terms": [
337
+ { "canonical": "Enrich Layer", "reject": ["EnrichLayer", "enrichlayer"] }
338
+ ],
339
+ "validation": { "enabled": true }
340
+ }
341
+ ```
342
+
343
+ Run `el-linear config show` to see the resolved config and confirm which team
344
+ config path is active (`teamConfig` field in the output).
345
+
253
346
  ## Term enforcement (with brand-promotion examples)
254
347
 
255
348
  The `terms` rules let you keep a list of canonical names and the misspellings
@@ -277,20 +370,23 @@ are allowed even though `enrichlayer` is rejected.
277
370
 
278
371
  If you don't define any rules, term enforcement is a no-op.
279
372
 
280
- ## The `--claude` delegation pattern
373
+ ## Native Agent Delegation
281
374
 
282
- `el-linear issues create` accepts `--claude`, which applies the workspace label
283
- configured at `config.labels.workspace.claude`. The label is the contract:
284
- it tells [Claude Code](https://claude.ai/code) "this issue is delegated to
285
- you for autonomous execution."
375
+ Linear now has a native issue `delegate` field for agent work. Use `assignee`
376
+ for the accountable human and `delegate` for the agent app user doing the
377
+ implementation.
286
378
 
287
379
  ```bash
288
380
  el-linear issues create "Migrate auth middleware to new session store" \
289
- --team ENG --assignee alice --project "Auth Refactor" \
290
- --description "..." --claude
381
+ --team ENG --assignee alice --delegate claude --project "Auth Refactor" \
382
+ --description "..."
383
+
384
+ el-linear issues start ENG-123
291
385
  ```
292
386
 
293
- Claude finds delegated work with `el-linear issues search "claude" --status "Todo"`.
387
+ Agents find delegated work with `el-linear issues list --delegate claude`.
388
+ `--claude` still applies the legacy configured label, but native `--delegate`
389
+ is the preferred contract.
294
390
 
295
391
  ## Commands at a glance
296
392
 
@@ -301,7 +397,7 @@ el-linear <command> --help # detailed help for one command
301
397
 
302
398
  | Group | Common commands |
303
399
  |-------|-----------------|
304
- | Issues | `issues {list, search, create, read, update, delete, history, related, link-references}` |
400
+ | Issues | `issues {list, search, create, read, update, start, delete, history, related, link-references}` |
305
401
  | Comments | `comments {list, create, update}` |
306
402
  | Labels | `labels {list, create, retire, restore}` |
307
403
  | Projects | `projects {list, add-team, remove-team}` |
@@ -385,6 +481,26 @@ global `summary` value works on every read/list command.
385
481
  `--raw` together with `--format summary` to render a list envelope as a
386
482
  bare item-list rather than an envelope.
387
483
 
484
+ ### Extract a single description section: `--field`
485
+
486
+ `issues read --field <name>` extracts one named section from an issue's
487
+ markdown description and prints just that section's text — no JSON
488
+ envelope. Matches `##`/`###` headers and bold pseudo-headers
489
+ (`**Done when**`) case-insensitively. Returns exit code 1 with a stderr
490
+ message when the section is missing.
491
+
492
+ ```bash
493
+ el-linear issues read DEV-123 --field "Done when"
494
+ # - Thing A
495
+ # - Thing B
496
+
497
+ el-linear read ADM-652 --field "Out of scope"
498
+ # Stuff we won't do.
499
+ ```
500
+
501
+ Single-issue only — pair it with `--jq` on full JSON for batch
502
+ extraction across many issues.
503
+
388
504
  ## Wrapping Linear references in arbitrary text
389
505
 
390
506
  `el-linear refs wrap` takes plain text on stdin (or via `--file`) and rewrites
@@ -58,6 +58,24 @@ el-linear issues read DEV-123 --format summary 2>&1
58
58
 
59
59
  The summary formatter exists exactly because every consumer (humans and LLMs) was reinventing the same `python -c` / `jq` extraction in shell. Pick the canonical path; the per-resource format is a stable contract.
60
60
 
61
+ ### Extracting one description section: `--field`
62
+
63
+ When you need a single named section out of an issue's markdown description (e.g. "Done when", "Out of scope", "Why we need this"), use `issues read --field`. It matches `##`/`###` headers and bold pseudo-headers (`**Done when**`) case-insensitively, prints just that section's text, and exits non-zero when the section is missing — the canonical replacement for piping into `python3 -c "...desc.find(...)"`.
64
+
65
+ ```bash
66
+ # ✅ One section, plain text, scriptable
67
+ el-linear issues read DEV-123 --field "Done when" 2>&1
68
+
69
+ # ❌ Don't do this
70
+ el-linear issues read DEV-123 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['description'].split('## Done when')[1].split('##')[0])"
71
+ ```
72
+
73
+ `--field` is single-issue only — for batch extraction, fall back to `--jq` on the full JSON.
74
+
75
+ ### When you must reach outside el-linear: prefer `jq` over `python3 -c`
76
+
77
+ For tools that aren't `el-linear` (e.g. `gh`, `glab`, `kubectl`), prefer `jq` for JSON extraction in one-shot shell commands. `python3 -c "import json,sys; ..."` produces longer, harder-to-read pipelines, and tends to attract incremental complexity (try/except, fallbacks) that `jq` handles inline. Reach for python only when the transformation genuinely needs control flow that's painful in `jq` (e.g. multi-step assembly with intermediate state).
78
+
61
79
  ### Coverage
62
80
 
63
81
  `--format summary` is implemented for:
@@ -129,6 +147,24 @@ el-linear issues related ENG-123 2>&1
129
147
 
130
148
  Returns all relations (related, blocks, blockedBy, duplicate) with direction, state, and assignee. Use this before creating follow-up work to understand the context around an issue.
131
149
 
150
+ ### Adding relations to an existing issue
151
+
152
+ To attach a relation to an issue that **already exists** (triage, splitting findings into separate issues, backfilling related work), use `issues relate` — or pass the same flags to `issues update`:
153
+
154
+ ```bash
155
+ # Dedicated relation command
156
+ el-linear issues relate ENG-123 --related-to "ENG-456,ENG-789" 2>&1
157
+ el-linear issues relate ENG-123 --blocked-by "ENG-400" 2>&1
158
+ el-linear issues relate ENG-123 --duplicate-of "ENG-111" 2>&1
159
+
160
+ # Or alongside a field update in one call
161
+ el-linear issues update ENG-123 --status "In Progress" --related-to "ENG-456" 2>&1
162
+ ```
163
+
164
+ Both `issues relate` and `issues update` accept `--related-to`, `--blocks`, `--blocked-by`, and `--duplicate-of` (comma-separated identifiers; `--duplicate-of` takes a single issue). `issues create` accepts the same flags.
165
+
166
+ > **Gotcha:** `--related-to` is **not** valid on `issues update` in el-linear < 1.12. On older versions it fails with `error: unknown option '--related-to'` — use `el-linear issues relate <id> --related-to …` instead. `issues relate` has always supported it.
167
+
132
168
  ### Auto-linking issue references on create/update
133
169
 
134
170
  `el-linear issues create`, `el-linear issues update`, `el-linear comments create`, and `el-linear comments update` run the same auto-linking flow on text being written:
@@ -333,7 +369,11 @@ el-linear projects add-team "Project Name" ENG 2>&1
333
369
 
334
370
  ### Discovery Before Creation
335
371
 
336
- Always check if a project exists before creating: `el-linear projects list --limit 50`.
372
+ Always check if a project exists before creating: `el-linear projects list`.
373
+
374
+ The CLI emits a `results_truncated` warning in `_warnings` when the result set hits `--limit` — bump `--limit` (the suggested next size is in the warning text) or narrow via `--name` / `--state` and retry. Linear URLs and bare slug-ids (`https://linear.app/<workspace>/project/<slug>-<12-hex>/...` or bare `<slug>-<12-hex>`) resolve directly when passed to `--project` — no need to extract a human-readable name yourself.
375
+
376
+ **If the user names a project that doesn't surface, do not substitute a different one.** Broaden the search (drop `--state` / `--active` / `--name` filters, then bump `--limit`); if it still doesn't appear, **ask** the user before falling back. Substituting a wrong project silently is worse than asking one extra question.
337
377
 
338
378
  ---
339
379
 
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared credential shape consumed by the Linear-talking service classes
3
+ * (`GraphQLService`, `LinearService`, `FileService`). Pre-DEV-4068 T7,
4
+ * each service declared its own `*ServiceAuth` type — three byte-identical
5
+ * unions plus a deprecated `string` arm that only tests exercised. The
6
+ * three duplicates would inevitably drift; the single shared shape forces
7
+ * lock-step.
8
+ *
9
+ * The discriminant is structural — TypeScript narrows on `"apiKey" in
10
+ * auth` vs `"oauthToken" in auth`. An explicit `kind` tag would force
11
+ * every call site (including ~40 tests) to thread it through; the
12
+ * structural form keeps the existing `{ apiKey: "..." }` /
13
+ * `{ oauthToken: "..." }` literal usage working unchanged.
14
+ *
15
+ * The runtime difference between the two arms is the `Authorization`
16
+ * header shape:
17
+ * - apiKey: `Authorization: <token>` (no Bearer prefix)
18
+ * - oauthToken: `Authorization: Bearer <token>`
19
+ *
20
+ * The `string` arm was dropped — tests now construct with
21
+ * `{ apiKey: "test-token" }` (mechanical rewrite, no semantic change).
22
+ */
23
+ export type LinearCredential = {
24
+ apiKey: string;
25
+ } | {
26
+ oauthToken: string;
27
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -5,9 +5,9 @@
5
5
  * materialize it from a password manager, while the OSS CLI keeps requiring
6
6
  * users to bring their own OAuth app when no local file exists.
7
7
  */
8
- import { type OAuthScope } from "./oauth-client.js";
9
- export declare const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
10
- export interface TeamOAuthConfig {
8
+ import { type OAuthActor, type OAuthScope } from "./oauth-client.js";
9
+ interface TeamOAuthConfig {
10
+ actor: OAuthActor;
11
11
  clientId: string;
12
12
  redirectPort: number;
13
13
  scopes: OAuthScope[];
@@ -19,3 +19,4 @@ export interface TeamOAuthConfig {
19
19
  sourcePath: string;
20
20
  }
21
21
  export declare function readTeamOAuthConfig(env?: NodeJS.ProcessEnv): Promise<TeamOAuthConfig | null>;
22
+ export {};
@@ -7,8 +7,8 @@
7
7
  */
8
8
  import fs from "node:fs/promises";
9
9
  import { TEAM_OAUTH_CONFIG_PATH } from "../config/paths.js";
10
- import { DEFAULT_SCOPES, validateScopes, } from "./oauth-client.js";
11
- export const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
10
+ import { DEFAULT_SCOPES, validateActorScopes, validateOAuthActor, validateScopes, } from "./oauth-client.js";
11
+ const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
12
12
  export async function readTeamOAuthConfig(env = process.env) {
13
13
  const envPath = env[TEAM_OAUTH_CONFIG_ENV]?.trim();
14
14
  const sourcePath = envPath || TEAM_OAUTH_CONFIG_PATH;
@@ -43,8 +43,11 @@ export async function readTeamOAuthConfig(env = process.env) {
43
43
  }
44
44
  const redirectPort = parseRedirectPort(linearOAuth.redirectPort, sourcePath);
45
45
  const scopes = parseScopes(linearOAuth.scopes, sourcePath);
46
+ const actor = parseActor(linearOAuth.actor, sourcePath);
47
+ validateActorScopes(actor, scopes);
46
48
  const passwordManagerPath = parsePasswordManagerPath(linearOAuth.passwordManagerPath, sourcePath);
47
49
  return {
50
+ actor,
48
51
  clientId,
49
52
  redirectPort,
50
53
  scopes,
@@ -52,6 +55,14 @@ export async function readTeamOAuthConfig(env = process.env) {
52
55
  sourcePath,
53
56
  };
54
57
  }
58
+ function parseActor(value, sourcePath) {
59
+ if (value === undefined)
60
+ return "user";
61
+ if (typeof value !== "string") {
62
+ throw new Error(`${sourcePath} linearOAuth.actor must be "user" or "app".`);
63
+ }
64
+ return validateOAuthActor(value);
65
+ }
55
66
  function parseRedirectPort(value, sourcePath) {
56
67
  if (value === undefined)
57
68
  return 8765;
@@ -18,9 +18,7 @@
18
18
  import { type Server } from "node:http";
19
19
  import { type CallbackParams } from "./oauth-client.js";
20
20
  export declare const DEFAULT_CALLBACK_PATH = "/oauth/callback";
21
- export declare const DEFAULT_LISTEN_HOST = "127.0.0.1";
22
- export declare const DEFAULT_TIMEOUT_MS: number;
23
- export interface CallbackOptions {
21
+ interface CallbackOptions {
24
22
  port: number;
25
23
  expectedState: string;
26
24
  host?: string;
@@ -38,3 +36,4 @@ export interface CallbackOptions {
38
36
  * CI).
39
37
  */
40
38
  export declare function runLocalhostCallback(options: CallbackOptions, serverFactory?: () => Server): Promise<CallbackParams>;
39
+ export {};
@@ -18,8 +18,8 @@
18
18
  import { createServer } from "node:http";
19
19
  import { parseCallbackUrl } from "./oauth-client.js";
20
20
  export const DEFAULT_CALLBACK_PATH = "/oauth/callback";
21
- export const DEFAULT_LISTEN_HOST = "127.0.0.1";
22
- export const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
21
+ const DEFAULT_LISTEN_HOST = "127.0.0.1";
22
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
23
23
  const SUCCESS_HTML = `<!doctype html>
24
24
  <html lang="en">
25
25
  <head><meta charset="utf-8"><title>el-linear · authorized</title>
@@ -3,11 +3,12 @@ export declare const DEFAULT_SCOPES: readonly OAuthScope[];
3
3
  /** All scopes Linear advertises. Keep in sync with their docs. */
4
4
  export declare const ALL_SCOPES: readonly ["read", "write", "issues:create", "comments:create", "timeSchedule:write", "admin", "app:assignable", "app:mentionable"];
5
5
  export type OAuthScope = (typeof ALL_SCOPES)[number];
6
+ export type OAuthActor = "user" | "app";
6
7
  export declare const SCOPE_DESCRIPTIONS: Record<OAuthScope, string>;
7
8
  export declare const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
8
9
  export declare const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
9
10
  export declare const LINEAR_REVOKE_URL = "https://api.linear.app/oauth/revoke";
10
- export interface PkcePair {
11
+ interface PkcePair {
11
12
  verifier: string;
12
13
  challenge: string;
13
14
  method: "S256";
@@ -22,12 +23,14 @@ export interface PkcePair {
22
23
  export declare function generatePkce(): PkcePair;
23
24
  /** Cryptographically random `state` parameter. 16 bytes → 22-char b64url. */
24
25
  export declare function generateState(): string;
25
- export interface AuthorizeUrlInput {
26
+ interface AuthorizeUrlInput {
26
27
  clientId: string;
27
28
  redirectUri: string;
28
29
  scopes: readonly string[];
29
30
  state: string;
30
31
  codeChallenge: string;
32
+ /** `app` makes mutations appear as the OAuth app user. */
33
+ actor?: OAuthActor;
31
34
  /** "consent" forces the consent screen even for previously-authorized users. */
32
35
  prompt?: "consent";
33
36
  }
@@ -53,3 +56,6 @@ export declare function parseCallbackUrl(rawUrl: string): CallbackParams;
53
56
  * tuple; throws on any unknown scope (typo guard).
54
57
  */
55
58
  export declare function validateScopes(scopes: readonly string[]): OAuthScope[];
59
+ export declare function validateOAuthActor(value: string | undefined): OAuthActor;
60
+ export declare function validateActorScopes(actor: OAuthActor, scopes: readonly OAuthScope[]): void;
61
+ export {};
@@ -35,6 +35,10 @@ export const ALL_SCOPES = [
35
35
  "app:assignable",
36
36
  "app:mentionable",
37
37
  ];
38
+ const APP_ONLY_SCOPES = new Set([
39
+ "app:assignable",
40
+ "app:mentionable",
41
+ ]);
38
42
  export const SCOPE_DESCRIPTIONS = {
39
43
  read: "Read access to the user's account.",
40
44
  write: "Write access. Without admin, also requires issues:create / comments:create for those mutations.",
@@ -86,6 +90,9 @@ export function buildAuthorizeUrl(input) {
86
90
  url.searchParams.set("state", input.state);
87
91
  url.searchParams.set("code_challenge", input.codeChallenge);
88
92
  url.searchParams.set("code_challenge_method", "S256");
93
+ if (input.actor && input.actor !== "user") {
94
+ url.searchParams.set("actor", input.actor);
95
+ }
89
96
  if (input.prompt) {
90
97
  url.searchParams.set("prompt", input.prompt);
91
98
  }
@@ -132,3 +139,22 @@ export function validateScopes(scopes) {
132
139
  }
133
140
  return out;
134
141
  }
142
+ export function validateOAuthActor(value) {
143
+ if (!value) {
144
+ return "user";
145
+ }
146
+ const trimmed = value.trim().toLowerCase();
147
+ if (trimmed === "user" || trimmed === "app") {
148
+ return trimmed;
149
+ }
150
+ throw new Error('OAuth actor must be either "user" or "app".');
151
+ }
152
+ export function validateActorScopes(actor, scopes) {
153
+ if (actor === "app" && scopes.includes("admin")) {
154
+ throw new Error('OAuth actor "app" cannot request the admin scope. Remove admin or use actor "user".');
155
+ }
156
+ const hasAppOnlyScope = scopes.some((scope) => APP_ONLY_SCOPES.has(scope));
157
+ if (actor !== "app" && hasAppOnlyScope) {
158
+ throw new Error('Scopes app:assignable/app:mentionable require OAuth actor "app". Re-run with `--actor app` or set linearOAuth.actor to "app".');
159
+ }
160
+ }
@@ -1,5 +1,5 @@
1
1
  export declare function atomicWrite(targetPath: string, data: string | Uint8Array, mode?: number): Promise<void>;
2
- export interface FileLockOptions {
2
+ interface FileLockOptions {
3
3
  /** Treat a lock older than this as crashed and steal it. Default 30s. */
4
4
  staleAfterMs?: number;
5
5
  /** Maximum time to wait for the lock before giving up. Default 30s. */
@@ -21,3 +21,4 @@ export interface FileLockOptions {
21
21
  * anyway).
22
22
  */
23
23
  export declare function withFileLock<T>(targetPath: string, fn: () => Promise<T>, options?: FileLockOptions): Promise<T>;
24
+ export {};
@@ -16,7 +16,7 @@
16
16
  * whether the URL is reachable.
17
17
  */
18
18
  import { type CallbackParams } from "./oauth-client.js";
19
- export interface PromptForPastedCodeOptions {
19
+ interface PromptForPastedCodeOptions {
20
20
  expectedState: string;
21
21
  /**
22
22
  * Allow the user to paste a bare authorization code without a
@@ -41,3 +41,4 @@ export interface PromptForPastedCodeOptions {
41
41
  * behalf. Opt in with `--unsafe-bare-code` (`unsafeBareCode: true`).
42
42
  */
43
43
  export declare function promptForPastedCode(options: PromptForPastedCodeOptions): Promise<CallbackParams>;
44
+ export {};
@@ -1,11 +1,15 @@
1
+ import type { OAuthActor } from "./oauth-client.js";
1
2
  export declare const OAUTH_STATE_VERSION = 1;
2
- export declare const OAUTH_STATE_FILENAME = "oauth.json";
3
3
  /**
4
4
  * Persisted OAuth state. Mirrors what we got back from Linear's token
5
5
  * endpoint plus the bits we need to refresh / re-authorize.
6
6
  */
7
7
  export interface OAuthState {
8
8
  v: typeof OAUTH_STATE_VERSION;
9
+ /** Linear OAuth actor tied to the access token. Defaults to user for legacy state. */
10
+ actor?: OAuthActor;
11
+ /** viewer.id returned after authorization; for actor=app this is the app user ID. */
12
+ viewerId?: string;
9
13
  clientId: string;
10
14
  clientSecret?: string;
11
15
  registeredRedirectUri: string;
@@ -15,7 +15,7 @@ import path from "node:path";
15
15
  import { CONFIG_DIR, resolveActiveProfile } from "../config/paths.js";
16
16
  import { atomicWrite } from "./oauth-fs.js";
17
17
  export const OAUTH_STATE_VERSION = 1;
18
- export const OAUTH_STATE_FILENAME = "oauth.json";
18
+ const OAUTH_STATE_FILENAME = "oauth.json";
19
19
  /**
20
20
  * Resolve the path to the active profile's `oauth.json`. Mirrors
21
21
  * `activePaths()` in `commands/init/shared.ts` so OAuth state lands in the
@@ -25,19 +25,19 @@ export type FetchLike = (url: string, init: {
25
25
  statusText: string;
26
26
  text(): Promise<string>;
27
27
  }>;
28
- export interface ExchangeCodeInput {
28
+ interface ExchangeCodeInput {
29
29
  clientId: string;
30
30
  clientSecret?: string;
31
31
  code: string;
32
32
  redirectUri: string;
33
33
  codeVerifier: string;
34
34
  }
35
- export interface RefreshTokensInput {
35
+ interface RefreshTokensInput {
36
36
  clientId: string;
37
37
  clientSecret?: string;
38
38
  refreshToken: string;
39
39
  }
40
- export interface RevokeTokenInput {
40
+ interface RevokeTokenInput {
41
41
  accessToken: string;
42
42
  }
43
43
  export interface ExchangeResult {
@@ -68,3 +68,4 @@ export declare function revokeToken(input: RevokeTokenInput, fetchImpl?: FetchLi
68
68
  status: number;
69
69
  message?: string;
70
70
  }>;
71
+ export {};
@@ -10,6 +10,7 @@
10
10
  * caller injects is the URL fetcher, so tests can mock without touching the
11
11
  * network.
12
12
  */
13
+ import { sanitizeForLog } from "../utils/sanitize-for-log.js";
13
14
  import { LINEAR_REVOKE_URL, LINEAR_TOKEN_URL, } from "./oauth-client.js";
14
15
  const defaultFetch = async (url, init) => {
15
16
  const res = await globalThis.fetch(url, init);
@@ -42,13 +43,19 @@ async function postForm(url, params, fetchImpl) {
42
43
  if (!res.ok) {
43
44
  // Don't dump the request body — it contains client_secret /
44
45
  // refresh_token / authorization code, all of which are secrets.
45
- throw new Error(`OAuth endpoint ${url} responded ${res.status} ${res.statusText}: ${text || "(empty body)"}`);
46
+ // Sanitize the response body at source too: an MITM, misconfigured
47
+ // proxy, or buggy upstream that echoes the request headers back in
48
+ // its error body would otherwise leak `Bearer <token>` into the
49
+ // error chain. Defense in depth on top of outputError's
50
+ // sanitization (DEV-4065).
51
+ const safe = sanitizeForLog(text || "(empty body)");
52
+ throw new Error(`OAuth endpoint ${url} responded ${res.status} ${res.statusText}: ${safe}`);
46
53
  }
47
54
  try {
48
55
  return JSON.parse(text);
49
56
  }
50
57
  catch {
51
- throw new Error(`OAuth endpoint ${url} returned non-JSON response: ${text.slice(0, 200)}`);
58
+ throw new Error(`OAuth endpoint ${url} returned non-JSON response: ${sanitizeForLog(text.slice(0, 200))}`);
52
59
  }
53
60
  }
54
61
  function parseScopes(raw) {
@@ -128,14 +135,19 @@ export async function revokeToken(input, fetchImpl = defaultFetch) {
128
135
  },
129
136
  body: "",
130
137
  });
138
+ // Sanitize at source for symmetry with postForm's error branch — a
139
+ // misconfigured proxy that echoes the request's Authorization header
140
+ // in its 5xx body would otherwise surface the bearer token through
141
+ // `message`. The two call sites in `commands/init/oauth.ts` already
142
+ // re-sanitize at emission, but defense in depth (DEV-4065).
131
143
  return {
132
144
  ok: res.ok,
133
145
  status: res.status,
134
- message: res.ok ? undefined : await res.text(),
146
+ message: res.ok ? undefined : sanitizeForLog(await res.text()),
135
147
  };
136
148
  }
137
149
  catch (err) {
138
150
  const message = err instanceof Error ? err.message : String(err);
139
- return { ok: false, status: 0, message };
151
+ return { ok: false, status: 0, message: sanitizeForLog(message) };
140
152
  }
141
153
  }
@@ -17,12 +17,21 @@
17
17
  */
18
18
  import { type OAuthState } from "./oauth-storage.js";
19
19
  import { type FetchLike } from "./oauth-token.js";
20
- export interface ActiveAuth {
21
- kind: "personal" | "oauth";
20
+ /**
21
+ * Resolved credential for a CLI invocation. Discriminated on `kind` so
22
+ * `oauth` is non-optional in the `"oauth"` arm and forbidden in the
23
+ * `"personal"` arm — eliminates the "personal auth with stale oauth field"
24
+ * foot-gun the flat shape allowed.
25
+ */
26
+ export type ActiveAuth = {
27
+ kind: "personal";
22
28
  token: string;
23
- /** Original OAuth state, when `kind === "oauth"`. */
24
- oauth?: OAuthState;
25
- }
29
+ } | {
30
+ kind: "oauth";
31
+ token: string;
32
+ /** Original OAuth state — refresh-time bookkeeping. */
33
+ oauth: OAuthState;
34
+ };
26
35
  export interface GetActiveAuthOptions {
27
36
  apiToken?: string;
28
37
  /** Test seam: override the network fetcher used by refresh. */
@@ -16,6 +16,7 @@
16
16
  * - oauth: `Authorization: Bearer <token>`
17
17
  */
18
18
  import { getApiToken } from "../utils/auth.js";
19
+ import { sanitizeForLog } from "../utils/sanitize-for-log.js";
19
20
  import { withFileLock } from "./oauth-fs.js";
20
21
  import { oauthStatePath, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
21
22
  import { refreshTokens, } from "./oauth-token.js";
@@ -94,7 +95,11 @@ export async function ensureFreshAccessToken(state, options = {}) {
94
95
  }
95
96
  catch (err) {
96
97
  const message = err instanceof Error ? err.message : String(err);
97
- throw new Error(`OAuth refresh failed: ${message}. Re-run \`el-linear init oauth\` to re-authorize.`);
98
+ // Sanitize again here even though refreshTokens already does
99
+ // so at source — defense in depth, in case a future caller
100
+ // (or a wrapper that catches+rethrows) inserts an unsanitized
101
+ // 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.`);
98
103
  }
99
104
  const next = {
100
105
  ...current,