@enrichlayer/el-linear 1.18.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 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
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.")
@@ -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
+ }
@@ -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.18.0",
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/",