@enrichlayer/el-linear 1.9.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +139 -10
  2. package/claude-skills/linear-operations/SKILL.md +41 -1
  3. package/dist/auth/linear-credential.d.ts +27 -0
  4. package/dist/auth/linear-credential.js +1 -0
  5. package/dist/auth/oauth-app-config.d.ts +4 -3
  6. package/dist/auth/oauth-app-config.js +13 -2
  7. package/dist/auth/oauth-callback.d.ts +2 -3
  8. package/dist/auth/oauth-callback.js +2 -2
  9. package/dist/auth/oauth-client.d.ts +8 -2
  10. package/dist/auth/oauth-client.js +26 -0
  11. package/dist/auth/oauth-fs.d.ts +2 -1
  12. package/dist/auth/oauth-headless.d.ts +2 -1
  13. package/dist/auth/oauth-storage.d.ts +5 -1
  14. package/dist/auth/oauth-storage.js +1 -1
  15. package/dist/auth/oauth-token.d.ts +4 -3
  16. package/dist/auth/oauth-token.js +16 -4
  17. package/dist/auth/token-resolver.d.ts +14 -5
  18. package/dist/auth/token-resolver.js +6 -1
  19. package/dist/commands/attachments.js +2 -1
  20. package/dist/commands/batch.js +18 -21
  21. package/dist/commands/comments.js +22 -33
  22. package/dist/commands/config.js +178 -5
  23. package/dist/commands/cycles.js +2 -1
  24. package/dist/commands/documents.js +2 -1
  25. package/dist/commands/graphql.js +4 -6
  26. package/dist/commands/init/aliases.js +1 -1
  27. package/dist/commands/init/defaults.d.ts +2 -1
  28. package/dist/commands/init/index.js +45 -35
  29. package/dist/commands/init/oauth.d.ts +4 -1
  30. package/dist/commands/init/oauth.js +22 -4
  31. package/dist/commands/init/shared.d.ts +24 -2
  32. package/dist/commands/init/shared.js +35 -4
  33. package/dist/commands/init/token.d.ts +3 -3
  34. package/dist/commands/init/token.js +5 -24
  35. package/dist/commands/init/workspace.d.ts +2 -1
  36. package/dist/commands/init/workspace.js +1 -1
  37. package/dist/commands/introspect.d.ts +27 -0
  38. package/dist/commands/introspect.js +178 -0
  39. package/dist/commands/issue-id.js +1 -3
  40. package/dist/commands/issues/branch.js +9 -1
  41. package/dist/commands/issues/description.js +2 -6
  42. package/dist/commands/issues/link-references.d.ts +21 -0
  43. package/dist/commands/issues/link-references.js +171 -0
  44. package/dist/commands/issues/relations.d.ts +44 -0
  45. package/dist/commands/issues/relations.js +132 -0
  46. package/dist/commands/issues.js +269 -309
  47. package/dist/commands/labels.js +15 -24
  48. package/dist/commands/profile.js +1 -0
  49. package/dist/commands/project-milestones.js +13 -20
  50. package/dist/commands/projects.d.ts +2 -0
  51. package/dist/commands/projects.js +157 -44
  52. package/dist/commands/read-shortcut.d.ts +1 -1
  53. package/dist/commands/read-shortcut.js +28 -8
  54. package/dist/commands/refs.js +75 -8
  55. package/dist/commands/releases.js +26 -30
  56. package/dist/commands/search.js +49 -33
  57. package/dist/commands/teams.js +2 -1
  58. package/dist/commands/templates.js +9 -14
  59. package/dist/commands/users.js +5 -2
  60. package/dist/config/config.d.ts +99 -1
  61. package/dist/config/config.js +264 -52
  62. package/dist/config/error-enrichment.d.ts +62 -0
  63. package/dist/config/error-enrichment.js +417 -0
  64. package/dist/config/issue-validation.d.ts +37 -0
  65. package/dist/config/issue-validation.js +63 -1
  66. package/dist/config/paths.d.ts +2 -8
  67. package/dist/config/paths.js +4 -2
  68. package/dist/config/resolver.d.ts +8 -1
  69. package/dist/config/resolver.js +11 -5
  70. package/dist/main.js +13 -1
  71. package/dist/queries/attachments-types.d.ts +30 -0
  72. package/dist/queries/attachments-types.js +5 -0
  73. package/dist/queries/comments-types.d.ts +55 -0
  74. package/dist/queries/comments-types.js +5 -0
  75. package/dist/queries/common.d.ts +2 -2
  76. package/dist/queries/common.js +8 -0
  77. package/dist/queries/documents-types.d.ts +62 -0
  78. package/dist/queries/documents-types.js +9 -0
  79. package/dist/queries/introspect-types.d.ts +58 -0
  80. package/dist/queries/introspect-types.js +10 -0
  81. package/dist/queries/issues-types.d.ts +481 -0
  82. package/dist/queries/issues-types.js +23 -0
  83. package/dist/queries/issues.d.ts +51 -10
  84. package/dist/queries/issues.js +147 -5
  85. package/dist/queries/labels-types.d.ts +65 -0
  86. package/dist/queries/labels-types.js +5 -0
  87. package/dist/queries/project-milestones-types.d.ts +92 -0
  88. package/dist/queries/project-milestones-types.js +10 -0
  89. package/dist/queries/project-milestones.d.ts +1 -1
  90. package/dist/queries/projects-types.d.ts +76 -0
  91. package/dist/queries/projects-types.js +5 -0
  92. package/dist/queries/projects.d.ts +2 -0
  93. package/dist/queries/projects.js +22 -0
  94. package/dist/queries/releases-types.d.ts +85 -0
  95. package/dist/queries/releases-types.js +5 -0
  96. package/dist/queries/search-types.d.ts +102 -0
  97. package/dist/queries/search-types.js +6 -0
  98. package/dist/queries/templates-types.d.ts +62 -0
  99. package/dist/queries/templates-types.js +9 -0
  100. package/dist/types/linear.d.ts +21 -3
  101. package/dist/utils/auto-link-references.d.ts +3 -3
  102. package/dist/utils/auto-link-references.js +30 -34
  103. package/dist/utils/extract-field.d.ts +19 -0
  104. package/dist/utils/extract-field.js +99 -0
  105. package/dist/utils/file-service.d.ts +6 -13
  106. package/dist/utils/file-service.js +0 -2
  107. package/dist/utils/formatters/summary.js +6 -1
  108. package/dist/utils/graphql-attachments-service.js +6 -9
  109. package/dist/utils/graphql-documents-service.js +19 -25
  110. package/dist/utils/graphql-issues-service.d.ts +112 -46
  111. package/dist/utils/graphql-issues-service.js +398 -206
  112. package/dist/utils/graphql-service.d.ts +10 -12
  113. package/dist/utils/graphql-service.js +0 -3
  114. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  115. package/dist/utils/issue-reference-extractor.js +5 -3
  116. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  117. package/dist/utils/issues-service-bootstrap.js +27 -0
  118. package/dist/utils/linear-service.d.ts +21 -14
  119. package/dist/utils/linear-service.js +73 -11
  120. package/dist/utils/markdown-prosemirror.js +12 -12
  121. package/dist/utils/mention-resolver.js +1 -1
  122. package/dist/utils/output.d.ts +82 -2
  123. package/dist/utils/output.js +76 -11
  124. package/dist/utils/project-slug.d.ts +21 -0
  125. package/dist/utils/project-slug.js +45 -0
  126. package/dist/utils/protected-ranges.d.ts +14 -0
  127. package/dist/utils/protected-ranges.js +88 -2
  128. package/dist/utils/sanitize-for-log.d.ts +24 -0
  129. package/dist/utils/sanitize-for-log.js +38 -0
  130. package/dist/utils/table-formatter.js +24 -0
  131. package/dist/utils/validators.d.ts +7 -2
  132. package/dist/utils/validators.js +6 -0
  133. package/dist/utils/workspace-url.d.ts +5 -1
  134. package/dist/utils/workspace-url.js +53 -7
  135. package/package.json +2 -2
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Parse a Linear project URL or bare slug-id into the `slugId` form that
3
+ * Linear's GraphQL `ProjectFilter.slugId` accepts.
4
+ *
5
+ * Linear project URLs look like:
6
+ * https://linear.app/<workspace>/project/<slug>-<12-hex>/<view>
7
+ *
8
+ * The slug-id is the `<slug>-<12-hex>` segment (kebab-case name followed
9
+ * by a 12-character hex suffix). Linear uses this exact string as the
10
+ * unique `slugId` field on the Project type.
11
+ *
12
+ * Accepts:
13
+ * - Full URL form: extracts the slug-id from the path
14
+ * - Bare slug-id: `tools-and-standardization-40815d9beb16`
15
+ * - Trailing path / query string is tolerated (`/overview`, `?foo=bar`)
16
+ *
17
+ * Returns `null` when the input is neither — caller can then fall through
18
+ * to name-based or UUID-based resolution. Canonical UUIDs are rejected
19
+ * here so that `isUuid()` callers stay the authoritative UUID path.
20
+ */
21
+ export declare function parseProjectSlugId(input: string): string | null;
@@ -0,0 +1,45 @@
1
+ import { isUuid } from "./uuid.js";
2
+ /**
3
+ * Parse a Linear project URL or bare slug-id into the `slugId` form that
4
+ * Linear's GraphQL `ProjectFilter.slugId` accepts.
5
+ *
6
+ * Linear project URLs look like:
7
+ * https://linear.app/<workspace>/project/<slug>-<12-hex>/<view>
8
+ *
9
+ * The slug-id is the `<slug>-<12-hex>` segment (kebab-case name followed
10
+ * by a 12-character hex suffix). Linear uses this exact string as the
11
+ * unique `slugId` field on the Project type.
12
+ *
13
+ * Accepts:
14
+ * - Full URL form: extracts the slug-id from the path
15
+ * - Bare slug-id: `tools-and-standardization-40815d9beb16`
16
+ * - Trailing path / query string is tolerated (`/overview`, `?foo=bar`)
17
+ *
18
+ * Returns `null` when the input is neither — caller can then fall through
19
+ * to name-based or UUID-based resolution. Canonical UUIDs are rejected
20
+ * here so that `isUuid()` callers stay the authoritative UUID path.
21
+ */
22
+ export function parseProjectSlugId(input) {
23
+ const trimmed = input.trim();
24
+ if (!trimmed || isUuid(trimmed)) {
25
+ return null;
26
+ }
27
+ const urlMatch = trimmed.match(/\blinear\.app\/[^/\s]+\/project\/([^/?#\s]+)/i);
28
+ if (urlMatch) {
29
+ const candidate = urlMatch[1];
30
+ return looksLikeSlugId(candidate) ? candidate : null;
31
+ }
32
+ if (looksLikeSlugId(trimmed)) {
33
+ return trimmed;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * A Linear project slug-id is a kebab-case name segment followed by a
39
+ * 12-character hex suffix. The minimum form is just the 12 hex chars
40
+ * (when the project name slugifies to empty), but in practice there's
41
+ * always at least one name segment.
42
+ */
43
+ function looksLikeSlugId(value) {
44
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-f0-9]{12}$/i.test(value);
45
+ }
@@ -17,6 +17,20 @@
17
17
  */
18
18
  /** Linear identifier shape: ABC-123, EMW-1, DEV-3592. */
19
19
  export declare const IDENTIFIER_REGEX: RegExp;
20
+ /**
21
+ * Find the first position in `s` where a close-bracket character
22
+ * appears without a matching opener earlier in the string. Returns
23
+ * `s.length` if all close-brackets are balanced. Used to truncate
24
+ * a bare-URL match at the first unbalanced `)`, `]`, or `}` —
25
+ * exactly the position CommonMark treats as the URL terminator.
26
+ *
27
+ * Exported for direct unit testing. Depth counters per bracket type
28
+ * are independent — pathological interleavings like `[(a]b)` don't
29
+ * trigger an unbalance, which errs toward keeping a URL whole rather
30
+ * than over-truncating (no real-world URL nests bracket types this
31
+ * way).
32
+ */
33
+ export declare function firstUnbalancedClose(s: string): number;
20
34
  export interface ProtectedRange {
21
35
  end: number;
22
36
  start: number;
@@ -33,7 +33,87 @@ const ANGLE_AUTOLINK_REGEX = /<[^>\s]+>/g;
33
33
  // Bare URLs in prose. We protect these so identifiers inside path
34
34
  // components (e.g. "https://github.com/foo/DEV-100") don't get
35
35
  // processed.
36
- const BARE_URL_REGEX = /https?:\/\/\S+/g;
36
+ //
37
+ // Match strategy follows CommonMark's "extended autolink" rule:
38
+ //
39
+ // 1. Greedy match up to whitespace or angle/quote terminators
40
+ // (`<`, `>`, `"`). Parens and brackets stay IN the match — they
41
+ // appear inside legitimate URLs (Wikipedia article paths, Next.js
42
+ // route groups like `/docs/app/(group)/page`, CDN signing-key
43
+ // query strings, etc.).
44
+ // 2. Post-process via `trimBareUrlTrailingPunct` to strip UNBALANCED
45
+ // trailing brackets and stand-alone punctuation. So
46
+ // `https://example.com/foo)DEV-100` correctly terminates at
47
+ // `…foo` (the closing paren is unbalanced — no opener inside the
48
+ // match), while `https://en.wikipedia.org/wiki/Foo_(bar)` keeps
49
+ // its balanced parens intact.
50
+ //
51
+ // Pre-fix `https?:\/\/\S+` greedily consumed everything to the next
52
+ // whitespace and silently hid identifiers in position-dependent
53
+ // prose. A char-class exclusion of `)`, `]`, `}` over-corrected and
54
+ // broke Wikipedia / route-group URLs (cycle 2 finding on PR #75).
55
+ // The balanced-paren trim is the CommonMark-faithful middle ground.
56
+ const BARE_URL_REGEX = /https?:\/\/[^\s<>"]+/g;
57
+ /**
58
+ * Find the first position in `s` where a close-bracket character
59
+ * appears without a matching opener earlier in the string. Returns
60
+ * `s.length` if all close-brackets are balanced. Used to truncate
61
+ * a bare-URL match at the first unbalanced `)`, `]`, or `}` —
62
+ * exactly the position CommonMark treats as the URL terminator.
63
+ *
64
+ * Exported for direct unit testing. Depth counters per bracket type
65
+ * are independent — pathological interleavings like `[(a]b)` don't
66
+ * trigger an unbalance, which errs toward keeping a URL whole rather
67
+ * than over-truncating (no real-world URL nests bracket types this
68
+ * way).
69
+ */
70
+ export function firstUnbalancedClose(s) {
71
+ let parenDepth = 0;
72
+ let brackDepth = 0;
73
+ let braceDepth = 0;
74
+ for (let i = 0; i < s.length; i++) {
75
+ const ch = s[i];
76
+ if (ch === "(")
77
+ parenDepth++;
78
+ else if (ch === ")") {
79
+ if (parenDepth === 0)
80
+ return i;
81
+ parenDepth--;
82
+ }
83
+ else if (ch === "[")
84
+ brackDepth++;
85
+ else if (ch === "]") {
86
+ if (brackDepth === 0)
87
+ return i;
88
+ brackDepth--;
89
+ }
90
+ else if (ch === "{")
91
+ braceDepth++;
92
+ else if (ch === "}") {
93
+ if (braceDepth === 0)
94
+ return i;
95
+ braceDepth--;
96
+ }
97
+ }
98
+ return s.length;
99
+ }
100
+ function trimBareUrlTrailingPunct(url) {
101
+ // First: truncate at the first unbalanced bracket. So
102
+ // `https://example.com/foo)DEV-100` (no `(` opener inside) terminates
103
+ // at the `)` regardless of what follows it; `Foo_(bar)/DEV` keeps
104
+ // the balanced `(bar)` intact.
105
+ let trimmed = url.slice(0, firstUnbalancedClose(url));
106
+ // Then: strip standard trailing sentence-terminators (`.`, `,`, `;`,
107
+ // `:`, `!`, `?`). These never appear in legitimate URL paths at the
108
+ // very end, but they're commonly adjacent to URLs in prose. The
109
+ // char class deliberately excludes `)`, `]`, `}` — those are
110
+ // already handled by firstUnbalancedClose above, where the
111
+ // balanced/unbalanced check lives.
112
+ while (trimmed.length > 0 && /[.,;:!?]$/.test(trimmed)) {
113
+ trimmed = trimmed.slice(0, -1);
114
+ }
115
+ return trimmed;
116
+ }
37
117
  /**
38
118
  * Find ranges of `text` that should NOT have identifiers processed.
39
119
  * Covered: fenced code, inline backticks, existing markdown links,
@@ -54,13 +134,19 @@ export function findProtectedRanges(text) {
54
134
  // clarity here.
55
135
  SLACK_LINK_REGEX,
56
136
  ANGLE_AUTOLINK_REGEX,
57
- BARE_URL_REGEX,
58
137
  ]) {
59
138
  for (const m of text.matchAll(re)) {
60
139
  const start = m.index ?? 0;
61
140
  ranges.push({ start, end: start + m[0].length });
62
141
  }
63
142
  }
143
+ // Bare URLs need the trailing-punctuation trim that's hard to express
144
+ // in pure regex (depends on bracket-balance inside the match).
145
+ for (const m of text.matchAll(BARE_URL_REGEX)) {
146
+ const start = m.index ?? 0;
147
+ const trimmed = trimBareUrlTrailingPunct(m[0]);
148
+ ranges.push({ start, end: start + trimmed.length });
149
+ }
64
150
  return ranges;
65
151
  }
66
152
  export function isProtected(pos, ranges) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Strip anything that looks like a Linear API/OAuth token from a string.
3
+ *
4
+ * Defense in depth: today the @linear/sdk error message embeds `{ query,
5
+ * variables }` but not the Authorization header. A future SDK upgrade that
6
+ * includes headers (which upstream graphql-request has done historically)
7
+ * would otherwise silently write `Bearer lin_api_…` into stdout, shell
8
+ * history, or CI logs. The regex also catches token shapes that may show
9
+ * up in custom error wrappers — e.g. a network proxy that echoes the
10
+ * Bearer header in its 502 body.
11
+ *
12
+ * Originally lived in `commands/init/token.ts` for the wizard's error
13
+ * formatting. Hoisted to `utils/` so the central error path
14
+ * (`output.ts`'s `outputError`) can use it too — that path runs on
15
+ * every non-wizard CLI invocation. `init/token.ts` re-exports the
16
+ * symbol so existing imports under `init/` keep working.
17
+ *
18
+ * The OAuth token-exchange / refresh / revoke paths also call this
19
+ * function at source (`auth/oauth-token.ts`'s `postForm` + `revokeToken`,
20
+ * `auth/token-resolver.ts`'s refresh-failure rewrap) so a future caller
21
+ * that catches+rethrows or logs mid-chain can't leak a token before the
22
+ * error reaches `outputError`. Defense in depth (DEV-4065).
23
+ */
24
+ export declare function sanitizeForLog(text: string): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Strip anything that looks like a Linear API/OAuth token from a string.
3
+ *
4
+ * Defense in depth: today the @linear/sdk error message embeds `{ query,
5
+ * variables }` but not the Authorization header. A future SDK upgrade that
6
+ * includes headers (which upstream graphql-request has done historically)
7
+ * would otherwise silently write `Bearer lin_api_…` into stdout, shell
8
+ * history, or CI logs. The regex also catches token shapes that may show
9
+ * up in custom error wrappers — e.g. a network proxy that echoes the
10
+ * Bearer header in its 502 body.
11
+ *
12
+ * Originally lived in `commands/init/token.ts` for the wizard's error
13
+ * formatting. Hoisted to `utils/` so the central error path
14
+ * (`output.ts`'s `outputError`) can use it too — that path runs on
15
+ * every non-wizard CLI invocation. `init/token.ts` re-exports the
16
+ * symbol so existing imports under `init/` keep working.
17
+ *
18
+ * The OAuth token-exchange / refresh / revoke paths also call this
19
+ * function at source (`auth/oauth-token.ts`'s `postForm` + `revokeToken`,
20
+ * `auth/token-resolver.ts`'s refresh-failure rewrap) so a future caller
21
+ * that catches+rethrows or logs mid-chain can't leak a token before the
22
+ * error reaches `outputError`. Defense in depth (DEV-4065).
23
+ */
24
+ // Personal-API tokens (`lin_api_…`) and OAuth access/refresh tokens
25
+ // (`lin_oauth_…`).
26
+ const TOKEN_PREFIX_RE = /lin_(api|oauth)_[A-Za-z0-9_-]{16,}/g;
27
+ // High-entropy bearer payload fallback: catches generic Bearer-style
28
+ // strings adjacent to Authorization / Bearer keywords. Useful for
29
+ // future SDK error wrappers that might leak headers without the
30
+ // `lin_` prefix.
31
+ const BEARER_PAYLOAD_RE = /(\b(?:Authorization|Bearer)\b[:\s]*)([A-Za-z0-9_\-/+=]{40,})/gi;
32
+ export function sanitizeForLog(text) {
33
+ return text
34
+ .replace(TOKEN_PREFIX_RE, (m) => m.startsWith("lin_oauth_")
35
+ ? "lin_oauth_***REDACTED***"
36
+ : "lin_api_***REDACTED***")
37
+ .replace(BEARER_PAYLOAD_RE, "$1***REDACTED***");
38
+ }
@@ -86,6 +86,19 @@ const ALL_COLUMNS = {
86
86
  return atIdx > 0 ? name.slice(0, atIdx) : name;
87
87
  },
88
88
  },
89
+ delegate: {
90
+ key: "delegate",
91
+ header: "Delegate",
92
+ width: 20,
93
+ extract: (i) => {
94
+ if (!i.delegate) {
95
+ return "—";
96
+ }
97
+ const name = i.delegate.name;
98
+ const atIdx = name.indexOf("@");
99
+ return atIdx > 0 ? name.slice(0, atIdx) : name;
100
+ },
101
+ },
89
102
  project: {
90
103
  key: "project",
91
104
  header: "Project",
@@ -229,6 +242,17 @@ const MD_COLUMNS = {
229
242
  return atIdx > 0 ? name.slice(0, atIdx) : name;
230
243
  },
231
244
  },
245
+ delegate: {
246
+ header: "Delegate",
247
+ extract: (i) => {
248
+ if (!i.delegate) {
249
+ return "—";
250
+ }
251
+ const name = i.delegate.name;
252
+ const atIdx = name.indexOf("@");
253
+ return atIdx > 0 ? name.slice(0, atIdx) : name;
254
+ },
255
+ },
232
256
  project: {
233
257
  header: "Project",
234
258
  extract: (i) => i.project?.name ?? "—",
@@ -1,3 +1,4 @@
1
+ import type { LinearPriority } from "../types/linear.js";
1
2
  export declare function parsePositiveInt(value: string, flagName: string): number;
2
3
  /**
3
4
  * Parse a single priority value (for `issues create --priority`, `issues update --priority`).
@@ -5,8 +6,12 @@ export declare function parsePositiveInt(value: string, flagName: string): numbe
5
6
  * Accepts:
6
7
  * - keywords: none | urgent | high | medium | normal | low
7
8
  * - numbers: 0 (no priority), 1 (urgent), 2 (high), 3 (medium), 4 (low)
9
+ *
10
+ * Narrows arbitrary string input to the `LinearPriority` literal union so
11
+ * downstream call sites get type-level guarantees that the value is in
12
+ * range — no `as 0 | 1 | 2 | 3 | 4` cast at the boundary.
8
13
  */
9
- export declare function validatePriority(value: string): number;
14
+ export declare function validatePriority(value: string): LinearPriority;
10
15
  export declare function validateHexColor(value: string): string;
11
16
  export declare function validateIsoDate(value: string): string;
12
17
  /**
@@ -18,5 +23,5 @@ export declare function validateIsoDate(value: string): string;
18
23
  * resolve to `[]`.
19
24
  */
20
25
  export declare function splitList(value: string | undefined | null | false): string[];
21
- export declare function parsePriorityFilter(value: string): number[];
26
+ export declare function parsePriorityFilter(value: string): LinearPriority[];
22
27
  export declare const PRIORITY_LABELS: Record<number, string>;
@@ -14,6 +14,10 @@ export function parsePositiveInt(value, flagName) {
14
14
  * Accepts:
15
15
  * - keywords: none | urgent | high | medium | normal | low
16
16
  * - numbers: 0 (no priority), 1 (urgent), 2 (high), 3 (medium), 4 (low)
17
+ *
18
+ * Narrows arbitrary string input to the `LinearPriority` literal union so
19
+ * downstream call sites get type-level guarantees that the value is in
20
+ * range — no `as 0 | 1 | 2 | 3 | 4` cast at the boundary.
17
21
  */
18
22
  export function validatePriority(value) {
19
23
  const asName = PRIORITY_NAMES[value.toLowerCase()];
@@ -24,6 +28,8 @@ export function validatePriority(value) {
24
28
  if (Number.isNaN(n) || n < 0 || n > 4) {
25
29
  throw invalidParameterError("--priority", `"${value}" is not valid. Use names (none, urgent, high, medium/normal, low) or numbers (0-4).`);
26
30
  }
31
+ // The 0-4 range check above is what narrows `n` to LinearPriority;
32
+ // TypeScript can't infer that automatically.
27
33
  return n;
28
34
  }
29
35
  export function validateHexColor(value) {
@@ -1,4 +1,8 @@
1
1
  import type { GraphQLService } from "./graphql-service.js";
2
- export declare function getWorkspaceUrlKey(graphQLService: GraphQLService): Promise<string>;
2
+ export interface GetWorkspaceUrlKeyOptions {
3
+ /** Highest-priority source. Typically the `--workspace-url-key` flag. */
4
+ override?: string;
5
+ }
6
+ export declare function getWorkspaceUrlKey(graphQLService?: GraphQLService, options?: GetWorkspaceUrlKeyOptions): Promise<string>;
3
7
  /** Test helper: clear the cached URL key. Not called from production code. */
4
8
  export declare function _resetWorkspaceUrlKeyCache(): void;
@@ -4,11 +4,20 @@ import { loadConfig } from "../config/config.js";
4
4
  * issue URLs). Used to build canonical markdown link URLs like
5
5
  * `https://linear.app/<urlKey>/issue/<identifier>/`.
6
6
  *
7
- * Resolution order:
8
- * 1. `config.workspaceUrlKey` if explicitly set in el-linear config
9
- * 2. `viewer.organization.urlKey` from the Linear API (fetched once, cached)
7
+ * Resolution order (highest priority first):
10
8
  *
11
- * Cached in-process for the lifetime of the CLI invocation.
9
+ * 1. `options.override` — typically wired from a `--workspace-url-key` CLI flag
10
+ * (per-invocation, wins over everything).
11
+ * 2. `EL_LINEAR_WORKSPACE_URL_KEY` env var.
12
+ * 3. `config.workspaceUrlKey` from `~/.config/el-linear/config.json`.
13
+ * 4. Live `viewer.organization.urlKey` GraphQL query (cached in-process).
14
+ *
15
+ * Layers 1–3 short-circuit without touching the network — this makes
16
+ * `el-linear refs wrap --no-validate` work offline once any one of them
17
+ * is set. Layer 4 is the original behavior preserved as a fallback.
18
+ *
19
+ * The graphQLService is only needed for layer 4 — pass `undefined` (or omit it)
20
+ * when the caller knows the network will not be used.
12
21
  */
13
22
  const VIEWER_ORG_URL_KEY_QUERY = /* GraphQL */ `
14
23
  query ViewerOrgUrlKey {
@@ -19,15 +28,52 @@ const VIEWER_ORG_URL_KEY_QUERY = /* GraphQL */ `
19
28
  }
20
29
  }
21
30
  `;
31
+ const WORKSPACE_URL_KEY_ENV = "EL_LINEAR_WORKSPACE_URL_KEY";
32
+ // Linear workspace URL keys are URL-safe slugs. The Linear API itself
33
+ // validates this shape (`viewerIsValid` in init/token.ts uses the same
34
+ // regex). Applying the check on env + config reads closes the gap where
35
+ // a malformed key (`EL_LINEAR_WORKSPACE_URL_KEY=" javascript:alert(1)#"`)
36
+ // would otherwise flow into markdown link URLs verbatim. Live-API path
37
+ // is already trusted because the value comes from `viewer.organization`.
38
+ // DEV-4067.
39
+ const VALID_URL_KEY_RE = /^[a-z0-9-]+$/i;
40
+ function validateUrlKey(value, source) {
41
+ if (!VALID_URL_KEY_RE.test(value)) {
42
+ throw new Error(`Invalid Linear workspace URL key from ${source}: ${JSON.stringify(value)}. ` +
43
+ "Must match /^[a-z0-9-]+$/i (e.g. 'verticalint').");
44
+ }
45
+ return value;
46
+ }
22
47
  let cachedUrlKey;
23
- export async function getWorkspaceUrlKey(graphQLService) {
48
+ export async function getWorkspaceUrlKey(graphQLService, options = {}) {
49
+ // 1. Per-invocation override — highest priority.
50
+ if (options.override) {
51
+ return validateUrlKey(options.override, "--workspace-url-key flag");
52
+ }
53
+ // 2. Env var. Read on every call (cheap; lets tests/CI flip the value
54
+ // mid-process without restarting). Not cached, so a test that sets
55
+ // EL_LINEAR_WORKSPACE_URL_KEY then unsets it sees the unset state.
56
+ const envKey = process.env[WORKSPACE_URL_KEY_ENV];
57
+ if (envKey) {
58
+ return validateUrlKey(envKey, `${WORKSPACE_URL_KEY_ENV} env var`);
59
+ }
60
+ // Layers 3-4 are cached in-process — config and the live lookup are
61
+ // both stable for the CLI invocation's lifetime.
24
62
  if (cachedUrlKey) {
25
63
  return cachedUrlKey;
26
64
  }
65
+ // 3. Config override.
27
66
  const { workspaceUrlKey } = loadConfig();
28
67
  if (workspaceUrlKey) {
29
- cachedUrlKey = workspaceUrlKey;
30
- return workspaceUrlKey;
68
+ const validated = validateUrlKey(workspaceUrlKey, "config.workspaceUrlKey");
69
+ cachedUrlKey = validated;
70
+ return validated;
71
+ }
72
+ // 4. Live API lookup — requires a GraphQL service.
73
+ if (!graphQLService) {
74
+ throw new Error("Could not resolve Linear workspace URL key — no override, env, or config set, " +
75
+ "and no GraphQL service was provided for the live lookup. " +
76
+ `Set \`workspaceUrlKey\` in your el-linear config, or \`${WORKSPACE_URL_KEY_ENV}\` in the environment.`);
31
77
  }
32
78
  const data = await graphQLService.rawRequest(VIEWER_ORG_URL_KEY_QUERY);
33
79
  const fetched = data?.viewer?.organization?.urlKey;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.9.0",
3
+ "version": "1.15.0",
4
4
  "description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
5
5
  "main": "dist/main.js",
6
6
  "types": "dist/main.d.ts",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@inquirer/prompts": "^8.4.2",
50
- "@linear/sdk": "^83.0.0",
50
+ "@linear/sdk": "^84.0.0",
51
51
  "commander": "^14.0.0",
52
52
  "picocolors": "^1.1.1"
53
53
  },