@enrichlayer/el-linear 1.7.0 → 1.9.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 +69 -1
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/init/index.js +2 -0
- package/dist/commands/init/oauth.d.ts +6 -0
- package/dist/commands/init/oauth.js +8 -2
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +187 -0
- package/dist/commands/issues.js +34 -215
- package/dist/commands/labels.js +7 -5
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +135 -64
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +2 -1
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.js +44 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +37 -2
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +15 -1
- package/dist/utils/disk-cache.js +2 -1
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-issues-service.d.ts +106 -3
- package/dist/utils/graphql-issues-service.js +51 -37
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +5 -1
- package/dist/utils/output.js +28 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -302,7 +302,75 @@ el-linear <command> --help # detailed help for one command
|
|
|
302
302
|
| Config | `config show`, `users list`, `teams list`, `templates list` |
|
|
303
303
|
|
|
304
304
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
305
|
-
top-level filters: `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
305
|
+
top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
306
|
+
|
|
307
|
+
## Output formats
|
|
308
|
+
|
|
309
|
+
Every command accepts `--format <kind>` at the root:
|
|
310
|
+
|
|
311
|
+
- `--format json` (default) — emits the full structured envelope. Stable
|
|
312
|
+
shape across releases. Composes with `--jq`, `--fields`, and `--raw`.
|
|
313
|
+
- `--format summary` — emits a fixed human-readable rendering. **Use this
|
|
314
|
+
whenever you'd otherwise pipe through `jq`, `head`, `python -c`, or
|
|
315
|
+
similar shell tools to extract a few fields.** Stable field set per
|
|
316
|
+
resource (identifier, title, state, assignee, project, labels, URL for
|
|
317
|
+
issues; analogous fields for projects, comments, cycles, milestones,
|
|
318
|
+
teams, labels, users, documents, templates, attachments, releases, and
|
|
319
|
+
cross-resource search results).
|
|
320
|
+
|
|
321
|
+
> **Working with an LLM / Claude Code?** Default every read/list call to
|
|
322
|
+
> `--format summary` unless you specifically need the JSON envelope.
|
|
323
|
+
> A 12-line summary table is dramatically cheaper in tokens than a 500-line
|
|
324
|
+
> JSON dump and contains the same information humans actually use. The
|
|
325
|
+
> bundled `claude-skills/linear-operations/SKILL.md` documents this rule.
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
el-linear issues read DEV-123 --format summary
|
|
329
|
+
# DEV-123 Fix login flicker on Safari 17
|
|
330
|
+
# State: In Progress
|
|
331
|
+
# Assignee: Alice
|
|
332
|
+
# Project: Auth Refactor
|
|
333
|
+
# Labels: Feature, tool
|
|
334
|
+
# URL: https://linear.app/acme/issue/DEV-123/...
|
|
335
|
+
#
|
|
336
|
+
# Login button briefly disappears when the form first loads.
|
|
337
|
+
# Repro on Safari 17 / iOS 17. Chrome / Firefox unaffected.
|
|
338
|
+
# ... (truncated; --format json for full body)
|
|
339
|
+
|
|
340
|
+
el-linear issues search "auth" --format summary
|
|
341
|
+
# ID TITLE STATE ASSIGNEE
|
|
342
|
+
# ---------------------------------------------------------------------------------------
|
|
343
|
+
# DEV-100 Migrate auth middleware to new session store In Progress Alice
|
|
344
|
+
# DEV-104 Auth callback returns 502 under load Todo Bob
|
|
345
|
+
#
|
|
346
|
+
# 2 issues
|
|
347
|
+
|
|
348
|
+
el-linear projects list --format summary
|
|
349
|
+
# NAME STATE PROGRESS LEAD
|
|
350
|
+
# -------------------------------------------------------
|
|
351
|
+
# Auth Refactor started 65% Alice
|
|
352
|
+
# Pricing v2 backlog 0% —
|
|
353
|
+
#
|
|
354
|
+
# 2 projects
|
|
355
|
+
|
|
356
|
+
el-linear templates list --format summary
|
|
357
|
+
# NAME TYPE TEAM ID
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# Bug report issue PYT cf45b82e-0c71-4d24-be70-d4ecf915
|
|
360
|
+
# Tech Planning document — d4bcb82e-5ee4-49d3-b057-40f4a2e2
|
|
361
|
+
#
|
|
362
|
+
# 2 templates
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Existing `issues list`, `issues search`, and `projects list` commands
|
|
366
|
+
continue to accept their per-command formats too: `table`, `md`,
|
|
367
|
+
`markdown`, `csv` — those go to the per-command rendering path. The
|
|
368
|
+
global `summary` value works on every read/list command.
|
|
369
|
+
|
|
370
|
+
`--format summary` does not compose with `--jq` (jq is JSON-only) or
|
|
371
|
+
`--fields` (fields filter the JSON shape, not the rendered text). Use
|
|
372
|
+
`--raw` together with `--format summary` to render a list envelope as a
|
|
373
|
+
bare item-list rather than an envelope.
|
|
306
374
|
|
|
307
375
|
## Wrapping Linear references in arbitrary text
|
|
308
376
|
|
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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(
|
|
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(
|
|
113
|
+
res.end(ERROR_HTML);
|
|
107
114
|
settle(() => reject(new Error(message)));
|
|
108
115
|
return;
|
|
109
116
|
}
|
package/dist/auth/oauth-fs.d.ts
CHANGED
|
@@ -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>;
|
package/dist/auth/oauth-fs.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal:
|
|
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
|
|
7
|
+
* Duplicating the helpers here keeps the dependency graph clean.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* tmp file then `rename`. On POSIX same-filesystem, rename is
|
|
11
|
-
* tmp suffix uses crypto-random bytes so concurrent writers
|
|
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
|
|
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
|
|
29
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
|
45
|
-
//
|
|
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(
|
|
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(
|
|
66
|
-
await atomicWrite(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
}
|
|
@@ -63,6 +63,7 @@ export function setupInitCommands(program) {
|
|
|
63
63
|
.option("--revoke", "revoke and remove the stored OAuth tokens")
|
|
64
64
|
.option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
|
|
65
65
|
.option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
|
|
66
|
+
.option("--unsafe-bare-code", "allow pasting a bare authorization code in the headless flow (skips the OAuth `state` CSRF check; opt-in only)")
|
|
66
67
|
.action(withCleanExit(async (options) => {
|
|
67
68
|
printStep("oauth", "Linear OAuth (PKCE)");
|
|
68
69
|
if (options.revoke) {
|
|
@@ -75,6 +76,7 @@ export function setupInitCommands(program) {
|
|
|
75
76
|
// commander's `--no-browser` produces `browser: false`.
|
|
76
77
|
noBrowser: options.browser === false,
|
|
77
78
|
port: options.port,
|
|
79
|
+
unsafeBareCode: options.unsafeBareCode === true,
|
|
78
80
|
});
|
|
79
81
|
}));
|
|
80
82
|
init
|
|
@@ -39,6 +39,12 @@ export interface OAuthStepOptions {
|
|
|
39
39
|
noBrowser?: boolean;
|
|
40
40
|
/** Override the localhost port. Default 8765. */
|
|
41
41
|
port?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Allow pasting a bare authorization code (no surrounding URL) in
|
|
44
|
+
* the headless flow. Bypasses the OAuth `state` CSRF check, so
|
|
45
|
+
* opt-in only — see `oauth-headless.ts` for the rationale.
|
|
46
|
+
*/
|
|
47
|
+
unsafeBareCode?: boolean;
|
|
42
48
|
/** Test seam for the OAuth token endpoint. */
|
|
43
49
|
fetchImpl?: FetchLike;
|
|
44
50
|
/**
|