@fink-andreas/pi-linear-tools 0.2.1 → 0.4.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/CHANGELOG.md +42 -0
- package/extensions/pi-linear-tools.js +167 -134
- package/index.js +167 -134
- package/package.json +2 -2
- package/src/auth/callback-server.js +1 -1
- package/src/auth/constants.js +9 -0
- package/src/auth/index.js +50 -7
- package/src/auth/oauth.js +6 -5
- package/src/auth/pkce.js +16 -53
- package/src/auth/token-refresh.js +5 -7
- package/src/cli.js +1 -13
- package/src/error-hints.js +24 -0
- package/src/handlers.js +50 -45
- package/src/linear-client.js +252 -11
- package/src/linear.js +1066 -636
- package/src/logger.js +56 -16
- package/src/settings.js +5 -4
- package/src/shared.js +112 -0
package/index.js
CHANGED
|
@@ -1,75 +1,12 @@
|
|
|
1
1
|
import { loadSettings, saveSettings } from './src/settings.js';
|
|
2
|
-
import { createLinearClient } from './src/linear-client.js';
|
|
2
|
+
import { createLinearClient, checkAndClearRateLimit, markRateLimited } from './src/linear-client.js';
|
|
3
3
|
import { setQuietMode } from './src/logger.js';
|
|
4
4
|
import {
|
|
5
5
|
resolveProjectRef,
|
|
6
6
|
fetchTeams,
|
|
7
7
|
fetchWorkspaces,
|
|
8
8
|
} from './src/linear.js';
|
|
9
|
-
import
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { pathToFileURL } from 'node:url';
|
|
12
|
-
|
|
13
|
-
function isPiCodingAgentRoot(dir) {
|
|
14
|
-
const pkgPath = path.join(dir, 'package.json');
|
|
15
|
-
if (!fs.existsSync(pkgPath)) return false;
|
|
16
|
-
try {
|
|
17
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
18
|
-
return pkg?.name === '@mariozechner/pi-coding-agent';
|
|
19
|
-
} catch {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function findPiCodingAgentRoot() {
|
|
25
|
-
const entry = process.argv?.[1];
|
|
26
|
-
if (!entry) return null;
|
|
27
|
-
|
|
28
|
-
// Method 1: walk up from argv1 (works when argv1 is .../pi-coding-agent/dist/cli.js)
|
|
29
|
-
{
|
|
30
|
-
let dir = path.dirname(entry);
|
|
31
|
-
for (let i = 0; i < 20; i += 1) {
|
|
32
|
-
if (isPiCodingAgentRoot(dir)) {
|
|
33
|
-
return dir;
|
|
34
|
-
}
|
|
35
|
-
const parent = path.dirname(dir);
|
|
36
|
-
if (parent === dir) break;
|
|
37
|
-
dir = parent;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Method 2: npm global layout guess (works when argv1 is .../<prefix>/bin/pi)
|
|
42
|
-
// <prefix>/bin/pi -> <prefix>/lib/node_modules/@mariozechner/pi-coding-agent
|
|
43
|
-
{
|
|
44
|
-
const binDir = path.dirname(entry);
|
|
45
|
-
const prefix = path.resolve(binDir, '..');
|
|
46
|
-
const candidate = path.join(prefix, 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
|
|
47
|
-
if (isPiCodingAgentRoot(candidate)) {
|
|
48
|
-
return candidate;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Method 3: common global node_modules locations
|
|
53
|
-
for (const candidate of [
|
|
54
|
-
'/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
|
|
55
|
-
'/usr/lib/node_modules/@mariozechner/pi-coding-agent',
|
|
56
|
-
]) {
|
|
57
|
-
if (isPiCodingAgentRoot(candidate)) {
|
|
58
|
-
return candidate;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function importFromPiRoot(relativePathFromPiRoot) {
|
|
66
|
-
const piRoot = findPiCodingAgentRoot();
|
|
67
|
-
|
|
68
|
-
if (!piRoot) throw new Error('Unable to locate @mariozechner/pi-coding-agent installation');
|
|
69
|
-
|
|
70
|
-
const absPath = path.join(piRoot, relativePathFromPiRoot);
|
|
71
|
-
return import(pathToFileURL(absPath).href);
|
|
72
|
-
}
|
|
9
|
+
import { isPiCodingAgentRoot, findPiCodingAgentRoot, importFromPiRoot, parseArgs, readFlag } from './src/shared.js';
|
|
73
10
|
|
|
74
11
|
async function importPiCodingAgent() {
|
|
75
12
|
try {
|
|
@@ -125,23 +62,7 @@ import {
|
|
|
125
62
|
executeMilestoneDelete,
|
|
126
63
|
} from './src/handlers.js';
|
|
127
64
|
import { authenticate, getAccessToken, logout } from './src/auth/index.js';
|
|
128
|
-
|
|
129
|
-
function parseArgs(argsString) {
|
|
130
|
-
if (!argsString || !argsString.trim()) return [];
|
|
131
|
-
const tokens = argsString.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
132
|
-
return tokens.map((t) => {
|
|
133
|
-
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
134
|
-
return t.slice(1, -1);
|
|
135
|
-
}
|
|
136
|
-
return t;
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function readFlag(args, flag) {
|
|
141
|
-
const idx = args.indexOf(flag);
|
|
142
|
-
if (idx >= 0 && idx + 1 < args.length) return args[idx + 1];
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
65
|
+
import { withMilestoneScopeHint } from './src/error-hints.js';
|
|
145
66
|
|
|
146
67
|
let cachedApiKey = null;
|
|
147
68
|
|
|
@@ -185,19 +106,6 @@ async function createAuthenticatedClient() {
|
|
|
185
106
|
return createLinearClient(await getLinearAuth());
|
|
186
107
|
}
|
|
187
108
|
|
|
188
|
-
function withMilestoneScopeHint(error) {
|
|
189
|
-
const message = String(error?.message || error || 'Unknown error');
|
|
190
|
-
|
|
191
|
-
if (/invalid scope/i.test(message) && /write/i.test(message)) {
|
|
192
|
-
return new Error(
|
|
193
|
-
`${message}\nHint: Milestone create/update/delete require Linear write scope. ` +
|
|
194
|
-
`Use API key auth for milestone management: /linear-tools-config --api-key <key>`
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return error;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
109
|
async function resolveDefaultTeam(projectId) {
|
|
202
110
|
const settings = await loadSettings();
|
|
203
111
|
|
|
@@ -502,7 +410,10 @@ function renderMarkdownResult(result, _options, _theme) {
|
|
|
502
410
|
}
|
|
503
411
|
|
|
504
412
|
async function registerLinearTools(pi) {
|
|
505
|
-
if (typeof pi.registerTool !== 'function')
|
|
413
|
+
if (typeof pi.registerTool !== 'function') {
|
|
414
|
+
console.warn('[pi-linear-tools] pi.registerTool not available');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
506
417
|
|
|
507
418
|
pi.registerTool({
|
|
508
419
|
name: 'linear_issue',
|
|
@@ -628,29 +539,90 @@ async function registerLinearTools(pi) {
|
|
|
628
539
|
},
|
|
629
540
|
renderResult: renderMarkdownResult,
|
|
630
541
|
async execute(_toolCallId, params) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
542
|
+
// Pre-check: skip API calls if we know we're rate limited
|
|
543
|
+
const { isRateLimited, resetAt } = checkAndClearRateLimit();
|
|
544
|
+
if (isRateLimited) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
`Linear API rate limit exceeded (cached).\n\n` +
|
|
547
|
+
`The rate limit resets at: ${resetAt.toLocaleTimeString()}\n\n` +
|
|
548
|
+
`Please wait before making more requests.`
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const client = await createAuthenticatedClient();
|
|
554
|
+
|
|
555
|
+
switch (params.action) {
|
|
556
|
+
case 'list':
|
|
557
|
+
return await executeIssueList(client, params);
|
|
558
|
+
case 'view':
|
|
559
|
+
return await executeIssueView(client, params);
|
|
560
|
+
case 'create':
|
|
561
|
+
return await executeIssueCreate(client, params, { resolveDefaultTeam });
|
|
562
|
+
case 'update':
|
|
563
|
+
return await executeIssueUpdate(client, params);
|
|
564
|
+
case 'comment':
|
|
565
|
+
return await executeIssueComment(client, params);
|
|
566
|
+
case 'start':
|
|
567
|
+
return await executeIssueStart(client, params, {
|
|
568
|
+
gitExecutor: async (branchName, fromRef, onBranchExists) => {
|
|
569
|
+
return startGitBranchForIssue(pi, branchName, fromRef, onBranchExists);
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
case 'delete':
|
|
573
|
+
return await executeIssueDelete(client, params);
|
|
574
|
+
default:
|
|
575
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
576
|
+
}
|
|
577
|
+
} catch (error) {
|
|
578
|
+
// Comprehensive error handling - catch ALL errors including SDK's RatelimitedLinearError
|
|
579
|
+
const errorType = error?.type || '';
|
|
580
|
+
const errorMessage = String(error?.message || error || 'Unknown error');
|
|
581
|
+
|
|
582
|
+
// Rate limit error - provide clear reset time and mark globally
|
|
583
|
+
if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
|
|
584
|
+
const resetTimestamp = error?.requestsResetAt || (Date.now() + 3600000);
|
|
585
|
+
const resetTime = new Date(resetTimestamp).toLocaleTimeString();
|
|
586
|
+
markRateLimited(resetTimestamp);
|
|
587
|
+
throw new Error(
|
|
588
|
+
`Linear API rate limit exceeded.\n\n` +
|
|
589
|
+
`The rate limit resets at: ${resetTime}\n\n` +
|
|
590
|
+
`Please wait before making more requests, or reduce the frequency of API calls.`
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Authentication/Forbidden errors (handles SDK's ForbiddenLinearError)
|
|
595
|
+
if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
|
|
596
|
+
errorMessage.toLowerCase().includes('forbidden') || errorMessage.toLowerCase().includes('unauthorized')) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`Linear API authentication failed: ${errorMessage}\n\n` +
|
|
599
|
+
`Please check your API key or OAuth token permissions.`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Network errors (handles SDK's NetworkError)
|
|
604
|
+
if (errorType === 'NetworkError' || errorMessage.toLowerCase().includes('network')) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`Network error communicating with Linear API.\n\n` +
|
|
607
|
+
`Please check your internet connection and try again.`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Internal server errors (handles SDK's InternalError)
|
|
612
|
+
if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`Linear API server error (${error?.status || 'unknown'}).\n\n` +
|
|
615
|
+
`Linear may be experiencing issues. Please try again later.`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Re-throw if already formatted with "Linear API error:"
|
|
620
|
+
if (errorMessage.includes('Linear API error:')) {
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Wrap unexpected errors with context - NEVER let raw errors propagate
|
|
625
|
+
throw new Error(`Linear issue operation failed: ${errorMessage}`);
|
|
654
626
|
}
|
|
655
627
|
},
|
|
656
628
|
});
|
|
@@ -673,13 +645,32 @@ async function registerLinearTools(pi) {
|
|
|
673
645
|
},
|
|
674
646
|
renderResult: renderMarkdownResult,
|
|
675
647
|
async execute(_toolCallId, params) {
|
|
676
|
-
|
|
648
|
+
try {
|
|
649
|
+
const client = await createAuthenticatedClient();
|
|
650
|
+
|
|
651
|
+
switch (params.action) {
|
|
652
|
+
case 'list':
|
|
653
|
+
return await executeProjectList(client);
|
|
654
|
+
default:
|
|
655
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
656
|
+
}
|
|
657
|
+
} catch (error) {
|
|
658
|
+
// Comprehensive error handling - catch ALL errors
|
|
659
|
+
const errorType = error?.type || '';
|
|
660
|
+
const errorMessage = String(error?.message || error || 'Unknown error');
|
|
661
|
+
|
|
662
|
+
if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
|
|
663
|
+
const resetAt = error?.requestsResetAt
|
|
664
|
+
? new Date(error.requestsResetAt).toLocaleTimeString()
|
|
665
|
+
: 'approximately 1 hour from now';
|
|
666
|
+
throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
|
|
667
|
+
}
|
|
677
668
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
669
|
+
if (errorMessage.includes('Linear API error:')) {
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
throw new Error(`Linear project operation failed: ${errorMessage}`);
|
|
683
674
|
}
|
|
684
675
|
},
|
|
685
676
|
});
|
|
@@ -702,13 +693,32 @@ async function registerLinearTools(pi) {
|
|
|
702
693
|
},
|
|
703
694
|
renderResult: renderMarkdownResult,
|
|
704
695
|
async execute(_toolCallId, params) {
|
|
705
|
-
|
|
696
|
+
try {
|
|
697
|
+
const client = await createAuthenticatedClient();
|
|
698
|
+
|
|
699
|
+
switch (params.action) {
|
|
700
|
+
case 'list':
|
|
701
|
+
return await executeTeamList(client);
|
|
702
|
+
default:
|
|
703
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
704
|
+
}
|
|
705
|
+
} catch (error) {
|
|
706
|
+
// Comprehensive error handling - catch ALL errors
|
|
707
|
+
const errorType = error?.type || '';
|
|
708
|
+
const errorMessage = String(error?.message || error || 'Unknown error');
|
|
709
|
+
|
|
710
|
+
if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
|
|
711
|
+
const resetAt = error?.requestsResetAt
|
|
712
|
+
? new Date(error.requestsResetAt).toLocaleTimeString()
|
|
713
|
+
: 'approximately 1 hour from now';
|
|
714
|
+
throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
|
|
715
|
+
}
|
|
706
716
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
717
|
+
if (errorMessage.includes('Linear API error:')) {
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
throw new Error(`Linear team operation failed: ${errorMessage}`);
|
|
712
722
|
}
|
|
713
723
|
},
|
|
714
724
|
});
|
|
@@ -752,9 +762,9 @@ async function registerLinearTools(pi) {
|
|
|
752
762
|
},
|
|
753
763
|
renderResult: renderMarkdownResult,
|
|
754
764
|
async execute(_toolCallId, params) {
|
|
755
|
-
const client = await createAuthenticatedClient();
|
|
756
|
-
|
|
757
765
|
try {
|
|
766
|
+
const client = await createAuthenticatedClient();
|
|
767
|
+
|
|
758
768
|
switch (params.action) {
|
|
759
769
|
case 'list':
|
|
760
770
|
return await executeMilestoneList(client, params);
|
|
@@ -770,7 +780,24 @@ async function registerLinearTools(pi) {
|
|
|
770
780
|
throw new Error(`Unknown action: ${params.action}`);
|
|
771
781
|
}
|
|
772
782
|
} catch (error) {
|
|
773
|
-
|
|
783
|
+
// Apply milestone-specific hint, then wrap with operation context
|
|
784
|
+
const hintError = withMilestoneScopeHint(error);
|
|
785
|
+
// Comprehensive error handling - catch ALL errors
|
|
786
|
+
const errorType = error?.type || '';
|
|
787
|
+
const errorMessage = String(hintError?.message || hintError || 'Unknown error');
|
|
788
|
+
|
|
789
|
+
if (errorType === 'Ratelimited' || errorMessage.toLowerCase().includes('rate limit')) {
|
|
790
|
+
const resetAt = error?.requestsResetAt
|
|
791
|
+
? new Date(error.requestsResetAt).toLocaleTimeString()
|
|
792
|
+
: 'approximately 1 hour from now';
|
|
793
|
+
throw new Error(`Linear API rate limit exceeded. Resets at: ${resetAt}. Please wait before retrying.`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (errorMessage.includes('Linear API error:')) {
|
|
797
|
+
throw hintError;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
throw new Error(`Linear milestone operation failed: ${errorMessage}`);
|
|
774
801
|
}
|
|
775
802
|
},
|
|
776
803
|
});
|
|
@@ -778,6 +805,8 @@ async function registerLinearTools(pi) {
|
|
|
778
805
|
}
|
|
779
806
|
|
|
780
807
|
export default async function piLinearToolsExtension(pi) {
|
|
808
|
+
// Safety wrapper: never let extension errors crash pi
|
|
809
|
+
try {
|
|
781
810
|
pi.registerCommand('linear-tools-config', {
|
|
782
811
|
description: 'Configure pi-linear-tools settings (API key and default team mappings)',
|
|
783
812
|
handler: async (argsText, ctx) => {
|
|
@@ -915,4 +944,8 @@ export default async function piLinearToolsExtension(pi) {
|
|
|
915
944
|
});
|
|
916
945
|
|
|
917
946
|
await registerLinearTools(pi);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
// Safety: never let extension initialization crash pi
|
|
949
|
+
console.error('[pi-linear-tools] Extension initialization failed:', error?.message || error);
|
|
950
|
+
}
|
|
918
951
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fink-andreas/pi-linear-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Pi extension with Linear SDK tools and configuration commands",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"license": "MIT",
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@linear/sdk": "^75.0.0",
|
|
49
|
-
"keytar": "^7.
|
|
49
|
+
"@github/keytar": "^7.10.6"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import http from 'node:http';
|
|
9
9
|
import { URL } from 'node:url';
|
|
10
|
-
import { debug,
|
|
10
|
+
import { debug, error as logError } from '../logger.js';
|
|
11
11
|
|
|
12
12
|
// Default callback server configuration
|
|
13
13
|
const SERVER_CONFIG = {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth constants for pi-linear-tools
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Linear OAuth application client ID
|
|
6
|
+
export const OAUTH_CLIENT_ID = 'a3e177176c6697611367f1a2405d4a34';
|
|
7
|
+
|
|
8
|
+
// OAuth scopes - minimal required scopes
|
|
9
|
+
export const OAUTH_SCOPES = ['read', 'issues:create', 'comments:create'];
|
package/src/auth/index.js
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generatePkceParams } from './pkce.js';
|
|
9
|
-
import { buildAuthorizationUrl, exchangeCodeForToken } from './oauth.js';
|
|
9
|
+
import { buildAuthorizationUrl, exchangeCodeForToken, refreshAccessToken, revokeToken } from './oauth.js';
|
|
10
10
|
import { waitForCallback } from './callback-server.js';
|
|
11
|
-
import { storeTokens, getTokens, clearTokens
|
|
12
|
-
import { getValidAccessToken } from './token-refresh.js';
|
|
11
|
+
import { storeTokens, getTokens, clearTokens } from './token-store.js';
|
|
13
12
|
import { debug, info, warn, error as logError } from '../logger.js';
|
|
14
13
|
|
|
15
14
|
function parseManualCallbackInput(rawInput) {
|
|
@@ -184,10 +183,41 @@ export async function authenticate({
|
|
|
184
183
|
/**
|
|
185
184
|
* Get a valid access token, refreshing if necessary
|
|
186
185
|
*
|
|
187
|
-
* @returns {Promise<string|null>} Valid access token or null if
|
|
186
|
+
* @returns {Promise<string|null>} Valid access token, or null if no auth configured or refresh failed
|
|
188
187
|
*/
|
|
189
188
|
export async function getAccessToken() {
|
|
190
|
-
|
|
189
|
+
const tokens = await getTokens();
|
|
190
|
+
|
|
191
|
+
if (!tokens) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const bufferSeconds = 60;
|
|
197
|
+
const bufferMs = bufferSeconds * 1000;
|
|
198
|
+
|
|
199
|
+
// Check if token is still valid (with buffer)
|
|
200
|
+
if (now < tokens.expiresAt - bufferMs) {
|
|
201
|
+
return tokens.accessToken;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Token needs refresh
|
|
205
|
+
try {
|
|
206
|
+
const tokenResponse = await refreshAccessToken(tokens.refreshToken);
|
|
207
|
+
const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
|
|
208
|
+
const newTokens = {
|
|
209
|
+
accessToken: tokenResponse.access_token,
|
|
210
|
+
refreshToken: tokenResponse.refresh_token,
|
|
211
|
+
expiresAt: expiresAt,
|
|
212
|
+
scope: tokenResponse.scope ? tokenResponse.scope.split(' ') : [],
|
|
213
|
+
tokenType: tokenResponse.token_type || 'Bearer',
|
|
214
|
+
};
|
|
215
|
+
await storeTokens(newTokens);
|
|
216
|
+
return newTokens.accessToken;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
debug('Failed to get valid access token', { error: error.message });
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
191
221
|
}
|
|
192
222
|
|
|
193
223
|
/**
|
|
@@ -196,7 +226,11 @@ export async function getAccessToken() {
|
|
|
196
226
|
* @returns {Promise<boolean>} True if authenticated with valid tokens
|
|
197
227
|
*/
|
|
198
228
|
export async function isAuthenticated() {
|
|
199
|
-
|
|
229
|
+
const tokens = await getTokens();
|
|
230
|
+
if (!tokens) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
return Date.now() < tokens.expiresAt;
|
|
200
234
|
}
|
|
201
235
|
|
|
202
236
|
/**
|
|
@@ -223,12 +257,21 @@ export async function getAuthStatus() {
|
|
|
223
257
|
}
|
|
224
258
|
|
|
225
259
|
/**
|
|
226
|
-
* Logout (clear stored tokens)
|
|
260
|
+
* Logout (clear stored tokens and revoke OAuth tokens)
|
|
227
261
|
*
|
|
228
262
|
* @returns {Promise<void>}
|
|
229
263
|
*/
|
|
230
264
|
export async function logout() {
|
|
231
265
|
info('Logging out...');
|
|
266
|
+
const tokens = await getTokens();
|
|
267
|
+
if (tokens?.accessToken) {
|
|
268
|
+
try {
|
|
269
|
+
await revokeToken(tokens.accessToken);
|
|
270
|
+
debug('OAuth token revoked');
|
|
271
|
+
} catch {
|
|
272
|
+
// Ignore revocation errors - we still want to clear local tokens
|
|
273
|
+
}
|
|
274
|
+
}
|
|
232
275
|
await clearTokens();
|
|
233
276
|
info('Logged out successfully');
|
|
234
277
|
}
|
package/src/auth/oauth.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { debug, warn, error as logError } from '../logger.js';
|
|
9
|
+
import { OAUTH_CLIENT_ID, OAUTH_SCOPES } from './constants.js';
|
|
9
10
|
|
|
10
11
|
// OAuth configuration
|
|
11
12
|
const OAUTH_CONFIG = {
|
|
@@ -15,11 +16,11 @@ const OAUTH_CONFIG = {
|
|
|
15
16
|
revokeUrl: 'https://api.linear.app/oauth/revoke',
|
|
16
17
|
|
|
17
18
|
// Client configuration
|
|
18
|
-
clientId:
|
|
19
|
+
clientId: OAUTH_CLIENT_ID,
|
|
19
20
|
redirectUri: 'http://localhost:34711/callback',
|
|
20
21
|
|
|
21
|
-
// OAuth scopes
|
|
22
|
-
scopes:
|
|
22
|
+
// OAuth scopes
|
|
23
|
+
scopes: OAUTH_SCOPES,
|
|
23
24
|
|
|
24
25
|
// Prompt consent to allow workspace reselection
|
|
25
26
|
prompt: 'consent',
|
|
@@ -272,10 +273,10 @@ export async function revokeToken(token, tokenTypeHint) {
|
|
|
272
273
|
}
|
|
273
274
|
|
|
274
275
|
/**
|
|
275
|
-
* Get OAuth configuration
|
|
276
|
+
* Get OAuth configuration (internal)
|
|
276
277
|
*
|
|
277
278
|
* @returns {object} OAuth configuration object
|
|
278
279
|
*/
|
|
279
|
-
|
|
280
|
+
function _getOAuthConfig() {
|
|
280
281
|
return { ...OAUTH_CONFIG };
|
|
281
282
|
}
|
package/src/auth/pkce.js
CHANGED
|
@@ -6,106 +6,69 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
|
-
import { debug } from '../logger.js';
|
|
9
|
+
import { debug, warn, error as logError } from '../logger.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Generate a random code verifier for PKCE
|
|
13
|
-
*
|
|
14
|
-
* The code verifier must be:
|
|
15
|
-
* - High-entropy cryptographically random
|
|
16
|
-
* - Between 43 and 128 characters
|
|
17
|
-
* - Containing only alphanumeric characters and '-', '.', '_', '~'
|
|
18
|
-
*
|
|
19
|
-
* @returns {string} Base64URL-encoded code verifier
|
|
12
|
+
* Generate a random code verifier for PKCE (RFC 7636: 43-128 chars, base64url)
|
|
13
|
+
* @returns {string} Code verifier
|
|
20
14
|
*/
|
|
21
15
|
export function generateCodeVerifier() {
|
|
22
|
-
//
|
|
23
|
-
// Base64URL encoding results in ~43 characters
|
|
16
|
+
// 32 bytes = ~43 base64url characters
|
|
24
17
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
25
|
-
|
|
26
18
|
debug('Generated code verifier', { length: verifier.length });
|
|
27
|
-
|
|
28
19
|
return verifier;
|
|
29
20
|
}
|
|
30
21
|
|
|
31
22
|
/**
|
|
32
|
-
* Generate a code challenge from
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* @param {string} verifier - The code verifier generated by generateCodeVerifier()
|
|
37
|
-
* @returns {string} Base64URL-encoded code challenge
|
|
23
|
+
* Generate a code challenge from verifier (SHA-256, S256 method)
|
|
24
|
+
* @param {string} verifier - Code verifier
|
|
25
|
+
* @returns {string} Code challenge
|
|
38
26
|
*/
|
|
39
27
|
export function generateCodeChallenge(verifier) {
|
|
40
28
|
const challenge = crypto
|
|
41
29
|
.createHash('sha256')
|
|
42
30
|
.update(verifier)
|
|
43
31
|
.digest('base64url');
|
|
44
|
-
|
|
45
32
|
debug('Generated code challenge', { challengeLength: challenge.length });
|
|
46
|
-
|
|
47
33
|
return challenge;
|
|
48
34
|
}
|
|
49
35
|
|
|
50
36
|
/**
|
|
51
|
-
* Generate a
|
|
52
|
-
*
|
|
53
|
-
* The state parameter is used for CSRF protection during OAuth flow.
|
|
54
|
-
* Must be unique per authentication session.
|
|
55
|
-
*
|
|
56
|
-
* @returns {string} Hex-encoded random state
|
|
37
|
+
* Generate a random state parameter for CSRF protection
|
|
38
|
+
* @returns {string} Hex-encoded state
|
|
57
39
|
*/
|
|
58
40
|
export function generateState() {
|
|
59
|
-
// Generate 16 bytes of random data (32 hex characters)
|
|
60
41
|
const state = crypto.randomBytes(16).toString('hex');
|
|
61
|
-
|
|
62
42
|
debug('Generated state parameter', { state });
|
|
63
|
-
|
|
64
43
|
return state;
|
|
65
44
|
}
|
|
66
45
|
|
|
67
46
|
/**
|
|
68
|
-
* Validate state
|
|
69
|
-
*
|
|
70
|
-
* @param {string}
|
|
71
|
-
* @
|
|
72
|
-
* @returns {boolean} True if states match, false otherwise
|
|
47
|
+
* Validate OAuth callback state matches expected
|
|
48
|
+
* @param {string} receivedState - From callback
|
|
49
|
+
* @param {string} expectedState - Generated at flow start
|
|
50
|
+
* @returns {boolean} True if match
|
|
73
51
|
*/
|
|
74
52
|
export function validateState(receivedState, expectedState) {
|
|
75
53
|
const isValid = receivedState === expectedState;
|
|
76
|
-
|
|
77
54
|
if (!isValid) {
|
|
78
|
-
debug('State validation failed', {
|
|
79
|
-
received: receivedState,
|
|
80
|
-
expected: expectedState,
|
|
81
|
-
});
|
|
82
|
-
} else {
|
|
83
|
-
debug('State validation successful');
|
|
55
|
+
debug('State validation failed', { received: receivedState, expected: expectedState });
|
|
84
56
|
}
|
|
85
|
-
|
|
86
57
|
return isValid;
|
|
87
58
|
}
|
|
88
59
|
|
|
89
60
|
/**
|
|
90
61
|
* Generate all PKCE parameters for OAuth flow
|
|
91
|
-
*
|
|
92
|
-
* Convenience function that generates all required PKCE parameters.
|
|
93
|
-
*
|
|
94
|
-
* @returns {object} Object containing verifier, challenge, and state
|
|
95
|
-
* @returns {string} returns.verifier - Code verifier
|
|
96
|
-
* @returns {string} returns.challenge - Code challenge
|
|
97
|
-
* @returns {string} returns.state - State parameter
|
|
62
|
+
* @returns {{verifier: string, challenge: string, state: string}}
|
|
98
63
|
*/
|
|
99
64
|
export function generatePkceParams() {
|
|
100
65
|
const verifier = generateCodeVerifier();
|
|
101
66
|
const challenge = generateCodeChallenge(verifier);
|
|
102
67
|
const state = generateState();
|
|
103
|
-
|
|
104
68
|
debug('Generated PKCE parameters', {
|
|
105
69
|
verifierLength: verifier.length,
|
|
106
70
|
challengeLength: challenge.length,
|
|
107
71
|
stateLength: state.length,
|
|
108
72
|
});
|
|
109
|
-
|
|
110
73
|
return { verifier, challenge, state };
|
|
111
|
-
}
|
|
74
|
+
}
|