linear-sdk-cli 0.1.0 → 0.1.2

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 (71) hide show
  1. package/CHANGELOG.md +46 -46
  2. package/COVERAGE.md +5 -5
  3. package/MIGRATING.md +69 -71
  4. package/README.md +88 -96
  5. package/package.json +8 -9
  6. package/skills/linear-sdk-cli/SKILL.md +3 -13
  7. package/skills/linear-sdk-cli/references/api.md +9 -9
  8. package/skills/linear-sdk-cli/references/auth.md +0 -18
  9. package/skills/linear-sdk-cli/references/commands.md +2 -2
  10. package/skills/linear-sdk-cli/references/config.md +11 -9
  11. package/skills/linear-sdk-cli/references/issue.md +12 -63
  12. package/skills/linear-sdk-cli/references/team.md +0 -16
  13. package/skills/linear-sdk-cli/scripts/generate-docs.ts +2 -6
  14. package/src/bin/linear.ts +6 -16
  15. package/src/cli.ts +1 -3
  16. package/src/commands/agent-session.ts +5 -13
  17. package/src/commands/api.ts +9 -15
  18. package/src/commands/attach.ts +4 -15
  19. package/src/commands/comment.ts +2 -6
  20. package/src/commands/completion.ts +6 -2
  21. package/src/commands/cycle.ts +1 -6
  22. package/src/commands/discover.ts +2 -6
  23. package/src/commands/document.ts +1 -4
  24. package/src/commands/initiative.ts +1 -5
  25. package/src/commands/issue.ts +88 -376
  26. package/src/commands/meta.ts +31 -139
  27. package/src/commands/milestone.ts +1 -4
  28. package/src/commands/notification.ts +6 -15
  29. package/src/commands/project.ts +4 -17
  30. package/src/commands/team.ts +0 -13
  31. package/src/commands/webhook.ts +2 -12
  32. package/src/config.ts +183 -500
  33. package/src/context.ts +4 -13
  34. package/src/git.ts +3 -28
  35. package/src/lib/body.ts +2 -26
  36. package/src/lib/errors.ts +7 -32
  37. package/src/lib/introspect.ts +7 -76
  38. package/src/lib/keyring.ts +9 -51
  39. package/src/lib/options.ts +12 -108
  40. package/src/lib/output-shapes.ts +9 -81
  41. package/src/lib/pagination.ts +17 -50
  42. package/src/lib/resolve.ts +7 -40
  43. package/src/lib/shape.ts +12 -16
  44. package/src/lib/upload.ts +1 -3
  45. package/src/output/format.ts +18 -96
  46. package/src/output/table.ts +7 -7
  47. package/src/services/agent-session.ts +3 -11
  48. package/src/services/attachment.ts +5 -14
  49. package/src/services/comment.ts +4 -12
  50. package/src/services/cycle.ts +4 -11
  51. package/src/services/document.ts +10 -29
  52. package/src/services/favorite.ts +9 -15
  53. package/src/services/initiative-update.ts +15 -16
  54. package/src/services/initiative.ts +10 -15
  55. package/src/services/issue.ts +18 -262
  56. package/src/services/label.ts +2 -12
  57. package/src/services/milestone.ts +5 -8
  58. package/src/services/notification.ts +2 -16
  59. package/src/services/organization.ts +18 -23
  60. package/src/services/project.ts +34 -55
  61. package/src/services/roadmap.ts +6 -7
  62. package/src/services/state.ts +3 -9
  63. package/src/services/team.ts +24 -67
  64. package/src/services/user.ts +2 -2
  65. package/src/services/webhook.ts +17 -28
  66. package/skills/linear-sdk-cli/references/open.md +0 -29
  67. package/src/commands/open.ts +0 -84
  68. package/src/lib/open.ts +0 -32
  69. package/src/output/color.ts +0 -52
  70. package/src/output/markdown.ts +0 -133
  71. package/src/output/pager.ts +0 -131
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): flag > env > project
5
+ * - Non-secret settings (team, workspace, sort, vcs): 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,10 +40,8 @@ import {
40
40
  chmodSync,
41
41
  openSync,
42
42
  closeSync,
43
- fstatSync,
44
43
  fsyncSync,
45
44
  renameSync,
46
- statSync,
47
45
  unlinkSync,
48
46
  } from "node:fs";
49
47
  import { execFileSync } from "node:child_process";
@@ -55,6 +53,7 @@ export interface RawSettings {
55
53
  team?: string;
56
54
  workspace?: string;
57
55
  sort?: string;
56
+ vcs?: string;
58
57
  apiKey?: string;
59
58
  }
60
59
 
@@ -68,16 +67,14 @@ export type ConfigSource = "flag" | "env" | "project" | "user" | "global" | "key
68
67
  /** Where a stored workspace credential's secret lives. */
69
68
  export type CredentialStorage = "file" | "keychain";
70
69
 
71
- /** A setting's provenance: which tier, and (for a file) which file/key. */
70
+ /** A setting's provenance: which tier, and (for a file) which file. */
72
71
  export interface SettingOrigin {
73
72
  source: ConfigSource;
74
73
  path?: string;
75
- /** Exact spelling found in the file (`issue_sort`, not just canonical `sort`). */
76
- key?: string;
77
74
  }
78
75
 
79
76
  /** The non-secret settings, each with its provenance. */
80
- export type SettingOrigins = Record<"team" | "workspace" | "sort", SettingOrigin>;
77
+ export type SettingOrigins = Record<"team" | "workspace" | "sort" | "vcs", SettingOrigin>;
81
78
 
82
79
  export interface ResolvedConfig {
83
80
  apiKey?: string;
@@ -98,6 +95,7 @@ export interface ResolvedConfig {
98
95
  sort: string;
99
96
  /** Where `sort` came from, so an invalid value can be blamed precisely. */
100
97
  sortSource: ConfigSource;
98
+ vcs: string;
101
99
  /** Provenance of every non-secret setting (`linear config` shows it). */
102
100
  origins: SettingOrigins;
103
101
  /** Absolute path of the user config file (may not exist yet). */
@@ -139,11 +137,7 @@ export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
139
137
  * directory — the reference CLI's order, so a repo that has more than one
140
138
  * resolves the same way under both tools.
141
139
  */
142
- export const PROJECT_CONFIG_NAMES = [
143
- "linear.toml",
144
- ".linear.toml",
145
- join(".config", "linear.toml"),
146
- ] as const;
140
+ export const PROJECT_CONFIG_NAMES = ["linear.toml", ".linear.toml", join(".config", "linear.toml")] as const;
147
141
 
148
142
  /**
149
143
  * The reference CLI's credentials file, a sibling of our config. Its keyring
@@ -166,22 +160,12 @@ interface WorkspaceEntry {
166
160
  interface UserConfig {
167
161
  /** Top-level non-secret settings. */
168
162
  settings: RawSettings;
169
- /** Exact top-level key spelling that supplied each non-secret setting. */
170
- settingKeys: SettingKeys;
171
163
  /** default_workspace, if set (ours, else the reference CLI's `default`). */
172
164
  defaultWorkspace?: string;
173
165
  /** slug → entry from `[workspaces."<slug>"]`, plus the reference CLI's list. */
174
166
  workspaces: Record<string, WorkspaceEntry>;
175
167
  }
176
168
 
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
-
185
169
  function asString(v: unknown): string | undefined {
186
170
  return typeof v === "string" && v.length > 0 ? v : undefined;
187
171
  }
@@ -247,31 +231,14 @@ function parseTomlFile(path: string): Record<string, unknown> {
247
231
  * Read flat NON-SECRET settings from a TOML file. Used for project
248
232
  * `.linear.toml`; the API key is intentionally NOT read here (trust boundary).
249
233
  */
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 };
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 {
234
+ function readTomlFile(path: string): RawSettings {
273
235
  const parsed = parseTomlFile(path);
274
- return settingsFromParsed(parsed);
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),
241
+ };
275
242
  }
276
243
 
277
244
  /**
@@ -280,17 +247,12 @@ function readTomlFile(path: string): FileSettings {
280
247
  * listing those slugs without reading their keys would only produce a
281
248
  * "no stored credential" error for a workspace the user can see in the file.
282
249
  */
283
- function readReferenceCredentials(path: string): {
284
- workspaces: string[];
285
- defaultWorkspace?: string;
286
- } {
250
+ function readReferenceCredentials(path: string): { workspaces: string[]; defaultWorkspace?: string } {
287
251
  if (!existsSync(path)) return { workspaces: [] };
288
252
  const parsed = parseTomlFile(path);
289
253
  const list = parsed.workspaces;
290
254
  if (!Array.isArray(list)) return { workspaces: [] };
291
- const workspaces = [
292
- ...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0)),
293
- ];
255
+ const workspaces = [...new Set(list.filter((v): v is string => typeof v === "string" && v.length > 0))];
294
256
  const def = asString(parsed.default);
295
257
  return {
296
258
  workspaces,
@@ -303,7 +265,6 @@ function readReferenceCredentials(path: string): {
303
265
  function readUserConfig(path: string): UserConfig {
304
266
  const workspaces: Record<string, WorkspaceEntry> = {};
305
267
  const parsed = existsSync(path) ? parseTomlFile(path) : {};
306
- const fileSettings = settingsFromParsed(parsed);
307
268
  const wsTable = parsed.workspaces;
308
269
  if (wsTable && typeof wsTable === "object") {
309
270
  for (const [slug, val] of Object.entries(wsTable as Record<string, unknown>)) {
@@ -319,8 +280,12 @@ function readUserConfig(path: string): UserConfig {
319
280
  const reference = readReferenceCredentials(join(dirname(path), "credentials.toml"));
320
281
  for (const slug of reference.workspaces) workspaces[slug] ??= {};
321
282
  return {
322
- settings: fileSettings.settings,
323
- settingKeys: fileSettings.keys,
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
+ },
324
289
  defaultWorkspace: asString(parsed.default_workspace) ?? reference.defaultWorkspace,
325
290
  workspaces,
326
291
  };
@@ -388,6 +353,7 @@ function fromEnv(env: NodeJS.ProcessEnv): RawSettings {
388
353
  team: env.LINEAR_TEAM || env.LINEAR_TEAM_ID,
389
354
  workspace: env.LINEAR_WORKSPACE,
390
355
  sort: env.LINEAR_ISSUE_SORT,
356
+ vcs: env.LINEAR_VCS,
391
357
  };
392
358
  }
393
359
 
@@ -400,12 +366,12 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
400
366
  const userPath = userConfigPath(env);
401
367
  const user = readUserConfig(userPath);
402
368
  const projectPath = findProjectConfig(cwd);
403
- const project = projectPath ? readTomlFile(projectPath) : { settings: {}, keys: {} };
369
+ const projectSettings = projectPath ? readTomlFile(projectPath) : {};
404
370
  // The reference CLI's global file: same non-secret reader as a project file,
405
371
  // so its `api_key` (it does allow one there) is never picked up.
406
372
  const globalCandidate = globalConfigPath(env);
407
373
  const globalPath = existsSync(globalCandidate) ? globalCandidate : undefined;
408
- const global = globalPath ? readTomlFile(globalPath) : { settings: {}, keys: {} };
374
+ const globalSettings = globalPath ? readTomlFile(globalPath) : {};
409
375
 
410
376
  // ----- API key resolution (strict trust boundary) -----------------------
411
377
  // Resolution is TOTAL: selection problems are stashed in `apiKeyError` rather
@@ -429,10 +395,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
429
395
  // already-stored credential by its non-secret slug, but cannot supply a
430
396
  // key: lookupCredential only reads the user config and OS keyring.
431
397
  const selected =
432
- flags.workspace ??
433
- env.LINEAR_WORKSPACE ??
434
- project.settings.workspace ??
435
- user.defaultWorkspace;
398
+ flags.workspace ?? env.LINEAR_WORKSPACE ?? projectSettings.workspace ?? user.defaultWorkspace;
436
399
 
437
400
  // Apply a lookup result for the slug the selection settled on. An
438
401
  // explicitly named slug is probed even when no file lists it — the keyring
@@ -475,30 +438,27 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
475
438
 
476
439
  // ----- Non-secret settings (project + global config MAY participate) -----
477
440
  // flag > env > project file > our user file > the reference CLI's global file.
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],
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],
484
447
  ];
485
- const pick = <K extends "team" | "workspace" | "sort">(key: K): string | undefined =>
448
+ const pick = <K extends "team" | "workspace" | "sort" | "vcs">(key: K): string | undefined =>
486
449
  tiers.find(([, settings]) => settings[key] !== undefined)?.[1][key];
487
450
 
488
451
  /** Which tier `pick` took the value from, and which file if it was one. */
489
- const originOf = (key: "team" | "workspace" | "sort"): SettingOrigin => {
452
+ const originOf = (key: "team" | "workspace" | "sort" | "vcs"): SettingOrigin => {
490
453
  const hit = tiers.find(([, settings]) => settings[key] !== undefined);
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;
454
+ return hit ? { source: hit[0], path: hit[2] } : { source: "none" };
496
455
  };
497
456
 
498
457
  const origins: SettingOrigins = {
499
458
  team: originOf("team"),
500
459
  workspace: originOf("workspace"),
501
460
  sort: originOf("sort"),
461
+ vcs: originOf("vcs"),
502
462
  };
503
463
 
504
464
  return {
@@ -512,6 +472,7 @@ export function resolveConfig(inputs: ConfigInputs = {}): ResolvedConfig {
512
472
  workspace: pick("workspace"),
513
473
  sort: pick("sort") ?? "priority",
514
474
  sortSource: origins.sort.source,
475
+ vcs: pick("vcs") ?? "git",
515
476
  origins,
516
477
  userConfigPath: userPath,
517
478
  projectConfigPath: projectPath,
@@ -534,184 +495,6 @@ function readUserObject(path: string): Record<string, unknown> {
534
495
  return parseTomlFile(path);
535
496
  }
536
497
 
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
-
715
498
  /**
716
499
  * Persist text ATOMICALLY, with the given perms.
717
500
  *
@@ -777,91 +560,6 @@ export interface WriteCredentialResult {
777
560
  keyringLabel?: string;
778
561
  }
779
562
 
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
-
865
563
  /**
866
564
  * Store a credential for `slug`. By default the secret goes to the OS keyring
867
565
  * (service `linear-cli`, account = slug) and the file only records
@@ -878,51 +576,47 @@ export function writeCredential(
878
576
  opts: { plaintext?: boolean } = {},
879
577
  ): WriteCredentialResult {
880
578
  const path = userConfigPath();
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
- });
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 };
908
604
  }
909
605
 
910
606
  /** Set `default_workspace`. Errors if that workspace is not configured anywhere. */
911
607
  export function setDefaultWorkspace(slug: string): string {
912
608
  const path = userConfigPath();
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
- });
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;
926
620
  }
927
621
 
928
622
  /**
@@ -952,33 +646,31 @@ function removeFromReferenceCredentials(slug: string): boolean {
952
646
  */
953
647
  export function removeCredential(slug: string): boolean {
954
648
  const path = userConfigPath();
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
- });
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;
982
674
  }
983
675
 
984
676
  export interface MigrateResult {
@@ -1002,38 +694,36 @@ export function migrateCredentials(): MigrateResult {
1002
694
  "runtime",
1003
695
  );
1004
696
  }
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
- }
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
- );
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);
1027
705
  }
1028
- if (migrated.length > 0) {
1029
- for (const [, table] of plaintext) {
1030
- delete table.api_key;
1031
- table.keyring = true;
706
+ } catch (err) {
707
+ for (const slug of migrated) {
708
+ try {
709
+ backend.delete(slug);
710
+ } catch {
711
+ // Best-effort rollback.
1032
712
  }
1033
- writeUserObject(path, obj);
1034
713
  }
1035
- return { migrated, path, keyringLabel: backend.label };
1036
- });
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;
723
+ }
724
+ writeUserObject(path, obj);
725
+ }
726
+ return { migrated, path, keyringLabel: backend.label };
1037
727
  }
1038
728
 
1039
729
  export interface CredentialEntry {
@@ -1073,7 +763,7 @@ export function redactKey(key: string | undefined): string {
1073
763
  // ---------------------------------------------------------------------------
1074
764
 
1075
765
  /** The settings `config set` / `config init` will write to a project file. */
1076
- export const SETTABLE_KEYS = ["team", "workspace", "sort"] as const;
766
+ export const SETTABLE_KEYS = ["team", "workspace", "sort", "vcs"] as const;
1077
767
  export type SettableKey = (typeof SETTABLE_KEYS)[number];
1078
768
 
1079
769
  /**
@@ -1085,6 +775,7 @@ const KEY_ALIASES: Record<SettableKey, readonly string[]> = {
1085
775
  team: ["team", "team_id"],
1086
776
  workspace: ["workspace"],
1087
777
  sort: ["sort", "issue_sort"],
778
+ vcs: ["vcs"],
1088
779
  };
1089
780
 
1090
781
  /** Keys that must never be written to a project file, and why. */
@@ -1141,72 +832,64 @@ export function setConfigKey(
1141
832
  opts: { mode?: number } = {},
1142
833
  ): string {
1143
834
  const mode = opts.mode ?? 0o644;
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";
1187
-
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
- };
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";
1206
875
 
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();
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;
1210
893
  }
1211
894
 
1212
895
  /**