opencode-qwen-cli-auth 2.2.9 → 2.3.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.
@@ -1,25 +1,69 @@
1
+ /**
2
+ * @fileoverview OAuth authentication utilities for Qwen Plugin
3
+ * Implements OAuth 2.0 Device Authorization Grant flow (RFC 8628)
4
+ * Handles token storage, refresh, and validation
5
+ * @license MIT
6
+ */
7
+
1
8
  import { generatePKCE } from "@openauthjs/openauth/pkce";
2
9
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, statSync } from "fs";
3
10
  import { QWEN_OAUTH, DEFAULT_QWEN_BASE_URL, TOKEN_REFRESH_BUFFER_MS, VERIFICATION_URI } from "../constants.js";
4
11
  import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
5
12
  import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
6
- const MAX_REFRESH_RETRIES = 1;
7
- const REFRESH_RETRY_DELAY_MS = 1000;
13
+
14
+ /** Maximum number of retries for token refresh operations */
15
+ const MAX_REFRESH_RETRIES = 2;
16
+ /** Delay between retry attempts in milliseconds */
17
+ const REFRESH_RETRY_DELAY_MS = 2000;
18
+ /** Timeout for OAuth HTTP requests in milliseconds */
8
19
  const OAUTH_REQUEST_TIMEOUT_MS = 15000;
20
+ /** Lock timeout for multi-process token refresh coordination */
9
21
  const LOCK_TIMEOUT_MS = 10000;
22
+ /** Interval between lock acquisition attempts */
10
23
  const LOCK_ATTEMPT_INTERVAL_MS = 100;
24
+ /** Backoff multiplier for lock retry interval */
11
25
  const LOCK_BACKOFF_MULTIPLIER = 1.5;
26
+ /** Maximum interval between lock attempts */
12
27
  const LOCK_MAX_INTERVAL_MS = 2000;
28
+ /** Maximum number of lock acquisition attempts */
13
29
  const LOCK_MAX_ATTEMPTS = 20;
30
+
31
+ /**
32
+ * Checks if an error is an AbortError (from AbortController)
33
+ * @param {*} error - The error to check
34
+ * @returns {boolean} True if error is an AbortError
35
+ */
14
36
  function isAbortError(error) {
15
37
  return typeof error === "object" && error !== null && ("name" in error) && error.name === "AbortError";
16
38
  }
39
+
40
+ /**
41
+ * Checks if an error has a specific error code (for Node.js system errors)
42
+ * @param {*} error - The error to check
43
+ * @param {string} code - The error code to look for (e.g., "EEXIST", "ENOENT")
44
+ * @returns {boolean} True if error has the specified code
45
+ */
17
46
  function hasErrorCode(error, code) {
18
47
  return typeof error === "object" && error !== null && ("code" in error) && error.code === code;
19
48
  }
49
+
50
+ /**
51
+ * Creates a promise that resolves after specified milliseconds
52
+ * @param {number} ms - Milliseconds to sleep
53
+ * @returns {Promise<void>} Promise that resolves after delay
54
+ */
20
55
  function sleep(ms) {
21
56
  return new Promise(resolve => setTimeout(resolve, ms));
22
57
  }
58
+ /**
59
+ * Performs fetch with timeout using AbortController
60
+ * Automatically aborts request if it exceeds timeout
61
+ * @param {string} url - URL to fetch
62
+ * @param {RequestInit} [init] - Fetch options
63
+ * @param {number} [timeoutMs=OAUTH_REQUEST_TIMEOUT_MS] - Timeout in milliseconds
64
+ * @returns {Promise<Response>} Fetch response
65
+ * @throws {Error} If request times out
66
+ */
23
67
  async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS) {
24
68
  const controller = new AbortController();
25
69
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -39,14 +83,23 @@ async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS)
39
83
  clearTimeout(timeoutId);
40
84
  }
41
85
  }
86
+
87
+ /**
88
+ * Normalizes resource URL to valid HTTPS URL format
89
+ * Adds https:// prefix if missing and validates URL format
90
+ * @param {string|undefined} resourceUrl - URL to normalize
91
+ * @returns {string|undefined} Normalized URL or undefined if invalid
92
+ */
42
93
  function normalizeResourceUrl(resourceUrl) {
43
94
  if (!resourceUrl)
44
95
  return undefined;
45
96
  try {
46
97
  let normalizedUrl = resourceUrl;
98
+ // Add https:// prefix if protocol is missing
47
99
  if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
48
100
  normalizedUrl = `https://${normalizedUrl}`;
49
101
  }
102
+ // Validate URL format
50
103
  new URL(normalizedUrl);
51
104
  if (LOGGING_ENABLED) {
52
105
  logInfo("Valid resource_url found and normalized:", normalizedUrl);
@@ -58,21 +111,37 @@ function normalizeResourceUrl(resourceUrl) {
58
111
  return undefined;
59
112
  }
60
113
  }
114
+
115
+ /**
116
+ * Validates OAuth token response has required fields
117
+ * @param {Object} json - Token response JSON
118
+ * @param {string} context - Context for error messages (e.g., "token response", "refresh response")
119
+ * @returns {boolean} True if response is valid
120
+ */
61
121
  function validateTokenResponse(json, context) {
122
+ // Check access_token exists and is string
62
123
  if (!json.access_token || typeof json.access_token !== "string") {
63
124
  logError(`${context} missing access_token`);
64
125
  return false;
65
126
  }
127
+ // Check refresh_token exists and is string
66
128
  if (!json.refresh_token || typeof json.refresh_token !== "string") {
67
129
  logError(`${context} missing refresh_token`);
68
130
  return false;
69
131
  }
132
+ // Check expires_in is valid positive number
70
133
  if (typeof json.expires_in !== "number" || json.expires_in <= 0) {
71
134
  logError(`${context} invalid expires_in:`, json.expires_in);
72
135
  return false;
73
136
  }
74
137
  return true;
75
138
  }
139
+ /**
140
+ * Converts raw token data to standardized stored token format
141
+ * Handles different field name variations (expiry_date vs expires)
142
+ * @param {Object} data - Raw token data from OAuth response or file
143
+ * @returns {Object|null} Normalized token data or null if invalid
144
+ */
76
145
  function toStoredTokenData(data) {
77
146
  if (!data || typeof data !== "object") {
78
147
  return null;
@@ -81,6 +150,7 @@ function toStoredTokenData(data) {
81
150
  const accessToken = typeof raw.access_token === "string" ? raw.access_token : undefined;
82
151
  const refreshToken = typeof raw.refresh_token === "string" ? raw.refresh_token : undefined;
83
152
  const tokenType = typeof raw.token_type === "string" && raw.token_type.length > 0 ? raw.token_type : "Bearer";
153
+ // Handle both expiry_date and expires field names
84
154
  const expiryDate = typeof raw.expiry_date === "number"
85
155
  ? raw.expiry_date
86
156
  : typeof raw.expires === "number"
@@ -89,6 +159,7 @@ function toStoredTokenData(data) {
89
159
  ? Number(raw.expiry_date)
90
160
  : undefined;
91
161
  const resourceUrl = typeof raw.resource_url === "string" ? normalizeResourceUrl(raw.resource_url) : undefined;
162
+ // Validate all required fields are present and valid
92
163
  if (!accessToken || !refreshToken || typeof expiryDate !== "number" || !Number.isFinite(expiryDate) || expiryDate <= 0) {
93
164
  return null;
94
165
  }
@@ -100,6 +171,12 @@ function toStoredTokenData(data) {
100
171
  resource_url: resourceUrl,
101
172
  };
102
173
  }
174
+
175
+ /**
176
+ * Builds token success object from stored token data
177
+ * @param {Object} stored - Stored token data from file
178
+ * @returns {Object} Token success object for SDK
179
+ */
103
180
  function buildTokenSuccessFromStored(stored) {
104
181
  return {
105
182
  type: "success",
@@ -109,12 +186,20 @@ function buildTokenSuccessFromStored(stored) {
109
186
  resourceUrl: stored.resource_url,
110
187
  };
111
188
  }
189
+ /**
190
+ * Writes token data to disk atomically using temp file + rename
191
+ * Uses secure file permissions (0o600 - owner read/write only)
192
+ * @param {Object} tokenData - Token data to write
193
+ * @throws {Error} If write operation fails
194
+ */
112
195
  function writeStoredTokenData(tokenData) {
113
196
  const qwenDir = getQwenDir();
197
+ // Create directory if it doesn't exist with secure permissions
114
198
  if (!existsSync(qwenDir)) {
115
199
  mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
116
200
  }
117
201
  const tokenPath = getTokenPath();
202
+ // Use atomic write: write to temp file then rename
118
203
  const tempPath = `${tokenPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
119
204
  try {
120
205
  writeFileSync(tempPath, JSON.stringify(tokenData, null, 2), {
@@ -124,6 +209,7 @@ function writeStoredTokenData(tokenData) {
124
209
  renameSync(tempPath, tokenPath);
125
210
  }
126
211
  catch (error) {
212
+ // Clean up temp file on error
127
213
  try {
128
214
  if (existsSync(tempPath)) {
129
215
  unlinkSync(tempPath);
@@ -134,12 +220,19 @@ function writeStoredTokenData(tokenData) {
134
220
  throw error;
135
221
  }
136
222
  }
223
+
224
+ /**
225
+ * Migrates legacy token from old plugin location to new location
226
+ * Checks if new token file exists, if not tries to migrate from legacy path
227
+ */
137
228
  function migrateLegacyTokenIfNeeded() {
138
229
  const tokenPath = getTokenPath();
230
+ // Skip if new token file already exists
139
231
  if (existsSync(tokenPath)) {
140
232
  return;
141
233
  }
142
234
  const legacyPath = getLegacyTokenPath();
235
+ // Skip if legacy file doesn't exist
143
236
  if (!existsSync(legacyPath)) {
144
237
  return;
145
238
  }
@@ -158,12 +251,19 @@ function migrateLegacyTokenIfNeeded() {
158
251
  logWarn("Failed to migrate legacy token:", error);
159
252
  }
160
253
  }
254
+ /**
255
+ * Acquires exclusive lock for token refresh to prevent concurrent refreshes
256
+ * Uses file-based locking with exponential backoff retry strategy
257
+ * @returns {Promise<string>} Lock file path if acquired successfully
258
+ * @throws {Error} If lock cannot be acquired within timeout
259
+ */
161
260
  async function acquireTokenLock() {
162
261
  const lockPath = getTokenLockPath();
163
262
  const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
164
263
  let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
165
264
  for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
166
265
  try {
266
+ // Try to create lock file with exclusive flag
167
267
  writeFileSync(lockPath, lockValue, {
168
268
  encoding: "utf-8",
169
269
  flag: "wx",
@@ -172,12 +272,14 @@ async function acquireTokenLock() {
172
272
  return lockPath;
173
273
  }
174
274
  catch (error) {
275
+ // EEXIST means lock file already exists
175
276
  if (!hasErrorCode(error, "EEXIST")) {
176
277
  throw error;
177
278
  }
178
279
  try {
179
280
  const stats = statSync(lockPath);
180
281
  const ageMs = Date.now() - stats.mtimeMs;
282
+ // Remove stale lock if it's older than timeout
181
283
  if (ageMs > LOCK_TIMEOUT_MS) {
182
284
  try {
183
285
  unlinkSync(lockPath);
@@ -196,22 +298,36 @@ async function acquireTokenLock() {
196
298
  logWarn("Failed to inspect token lock file", statError);
197
299
  }
198
300
  }
301
+ // Wait with exponential backoff before retry
199
302
  await sleep(waitMs);
200
303
  waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
201
304
  }
202
305
  }
203
306
  throw new Error("Token refresh lock timeout");
204
307
  }
308
+
309
+ /**
310
+ * Releases token refresh lock
311
+ * Silently ignores errors if lock file doesn't exist
312
+ * @param {string} lockPath - Path to lock file to release
313
+ */
205
314
  function releaseTokenLock(lockPath) {
206
315
  try {
207
316
  unlinkSync(lockPath);
208
317
  }
209
318
  catch (error) {
319
+ // Ignore ENOENT (file not found) errors
210
320
  if (!hasErrorCode(error, "ENOENT")) {
211
321
  logWarn("Failed to release token lock file", error);
212
322
  }
213
323
  }
214
324
  }
325
+ /**
326
+ * Requests device code from Qwen OAuth server
327
+ * Initiates OAuth 2.0 Device Authorization Grant flow
328
+ * @param {{ challenge: string, verifier: string }} pkce - PKCE challenge and verifier
329
+ * @returns {Promise<Object|null>} Device auth response or null on failure
330
+ */
215
331
  export async function requestDeviceCode(pkce) {
216
332
  try {
217
333
  const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
@@ -236,10 +352,12 @@ export async function requestDeviceCode(pkce) {
236
352
  if (LOGGING_ENABLED) {
237
353
  logInfo("Device code response received:", json);
238
354
  }
355
+ // Validate required fields are present
239
356
  if (!json.device_code || !json.user_code || !json.verification_uri) {
240
357
  logError("device code response missing fields:", json);
241
358
  return null;
242
359
  }
360
+ // Fix verification_uri_complete if missing client parameter
243
361
  if (!json.verification_uri_complete || !json.verification_uri_complete.includes(VERIFICATION_URI.CLIENT_PARAM_KEY)) {
244
362
  const baseUrl = json.verification_uri_complete || json.verification_uri;
245
363
  const separator = baseUrl.includes("?") ? "&" : "?";
@@ -255,6 +373,14 @@ export async function requestDeviceCode(pkce) {
255
373
  return null;
256
374
  }
257
375
  }
376
+ /**
377
+ * Polls Qwen OAuth server for access token using device code
378
+ * Implements OAuth 2.0 Device Flow polling with proper error handling
379
+ * @param {string} deviceCode - Device code from requestDeviceCode
380
+ * @param {string} verifier - PKCE code verifier
381
+ * @param {number} [interval=2] - Polling interval in seconds
382
+ * @returns {Promise<Object>} Token result object with type: success|pending|slow_down|failed|denied|expired
383
+ */
258
384
  export async function pollForToken(deviceCode, verifier, interval = 2) {
259
385
  try {
260
386
  const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
@@ -274,6 +400,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
274
400
  const json = await res.json().catch(() => ({}));
275
401
  const errorCode = typeof json.error === "string" ? json.error : undefined;
276
402
  const errorDescription = typeof json.error_description === "string" ? json.error_description : "No details provided";
403
+ // Handle standard OAuth 2.0 Device Flow errors
277
404
  if (errorCode === "authorization_pending") {
278
405
  return { type: "pending" };
279
406
  }
@@ -286,6 +413,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
286
413
  if (errorCode === "access_denied") {
287
414
  return { type: "denied" };
288
415
  }
416
+ // Log and return fatal error for unknown errors
289
417
  logError("token poll failed:", {
290
418
  status: res.status,
291
419
  error: errorCode,
@@ -309,6 +437,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
309
437
  all_fields: Object.keys(json),
310
438
  });
311
439
  }
440
+ // Validate token response structure
312
441
  if (!validateTokenResponse(json, "token response")) {
313
442
  return {
314
443
  type: "failed",
@@ -332,6 +461,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
332
461
  catch (error) {
333
462
  const message = error instanceof Error ? error.message : String(error);
334
463
  const lowered = message.toLowerCase();
464
+ // Identify transient errors that may succeed on retry
335
465
  const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
336
466
  logWarn("token poll failed:", { message, transient });
337
467
  return {
@@ -341,6 +471,11 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
341
471
  };
342
472
  }
343
473
  }
474
+ /**
475
+ * Performs single token refresh attempt
476
+ * @param {string} refreshToken - Refresh token to use
477
+ * @returns {Promise<Object>} Token result object with type: success|failed
478
+ */
344
479
  async function refreshAccessTokenOnce(refreshToken) {
345
480
  try {
346
481
  const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
@@ -360,6 +495,7 @@ async function refreshAccessTokenOnce(refreshToken) {
360
495
  const lowered = text.toLowerCase();
361
496
  const isUnauthorized = res.status === 401 || res.status === 403;
362
497
  const isRateLimited = res.status === 429;
498
+ // Identify transient errors (5xx, timeout, network)
363
499
  const transient = res.status >= 500 || lowered.includes("timed out") || lowered.includes("network");
364
500
  logError("token refresh failed:", { status: res.status, text });
365
501
  return {
@@ -379,6 +515,7 @@ async function refreshAccessTokenOnce(refreshToken) {
379
515
  all_fields: Object.keys(json),
380
516
  });
381
517
  }
518
+ // Validate refresh response structure
382
519
  if (!validateTokenResponse(json, "refresh response")) {
383
520
  return {
384
521
  type: "failed",
@@ -402,6 +539,7 @@ async function refreshAccessTokenOnce(refreshToken) {
402
539
  catch (error) {
403
540
  const message = error instanceof Error ? error.message : String(error);
404
541
  const lowered = message.toLowerCase();
542
+ // Identify transient errors that may succeed on retry
405
543
  const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
406
544
  logError("token refresh error:", { message, transient });
407
545
  return {
@@ -411,33 +549,47 @@ async function refreshAccessTokenOnce(refreshToken) {
411
549
  };
412
550
  }
413
551
  }
552
+ /**
553
+ * Refreshes access token using refresh token with lock coordination
554
+ * Implements retry logic for transient failures
555
+ * @param {string} refreshToken - Refresh token to use
556
+ * @returns {Promise<Object>} Token result object with type: success|failed
557
+ */
414
558
  export async function refreshAccessToken(refreshToken) {
559
+ // Acquire lock to prevent concurrent refresh operations
415
560
  const lockPath = await acquireTokenLock();
416
561
  try {
562
+ // Check if another process already refreshed the token
417
563
  const latest = loadStoredToken();
418
564
  if (latest && !isTokenExpired(latest.expiry_date)) {
419
565
  return buildTokenSuccessFromStored(latest);
420
566
  }
567
+ // Use latest refresh token if available
421
568
  const effectiveRefreshToken = latest?.refresh_token || refreshToken;
569
+ // Retry loop for transient failures
422
570
  for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
423
571
  const result = await refreshAccessTokenOnce(effectiveRefreshToken);
424
572
  if (result.type === "success") {
425
573
  saveToken(result);
426
574
  return result;
427
575
  }
576
+ // Non-retryable errors: 401/403 (unauthorized)
428
577
  if (result.status === 401 || result.status === 403) {
429
578
  logError(`Refresh token rejected (${result.status}), re-authentication required`);
430
579
  clearStoredToken();
431
580
  return { type: "failed", status: result.status, error: "refresh_token_rejected", fatal: true };
432
581
  }
582
+ // Non-retryable errors: 429 (rate limited)
433
583
  if (result.status === 429) {
434
584
  logError("Token refresh rate-limited (429), aborting retries");
435
585
  return { type: "failed", status: 429, error: "rate_limited", fatal: true };
436
586
  }
587
+ // Non-retryable errors: fatal flag set
437
588
  if (result.fatal) {
438
589
  logError("Token refresh failed with fatal error", result);
439
590
  return result;
440
591
  }
592
+ // Retry transient failures
441
593
  if (attempt < MAX_REFRESH_RETRIES) {
442
594
  if (LOGGING_ENABLED) {
443
595
  logInfo(`Token refresh transient failure, retrying attempt ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
@@ -449,14 +601,24 @@ export async function refreshAccessToken(refreshToken) {
449
601
  return { type: "failed", error: "refresh_failed" };
450
602
  }
451
603
  finally {
604
+ // Always release lock
452
605
  releaseTokenLock(lockPath);
453
606
  }
454
607
  }
608
+ /**
609
+ * Generates PKCE challenge and verifier for OAuth flow
610
+ * @returns {Promise<{challenge: string, verifier: string}>} PKCE challenge and verifier pair
611
+ */
455
612
  export async function createPKCE() {
456
613
  const { challenge, verifier } = await generatePKCE();
457
614
  return { challenge, verifier };
458
615
  }
616
+ /**
617
+ * Loads stored token from disk with legacy migration
618
+ * @returns {Object|null} Stored token data or null if not found/invalid
619
+ */
459
620
  export function loadStoredToken() {
621
+ // Migrate legacy token if needed
460
622
  migrateLegacyTokenIfNeeded();
461
623
  const tokenPath = getTokenPath();
462
624
  if (!existsSync(tokenPath)) {
@@ -470,6 +632,7 @@ export function loadStoredToken() {
470
632
  logWarn("Invalid token data, re-authentication required");
471
633
  return null;
472
634
  }
635
+ // Check if token file needs format update
473
636
  const needsRewrite = typeof parsed.expiry_date !== "number" ||
474
637
  typeof parsed.token_type !== "string" ||
475
638
  typeof parsed.expires === "number" ||
@@ -489,6 +652,9 @@ export function loadStoredToken() {
489
652
  return null;
490
653
  }
491
654
  }
655
+ /**
656
+ * Clears stored token from both current and legacy paths
657
+ */
492
658
  export function clearStoredToken() {
493
659
  const targets = [getTokenPath(), getLegacyTokenPath()];
494
660
  for (const tokenPath of targets) {
@@ -504,6 +670,11 @@ export function clearStoredToken() {
504
670
  }
505
671
  }
506
672
  }
673
+ /**
674
+ * Saves token result to disk
675
+ * @param {{ type: string, access: string, refresh: string, expires: number, resourceUrl?: string }} tokenResult - Token result from OAuth flow
676
+ * @throws {Error} If token result is invalid or write fails
677
+ */
507
678
  export function saveToken(tokenResult) {
508
679
  if (tokenResult.type !== "success") {
509
680
  throw new Error("Cannot save non-success token result");
@@ -523,14 +694,25 @@ export function saveToken(tokenResult) {
523
694
  throw error;
524
695
  }
525
696
  }
697
+ /**
698
+ * Checks if token is expired (with buffer)
699
+ * @param {number} expiresAt - Token expiry timestamp in milliseconds
700
+ * @returns {boolean} True if token is expired or expiring soon
701
+ */
526
702
  export function isTokenExpired(expiresAt) {
527
703
  return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
528
704
  }
705
+
706
+ /**
707
+ * Gets valid access token, refreshing if expired
708
+ * @returns {Promise<{ accessToken: string, resourceUrl?: string }|null>} Valid token or null if unavailable
709
+ */
529
710
  export async function getValidToken() {
530
711
  const stored = loadStoredToken();
531
712
  if (!stored) {
532
713
  return null;
533
714
  }
715
+ // Return cached token if still valid
534
716
  if (!isTokenExpired(stored.expiry_date)) {
535
717
  return {
536
718
  accessToken: stored.access_token,
@@ -540,6 +722,7 @@ export async function getValidToken() {
540
722
  if (LOGGING_ENABLED) {
541
723
  logInfo("Token expired, refreshing...");
542
724
  }
725
+ // Token expired, try to refresh
543
726
  const refreshResult = await refreshAccessToken(stored.refresh_token);
544
727
  if (refreshResult.type !== "success") {
545
728
  logError("Token refresh failed, re-authentication required");
@@ -551,6 +734,12 @@ export async function getValidToken() {
551
734
  resourceUrl: refreshResult.resourceUrl,
552
735
  };
553
736
  }
737
+
738
+ /**
739
+ * Constructs DashScope API base URL from resource_url
740
+ * @param {string} [resourceUrl] - Resource URL from token (optional)
741
+ * @returns {string} DashScope API base URL
742
+ */
554
743
  export function getApiBaseUrl(resourceUrl) {
555
744
  if (resourceUrl) {
556
745
  try {
@@ -564,6 +753,7 @@ export function getApiBaseUrl(resourceUrl) {
564
753
  logWarn("Invalid resource_url protocol, using default DashScope endpoint");
565
754
  return DEFAULT_QWEN_BASE_URL;
566
755
  }
756
+ // Ensure URL ends with /v1 suffix
567
757
  let baseUrl = normalizedResourceUrl.replace(/\/$/, "");
568
758
  const suffix = "/v1";
569
759
  if (!baseUrl.endsWith(suffix)) {
@@ -1,31 +1,41 @@
1
1
  /**
2
- * Browser utilities for OAuth flow
3
- * Handles platform-specific browser opening
2
+ * @fileoverview Browser utilities for OAuth flow
3
+ * Handles platform-specific browser opening for OAuth authorization URL
4
+ * @license MIT
4
5
  */
6
+
5
7
  import { spawn } from "node:child_process";
6
8
  import { PLATFORM_OPENERS } from "../constants.js";
9
+
7
10
  /**
8
11
  * Gets the platform-specific command to open a URL in the default browser
9
- * @returns Browser opener command for the current platform
12
+ * @returns {string} Browser opener command for the current platform (darwin: 'open', win32: 'start', linux: 'xdg-open')
10
13
  */
11
14
  export function getBrowserOpener() {
12
15
  const platform = process.platform;
16
+ // macOS uses 'open' command
13
17
  if (platform === "darwin")
14
18
  return PLATFORM_OPENERS.darwin;
19
+ // Windows uses 'start' command
15
20
  if (platform === "win32")
16
21
  return PLATFORM_OPENERS.win32;
22
+ // Linux uses 'xdg-open' command
17
23
  return PLATFORM_OPENERS.linux;
18
24
  }
25
+
19
26
  /**
20
27
  * Opens a URL in the default browser
21
28
  * Silently fails if browser cannot be opened (user can copy URL manually)
22
- * @param url - URL to open
29
+ * @param {string} url - The URL to open in browser (typically OAuth verification URL)
30
+ * @returns {void}
23
31
  */
24
32
  export function openBrowserUrl(url) {
25
33
  try {
26
34
  const opener = getBrowserOpener();
35
+ // Spawn browser process with detached stdio to avoid blocking
27
36
  spawn(opener, [url], {
28
37
  stdio: "ignore",
38
+ // Use shell on Windows for 'start' command to work properly
29
39
  shell: process.platform === "win32",
30
40
  });
31
41
  }
@@ -1,30 +1,46 @@
1
+ /**
2
+ * @fileoverview Configuration utilities for Qwen OAuth Plugin
3
+ * Manages paths for configuration, tokens, and cache directories
4
+ * @license MIT
5
+ */
6
+
1
7
  import { homedir } from "os";
2
8
  import { join } from "path";
3
9
  import { readFileSync, existsSync } from "fs";
10
+
4
11
  /**
5
12
  * Get plugin configuration directory
13
+ * @returns {string} Path to ~/.opencode/qwen/
6
14
  */
7
15
  export function getConfigDir() {
8
16
  return join(homedir(), ".opencode", "qwen");
9
17
  }
18
+
10
19
  /**
11
20
  * Get Qwen CLI credential directory (~/.qwen)
21
+ * This directory is shared with the official qwen-code CLI for token storage
22
+ * @returns {string} Path to ~/.qwen/
12
23
  */
13
24
  export function getQwenDir() {
14
25
  return join(homedir(), ".qwen");
15
26
  }
27
+
16
28
  /**
17
29
  * Get plugin configuration file path
30
+ * @returns {string} Path to ~/.opencode/qwen/auth-config.json
18
31
  */
19
32
  export function getConfigPath() {
20
33
  return join(getConfigDir(), "auth-config.json");
21
34
  }
35
+
22
36
  /**
23
37
  * Load plugin configuration from ~/.opencode/qwen/auth-config.json
24
38
  * Returns default config if file doesn't exist
39
+ * @returns {{ qwenMode: boolean }} Configuration object with qwenMode flag
25
40
  */
26
41
  export function loadPluginConfig() {
27
42
  const configPath = getConfigPath();
43
+ // Return default config if config file doesn't exist
28
44
  if (!existsSync(configPath)) {
29
45
  return { qwenMode: true }; // Default: QWEN_MODE enabled
30
46
  }
@@ -33,15 +49,20 @@ export function loadPluginConfig() {
33
49
  return JSON.parse(content);
34
50
  }
35
51
  catch (error) {
52
+ // Log warning and return default config on parse error
36
53
  console.warn(`[qwen-oauth-plugin] Failed to load config from ${configPath}:`, error);
37
54
  return { qwenMode: true };
38
55
  }
39
56
  }
57
+
40
58
  /**
41
59
  * Get QWEN_MODE setting
42
60
  * Priority: QWEN_MODE env var > config file > default (true)
61
+ * @param {{ qwenMode?: boolean|string|null }} config - Configuration object from file
62
+ * @returns {boolean} True if QWEN_MODE is enabled, false otherwise
43
63
  */
44
64
  export function getQwenMode(config) {
65
+ // Environment variable takes highest priority
45
66
  const envValue = process.env.QWEN_MODE;
46
67
  if (envValue !== undefined) {
47
68
  return envValue === "1" || envValue.toLowerCase() === "true";
@@ -49,31 +70,44 @@ export function getQwenMode(config) {
49
70
  // Ensure boolean type, avoid string "false" being truthy
50
71
  const val = config.qwenMode;
51
72
  if (val === undefined || val === null) return true; // default: enabled
73
+ // Handle string values from config file
52
74
  if (typeof val === "string") {
53
75
  return val === "1" || val.toLowerCase() === "true";
54
76
  }
77
+ // Convert to boolean for actual boolean values
55
78
  return !!val;
56
79
  }
80
+
57
81
  /**
58
82
  * Get token storage path
83
+ * Token file contains OAuth credentials: access_token, refresh_token, expiry_date, resource_url
84
+ * @returns {string} Path to ~/.qwen/oauth_creds.json
59
85
  */
60
86
  export function getTokenPath() {
61
87
  return join(getQwenDir(), "oauth_creds.json");
62
88
  }
89
+
63
90
  /**
64
91
  * Get token lock path for multi-process refresh coordination
92
+ * Prevents concurrent token refresh operations across multiple processes
93
+ * @returns {string} Path to ~/.qwen/oauth_creds.lock
65
94
  */
66
95
  export function getTokenLockPath() {
67
96
  return join(getQwenDir(), "oauth_creds.lock");
68
97
  }
98
+
69
99
  /**
70
100
  * Get legacy token storage path used by old plugin versions
101
+ * Used for backward compatibility and token migration
102
+ * @returns {string} Path to ~/.opencode/qwen/oauth_token.json
71
103
  */
72
104
  export function getLegacyTokenPath() {
73
105
  return join(getConfigDir(), "oauth_token.json");
74
106
  }
107
+
75
108
  /**
76
109
  * Get cache directory for prompts
110
+ * @returns {string} Path to ~/.opencode/cache/
77
111
  */
78
112
  export function getCacheDir() {
79
113
  return join(homedir(), ".opencode", "cache");