happyskills 0.30.0 → 0.30.1

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
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.30.1] - 2026-04-05
11
+
12
+ ### Fixed
13
+ - Fix frequent unnecessary re-login caused by transient refresh failures (network errors, Lambda/NeonDB cold starts) destroying the credentials file — now only clears credentials on permanent failures (Cognito token rejection), preserves the refresh token for transient errors so the next CLI invocation can retry
14
+ - Add diagnostic logging to token refresh failures (`print_warn` to stderr) with actual error reason, replacing silent failure that made auth issues impossible to diagnose
15
+ - Add single retry on transient refresh failure within the same CLI invocation
16
+
10
17
  ## [0.30.0] - 2026-04-03
11
18
 
12
19
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -3,18 +3,43 @@ const path = require('path')
3
3
  const { error: { catch_errors } } = require('puffy-core')
4
4
  const { credentials_path, config_dir } = require('../config/paths')
5
5
 
6
+ const _is_permanent_failure = (errors) => {
7
+ if (!errors) return false
8
+ const { AuthError } = require('../utils/errors')
9
+ return errors.some(e => e instanceof AuthError)
10
+ }
11
+
6
12
  const _try_refresh = async (refresh_token) => {
7
- try {
8
- const { refresh } = require('../api/auth')
9
- const [errors, data] = await refresh(refresh_token)
10
- if (errors || !data) return null
11
- const merged = { ...data, refresh_token }
12
- const [save_err] = await save_token(merged)
13
- if (save_err) return null
14
- return { ...merged, stored_at: new Date().toISOString() }
15
- } catch {
16
- return null
13
+ const max_attempts = 2
14
+ let permanent = false
15
+ for (let attempt = 1; attempt <= max_attempts; attempt++) {
16
+ try {
17
+ const { refresh } = require('../api/auth')
18
+ const [errors, data] = await refresh(refresh_token)
19
+ if (errors || !data) {
20
+ const reason = errors
21
+ ? errors.map(e => e.message || String(e)).join('; ')
22
+ : 'empty response from server'
23
+ const { print_warn } = require('../ui/output')
24
+ print_warn(`Token refresh failed (attempt ${attempt}/${max_attempts}): ${reason}`)
25
+ permanent = _is_permanent_failure(errors)
26
+ if (permanent || attempt >= max_attempts) return { ok: false, permanent }
27
+ continue
28
+ }
29
+ const merged = { ...data, refresh_token }
30
+ const [save_err] = await save_token(merged)
31
+ if (save_err) return { ok: false, permanent: false }
32
+ return { ok: true, data: { ...merged, stored_at: new Date().toISOString() } }
33
+ } catch (err) {
34
+ const { print_warn } = require('../ui/output')
35
+ print_warn(`Token refresh exception (attempt ${attempt}/${max_attempts}): ${err.message || String(err)}`)
36
+ const { AuthError } = require('../utils/errors')
37
+ permanent = err instanceof AuthError
38
+ if (permanent || attempt >= max_attempts) return { ok: false, permanent }
39
+ continue
40
+ }
17
41
  }
42
+ return { ok: false, permanent: false }
18
43
  }
19
44
 
20
45
  const save_token = (token_data) => catch_errors('Failed to save token', async () => {
@@ -55,10 +80,16 @@ const load_token = () => catch_errors('Failed to load token', async () => {
55
80
  const elapsed_sec = (now - stored) / 1000
56
81
  if (elapsed_sec >= data.expires_in) {
57
82
  if (data.refresh_token) {
58
- const refreshed = await _try_refresh(data.refresh_token)
59
- if (refreshed) return refreshed
83
+ const result = await _try_refresh(data.refresh_token)
84
+ if (result.ok) return result.data
85
+ // Permanent failure (Cognito rejected the token) → clear the file.
86
+ // Transient failure (network, cold start) → preserve the file so
87
+ // the next invocation can retry with the still-valid refresh token.
88
+ if (result.permanent) await clear_token()
89
+ } else {
90
+ // No refresh token — nothing to preserve, clean up the expired file.
91
+ await clear_token()
60
92
  }
61
- await clear_token()
62
93
  return null
63
94
  }
64
95
  }
@@ -12,7 +12,7 @@ const fs = require('fs')
12
12
  // ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
14
  const { save_token, load_token, clear_token, require_token } = require('./token_store')
15
- const { AuthError } = require('../utils/errors')
15
+ const { AuthError, NetworkError } = require('../utils/errors')
16
16
 
17
17
  // ─── helpers ──────────────────────────────────────────────────────────────────
18
18
 
@@ -200,3 +200,199 @@ describe('require_token', () => {
200
200
  })
201
201
  })
202
202
  })
203
+
204
+ // ─── token refresh behavior ─────────────────────────────────────────────────
205
+ //
206
+ // _try_refresh is internal, but its behavior is observable through load_token:
207
+ // when the id_token is expired and a refresh_token exists, load_token calls
208
+ // _try_refresh which calls require('../api/auth').refresh(). We mock that
209
+ // module in require.cache to control the refresh outcome.
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+
212
+ describe('load_token — refresh behavior', () => {
213
+ const auth_path = require.resolve('../api/auth')
214
+ let orig_auth_cache
215
+
216
+ const mock_refresh = (fn) => {
217
+ orig_auth_cache = require.cache[auth_path]
218
+ require.cache[auth_path] = {
219
+ id: auth_path,
220
+ filename: auth_path,
221
+ loaded: true,
222
+ exports: { refresh: fn }
223
+ }
224
+ }
225
+
226
+ const restore_refresh = () => {
227
+ if (orig_auth_cache) require.cache[auth_path] = orig_auth_cache
228
+ else delete require.cache[auth_path]
229
+ }
230
+
231
+ const write_expired_token = (dir, overrides = {}) => {
232
+ const token = {
233
+ id_token: 'expired-id-token',
234
+ access_token: 'expired-access-token',
235
+ refresh_token: 'valid-refresh-token',
236
+ expires_in: 1,
237
+ stored_at: new Date(Date.now() - 5000).toISOString(),
238
+ ...overrides
239
+ }
240
+ fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
241
+ fs.writeFileSync(creds_file(dir), JSON.stringify(token), { mode: 0o600 })
242
+ return token
243
+ }
244
+
245
+ it('returns refreshed token when refresh succeeds', async () => {
246
+ await with_tmp(async (dir) => {
247
+ write_expired_token(dir)
248
+ mock_refresh(async () => [null, {
249
+ id_token: 'new-id-token',
250
+ access_token: 'new-access-token',
251
+ expires_in: 3600
252
+ }])
253
+ try {
254
+ const [err, loaded] = await load_token()
255
+ assert.strictEqual(err, null)
256
+ assert.ok(loaded, 'should return refreshed token')
257
+ assert.strictEqual(loaded.id_token, 'new-id-token')
258
+ assert.strictEqual(loaded.access_token, 'new-access-token')
259
+ assert.strictEqual(loaded.refresh_token, 'valid-refresh-token', 'original refresh_token preserved')
260
+ assert.ok(loaded.stored_at, 'stored_at should be set')
261
+ } finally {
262
+ restore_refresh()
263
+ }
264
+ })
265
+ })
266
+
267
+ it('saves refreshed tokens to disk after successful refresh', async () => {
268
+ await with_tmp(async (dir) => {
269
+ write_expired_token(dir)
270
+ mock_refresh(async () => [null, {
271
+ id_token: 'refreshed-on-disk',
272
+ access_token: 'new-access',
273
+ expires_in: 7200
274
+ }])
275
+ try {
276
+ await load_token()
277
+ const on_disk = JSON.parse(fs.readFileSync(creds_file(dir), 'utf-8'))
278
+ assert.strictEqual(on_disk.id_token, 'refreshed-on-disk')
279
+ assert.strictEqual(on_disk.refresh_token, 'valid-refresh-token', 'refresh_token preserved on disk')
280
+ assert.strictEqual(on_disk.expires_in, 7200)
281
+ assert.ok(on_disk.stored_at, 'stored_at should be updated on disk')
282
+ } finally {
283
+ restore_refresh()
284
+ }
285
+ })
286
+ })
287
+
288
+ it('preserves credentials file on transient refresh failure (NetworkError)', async () => {
289
+ await with_tmp(async (dir) => {
290
+ write_expired_token(dir)
291
+ mock_refresh(async () => [[new NetworkError('connection failed')], null])
292
+ try {
293
+ const [err, loaded] = await load_token()
294
+ assert.strictEqual(err, null)
295
+ assert.strictEqual(loaded, null, 'should return null on failed refresh')
296
+ assert.ok(fs.existsSync(creds_file(dir)), 'credentials file should be preserved for retry')
297
+ // Verify the refresh_token is still intact on disk
298
+ const on_disk = JSON.parse(fs.readFileSync(creds_file(dir), 'utf-8'))
299
+ assert.strictEqual(on_disk.refresh_token, 'valid-refresh-token')
300
+ } finally {
301
+ restore_refresh()
302
+ }
303
+ })
304
+ })
305
+
306
+ it('preserves credentials file on server error (ApiError)', async () => {
307
+ await with_tmp(async (dir) => {
308
+ write_expired_token(dir)
309
+ const { ApiError } = require('../utils/errors')
310
+ mock_refresh(async () => [[new ApiError('Internal server error', 500, 'INTERNAL_ERROR')], null])
311
+ try {
312
+ const [err, loaded] = await load_token()
313
+ assert.strictEqual(err, null)
314
+ assert.strictEqual(loaded, null)
315
+ assert.ok(fs.existsSync(creds_file(dir)), 'credentials file should be preserved on server error')
316
+ } finally {
317
+ restore_refresh()
318
+ }
319
+ })
320
+ })
321
+
322
+ it('deletes credentials file on permanent refresh failure (AuthError)', async () => {
323
+ await with_tmp(async (dir) => {
324
+ write_expired_token(dir)
325
+ mock_refresh(async () => [[new AuthError('token revoked')], null])
326
+ try {
327
+ const [err, loaded] = await load_token()
328
+ assert.strictEqual(err, null)
329
+ assert.strictEqual(loaded, null)
330
+ assert.ok(!fs.existsSync(creds_file(dir)), 'credentials file should be deleted on permanent failure')
331
+ } finally {
332
+ restore_refresh()
333
+ }
334
+ })
335
+ })
336
+
337
+ it('does not retry on permanent failure', async () => {
338
+ await with_tmp(async (dir) => {
339
+ write_expired_token(dir)
340
+ let call_count = 0
341
+ mock_refresh(async () => {
342
+ call_count++
343
+ return [[new AuthError('token expired')], null]
344
+ })
345
+ try {
346
+ await load_token()
347
+ assert.strictEqual(call_count, 1, 'should not retry on permanent failure')
348
+ } finally {
349
+ restore_refresh()
350
+ }
351
+ })
352
+ })
353
+
354
+ it('retries once on transient failure then succeeds', async () => {
355
+ await with_tmp(async (dir) => {
356
+ write_expired_token(dir)
357
+ let call_count = 0
358
+ mock_refresh(async () => {
359
+ call_count++
360
+ if (call_count === 1) return [[new NetworkError('cold start')], null]
361
+ return [null, {
362
+ id_token: 'retried-id-token',
363
+ access_token: 'retried-access-token',
364
+ expires_in: 3600
365
+ }]
366
+ })
367
+ try {
368
+ const [err, loaded] = await load_token()
369
+ assert.strictEqual(err, null)
370
+ assert.ok(loaded, 'should return refreshed token after retry')
371
+ assert.strictEqual(loaded.id_token, 'retried-id-token')
372
+ assert.strictEqual(call_count, 2, 'refresh should have been called twice')
373
+ } finally {
374
+ restore_refresh()
375
+ }
376
+ })
377
+ })
378
+
379
+ it('retries once on transient failure then gives up', async () => {
380
+ await with_tmp(async (dir) => {
381
+ write_expired_token(dir)
382
+ let call_count = 0
383
+ mock_refresh(async () => {
384
+ call_count++
385
+ return [[new NetworkError('still failing')], null]
386
+ })
387
+ try {
388
+ const [err, loaded] = await load_token()
389
+ assert.strictEqual(err, null)
390
+ assert.strictEqual(loaded, null)
391
+ assert.strictEqual(call_count, 2, 'should have retried once')
392
+ assert.ok(fs.existsSync(creds_file(dir)), 'credentials file preserved after exhausting retries')
393
+ } finally {
394
+ restore_refresh()
395
+ }
396
+ })
397
+ })
398
+ })