@hienlh/ppm 0.8.21 → 0.8.23

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.23] - 2026-03-24
4
+
5
+ ### Fixed
6
+ - **Stop disabling accounts on background refresh failure**: Startup and background auto-refresh no longer disable accounts when refresh fails — only actual query-time failures disable accounts. Prevents all accounts being disabled after server restart.
7
+ - **Better refresh error logging**: Log response body from Anthropic when token refresh fails (was only logging status code)
8
+
9
+ ## [0.8.22] - 2026-03-24
10
+
11
+ ### Fixed
12
+ - **OAuth pre-flight token refresh**: Check token freshness before each SDK query and refresh proactively if expired or expiring within 60s
13
+ - **Auto-refresh on startup**: Run token refresh check immediately on server start instead of waiting 5 minutes for first interval
14
+ - **OAuth auto-refresh on auth failure**: Automatically refresh expired OAuth token and retry when SDK returns `authentication_failed`, instead of just showing error to user
15
+
3
16
  ## [0.8.21] - 2026-03-24
4
17
 
5
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -545,7 +545,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
545
545
  // Account-based auth injection (multi-account mode)
546
546
  // Fallback to existing env (ANTHROPIC_API_KEY) when no accounts configured.
547
547
  const accountsEnabled = accountSelector.isEnabled();
548
- const account = accountsEnabled ? accountSelector.next() : null;
548
+ let account = accountsEnabled ? accountSelector.next() : null;
549
549
  if (accountsEnabled && !account) {
550
550
  // All accounts in DB but none usable
551
551
  const reason = accountSelector.lastFailReason;
@@ -557,7 +557,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
557
557
  yield { type: "done" as const, sessionId, resultSubtype: "error_auth" };
558
558
  return;
559
559
  }
560
+ // Pre-flight: ensure OAuth token is fresh before sending to SDK
560
561
  if (account) {
562
+ const fresh = await accountService.ensureFreshToken(account.id);
563
+ if (fresh) {
564
+ account = fresh;
565
+ }
561
566
  console.log(`[sdk] Using account ${account.id} (${account.email ?? "no-email"})`);
562
567
  yield { type: "account_info" as const, accountId: account.id, accountLabel: account.label ?? account.email ?? "Unknown" };
563
568
  }
@@ -116,6 +116,29 @@ class AccountService {
116
116
  }
117
117
  }
118
118
 
119
+ /**
120
+ * Ensure the access token for an OAuth account is still fresh.
121
+ * If it's expired or about to expire (within 60s), refresh it proactively.
122
+ * Returns the refreshed account with fresh tokens, or null if refresh failed.
123
+ */
124
+ async ensureFreshToken(id: string): Promise<AccountWithTokens | null> {
125
+ const acc = this.getWithTokens(id);
126
+ if (!acc) return null;
127
+ // Only OAuth tokens need refresh
128
+ if (!acc.accessToken.startsWith("sk-ant-oat")) return acc;
129
+ if (!acc.expiresAt) return acc;
130
+ const nowS = Math.floor(Date.now() / 1000);
131
+ if (acc.expiresAt - nowS > 60) return acc; // still fresh
132
+ try {
133
+ console.log(`[accounts] Pre-flight refresh for ${acc.email ?? id} (expires in ${acc.expiresAt - nowS}s)`);
134
+ await this.refreshAccessToken(id, false);
135
+ return this.getWithTokens(id);
136
+ } catch (e) {
137
+ console.error(`[accounts] Pre-flight refresh failed for ${id}:`, e);
138
+ return null;
139
+ }
140
+ }
141
+
119
142
  /** Find existing account by email or profile UUID */
120
143
  private findDuplicate(email?: string | null, profileData?: OAuthProfileData | null): Account | null {
121
144
  if (!email && !profileData?.account?.uuid) return null;
@@ -479,7 +502,12 @@ class AccountService {
479
502
  };
480
503
  }
481
504
 
482
- async refreshAccessToken(accountId: string): Promise<void> {
505
+ /**
506
+ * Refresh an OAuth access token using the stored refresh token.
507
+ * @param disableOnFail - if true, disable the account when refresh fails (default: true).
508
+ * Background/startup refresh should pass false to avoid disabling accounts prematurely.
509
+ */
510
+ async refreshAccessToken(accountId: string, disableOnFail = true): Promise<void> {
483
511
  const account = this.getWithTokens(accountId);
484
512
  if (!account) throw new Error(`Account ${accountId} not found`);
485
513
  const res = await fetch(OAUTH_TOKEN_URL, {
@@ -492,14 +520,19 @@ class AccountService {
492
520
  }),
493
521
  });
494
522
  if (!res.ok) {
495
- this.setDisabled(accountId);
496
- throw new Error(`Token refresh failed for account ${accountId}: ${res.status}`);
523
+ const errorBody = await res.text().catch(() => "");
524
+ console.error(`[accounts] Refresh failed for ${accountId}: ${res.status} ${errorBody}`);
525
+ if (disableOnFail) {
526
+ this.setDisabled(accountId);
527
+ }
528
+ throw new Error(`Token refresh failed for account ${accountId}: ${res.status} ${errorBody}`);
497
529
  }
498
530
  const data = await res.json() as {
499
531
  access_token: string;
500
532
  refresh_token?: string;
501
533
  expires_in: number;
502
534
  };
535
+ console.log(`[accounts] Token refreshed for ${account.email ?? accountId} (expires_in=${data.expires_in}s, new_refresh=${!!data.refresh_token})`);
503
536
  this.updateTokens(
504
537
  accountId,
505
538
  data.access_token,
@@ -586,7 +619,7 @@ class AccountService {
586
619
  const CHECK_INTERVAL_MS = 5 * 60_000;
587
620
  const REFRESH_BUFFER_S = 5 * 60;
588
621
 
589
- this.refreshTimer = setInterval(async () => {
622
+ const refreshExpiring = async () => {
590
623
  const accounts = this.list();
591
624
  const nowS = Math.floor(Date.now() / 1000);
592
625
  for (const acc of accounts) {
@@ -595,12 +628,16 @@ class AccountService {
595
628
  if (acc.expiresAt - nowS > REFRESH_BUFFER_S) continue;
596
629
  console.log(`[accounts] Auto-refreshing token for ${acc.email ?? acc.id}`);
597
630
  try {
598
- await this.refreshAccessToken(acc.id);
631
+ await this.refreshAccessToken(acc.id, false);
599
632
  } catch (e) {
600
633
  console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
601
634
  }
602
635
  }
603
- }, CHECK_INTERVAL_MS);
636
+ };
637
+
638
+ // Run immediately on startup, then every 5 minutes
639
+ refreshExpiring().catch(() => {});
640
+ this.refreshTimer = setInterval(refreshExpiring, CHECK_INTERVAL_MS);
604
641
 
605
642
  if (typeof this.refreshTimer === "object" && this.refreshTimer !== null && "unref" in this.refreshTimer) {
606
643
  (this.refreshTimer as NodeJS.Timeout).unref();