@fink-andreas/pi-linear-tools 0.1.0 → 0.2.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/src/cli.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { loadSettings, saveSettings } from './settings.js';
2
2
  import { createLinearClient } from './linear-client.js';
3
3
  import { resolveProjectRef } from './linear.js';
4
+ import {
5
+ authenticate,
6
+ logout,
7
+ getAuthStatus,
8
+ isAuthenticated,
9
+ getAccessToken,
10
+ } from './auth/index.js';
4
11
  import {
5
12
  executeIssueList,
6
13
  executeIssueView,
@@ -60,31 +67,61 @@ function parseBoolean(value) {
60
67
  return undefined;
61
68
  }
62
69
 
63
- // ===== API KEY RESOLUTION =====
70
+ function withMilestoneScopeHint(error) {
71
+ const message = String(error?.message || error || 'Unknown error');
72
+
73
+ if (/invalid scope/i.test(message) && /write/i.test(message)) {
74
+ return new Error(
75
+ `${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
76
+ `Use API key auth: pi-linear-tools config --api-key <key>`
77
+ );
78
+ }
79
+
80
+ return error;
81
+ }
82
+
83
+ // ===== AUTH RESOLUTION =====
64
84
 
65
85
  let cachedApiKey = null;
66
86
 
67
- async function getLinearApiKey() {
87
+ async function getLinearAuth() {
68
88
  const envKey = process.env.LINEAR_API_KEY;
69
89
  if (envKey && envKey.trim()) {
70
- return envKey.trim();
90
+ return { apiKey: envKey.trim() };
91
+ }
92
+
93
+ const settings = await loadSettings();
94
+ const authMethod = settings.authMethod || 'api-key';
95
+
96
+ if (authMethod === 'oauth') {
97
+ const accessToken = await getAccessToken();
98
+ if (accessToken) {
99
+ return { accessToken };
100
+ }
71
101
  }
72
102
 
73
103
  if (cachedApiKey) {
74
- return cachedApiKey;
104
+ return { apiKey: cachedApiKey };
75
105
  }
76
106
 
77
- try {
78
- const settings = await loadSettings();
79
- if (settings.linearApiKey && settings.linearApiKey.trim()) {
80
- cachedApiKey = settings.linearApiKey.trim();
81
- return cachedApiKey;
82
- }
83
- } catch {
84
- // ignore, error below
107
+ const apiKey = settings.apiKey || settings.linearApiKey;
108
+ if (apiKey && apiKey.trim()) {
109
+ cachedApiKey = apiKey.trim();
110
+ return { apiKey: cachedApiKey };
85
111
  }
86
112
 
87
- throw new Error('LINEAR_API_KEY not set. Run: pi-linear-tools config --api-key <key>');
113
+ const fallbackAccessToken = await getAccessToken();
114
+ if (fallbackAccessToken) {
115
+ return { accessToken: fallbackAccessToken };
116
+ }
117
+
118
+ throw new Error(
119
+ 'No Linear authentication configured. Run: pi-linear-tools auth login or pi-linear-tools config --api-key <key>'
120
+ );
121
+ }
122
+
123
+ async function createAuthenticatedClient() {
124
+ return createLinearClient(await getLinearAuth());
88
125
  }
89
126
 
90
127
  async function resolveDefaultTeam(projectId) {
@@ -107,14 +144,20 @@ Usage:
107
144
 
108
145
  Commands:
109
146
  help Show this help message
147
+ auth <action> Manage authentication (OAuth 2.0)
110
148
  config Show current configuration
111
- config --api-key <key> Set Linear API key
149
+ config --api-key <key> Set Linear API key (legacy)
112
150
  config --default-team <key> Set default team
113
151
  issue <action> [options] Manage issues
114
152
  project <action> [options] Manage projects
115
153
  team <action> [options] Manage teams
116
154
  milestone <action> [options] Manage milestones
117
155
 
156
+ Auth Actions:
157
+ login Authenticate with Linear via OAuth 2.0
158
+ logout Clear stored authentication tokens
159
+ status Show current authentication status
160
+
118
161
  Issue Actions:
119
162
  list [--project X] [--states X,Y] [--assignee me|all] [--limit N]
120
163
  view <issue> [--no-comments]
@@ -147,6 +190,8 @@ Common Flags:
147
190
  --limit Max results (default: 50)
148
191
 
149
192
  Examples:
193
+ pi-linear-tools auth login
194
+ pi-linear-tools auth status
150
195
  pi-linear-tools issue list --project MyProject --states "In Progress,Backlog"
151
196
  pi-linear-tools issue view ENG-123
152
197
  pi-linear-tools issue create --title "Fix bug" --team ENG --priority 2
@@ -154,6 +199,12 @@ Examples:
154
199
  pi-linear-tools issue start ENG-123
155
200
  pi-linear-tools milestone list --project MyProject
156
201
  pi-linear-tools config --api-key lin_xxx
202
+
203
+ Authentication:
204
+ OAuth 2.0 is the recommended authentication method.
205
+ Run 'pi-linear-tools auth login' to authenticate.
206
+ For CI/headless environments, set environment variables:
207
+ LINEAR_ACCESS_TOKEN, LINEAR_REFRESH_TOKEN, LINEAR_EXPIRES_AT
157
208
  `);
158
209
  }
159
210
 
@@ -276,6 +327,132 @@ Delete Options:
276
327
  `);
277
328
  }
278
329
 
330
+ function printAuthHelp() {
331
+ console.log(`pi-linear-tools auth - Manage Linear authentication
332
+
333
+ Usage:
334
+ pi-linear-tools auth <action>
335
+
336
+ Actions:
337
+ login Authenticate with Linear via OAuth 2.0
338
+ logout Clear stored authentication tokens
339
+ status Show current authentication status
340
+
341
+ Login:
342
+ Starts the OAuth 2.0 authentication flow:
343
+ 1. Opens your browser to Linear's authorization page
344
+ 2. You authorize the application
345
+ 3. Tokens are stored securely in your OS keychain
346
+ 4. Automatic token refresh keeps you authenticated
347
+
348
+ Logout:
349
+ Clears stored OAuth tokens from your keychain.
350
+ You'll need to authenticate again to access Linear.
351
+
352
+ Status:
353
+ Shows your current authentication status:
354
+ - Whether you're authenticated
355
+ - Token expiry time
356
+ - Granted OAuth scopes
357
+
358
+ Examples:
359
+ pi-linear-tools auth login
360
+ pi-linear-tools auth status
361
+ pi-linear-tools auth logout
362
+
363
+ Environment Variables (for CI/headless environments):
364
+ LINEAR_ACCESS_TOKEN OAuth access token
365
+ LINEAR_REFRESH_TOKEN OAuth refresh token
366
+ LINEAR_EXPIRES_AT Token expiry timestamp (milliseconds)
367
+ `);
368
+ }
369
+
370
+ // ===== AUTH HANDLERS =====
371
+
372
+ async function handleAuthLogin(args) {
373
+ const port = readFlag(args, '--port');
374
+
375
+ try {
376
+ const tokens = await authenticate({
377
+ port: port ? parseInt(port, 10) : undefined,
378
+ });
379
+
380
+ const settings = await loadSettings();
381
+ settings.authMethod = 'oauth';
382
+ await saveSettings(settings);
383
+
384
+ console.log('\n✓ Authentication successful!');
385
+ console.log(`✓ Token expires at: ${new Date(tokens.expiresAt).toLocaleString()}`);
386
+ console.log(`✓ Granted scopes: ${tokens.scope.join(', ')}`);
387
+ console.log('\nYou can now use pi-linear-tools commands.');
388
+ } catch (error) {
389
+ console.error('\n✗ Authentication failed:', error.message);
390
+ process.exitCode = 1;
391
+ }
392
+ }
393
+
394
+ async function handleAuthLogout() {
395
+ try {
396
+ await logout();
397
+ console.log('\n✓ Logged out successfully');
398
+ console.log('\nYou will need to authenticate again to access Linear.');
399
+ } catch (error) {
400
+ console.error('\n✗ Logout failed:', error.message);
401
+ process.exitCode = 1;
402
+ }
403
+ }
404
+
405
+ async function handleAuthStatus() {
406
+ try {
407
+ const status = await getAuthStatus();
408
+
409
+ if (!status) {
410
+ console.log('\nAuthentication status: Not authenticated');
411
+ console.log('\nTo authenticate, run: pi-linear-tools auth login');
412
+ console.log('\nFor CI/headless environments, set these environment variables:');
413
+ console.log(' LINEAR_ACCESS_TOKEN');
414
+ console.log(' LINEAR_REFRESH_TOKEN');
415
+ console.log(' LINEAR_EXPIRES_AT');
416
+ return;
417
+ }
418
+
419
+ const isAuth = await isAuthenticated();
420
+
421
+ console.log('\nAuthentication status:', isAuth ? 'Authenticated' : 'Token expired');
422
+ console.log(`Token expires at: ${new Date(status.expiresAt).toLocaleString()}`);
423
+
424
+ if (status.expiresIn > 0) {
425
+ const minutes = Math.floor(status.expiresIn / 60000);
426
+ console.log(`Time until expiry: ${minutes} minute${minutes !== 1 ? 's' : ''}`);
427
+ }
428
+
429
+ console.log(`Granted scopes: ${status.scopes.join(', ')}`);
430
+ } catch (error) {
431
+ console.error('\n✗ Failed to get authentication status:', error.message);
432
+ process.exitCode = 1;
433
+ }
434
+ }
435
+
436
+ async function handleAuth(args) {
437
+ const [action] = args;
438
+
439
+ if (!action || action === '--help' || action === '-h') {
440
+ printAuthHelp();
441
+ return;
442
+ }
443
+
444
+ switch (action) {
445
+ case 'login':
446
+ return handleAuthLogin(args);
447
+ case 'logout':
448
+ return handleAuthLogout();
449
+ case 'status':
450
+ return handleAuthStatus();
451
+ default:
452
+ throw new Error(`Unknown auth action: ${action}`);
453
+ }
454
+ }
455
+
279
456
  // ===== CONFIG HANDLER =====
280
457
 
281
458
  async function tryResolveProjectId(projectRef, explicitApiKey = null) {
@@ -303,7 +480,8 @@ async function handleConfig(args) {
303
480
 
304
481
  if (apiKey) {
305
482
  const settings = await loadSettings();
306
- settings.linearApiKey = apiKey;
483
+ settings.apiKey = apiKey;
484
+ settings.authMethod = 'api-key';
307
485
  await saveSettings(settings);
308
486
  cachedApiKey = null;
309
487
  console.log('LINEAR_API_KEY saved to settings');
@@ -324,7 +502,7 @@ async function handleConfig(args) {
324
502
  }
325
503
 
326
504
  const settings = await loadSettings();
327
- const projectId = await tryResolveProjectId(projectName, settings.linearApiKey);
505
+ const projectId = await tryResolveProjectId(projectName, settings.apiKey || settings.linearApiKey);
328
506
 
329
507
  if (!settings.projects[projectId]) {
330
508
  settings.projects[projectId] = { scope: { team: null } };
@@ -341,8 +519,8 @@ async function handleConfig(args) {
341
519
  }
342
520
 
343
521
  const settings = await loadSettings();
344
- const hasKey = !!(settings.linearApiKey || process.env.LINEAR_API_KEY);
345
- const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.linearApiKey ? 'settings' : 'not set');
522
+ const hasKey = !!(settings.apiKey || settings.linearApiKey || process.env.LINEAR_API_KEY);
523
+ const keySource = process.env.LINEAR_API_KEY ? 'environment' : (settings.apiKey || settings.linearApiKey ? 'settings' : 'not set');
346
524
 
347
525
  console.log(`Configuration:
348
526
  LINEAR_API_KEY: ${hasKey ? 'configured' : 'not set'} (source: ${keySource})
@@ -358,8 +536,7 @@ Commands:
358
536
  // ===== ISSUE HANDLERS =====
359
537
 
360
538
  async function handleIssueList(args) {
361
- const apiKey = await getLinearApiKey();
362
- const client = createLinearClient(apiKey);
539
+ const client = await createAuthenticatedClient();
363
540
 
364
541
  const params = {
365
542
  project: readFlag(args, '--project'),
@@ -373,8 +550,7 @@ async function handleIssueList(args) {
373
550
  }
374
551
 
375
552
  async function handleIssueView(args) {
376
- const apiKey = await getLinearApiKey();
377
- const client = createLinearClient(apiKey);
553
+ const client = await createAuthenticatedClient();
378
554
 
379
555
  const positional = args.filter((a) => !a.startsWith('-'));
380
556
  if (positional.length === 0) {
@@ -391,8 +567,7 @@ async function handleIssueView(args) {
391
567
  }
392
568
 
393
569
  async function handleIssueCreate(args) {
394
- const apiKey = await getLinearApiKey();
395
- const client = createLinearClient(apiKey);
570
+ const client = await createAuthenticatedClient();
396
571
 
397
572
  const params = {
398
573
  title: readFlag(args, '--title'),
@@ -414,8 +589,7 @@ async function handleIssueCreate(args) {
414
589
  }
415
590
 
416
591
  async function handleIssueUpdate(args) {
417
- const apiKey = await getLinearApiKey();
418
- const client = createLinearClient(apiKey);
592
+ const client = await createAuthenticatedClient();
419
593
 
420
594
  const positional = args.filter((a) => !a.startsWith('-'));
421
595
  if (positional.length === 0) {
@@ -438,8 +612,7 @@ async function handleIssueUpdate(args) {
438
612
  }
439
613
 
440
614
  async function handleIssueComment(args) {
441
- const apiKey = await getLinearApiKey();
442
- const client = createLinearClient(apiKey);
615
+ const client = await createAuthenticatedClient();
443
616
 
444
617
  const positional = args.filter((a) => !a.startsWith('-'));
445
618
  if (positional.length === 0) {
@@ -460,8 +633,7 @@ async function handleIssueComment(args) {
460
633
  }
461
634
 
462
635
  async function handleIssueStart(args) {
463
- const apiKey = await getLinearApiKey();
464
- const client = createLinearClient(apiKey);
636
+ const client = await createAuthenticatedClient();
465
637
 
466
638
  const positional = args.filter((a) => !a.startsWith('-'));
467
639
  if (positional.length === 0) {
@@ -480,8 +652,7 @@ async function handleIssueStart(args) {
480
652
  }
481
653
 
482
654
  async function handleIssueDelete(args) {
483
- const apiKey = await getLinearApiKey();
484
- const client = createLinearClient(apiKey);
655
+ const client = await createAuthenticatedClient();
485
656
 
486
657
  const positional = args.filter((a) => !a.startsWith('-'));
487
658
  if (positional.length === 0) {
@@ -527,8 +698,7 @@ async function handleIssue(args) {
527
698
  // ===== PROJECT HANDLERS =====
528
699
 
529
700
  async function handleProjectList() {
530
- const apiKey = await getLinearApiKey();
531
- const client = createLinearClient(apiKey);
701
+ const client = await createAuthenticatedClient();
532
702
 
533
703
  const result = await executeProjectList(client);
534
704
  console.log(result.content[0].text);
@@ -553,8 +723,7 @@ async function handleProject(args) {
553
723
  // ===== TEAM HANDLERS =====
554
724
 
555
725
  async function handleTeamList() {
556
- const apiKey = await getLinearApiKey();
557
- const client = createLinearClient(apiKey);
726
+ const client = await createAuthenticatedClient();
558
727
 
559
728
  const result = await executeTeamList(client);
560
729
  console.log(result.content[0].text);
@@ -579,8 +748,7 @@ async function handleTeam(args) {
579
748
  // ===== MILESTONE HANDLERS =====
580
749
 
581
750
  async function handleMilestoneList(args) {
582
- const apiKey = await getLinearApiKey();
583
- const client = createLinearClient(apiKey);
751
+ const client = await createAuthenticatedClient();
584
752
 
585
753
  const params = {
586
754
  project: readFlag(args, '--project'),
@@ -591,8 +759,7 @@ async function handleMilestoneList(args) {
591
759
  }
592
760
 
593
761
  async function handleMilestoneView(args) {
594
- const apiKey = await getLinearApiKey();
595
- const client = createLinearClient(apiKey);
762
+ const client = await createAuthenticatedClient();
596
763
 
597
764
  const positional = args.filter((a) => !a.startsWith('-'));
598
765
  if (positional.length === 0) {
@@ -608,8 +775,7 @@ async function handleMilestoneView(args) {
608
775
  }
609
776
 
610
777
  async function handleMilestoneCreate(args) {
611
- const apiKey = await getLinearApiKey();
612
- const client = createLinearClient(apiKey);
778
+ const client = await createAuthenticatedClient();
613
779
 
614
780
  const params = {
615
781
  project: readFlag(args, '--project'),
@@ -628,8 +794,7 @@ async function handleMilestoneCreate(args) {
628
794
  }
629
795
 
630
796
  async function handleMilestoneUpdate(args) {
631
- const apiKey = await getLinearApiKey();
632
- const client = createLinearClient(apiKey);
797
+ const client = await createAuthenticatedClient();
633
798
 
634
799
  const positional = args.filter((a) => !a.startsWith('-'));
635
800
  if (positional.length === 0) {
@@ -649,8 +814,7 @@ async function handleMilestoneUpdate(args) {
649
814
  }
650
815
 
651
816
  async function handleMilestoneDelete(args) {
652
- const apiKey = await getLinearApiKey();
653
- const client = createLinearClient(apiKey);
817
+ const client = await createAuthenticatedClient();
654
818
 
655
819
  const positional = args.filter((a) => !a.startsWith('-'));
656
820
  if (positional.length === 0) {
@@ -673,19 +837,23 @@ async function handleMilestone(args) {
673
837
  return;
674
838
  }
675
839
 
676
- switch (action) {
677
- case 'list':
678
- return handleMilestoneList(rest);
679
- case 'view':
680
- return handleMilestoneView(rest);
681
- case 'create':
682
- return handleMilestoneCreate(rest);
683
- case 'update':
684
- return handleMilestoneUpdate(rest);
685
- case 'delete':
686
- return handleMilestoneDelete(rest);
687
- default:
688
- throw new Error(`Unknown milestone action: ${action}`);
840
+ try {
841
+ switch (action) {
842
+ case 'list':
843
+ return await handleMilestoneList(rest);
844
+ case 'view':
845
+ return await handleMilestoneView(rest);
846
+ case 'create':
847
+ return await handleMilestoneCreate(rest);
848
+ case 'update':
849
+ return await handleMilestoneUpdate(rest);
850
+ case 'delete':
851
+ return await handleMilestoneDelete(rest);
852
+ default:
853
+ throw new Error(`Unknown milestone action: ${action}`);
854
+ }
855
+ } catch (error) {
856
+ throw withMilestoneScopeHint(error);
689
857
  }
690
858
  }
691
859
 
@@ -699,6 +867,11 @@ export async function runCli(argv = process.argv.slice(2)) {
699
867
  return;
700
868
  }
701
869
 
870
+ if (command === 'auth') {
871
+ await handleAuth(rest);
872
+ return;
873
+ }
874
+
702
875
  if (command === 'config') {
703
876
  await handleConfig(rest);
704
877
  return;
package/src/handlers.js CHANGED
@@ -589,7 +589,7 @@ export async function executeMilestoneList(client, params) {
589
589
  ? ` → ${milestone.targetDate.split('T')[0]}`
590
590
  : '';
591
591
 
592
- lines.push(`- ${statusEmoji} **${milestone.name}** _[${milestone.status}]_ (${progressLabel})${dateLabel}`);
592
+ lines.push(`- ${statusEmoji} **${milestone.name}** _[${milestone.status}]_ (${progressLabel})${dateLabel} \`${milestone.id}\``);
593
593
  if (milestone.description) {
594
594
  lines.push(` ${milestone.description.split('\n')[0].slice(0, 100)}${milestone.description.length > 100 ? '...' : ''}`);
595
595
  }
@@ -771,10 +771,15 @@ export async function executeMilestoneDelete(client, params) {
771
771
  const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
772
772
  const result = await deleteProjectMilestone(client, milestoneId);
773
773
 
774
+ const label = result.name
775
+ ? `**${result.name}** (\`${milestoneId}\`)`
776
+ : `\`${milestoneId}\``;
777
+
774
778
  return toTextResult(
775
- `Deleted milestone \`${milestoneId}\``,
779
+ `Deleted milestone ${label}`,
776
780
  {
777
781
  milestoneId: result.milestoneId,
782
+ name: result.name,
778
783
  success: result.success,
779
784
  }
780
785
  );
@@ -2,29 +2,59 @@
2
2
  * Linear SDK client factory
3
3
  *
4
4
  * Creates a configured LinearClient instance for interacting with Linear API.
5
+ * Supports both API key and OAuth token authentication.
5
6
  */
6
7
 
7
8
  import { LinearClient } from '@linear/sdk';
9
+ import { debug, warn, error as logError } from './logger.js';
8
10
 
9
11
  /** @type {Function|null} Test-only client factory override */
10
12
  let _testClientFactory = null;
11
13
 
12
14
  /**
13
15
  * Create a Linear SDK client
14
- * @param {string} apiKey - Linear API key
16
+ *
17
+ * Supports two authentication methods:
18
+ * 1. API Key: Pass as string or { apiKey: '...' }
19
+ * 2. OAuth Token: Pass as { accessToken: '...' }
20
+ *
21
+ * @param {string|object} auth - Authentication credential
22
+ * @param {string} [auth.apiKey] - Linear API key (for API key auth)
23
+ * @param {string} [auth.accessToken] - OAuth access token (for OAuth auth)
15
24
  * @returns {LinearClient} Configured Linear client
16
25
  */
17
- export function createLinearClient(apiKey) {
26
+ export function createLinearClient(auth) {
18
27
  // Allow test override
19
28
  if (_testClientFactory) {
20
- return _testClientFactory(apiKey);
29
+ return _testClientFactory(auth);
21
30
  }
22
31
 
23
- if (!apiKey || typeof apiKey !== 'string') {
24
- throw new Error('Linear API key is required');
32
+ let clientConfig;
33
+
34
+ // Handle different input formats
35
+ if (typeof auth === 'string') {
36
+ // Legacy: API key passed as string
37
+ clientConfig = { apiKey: auth };
38
+ } else if (typeof auth === 'object' && auth !== null) {
39
+ // Object format: { apiKey: '...' } or { accessToken: '...' }
40
+ if (auth.accessToken) {
41
+ clientConfig = { apiKey: auth.accessToken };
42
+ debug('Creating Linear client with OAuth access token');
43
+ } else if (auth.apiKey) {
44
+ clientConfig = { apiKey: auth.apiKey };
45
+ debug('Creating Linear client with API key');
46
+ } else {
47
+ throw new Error(
48
+ 'Auth object must contain either apiKey or accessToken'
49
+ );
50
+ }
51
+ } else {
52
+ throw new Error(
53
+ 'Invalid auth parameter: must be a string (API key) or an object with apiKey or accessToken'
54
+ );
25
55
  }
26
56
 
27
- return new LinearClient({ apiKey });
57
+ return new LinearClient(clientConfig);
28
58
  }
29
59
 
30
60
  /**
package/src/linear.js CHANGED
@@ -1166,14 +1166,26 @@ export async function updateProjectMilestone(client, milestoneId, patch = {}) {
1166
1166
  * Delete a project milestone
1167
1167
  * @param {LinearClient} client - Linear SDK client
1168
1168
  * @param {string} milestoneId - Milestone ID
1169
- * @returns {Promise<{success: boolean, milestoneId: string}>}
1169
+ * @returns {Promise<{success: boolean, milestoneId: string, name: string|null}>}
1170
1170
  */
1171
1171
  export async function deleteProjectMilestone(client, milestoneId) {
1172
+ let milestoneName = null;
1173
+
1174
+ try {
1175
+ const existing = await client.projectMilestone(milestoneId);
1176
+ if (existing?.name) {
1177
+ milestoneName = existing.name;
1178
+ }
1179
+ } catch {
1180
+ milestoneName = null;
1181
+ }
1182
+
1172
1183
  const result = await client.deleteProjectMilestone(milestoneId);
1173
1184
 
1174
1185
  return {
1175
1186
  success: result.success,
1176
1187
  milestoneId,
1188
+ name: milestoneName,
1177
1189
  };
1178
1190
  }
1179
1191