happyskills 1.13.0 → 1.13.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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.13.1] - 2026-06-29
11
+
12
+ ### Fixed
13
+
14
+ - Stop a transient session-refresh hiccup from becoming a terminal "session expired" auth failure on a **write** (`publish`/`release` and other authenticated writes). When your `id_token` had expired and the automatic refresh blipped (a network/cold-start failure on the refresh call), the CLI would fire the write with no token, the API would answer `401`, and that was rendered as `AUTH_REQUIRED` ("your session has expired … run `happyskills login`") — aborting the command even though an immediate retry succeeds. Now, when valid credentials exist on disk but the token simply couldn't be refreshed at that moment, a write surfaces a **retryable** `NETWORK_ERROR` (a `retry` `next_step`) instead of sending a tokenless request. The guard is deliberately narrow: **reads are unchanged** (a `GET` still degrades to anonymous/public results), a **corrupt/unreadable credentials file** still falls through to re-login (it is not turned into an infinite "please retry" loop), and the genuinely-not-signed-in case (no credentials) is unchanged. (Spec 260629-02.)
15
+
10
16
  ## [1.13.0] - 2026-06-29
11
17
 
12
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.13.0",
3
+ "version": "1.13.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)",
package/src/api/client.js CHANGED
@@ -2,7 +2,7 @@ const { createHash } = require('crypto')
2
2
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
3
3
  const { API_URL, CLI_VERSION } = require('../constants')
4
4
  const { ApiError, NetworkError, AuthError } = require('../utils/errors')
5
- const { load_token } = require('../auth/token_store')
5
+ const { load_token, credentials_present } = require('../auth/token_store')
6
6
  const { get_envelope, set_envelope } = require('../utils/intent')
7
7
 
8
8
  const get_base_url = () => process.env.HAPPYSKILLS_API_URL || API_URL
@@ -29,10 +29,28 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
29
29
 
30
30
  let token_attached = false
31
31
  if (auth) {
32
- const [, token_data] = await load_token()
32
+ const [token_err, token_data] = await load_token()
33
33
  if (token_data) {
34
34
  headers['Authorization'] = `Bearer ${token_data.id_token}`
35
35
  token_attached = true
36
+ } else if (!token_err && credentials_present() && method !== 'GET' && method !== 'HEAD') {
37
+ // A WRITE (non-GET/HEAD) where load_token returned CLEANLY (no error) with no
38
+ // token, yet the credentials file still exists. That is uniquely the
39
+ // TRANSIENT-refresh-failure branch: a real session whose id_token expired and
40
+ // whose proactive refresh blipped (network / cold start) — load_token preserves
41
+ // the still-valid file and returns [null, null]. Permanent failures (Cognito
42
+ // rejected the token) and the no-refresh-token case both DELETE the file, and a
43
+ // corrupt/unreadable file returns [errors, undefined] (token_err set) — so
44
+ // guarding on `!token_err && file-present` isolates the genuinely-retryable case
45
+ // and lets a corrupt file fall through to a tokenless request (→ server 401 →
46
+ // re-login, which overwrites the bad file) rather than a dead retry loop.
47
+ // Firing a tokenless WRITE in the transient case makes the API answer 401 and
48
+ // the CLI render it as a terminal AUTH_REQUIRED "your session has expired" — the
49
+ // spurious-AUTH_REQUIRED bug (spec 260629-02). Surface a retryable error instead.
50
+ // Scoped to writes ONLY: a read (GET/HEAD) keeps its pre-existing behavior —
51
+ // proceed tokenless and degrade to anonymous/public results (spec §6.3: "keep
52
+ // the read-path behavior unchanged"); a write must not silently lose authorship.
53
+ throw new NetworkError('Your session could not be refreshed right now (a temporary issue). Please retry.')
36
54
  }
37
55
  }
38
56
 
@@ -122,3 +122,156 @@ describe('api client — 401 message never tells a signed-in user to log in', ()
122
122
  })
123
123
  })
124
124
  })
125
+
126
+ // ─────────────────────────────────────────────────────────────────────────────
127
+ // Transient backend failure on a write/publish path (spec 260629-02). The API no
128
+ // longer maps a transient verify/lookup hiccup to a 401 "session expired" — it
129
+ // returns a retryable 503 DB_UNAVAILABLE. The CLI must surface that as a RETRY
130
+ // affordance, NOT collapse it into an AUTH_REQUIRED / "run login" dead-end. This
131
+ // is the operator-facing half of the fix: an authenticated, authorized operator
132
+ // whose publish hit a blip is told to retry (and an immediate retry succeeds),
133
+ // instead of being sent down a pointless re-login path.
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ const with_503 = async (code, fn) => {
137
+ const orig = global.fetch
138
+ global.fetch = async () => ({
139
+ ok: false,
140
+ status: 503,
141
+ headers: { get: () => null },
142
+ json: async () => ({ error: { code, message: 'Authentication is temporarily unavailable. Please retry.' } }),
143
+ })
144
+ try { return await fn() } finally { global.fetch = orig }
145
+ }
146
+
147
+ describe('api client — transient 503 on publish surfaces retry, not a login dead-end (spec 260629-02)', () => {
148
+ const { next_step_for_error } = require('../constants/next_step_by_error_code')
149
+
150
+ it('a 503 DB_UNAVAILABLE on push preserves the retryable code (NOT AUTH_REQUIRED)', async () => {
151
+ await with_503('DB_UNAVAILABLE', async () => {
152
+ const [errors] = await client.post('/repos/acme/deploy/push', { commit: 'x' }, { auth: false })
153
+ assert.ok(errors, 'expected an error tuple')
154
+ const err = errors.find(e => e && e.name === 'ApiError')
155
+ assert.ok(err, 'a 503 must surface as an ApiError, not an AuthError')
156
+ assert.equal(err.code, 'DB_UNAVAILABLE')
157
+ assert.notEqual(err.code, 'AUTH_REQUIRED')
158
+ assert.equal(err.status_code, 503)
159
+ })
160
+ })
161
+
162
+ it('the operator-facing next_step for that code is RETRY, not login', () => {
163
+ const next_step = next_step_for_error('DB_UNAVAILABLE', 'whatever', {})
164
+ assert.equal(next_step.action, 'retry')
165
+ assert.notEqual(next_step.action, 'login')
166
+ })
167
+ })
168
+
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+ // The ACTUAL 2026-06-29 incident (spec 260629-02 Findings): the operator's session
171
+ // had expired and the CLI's proactive token refresh hit a TRANSIENT failure. On a
172
+ // transient refresh failure, load_token preserves the (still-valid) refresh_token
173
+ // on disk but returns null — so the client fired an authed request WITHOUT a token,
174
+ // the API answered 401 (no-token branch), and the CLI rendered it as AUTH_REQUIRED
175
+ // "session expired". An immediate retry (after the refresh blip cleared) succeeded.
176
+ //
177
+ // The fix: a real session whose token can't be refreshed *right now* (file present
178
+ // but load_token null) must surface a RETRYABLE error, never a tokenless request
179
+ // that turns into a terminal "session expired".
180
+ // ─────────────────────────────────────────────────────────────────────────────
181
+
182
+ describe('api client — transient token-refresh failure → retry, never a tokenless 401 (spec 260629-02)', () => {
183
+ const auth_path = require.resolve('./auth')
184
+
185
+ const write_expired_token = (dir) => {
186
+ const token = {
187
+ id_token: 'expired-id-token',
188
+ access_token: 'expired-access-token',
189
+ refresh_token: 'still-valid-refresh-token',
190
+ expires_in: 1,
191
+ stored_at: new Date(Date.now() - 5000).toISOString(),
192
+ }
193
+ fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
194
+ fs.writeFileSync(path.join(dir, 'happyskills', 'credentials.json'), JSON.stringify(token), { mode: 0o600 })
195
+ }
196
+
197
+ // Make the refresh endpoint fail transiently (NetworkError) for both attempts.
198
+ const with_transient_refresh = async (fn) => {
199
+ const { NetworkError } = require('../utils/errors')
200
+ const orig = require.cache[auth_path]
201
+ require.cache[auth_path] = {
202
+ id: auth_path, filename: auth_path, loaded: true,
203
+ exports: { refresh: async () => [[new NetworkError('refresh endpoint unreachable')], null] },
204
+ }
205
+ try { return await fn() } finally {
206
+ if (orig) require.cache[auth_path] = orig; else delete require.cache[auth_path]
207
+ }
208
+ }
209
+
210
+ it('expired session + transient refresh blip on a push → retryable NetworkError, no tokenless request, NOT AUTH_REQUIRED', async () => {
211
+ await with_tmp_config(async (dir) => {
212
+ write_expired_token(dir)
213
+ await with_transient_refresh(async () => {
214
+ let fetched = false
215
+ const orig_fetch = global.fetch
216
+ global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
217
+ try {
218
+ const [errors] = await client.post('/acme/deploy/push', { commit: 'z' })
219
+ assert.ok(errors, 'expected an error tuple')
220
+ assert.equal(fetched, false, 'must NOT fire a tokenless authed request when the session merely failed to refresh')
221
+ assert.ok(errors.find(e => e && e.name === 'NetworkError'), 'a transient refresh failure must surface as a retryable NetworkError')
222
+ assert.ok(!errors.find(e => e && e.name === 'AuthError'), 'must NOT surface AUTH_REQUIRED / "session expired" for a transient refresh blip')
223
+ } finally { global.fetch = orig_fetch }
224
+ })
225
+ })
226
+ })
227
+
228
+ it('NO credentials at all → proceeds tokenless (anonymous), unchanged', async () => {
229
+ await with_tmp_config(async () => {
230
+ // No creds file written → genuinely not signed in.
231
+ let fetched = false
232
+ const orig_fetch = global.fetch
233
+ global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
234
+ try {
235
+ await client.get('/repos:search')
236
+ assert.equal(fetched, true, 'with no session, the request still goes out (server decides auth)')
237
+ } finally { global.fetch = orig_fetch }
238
+ })
239
+ })
240
+
241
+ it('transient refresh blip on a GET (read) → proceeds tokenless (anonymous), read-path unchanged (spec §6.3)', async () => {
242
+ await with_tmp_config(async (dir) => {
243
+ write_expired_token(dir)
244
+ await with_transient_refresh(async () => {
245
+ let fetched = false
246
+ const orig_fetch = global.fetch
247
+ global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
248
+ try {
249
+ const [errors] = await client.get('/repos:search')
250
+ assert.equal(fetched, true, 'a read must still go out tokenless during a refresh blip (degrade to anonymous), not throw')
251
+ assert.ok(!errors || !errors.find(e => e && e.name === 'NetworkError'), 'reads keep their pre-existing anonymous-degrade behavior')
252
+ } finally { global.fetch = orig_fetch }
253
+ })
254
+ })
255
+ })
256
+
257
+ // A corrupt/unparseable credentials file ALSO leaves the file on disk while
258
+ // load_token yields no token — but it returns an ERROR (JSON.parse throws),
259
+ // NOT a clean null like the transient-refresh branch. It must NOT be treated as
260
+ // a retryable refresh blip (retrying never fixes a corrupt file → a dead loop).
261
+ // The actionable path is the pre-existing one: proceed tokenless so the server
262
+ // 401s and the user is steered to re-login (which overwrites the bad file).
263
+ it('corrupt credentials file → does NOT become a retry loop; proceeds tokenless so re-login is reachable', async () => {
264
+ await with_tmp_config(async (dir) => {
265
+ fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
266
+ fs.writeFileSync(path.join(dir, 'happyskills', 'credentials.json'), 'not-valid-json{{{', { mode: 0o600 })
267
+ let fetched = false
268
+ const orig_fetch = global.fetch
269
+ global.fetch = async () => { fetched = true; return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) } }
270
+ try {
271
+ const [errors] = await client.post('/acme/deploy/push', { commit: 'z' })
272
+ assert.equal(fetched, true, 'a corrupt creds file must proceed tokenless (server → 401 → login), not throw a retryable error')
273
+ assert.ok(!errors || !errors.find(e => e && e.name === 'NetworkError'), 'a corrupt creds file must NOT be reported as a transient/retryable refresh failure')
274
+ } finally { global.fetch = orig_fetch }
275
+ })
276
+ })
277
+ })
@@ -97,6 +97,15 @@ const load_token = () => catch_errors('Failed to load token', async () => {
97
97
  return data
98
98
  })
99
99
 
100
+ // Synchronous presence check for the credentials file. Used by the API client to
101
+ // tell "no session at all" (file absent) apart from "a real session whose token
102
+ // could not be refreshed right now" (file present but load_token returned null —
103
+ // the transient-refresh-failure branch preserves the file, whereas permanent /
104
+ // no-refresh-token failures delete it). See spec 260629-02.
105
+ const credentials_present = () => {
106
+ try { return fs.existsSync(credentials_path()) } catch { return false }
107
+ }
108
+
100
109
  const clear_token = () => catch_errors('Failed to clear token', async () => {
101
110
  const creds_path = credentials_path()
102
111
  try {
@@ -115,4 +124,4 @@ const require_token = async () => {
115
124
  return data
116
125
  }
117
126
 
118
- module.exports = { save_token, load_token, clear_token, require_token }
127
+ module.exports = { save_token, load_token, clear_token, require_token, credentials_present }