@enrichlayer/el-linear 1.9.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.
- package/README.md +139 -10
- package/claude-skills/linear-operations/SKILL.md +41 -1
- package/dist/auth/linear-credential.d.ts +27 -0
- package/dist/auth/linear-credential.js +1 -0
- package/dist/auth/oauth-app-config.d.ts +4 -3
- package/dist/auth/oauth-app-config.js +13 -2
- package/dist/auth/oauth-callback.d.ts +2 -3
- package/dist/auth/oauth-callback.js +2 -2
- package/dist/auth/oauth-client.d.ts +8 -2
- package/dist/auth/oauth-client.js +26 -0
- package/dist/auth/oauth-fs.d.ts +2 -1
- package/dist/auth/oauth-headless.d.ts +2 -1
- package/dist/auth/oauth-storage.d.ts +5 -1
- package/dist/auth/oauth-storage.js +1 -1
- package/dist/auth/oauth-token.d.ts +4 -3
- package/dist/auth/oauth-token.js +16 -4
- package/dist/auth/token-resolver.d.ts +14 -5
- package/dist/auth/token-resolver.js +6 -1
- package/dist/commands/attachments.js +2 -1
- package/dist/commands/batch.js +18 -21
- package/dist/commands/comments.js +22 -33
- package/dist/commands/config.js +178 -5
- package/dist/commands/cycles.js +2 -1
- package/dist/commands/documents.js +2 -1
- package/dist/commands/graphql.js +4 -6
- package/dist/commands/init/aliases.js +1 -1
- package/dist/commands/init/defaults.d.ts +2 -1
- package/dist/commands/init/index.js +45 -35
- package/dist/commands/init/oauth.d.ts +4 -1
- package/dist/commands/init/oauth.js +22 -4
- package/dist/commands/init/shared.d.ts +24 -2
- package/dist/commands/init/shared.js +35 -4
- package/dist/commands/init/token.d.ts +3 -3
- package/dist/commands/init/token.js +5 -24
- package/dist/commands/init/workspace.d.ts +2 -1
- package/dist/commands/init/workspace.js +1 -1
- package/dist/commands/introspect.d.ts +27 -0
- package/dist/commands/introspect.js +178 -0
- package/dist/commands/issue-id.js +1 -3
- package/dist/commands/issues/branch.js +9 -1
- package/dist/commands/issues/description.js +2 -6
- package/dist/commands/issues/link-references.d.ts +21 -0
- package/dist/commands/issues/link-references.js +171 -0
- package/dist/commands/issues/relations.d.ts +44 -0
- package/dist/commands/issues/relations.js +132 -0
- package/dist/commands/issues.js +269 -309
- package/dist/commands/labels.js +15 -24
- package/dist/commands/profile.js +1 -0
- package/dist/commands/project-milestones.js +13 -20
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +157 -44
- package/dist/commands/read-shortcut.d.ts +1 -1
- package/dist/commands/read-shortcut.js +28 -8
- package/dist/commands/refs.js +75 -8
- package/dist/commands/releases.js +26 -30
- package/dist/commands/search.js +49 -33
- package/dist/commands/teams.js +2 -1
- package/dist/commands/templates.js +9 -14
- package/dist/commands/users.js +5 -2
- package/dist/config/config.d.ts +99 -1
- package/dist/config/config.js +264 -52
- package/dist/config/error-enrichment.d.ts +62 -0
- package/dist/config/error-enrichment.js +417 -0
- package/dist/config/issue-validation.d.ts +37 -0
- package/dist/config/issue-validation.js +63 -1
- package/dist/config/paths.d.ts +2 -8
- package/dist/config/paths.js +4 -2
- package/dist/config/resolver.d.ts +8 -1
- package/dist/config/resolver.js +11 -5
- package/dist/main.js +13 -1
- package/dist/queries/attachments-types.d.ts +30 -0
- package/dist/queries/attachments-types.js +5 -0
- package/dist/queries/comments-types.d.ts +55 -0
- package/dist/queries/comments-types.js +5 -0
- package/dist/queries/common.d.ts +2 -2
- package/dist/queries/common.js +8 -0
- package/dist/queries/documents-types.d.ts +62 -0
- package/dist/queries/documents-types.js +9 -0
- package/dist/queries/introspect-types.d.ts +58 -0
- package/dist/queries/introspect-types.js +10 -0
- package/dist/queries/issues-types.d.ts +481 -0
- package/dist/queries/issues-types.js +23 -0
- package/dist/queries/issues.d.ts +51 -10
- package/dist/queries/issues.js +147 -5
- package/dist/queries/labels-types.d.ts +65 -0
- package/dist/queries/labels-types.js +5 -0
- package/dist/queries/project-milestones-types.d.ts +92 -0
- package/dist/queries/project-milestones-types.js +10 -0
- package/dist/queries/project-milestones.d.ts +1 -1
- package/dist/queries/projects-types.d.ts +76 -0
- package/dist/queries/projects-types.js +5 -0
- package/dist/queries/projects.d.ts +2 -0
- package/dist/queries/projects.js +22 -0
- package/dist/queries/releases-types.d.ts +85 -0
- package/dist/queries/releases-types.js +5 -0
- package/dist/queries/search-types.d.ts +102 -0
- package/dist/queries/search-types.js +6 -0
- package/dist/queries/templates-types.d.ts +62 -0
- package/dist/queries/templates-types.js +9 -0
- package/dist/types/linear.d.ts +21 -3
- package/dist/utils/auto-link-references.d.ts +3 -3
- package/dist/utils/auto-link-references.js +30 -34
- package/dist/utils/extract-field.d.ts +19 -0
- package/dist/utils/extract-field.js +99 -0
- package/dist/utils/file-service.d.ts +6 -13
- package/dist/utils/file-service.js +0 -2
- package/dist/utils/formatters/summary.js +6 -1
- package/dist/utils/graphql-attachments-service.js +6 -9
- package/dist/utils/graphql-documents-service.js +19 -25
- package/dist/utils/graphql-issues-service.d.ts +112 -46
- package/dist/utils/graphql-issues-service.js +398 -206
- package/dist/utils/graphql-service.d.ts +10 -12
- package/dist/utils/graphql-service.js +0 -3
- package/dist/utils/issue-reference-extractor.d.ts +7 -0
- package/dist/utils/issue-reference-extractor.js +5 -3
- package/dist/utils/issues-service-bootstrap.d.ts +28 -0
- package/dist/utils/issues-service-bootstrap.js +27 -0
- package/dist/utils/linear-service.d.ts +21 -14
- package/dist/utils/linear-service.js +73 -11
- package/dist/utils/markdown-prosemirror.js +12 -12
- package/dist/utils/mention-resolver.js +1 -1
- package/dist/utils/output.d.ts +82 -2
- package/dist/utils/output.js +76 -11
- package/dist/utils/project-slug.d.ts +21 -0
- package/dist/utils/project-slug.js +45 -0
- package/dist/utils/protected-ranges.d.ts +14 -0
- package/dist/utils/protected-ranges.js +88 -2
- package/dist/utils/sanitize-for-log.d.ts +24 -0
- package/dist/utils/sanitize-for-log.js +38 -0
- package/dist/utils/table-formatter.js +24 -0
- package/dist/utils/validators.d.ts +7 -2
- package/dist/utils/validators.js +6 -0
- package/dist/utils/workspace-url.d.ts +5 -1
- package/dist/utils/workspace-url.js +53 -7
- 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
|
-
|
|
|
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.
|
|
@@ -237,6 +247,102 @@ A full reference with every key documented lives in [config.example.json](./conf
|
|
|
237
247
|
UUIDs come from the Linear UI (URL bars, settings pages) or via el-linear
|
|
238
248
|
itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
239
249
|
|
|
250
|
+
### Workspace URL key
|
|
251
|
+
|
|
252
|
+
`refs wrap` and the auto-link paths build canonical issue URLs like
|
|
253
|
+
`https://linear.app/<workspaceUrlKey>/issue/<id>/`. By default the key is
|
|
254
|
+
fetched once per CLI invocation via `viewer.organization.urlKey`. To skip the
|
|
255
|
+
network call (offline use, perf, `--no-validate`), provide it via any of:
|
|
256
|
+
|
|
257
|
+
1. `--workspace-url-key <key>` flag on `refs wrap` (per-invocation, highest priority)
|
|
258
|
+
2. `EL_LINEAR_WORKSPACE_URL_KEY` env var
|
|
259
|
+
3. `workspaceUrlKey` field in `config.json`
|
|
260
|
+
|
|
261
|
+
When any of these is set, no GraphQL request is made.
|
|
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
|
+
|
|
240
346
|
## Term enforcement (with brand-promotion examples)
|
|
241
347
|
|
|
242
348
|
The `terms` rules let you keep a list of canonical names and the misspellings
|
|
@@ -264,20 +370,23 @@ are allowed even though `enrichlayer` is rejected.
|
|
|
264
370
|
|
|
265
371
|
If you don't define any rules, term enforcement is a no-op.
|
|
266
372
|
|
|
267
|
-
##
|
|
373
|
+
## Native Agent Delegation
|
|
268
374
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
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.
|
|
273
378
|
|
|
274
379
|
```bash
|
|
275
380
|
el-linear issues create "Migrate auth middleware to new session store" \
|
|
276
|
-
--team ENG --assignee alice --project "Auth Refactor" \
|
|
277
|
-
--description "..."
|
|
381
|
+
--team ENG --assignee alice --delegate claude --project "Auth Refactor" \
|
|
382
|
+
--description "..."
|
|
383
|
+
|
|
384
|
+
el-linear issues start ENG-123
|
|
278
385
|
```
|
|
279
386
|
|
|
280
|
-
|
|
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.
|
|
281
390
|
|
|
282
391
|
## Commands at a glance
|
|
283
392
|
|
|
@@ -288,7 +397,7 @@ el-linear <command> --help # detailed help for one command
|
|
|
288
397
|
|
|
289
398
|
| Group | Common commands |
|
|
290
399
|
|-------|-----------------|
|
|
291
|
-
| 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}` |
|
|
292
401
|
| Comments | `comments {list, create, update}` |
|
|
293
402
|
| Labels | `labels {list, create, retire, restore}` |
|
|
294
403
|
| Projects | `projects {list, add-team, remove-team}` |
|
|
@@ -372,6 +481,26 @@ global `summary` value works on every read/list command.
|
|
|
372
481
|
`--raw` together with `--format summary` to render a list envelope as a
|
|
373
482
|
bare item-list rather than an envelope.
|
|
374
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
|
+
|
|
375
504
|
## Wrapping Linear references in arbitrary text
|
|
376
505
|
|
|
377
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
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/auth/oauth-fs.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare function atomicWrite(targetPath: string, data: string | Uint8Array, mode?: number): Promise<void>;
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
interface RefreshTokensInput {
|
|
36
36
|
clientId: string;
|
|
37
37
|
clientSecret?: string;
|
|
38
38
|
refreshToken: string;
|
|
39
39
|
}
|
|
40
|
-
|
|
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 {};
|
package/dist/auth/oauth-token.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
oauth
|
|
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
|
-
|
|
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,
|