|
96 | 96 | return out |
97 | 97 | } |
98 | 98 |
|
99 | | - function tryParseCredentialJSON(text) { |
| 99 | + function tryParseCredentialJSON(ctx, text) { |
100 | 100 | if (!text) return null |
101 | | - const trimmed = String(text).trim() |
102 | | - if (!trimmed) return null |
103 | | - try { |
104 | | - return JSON.parse(trimmed) |
105 | | - } catch {} |
| 101 | + const parsed = ctx.util.tryParseJson(text) |
| 102 | + if (parsed) return parsed |
106 | 103 |
|
107 | 104 | // Some macOS keychain items are returned by `security ... -w` as hex-encoded UTF-8 bytes. |
108 | 105 | // Example prefix: "7b0a" ( "{\\n" ). |
109 | 106 | // Support both plain hex and "0x..." forms. |
110 | | - let hex = trimmed |
| 107 | + let hex = String(text).trim() |
111 | 108 | if (hex.startsWith("0x") || hex.startsWith("0X")) hex = hex.slice(2) |
112 | 109 | if (!hex || hex.length % 2 !== 0) return null |
113 | 110 | if (!/^[0-9a-fA-F]+$/.test(hex)) return null |
|
117 | 114 | bytes.push(parseInt(hex.slice(i, i + 2), 16)) |
118 | 115 | } |
119 | 116 | const decoded = utf8DecodeBytes(bytes) |
120 | | - return JSON.parse(decoded) |
| 117 | + const decodedParsed = ctx.util.tryParseJson(decoded) |
| 118 | + if (decodedParsed) return decodedParsed |
121 | 119 | } catch {} |
122 | 120 |
|
123 | 121 | return null |
|
128 | 126 | if (ctx.host.fs.exists(CRED_FILE)) { |
129 | 127 | try { |
130 | 128 | const text = ctx.host.fs.readText(CRED_FILE) |
131 | | - const parsed = tryParseCredentialJSON(text) |
| 129 | + const parsed = tryParseCredentialJSON(ctx, text) |
132 | 130 | if (parsed) { |
133 | 131 | const oauth = parsed.claudeAiOauth |
134 | 132 | if (oauth && oauth.accessToken) { |
|
143 | 141 | try { |
144 | 142 | const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) |
145 | 143 | if (keychainValue) { |
146 | | - const parsed = tryParseCredentialJSON(keychainValue) |
| 144 | + const parsed = tryParseCredentialJSON(ctx, keychainValue) |
147 | 145 | if (parsed) { |
148 | 146 | const oauth = parsed.claudeAiOauth |
149 | 147 | if (oauth && oauth.accessToken) { |
|
174 | 172 | } |
175 | 173 | } |
176 | 174 |
|
177 | | - function needsRefresh(oauth, nowMs) { |
178 | | - if (!oauth.expiresAt) return true |
179 | | - const expiresAt = Number(oauth.expiresAt) |
180 | | - if (!Number.isFinite(expiresAt)) return true |
181 | | - return nowMs + REFRESH_BUFFER_MS >= expiresAt |
| 175 | + function needsRefresh(ctx, oauth, nowMs) { |
| 176 | + return ctx.util.needsRefreshByExpiry({ |
| 177 | + nowMs, |
| 178 | + expiresAtMs: oauth.expiresAt, |
| 179 | + bufferMs: REFRESH_BUFFER_MS, |
| 180 | + }) |
182 | 181 | } |
183 | 182 |
|
184 | 183 | function refreshToken(ctx, creds) { |
185 | 184 | const { oauth, source, fullData } = creds |
186 | 185 | if (!oauth.refreshToken) return null |
187 | 186 |
|
188 | 187 | try { |
189 | | - const resp = ctx.host.http.request({ |
| 188 | + const resp = ctx.util.request({ |
190 | 189 | method: "POST", |
191 | 190 | url: REFRESH_URL, |
192 | 191 | headers: { "Content-Type": "application/json" }, |
|
201 | 200 |
|
202 | 201 | if (resp.status === 400 || resp.status === 401) { |
203 | 202 | let errorCode = null |
204 | | - try { |
205 | | - const body = JSON.parse(resp.bodyText) |
206 | | - errorCode = body.error || body.error_description |
207 | | - } catch {} |
| 203 | + const body = ctx.util.tryParseJson(resp.bodyText) |
| 204 | + if (body) errorCode = body.error || body.error_description |
208 | 205 | if (errorCode === "invalid_grant") { |
209 | 206 | throw "Session expired. Run `claude` to log in again." |
210 | 207 | } |
211 | 208 | throw "Token expired. Run `claude` to log in again." |
212 | 209 | } |
213 | 210 | if (resp.status < 200 || resp.status >= 300) return null |
214 | 211 |
|
215 | | - const body = JSON.parse(resp.bodyText) |
| 212 | + const body = ctx.util.tryParseJson(resp.bodyText) |
| 213 | + if (!body) return null |
216 | 214 | const newAccessToken = body.access_token |
217 | 215 | if (!newAccessToken) return null |
218 | 216 |
|
|
235 | 233 | } |
236 | 234 |
|
237 | 235 | function fetchUsage(ctx, accessToken) { |
238 | | - return ctx.host.http.request({ |
| 236 | + return ctx.util.request({ |
239 | 237 | method: "GET", |
240 | 238 | url: USAGE_URL, |
241 | 239 | headers: { |
|
251 | 249 |
|
252 | 250 | function getResetInFromIso(ctx, isoString) { |
253 | 251 | if (!isoString) return null |
254 | | - const ts = Date.parse(isoString) |
255 | | - if (!Number.isFinite(ts)) return null |
| 252 | + const ts = ctx.util.parseDateMs(isoString) |
| 253 | + if (ts === null) return null |
256 | 254 | const diffSeconds = Math.floor((ts - Date.now()) / 1000) |
257 | 255 | return ctx.fmt.resetIn(diffSeconds) |
258 | 256 | } |
|
267 | 265 | let accessToken = creds.oauth.accessToken |
268 | 266 |
|
269 | 267 | // Proactively refresh if token is expired or about to expire |
270 | | - if (needsRefresh(creds.oauth, nowMs)) { |
| 268 | + if (needsRefresh(ctx, creds.oauth, nowMs)) { |
271 | 269 | const refreshed = refreshToken(ctx, creds) |
272 | 270 | if (refreshed) accessToken = refreshed |
273 | 271 | } |
274 | 272 |
|
275 | 273 | let resp |
| 274 | + let didRefresh = false |
276 | 275 | try { |
277 | | - resp = fetchUsage(ctx, accessToken) |
| 276 | + resp = ctx.util.retryOnceOnAuth({ |
| 277 | + request: (token) => { |
| 278 | + try { |
| 279 | + return fetchUsage(ctx, token || accessToken) |
| 280 | + } catch (e) { |
| 281 | + if (didRefresh) { |
| 282 | + throw "Usage request failed after refresh. Try again." |
| 283 | + } |
| 284 | + throw "Usage request failed. Check your connection." |
| 285 | + } |
| 286 | + }, |
| 287 | + refresh: () => { |
| 288 | + didRefresh = true |
| 289 | + return refreshToken(ctx, creds) |
| 290 | + }, |
| 291 | + }) |
278 | 292 | } catch (e) { |
| 293 | + if (typeof e === "string") throw e |
279 | 294 | throw "Usage request failed. Check your connection." |
280 | 295 | } |
281 | 296 |
|
282 | | - // On 401/403, try refreshing once and retry |
283 | | - if (resp.status === 401 || resp.status === 403) { |
284 | | - const refreshed = refreshToken(ctx, creds) |
285 | | - if (!refreshed) { |
286 | | - throw "Token expired. Run `claude` to log in again." |
287 | | - } |
288 | | - try { |
289 | | - resp = fetchUsage(ctx, refreshed) |
290 | | - } catch (e) { |
291 | | - throw "Usage request failed after refresh. Try again." |
292 | | - } |
293 | | - if (resp.status === 401 || resp.status === 403) { |
294 | | - throw "Token expired. Run `claude` to log in again." |
295 | | - } |
| 297 | + if (ctx.util.isAuthStatus(resp.status)) { |
| 298 | + throw "Token expired. Run `claude` to log in again." |
296 | 299 | } |
297 | 300 |
|
298 | 301 | if (resp.status < 200 || resp.status >= 300) { |
299 | 302 | throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later." |
300 | 303 | } |
301 | 304 |
|
302 | 305 | let data |
303 | | - try { |
304 | | - data = JSON.parse(resp.bodyText) |
305 | | - } catch { |
| 306 | + data = ctx.util.tryParseJson(resp.bodyText) |
| 307 | + if (data === null) { |
306 | 308 | throw "Usage response invalid. Try again later." |
307 | 309 | } |
308 | 310 |
|
|
0 commit comments