@enrichlayer/el-linear 1.18.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/commands/config.js +164 -1
- package/dist/config/migrate-from-personal.d.ts +77 -0
- package/dist/config/migrate-from-personal.js +236 -0
- package/dist/main.js +18 -0
- package/dist/sentry.d.ts +78 -0
- package/dist/sentry.js +150 -0
- 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
|
|
@@ -343,6 +356,27 @@ preferences; those stay in personal config. Example team file:
|
|
|
343
356
|
Run `el-linear config show` to see the resolved config and confirm which team
|
|
344
357
|
config path is active (`teamConfig` field in the output).
|
|
345
358
|
|
|
359
|
+
#### Migrating a personal-heavy config
|
|
360
|
+
|
|
361
|
+
Before the team-config split, members typically carried full local copies of
|
|
362
|
+
`members` / `teams` / `labels` / `statusDefaults` / `teamAliases` — and often
|
|
363
|
+
a deprecated `brand: { name, reject }` key. Once a team config is in place
|
|
364
|
+
those local copies just silently shadow the shared values (and silently
|
|
365
|
+
*diverge* when they drift). To clean it up safely:
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
el-linear config migrate-from-personal # dry-run — prints the plan per file
|
|
369
|
+
el-linear config migrate-from-personal --apply # write the slimmed files (with .bak-<ts> backups)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Targets the global personal config + every named profile's `config.json`. For
|
|
373
|
+
each top-level shadowable key, the slim drops it **only** when the local copy
|
|
374
|
+
is a strict subset of team (zero divergence, zero non-trivial personal-only
|
|
375
|
+
entries); otherwise the key is left untouched and the divergence is reported
|
|
376
|
+
in the plan so a human resolves it. The deprecated `brand` key is dropped
|
|
377
|
+
when content-identical to a team `terms[]` entry, or converted to a personal
|
|
378
|
+
`terms[]` entry when it differs (with a warning).
|
|
379
|
+
|
|
346
380
|
## Term enforcement (with brand-promotion examples)
|
|
347
381
|
|
|
348
382
|
The `terms` rules let you keep a list of canonical names and the misspellings
|
package/dist/commands/config.js
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { getActiveTeamConfigInfo, getActiveTeamConfigPath, loadConfig, loadLocalConfig, } from "../config/config.js";
|
|
5
|
-
import {
|
|
5
|
+
import { planMigration, } from "../config/migrate-from-personal.js";
|
|
6
|
+
import { CONFIG_PATH, isSafeProfileName, PROFILES_DIR, resolveActiveProfile, } from "../config/paths.js";
|
|
6
7
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
7
8
|
import { updateConfig } from "./init/shared.js";
|
|
8
9
|
/**
|
|
@@ -185,4 +186,166 @@ export function setupConfigCommands(program) {
|
|
|
185
186
|
fs.writeFileSync(localPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
|
|
186
187
|
outputSuccess({ data: updated });
|
|
187
188
|
}));
|
|
189
|
+
// ── config migrate-from-personal ──────────────────────────────────
|
|
190
|
+
//
|
|
191
|
+
// Slim personal/profile configs that duplicate keys the team config now
|
|
192
|
+
// provides (DEV-4458). The companion pure logic + tests live in
|
|
193
|
+
// `src/config/migrate-from-personal.ts`. This command just discovers
|
|
194
|
+
// the target files, reads the active team config, calls planMigration
|
|
195
|
+
// for each, and on --apply backs up + atomically rewrites them.
|
|
196
|
+
config
|
|
197
|
+
.command("migrate-from-personal")
|
|
198
|
+
.description("Slim personal/profile configs against the active team config — drops duplicated members/teams/labels/statusDefaults/teamAliases entries, and migrates the deprecated 'brand' key to a 'terms[]' entry. Dry-run by default; pass --apply to write.")
|
|
199
|
+
.option("--apply", "Write the slimmed files (with timestamped .bak-<ts> backups + atomic replace). Default is dry-run — only prints the plan.")
|
|
200
|
+
.option("--file <path>", "Scope to one file. Default: ~/.config/el-linear/config.json plus every <profiles>/<name>/config.json with a safe name.")
|
|
201
|
+
.addHelpText("after", `\nThe slim only drops a shadowable top-level key when the personal copy is a strict subset of team (zero divergence and zero non-trivial personal-only entries). Anything that would silently shadow a team value, or carry a personal-only entry team doesn't have, is left untouched and reported in the plan so a human can decide.\n\nThe active team config is never a target — passing --file <team-path> is rejected, and a profile config that happens to coincide with the team path is skipped silently with a warning. The first --apply may re-format JSON to 2-space indent + trailing newline; this is harmless but the .bak-<ts> backup preserves the original byte-for-byte.\n\nUses the currently-active team config for every target. If you have profiles pointing at different team configs, run --file <path> per profile with EL_LINEAR_TEAM_CONFIG set.\n\nExamples:\n el-linear config migrate-from-personal\n el-linear config migrate-from-personal --apply\n el-linear config migrate-from-personal --file ~/.config/el-linear/profiles/foo/config.json --apply`)
|
|
202
|
+
.action(handleAsyncCommand(async (opts) => {
|
|
203
|
+
const teamPath = getActiveTeamConfigPath();
|
|
204
|
+
if (!teamPath) {
|
|
205
|
+
throw new Error("No active team config found. Set one with `el-linear config team set-path <path>` (or EL_LINEAR_TEAM_CONFIG) before running migrate-from-personal — without a team layer there's nothing to dedupe against.");
|
|
206
|
+
}
|
|
207
|
+
let team;
|
|
208
|
+
try {
|
|
209
|
+
team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
throw new Error(`Failed to read team config at ${teamPath}: ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
const apply = opts.apply ?? false;
|
|
215
|
+
const resolvedTeamPath = path.resolve(teamPath);
|
|
216
|
+
// Self-diff guard: refuse to migrate the team config against itself
|
|
217
|
+
// (cycle-1 nit). Without this, every shadowable key in team would
|
|
218
|
+
// classify as "drop" (a key is trivially a subset of itself) and
|
|
219
|
+
// --apply would gut the team config.
|
|
220
|
+
if (opts.file &&
|
|
221
|
+
path.resolve(expandHome(opts.file)) === resolvedTeamPath) {
|
|
222
|
+
throw new Error(`--file resolves to the active team config (${resolvedTeamPath}). Refusing to migrate the team config against itself.`);
|
|
223
|
+
}
|
|
224
|
+
const targetsBeforeFilter = opts.file
|
|
225
|
+
? [path.resolve(expandHome(opts.file))]
|
|
226
|
+
: enumerateMigrationTargets();
|
|
227
|
+
const targets = [];
|
|
228
|
+
for (const t of targetsBeforeFilter) {
|
|
229
|
+
if (path.resolve(t) === resolvedTeamPath) {
|
|
230
|
+
outputWarning(`Skipping ${t} — it resolves to the active team config; migrating it against itself would erase team-shared keys.`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
targets.push(t);
|
|
234
|
+
}
|
|
235
|
+
const results = targets.map((file) => migrateOneFile(file, team, apply));
|
|
236
|
+
// Forward per-file plan warnings to the standard _warnings envelope
|
|
237
|
+
// (cycle-1 nit) so scripters reading the stable warning channel see
|
|
238
|
+
// them without having to inspect each target.
|
|
239
|
+
for (const r of results) {
|
|
240
|
+
for (const w of r.warnings)
|
|
241
|
+
outputWarning(`${r.file}: ${w}`);
|
|
242
|
+
}
|
|
243
|
+
// --file <single>: an unreadable / unparseable target is a hard
|
|
244
|
+
// failure (cycle-1 nit). Default enumeration stays soft so one bad
|
|
245
|
+
// profile doesn't crash the run for the rest.
|
|
246
|
+
if (opts.file && results.length === 1 && results[0].error) {
|
|
247
|
+
throw new Error(results[0].error);
|
|
248
|
+
}
|
|
249
|
+
outputSuccess({
|
|
250
|
+
data: {
|
|
251
|
+
teamConfigPath: teamPath,
|
|
252
|
+
applied: apply,
|
|
253
|
+
targetCount: results.length,
|
|
254
|
+
targets: results,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Enumerate every personal/profile config file the migration tool should
|
|
261
|
+
* consider by default: the global personal config, plus each profile dir
|
|
262
|
+
* whose name passes `isSafeProfileName` and whose `config.json` exists.
|
|
263
|
+
*/
|
|
264
|
+
function enumerateMigrationTargets() {
|
|
265
|
+
const out = [];
|
|
266
|
+
if (fs.existsSync(CONFIG_PATH))
|
|
267
|
+
out.push(CONFIG_PATH);
|
|
268
|
+
if (fs.existsSync(PROFILES_DIR)) {
|
|
269
|
+
const entries = fs.readdirSync(PROFILES_DIR);
|
|
270
|
+
for (const name of entries.sort()) {
|
|
271
|
+
if (!isSafeProfileName(name))
|
|
272
|
+
continue;
|
|
273
|
+
const p = path.join(PROFILES_DIR, name, "config.json");
|
|
274
|
+
if (fs.existsSync(p))
|
|
275
|
+
out.push(p);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
function migrateOneFile(file, team, apply) {
|
|
281
|
+
let rawText;
|
|
282
|
+
let personal;
|
|
283
|
+
try {
|
|
284
|
+
rawText = fs.readFileSync(file, "utf8");
|
|
285
|
+
personal = JSON.parse(rawText);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
return {
|
|
289
|
+
file,
|
|
290
|
+
changed: false,
|
|
291
|
+
before: { sizeBytes: 0, keys: [] },
|
|
292
|
+
after: { sizeBytes: 0, keys: [] },
|
|
293
|
+
keys: [],
|
|
294
|
+
brand: { status: "absent" },
|
|
295
|
+
warnings: [],
|
|
296
|
+
error: `Could not read/parse: ${err.message}`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const { slimmed, plan } = planMigration(personal, team);
|
|
300
|
+
const slimmedText = `${JSON.stringify(slimmed, null, 2)}\n`;
|
|
301
|
+
const beforeBytes = Buffer.byteLength(rawText, "utf8");
|
|
302
|
+
const afterBytes = Buffer.byteLength(slimmedText, "utf8");
|
|
303
|
+
const changed = slimmedText !== rawText;
|
|
304
|
+
const result = {
|
|
305
|
+
file,
|
|
306
|
+
changed,
|
|
307
|
+
before: { sizeBytes: beforeBytes, keys: Object.keys(personal) },
|
|
308
|
+
after: { sizeBytes: afterBytes, keys: Object.keys(slimmed) },
|
|
309
|
+
keys: plan.keys,
|
|
310
|
+
brand: plan.brand,
|
|
311
|
+
warnings: plan.warnings,
|
|
312
|
+
};
|
|
313
|
+
if (apply && changed) {
|
|
314
|
+
// Backup names include milliseconds (sub-second collisions on rapid
|
|
315
|
+
// re-apply would otherwise overwrite the original backup) AND copy
|
|
316
|
+
// with COPYFILE_EXCL so a same-millisecond collision throws instead
|
|
317
|
+
// of clobbering. Same for the temp file via `wx` flag.
|
|
318
|
+
const { backup, tmp } = acquireBackupAndTempPaths(file);
|
|
319
|
+
fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
|
|
320
|
+
fs.writeFileSync(tmp, slimmedText, { encoding: "utf8", flag: "wx" });
|
|
321
|
+
fs.renameSync(tmp, file);
|
|
322
|
+
result.backup = backup;
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
function backupTimestamp() {
|
|
327
|
+
const d = new Date();
|
|
328
|
+
const pad = (n) => n.toString().padStart(2, "0");
|
|
329
|
+
const pad3 = (n) => n.toString().padStart(3, "0");
|
|
330
|
+
return (`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
|
331
|
+
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` +
|
|
332
|
+
`-${pad3(d.getMilliseconds())}`);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Pick a `.bak-<ts>` and `.tmp-<ts>` pair that don't collide with anything
|
|
336
|
+
* on disk. Millisecond resolution makes a collision extraordinarily rare,
|
|
337
|
+
* but a tight retry loop closes the remaining gap (and the `COPYFILE_EXCL`
|
|
338
|
+
* / `wx` flags at the call site fail loudly if the gap is ever crossed).
|
|
339
|
+
*/
|
|
340
|
+
function acquireBackupAndTempPaths(file) {
|
|
341
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
342
|
+
const ts = backupTimestamp();
|
|
343
|
+
const suffix = attempt === 0 ? ts : `${ts}-${attempt}`;
|
|
344
|
+
const backup = `${file}.bak-${suffix}`;
|
|
345
|
+
const tmp = `${file}.tmp-${suffix}`;
|
|
346
|
+
if (!fs.existsSync(backup) && !fs.existsSync(tmp)) {
|
|
347
|
+
return { backup, tmp };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`Could not pick a unique backup name for ${file} after 100 attempts; clean up old .bak-/.tmp- siblings and retry.`);
|
|
188
351
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan + apply the personal-to-team-backed config slim.
|
|
3
|
+
*
|
|
4
|
+
* After the team-config split (DEV-4172 / ALL-964) members typically still
|
|
5
|
+
* carry full duplicates of the now-shared keys in their personal/profile
|
|
6
|
+
* configs. The duplicates are silent shadows when identical and silent
|
|
7
|
+
* divergences when they drift. This module computes a per-file plan:
|
|
8
|
+
*
|
|
9
|
+
* - **Top-level shadowable keys** (`members`, `teams`, `labels`,
|
|
10
|
+
* `statusDefaults`, `teamAliases`) are *dropped* only when the personal
|
|
11
|
+
* copy is a strict subset of team (zero divergence and zero non-trivial
|
|
12
|
+
* personal-only entries). Otherwise the key is left untouched and the
|
|
13
|
+
* diff is reported, so a human resolves it.
|
|
14
|
+
* - The deprecated `brand: { name, reject }` key is dropped when content-
|
|
15
|
+
* identical to an existing team `terms[]` entry; otherwise it is
|
|
16
|
+
* *converted* into a personal `terms[]` entry (no shadowing of team).
|
|
17
|
+
* - Genuinely personal keys (`defaultTeam`, `defaultLabels`,
|
|
18
|
+
* `teamConfigPath`, anything not in the shadowable set or `brand`) are
|
|
19
|
+
* never touched.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no fs / no process / no logging. The caller does I/O. DEV-4458.
|
|
22
|
+
*/
|
|
23
|
+
/** Top-level keys typically owned by the team config layer. */
|
|
24
|
+
export declare const TEAM_SHADOWABLE_KEYS: readonly ["members", "teams", "labels", "statusDefaults", "teamAliases"];
|
|
25
|
+
export type TeamShadowableKey = (typeof TEAM_SHADOWABLE_KEYS)[number];
|
|
26
|
+
export type JsonValue = string | number | boolean | null | {
|
|
27
|
+
[k: string]: JsonValue;
|
|
28
|
+
} | JsonValue[];
|
|
29
|
+
export type JsonObject = {
|
|
30
|
+
[k: string]: JsonValue;
|
|
31
|
+
};
|
|
32
|
+
/** Per-key decision in the plan. */
|
|
33
|
+
export interface KeyAction {
|
|
34
|
+
key: string;
|
|
35
|
+
action: "drop" | "keep-divergent" | "keep-additions" | "absent";
|
|
36
|
+
/** Leaves whose personal value matched team exactly. */
|
|
37
|
+
matchCount: number;
|
|
38
|
+
/** Leaves whose personal value differs from team (would be a silent shadow). */
|
|
39
|
+
divergentCount: number;
|
|
40
|
+
/** Non-trivial personal-only leaves (would be lost if dropped). */
|
|
41
|
+
additionCount: number;
|
|
42
|
+
/** First few divergent / addition paths, for human-readable context. */
|
|
43
|
+
sampleDivergent?: string[];
|
|
44
|
+
sampleAdditions?: string[];
|
|
45
|
+
}
|
|
46
|
+
/** What we did with the deprecated `brand` key. */
|
|
47
|
+
export interface BrandAction {
|
|
48
|
+
status: "absent" | "drop-duplicate" | "convert-to-term" /** Couldn't convert safely (e.g. existing personal `terms` is malformed). */ | "keep-malformed";
|
|
49
|
+
/** When status === "convert-to-term", the new entry appended to terms[]. */
|
|
50
|
+
convertedTo?: {
|
|
51
|
+
canonical: string;
|
|
52
|
+
reject: string[];
|
|
53
|
+
};
|
|
54
|
+
reason?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface MigrationPlan {
|
|
57
|
+
keys: KeyAction[];
|
|
58
|
+
brand: BrandAction;
|
|
59
|
+
warnings: string[];
|
|
60
|
+
}
|
|
61
|
+
export interface MigrationResult {
|
|
62
|
+
slimmed: JsonObject;
|
|
63
|
+
plan: MigrationPlan;
|
|
64
|
+
}
|
|
65
|
+
/** A canonical JSON-friendly deep equality. */
|
|
66
|
+
export declare function deepEqual(a: JsonValue, b: JsonValue): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Compute the slimmed config + the plan that explains it. Pure.
|
|
69
|
+
*
|
|
70
|
+
* @param personal The personal/profile config object as parsed from disk.
|
|
71
|
+
* @param team The active team config object as parsed from disk. Pass
|
|
72
|
+
* `{}` if no team config is configured — every shadowable
|
|
73
|
+
* key becomes either `keep-additions` or `keep-divergent`,
|
|
74
|
+
* so nothing gets dropped (correct: without team there is
|
|
75
|
+
* nothing to fall back to).
|
|
76
|
+
*/
|
|
77
|
+
export declare function planMigration(personal: JsonObject, team: JsonObject): MigrationResult;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan + apply the personal-to-team-backed config slim.
|
|
3
|
+
*
|
|
4
|
+
* After the team-config split (DEV-4172 / ALL-964) members typically still
|
|
5
|
+
* carry full duplicates of the now-shared keys in their personal/profile
|
|
6
|
+
* configs. The duplicates are silent shadows when identical and silent
|
|
7
|
+
* divergences when they drift. This module computes a per-file plan:
|
|
8
|
+
*
|
|
9
|
+
* - **Top-level shadowable keys** (`members`, `teams`, `labels`,
|
|
10
|
+
* `statusDefaults`, `teamAliases`) are *dropped* only when the personal
|
|
11
|
+
* copy is a strict subset of team (zero divergence and zero non-trivial
|
|
12
|
+
* personal-only entries). Otherwise the key is left untouched and the
|
|
13
|
+
* diff is reported, so a human resolves it.
|
|
14
|
+
* - The deprecated `brand: { name, reject }` key is dropped when content-
|
|
15
|
+
* identical to an existing team `terms[]` entry; otherwise it is
|
|
16
|
+
* *converted* into a personal `terms[]` entry (no shadowing of team).
|
|
17
|
+
* - Genuinely personal keys (`defaultTeam`, `defaultLabels`,
|
|
18
|
+
* `teamConfigPath`, anything not in the shadowable set or `brand`) are
|
|
19
|
+
* never touched.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no fs / no process / no logging. The caller does I/O. DEV-4458.
|
|
22
|
+
*/
|
|
23
|
+
/** Top-level keys typically owned by the team config layer. */
|
|
24
|
+
export const TEAM_SHADOWABLE_KEYS = [
|
|
25
|
+
"members",
|
|
26
|
+
"teams",
|
|
27
|
+
"labels",
|
|
28
|
+
"statusDefaults",
|
|
29
|
+
"teamAliases",
|
|
30
|
+
];
|
|
31
|
+
/** A canonical JSON-friendly deep equality. */
|
|
32
|
+
export function deepEqual(a, b) {
|
|
33
|
+
if (a === b)
|
|
34
|
+
return true;
|
|
35
|
+
if (a === null || b === null)
|
|
36
|
+
return false;
|
|
37
|
+
if (typeof a !== typeof b)
|
|
38
|
+
return false;
|
|
39
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
40
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
41
|
+
return false;
|
|
42
|
+
if (a.length !== b.length)
|
|
43
|
+
return false;
|
|
44
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
45
|
+
}
|
|
46
|
+
if (typeof a === "object") {
|
|
47
|
+
const ak = Object.keys(a).sort();
|
|
48
|
+
const bk = Object.keys(b).sort();
|
|
49
|
+
if (ak.length !== bk.length)
|
|
50
|
+
return false;
|
|
51
|
+
if (ak.some((k, i) => k !== bk[i]))
|
|
52
|
+
return false;
|
|
53
|
+
return ak.every((k) => deepEqual(a[k], b[k]));
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function isTriviallyEmpty(v) {
|
|
58
|
+
if (v === undefined || v === null)
|
|
59
|
+
return true;
|
|
60
|
+
if (Array.isArray(v))
|
|
61
|
+
return v.length === 0;
|
|
62
|
+
if (typeof v === "object")
|
|
63
|
+
return Object.keys(v).length === 0;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
function isPlainObject(v) {
|
|
67
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Walk personal and classify each LEAF against team at the same path:
|
|
71
|
+
* match — present in team with the same value
|
|
72
|
+
* divergent — present in team with a different value (would silently shadow)
|
|
73
|
+
* addition — absent from team and non-trivially non-empty (would be lost)
|
|
74
|
+
*
|
|
75
|
+
* Trivially-empty containers (e.g. `members.handles.github = {}`) are not
|
|
76
|
+
* counted as additions — dropping them loses no information.
|
|
77
|
+
*/
|
|
78
|
+
function classifyDeep(personal, team, path, out) {
|
|
79
|
+
if (personal === undefined)
|
|
80
|
+
return;
|
|
81
|
+
// Both objects → recurse into keys.
|
|
82
|
+
if (isPlainObject(personal) && isPlainObject(team)) {
|
|
83
|
+
for (const k of Object.keys(personal)) {
|
|
84
|
+
classifyDeep(personal[k], team[k], path ? `${path}.${k}` : k, out);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Personal is a leaf (primitive / array / object without a matching team obj).
|
|
89
|
+
if (team === undefined) {
|
|
90
|
+
if (!isTriviallyEmpty(personal))
|
|
91
|
+
out.additions.push(path);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (deepEqual(personal, team)) {
|
|
95
|
+
out.matches += 1;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
out.divergent.push(path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function planTopLevelKey(key, personal, team) {
|
|
102
|
+
if (personal === undefined) {
|
|
103
|
+
return {
|
|
104
|
+
key,
|
|
105
|
+
action: "absent",
|
|
106
|
+
matchCount: 0,
|
|
107
|
+
divergentCount: 0,
|
|
108
|
+
additionCount: 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const acc = { matches: 0, divergent: [], additions: [] };
|
|
112
|
+
classifyDeep(personal, team, key, acc);
|
|
113
|
+
const action = acc.divergent.length === 0 && acc.additions.length === 0
|
|
114
|
+
? "drop"
|
|
115
|
+
: acc.divergent.length > 0
|
|
116
|
+
? "keep-divergent"
|
|
117
|
+
: "keep-additions";
|
|
118
|
+
const ka = {
|
|
119
|
+
key,
|
|
120
|
+
action,
|
|
121
|
+
matchCount: acc.matches,
|
|
122
|
+
divergentCount: acc.divergent.length,
|
|
123
|
+
additionCount: acc.additions.length,
|
|
124
|
+
};
|
|
125
|
+
if (acc.divergent.length > 0)
|
|
126
|
+
ka.sampleDivergent = acc.divergent.slice(0, 5);
|
|
127
|
+
if (acc.additions.length > 0)
|
|
128
|
+
ka.sampleAdditions = acc.additions.slice(0, 5);
|
|
129
|
+
return ka;
|
|
130
|
+
}
|
|
131
|
+
function readBrand(personal) {
|
|
132
|
+
const raw = personal.brand;
|
|
133
|
+
if (!isPlainObject(raw))
|
|
134
|
+
return null;
|
|
135
|
+
const name = raw.name;
|
|
136
|
+
const reject = raw.reject;
|
|
137
|
+
if (typeof name !== "string")
|
|
138
|
+
return null;
|
|
139
|
+
if (!Array.isArray(reject) || !reject.every((r) => typeof r === "string")) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return { name, reject: reject };
|
|
143
|
+
}
|
|
144
|
+
function readTeamTerms(team) {
|
|
145
|
+
const t = team.terms;
|
|
146
|
+
if (!Array.isArray(t))
|
|
147
|
+
return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const e of t) {
|
|
150
|
+
if (!isPlainObject(e))
|
|
151
|
+
continue;
|
|
152
|
+
const canonical = e.canonical;
|
|
153
|
+
const reject = e.reject;
|
|
154
|
+
if (typeof canonical !== "string")
|
|
155
|
+
continue;
|
|
156
|
+
if (!Array.isArray(reject) || !reject.every((r) => typeof r === "string")) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
out.push({ canonical, reject: reject });
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function brandMatchesTerm(brand, term) {
|
|
164
|
+
return brand.name === term.canonical && deepEqual(brand.reject, term.reject);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Compute the slimmed config + the plan that explains it. Pure.
|
|
168
|
+
*
|
|
169
|
+
* @param personal The personal/profile config object as parsed from disk.
|
|
170
|
+
* @param team The active team config object as parsed from disk. Pass
|
|
171
|
+
* `{}` if no team config is configured — every shadowable
|
|
172
|
+
* key becomes either `keep-additions` or `keep-divergent`,
|
|
173
|
+
* so nothing gets dropped (correct: without team there is
|
|
174
|
+
* nothing to fall back to).
|
|
175
|
+
*/
|
|
176
|
+
export function planMigration(personal, team) {
|
|
177
|
+
const warnings = [];
|
|
178
|
+
const slimmed = { ...personal };
|
|
179
|
+
const keyActions = TEAM_SHADOWABLE_KEYS.map((key) => planTopLevelKey(key, personal[key], team[key]));
|
|
180
|
+
for (const ka of keyActions) {
|
|
181
|
+
if (ka.action === "drop")
|
|
182
|
+
delete slimmed[ka.key];
|
|
183
|
+
}
|
|
184
|
+
// Brand → terms.
|
|
185
|
+
let brand;
|
|
186
|
+
const brandShape = readBrand(personal);
|
|
187
|
+
if (brandShape === null) {
|
|
188
|
+
brand = { status: "absent" };
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const teamTerms = readTeamTerms(team);
|
|
192
|
+
const dup = teamTerms.find((t) => brandMatchesTerm(brandShape, t));
|
|
193
|
+
if (dup !== undefined) {
|
|
194
|
+
delete slimmed.brand;
|
|
195
|
+
brand = {
|
|
196
|
+
status: "drop-duplicate",
|
|
197
|
+
reason: `brand is content-identical to team terms entry "${dup.canonical}"`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
else if (personal.terms !== undefined && !Array.isArray(personal.terms)) {
|
|
201
|
+
// Refuse to convert: an existing personal `terms` field is not an
|
|
202
|
+
// array (likely a hand-edit gone wrong). The strict-subset gate
|
|
203
|
+
// elsewhere never silently destroys info — neither should this
|
|
204
|
+
// path. Leave brand AND terms as-is; surface as a warning so a
|
|
205
|
+
// human can resolve.
|
|
206
|
+
brand = {
|
|
207
|
+
status: "keep-malformed",
|
|
208
|
+
reason: "existing personal 'terms' field is not an array; leaving 'brand' and 'terms' untouched for human review",
|
|
209
|
+
};
|
|
210
|
+
warnings.push(`Cannot convert deprecated 'brand': existing 'terms' field is not an array (got ${typeof personal.terms}). Left 'brand' and 'terms' as-is — fix 'terms' to a proper array of { canonical, reject } entries and re-run.`);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// Convert: append a personal terms entry, drop brand.
|
|
214
|
+
delete slimmed.brand;
|
|
215
|
+
const existing = Array.isArray(slimmed.terms)
|
|
216
|
+
? slimmed.terms
|
|
217
|
+
: [];
|
|
218
|
+
const converted = {
|
|
219
|
+
canonical: brandShape.name,
|
|
220
|
+
reject: brandShape.reject,
|
|
221
|
+
};
|
|
222
|
+
slimmed.terms = [...existing, { ...converted }];
|
|
223
|
+
brand = {
|
|
224
|
+
status: "convert-to-term",
|
|
225
|
+
convertedTo: converted,
|
|
226
|
+
reason: teamTerms.length === 0
|
|
227
|
+
? "team config has no terms; converted brand to a personal terms entry"
|
|
228
|
+
: "brand does not match any team terms entry; converted to a personal terms entry",
|
|
229
|
+
};
|
|
230
|
+
if (teamTerms.length > 0) {
|
|
231
|
+
warnings.push(`brand on this file diverges from team terms — preserved as a personal terms entry. Review whether the team config should adopt it.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { slimmed, plan: { keys: keyActions, brand, warnings } };
|
|
236
|
+
}
|
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.")
|
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
|
+
}
|
|
@@ -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.19.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|
|
@@ -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/",
|