@enrichlayer/el-linear 1.10.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 (106) hide show
  1. package/README.md +126 -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/batch.js +18 -21
  20. package/dist/commands/comments.js +5 -5
  21. package/dist/commands/config.js +178 -5
  22. package/dist/commands/init/aliases.js +1 -1
  23. package/dist/commands/init/defaults.d.ts +2 -1
  24. package/dist/commands/init/index.js +45 -35
  25. package/dist/commands/init/oauth.d.ts +4 -1
  26. package/dist/commands/init/oauth.js +22 -4
  27. package/dist/commands/init/shared.d.ts +24 -2
  28. package/dist/commands/init/shared.js +35 -4
  29. package/dist/commands/init/token.d.ts +3 -3
  30. package/dist/commands/init/token.js +5 -24
  31. package/dist/commands/init/workspace.d.ts +2 -1
  32. package/dist/commands/init/workspace.js +1 -1
  33. package/dist/commands/introspect.d.ts +27 -0
  34. package/dist/commands/introspect.js +178 -0
  35. package/dist/commands/issues/branch.js +9 -1
  36. package/dist/commands/issues/relations.d.ts +3 -14
  37. package/dist/commands/issues/relations.js +3 -3
  38. package/dist/commands/issues.js +222 -43
  39. package/dist/commands/labels.js +2 -1
  40. package/dist/commands/profile.js +1 -0
  41. package/dist/commands/projects.d.ts +2 -0
  42. package/dist/commands/projects.js +91 -7
  43. package/dist/commands/read-shortcut.d.ts +1 -1
  44. package/dist/commands/read-shortcut.js +28 -8
  45. package/dist/commands/refs.js +67 -8
  46. package/dist/commands/search.js +30 -5
  47. package/dist/commands/users.js +4 -2
  48. package/dist/config/config.d.ts +99 -1
  49. package/dist/config/config.js +264 -52
  50. package/dist/config/error-enrichment.d.ts +62 -0
  51. package/dist/config/error-enrichment.js +417 -0
  52. package/dist/config/issue-validation.d.ts +37 -0
  53. package/dist/config/issue-validation.js +63 -1
  54. package/dist/config/paths.d.ts +2 -8
  55. package/dist/config/paths.js +4 -2
  56. package/dist/config/resolver.d.ts +8 -1
  57. package/dist/config/resolver.js +9 -2
  58. package/dist/main.js +13 -1
  59. package/dist/queries/comments-types.d.ts +15 -9
  60. package/dist/queries/common.d.ts +2 -2
  61. package/dist/queries/common.js +8 -0
  62. package/dist/queries/documents-types.d.ts +4 -3
  63. package/dist/queries/introspect-types.d.ts +8 -7
  64. package/dist/queries/issues-types.d.ts +92 -27
  65. package/dist/queries/issues.d.ts +49 -10
  66. package/dist/queries/issues.js +125 -5
  67. package/dist/queries/labels-types.d.ts +7 -6
  68. package/dist/queries/project-milestones-types.d.ts +5 -4
  69. package/dist/queries/project-milestones.d.ts +1 -1
  70. package/dist/queries/projects-types.d.ts +8 -7
  71. package/dist/queries/releases-types.d.ts +5 -4
  72. package/dist/queries/search-types.d.ts +28 -12
  73. package/dist/queries/templates-types.d.ts +3 -2
  74. package/dist/types/linear.d.ts +13 -1
  75. package/dist/utils/auto-link-references.d.ts +3 -3
  76. package/dist/utils/auto-link-references.js +1 -10
  77. package/dist/utils/extract-field.d.ts +19 -0
  78. package/dist/utils/extract-field.js +99 -0
  79. package/dist/utils/file-service.d.ts +6 -13
  80. package/dist/utils/file-service.js +0 -2
  81. package/dist/utils/formatters/summary.js +6 -1
  82. package/dist/utils/graphql-issues-service.d.ts +101 -45
  83. package/dist/utils/graphql-issues-service.js +252 -39
  84. package/dist/utils/graphql-service.d.ts +10 -12
  85. package/dist/utils/graphql-service.js +0 -3
  86. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  87. package/dist/utils/issue-reference-extractor.js +5 -3
  88. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  89. package/dist/utils/issues-service-bootstrap.js +27 -0
  90. package/dist/utils/linear-service.d.ts +21 -14
  91. package/dist/utils/linear-service.js +73 -11
  92. package/dist/utils/markdown-prosemirror.js +12 -12
  93. package/dist/utils/mention-resolver.js +1 -1
  94. package/dist/utils/output.d.ts +81 -3
  95. package/dist/utils/output.js +61 -6
  96. package/dist/utils/project-slug.d.ts +21 -0
  97. package/dist/utils/project-slug.js +45 -0
  98. package/dist/utils/protected-ranges.d.ts +14 -0
  99. package/dist/utils/protected-ranges.js +88 -2
  100. package/dist/utils/sanitize-for-log.d.ts +24 -0
  101. package/dist/utils/sanitize-for-log.js +38 -0
  102. package/dist/utils/table-formatter.js +24 -0
  103. package/dist/utils/validators.d.ts +7 -2
  104. package/dist/utils/validators.js +6 -0
  105. package/dist/utils/workspace-url.js +20 -4
  106. package/package.json +2 -2
@@ -4,6 +4,7 @@ import { resolveUserDisplayName } from "../config/resolver.js";
4
4
  import { toISOStringOrNow, toISOStringOrUndefined } from "./date-format.js";
5
5
  import { multipleMatchesError, notFoundError } from "./error-messages.js";
6
6
  import { parseIssueIdentifier } from "./identifier-parser.js";
7
+ import { parseProjectSlugId } from "./project-slug.js";
7
8
  import { isUuid } from "./uuid.js";
8
9
  const DEFAULT_CYCLE_PAGINATION_LIMIT = 250;
9
10
  // The Linear SDK types don't accept string orderBy values, but the API does.
@@ -22,9 +23,6 @@ function nonEmptyFilter(filter) {
22
23
  return Object.keys(filter).length > 0 ? filter : undefined;
23
24
  }
24
25
  function buildLinearClient(auth) {
25
- if (typeof auth === "string") {
26
- return new LinearClient({ apiKey: auth });
27
- }
28
26
  if ("oauthToken" in auth) {
29
27
  // Linear's SDK natively supports OAuth via the `accessToken` option,
30
28
  // which causes the underlying transport to send
@@ -133,13 +131,28 @@ export class LinearService {
133
131
  else if (options.excludeStates && options.excludeStates.length > 0) {
134
132
  filter.state = { nin: options.excludeStates };
135
133
  }
136
- const projects = await this.client.projects({
134
+ if (options.teamId) {
135
+ filter.teams = { some: { id: { eq: options.teamId } } };
136
+ }
137
+ // `limit === 0` means "no limit" — paginate the full result set.
138
+ // Otherwise a single page of `limit` projects. DEV-4175: `--all` /
139
+ // `--limit 0` must return every project so callers never make a false
140
+ // "does not exist" determination off a silently truncated page.
141
+ const unlimited = limit === 0;
142
+ let page = await this.client.projects({
137
143
  filter: nonEmptyFilter(filter),
138
- first: limit,
144
+ first: unlimited ? 250 : limit,
139
145
  orderBy: sdkOrderBy("updatedAt"),
140
146
  includeArchived: false,
141
147
  });
142
- const projectsWithData = await Promise.all(projects.nodes.map(async (project) => {
148
+ const projectNodes = [...page.nodes];
149
+ if (unlimited) {
150
+ while (page.pageInfo.hasNextPage) {
151
+ page = await page.fetchNext();
152
+ projectNodes.push(...page.nodes);
153
+ }
154
+ }
155
+ const projectsWithData = await Promise.all(projectNodes.map(async (project) => {
143
156
  const [teams, lead] = await Promise.all([
144
157
  project.teams(),
145
158
  project.lead,
@@ -373,6 +386,9 @@ export class LinearService {
373
386
  url: issue.url,
374
387
  title: issue.title,
375
388
  description: issue.description || undefined,
389
+ // Linear SDK types `priority` as `number`; the GraphQL schema only
390
+ // emits 0-4, so the cast is safe and the runtime range is
391
+ // guaranteed by Linear's server-side schema.
376
392
  priority: issue.priority,
377
393
  estimate: issue.estimate || undefined,
378
394
  state: state ? { id: state.id, name: state.name } : undefined,
@@ -455,20 +471,66 @@ export class LinearService {
455
471
  }
456
472
  return chosen.id;
457
473
  }
458
- async resolveProjectId(projectNameOrId) {
459
- if (isUuid(projectNameOrId)) {
460
- return projectNameOrId;
474
+ async resolveProjectId(projectInput) {
475
+ if (isUuid(projectInput)) {
476
+ return projectInput;
477
+ }
478
+ const slugId = parseProjectSlugId(projectInput);
479
+ if (slugId) {
480
+ // `as Record<string, unknown>` — `@linear/sdk`'s typed ProjectFilter
481
+ // lags Linear's schema and doesn't yet expose `slugId`. The field is
482
+ // present on the server; the cast is here until the SDK ships the
483
+ // typing. Don't "clean up" without verifying the typed shape exists.
484
+ //
485
+ // `includeArchived: true` — URLs in the wild often point at archived
486
+ // projects (someone digs up an old link); silently 404-ing them is
487
+ // worse than resolving to an archived project, which the caller can
488
+ // inspect via the returned UUID. Name resolution stays active-only
489
+ // because names collide more readily than slug-ids do.
490
+ const bySlug = await this.client.projects({
491
+ filter: { slugId: { eq: slugId } },
492
+ first: 1,
493
+ includeArchived: true,
494
+ });
495
+ if (bySlug.nodes.length > 0) {
496
+ return bySlug.nodes[0].id;
497
+ }
498
+ // Slug-id form was syntactically valid but didn't match a project
499
+ // — fall through to name resolution would be misleading (the user
500
+ // clearly pasted a URL/slug, not a name). Throw the same shape of
501
+ // not-found error as the name path.
502
+ throw notFoundError("Project", projectInput);
461
503
  }
462
- const filter = { name: { eqIgnoreCase: projectNameOrId } };
504
+ const filter = { name: { eqIgnoreCase: projectInput } };
463
505
  const projectsConnection = await this.client.projects({
464
506
  filter,
465
507
  first: 1,
466
508
  });
467
509
  if (projectsConnection.nodes.length === 0) {
468
- throw notFoundError("Project", projectNameOrId);
510
+ throw notFoundError("Project", projectInput);
469
511
  }
470
512
  return projectsConnection.nodes[0].id;
471
513
  }
514
+ /**
515
+ * Normalize a user-supplied project input to a UUID when the input is
516
+ * a URL or slug-id form. Pass-through for UUIDs and plain names — the
517
+ * latter stays a name so downstream batch-resolve queries can fold the
518
+ * lookup into their single round-trip.
519
+ *
520
+ * Used by callers that route project resolution through a separate
521
+ * batch-resolve step (e.g. `GraphqlIssuesService.createIssue`) — they
522
+ * can pre-normalize URL/slug inputs to UUIDs so the batch query's
523
+ * `name eqIgnoreCase` filter doesn't have to learn the URL/slug shape.
524
+ */
525
+ async normalizeProjectInput(projectInput) {
526
+ if (isUuid(projectInput)) {
527
+ return projectInput;
528
+ }
529
+ if (parseProjectSlugId(projectInput)) {
530
+ return this.resolveProjectId(projectInput);
531
+ }
532
+ return projectInput;
533
+ }
472
534
  }
473
535
  export async function createLinearService(options) {
474
536
  const auth = await getActiveAuth(options);
@@ -88,7 +88,7 @@ function parseFencedCodeBlock(state, line) {
88
88
  attrs.language = language;
89
89
  }
90
90
  state.content.push({
91
- type: "codeBlock",
91
+ type: "code_block",
92
92
  ...(Object.keys(attrs).length > 0 ? { attrs } : {}),
93
93
  content: codeLines.length > 0
94
94
  ? [{ type: "text", text: codeLines.join("\n") }]
@@ -100,7 +100,7 @@ function parseHorizontalRule(state, line) {
100
100
  if (!HR_RE.test(line)) {
101
101
  return false;
102
102
  }
103
- state.content.push({ type: "horizontalRule" });
103
+ state.content.push({ type: "horizontal_rule" });
104
104
  state.i++;
105
105
  return true;
106
106
  }
@@ -128,12 +128,12 @@ function parseBulletList(state, line) {
128
128
  break;
129
129
  }
130
130
  items.push({
131
- type: "listItem",
131
+ type: "list_item",
132
132
  content: [{ type: "paragraph", content: parseInline(m[1]) }],
133
133
  });
134
134
  state.i++;
135
135
  }
136
- state.content.push({ type: "bulletList", content: items });
136
+ state.content.push({ type: "bullet_list", content: items });
137
137
  return true;
138
138
  }
139
139
  function parseOrderedList(state, line) {
@@ -147,12 +147,12 @@ function parseOrderedList(state, line) {
147
147
  break;
148
148
  }
149
149
  items.push({
150
- type: "listItem",
150
+ type: "list_item",
151
151
  content: [{ type: "paragraph", content: parseInline(m[1]) }],
152
152
  });
153
153
  state.i++;
154
154
  }
155
- state.content.push({ type: "orderedList", content: items });
155
+ state.content.push({ type: "ordered_list", content: items });
156
156
  return true;
157
157
  }
158
158
  function parseBlockquote(state) {
@@ -217,9 +217,9 @@ function parseTable(state, line) {
217
217
  const headerCells = splitTableRow(rowMatch[1]);
218
218
  const cols = headerCells.length;
219
219
  rows.push({
220
- type: "tableRow",
220
+ type: "table_row",
221
221
  content: headerCells.map((cell) => ({
222
- type: "tableHeader",
222
+ type: "table_header",
223
223
  content: [{ type: "paragraph", content: cellContent(cell) }],
224
224
  })),
225
225
  });
@@ -238,9 +238,9 @@ function parseTable(state, line) {
238
238
  }
239
239
  cells.length = cols;
240
240
  rows.push({
241
- type: "tableRow",
241
+ type: "table_row",
242
242
  content: cells.map((cell) => ({
243
- type: "tableCell",
243
+ type: "table_cell",
244
244
  content: [{ type: "paragraph", content: cellContent(cell) }],
245
245
  })),
246
246
  });
@@ -327,7 +327,7 @@ function findEarliestInlineMatch(text) {
327
327
  index: boldMatch.index ?? 0,
328
328
  length: boldMatch[0].length,
329
329
  innerText: boldMatch[1] ?? boldMatch[2],
330
- marks: [{ type: "bold" }],
330
+ marks: [{ type: "strong" }],
331
331
  });
332
332
  }
333
333
  const italicMatch = text.match(INLINE_ITALIC_RE);
@@ -336,7 +336,7 @@ function findEarliestInlineMatch(text) {
336
336
  index: italicMatch.index ?? 0,
337
337
  length: italicMatch[0].length,
338
338
  innerText: italicMatch[1] ?? italicMatch[2],
339
- marks: [{ type: "italic" }],
339
+ marks: [{ type: "em" }],
340
340
  });
341
341
  }
342
342
  if (candidates.length === 0) {
@@ -146,7 +146,7 @@ function injectMentions(doc, explicit, bare) {
146
146
  return {
147
147
  ...doc,
148
148
  content: doc.content.map((node) => {
149
- if (node.content && node.type !== "codeBlock") {
149
+ if (node.content && node.type !== "code_block") {
150
150
  const processed = injectMentions(node, explicit, bare);
151
151
  if (node.type === "paragraph" || node.type === "heading") {
152
152
  return {
@@ -1,13 +1,91 @@
1
- export type OutputFormat = "json" | "summary";
1
+ type OutputFormat = "json" | "summary";
2
2
  export declare function setRawMode(enabled: boolean): void;
3
3
  export declare function setJqFilter(filter: string | null): void;
4
4
  export declare function setFieldsFilter(fields: string[] | null): void;
5
5
  export declare function setOutputFormat(format: OutputFormat): void;
6
6
  /** @internal Test seam — consumers should not depend on the format state. */
7
7
  export declare function getOutputFormat(): OutputFormat;
8
+ /**
9
+ * Resource-specific extra metadata that may be added to a list response
10
+ * alongside the canonical `count` (e.g. `query` on search, `team` on
11
+ * filtered list). Excludes `count` at the type level — callers can't
12
+ * accidentally pass a string `count` and have it silently overridden;
13
+ * the only way to set `count` is via `data.length` inside `outputList`.
14
+ *
15
+ * Implementation note: `count?: never` together with `Record<string, unknown>`
16
+ * lets TypeScript accept any other key while disallowing the literal
17
+ * `count` key. The `never`-typed property is impossible to assign, which
18
+ * is what we want for the "no count here" contract.
19
+ */
20
+ export type ListExtraMeta = Record<string, unknown> & {
21
+ count?: never;
22
+ };
23
+ /**
24
+ * Metadata envelope for list responses. Always includes `count`; resources
25
+ * may add their own keys (e.g. `query` on search, `team` on filtered list).
26
+ *
27
+ * `meta.count` is the cardinality of `data[]` — downstream `jq` pipelines
28
+ * read it both for emptiness checks (`.meta.count == 0`) and for the
29
+ * actual magnitude (e.g. logging "found N issues"). A boolean isEmpty
30
+ * would lose the magnitude signal, so `count: number` stays.
31
+ */
32
+ export interface ListMeta extends Record<string, unknown> {
33
+ count: number;
34
+ }
35
+ /**
36
+ * Canonical JSON envelope for a list response. Pre-DEV-4068 T6, every
37
+ * call site built this object literal inline and `outputSuccess(data:
38
+ * unknown)` accepted it without type-checking. Use `outputList<T>` to
39
+ * route a list through the same emit path with a real per-element type
40
+ * (so e.g. `data: T[]` and `--fields` consumers stay in lock-step).
41
+ */
42
+ export interface CliListEnvelope<T> {
43
+ data: T[];
44
+ meta: ListMeta;
45
+ }
46
+ /**
47
+ * Typed wrapper around `outputSuccess` for list responses — builds the
48
+ * `{ data, meta: { count, ...extraMeta } }` envelope and emits it. The
49
+ * 81 existing inline `outputSuccess({ data, meta: { count, ... } })`
50
+ * call sites can migrate to this incrementally; both go through the
51
+ * same `outputSuccess` emit path so JSON / summary / --raw / --fields /
52
+ * --jq behavior is identical.
53
+ *
54
+ * @param data The array payload.
55
+ * @param extraMeta Optional resource-specific meta keys (e.g. `query`,
56
+ * `team`). The type excludes `count` — `count` is
57
+ * always computed from `data.length` to preserve the
58
+ * wire-contract invariant.
59
+ */
60
+ export declare function outputList<T>(data: T[], extraMeta?: ListExtraMeta): void;
61
+ /**
62
+ * Typed wrapper around `outputSuccess` for a single-resource response.
63
+ * Pure passthrough — the envelope contract for single resources is just
64
+ * the resource object itself (no `data`/`meta` wrapping). Same emit
65
+ * path as `outputSuccess` and `outputList`; this overload exists so
66
+ * call sites can document their intent at the type level.
67
+ *
68
+ * The conditional return type rejects array inputs at the type level —
69
+ * a caller that meant `outputList` and passed an array gets a compile
70
+ * error pointing them at the right helper. Runtime behavior on an array
71
+ * is unchanged (it'd still emit JSON), but the type catches the foot-gun.
72
+ */
73
+ export declare function outputSingle<T>(data: T extends readonly unknown[] ? "outputSingle does not accept arrays — use outputList(data) instead" : T): void;
8
74
  export declare function outputSuccess(data: unknown): void;
75
+ /**
76
+ * Emit a `results_truncated` warning when a list command's result count
77
+ * equals the requested `--limit`, signaling to the caller (typically an
78
+ * AI agent) that more results may exist beyond the page. Heuristic, not
79
+ * exact — `length === limit` has a false-positive when the workspace
80
+ * happens to hold exactly `limit` matching items. Accurate `hasNextPage`
81
+ * would require widening every list-service return type; deferred until
82
+ * a caller cares about the false-positive rate.
83
+ *
84
+ * Message includes a concrete next-step (suggested `--limit` is 2× the
85
+ * current limit) so the agent doesn't have to derive it.
86
+ */
87
+ export declare function warnIfTruncated(count: number, limit: number): void;
9
88
  export declare function outputWarning(message: string | string[]): void;
10
89
  export declare function resetWarnings(): void;
11
- /** @internal Test seam — call between tests that toggle `setOutputFormat`. */
12
- export declare function resetOutputFormat(): void;
13
90
  export declare function handleAsyncCommand<TArgs extends unknown[]>(asyncFn: (...args: TArgs) => Promise<void>): (...args: TArgs) => Promise<void>;
91
+ export {};
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { dispatch as dispatchSummary, inferKindFromPayload, } from "./formatters/summary.js";
3
3
  import { logger } from "./logger.js";
4
+ import { sanitizeForLog } from "./sanitize-for-log.js";
4
5
  const warningBuffer = [];
5
6
  let rawMode = false;
6
7
  let jqFilter = null;
@@ -50,6 +51,40 @@ function filterFields(obj, fields) {
50
51
  function emitSummary(payload, kind) {
51
52
  logger.info(dispatchSummary(kind, payload));
52
53
  }
54
+ /**
55
+ * Typed wrapper around `outputSuccess` for list responses — builds the
56
+ * `{ data, meta: { count, ...extraMeta } }` envelope and emits it. The
57
+ * 81 existing inline `outputSuccess({ data, meta: { count, ... } })`
58
+ * call sites can migrate to this incrementally; both go through the
59
+ * same `outputSuccess` emit path so JSON / summary / --raw / --fields /
60
+ * --jq behavior is identical.
61
+ *
62
+ * @param data The array payload.
63
+ * @param extraMeta Optional resource-specific meta keys (e.g. `query`,
64
+ * `team`). The type excludes `count` — `count` is
65
+ * always computed from `data.length` to preserve the
66
+ * wire-contract invariant.
67
+ */
68
+ export function outputList(data, extraMeta) {
69
+ const meta = { ...extraMeta, count: data.length };
70
+ const envelope = { data, meta };
71
+ outputSuccess(envelope);
72
+ }
73
+ /**
74
+ * Typed wrapper around `outputSuccess` for a single-resource response.
75
+ * Pure passthrough — the envelope contract for single resources is just
76
+ * the resource object itself (no `data`/`meta` wrapping). Same emit
77
+ * path as `outputSuccess` and `outputList`; this overload exists so
78
+ * call sites can document their intent at the type level.
79
+ *
80
+ * The conditional return type rejects array inputs at the type level —
81
+ * a caller that meant `outputList` and passed an array gets a compile
82
+ * error pointing them at the right helper. Runtime behavior on an array
83
+ * is unchanged (it'd still emit JSON), but the type catches the foot-gun.
84
+ */
85
+ export function outputSingle(data) {
86
+ outputSuccess(data);
87
+ }
53
88
  export function outputSuccess(data) {
54
89
  const warnings = drainWarnings();
55
90
  let output;
@@ -120,6 +155,25 @@ export function outputSuccess(data) {
120
155
  logger.info(JSON.stringify(output, null, 2));
121
156
  }
122
157
  }
158
+ /**
159
+ * Emit a `results_truncated` warning when a list command's result count
160
+ * equals the requested `--limit`, signaling to the caller (typically an
161
+ * AI agent) that more results may exist beyond the page. Heuristic, not
162
+ * exact — `length === limit` has a false-positive when the workspace
163
+ * happens to hold exactly `limit` matching items. Accurate `hasNextPage`
164
+ * would require widening every list-service return type; deferred until
165
+ * a caller cares about the false-positive rate.
166
+ *
167
+ * Message includes a concrete next-step (suggested `--limit` is 2× the
168
+ * current limit) so the agent doesn't have to derive it.
169
+ */
170
+ export function warnIfTruncated(count, limit) {
171
+ if (count !== limit) {
172
+ return;
173
+ }
174
+ outputWarning(`results_truncated: returned ${count} matching --limit ${limit}; more results may exist. ` +
175
+ `Re-run with --limit ${limit * 2} (or narrow via filters like --name, --state, --team) to verify.`);
176
+ }
123
177
  export function outputWarning(message) {
124
178
  const messages = Array.isArray(message) ? message : [message];
125
179
  for (const msg of messages) {
@@ -136,17 +190,18 @@ function drainWarnings() {
136
190
  export function resetWarnings() {
137
191
  warningBuffer.length = 0;
138
192
  }
139
- /** @internal Test seam — call between tests that toggle `setOutputFormat`. */
140
- export function resetOutputFormat() {
141
- outputFormat = "json";
142
- }
143
193
  function outputError(error) {
144
- const payload = JSON.stringify({ error: error.message }, null, 2);
194
+ // Run the message and stack through sanitizeForLog so a future SDK
195
+ // upgrade (or proxy/MITM error body) that embeds `lin_api_…` /
196
+ // `lin_oauth_…` / `Bearer <payload>` in error text can't leak a token
197
+ // into stdout, shell history, or CI logs. The wizard already sanitizes
198
+ // its own log paths; this is the central error path on every command.
199
+ const payload = JSON.stringify({ error: sanitizeForLog(error.message) }, null, 2);
145
200
  // Write to stdout (same channel as success) so machine callers always
146
201
  // receive exactly one parseable JSON object regardless of stream capture.
147
202
  logger.info(payload);
148
203
  if (process.env.EL_LINEAR_DEBUG ?? process.env.LINCTL_DEBUG) {
149
- logger.error(error.stack ?? "");
204
+ logger.error(sanitizeForLog(error.stack ?? ""));
150
205
  }
151
206
  process.exit(1);
152
207
  }
@@ -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;