@enrichlayer/el-linear 1.7.0 → 1.10.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 (108) hide show
  1. package/README.md +82 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/attachments.js +2 -1
  13. package/dist/commands/comments.js +17 -28
  14. package/dist/commands/cycles.js +2 -1
  15. package/dist/commands/documents.js +2 -1
  16. package/dist/commands/graphql.js +4 -6
  17. package/dist/commands/init/index.js +2 -0
  18. package/dist/commands/init/oauth.d.ts +6 -0
  19. package/dist/commands/init/oauth.js +8 -2
  20. package/dist/commands/init/token.d.ts +0 -8
  21. package/dist/commands/init/token.js +13 -1
  22. package/dist/commands/issue-id.js +1 -3
  23. package/dist/commands/issues/branch.d.ts +17 -0
  24. package/dist/commands/issues/branch.js +40 -0
  25. package/dist/commands/issues/description.d.ts +89 -0
  26. package/dist/commands/issues/description.js +183 -0
  27. package/dist/commands/issues/link-references.d.ts +21 -0
  28. package/dist/commands/issues/link-references.js +171 -0
  29. package/dist/commands/issues/relations.d.ts +55 -0
  30. package/dist/commands/issues/relations.js +132 -0
  31. package/dist/commands/issues.js +97 -497
  32. package/dist/commands/labels.js +20 -28
  33. package/dist/commands/profile/migrate-legacy.js +10 -33
  34. package/dist/commands/profile.js +1 -10
  35. package/dist/commands/project-milestones.js +13 -20
  36. package/dist/commands/projects.d.ts +5 -1
  37. package/dist/commands/projects.js +202 -102
  38. package/dist/commands/read-shortcut.d.ts +6 -0
  39. package/dist/commands/read-shortcut.js +6 -1
  40. package/dist/commands/refs.js +10 -1
  41. package/dist/commands/releases.js +26 -30
  42. package/dist/commands/search.js +21 -30
  43. package/dist/commands/teams.js +2 -1
  44. package/dist/commands/templates.js +128 -7
  45. package/dist/commands/users.js +3 -1
  46. package/dist/config/config.js +31 -9
  47. package/dist/config/issue-validation.js +1 -1
  48. package/dist/config/paths.d.ts +11 -0
  49. package/dist/config/paths.js +44 -6
  50. package/dist/config/resolver.js +2 -3
  51. package/dist/config/term-enforcer.js +1 -1
  52. package/dist/main.js +37 -2
  53. package/dist/queries/attachments-types.d.ts +30 -0
  54. package/dist/queries/attachments-types.js +5 -0
  55. package/dist/queries/comments-types.d.ts +49 -0
  56. package/dist/queries/comments-types.js +5 -0
  57. package/dist/queries/documents-types.d.ts +61 -0
  58. package/dist/queries/documents-types.js +9 -0
  59. package/dist/queries/introspect-types.d.ts +57 -0
  60. package/dist/queries/introspect-types.js +10 -0
  61. package/dist/queries/issues-types.d.ts +416 -0
  62. package/dist/queries/issues-types.js +23 -0
  63. package/dist/queries/issues.d.ts +2 -0
  64. package/dist/queries/issues.js +22 -0
  65. package/dist/queries/labels-types.d.ts +64 -0
  66. package/dist/queries/labels-types.js +5 -0
  67. package/dist/queries/project-milestones-types.d.ts +91 -0
  68. package/dist/queries/project-milestones-types.js +10 -0
  69. package/dist/queries/projects-types.d.ts +75 -0
  70. package/dist/queries/projects-types.js +5 -0
  71. package/dist/queries/projects.d.ts +2 -0
  72. package/dist/queries/projects.js +22 -0
  73. package/dist/queries/releases-types.d.ts +84 -0
  74. package/dist/queries/releases-types.js +5 -0
  75. package/dist/queries/search-types.d.ts +86 -0
  76. package/dist/queries/search-types.js +6 -0
  77. package/dist/queries/templates-types.d.ts +61 -0
  78. package/dist/queries/templates-types.js +9 -0
  79. package/dist/queries/templates.d.ts +3 -0
  80. package/dist/queries/templates.js +43 -0
  81. package/dist/types/linear.d.ts +8 -2
  82. package/dist/utils/auth.js +7 -9
  83. package/dist/utils/auto-link-references.js +44 -25
  84. package/dist/utils/disk-cache.js +2 -1
  85. package/dist/utils/formatters/summary.d.ts +62 -0
  86. package/dist/utils/formatters/summary.js +755 -0
  87. package/dist/utils/graphql-attachments-service.js +6 -9
  88. package/dist/utils/graphql-documents-service.js +19 -25
  89. package/dist/utils/graphql-issues-service.d.ts +118 -5
  90. package/dist/utils/graphql-issues-service.js +202 -209
  91. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  92. package/dist/utils/issue-reference-extractor.js +16 -9
  93. package/dist/utils/issue-reference-wrapper.js +1 -54
  94. package/dist/utils/linear-service.d.ts +7 -3
  95. package/dist/utils/linear-service.js +27 -5
  96. package/dist/utils/markdown-prosemirror.js +17 -1
  97. package/dist/utils/mention-resolver.js +17 -5
  98. package/dist/utils/output.d.ts +7 -1
  99. package/dist/utils/output.js +38 -1
  100. package/dist/utils/protected-ranges.d.ts +33 -0
  101. package/dist/utils/protected-ranges.js +73 -0
  102. package/dist/utils/table-formatter.d.ts +36 -0
  103. package/dist/utils/table-formatter.js +46 -24
  104. package/dist/utils/validators.d.ts +9 -1
  105. package/dist/utils/validators.js +10 -0
  106. package/dist/utils/workspace-url.d.ts +5 -1
  107. package/dist/utils/workspace-url.js +35 -5
  108. package/package.json +1 -1
package/README.md CHANGED
@@ -237,6 +237,19 @@ A full reference with every key documented lives in [config.example.json](./conf
237
237
  UUIDs come from the Linear UI (URL bars, settings pages) or via el-linear
238
238
  itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
239
239
 
240
+ ### Workspace URL key
241
+
242
+ `refs wrap` and the auto-link paths build canonical issue URLs like
243
+ `https://linear.app/<workspaceUrlKey>/issue/<id>/`. By default the key is
244
+ fetched once per CLI invocation via `viewer.organization.urlKey`. To skip the
245
+ network call (offline use, perf, `--no-validate`), provide it via any of:
246
+
247
+ 1. `--workspace-url-key <key>` flag on `refs wrap` (per-invocation, highest priority)
248
+ 2. `EL_LINEAR_WORKSPACE_URL_KEY` env var
249
+ 3. `workspaceUrlKey` field in `config.json`
250
+
251
+ When any of these is set, no GraphQL request is made.
252
+
240
253
  ## Term enforcement (with brand-promotion examples)
241
254
 
242
255
  The `terms` rules let you keep a list of canonical names and the misspellings
@@ -302,7 +315,75 @@ el-linear <command> --help # detailed help for one command
302
315
  | Config | `config show`, `users list`, `teams list`, `templates list` |
303
316
 
304
317
  All `list` subcommands support `-l, --limit <n>`. All commands accept the
305
- top-level filters: `--raw`, `--jq <expr>`, `--fields <list>`.
318
+ top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
319
+
320
+ ## Output formats
321
+
322
+ Every command accepts `--format <kind>` at the root:
323
+
324
+ - `--format json` (default) — emits the full structured envelope. Stable
325
+ shape across releases. Composes with `--jq`, `--fields`, and `--raw`.
326
+ - `--format summary` — emits a fixed human-readable rendering. **Use this
327
+ whenever you'd otherwise pipe through `jq`, `head`, `python -c`, or
328
+ similar shell tools to extract a few fields.** Stable field set per
329
+ resource (identifier, title, state, assignee, project, labels, URL for
330
+ issues; analogous fields for projects, comments, cycles, milestones,
331
+ teams, labels, users, documents, templates, attachments, releases, and
332
+ cross-resource search results).
333
+
334
+ > **Working with an LLM / Claude Code?** Default every read/list call to
335
+ > `--format summary` unless you specifically need the JSON envelope.
336
+ > A 12-line summary table is dramatically cheaper in tokens than a 500-line
337
+ > JSON dump and contains the same information humans actually use. The
338
+ > bundled `claude-skills/linear-operations/SKILL.md` documents this rule.
339
+
340
+ ```bash
341
+ el-linear issues read DEV-123 --format summary
342
+ # DEV-123 Fix login flicker on Safari 17
343
+ # State: In Progress
344
+ # Assignee: Alice
345
+ # Project: Auth Refactor
346
+ # Labels: Feature, tool
347
+ # URL: https://linear.app/acme/issue/DEV-123/...
348
+ #
349
+ # Login button briefly disappears when the form first loads.
350
+ # Repro on Safari 17 / iOS 17. Chrome / Firefox unaffected.
351
+ # ... (truncated; --format json for full body)
352
+
353
+ el-linear issues search "auth" --format summary
354
+ # ID TITLE STATE ASSIGNEE
355
+ # ---------------------------------------------------------------------------------------
356
+ # DEV-100 Migrate auth middleware to new session store In Progress Alice
357
+ # DEV-104 Auth callback returns 502 under load Todo Bob
358
+ #
359
+ # 2 issues
360
+
361
+ el-linear projects list --format summary
362
+ # NAME STATE PROGRESS LEAD
363
+ # -------------------------------------------------------
364
+ # Auth Refactor started 65% Alice
365
+ # Pricing v2 backlog 0% —
366
+ #
367
+ # 2 projects
368
+
369
+ el-linear templates list --format summary
370
+ # NAME TYPE TEAM ID
371
+ # ------------------------------------------------------------------
372
+ # Bug report issue PYT cf45b82e-0c71-4d24-be70-d4ecf915
373
+ # Tech Planning document — d4bcb82e-5ee4-49d3-b057-40f4a2e2
374
+ #
375
+ # 2 templates
376
+ ```
377
+
378
+ Existing `issues list`, `issues search`, and `projects list` commands
379
+ continue to accept their per-command formats too: `table`, `md`,
380
+ `markdown`, `csv` — those go to the per-command rendering path. The
381
+ global `summary` value works on every read/list command.
382
+
383
+ `--format summary` does not compose with `--jq` (jq is JSON-only) or
384
+ `--fields` (fields filter the JSON shape, not the rendered text). Use
385
+ `--raw` together with `--format summary` to render a list envelope as a
386
+ bare item-list rather than an envelope.
306
387
 
307
388
  ## Wrapping Linear references in arbitrary text
308
389
 
@@ -12,6 +12,61 @@ This skill covers **mandatory processes and non-obvious rules** — everything t
12
12
 
13
13
  > **Team-specific overrides.** Many teams keep their own issue-creation guide, label taxonomy, or member alias map. If your project has a `CLAUDE.md` or sibling skill that supplements this one, treat its rules as authoritative on top of these defaults.
14
14
 
15
+ ## Output formats — use `--format summary` for terminals and agents
16
+
17
+ `el-linear` defaults to a structured JSON envelope. **For human-readable or chat-bound output, always pass `--format summary`.** Do not pipe el-linear through `python -c "json.load(...)"` or `jq` to pull out title / state / assignee — that is exactly what `--format summary` is for, and it produces a stable rendering across releases.
18
+
19
+ ```bash
20
+ el-linear issues read DEV-123 --format summary
21
+ # DEV-123 Fix login flicker on Safari 17
22
+ # State: In Progress
23
+ # Assignee: Alice
24
+ # Project: Auth Refactor
25
+ # Labels: Feature, tool
26
+ # URL: https://linear.app/acme/issue/DEV-123/...
27
+
28
+ el-linear issues search "auth" --format summary
29
+ # ID TITLE STATE ASSIGNEE
30
+ # ---------------------------------------------------------------------------------------
31
+ # DEV-100 Migrate auth middleware to new session store In Progress Alice
32
+ # DEV-104 Auth callback returns 502 under load Todo Bob
33
+ #
34
+ # 2 issues
35
+ ```
36
+
37
+ Use `--format json` (the default — pass nothing) **only** when you genuinely need the full envelope: writing scripts that parse the response, mutating with `--jq`, or chaining into another tool that expects structured data. Default to summary; reach for JSON when the task warrants it.
38
+
39
+ ### Anti-patterns to avoid
40
+
41
+ If you find yourself writing any of these, you are reaching for the wrong tool:
42
+
43
+ ```bash
44
+ # ❌ Don't do this — pipe through python to extract a few fields
45
+ el-linear issues search "..." --limit 10 2>&1 | python3 -c "import json,sys; d=json.load(sys.stdin); ..."
46
+
47
+ # ❌ Don't do this either — head the JSON to make it manageable
48
+ el-linear projects list --limit 50 2>&1 | head -100
49
+
50
+ # ❌ Don't reach for jq just to print title + state
51
+ el-linear issues read DEV-123 --jq '.title + " " + .state.name' 2>&1
52
+
53
+ # ✅ Just use --format summary
54
+ el-linear issues search "..." --limit 10 --format summary 2>&1
55
+ el-linear projects list --limit 50 --format summary 2>&1
56
+ el-linear issues read DEV-123 --format summary 2>&1
57
+ ```
58
+
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
+
61
+ ### Coverage
62
+
63
+ `--format summary` is implemented for:
64
+
65
+ - **Single resources:** `issues read`, `projects read`, `cycles read`, `project-milestones read`, `documents read`, `templates read`, releases (`graphql` query results), `users read`
66
+ - **Lists:** `issues list`, `issues search`, `projects list`, `comments list`, `cycles list`, `project-milestones list`, `labels list`, `teams list`, `users list`, `documents list`, `templates list`, `attachments list`, `releases list`, and the cross-resource `search` command
67
+
68
+ Commands without a dedicated formatter (e.g. `config show`, custom `graphql` queries) fall back to a generic key/value rendering of their JSON payload.
69
+
15
70
  ---
16
71
 
17
72
  ## Intent-Driven Issue Writing
@@ -30,20 +30,27 @@ const SUCCESS_HTML = `<!doctype html>
30
30
  <p>You can close this tab and return to your terminal.</p>
31
31
  </body>
32
32
  </html>`;
33
- function errorHtml(message) {
34
- const safe = message.replace(/[<>&]/g, "");
35
- return `<!doctype html>
33
+ // Fixed error page — never interpolates attacker-controlled prose.
34
+ //
35
+ // Pre-fix: the upstream `error_description` from the redirect URL was
36
+ // embedded into the HTML response (with `<>&` stripped). An attacker
37
+ // who knew the local listener port could fire
38
+ // `http://localhost:<port>/oauth/callback?error=phish&error_description=Your+account+is+compromised…`
39
+ // and have arbitrary phishing prose render in the user's browser
40
+ // before the legitimate redirect arrived. Now we render a fixed
41
+ // string and log the upstream detail to the terminal where the user
42
+ // can compare it to expected output.
43
+ const ERROR_HTML = `<!doctype html>
36
44
  <html lang="en">
37
45
  <head><meta charset="utf-8"><title>el-linear · authorization error</title>
38
46
  <style>body{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 16px;color:#1a1a1a}h1{font-size:18px;margin:0 0 12px}p{margin:8px 0}.bad{color:#a30000}code{background:#f4f4f4;padding:2px 4px;border-radius:3px}</style>
39
47
  </head>
40
48
  <body>
41
49
  <h1 class="bad">el-linear · authorization error</h1>
42
- <p>${safe}</p>
50
+ <p>Authorization failed. Return to your terminal — the CLI has the details.</p>
43
51
  <p>You can close this tab. Re-run <code>el-linear init oauth</code> to retry.</p>
44
52
  </body>
45
53
  </html>`;
46
- }
47
54
  /**
48
55
  * Spin up a one-shot HTTP server on `127.0.0.1:<port>`, accept the OAuth
49
56
  * callback, validate state, and resolve with `{code, state}`.
@@ -95,7 +102,7 @@ export async function runLocalhostCallback(options, serverFactory = () => create
95
102
  const message = err instanceof Error ? err.message : String(err);
96
103
  res.statusCode = 400;
97
104
  res.setHeader("content-type", "text/html; charset=utf-8");
98
- res.end(errorHtml(message));
105
+ res.end(ERROR_HTML);
99
106
  settle(() => reject(new Error(message)));
100
107
  return;
101
108
  }
@@ -103,7 +110,7 @@ export async function runLocalhostCallback(options, serverFactory = () => create
103
110
  const message = "State mismatch — the OAuth callback's `state` parameter doesn't match what we sent. This could indicate a CSRF attempt; aborting.";
104
111
  res.statusCode = 400;
105
112
  res.setHeader("content-type", "text/html; charset=utf-8");
106
- res.end(errorHtml(message));
113
+ res.end(ERROR_HTML);
107
114
  settle(() => reject(new Error(message)));
108
115
  return;
109
116
  }
@@ -1 +1,23 @@
1
1
  export declare function atomicWrite(targetPath: string, data: string | Uint8Array, mode?: number): Promise<void>;
2
+ export interface FileLockOptions {
3
+ /** Treat a lock older than this as crashed and steal it. Default 30s. */
4
+ staleAfterMs?: number;
5
+ /** Maximum time to wait for the lock before giving up. Default 30s. */
6
+ maxWaitMs?: number;
7
+ /** Poll interval while waiting. Default 100ms. */
8
+ pollMs?: number;
9
+ }
10
+ /**
11
+ * Run `fn` while holding an exclusive file lock at `<targetPath>.lock`.
12
+ *
13
+ * Implementation: `fs.open(..., "wx")` is the POSIX `O_EXCL` create — it
14
+ * fails atomically if the lockfile already exists. On success we own the
15
+ * lock; we delete the file in `finally` so a synchronous throw still
16
+ * releases. A crashed process leaves the lockfile behind; the next caller
17
+ * detects staleness via `mtime` and steals the lock.
18
+ *
19
+ * Limitations: this is a single-machine lock. NFS-style multi-machine
20
+ * coordination is out of scope (and the OAuth state is per-machine
21
+ * anyway).
22
+ */
23
+ export declare function withFileLock<T>(targetPath: string, fn: () => Promise<T>, options?: FileLockOptions): Promise<T>;
@@ -1,14 +1,20 @@
1
1
  /**
2
- * Internal: atomic write helper used by `oauth-storage.ts`.
2
+ * Internal: filesystem helpers for the auth module.
3
3
  *
4
4
  * The wizard already has an `atomicWrite` in `commands/init/shared.ts`, but
5
5
  * importing wizard internals from non-wizard code creates a cycle (the
6
6
  * wizard depends on `auth/`, and `auth/` would depend back on the wizard).
7
- * Duplicating the 12-line helper here keeps the dependency graph clean.
7
+ * Duplicating the helpers here keeps the dependency graph clean.
8
8
  *
9
- * Behaviour matches `commands/init/shared.ts#atomicWrite`: write to a sibling
10
- * tmp file then `rename`. On POSIX same-filesystem, rename is atomic. The
11
- * tmp suffix uses crypto-random bytes so concurrent writers don't collide.
9
+ * `atomicWrite` matches `commands/init/shared.ts#atomicWrite`: write to a
10
+ * sibling tmp file then `rename`. On POSIX same-filesystem, rename is
11
+ * atomic. The tmp suffix uses crypto-random bytes so concurrent writers
12
+ * don't collide.
13
+ *
14
+ * `withFileLock` serialises a critical section using a sidecar `<path>.lock`
15
+ * file created with `O_EXCL`. Used by the OAuth refresh path so two
16
+ * concurrent CLI invocations can't both consume the same refresh token,
17
+ * which would invalidate it on Linear's side and brick the user's auth.
12
18
  */
13
19
  import { randomBytes } from "node:crypto";
14
20
  import fs from "node:fs/promises";
@@ -16,7 +22,7 @@ export async function atomicWrite(targetPath, data, mode = 0o644) {
16
22
  const tmpPath = `${targetPath}.tmp-${randomBytes(8).toString("hex")}`;
17
23
  try {
18
24
  await fs.writeFile(tmpPath, data, { encoding: "utf8", mode });
19
- // fs.writeFile only honours `mode` when the file is newly created.
25
+ // fs.writeFile only honors `mode` when the file is newly created.
20
26
  // Tmp paths are always new, but be explicit to make this airtight if
21
27
  // the random suffix ever collides with a stale tmp.
22
28
  await fs.chmod(tmpPath, mode);
@@ -27,3 +33,68 @@ export async function atomicWrite(targetPath, data, mode = 0o644) {
27
33
  throw err;
28
34
  }
29
35
  }
36
+ /**
37
+ * Run `fn` while holding an exclusive file lock at `<targetPath>.lock`.
38
+ *
39
+ * Implementation: `fs.open(..., "wx")` is the POSIX `O_EXCL` create — it
40
+ * fails atomically if the lockfile already exists. On success we own the
41
+ * lock; we delete the file in `finally` so a synchronous throw still
42
+ * releases. A crashed process leaves the lockfile behind; the next caller
43
+ * detects staleness via `mtime` and steals the lock.
44
+ *
45
+ * Limitations: this is a single-machine lock. NFS-style multi-machine
46
+ * coordination is out of scope (and the OAuth state is per-machine
47
+ * anyway).
48
+ */
49
+ export async function withFileLock(targetPath, fn, options = {}) {
50
+ const staleAfterMs = options.staleAfterMs ?? 30_000;
51
+ const maxWaitMs = options.maxWaitMs ?? 30_000;
52
+ const pollMs = options.pollMs ?? 100;
53
+ const lockPath = `${targetPath}.lock`;
54
+ const start = Date.now();
55
+ while (true) {
56
+ try {
57
+ const handle = await fs.open(lockPath, "wx");
58
+ try {
59
+ await handle.writeFile(`${process.pid}\n${Date.now()}\n`);
60
+ }
61
+ finally {
62
+ await handle.close();
63
+ }
64
+ try {
65
+ return await fn();
66
+ }
67
+ finally {
68
+ await fs.unlink(lockPath).catch(() => { });
69
+ }
70
+ }
71
+ catch (err) {
72
+ const code = err.code;
73
+ if (code !== "EEXIST")
74
+ throw err;
75
+ // Lockfile already exists. Check whether it's stale (process died
76
+ // before releasing) and either steal it or wait.
77
+ let stolen = false;
78
+ try {
79
+ const stat = await fs.stat(lockPath);
80
+ if (Date.now() - stat.mtimeMs > staleAfterMs) {
81
+ await fs.unlink(lockPath).catch(() => { });
82
+ stolen = true;
83
+ }
84
+ }
85
+ catch {
86
+ // Lockfile was removed between the EEXIST and the stat. Race
87
+ // with the holder's normal release path — just retry.
88
+ stolen = true;
89
+ }
90
+ if (stolen)
91
+ continue;
92
+ if (Date.now() - start > maxWaitMs) {
93
+ throw new Error(`Timed out after ${maxWaitMs}ms waiting for ${lockPath}. ` +
94
+ `Another el-linear process is holding the lock; if none is running, ` +
95
+ `delete the file manually and retry.`);
96
+ }
97
+ await new Promise((r) => setTimeout(r, pollMs));
98
+ }
99
+ }
100
+ }
@@ -18,6 +18,13 @@
18
18
  import { type CallbackParams } from "./oauth-client.js";
19
19
  export interface PromptForPastedCodeOptions {
20
20
  expectedState: string;
21
+ /**
22
+ * Allow the user to paste a bare authorization code without a
23
+ * surrounding URL. Defeats the OAuth `state` CSRF check (we can't
24
+ * verify state without the URL), so it's gated behind explicit
25
+ * opt-in. Default: false — paste the full callback URL.
26
+ */
27
+ unsafeBareCode?: boolean;
21
28
  /** Test seam — defaults to @inquirer/prompts `input`. */
22
29
  prompt?: (opts: {
23
30
  message: string;
@@ -25,14 +32,12 @@ export interface PromptForPastedCodeOptions {
25
32
  }) => Promise<string>;
26
33
  }
27
34
  /**
28
- * Ask the user to paste either:
29
- * - The full callback URL (we extract code+state ourselves), OR
30
- * - Just the `code` value (we accept the user's word on state).
35
+ * Ask the user to paste the full OAuth callback URL. We parse `code`
36
+ * and `state`, then verify `state` matches what we sent.
31
37
  *
32
- * For the URL form, state is validated against `expectedState`.
33
- *
34
- * Returns `{code, state}`. When the user pasted only a code, `state` is
35
- * the expected value (the user has implicitly trusted it; we have no
36
- * other check available).
38
+ * Bare `code` pastes (no URL) are rejected by default because we have
39
+ * no way to verify the `state` parameter — the entire CSRF protection
40
+ * collapses if we silently accept the expected state on the user's
41
+ * behalf. Opt in with `--unsafe-bare-code` (`unsafeBareCode: true`).
37
42
  */
38
43
  export declare function promptForPastedCode(options: PromptForPastedCodeOptions): Promise<CallbackParams>;
@@ -18,20 +18,21 @@
18
18
  import { input } from "@inquirer/prompts";
19
19
  import { parseCallbackUrl } from "./oauth-client.js";
20
20
  /**
21
- * Ask the user to paste either:
22
- * - The full callback URL (we extract code+state ourselves), OR
23
- * - Just the `code` value (we accept the user's word on state).
21
+ * Ask the user to paste the full OAuth callback URL. We parse `code`
22
+ * and `state`, then verify `state` matches what we sent.
24
23
  *
25
- * For the URL form, state is validated against `expectedState`.
26
- *
27
- * Returns `{code, state}`. When the user pasted only a code, `state` is
28
- * the expected value (the user has implicitly trusted it; we have no
29
- * other check available).
24
+ * Bare `code` pastes (no URL) are rejected by default because we have
25
+ * no way to verify the `state` parameter — the entire CSRF protection
26
+ * collapses if we silently accept the expected state on the user's
27
+ * behalf. Opt in with `--unsafe-bare-code` (`unsafeBareCode: true`).
30
28
  */
31
29
  export async function promptForPastedCode(options) {
32
30
  const ask = options.prompt ?? ((o) => input(o));
31
+ const message = options.unsafeBareCode
32
+ ? "Paste the full callback URL (or just the `code` value, since --unsafe-bare-code was set):"
33
+ : "Paste the full callback URL (it includes both `code` and `state`):";
33
34
  const raw = (await ask({
34
- message: "Paste the full callback URL (or just the `code` value):",
35
+ message,
35
36
  validate: (value) => value.trim().length > 0 || "Cannot be empty",
36
37
  })).trim();
37
38
  if (raw.startsWith("http://") || raw.startsWith("https://")) {
@@ -41,8 +42,14 @@ export async function promptForPastedCode(options) {
41
42
  }
42
43
  return parsed;
43
44
  }
44
- // Bare code path. Reject anything that looks like it has a query
45
- // string but isn't a URL (the user partially copied something).
45
+ // Bare-code pastes are opt-in. Without `unsafeBareCode`, refuse to
46
+ // silently fabricate `state` and bypass CSRF.
47
+ if (!options.unsafeBareCode) {
48
+ throw new Error("Paste the FULL callback URL — bare `code` pastes are disabled because they bypass the OAuth `state` CSRF check. " +
49
+ "Re-run with `--unsafe-bare-code` only if you understand and accept the risk.");
50
+ }
51
+ // Reject anything that looks like a query fragment but isn't a URL
52
+ // (the user partially copied something).
46
53
  if (raw.includes("=") || raw.includes("?")) {
47
54
  throw new Error("Pasted value looks malformed. Paste either the full callback URL or just the `code` value (alphanumeric + dashes).");
48
55
  }
@@ -29,20 +29,28 @@ export declare function oauthStatePath(): string;
29
29
  * Returns `null` (not throw) on JSON parse errors so callers can fall back
30
30
  * to personal-token auth without spamming users with repair instructions —
31
31
  * the `init oauth` command is responsible for repair.
32
+ *
33
+ * Pass an explicit `targetPath` to bind to a specific profile's
34
+ * `oauth.json`. Useful for read-modify-write sequences that snapshot
35
+ * the path once at the top so a profile switch mid-sequence can't
36
+ * cause cross-profile contamination. ALL-935.
32
37
  */
33
- export declare function readOAuthState(): Promise<OAuthState | null>;
38
+ export declare function readOAuthState(targetPath?: string): Promise<OAuthState | null>;
34
39
  /**
35
40
  * Write the active profile's OAuth state atomically with mode 0600.
36
41
  *
37
42
  * IMPORTANT: uses the same write-tmp + rename pattern as `writeToken` so a
38
43
  * pre-existing 0644 file gets its mode reset. Tokens leaking via group/other
39
44
  * read is the failure mode we want to make impossible.
45
+ *
46
+ * Pass an explicit `targetPath` to bind to a specific profile (see
47
+ * `readOAuthState`).
40
48
  */
41
- export declare function writeOAuthState(state: OAuthState): Promise<void>;
49
+ export declare function writeOAuthState(state: OAuthState, targetPath?: string): Promise<void>;
42
50
  /**
43
51
  * Delete the active profile's OAuth state. No-op if the file is already gone.
44
52
  */
45
- export declare function clearOAuthState(): Promise<void>;
53
+ export declare function clearOAuthState(targetPath?: string): Promise<void>;
46
54
  /**
47
55
  * Return `true` when the access token is still valid for at least
48
56
  * `skewMs` milliseconds. Default 60s skew protects against clock drift +
@@ -30,10 +30,15 @@ export function oauthStatePath() {
30
30
  * Returns `null` (not throw) on JSON parse errors so callers can fall back
31
31
  * to personal-token auth without spamming users with repair instructions —
32
32
  * the `init oauth` command is responsible for repair.
33
+ *
34
+ * Pass an explicit `targetPath` to bind to a specific profile's
35
+ * `oauth.json`. Useful for read-modify-write sequences that snapshot
36
+ * the path once at the top so a profile switch mid-sequence can't
37
+ * cause cross-profile contamination. ALL-935.
33
38
  */
34
- export async function readOAuthState() {
39
+ export async function readOAuthState(targetPath = oauthStatePath()) {
35
40
  try {
36
- const raw = await fs.readFile(oauthStatePath(), "utf8");
41
+ const raw = await fs.readFile(targetPath, "utf8");
37
42
  const parsed = JSON.parse(raw);
38
43
  if (parsed?.v !== OAUTH_STATE_VERSION)
39
44
  return null;
@@ -56,21 +61,23 @@ export async function readOAuthState() {
56
61
  * IMPORTANT: uses the same write-tmp + rename pattern as `writeToken` so a
57
62
  * pre-existing 0644 file gets its mode reset. Tokens leaking via group/other
58
63
  * read is the failure mode we want to make impossible.
64
+ *
65
+ * Pass an explicit `targetPath` to bind to a specific profile (see
66
+ * `readOAuthState`).
59
67
  */
60
- export async function writeOAuthState(state) {
61
- const target = oauthStatePath();
68
+ export async function writeOAuthState(state, targetPath = oauthStatePath()) {
62
69
  // Ensure both the legacy CONFIG_DIR (where active-profile + profiles/
63
70
  // live) and the active profile's directory exist before writing.
64
71
  await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
65
- await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
66
- await atomicWrite(target, `${JSON.stringify(state, null, 2)}\n`, 0o600);
72
+ await fs.mkdir(path.dirname(targetPath), { recursive: true, mode: 0o700 });
73
+ await atomicWrite(targetPath, `${JSON.stringify(state, null, 2)}\n`, 0o600);
67
74
  }
68
75
  /**
69
76
  * Delete the active profile's OAuth state. No-op if the file is already gone.
70
77
  */
71
- export async function clearOAuthState() {
78
+ export async function clearOAuthState(targetPath = oauthStatePath()) {
72
79
  try {
73
- await fs.unlink(oauthStatePath());
80
+ await fs.unlink(targetPath);
74
81
  }
75
82
  catch (err) {
76
83
  if (err.code !== "ENOENT")
@@ -44,5 +44,14 @@ export declare function getActiveAuth(options?: GetActiveAuthOptions): Promise<A
44
44
  *
45
45
  * On refresh failure, throws an actionable error pointing at
46
46
  * `el-linear init oauth`.
47
+ *
48
+ * **Concurrency.** When a refresh is needed, this acquires an exclusive
49
+ * file lock on the oauth.json sidecar before reading-refreshing-writing.
50
+ * Two parallel CLI invocations would otherwise both call `refreshTokens`
51
+ * with the same refresh token; Linear's server invalidates the loser's
52
+ * stored token and the next refresh permanently fails. The lock
53
+ * serialises them — the second process re-reads the freshly-written
54
+ * state inside the lock and uses the winner's tokens instead of issuing
55
+ * a second refresh.
47
56
  */
48
57
  export declare function ensureFreshAccessToken(state: OAuthState, options?: GetActiveAuthOptions): Promise<OAuthState>;
@@ -16,7 +16,8 @@
16
16
  * - oauth: `Authorization: Bearer <token>`
17
17
  */
18
18
  import { getApiToken } from "../utils/auth.js";
19
- import { isAccessTokenFresh, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
19
+ import { withFileLock } from "./oauth-fs.js";
20
+ import { oauthStatePath, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
20
21
  import { refreshTokens, } from "./oauth-token.js";
21
22
  /**
22
23
  * Resolve the credential for this invocation.
@@ -52,44 +53,63 @@ export async function getActiveAuth(options = {}) {
52
53
  *
53
54
  * On refresh failure, throws an actionable error pointing at
54
55
  * `el-linear init oauth`.
56
+ *
57
+ * **Concurrency.** When a refresh is needed, this acquires an exclusive
58
+ * file lock on the oauth.json sidecar before reading-refreshing-writing.
59
+ * Two parallel CLI invocations would otherwise both call `refreshTokens`
60
+ * with the same refresh token; Linear's server invalidates the loser's
61
+ * stored token and the next refresh permanently fails. The lock
62
+ * serialises them — the second process re-reads the freshly-written
63
+ * state inside the lock and uses the winner's tokens instead of issuing
64
+ * a second refresh.
55
65
  */
56
66
  export async function ensureFreshAccessToken(state, options = {}) {
57
67
  const now = options.now ?? Date.now;
58
- if (isAccessTokenFresh(state, /* skewMs */ 60_000)) {
59
- // `isAccessTokenFresh` reads `Date.now()` internally; for the
60
- // purpose of the test seam we re-check against the injected clock.
61
- if (now() + 60_000 < state.expiresAt) {
62
- return state;
63
- }
64
- }
65
- if (!state.refreshToken) {
66
- throw new Error("OAuth access token expired and no refresh token is stored. Re-run `el-linear init oauth`.");
67
- }
68
- let refreshed;
69
- try {
70
- refreshed = await refreshTokens({
71
- clientId: state.clientId,
72
- clientSecret: state.clientSecret,
73
- refreshToken: state.refreshToken,
74
- }, options.fetchImpl, now);
75
- }
76
- catch (err) {
77
- const message = err instanceof Error ? err.message : String(err);
78
- throw new Error(`OAuth refresh failed: ${message}. Re-run \`el-linear init oauth\` to re-authorize.`);
68
+ // Fast path: token is fresh; no lock, no refresh.
69
+ if (now() + 60_000 < state.expiresAt) {
70
+ return state;
79
71
  }
80
- const next = {
81
- ...state,
82
- accessToken: refreshed.accessToken,
83
- // Preserve the previous refresh token if the server didn't rotate
84
- // (some OAuth servers only return a new refresh_token periodically).
85
- refreshToken: refreshed.refreshToken ?? state.refreshToken,
86
- tokenType: refreshed.tokenType,
87
- // Use the freshly-returned scopes only if non-empty; otherwise keep
88
- // what we had, since some token endpoints omit `scope` on refresh.
89
- scopes: refreshed.scopes.length > 0 ? refreshed.scopes : state.scopes,
90
- expiresAt: refreshed.expiresAt,
91
- obtainedAt: now(),
92
- };
93
- await writeOAuthState(next);
94
- return next;
72
+ // Snapshot the target path ONCE so a profile switch between the
73
+ // lock acquisition and the write inside the closure can't cause
74
+ // cross-profile contamination. The lock + read + write all bind
75
+ // to this path. ALL-935 deferred fix.
76
+ const targetPath = oauthStatePath();
77
+ return withFileLock(targetPath, async () => {
78
+ // Re-read inside the lock — another process may have refreshed
79
+ // while we were waiting. If so, use their result.
80
+ const current = (await readOAuthState(targetPath)) ?? state;
81
+ if (now() + 60_000 < current.expiresAt) {
82
+ return current;
83
+ }
84
+ if (!current.refreshToken) {
85
+ throw new Error("OAuth access token expired and no refresh token is stored. Re-run `el-linear init oauth`.");
86
+ }
87
+ let refreshed;
88
+ try {
89
+ refreshed = await refreshTokens({
90
+ clientId: current.clientId,
91
+ clientSecret: current.clientSecret,
92
+ refreshToken: current.refreshToken,
93
+ }, options.fetchImpl, now);
94
+ }
95
+ catch (err) {
96
+ 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
+ }
99
+ const next = {
100
+ ...current,
101
+ accessToken: refreshed.accessToken,
102
+ // Preserve the previous refresh token if the server didn't rotate
103
+ // (some OAuth servers only return a new refresh_token periodically).
104
+ refreshToken: refreshed.refreshToken ?? current.refreshToken,
105
+ tokenType: refreshed.tokenType,
106
+ // Use the freshly-returned scopes only if non-empty; otherwise
107
+ // keep what we had — some token endpoints omit `scope` on refresh.
108
+ scopes: refreshed.scopes.length > 0 ? refreshed.scopes : current.scopes,
109
+ expiresAt: refreshed.expiresAt,
110
+ obtainedAt: now(),
111
+ };
112
+ await writeOAuthState(next, targetPath);
113
+ return next;
114
+ });
95
115
  }
@@ -3,6 +3,7 @@ import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-se
3
3
  import { createLinearService } from "../utils/linear-service.js";
4
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
5
  import { getRootOpts } from "../utils/root-opts.js";
6
+ import { parsePositiveInt } from "../utils/validators.js";
6
7
  export function setupAttachmentsCommands(program) {
7
8
  const attachments = program
8
9
  .command("attachments")
@@ -40,7 +41,7 @@ export function setupAttachmentsCommands(program) {
40
41
  const resolvedIssueId = await linearService.resolveIssueId(issueId);
41
42
  const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
42
43
  const allAttachments = await attachmentsService.listAttachments(resolvedIssueId);
43
- const limit = Number.parseInt(options.limit, 10);
44
+ const limit = parsePositiveInt(options.limit, "--limit");
44
45
  const data = allAttachments.slice(0, limit);
45
46
  outputSuccess({ data, meta: { count: data.length } });
46
47
  }));