@enrichlayer/el-linear 1.6.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 +101 -5
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-app-config.d.ts +21 -0
- package/dist/auth/oauth-app-config.js +82 -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/defaults.d.ts +18 -1
- package/dist/commands/init/defaults.js +83 -2
- package/dist/commands/init/index.js +9 -1
- package/dist/commands/init/oauth.d.ts +10 -4
- package/dist/commands/init/oauth.js +32 -8
- 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 +76 -222
- package/dist/commands/labels.js +17 -2
- 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 +144 -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/teams.js +12 -2
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +12 -0
- package/dist/config/paths.js +45 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +39 -3
- 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.d.ts +51 -0
- package/dist/utils/disk-cache.js +179 -0
- 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
|
@@ -74,13 +74,41 @@ every team ends up writing themselves:
|
|
|
74
74
|
|
|
75
75
|
## Authentication
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
el-linear supports either OAuth or a personal Linear API token. OAuth is
|
|
78
|
+
configured with:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
el-linear init oauth
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
By default, that command walks you through registering your own Linear OAuth
|
|
85
|
+
app. Teams can make the flow a single browser authorization step by writing a
|
|
86
|
+
local, untracked app-defaults file at `~/.config/el-linear/team-oauth.json`
|
|
87
|
+
or pointing `EL_LINEAR_OAUTH_CONFIG` at one:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"linearOAuth": {
|
|
92
|
+
"clientId": "your-linear-oauth-client-id",
|
|
93
|
+
"redirectPort": 8765,
|
|
94
|
+
"scopes": ["read", "write", "issues:create", "comments:create"],
|
|
95
|
+
"passwordManagerPath": "op://vault/item/client_id"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`passwordManagerPath` is optional metadata for humans/scripts; el-linear does
|
|
101
|
+
not execute password-manager commands from it. Do not put a `client_secret` in
|
|
102
|
+
this shared file. The OAuth flow uses PKCE.
|
|
103
|
+
|
|
104
|
+
At runtime, credentials are resolved in this order:
|
|
78
105
|
|
|
79
106
|
1. `--api-token <token>` flag.
|
|
80
107
|
2. `LINEAR_API_TOKEN` environment variable.
|
|
81
|
-
3. **Active profile's**
|
|
82
|
-
4. `~/.config/el-linear/token` file (
|
|
83
|
-
5. `~/.
|
|
108
|
+
3. **Active profile's** OAuth state (`oauth.json`) from `el-linear init oauth`.
|
|
109
|
+
4. **Active profile's** `~/.config/el-linear/profiles/<name>/token` file (see *Profiles* below).
|
|
110
|
+
5. `~/.config/el-linear/token` file (legacy single-profile, recommended for human use when only one workspace is needed).
|
|
111
|
+
6. `~/.linear_api_token` file (legacy, still honored).
|
|
84
112
|
|
|
85
113
|
el-linear never logs the token.
|
|
86
114
|
|
|
@@ -274,7 +302,75 @@ el-linear <command> --help # detailed help for one command
|
|
|
274
302
|
| Config | `config show`, `users list`, `teams list`, `templates list` |
|
|
275
303
|
|
|
276
304
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
277
|
-
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.
|
|
278
374
|
|
|
279
375
|
## Wrapping Linear references in arbitrary text
|
|
280
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
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional local OAuth app defaults.
|
|
3
|
+
*
|
|
4
|
+
* This deliberately lives outside the packaged/default config. Teams can
|
|
5
|
+
* materialize it from a password manager, while the OSS CLI keeps requiring
|
|
6
|
+
* users to bring their own OAuth app when no local file exists.
|
|
7
|
+
*/
|
|
8
|
+
import { type OAuthScope } from "./oauth-client.js";
|
|
9
|
+
export declare const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
|
|
10
|
+
export interface TeamOAuthConfig {
|
|
11
|
+
clientId: string;
|
|
12
|
+
redirectPort: number;
|
|
13
|
+
scopes: OAuthScope[];
|
|
14
|
+
/**
|
|
15
|
+
* Optional human-facing pointer such as `op://vault/item/client_id`.
|
|
16
|
+
* The CLI does not execute password-manager commands from this value.
|
|
17
|
+
*/
|
|
18
|
+
passwordManagerPath?: string;
|
|
19
|
+
sourcePath: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function readTeamOAuthConfig(env?: NodeJS.ProcessEnv): Promise<TeamOAuthConfig | null>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional local OAuth app defaults.
|
|
3
|
+
*
|
|
4
|
+
* This deliberately lives outside the packaged/default config. Teams can
|
|
5
|
+
* materialize it from a password manager, while the OSS CLI keeps requiring
|
|
6
|
+
* users to bring their own OAuth app when no local file exists.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import { TEAM_OAUTH_CONFIG_PATH } from "../config/paths.js";
|
|
10
|
+
import { DEFAULT_SCOPES, validateScopes, } from "./oauth-client.js";
|
|
11
|
+
export const TEAM_OAUTH_CONFIG_ENV = "EL_LINEAR_OAUTH_CONFIG";
|
|
12
|
+
export async function readTeamOAuthConfig(env = process.env) {
|
|
13
|
+
const envPath = env[TEAM_OAUTH_CONFIG_ENV]?.trim();
|
|
14
|
+
const sourcePath = envPath || TEAM_OAUTH_CONFIG_PATH;
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = await fs.readFile(sourcePath, "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
if (err.code === "ENOENT" && !envPath) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (err.code === "ENOENT") {
|
|
24
|
+
throw new Error(`${TEAM_OAUTH_CONFIG_ENV} points to ${sourcePath}, but that file does not exist.`);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
throw new Error(`Failed to parse ${sourcePath}: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
const linearOAuth = parsed.linearOAuth;
|
|
37
|
+
if (!linearOAuth || typeof linearOAuth !== "object") {
|
|
38
|
+
throw new Error(`${sourcePath} must contain a linearOAuth object with a clientId.`);
|
|
39
|
+
}
|
|
40
|
+
const clientId = typeof linearOAuth.clientId === "string" ? linearOAuth.clientId.trim() : "";
|
|
41
|
+
if (!clientId) {
|
|
42
|
+
throw new Error(`${sourcePath} linearOAuth.clientId must be a string.`);
|
|
43
|
+
}
|
|
44
|
+
const redirectPort = parseRedirectPort(linearOAuth.redirectPort, sourcePath);
|
|
45
|
+
const scopes = parseScopes(linearOAuth.scopes, sourcePath);
|
|
46
|
+
const passwordManagerPath = parsePasswordManagerPath(linearOAuth.passwordManagerPath, sourcePath);
|
|
47
|
+
return {
|
|
48
|
+
clientId,
|
|
49
|
+
redirectPort,
|
|
50
|
+
scopes,
|
|
51
|
+
passwordManagerPath,
|
|
52
|
+
sourcePath,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function parseRedirectPort(value, sourcePath) {
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
return 8765;
|
|
58
|
+
if (typeof value !== "number" ||
|
|
59
|
+
!Number.isInteger(value) ||
|
|
60
|
+
value < 1024 ||
|
|
61
|
+
value > 65535) {
|
|
62
|
+
throw new Error(`${sourcePath} linearOAuth.redirectPort must be an integer between 1024 and 65535.`);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function parseScopes(value, sourcePath) {
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
return [...DEFAULT_SCOPES];
|
|
69
|
+
if (!Array.isArray(value) || !value.every((s) => typeof s === "string")) {
|
|
70
|
+
throw new Error(`${sourcePath} linearOAuth.scopes must be a string array.`);
|
|
71
|
+
}
|
|
72
|
+
return validateScopes(value);
|
|
73
|
+
}
|
|
74
|
+
function parsePasswordManagerPath(value, sourcePath) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (typeof value !== "string") {
|
|
78
|
+
throw new Error(`${sourcePath} linearOAuth.passwordManagerPath must be a string when set.`);
|
|
79
|
+
}
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
return trimmed || undefined;
|
|
82
|
+
}
|
|
@@ -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>;
|