@enrichlayer/el-linear 1.17.0 → 1.18.1
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 +13 -0
- package/dist/commands/comments.js +17 -1
- package/dist/commands/issues/branch.d.ts +48 -1
- package/dist/commands/issues/branch.js +81 -1
- package/dist/commands/issues.js +72 -3
- package/dist/main.js +18 -0
- package/dist/queries/comments-types.d.ts +5 -0
- package/dist/queries/comments.d.ts +1 -0
- package/dist/queries/comments.js +13 -0
- package/dist/sentry.d.ts +78 -0
- package/dist/sentry.js +150 -0
- package/dist/utils/graphql-issues-service.js +13 -3
- package/dist/utils/network-preference.d.ts +31 -0
- package/dist/utils/network-preference.js +37 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -247,6 +247,19 @@ A full reference with every key documented lives in [config.example.json](./conf
|
|
|
247
247
|
UUIDs come from the Linear UI (URL bars, settings pages) or via el-linear
|
|
248
248
|
itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
249
249
|
|
|
250
|
+
### Networking (IPv4 preference)
|
|
251
|
+
|
|
252
|
+
el-linear talks only to `api.linear.app` (Cloudflare, dual-stack). On a network
|
|
253
|
+
whose IPv6 route is broken or blackholed, Node's defaults (DNS result order
|
|
254
|
+
`verbatim`, often IPv6-first, plus Happy Eyeballs) can make every call stall
|
|
255
|
+
until it times out — surfacing as `GraphQL request failed: fetch failed`. To
|
|
256
|
+
avoid that, el-linear prefers IPv4 by default (`ipv4first` DNS ordering with
|
|
257
|
+
`autoSelectFamily` disabled).
|
|
258
|
+
|
|
259
|
+
If you're on a **pure IPv6-only** network (no IPv4 route at all), set
|
|
260
|
+
`EL_LINEAR_NETWORK_VERBATIM=1` to restore Node's native verbatim /
|
|
261
|
+
Happy-Eyeballs behavior.
|
|
262
|
+
|
|
250
263
|
### Workspace URL key
|
|
251
264
|
|
|
252
265
|
`refs wrap` and the auto-link paths build canonical issue URLs like
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
|
-
import { CREATE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
|
|
3
|
+
import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
|
|
4
4
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
5
5
|
import { applyFooter } from "../utils/footer.js";
|
|
6
6
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
@@ -260,6 +260,15 @@ async function handleListComments(issueId, options, command) {
|
|
|
260
260
|
},
|
|
261
261
|
});
|
|
262
262
|
}
|
|
263
|
+
async function handleDeleteComment(commentId, _options, command) {
|
|
264
|
+
const rootOpts = getRootOpts(command);
|
|
265
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
266
|
+
const result = await graphQLService.rawRequest(DELETE_COMMENT_MUTATION, { id: commentId });
|
|
267
|
+
if (!result.commentDelete.success) {
|
|
268
|
+
throw new Error(`Failed to delete comment "${commentId}"`);
|
|
269
|
+
}
|
|
270
|
+
outputSuccess({ id: commentId, deleted: true });
|
|
271
|
+
}
|
|
263
272
|
export function setupCommentsCommands(program) {
|
|
264
273
|
const comments = program
|
|
265
274
|
.command("comments")
|
|
@@ -291,4 +300,11 @@ export function setupCommentsCommands(program) {
|
|
|
291
300
|
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
|
|
292
301
|
.option("-l, --limit <number>", "limit results", "25")
|
|
293
302
|
.action(handleAsyncCommand(handleListComments));
|
|
303
|
+
comments
|
|
304
|
+
.command("delete <commentId>")
|
|
305
|
+
.alias("remove")
|
|
306
|
+
.alias("rm")
|
|
307
|
+
.description("Delete a comment by its id.")
|
|
308
|
+
.addHelpText("after", "\nTakes a comment id (the `id` field from `comments list` / `comments create`), not an issue identifier.")
|
|
309
|
+
.action(handleAsyncCommand(handleDeleteComment));
|
|
294
310
|
}
|
|
@@ -13,5 +13,52 @@ export declare function toBranchName(linearBranchName: string, prefix?: string):
|
|
|
13
13
|
/**
|
|
14
14
|
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
15
15
|
* Throws if the branch already exists.
|
|
16
|
+
*
|
|
17
|
+
* Returns `true` if the branch was actually checked out, `false` if the
|
|
18
|
+
* checkout was skipped (not in a git repo). Callers gate follow-on work
|
|
19
|
+
* (e.g. writing the `branch.<branch>.linearIssue` marker) on the result so
|
|
20
|
+
* they don't emit a second, contradictory warning about a branch that was
|
|
21
|
+
* never created.
|
|
22
|
+
*/
|
|
23
|
+
export declare function gitCheckoutBranch(branchName: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Extract the canonical (uppercase) Linear identifier embedded in a branch
|
|
26
|
+
* name — `feature/DEV-4293-slug` → `DEV-4293`, `dev-4293-slug` → `DEV-4293`.
|
|
27
|
+
* Returns null when the branch carries no identifier token.
|
|
28
|
+
*/
|
|
29
|
+
export declare function extractIssueIdentifierFromBranch(branch: string): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Record the Linear issue a branch implements as durable git metadata:
|
|
32
|
+
* `git config branch.<branch>.linearIssue <identifier>`. This is the signal
|
|
33
|
+
* DEV-4241's PreToolUse hook reads to confirm a workflow was established
|
|
34
|
+
* before allowing tracked-file edits — far more robust than parsing the
|
|
35
|
+
* branch name shape (which legitimately varies: `dev-4083-slug`,
|
|
36
|
+
* `fe-630-slug`, `emw-280-slug`), and it stays fast + offline (a local
|
|
37
|
+
* git config read, no API call).
|
|
38
|
+
*
|
|
39
|
+
* Best-effort by design: a failure to write the marker must never abort the
|
|
40
|
+
* issue-creation flow that triggered it. Callers in a known-good repo
|
|
41
|
+
* context wrap this and warn on failure; the standalone `mark-branch`
|
|
42
|
+
* command does its own repo/branch validation first so it can surface a
|
|
43
|
+
* clear error instead.
|
|
44
|
+
*
|
|
45
|
+
* No `--` ref-terminator (unlike the checkout/rename calls above): `git
|
|
46
|
+
* config <key> <value>` takes exactly two positional operands, the key is
|
|
47
|
+
* always our `branch.`-prefixed literal, and a value beginning with `-` is
|
|
48
|
+
* stored verbatim rather than parsed as a flag — so there's no
|
|
49
|
+
* flag-injection surface to defend here.
|
|
50
|
+
*/
|
|
51
|
+
export declare function setBranchLinearIssue(branch: string, identifier: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Read back the `branch.<branch>.linearIssue` marker, or null when unset.
|
|
54
|
+
*/
|
|
55
|
+
export declare function getBranchLinearIssue(branch: string): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Current branch's short name, or null when detached / not in a repo.
|
|
58
|
+
*
|
|
59
|
+
* Uses `symbolic-ref` rather than `rev-parse --abbrev-ref`: the former
|
|
60
|
+
* resolves the name even on an unborn branch (fresh repo, no commits yet)
|
|
61
|
+
* and still fails — yielding null — on a detached HEAD, which is exactly
|
|
62
|
+
* the "no named branch" signal callers want.
|
|
16
63
|
*/
|
|
17
|
-
export declare function
|
|
64
|
+
export declare function currentGitBranch(): string | null;
|
|
@@ -25,6 +25,12 @@ export function toBranchName(linearBranchName, prefix = "feature/") {
|
|
|
25
25
|
/**
|
|
26
26
|
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
27
27
|
* Throws if the branch already exists.
|
|
28
|
+
*
|
|
29
|
+
* Returns `true` if the branch was actually checked out, `false` if the
|
|
30
|
+
* checkout was skipped (not in a git repo). Callers gate follow-on work
|
|
31
|
+
* (e.g. writing the `branch.<branch>.linearIssue` marker) on the result so
|
|
32
|
+
* they don't emit a second, contradictory warning about a branch that was
|
|
33
|
+
* never created.
|
|
28
34
|
*/
|
|
29
35
|
export function gitCheckoutBranch(branchName) {
|
|
30
36
|
try {
|
|
@@ -34,7 +40,7 @@ export function gitCheckoutBranch(branchName) {
|
|
|
34
40
|
}
|
|
35
41
|
catch {
|
|
36
42
|
outputWarning("Not inside a git repository — skipping branch checkout.");
|
|
37
|
-
return;
|
|
43
|
+
return false;
|
|
38
44
|
}
|
|
39
45
|
// `--` separates the new-branch name from any ref. Without it, a
|
|
40
46
|
// branch name starting with `-` (server bug, malicious team member
|
|
@@ -45,4 +51,78 @@ export function gitCheckoutBranch(branchName) {
|
|
|
45
51
|
// prefix callers would lose the defense without this terminator.
|
|
46
52
|
// Defense-in-depth (DEV-4064).
|
|
47
53
|
execFileSync("git", ["checkout", "-b", branchName, "--"], { stdio: "pipe" });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
// First Linear-style identifier token in a branch name, in any position, so
|
|
57
|
+
// it survives a configurable prefix (`feature/DEV-123-slug`) and the bare
|
|
58
|
+
// Linear shape (`dev-123-slug`). Anchored on a non-word boundary so it
|
|
59
|
+
// doesn't match mid-token.
|
|
60
|
+
const BRANCH_IDENTIFIER_REGEX = /(?:^|[^A-Za-z0-9])([A-Za-z]+-\d+)(?![A-Za-z0-9])/;
|
|
61
|
+
/**
|
|
62
|
+
* Extract the canonical (uppercase) Linear identifier embedded in a branch
|
|
63
|
+
* name — `feature/DEV-4293-slug` → `DEV-4293`, `dev-4293-slug` → `DEV-4293`.
|
|
64
|
+
* Returns null when the branch carries no identifier token.
|
|
65
|
+
*/
|
|
66
|
+
export function extractIssueIdentifierFromBranch(branch) {
|
|
67
|
+
const match = branch.match(BRANCH_IDENTIFIER_REGEX);
|
|
68
|
+
return match ? match[1].toUpperCase() : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Record the Linear issue a branch implements as durable git metadata:
|
|
72
|
+
* `git config branch.<branch>.linearIssue <identifier>`. This is the signal
|
|
73
|
+
* DEV-4241's PreToolUse hook reads to confirm a workflow was established
|
|
74
|
+
* before allowing tracked-file edits — far more robust than parsing the
|
|
75
|
+
* branch name shape (which legitimately varies: `dev-4083-slug`,
|
|
76
|
+
* `fe-630-slug`, `emw-280-slug`), and it stays fast + offline (a local
|
|
77
|
+
* git config read, no API call).
|
|
78
|
+
*
|
|
79
|
+
* Best-effort by design: a failure to write the marker must never abort the
|
|
80
|
+
* issue-creation flow that triggered it. Callers in a known-good repo
|
|
81
|
+
* context wrap this and warn on failure; the standalone `mark-branch`
|
|
82
|
+
* command does its own repo/branch validation first so it can surface a
|
|
83
|
+
* clear error instead.
|
|
84
|
+
*
|
|
85
|
+
* No `--` ref-terminator (unlike the checkout/rename calls above): `git
|
|
86
|
+
* config <key> <value>` takes exactly two positional operands, the key is
|
|
87
|
+
* always our `branch.`-prefixed literal, and a value beginning with `-` is
|
|
88
|
+
* stored verbatim rather than parsed as a flag — so there's no
|
|
89
|
+
* flag-injection surface to defend here.
|
|
90
|
+
*/
|
|
91
|
+
export function setBranchLinearIssue(branch, identifier) {
|
|
92
|
+
execFileSync("git", ["config", `branch.${branch}.linearIssue`, identifier], {
|
|
93
|
+
stdio: "pipe",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Read back the `branch.<branch>.linearIssue` marker, or null when unset.
|
|
98
|
+
*/
|
|
99
|
+
export function getBranchLinearIssue(branch) {
|
|
100
|
+
try {
|
|
101
|
+
return execFileSync("git", ["config", "--get", `branch.${branch}.linearIssue`], { stdio: "pipe" })
|
|
102
|
+
.toString()
|
|
103
|
+
.trim();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Current branch's short name, or null when detached / not in a repo.
|
|
111
|
+
*
|
|
112
|
+
* Uses `symbolic-ref` rather than `rev-parse --abbrev-ref`: the former
|
|
113
|
+
* resolves the name even on an unborn branch (fresh repo, no commits yet)
|
|
114
|
+
* and still fails — yielding null — on a detached HEAD, which is exactly
|
|
115
|
+
* the "no named branch" signal callers want.
|
|
116
|
+
*/
|
|
117
|
+
export function currentGitBranch() {
|
|
118
|
+
try {
|
|
119
|
+
return execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
|
|
120
|
+
stdio: "pipe",
|
|
121
|
+
})
|
|
122
|
+
.toString()
|
|
123
|
+
.trim();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
48
128
|
}
|
package/dist/commands/issues.js
CHANGED
|
@@ -17,7 +17,7 @@ import { handleAsyncCommand, outputSuccess, outputWarning, warnIfTruncated, } fr
|
|
|
17
17
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
18
18
|
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
19
19
|
import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
20
|
-
import { gitCheckoutBranch, toBranchName } from "./issues/branch.js";
|
|
20
|
+
import { currentGitBranch, extractIssueIdentifierFromBranch, getBranchLinearIssue, gitCheckoutBranch, setBranchLinearIssue, toBranchName, } from "./issues/branch.js";
|
|
21
21
|
import { maybeAutoLink, prepareAutoLinkedDescription, readDescriptionFile, resolveDescription, } from "./issues/description.js";
|
|
22
22
|
import { handleLinkReferencesIssue } from "./issues/link-references.js";
|
|
23
23
|
import { buildIncomingRelationEntries, buildOutgoingRelationEntries, createRelations, } from "./issues/relations.js";
|
|
@@ -472,8 +472,25 @@ async function handleCreateIssue(title, options, command) {
|
|
|
472
472
|
}
|
|
473
473
|
let branch;
|
|
474
474
|
if (options.checkout && result.branchName) {
|
|
475
|
-
|
|
476
|
-
|
|
475
|
+
const branchName = toBranchName(result.branchName);
|
|
476
|
+
// Only treat the branch as created (and surface it in the output) when
|
|
477
|
+
// the checkout actually happened — outside a git repo gitCheckoutBranch
|
|
478
|
+
// warns and skips, and claiming a branch / a missing marker afterward
|
|
479
|
+
// would be a contradictory second warning (DEV-4293 cycle-1).
|
|
480
|
+
if (gitCheckoutBranch(branchName)) {
|
|
481
|
+
branch = branchName;
|
|
482
|
+
// Record the issue→branch link as git metadata (DEV-4293) so the
|
|
483
|
+
// DEV-4241 hook can confirm a workflow was established without parsing
|
|
484
|
+
// the branch name. Best-effort: never let a marker-write failure abort
|
|
485
|
+
// a successful issue creation.
|
|
486
|
+
try {
|
|
487
|
+
setBranchLinearIssue(branch, result.identifier);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
outputWarning(`Created branch ${branch} but could not record its Linear issue marker. ` +
|
|
491
|
+
`Run 'el-linear issues mark-branch ${result.identifier}' from the branch to set it.`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
477
494
|
}
|
|
478
495
|
const output = {
|
|
479
496
|
...result,
|
|
@@ -794,6 +811,14 @@ async function handleRetrolink(options, command) {
|
|
|
794
811
|
execFileSync("git", ["branch", "-m", "--", oldBranch, newBranch], {
|
|
795
812
|
stdio: "pipe",
|
|
796
813
|
});
|
|
814
|
+
// Record the issue→branch link (DEV-4293), same as `create --checkout`.
|
|
815
|
+
try {
|
|
816
|
+
setBranchLinearIssue(newBranch, result.identifier);
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
outputWarning(`Renamed branch to ${newBranch} but could not record its Linear issue marker. ` +
|
|
820
|
+
`Run 'el-linear issues mark-branch ${result.identifier}' from the branch to set it.`);
|
|
821
|
+
}
|
|
797
822
|
}
|
|
798
823
|
outputSuccess({
|
|
799
824
|
...result,
|
|
@@ -801,6 +826,43 @@ async function handleRetrolink(options, command) {
|
|
|
801
826
|
...(newBranch ? { branch: newBranch } : {}),
|
|
802
827
|
});
|
|
803
828
|
}
|
|
829
|
+
async function handleMarkBranch(issueId, _options, _command) {
|
|
830
|
+
const branch = currentGitBranch();
|
|
831
|
+
if (!branch) {
|
|
832
|
+
throw new Error("Not on a named git branch (detached HEAD or not in a repository).");
|
|
833
|
+
}
|
|
834
|
+
if (branch === "main" || branch === "master") {
|
|
835
|
+
throw new Error(`Refusing to mark the '${branch}' branch.`);
|
|
836
|
+
}
|
|
837
|
+
// Resolve the identifier to record. An explicit arg wins; otherwise infer
|
|
838
|
+
// it from the branch name. Both paths are offline — the marker's whole
|
|
839
|
+
// value is letting the hook verify a workflow without a network call, so
|
|
840
|
+
// we don't round-trip to the API just to normalize an identifier.
|
|
841
|
+
let identifier;
|
|
842
|
+
if (issueId) {
|
|
843
|
+
// Accepts a bare identifier (DEV-123), a branch-style token
|
|
844
|
+
// (dev-123-slug), or a Linear URL — extract normalizes all three.
|
|
845
|
+
identifier = extractIssueIdentifierFromBranch(issueId);
|
|
846
|
+
if (!identifier) {
|
|
847
|
+
throw new Error(`'${issueId}' is not a Linear issue identifier (expected a shape like DEV-123).`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
identifier = extractIssueIdentifierFromBranch(branch);
|
|
852
|
+
if (!identifier) {
|
|
853
|
+
throw new Error(`Branch '${branch}' has no Linear identifier to infer. ` +
|
|
854
|
+
`Pass one explicitly: 'el-linear issues mark-branch DEV-123'.`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const previous = getBranchLinearIssue(branch);
|
|
858
|
+
setBranchLinearIssue(branch, identifier);
|
|
859
|
+
outputSuccess({
|
|
860
|
+
branch,
|
|
861
|
+
linearIssue: identifier,
|
|
862
|
+
...(previous && previous !== identifier ? { previous } : {}),
|
|
863
|
+
marked: true,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
804
866
|
export function setupIssuesCommands(program) {
|
|
805
867
|
const issues = program
|
|
806
868
|
.command("issues")
|
|
@@ -1001,4 +1063,11 @@ export function setupIssuesCommands(program) {
|
|
|
1001
1063
|
.option("--title <title>", "override auto-generated title (default: first commit message)")
|
|
1002
1064
|
.option("--base <branch>", "base branch for log comparison", "main")
|
|
1003
1065
|
.action(handleAsyncCommand(handleRetrolink));
|
|
1066
|
+
issues
|
|
1067
|
+
.command("mark-branch [issueId]")
|
|
1068
|
+
.description("Record the current branch's Linear issue as git metadata (branch.<branch>.linearIssue).")
|
|
1069
|
+
.addHelpText("after", "\nManual recovery for branches not created via 'issues create --checkout' / 'retrolink'.\n" +
|
|
1070
|
+
"With no argument, infers the identifier from the branch name (e.g. dev-4293-slug -> DEV-4293).\n" +
|
|
1071
|
+
"Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.")
|
|
1072
|
+
.action(handleAsyncCommand(handleMarkBranch));
|
|
1004
1073
|
}
|
package/dist/main.js
CHANGED
|
@@ -28,9 +28,21 @@ import { setupTeamsCommands } from "./commands/teams.js";
|
|
|
28
28
|
import { setupTemplatesCommands } from "./commands/templates.js";
|
|
29
29
|
import { setupUsersCommands } from "./commands/users.js";
|
|
30
30
|
import { setActiveProfileForSession } from "./config/paths.js";
|
|
31
|
+
import { initCliSentry } from "./sentry.js";
|
|
32
|
+
import { logger } from "./utils/logger.js";
|
|
33
|
+
import { applyIpv4Preference } from "./utils/network-preference.js";
|
|
31
34
|
import { setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, } from "./utils/output.js";
|
|
32
35
|
import { outputUsageInfo } from "./utils/usage.js";
|
|
33
36
|
import { splitList } from "./utils/validators.js";
|
|
37
|
+
// Prefer IPv4 for outbound API calls before any network I/O (Sentry init or a
|
|
38
|
+
// command) runs — works around broken-IPv6 networks stalling Node's fetch.
|
|
39
|
+
// Opt out with EL_LINEAR_NETWORK_VERBATIM=1. See DEV-4415 / network-preference.ts.
|
|
40
|
+
const ipv4Preferred = applyIpv4Preference();
|
|
41
|
+
if (process.env.EL_LINEAR_DEBUG ?? process.env.LINCTL_DEBUG) {
|
|
42
|
+
logger.error(ipv4Preferred
|
|
43
|
+
? "[el-linear] network: preferring IPv4 (dns=ipv4first, autoSelectFamily=off)"
|
|
44
|
+
: "[el-linear] network: verbatim mode (EL_LINEAR_NETWORK_VERBATIM=1)");
|
|
45
|
+
}
|
|
34
46
|
// Read the version from package.json at startup so `--version` can never
|
|
35
47
|
// drift from the published release (pre-fix: a stale 1.8.1 literal lived
|
|
36
48
|
// here while package.json was at 1.10.0). `dist/main.js` lives one
|
|
@@ -38,6 +50,12 @@ import { splitList } from "./utils/validators.js";
|
|
|
38
50
|
// correctly in both `pnpm dev` (tsx) and `node dist/main.js` invocations.
|
|
39
51
|
const __dirname_main = dirname(fileURLToPath(import.meta.url));
|
|
40
52
|
const packageJson = JSON.parse(readFileSync(join(__dirname_main, "..", "package.json"), "utf-8"));
|
|
53
|
+
// Opt-in error reporting (DEV-4349): no-ops unless the namespaced SENTRY_DSN_CLI
|
|
54
|
+
// env var is set AND the optional `@sentry/node` dependency is installed.
|
|
55
|
+
// Fire-and-forget — the dynamic SDK import must never delay or break the CLI;
|
|
56
|
+
// the global handlers it installs catch async failures once it resolves (a tick
|
|
57
|
+
// later).
|
|
58
|
+
void initCliSentry("el-linear", { version: packageJson.version });
|
|
41
59
|
program
|
|
42
60
|
.name("el-linear")
|
|
43
61
|
.description("A pragmatic CLI for Linear.app — deterministic resolution, structured validation, GraphQL escape hatch.")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare const LIST_COMMENTS_QUERY = "\n query ListComments($issueId: String!, $first: Int) {\n issue(id: $issueId) {\n id\n identifier\n comments(first: $first, orderBy: createdAt) {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n }\n }\n }\n }\n";
|
|
2
2
|
export declare const CREATE_COMMENT_MUTATION = "\n mutation CreateComment($input: CommentCreateInput!) {\n commentCreate(input: $input) {\n success\n comment {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n }\n }\n }\n";
|
|
3
3
|
export declare const UPDATE_COMMENT_MUTATION = "\n mutation UpdateComment($id: String!, $input: CommentUpdateInput!) {\n commentUpdate(id: $id, input: $input) {\n success\n comment {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n issue {\n id\n identifier\n }\n }\n }\n }\n";
|
|
4
|
+
export declare const DELETE_COMMENT_MUTATION = "\n mutation DeleteComment($id: String!) {\n commentDelete(id: $id) {\n success\n }\n }\n";
|
package/dist/queries/comments.js
CHANGED
|
@@ -62,3 +62,16 @@ export const UPDATE_COMMENT_MUTATION = `
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
`;
|
|
65
|
+
// commentDelete returns a DeletePayload (`{ success }`) — no `comment` node,
|
|
66
|
+
// since the entity is gone. The @linear/sdk exposes this as
|
|
67
|
+
// `client.deleteComment(id)`, but the standalone comment commands all go
|
|
68
|
+
// through raw mutations on `graphQLService` (create/update need `bodyData`,
|
|
69
|
+
// which the SDK's typed input wraps awkwardly), so delete stays on the same
|
|
70
|
+
// raw path for a consistent, single-mock test surface.
|
|
71
|
+
export const DELETE_COMMENT_MUTATION = `
|
|
72
|
+
mutation DeleteComment($id: String!) {
|
|
73
|
+
commentDelete(id: $id) {
|
|
74
|
+
success
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`;
|
package/dist/sentry.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional, opt-in Sentry error reporting for el-linear (DEV-4349, sub of DEV-4328).
|
|
3
|
+
*
|
|
4
|
+
* el-linear ships open-source, so Sentry is NEVER required:
|
|
5
|
+
* - No dependency on the private `@enrichlayer/sentry` package — this is a
|
|
6
|
+
* self-contained copy of its scrub + init (acceptable duplication: the repo
|
|
7
|
+
* boundary makes sharing the tools-repo util impossible).
|
|
8
|
+
* - `@sentry/node` is an OPTIONAL dependency, loaded via a dynamic
|
|
9
|
+
* `import("@sentry/node")` ONLY when a DSN resolves. `npm i @enrichlayer/el-linear`
|
|
10
|
+
* never forces Sentry, and a missing/uninstalled SDK is a clean no-op.
|
|
11
|
+
* - The DSN comes from the environment only (`SENTRY_DSN_CLI`) — no Vault (that
|
|
12
|
+
* is internal infra OSS users do not have), and NOT the conventional
|
|
13
|
+
* `SENTRY_DSN` (which would collide with an OSS user's own app). Default OFF;
|
|
14
|
+
* only active when we set our namespaced env var in our own environment.
|
|
15
|
+
*
|
|
16
|
+
* One line at the top of `main.ts`:
|
|
17
|
+
*
|
|
18
|
+
* import { initCliSentry } from "./sentry.js";
|
|
19
|
+
* void initCliSentry("el-linear", { version });
|
|
20
|
+
* // ... program.parse()
|
|
21
|
+
*
|
|
22
|
+
* Set `EL_SENTRY_DISABLED=1` to force-disable even when a DSN is present.
|
|
23
|
+
*
|
|
24
|
+
* Safety: a mandatory `beforeSend` scrub redacts secret-shaped values (tokens,
|
|
25
|
+
* keys, auth headers). CLI argv/env routinely carry credentials (the Linear API
|
|
26
|
+
* token, GitHub PATs), and an unscrubbed report would leak them into Sentry.
|
|
27
|
+
*/
|
|
28
|
+
/** A Sentry event is deeply dynamic; we walk it structurally. */
|
|
29
|
+
type Json = unknown;
|
|
30
|
+
export declare const REDACTED = "[redacted]";
|
|
31
|
+
/** Redact credential-shaped substrings from a string. Pure. */
|
|
32
|
+
export declare function scrubString(input: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Recursively scrub a JSON-ish value: redact whole values under secret-named
|
|
35
|
+
* keys, scrub credential-shaped substrings everywhere else, and cap depth so a
|
|
36
|
+
* cyclic / huge event can't hang the scrubber. Pure.
|
|
37
|
+
*/
|
|
38
|
+
export declare function scrubValue(value: Json, depth?: number): Json;
|
|
39
|
+
/**
|
|
40
|
+
* Scrub a Sentry event (message, exceptions, breadcrumbs, extra, request — which
|
|
41
|
+
* carries headers + env — and contexts) by walking it structurally. Pure.
|
|
42
|
+
*/
|
|
43
|
+
export declare function scrubEvent(event: Json): Json;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the CLI Sentry DSN from the environment. Returns null when reporting
|
|
46
|
+
* is disabled or no DSN is configured (→ init no-ops). No Vault: OSS users do
|
|
47
|
+
* not have it, so this path is intentionally env-only.
|
|
48
|
+
*
|
|
49
|
+
* Only the namespaced `SENTRY_DSN_CLI` is read — NOT the conventional
|
|
50
|
+
* `SENTRY_DSN`. el-linear ships open-source, and `SENTRY_DSN` is the var the
|
|
51
|
+
* `@sentry/node` SDK reads by default, so an OSS user running their own
|
|
52
|
+
* Sentry-instrumented app very likely has it set; falling back to it would make
|
|
53
|
+
* el-linear silently report into *their* project. Requiring our explicit,
|
|
54
|
+
* namespaced var keeps activation unambiguous and collision-free (DEV-4349
|
|
55
|
+
* cycle-1 review). The internal tools build uses `SENTRY_DSN_CLI` too, so we
|
|
56
|
+
* lose nothing.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveDsn(): string | null;
|
|
59
|
+
export interface InitCliSentryOptions {
|
|
60
|
+
/** Override the resolved DSN (mainly for tests). */
|
|
61
|
+
dsn?: string | null;
|
|
62
|
+
/** CLI version for the Sentry `release` (defaults to "0.0.0"). */
|
|
63
|
+
version?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Initialize Sentry for el-linear. Async because `@sentry/node` is loaded via a
|
|
67
|
+
* dynamic import only when a DSN resolves — so a CLI run without a DSN (the
|
|
68
|
+
* default for OSS users) never even loads the SDK. Returns true when reporting
|
|
69
|
+
* is active, false when it no-ops (disabled / no DSN / SDK not installed).
|
|
70
|
+
*
|
|
71
|
+
* When active, installs global uncaughtException + unhandledRejection handlers
|
|
72
|
+
* that capture → flush → exit(1). Because the SDK loads via a dynamic import
|
|
73
|
+
* (resolving a tick after the caller's fire-and-forget `void`), a synchronous
|
|
74
|
+
* throw during the very first tick of CLI startup — before the handlers install
|
|
75
|
+
* — is out of scope; this is best-effort reporting, not a crash guarantee.
|
|
76
|
+
*/
|
|
77
|
+
export declare function initCliSentry(cliName: string, opts?: InitCliSentryOptions): Promise<boolean>;
|
|
78
|
+
export {};
|
package/dist/sentry.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional, opt-in Sentry error reporting for el-linear (DEV-4349, sub of DEV-4328).
|
|
3
|
+
*
|
|
4
|
+
* el-linear ships open-source, so Sentry is NEVER required:
|
|
5
|
+
* - No dependency on the private `@enrichlayer/sentry` package — this is a
|
|
6
|
+
* self-contained copy of its scrub + init (acceptable duplication: the repo
|
|
7
|
+
* boundary makes sharing the tools-repo util impossible).
|
|
8
|
+
* - `@sentry/node` is an OPTIONAL dependency, loaded via a dynamic
|
|
9
|
+
* `import("@sentry/node")` ONLY when a DSN resolves. `npm i @enrichlayer/el-linear`
|
|
10
|
+
* never forces Sentry, and a missing/uninstalled SDK is a clean no-op.
|
|
11
|
+
* - The DSN comes from the environment only (`SENTRY_DSN_CLI`) — no Vault (that
|
|
12
|
+
* is internal infra OSS users do not have), and NOT the conventional
|
|
13
|
+
* `SENTRY_DSN` (which would collide with an OSS user's own app). Default OFF;
|
|
14
|
+
* only active when we set our namespaced env var in our own environment.
|
|
15
|
+
*
|
|
16
|
+
* One line at the top of `main.ts`:
|
|
17
|
+
*
|
|
18
|
+
* import { initCliSentry } from "./sentry.js";
|
|
19
|
+
* void initCliSentry("el-linear", { version });
|
|
20
|
+
* // ... program.parse()
|
|
21
|
+
*
|
|
22
|
+
* Set `EL_SENTRY_DISABLED=1` to force-disable even when a DSN is present.
|
|
23
|
+
*
|
|
24
|
+
* Safety: a mandatory `beforeSend` scrub redacts secret-shaped values (tokens,
|
|
25
|
+
* keys, auth headers). CLI argv/env routinely carry credentials (the Linear API
|
|
26
|
+
* token, GitHub PATs), and an unscrubbed report would leak them into Sentry.
|
|
27
|
+
*/
|
|
28
|
+
/** Key names whose values are always redacted, regardless of content. */
|
|
29
|
+
const SECRET_KEY_RE = /(token|secret|passwd|password|api[_-]?key|apikey|bearer|authorization|auth|dsn|cookie|session|credential|private[_-]?key)/i;
|
|
30
|
+
/** Value patterns that look like a credential even under an innocent key. */
|
|
31
|
+
const SECRET_VALUE_RES = [
|
|
32
|
+
/glpat-[A-Za-z0-9_-]{10,}/g, // GitLab PAT
|
|
33
|
+
/gh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub classic token
|
|
34
|
+
/github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT (now the default)
|
|
35
|
+
/xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack bot/user token
|
|
36
|
+
/xapp-[A-Za-z0-9-]{10,}/g, // Slack app-level token
|
|
37
|
+
/lin_api_[A-Za-z0-9]{20,}/g, // Linear API key
|
|
38
|
+
/sk-[A-Za-z0-9]{16,}/g, // OpenAI-style key
|
|
39
|
+
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, // JWT
|
|
40
|
+
/\b[Bb]earer\s+[A-Za-z0-9._-]{10,}/g, // bearer header
|
|
41
|
+
/https?:\/\/[^:@/\s]+:[^@/\s]+@/g, // creds in a URL (user:pass@host)
|
|
42
|
+
];
|
|
43
|
+
export const REDACTED = "[redacted]";
|
|
44
|
+
const MAX_DEPTH = 8;
|
|
45
|
+
/** Redact credential-shaped substrings from a string. Pure. */
|
|
46
|
+
export function scrubString(input) {
|
|
47
|
+
let out = input;
|
|
48
|
+
for (const re of SECRET_VALUE_RES) {
|
|
49
|
+
out = out.replace(re, REDACTED);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Recursively scrub a JSON-ish value: redact whole values under secret-named
|
|
55
|
+
* keys, scrub credential-shaped substrings everywhere else, and cap depth so a
|
|
56
|
+
* cyclic / huge event can't hang the scrubber. Pure.
|
|
57
|
+
*/
|
|
58
|
+
export function scrubValue(value, depth = 0) {
|
|
59
|
+
if (depth > MAX_DEPTH) {
|
|
60
|
+
// Fail CLOSED: past the cap we can't recurse to check for secrets, so a
|
|
61
|
+
// primitive could be a credential — redact rather than leak it.
|
|
62
|
+
return REDACTED;
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === "string") {
|
|
65
|
+
return scrubString(value);
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
return value.map((v) => scrubValue(v, depth + 1));
|
|
69
|
+
}
|
|
70
|
+
if (value && typeof value === "object") {
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const [k, v] of Object.entries(value)) {
|
|
73
|
+
out[k] = SECRET_KEY_RE.test(k) ? REDACTED : scrubValue(v, depth + 1);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Scrub a Sentry event (message, exceptions, breadcrumbs, extra, request — which
|
|
81
|
+
* carries headers + env — and contexts) by walking it structurally. Pure.
|
|
82
|
+
*/
|
|
83
|
+
export function scrubEvent(event) {
|
|
84
|
+
return scrubValue(event);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the CLI Sentry DSN from the environment. Returns null when reporting
|
|
88
|
+
* is disabled or no DSN is configured (→ init no-ops). No Vault: OSS users do
|
|
89
|
+
* not have it, so this path is intentionally env-only.
|
|
90
|
+
*
|
|
91
|
+
* Only the namespaced `SENTRY_DSN_CLI` is read — NOT the conventional
|
|
92
|
+
* `SENTRY_DSN`. el-linear ships open-source, and `SENTRY_DSN` is the var the
|
|
93
|
+
* `@sentry/node` SDK reads by default, so an OSS user running their own
|
|
94
|
+
* Sentry-instrumented app very likely has it set; falling back to it would make
|
|
95
|
+
* el-linear silently report into *their* project. Requiring our explicit,
|
|
96
|
+
* namespaced var keeps activation unambiguous and collision-free (DEV-4349
|
|
97
|
+
* cycle-1 review). The internal tools build uses `SENTRY_DSN_CLI` too, so we
|
|
98
|
+
* lose nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveDsn() {
|
|
101
|
+
if (process.env.EL_SENTRY_DISABLED === "1") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return process.env.SENTRY_DSN_CLI?.trim() || null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Initialize Sentry for el-linear. Async because `@sentry/node` is loaded via a
|
|
108
|
+
* dynamic import only when a DSN resolves — so a CLI run without a DSN (the
|
|
109
|
+
* default for OSS users) never even loads the SDK. Returns true when reporting
|
|
110
|
+
* is active, false when it no-ops (disabled / no DSN / SDK not installed).
|
|
111
|
+
*
|
|
112
|
+
* When active, installs global uncaughtException + unhandledRejection handlers
|
|
113
|
+
* that capture → flush → exit(1). Because the SDK loads via a dynamic import
|
|
114
|
+
* (resolving a tick after the caller's fire-and-forget `void`), a synchronous
|
|
115
|
+
* throw during the very first tick of CLI startup — before the handlers install
|
|
116
|
+
* — is out of scope; this is best-effort reporting, not a crash guarantee.
|
|
117
|
+
*/
|
|
118
|
+
export async function initCliSentry(cliName, opts = {}) {
|
|
119
|
+
const dsn = opts.dsn === undefined ? resolveDsn() : opts.dsn;
|
|
120
|
+
if (!dsn) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
let Sentry;
|
|
124
|
+
try {
|
|
125
|
+
// Optional dependency: loaded only here, only when a DSN is set. A
|
|
126
|
+
// missing/uninstalled SDK is a clean no-op (we never ship Sentry to OSS
|
|
127
|
+
// users who have not opted in).
|
|
128
|
+
Sentry = (await import("@sentry/node"));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
Sentry.init({
|
|
134
|
+
dsn,
|
|
135
|
+
release: `${cliName}@${opts.version ?? "0.0.0"}`,
|
|
136
|
+
environment: process.env.CI ? "ci" : "development",
|
|
137
|
+
tracesSampleRate: 0,
|
|
138
|
+
beforeSend: (event) => scrubEvent(event),
|
|
139
|
+
});
|
|
140
|
+
Sentry.setTag("cli", cliName);
|
|
141
|
+
const report = (err) => {
|
|
142
|
+
Sentry.captureException(err);
|
|
143
|
+
// Flush before exiting; the process is in an undefined state after an
|
|
144
|
+
// uncaught error, so report-then-die is the standard Sentry pattern.
|
|
145
|
+
Sentry.flush(2000).then(() => process.exit(1), () => process.exit(1));
|
|
146
|
+
};
|
|
147
|
+
process.on("uncaughtException", report);
|
|
148
|
+
process.on("unhandledRejection", (reason) => report(reason instanceof Error ? reason : new Error(String(reason))));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
@@ -359,7 +359,7 @@ export class GraphQLIssuesService {
|
|
|
359
359
|
const finalProjectId = projectIdArg
|
|
360
360
|
? this.resolveProjectId(projectIdArg, resolveResult)
|
|
361
361
|
: undefined;
|
|
362
|
-
const finalAssigneeId = this.resolveAssigneeId(args.assigneeId, resolveResult);
|
|
362
|
+
const finalAssigneeId = await this.resolveAssigneeId(args.assigneeId, resolveResult);
|
|
363
363
|
const finalDelegateId = await this.resolveDelegateId(args.delegateId, resolveResult);
|
|
364
364
|
// Map the public discriminant back to the internal filter shape:
|
|
365
365
|
// `{ kind: "id", id }` → `{ kind: "id", id: <resolvedUuid> }`
|
|
@@ -877,10 +877,20 @@ export class GraphQLIssuesService {
|
|
|
877
877
|
}
|
|
878
878
|
return filter;
|
|
879
879
|
}
|
|
880
|
-
resolveAssigneeId(assigneeId, resolveResult) {
|
|
881
|
-
if (!assigneeId || isUuid(assigneeId)
|
|
880
|
+
async resolveAssigneeId(assigneeId, resolveResult) {
|
|
881
|
+
if (!assigneeId || isUuid(assigneeId)) {
|
|
882
882
|
return assigneeId;
|
|
883
883
|
}
|
|
884
|
+
// A plain name (no `@`) resolves via the user lookup — same as
|
|
885
|
+
// `resolveDelegateId`. Without this, a non-config full name like
|
|
886
|
+
// "Yury Tsukerman" fell through unchanged and was sent to the
|
|
887
|
+
// GraphQL filter as a bogus UUID, producing an opaque "Argument
|
|
888
|
+
// Validation Error" instead of resolving (or a clean not-found).
|
|
889
|
+
// `resolveUserId` matches displayName then name and throws a
|
|
890
|
+
// structured notFound / multiple-matches error (DEV-4312).
|
|
891
|
+
if (!assigneeId.includes("@")) {
|
|
892
|
+
return this.linearService.resolveUserId(assigneeId);
|
|
893
|
+
}
|
|
884
894
|
const userNodes = resolveResult.users?.nodes;
|
|
885
895
|
if (!userNodes?.length) {
|
|
886
896
|
throw notFoundError("User", assigneeId);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injectable seams for the two Node defaults we flip. Real callers use the
|
|
3
|
+
* `node:dns` / `node:net` implementations; tests pass spies.
|
|
4
|
+
*/
|
|
5
|
+
export interface NetworkPreferenceDeps {
|
|
6
|
+
setDefaultResultOrder: (order: "ipv4first" | "ipv6first" | "verbatim") => void;
|
|
7
|
+
setDefaultAutoSelectFamily: (value: boolean) => void;
|
|
8
|
+
}
|
|
9
|
+
/** Set this env var to `1` to keep Node's native behavior (see below). */
|
|
10
|
+
export declare const VERBATIM_ENV = "EL_LINEAR_NETWORK_VERBATIM";
|
|
11
|
+
/**
|
|
12
|
+
* Prefer IPv4 for el-linear's outbound API calls.
|
|
13
|
+
*
|
|
14
|
+
* el-linear talks only to `api.linear.app` (Cloudflare, dual-stack — it
|
|
15
|
+
* publishes AAAA records). On a network whose IPv6 route is broken or
|
|
16
|
+
* blackholed, Node 17+'s defaults — DNS result order `verbatim` (often
|
|
17
|
+
* IPv6-first) plus Happy Eyeballs (`autoSelectFamily`) — make `fetch`
|
|
18
|
+
* (undici) stall on the dead IPv6 path until it times out, surfacing as
|
|
19
|
+
* `GraphQL request failed: fetch failed`. Restoring `ipv4first` AND disabling
|
|
20
|
+
* `autoSelectFamily` makes el-linear use the working IPv4 path directly.
|
|
21
|
+
* (`ipv4first` alone is not enough — Happy Eyeballs still races the dead IPv6
|
|
22
|
+
* address; both levers are required.) `ipv4first` was Node's own default
|
|
23
|
+
* before v17, so this is a conservative choice.
|
|
24
|
+
*
|
|
25
|
+
* Opt out with `EL_LINEAR_NETWORK_VERBATIM=1` — required only on pure
|
|
26
|
+
* IPv6-only networks (no IPv4 route at all), where preferring IPv4 would pick
|
|
27
|
+
* an unreachable address. See DEV-4415.
|
|
28
|
+
*
|
|
29
|
+
* @returns `true` if the IPv4 preference was applied, `false` if opted out.
|
|
30
|
+
*/
|
|
31
|
+
export declare function applyIpv4Preference(env?: NodeJS.ProcessEnv, deps?: NetworkPreferenceDeps): boolean;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import dns from "node:dns";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
const defaultDeps = {
|
|
4
|
+
// Wrapped (not passed by reference) so the receiver keeps its module binding.
|
|
5
|
+
setDefaultResultOrder: (order) => dns.setDefaultResultOrder(order),
|
|
6
|
+
setDefaultAutoSelectFamily: (value) => net.setDefaultAutoSelectFamily(value),
|
|
7
|
+
};
|
|
8
|
+
/** Set this env var to `1` to keep Node's native behavior (see below). */
|
|
9
|
+
export const VERBATIM_ENV = "EL_LINEAR_NETWORK_VERBATIM";
|
|
10
|
+
/**
|
|
11
|
+
* Prefer IPv4 for el-linear's outbound API calls.
|
|
12
|
+
*
|
|
13
|
+
* el-linear talks only to `api.linear.app` (Cloudflare, dual-stack — it
|
|
14
|
+
* publishes AAAA records). On a network whose IPv6 route is broken or
|
|
15
|
+
* blackholed, Node 17+'s defaults — DNS result order `verbatim` (often
|
|
16
|
+
* IPv6-first) plus Happy Eyeballs (`autoSelectFamily`) — make `fetch`
|
|
17
|
+
* (undici) stall on the dead IPv6 path until it times out, surfacing as
|
|
18
|
+
* `GraphQL request failed: fetch failed`. Restoring `ipv4first` AND disabling
|
|
19
|
+
* `autoSelectFamily` makes el-linear use the working IPv4 path directly.
|
|
20
|
+
* (`ipv4first` alone is not enough — Happy Eyeballs still races the dead IPv6
|
|
21
|
+
* address; both levers are required.) `ipv4first` was Node's own default
|
|
22
|
+
* before v17, so this is a conservative choice.
|
|
23
|
+
*
|
|
24
|
+
* Opt out with `EL_LINEAR_NETWORK_VERBATIM=1` — required only on pure
|
|
25
|
+
* IPv6-only networks (no IPv4 route at all), where preferring IPv4 would pick
|
|
26
|
+
* an unreachable address. See DEV-4415.
|
|
27
|
+
*
|
|
28
|
+
* @returns `true` if the IPv4 preference was applied, `false` if opted out.
|
|
29
|
+
*/
|
|
30
|
+
export function applyIpv4Preference(env = process.env, deps = defaultDeps) {
|
|
31
|
+
if (env[VERBATIM_ENV] === "1") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
deps.setDefaultResultOrder("ipv4first");
|
|
35
|
+
deps.setDefaultAutoSelectFamily(false);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.1",
|
|
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",
|
|
@@ -64,6 +64,9 @@
|
|
|
64
64
|
"typescript": "^6.0.3",
|
|
65
65
|
"vitest": "^4.0.18"
|
|
66
66
|
},
|
|
67
|
+
"optionalDependencies": {
|
|
68
|
+
"@sentry/node": "^10.50.0"
|
|
69
|
+
},
|
|
67
70
|
"scripts": {
|
|
68
71
|
"build": "tsc && chmod +x dist/main.js",
|
|
69
72
|
"clean": "rm -rf dist/",
|