linear-sdk-cli 0.1.3 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -58
  4. package/COVERAGE.md +5 -5
  5. package/MIGRATING.md +71 -69
  6. package/README.md +115 -90
  7. package/package.json +6 -7
  8. package/skills/README.md +15 -0
  9. package/skills/linear-sdk-cli/SKILL.md +27 -3
  10. package/skills/linear-sdk-cli/references/api.md +9 -9
  11. package/skills/linear-sdk-cli/references/auth.md +18 -0
  12. package/skills/linear-sdk-cli/references/commands.md +2 -2
  13. package/skills/linear-sdk-cli/references/comment.md +9 -6
  14. package/skills/linear-sdk-cli/references/config.md +9 -11
  15. package/skills/linear-sdk-cli/references/issue.md +72 -18
  16. package/skills/linear-sdk-cli/references/open.md +29 -0
  17. package/skills/linear-sdk-cli/references/team.md +16 -0
  18. package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
  19. package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
  20. package/src/bin/linear.ts +16 -6
  21. package/src/cli.ts +3 -1
  22. package/src/commands/agent-session.ts +13 -5
  23. package/src/commands/api.ts +15 -9
  24. package/src/commands/attach.ts +15 -4
  25. package/src/commands/comment.ts +50 -11
  26. package/src/commands/completion.ts +2 -6
  27. package/src/commands/cycle.ts +6 -1
  28. package/src/commands/discover.ts +6 -2
  29. package/src/commands/document.ts +4 -1
  30. package/src/commands/initiative.ts +5 -1
  31. package/src/commands/issue.ts +392 -91
  32. package/src/commands/meta.ts +139 -31
  33. package/src/commands/milestone.ts +4 -1
  34. package/src/commands/notification.ts +15 -6
  35. package/src/commands/open.ts +84 -0
  36. package/src/commands/project.ts +17 -4
  37. package/src/commands/team.ts +13 -0
  38. package/src/commands/user.ts +2 -2
  39. package/src/commands/webhook.ts +12 -2
  40. package/src/config.ts +501 -184
  41. package/src/context.ts +13 -4
  42. package/src/git.ts +28 -3
  43. package/src/lib/body.ts +26 -2
  44. package/src/lib/errors.ts +33 -8
  45. package/src/lib/introspect.ts +76 -7
  46. package/src/lib/keyring.ts +51 -9
  47. package/src/lib/mentions.ts +49 -0
  48. package/src/lib/open.ts +32 -0
  49. package/src/lib/options.ts +108 -12
  50. package/src/lib/output-shapes.ts +81 -9
  51. package/src/lib/pagination.ts +50 -17
  52. package/src/lib/resolve.ts +40 -7
  53. package/src/lib/shape.ts +16 -12
  54. package/src/lib/upload.ts +3 -1
  55. package/src/output/color.ts +52 -0
  56. package/src/output/format.ts +96 -18
  57. package/src/output/markdown.ts +133 -0
  58. package/src/output/pager.ts +131 -0
  59. package/src/output/sanitize.ts +1 -1
  60. package/src/output/table.ts +7 -7
  61. package/src/services/agent-session.ts +11 -3
  62. package/src/services/attachment.ts +14 -5
  63. package/src/services/comment.ts +37 -9
  64. package/src/services/cycle.ts +11 -4
  65. package/src/services/document.ts +29 -10
  66. package/src/services/favorite.ts +15 -9
  67. package/src/services/initiative-update.ts +16 -15
  68. package/src/services/initiative.ts +15 -10
  69. package/src/services/issue.ts +263 -19
  70. package/src/services/label.ts +12 -2
  71. package/src/services/milestone.ts +8 -5
  72. package/src/services/notification.ts +16 -2
  73. package/src/services/organization.ts +23 -18
  74. package/src/services/project.ts +55 -34
  75. package/src/services/roadmap.ts +7 -6
  76. package/src/services/state.ts +9 -3
  77. package/src/services/team.ts +67 -24
  78. package/src/services/user.ts +2 -2
  79. package/src/services/webhook.ts +28 -17
package/src/config.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Configuration resolution.
3
3
  *
4
4
  * Two tiers with different trust boundaries:
5
- * - Non-secret settings (team, workspace, sort, vcs): flag > env > project
5
+ * - Non-secret settings (team, workspace, sort): flag > env > project
6
6
  * config > user config (`~/.config/linear/config.toml`) > the reference
7
7
  * CLI's global config (`~/.config/linear/linear.toml`, read-only).
8
8
  * The project config is the first file found walking cwd → filesystem
@@ -40,8 +40,10 @@ import {
40
40
  chmodSync,
41
41
  openSync,
42
42
  closeSync,
43
+ fstatSync,
43
44
  fsyncSync,
44
45
  renameSync,
46
+ statSync,
45
47
  unlinkSync,
46
48
  } from "node:fs";
47
49
  import { execFileSync } from "node:child_process";
@@ -53,7 +55,6 @@ export interface RawSettings {
53
55
  team?: string;
54
56
  workspace?: string;
55
57
  sort?: string;
56
- vcs?: string;
57
58
  apiKey?: string;
58
59
  }
59
60
 
@@ -67,14 +68,16 @@ export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "key
67
68
  /** Where a stored workspace credential's secret lives. */
68
69
  export type CredentialStorage = "file" | "keychain";
69
70
 
70
- /** A setting's provenance: which tier, and (for a file) which file. */
71
+ /** A setting's provenance: which tier, and (for a file) which file/key. */
71
72
  export interface SettingOrigin {
72
73
  source: ConfigSource;
73
74
  path?: string;
75
+ /** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
76
+ key?: string;
74
77
  }
75
78
 
76
79
  /** The non-secret settings, each with its provenance. */
77
- export type SettingOrigins = Record<"team" | "workspace" | "sort" | "vcs", SettingOrigin>;
80
+ export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin>;
78
81
 
79
82
  export interface ResolvedConfig {
80
83
  apiKey?: string;
@@ -95,7 +98,6 @@ export interface ResolvedConfig {
95
98
  sort: string;
96
99
  /** Where `sort` came from, so an invalid value can be blamed precisely. */
97
100
  sortSource: ConfigSource;
98
- vcs: string;
99
101
  /** Provenance of every non-secret setting (`linear config` shows it). */
100
102
  origins: SettingOrigins;
101
103
  /** Absolute path of the user config file (may not exist yet). */
@@ -137,7 +139,11 @@ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
137
139
  * directory — the reference CLI's order, so a repo that has more than one
138
140
  * resolves the same way under both tools.
139
141
  */
140
- export const PROJECT_CONFIG_NAMES = ["linear.toml", ".linear.toml", join(".config", "linear.toml")] as const;
142
+ export const PROJECT_CONFIG_NAMES = [
143
+ "linear.toml",
144
+ ".linear.toml",
145
+ join(".config", "linear.toml"),
146
+ ] as const;
141
147
 
142
148
  /**
143
149
  * The reference CLI's credentials file, a sibling of our config. Its keyring
@@ -160,12 +166,22 @@ interface WorkspaceEntry {
160
166
  interface UserConfig {
161
167
  /** Top-level non-secret settings. */
162
168
  settings: RawSettings;
169
+ /** Exact top-level key spelling that supplied each non-secret setting. */
170
+ settingKeys: SettingKeys;
163
171
  /** default_workspace, if set (ours, else the reference CLI's `default`). */
164
172
  defaultWorkspace?: string;
165
173
  /** slug → entry from `[workspaces."<slug>"]`, plus the reference CLI's list. */
166
174
  workspaces: Record<string, WorkspaceEntry>;
167
175
  }
168
176
 
177
+ type NonSecretSetting = "team" | "workspace" | "sort";
178
+ type SettingKeys = Partial<Record<NonSecretSetting, string>>;
179
+
180
+ interface FileSettings {
181
+ settings: RawSettings;
182
+ keys: SettingKeys;
183
+ }
184
+
169
185
  function asString(v: unknown): string | undefined {
170
186
  return typeof v === "string" && v.length > 0 ? v : undefined;
171
187
  }
@@ -177,7 +193,7 @@ function asString(v: unknown): string | undefined {
177
193
  * spelled as escapes so no literal control byte lives in this source.
178
194
  */
179
195
  const CONTROL_CHARS = new RegExp(
180
- // eslint-disable-next-line no-control-regex
196
+ // oxlint-disable-next-line no-control-regex
181
197
  "[\\u0000-\\u001F\\u007F-\\u009F\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]",
182
198
  "g",
183
199
  );
@@ -231,14 +247,31 @@ function parseTomlFile(path: string): Record<string, unknown> {
231
247
  * Read flat NON-SECRET settings from a TOML file. Used for project
232
248
  * `.linear.toml`; the API key is intentionally NOT read here (trust boundary).
233
249
  */
234
- function readTomlFile(path: string): RawSettings {
235
- const parsed = parseTomlFile(path);
236
- return {
237
- team: asString(parsed.team ?? parsed.team_id),
238
- workspace: asString(parsed.workspace),
239
- sort: asString(parsed.sort ?? parsed.issue_sort),
240
- vcs: asString(parsed.vcs),
250
+ function settingsFromParsed(parsed: Record<string, unknown>): FileSettings {
251
+ const read = (canonical: NonSecretSetting, aliases: readonly string[]) => {
252
+ // Match the old `primary ?? alias` behavior exactly: a present-but-invalid
253
+ // primary spelling does not silently fall through to a second key.
254
+ const key = aliases.find((alias) => parsed[alias] !== undefined);
255
+ const value = key === undefined ? undefined : asString(parsed[key]);
256
+ return { canonical, key: value === undefined ? undefined : key, value };
241
257
  };
258
+ const found = [
259
+ read("team", ["team", "team_id"]),
260
+ read("workspace", ["workspace"]),
261
+ read("sort", ["sort", "issue_sort"]),
262
+ ];
263
+ const settings: RawSettings = {};
264
+ const keys: SettingKeys = {};
265
+ for (const item of found) {
266
+ if (item.value !== undefined) settings[item.canonical] = item.value;
267
+ if (item.key !== undefined) keys[item.canonical] = item.key;
268
+ }
269
+ return { settings, keys };
270
+ }
271
+
272
+ function readTomlFile(path: string): FileSettings {
273
+ const parsed = parseTomlFile(path);
274
+ return settingsFromParsed(parsed);
242
275
  }
243
276
 
244
277
  /**
@@ -247,12 +280,17 @@ function readTomlFile(path: string): RawSettings {
247
280
  * listing those slugs without reading their keys would only produce a
248
281
  * "no stored credential" error for a workspace the user can see in the file.
249
282
  */
250
- function readReferenceCredentials(path: string): { workspaces: string[]; defaultWorkspace?: string } {
283
+ function readReferenceCredentials(path: string): {
284
+ workspaces: string[];
285
+ defaultWorkspace?: string;
286
+ } {
251
287
  if (!existsSync(path)) return { workspaces: [] };
252
288
  const parsed = parseTomlFile(path);
253
289
  const list = parsed.workspaces;
254
290
  if (!Array.isArray(list)) return { workspaces: [] };
255
- const workspaces = [...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0))];
291
+ const workspaces = [
292
+ ...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0)),
293
+ ];
256
294
  const def = asString(parsed.default);
257
295
  return {
258
296
  workspaces,
@@ -265,6 +303,7 @@ function readReferenceCredentials(path: string): { workspaces: string[]; default
265
303
  function readUserConfig(path: string): UserConfig {
266
304
  const workspaces: Record<string, WorkspaceEntry> = {};
267
305
  const parsed = existsSync(path) ? parseTomlFile(path) : {};
306
+ const fileSettings = settingsFromParsed(parsed);
268
307
  const wsTable = parsed.workspaces;
269
308
  if (wsTable && typeof wsTable === "object") {
270
309
  for (const [slug, val] of Object.entries(wsTable as Record<string, unknown>)) {
@@ -280,12 +319,8 @@ function readUserConfig(path: string): UserConfig {
280
319
  const reference = readReferenceCredentials(join(dirname(path), "credentials.toml"));
281
320
  for (const slug of reference.workspaces) workspaces[slug] ??= {};
282
321
  return {
283
- settings: {
284
- team: asString(parsed.team ?? parsed.team_id),
285
- workspace: asString(parsed.workspace),
286
- sort: asString(parsed.sort ?? parsed.issue_sort),
287
- vcs: asString(parsed.vcs),
288
- },
322
+ settings: fileSettings.settings,
323
+ settingKeys: fileSettings.keys,
289
324
  defaultWorkspace: asString(parsed.default_workspace) ?? reference.defaultWorkspace,
290
325
  workspaces,
291
326
  };
@@ -353,7 +388,6 @@ function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
353
388
  team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
354
389
  workspace: env.LINEAR_WORKSPACE,
355
390
  sort: env.LINEAR_ISSUE_SORT,
356
- vcs: env.LINEAR_VCS,
357
391
  };
358
392
  }
359
393
 
@@ -366,12 +400,12 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
366
400
  const userPath = userConfigPath(env);
367
401
  const user = readUserConfig(userPath);
368
402
  const projectPath = findProjectConfig(cwd);
369
- const projectSettings = projectPath ? readTomlFile(projectPath) : {};
403
+ const project = projectPath ? readTomlFile(projectPath) : { settings: {}, keys: {} };
370
404
  // The reference CLI's global file: same non-secret reader as a project file,
371
405
  // so its `api_key` (it does allow one there) is never picked up.
372
406
  const globalCandidate = globalConfigPath(env);
373
407
  const globalPath = existsSync(globalCandidate) ? globalCandidate : undefined;
374
- const globalSettings = globalPath ? readTomlFile(globalPath) : {};
408
+ const global = globalPath ? readTomlFile(globalPath) : { settings: {}, keys: {} };
375
409
 
376
410
  // ----- API key resolution (strict trust boundary) -----------------------
377
411
  // Resolution is TOTAL: selection problems are stashed in `apiKeyError` rather
@@ -395,7 +429,10 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
395
429
  // already-stored credential by its non-secret slug, but cannot supply a
396
430
  // key: lookupCredential only reads the user config and OS keyring.
397
431
  const selected =
398
- flags.workspace ?? env.LINEAR_WORKSPACE ?? projectSettings.workspace ?? user.defaultWorkspace;
432
+ flags.workspace ??
433
+ env.LINEAR_WORKSPACE ??
434
+ project.settings.workspace ??
435
+ user.defaultWorkspace;
399
436
 
400
437
  // Apply a lookup result for the slug the selection settled on. An
401
438
  // explicitly named slug is probed even when no file lists it — the keyring
@@ -438,27 +475,30 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
438
475
 
439
476
  // ----- Non-secret settings (project + global config MAY participate) -----
440
477
  // flag > env > project file > our user file > the reference CLI's global file.
441
- const tiers: Array<[ConfigSource, RawSettings, string | undefined]> = [
442
- ["flag", flags, undefined],
443
- ["env", envSettings, undefined],
444
- ["project", projectSettings, projectPath],
445
- ["user", user.settings, userPath],
446
- ["global", globalSettings, globalPath],
478
+ const tiers: Array<[ConfigSource, RawSettings, string | undefined, SettingKeys | undefined]> = [
479
+ ["flag", flags, undefined, undefined],
480
+ ["env", envSettings, undefined, undefined],
481
+ ["project", project.settings, projectPath, project.keys],
482
+ ["user", user.settings, userPath, user.settingKeys],
483
+ ["global", global.settings, globalPath, global.keys],
447
484
  ];
448
- const pick = <K extends "team" | "workspace" | "sort" | "vcs">(key: K): string | undefined =>
485
+ const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
449
486
  tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
450
487
 
451
488
  /** Which tier `pick` took the value from, and which file if it was one. */
452
- const originOf = (key: "team" | "workspace" | "sort" | "vcs"): SettingOrigin => {
489
+ const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
453
490
  const hit = tiers.find(([, settings]) => settings[key] !== undefined);
454
- return hit ? { source: hit[0], path: hit[2] } : { source: "none" };
491
+ if (!hit) return { source: "none" };
492
+ const origin: SettingOrigin = { source: hit[0], path: hit[2] };
493
+ const spelling = hit[3]?.[key];
494
+ if (spelling !== undefined) origin.key = spelling;
495
+ return origin;
455
496
  };
456
497
 
457
498
  const origins: SettingOrigins = {
458
499
  team: originOf("team"),
459
500
  workspace: originOf("workspace"),
460
501
  sort: originOf("sort"),
461
- vcs: originOf("vcs"),
462
502
  };
463
503
 
464
504
  return {
@@ -472,7 +512,6 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
472
512
  workspace: pick("workspace"),
473
513
  sort: pick("sort") ?? "priority",
474
514
  sortSource: origins.sort.source,
475
- vcs: pick("vcs") ?? "git",
476
515
  origins,
477
516
  userConfigPath: userPath,
478
517
  projectConfigPath: projectPath,
@@ -495,6 +534,184 @@ function readUserObject(path: string): Record<string, unknown> {
495
534
  return parseTomlFile(path);
496
535
  }
497
536
 
537
+ // A rename makes each individual write atomic, but does not make a whole
538
+ // read/modify/write transaction atomic: two processes can read the same old
539
+ // object and then replace one another's changes. User-config mutations take a
540
+ // short-lived advisory lock around that entire transaction. Reads remain
541
+ // lock-free and continue to see either the old complete file or the new one.
542
+ const USER_CONFIG_LOCK_TIMEOUT_MS = 5_000;
543
+ const USER_CONFIG_LOCK_STALE_MS = 30_000;
544
+ const USER_CONFIG_LOCK_POLL_MS = 20;
545
+ const LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
546
+
547
+ interface AdvisoryLock {
548
+ path: string;
549
+ fd: number;
550
+ }
551
+
552
+ interface LockSnapshot {
553
+ dev: number;
554
+ ino: number;
555
+ mtimeMs: number;
556
+ pid?: number;
557
+ }
558
+
559
+ function errorCode(err: unknown): string | undefined {
560
+ return (err as NodeJS.ErrnoException)?.code;
561
+ }
562
+
563
+ /** Create one exclusive lock marker, returning undefined when it already exists. */
564
+ function createLockMarker(path: string): AdvisoryLock | undefined {
565
+ let fd: number;
566
+ try {
567
+ fd = openSync(path, "wx", 0o600);
568
+ } catch (err) {
569
+ if (errorCode(err) === "EEXIST") return undefined;
570
+ throw err;
571
+ }
572
+ try {
573
+ writeFileSync(
574
+ fd,
575
+ JSON.stringify({
576
+ pid: process.pid,
577
+ createdAt: Date.now(),
578
+ token: randomBytes(12).toString("hex"),
579
+ }) + "\n",
580
+ "utf8",
581
+ );
582
+ fsyncSync(fd);
583
+ return { path, fd };
584
+ } catch (err) {
585
+ releaseLockMarker({ path, fd });
586
+ throw err;
587
+ }
588
+ }
589
+
590
+ /** Release only the path that still names our inode, never a successor's lock. */
591
+ function releaseLockMarker(lock: AdvisoryLock): void {
592
+ try {
593
+ const held = fstatSync(lock.fd);
594
+ try {
595
+ const current = statSync(lock.path);
596
+ if (current.dev === held.dev && current.ino === held.ino) unlinkSync(lock.path);
597
+ } catch (err) {
598
+ if (errorCode(err) !== "ENOENT") throw err;
599
+ }
600
+ } finally {
601
+ closeSync(lock.fd);
602
+ }
603
+ }
604
+
605
+ /** Open a lock marker as one immutable inode and read its owner metadata. */
606
+ function readLockSnapshot(path: string): LockSnapshot | undefined {
607
+ let fd: number;
608
+ try {
609
+ fd = openSync(path, "r");
610
+ } catch (err) {
611
+ if (errorCode(err) === "ENOENT") return undefined;
612
+ throw err;
613
+ }
614
+ try {
615
+ const stat = fstatSync(fd);
616
+ let pid: number | undefined;
617
+ try {
618
+ const parsed = JSON.parse(readFileSync(fd, "utf8")) as { pid?: unknown };
619
+ if (Number.isSafeInteger(parsed.pid) && (parsed.pid as number) > 0) {
620
+ pid = parsed.pid as number;
621
+ }
622
+ } catch {
623
+ // A creator can briefly expose an empty marker before writing metadata.
624
+ // It is considered active until its mtime crosses the stale bound.
625
+ }
626
+ return { dev: stat.dev, ino: stat.ino, mtimeMs: stat.mtimeMs, pid };
627
+ } finally {
628
+ closeSync(fd);
629
+ }
630
+ }
631
+
632
+ function processIsAlive(pid: number): boolean {
633
+ try {
634
+ process.kill(pid, 0);
635
+ return true;
636
+ } catch (err) {
637
+ // EPERM still proves the process exists; ESRCH (and an invalid/out-of-range
638
+ // PID from a corrupt marker) means nobody can own this lock now.
639
+ return errorCode(err) === "EPERM";
640
+ }
641
+ }
642
+
643
+ function lockIsStale(snapshot: LockSnapshot): boolean {
644
+ if (snapshot.pid !== undefined) return !processIsAlive(snapshot.pid);
645
+ return Date.now() - snapshot.mtimeMs >= USER_CONFIG_LOCK_STALE_MS;
646
+ }
647
+
648
+ /** Unlink only if the path still names the stale inode we inspected. */
649
+ function unlinkSnapshot(path: string, snapshot: LockSnapshot): boolean {
650
+ try {
651
+ const current = statSync(path);
652
+ if (current.dev !== snapshot.dev || current.ino !== snapshot.ino) return false;
653
+ unlinkSync(path);
654
+ return true;
655
+ } catch (err) {
656
+ if (errorCode(err) === "ENOENT") return false;
657
+ throw err;
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Reap a dead owner's lock under a second exclusive marker. The reaper marker
663
+ * prevents several waiting writers from deleting a newly acquired successor
664
+ * while they all race to clean up the same stale inode.
665
+ */
666
+ function reapStaleLock(path: string): boolean {
667
+ const candidate = readLockSnapshot(path);
668
+ if (!candidate || !lockIsStale(candidate)) return false;
669
+
670
+ const reapPath = `${path}.reap`;
671
+ const reaper = createLockMarker(reapPath);
672
+ if (!reaper) {
673
+ // A reaper can itself die in its tiny critical section. Recover it with
674
+ // the same PID/age rules; a later poll will then claim it normally.
675
+ const staleReaper = readLockSnapshot(reapPath);
676
+ if (staleReaper && lockIsStale(staleReaper)) unlinkSnapshot(reapPath, staleReaper);
677
+ return false;
678
+ }
679
+ try {
680
+ const snapshot = readLockSnapshot(path);
681
+ return snapshot !== undefined && lockIsStale(snapshot) && unlinkSnapshot(path, snapshot);
682
+ } finally {
683
+ releaseLockMarker(reaper);
684
+ }
685
+ }
686
+
687
+ function acquireUserConfigLock(path: string): AdvisoryLock {
688
+ const lockPath = `${path}.lock`;
689
+ mkdirSync(dirname(path), { recursive: true });
690
+ const deadline = performance.now() + USER_CONFIG_LOCK_TIMEOUT_MS;
691
+ while (true) {
692
+ const lock = createLockMarker(lockPath);
693
+ if (lock) return lock;
694
+ if (reapStaleLock(lockPath)) continue;
695
+ const remaining = deadline - performance.now();
696
+ if (remaining <= 0) {
697
+ throw new CliError(
698
+ `Timed out waiting for another process to finish updating ${sanitize(path, 512)}. Retry the command once that config write completes.`,
699
+ "runtime",
700
+ );
701
+ }
702
+ Atomics.wait(LOCK_SLEEP, 0, 0, Math.min(USER_CONFIG_LOCK_POLL_MS, remaining));
703
+ }
704
+ }
705
+
706
+ function withUserConfigLock<T>(path: string, mutate: () => T): T {
707
+ const lock = acquireUserConfigLock(path);
708
+ try {
709
+ return mutate();
710
+ } finally {
711
+ releaseLockMarker(lock);
712
+ }
713
+ }
714
+
498
715
  /**
499
716
  * Persist text ATOMICALLY, with the given perms.
500
717
  *
@@ -560,6 +777,91 @@ export interface WriteCredentialResult {
560
777
  keyringLabel?: string;
561
778
  }
562
779
 
780
+ /** A single, explicitly named credential found in the shared OS keyring. */
781
+ export interface KeyringCredentialProbe {
782
+ /** Kept in memory only; callers must never print it or pass it on argv. */
783
+ apiKey: string;
784
+ keyringLabel: string;
785
+ }
786
+
787
+ /**
788
+ * Read exactly one workspace account from the shared keyring.
789
+ *
790
+ * This is intentionally not a discovery API: keyring enumeration can expose
791
+ * unrelated account names and secrets. `auth adopt <slug>` asks the user for
792
+ * the non-secret account name and probes only service `linear-cli` / that
793
+ * account, matching the reference CLI's storage convention.
794
+ */
795
+ export function probeKeyringCredential(slug: string): KeyringCredentialProbe {
796
+ const backend = keyring();
797
+ if (!backend) {
798
+ throw new CliError(
799
+ `No usable system keyring is available, so workspace '${sanitize(slug)}' cannot be adopted. Run \`linear auth login --key -\` to read the key from stdin instead.`,
800
+ "runtime",
801
+ );
802
+ }
803
+ let apiKey: string | null;
804
+ try {
805
+ apiKey = backend.get(slug);
806
+ } catch (err) {
807
+ if (err instanceof KeyringError) {
808
+ throw new CliError(
809
+ `Could not read the ${backend.label} entry for workspace '${sanitize(slug)}': ${err.message}`,
810
+ "runtime",
811
+ );
812
+ }
813
+ throw err;
814
+ }
815
+ if (!apiKey) {
816
+ throw new CliError(
817
+ `No ${backend.label} credential exists for workspace '${sanitize(slug)}' (service linear-cli, account '${sanitize(slug)}'). Run \`linear auth login --key -\` to store one from stdin.`,
818
+ "not_found",
819
+ );
820
+ }
821
+ return { apiKey, keyringLabel: backend.label };
822
+ }
823
+
824
+ export interface AdoptCredentialResult {
825
+ path: string;
826
+ storage: "keychain";
827
+ keyringLabel: string;
828
+ }
829
+
830
+ /**
831
+ * Record a previously validated, explicitly named keyring credential in our
832
+ * workspace list. The secret remains in the keyring and is never serialized.
833
+ * A second exact-name probe inside the config lock prevents a changed or
834
+ * removed key from being adopted after validation.
835
+ */
836
+ export function adoptKeyringCredential(
837
+ slug: string,
838
+ expectedApiKey: string,
839
+ ): AdoptCredentialResult {
840
+ const path = userConfigPath();
841
+ return withUserConfigLock(path, () => {
842
+ const found = probeKeyringCredential(slug);
843
+ if (found.apiKey !== expectedApiKey) {
844
+ throw new CliError(
845
+ `The ${found.keyringLabel} credential for workspace '${sanitize(slug)}' changed while it was being validated. Retry the adopt command.`,
846
+ "runtime",
847
+ );
848
+ }
849
+
850
+ const obj = readUserObject(path);
851
+ const ws = workspacesTable(obj);
852
+ const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
853
+ delete table.api_key;
854
+ table.keyring = true;
855
+ ws[slug] = table;
856
+ if (!asString(obj.default_workspace)) {
857
+ const reference = readReferenceCredentials(referenceCredentialsPath());
858
+ if (!reference.defaultWorkspace) obj.default_workspace = slug;
859
+ }
860
+ writeUserObject(path, obj);
861
+ return { path, storage: "keychain", keyringLabel: found.keyringLabel };
862
+ });
863
+ }
864
+
563
865
  /**
564
866
  * Store a credential for `slug`. By default the secret goes to the OS keyring
565
867
  * (service `linear-cli`, account = slug) and the file only records
@@ -576,47 +878,51 @@ export function writeCredential(
576
878
  opts: { plaintext?: boolean } = {},
577
879
  ): WriteCredentialResult {
578
880
  const path = userConfigPath();
579
- const backend = opts.plaintext ? null : keyring();
580
- const obj = readUserObject(path);
581
- const ws = workspacesTable(obj);
582
- const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
583
- let storage: CredentialStorage;
584
- if (backend) {
585
- // Keyring first: if it refuses, the file is untouched and the error is loud.
586
- backend.set(slug, apiKey);
587
- delete table.api_key;
588
- table.keyring = true;
589
- storage = "keychain";
590
- } else {
591
- delete table.keyring;
592
- table.api_key = apiKey;
593
- storage = "file";
594
- }
595
- ws[slug] = table;
596
- if (!asString(obj.default_workspace)) {
597
- // Leave the reference CLI's default in charge if it has one; writing ours
598
- // over it would silently switch a migrating user's workspace.
599
- const reference = readReferenceCredentials(referenceCredentialsPath());
600
- if (!reference.defaultWorkspace) obj.default_workspace = slug;
601
- }
602
- writeUserObject(path, obj);
603
- return { path, storage, keyringLabel: backend?.label };
881
+ return withUserConfigLock(path, () => {
882
+ const backend = opts.plaintext ? null : keyring();
883
+ const obj = readUserObject(path);
884
+ const ws = workspacesTable(obj);
885
+ const table: WorkspaceTable = { ...(ws[slug] ?? {}) };
886
+ let storage: CredentialStorage;
887
+ if (backend) {
888
+ // Keyring first: if it refuses, the file is untouched and the error is loud.
889
+ backend.set(slug, apiKey);
890
+ delete table.api_key;
891
+ table.keyring = true;
892
+ storage = "keychain";
893
+ } else {
894
+ delete table.keyring;
895
+ table.api_key = apiKey;
896
+ storage = "file";
897
+ }
898
+ ws[slug] = table;
899
+ if (!asString(obj.default_workspace)) {
900
+ // Leave the reference CLI's default in charge if it has one; writing ours
901
+ // over it would silently switch a migrating user's workspace.
902
+ const reference = readReferenceCredentials(referenceCredentialsPath());
903
+ if (!reference.defaultWorkspace) obj.default_workspace = slug;
904
+ }
905
+ writeUserObject(path, obj);
906
+ return { path, storage, keyringLabel: backend?.label };
907
+ });
604
908
  }
605
909
 
606
910
  /** Set `default_workspace`. Errors if that workspace is not configured anywhere. */
607
911
  export function setDefaultWorkspace(slug: string): string {
608
912
  const path = userConfigPath();
609
- const known = readUserConfig(path).workspaces;
610
- if (!known[slug]) {
611
- throw new CliError(
612
- `Workspace '${slug}' is not configured. Run \`linear auth login --workspace ${slug}\` first.`,
613
- "not_found",
614
- );
615
- }
616
- const obj = readUserObject(path);
617
- obj.default_workspace = slug;
618
- writeUserObject(path, obj);
619
- return path;
913
+ return withUserConfigLock(path, () => {
914
+ const known = readUserConfig(path).workspaces;
915
+ if (!known[slug]) {
916
+ throw new CliError(
917
+ `Workspace '${slug}' is not configured. Run \`linear auth login --workspace ${slug}\` first.`,
918
+ "not_found",
919
+ );
920
+ }
921
+ const obj = readUserObject(path);
922
+ obj.default_workspace = slug;
923
+ writeUserObject(path, obj);
924
+ return path;
925
+ });
620
926
  }
621
927
 
622
928
  /**
@@ -646,31 +952,33 @@ function removeFromReferenceCredentials(slug: string): boolean {
646
952
  */
647
953
  export function removeCredential(slug: string): boolean {
648
954
  const path = userConfigPath();
649
- let removed = false;
650
-
651
- const backend = keyring();
652
- if (backend && backend.delete(slug)) removed = true;
653
- if (removeFromReferenceCredentials(slug)) removed = true;
654
-
655
- if (!existsSync(path)) return removed;
656
- const obj = readUserObject(path);
657
- const ws = workspacesTable(obj);
658
- const listed = Boolean(ws[slug]);
659
- if (listed) {
660
- delete ws[slug];
661
- removed = true;
662
- }
663
- if (Object.keys(ws).length === 0) delete obj.workspaces;
664
-
665
- if (asString(obj.default_workspace) === slug) {
666
- const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
667
- if (remaining.length > 0) obj.default_workspace = remaining[0]!;
668
- else delete obj.default_workspace;
669
- writeUserObject(path, obj);
670
- } else if (listed) {
671
- writeUserObject(path, obj);
672
- }
673
- return removed;
955
+ return withUserConfigLock(path, () => {
956
+ let removed = false;
957
+
958
+ const backend = keyring();
959
+ if (backend && backend.delete(slug)) removed = true;
960
+ if (removeFromReferenceCredentials(slug)) removed = true;
961
+
962
+ if (!existsSync(path)) return removed;
963
+ const obj = readUserObject(path);
964
+ const ws = workspacesTable(obj);
965
+ const listed = Boolean(ws[slug]);
966
+ if (listed) {
967
+ delete ws[slug];
968
+ removed = true;
969
+ }
970
+ if (Object.keys(ws).length === 0) delete obj.workspaces;
971
+
972
+ if (asString(obj.default_workspace) === slug) {
973
+ const remaining = obj.workspaces ? Object.keys(obj.workspaces as object) : [];
974
+ if (remaining.length > 0) obj.default_workspace = remaining[0]!;
975
+ else delete obj.default_workspace;
976
+ writeUserObject(path, obj);
977
+ } else if (listed) {
978
+ writeUserObject(path, obj);
979
+ }
980
+ return removed;
981
+ });
674
982
  }
675
983
 
676
984
  export interface MigrateResult {
@@ -694,36 +1002,38 @@ export function migrateCredentials(): MigrateResult {
694
1002
  "runtime",
695
1003
  );
696
1004
  }
697
- const obj = readUserObject(path);
698
- const ws = workspacesTable(obj);
699
- const plaintext = Object.entries(ws).filter(([, t]) => asString(t.api_key));
700
- const migrated: string[] = [];
701
- try {
702
- for (const [slug, table] of plaintext) {
703
- backend.set(slug, table.api_key!);
704
- migrated.push(slug);
705
- }
706
- } catch (err) {
707
- for (const slug of migrated) {
708
- try {
709
- backend.delete(slug);
710
- } catch {
711
- // Best-effort rollback.
1005
+ return withUserConfigLock(path, () => {
1006
+ const obj = readUserObject(path);
1007
+ const ws = workspacesTable(obj);
1008
+ const plaintext = Object.entries(ws).filter(([, t]) => asString(t.api_key));
1009
+ const migrated: string[] = [];
1010
+ try {
1011
+ for (const [slug, table] of plaintext) {
1012
+ backend.set(slug, table.api_key!);
1013
+ migrated.push(slug);
1014
+ }
1015
+ } catch (err) {
1016
+ for (const slug of migrated) {
1017
+ try {
1018
+ backend.delete(slug);
1019
+ } catch {
1020
+ // Best-effort rollback.
1021
+ }
712
1022
  }
1023
+ throw new CliError(
1024
+ `Failed to store the key for workspace '${plaintext[migrated.length]![0]}' in the ${backend.label}: ${(err as Error).message}. Rolled back ${migrated.length} already-written entr${migrated.length === 1 ? "y" : "ies"}; ${path} is unchanged.`,
1025
+ "runtime",
1026
+ );
713
1027
  }
714
- throw new CliError(
715
- `Failed to store the key for workspace '${plaintext[migrated.length]![0]}' in the ${backend.label}: ${(err as Error).message}. Rolled back ${migrated.length} already-written entr${migrated.length === 1 ? "y" : "ies"}; ${path} is unchanged.`,
716
- "runtime",
717
- );
718
- }
719
- if (migrated.length > 0) {
720
- for (const [, table] of plaintext) {
721
- delete table.api_key;
722
- table.keyring = true;
1028
+ if (migrated.length > 0) {
1029
+ for (const [, table] of plaintext) {
1030
+ delete table.api_key;
1031
+ table.keyring = true;
1032
+ }
1033
+ writeUserObject(path, obj);
723
1034
  }
724
- writeUserObject(path, obj);
725
- }
726
- return { migrated, path, keyringLabel: backend.label };
1035
+ return { migrated, path, keyringLabel: backend.label };
1036
+ });
727
1037
  }
728
1038
 
729
1039
  export interface CredentialEntry {
@@ -763,7 +1073,7 @@ export function redactKey(key: string | undefined): string {
763
1073
  // ---------------------------------------------------------------------------
764
1074
 
765
1075
  /** The settings `config set` / `config init` will write to a project file. */
766
- export const SETTABLE_KEYS = ["team", "workspace", "sort", "vcs"] as const;
1076
+ export const SETTABLE_KEYS = ["team", "workspace", "sort"] as const;
767
1077
  export type SettableKey = (typeof SETTABLE_KEYS)[number];
768
1078
 
769
1079
  /**
@@ -775,7 +1085,6 @@ const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
775
1085
  team: ["team", "team_id"],
776
1086
  workspace: ["workspace"],
777
1087
  sort: ["sort", "issue_sort"],
778
- vcs: ["vcs"],
779
1088
  };
780
1089
 
781
1090
  /** Keys that must never be written to a project file, and why. */
@@ -832,64 +1141,72 @@ export function setConfigKey(
832
1141
  opts: { mode?: number } = {},
833
1142
  ): string {
834
1143
  const mode = opts.mode ?? 0o644;
835
- const text = existsSync(path) ? readFileSync(path, "utf8") : "";
836
- // Parse first so a broken file is reported as such rather than edited blind.
837
- if (text.trim()) parseTomlFile(path);
838
-
839
- const lines = text.split("\n");
840
- // Only the top-level region (before the first table header) is ours to
841
- // touch: `api_key = …` inside `[workspaces.x]` must not be mistaken for a
842
- // top-level key of the same name.
843
- const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
844
- const topEnd = firstTable === -1 ? lines.length : firstTable;
845
- const spellings = KEY_ALIASES[key];
846
- const keyLine = new RegExp(`^\\s*(${spellings.map((s) => s.replace(".", "\\.")).join("|")})\\s*=`);
847
- const idx = lines.findIndex((l, i) => i < topEnd && keyLine.test(l));
848
-
849
- let spelling: string;
850
- let next: string[];
851
- if (idx !== -1) {
852
- spelling = lines[idx]!.match(keyLine)![1]!;
853
- // Keep a trailing comment on the line, if any.
854
- const comment = lines[idx]!.match(/\s+#.*$/)?.[0] ?? "";
855
- next = [...lines];
856
- next[idx] = `${spelling} = ${tomlString(value)}${comment}`;
857
- } else {
858
- spelling = key;
859
- next = [...lines];
860
- // Drop the trailing empty line a `\n`-terminated file splits into (the
861
- // file is re-terminated below), then insert right after the last
862
- // non-blank top-level line — before any blank line that separates the
863
- // top-level keys from the first table. No alias line exists up there (we
864
- // searched), so the insert is unambiguous.
865
- if (next[next.length - 1] === "") next.pop();
866
- let at = firstTable === -1 ? next.length : firstTable;
867
- while (at > 0 && next[at - 1]!.trim() === "") at--;
868
- const inserted = [`${spelling} = ${tomlString(value)}`];
869
- // A file that opens with a table needs a blank line between us and it.
870
- if (at < next.length && /^\s*\[/.test(next[at]!)) inserted.push("");
871
- next.splice(at, 0, ...inserted);
872
- }
873
- let body = next.join("\n");
874
- if (!body.endsWith("\n")) body += "\n";
1144
+ const mutate = (): string => {
1145
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
1146
+ // Parse first so a broken file is reported as such rather than edited blind.
1147
+ if (text.trim()) parseTomlFile(path);
1148
+
1149
+ const lines = text.split("\n");
1150
+ // Only the top-level region (before the first table header) is ours to
1151
+ // touch: `api_key = …` inside `[workspaces.x]` must not be mistaken for a
1152
+ // top-level key of the same name.
1153
+ const firstTable = lines.findIndex((l) => /^\s*\[/.test(l));
1154
+ const topEnd = firstTable === -1 ? lines.length : firstTable;
1155
+ const spellings = KEY_ALIASES[key];
1156
+ const keyLine = new RegExp(
1157
+ `^\\s*(${spellings.map((s) => s.replace(".", "\\.")).join("|")})\\s*=`,
1158
+ );
1159
+ const idx = lines.findIndex((l, i) => i < topEnd && keyLine.test(l));
1160
+
1161
+ let spelling: string;
1162
+ let next: string[];
1163
+ if (idx !== -1) {
1164
+ spelling = lines[idx]!.match(keyLine)![1]!;
1165
+ // Keep a trailing comment on the line, if any.
1166
+ const comment = lines[idx]!.match(/\s+#.*$/)?.[0] ?? "";
1167
+ next = [...lines];
1168
+ next[idx] = `${spelling} = ${tomlString(value)}${comment}`;
1169
+ } else {
1170
+ spelling = key;
1171
+ next = [...lines];
1172
+ // Drop the trailing empty line a `\n`-terminated file splits into (the
1173
+ // file is re-terminated below), then insert right after the last
1174
+ // non-blank top-level line — before any blank line that separates the
1175
+ // top-level keys from the first table. No alias line exists up there (we
1176
+ // searched), so the insert is unambiguous.
1177
+ if (next[next.length - 1] === "") next.pop();
1178
+ let at = firstTable === -1 ? next.length : firstTable;
1179
+ while (at > 0 && next[at - 1]!.trim() === "") at--;
1180
+ const inserted = [`${spelling} = ${tomlString(value)}`];
1181
+ // A file that opens with a table needs a blank line between us and it.
1182
+ if (at < next.length && /^\s*\[/.test(next[at]!)) inserted.push("");
1183
+ next.splice(at, 0, ...inserted);
1184
+ }
1185
+ let body = next.join("\n");
1186
+ if (!body.endsWith("\n")) body += "\n";
875
1187
 
876
- // Prove the edit: the parsed file must carry exactly this value under this
877
- // spelling. If a layout we did not foresee defeats the line edit, fall back
878
- // to a full round-trip — correct, if less pretty.
879
- let parsed: Record<string, unknown> | undefined;
880
- try {
881
- parsed = parseToml(body) as Record<string, unknown>;
882
- } catch {
883
- parsed = undefined;
884
- }
885
- if (!parsed || parsed[spelling] !== value) {
886
- const obj = text.trim() ? parseTomlFile(path) : {};
887
- for (const s of spellings) if (s !== spelling) delete obj[s];
888
- obj[spelling] = value;
889
- body = stringifyToml(obj) + "\n";
890
- }
891
- writeFileAtomic(path, body, mode);
892
- return spelling;
1188
+ // Prove the edit: the parsed file must carry exactly this value under this
1189
+ // spelling. If a layout we did not foresee defeats the line edit, fall back
1190
+ // to a full round-trip — correct, if less pretty.
1191
+ let parsed: Record<string, unknown> | undefined;
1192
+ try {
1193
+ parsed = parseToml(body) as Record<string, unknown>;
1194
+ } catch {
1195
+ parsed = undefined;
1196
+ }
1197
+ if (!parsed || parsed[spelling] !== value) {
1198
+ const obj = text.trim() ? parseTomlFile(path) : {};
1199
+ for (const s of spellings) if (s !== spelling) delete obj[s];
1200
+ obj[spelling] = value;
1201
+ body = stringifyToml(obj) + "\n";
1202
+ }
1203
+ writeFileAtomic(path, body, mode);
1204
+ return spelling;
1205
+ };
1206
+
1207
+ // `config set --user` passes 0600 because this file can contain credentials;
1208
+ // serialize that read/line-edit/rename with the credential writers too.
1209
+ return mode === 0o600 ? withUserConfigLock(path, mutate) : mutate();
893
1210
  }
894
1211
 
895
1212
  /**