happyskills 0.29.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 +13 -0
- package/package.json +3 -2
- package/src/api/repos.js +1 -0
- package/src/auth/token_store.js +44 -13
- package/src/auth/token_store.test.js +197 -1
- package/src/commands/publish.js +4 -1
- package/src/commands/validate.js +4 -1
- package/src/engine/archive_installer.js +87 -0
- package/src/engine/archive_installer.test.js +121 -0
- package/src/engine/installer.js +17 -4
- package/src/validation/file_size_rules.js +48 -0
- package/src/validation/file_size_rules.test.js +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ 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
|
+
|
|
17
|
+
## [0.30.0] - 2026-04-03
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Add archive-based install for large skills — installer tries `?format=archive` clone first (single `.tar.gz` download via presigned URL), falls back to JSON clone transparently
|
|
21
|
+
- Add 1MB per-file size validation rule — enforced in `validate` and `publish` commands, blocks oversized files before upload
|
|
22
|
+
|
|
10
23
|
## [0.29.0] - 2026-04-02
|
|
11
24
|
|
|
12
25
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "happyskills",
|
|
3
|
-
"version": "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)",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"node-diff3": "^3.2.0",
|
|
47
47
|
"puffy-core": "^1.3.1",
|
|
48
|
-
"semver": "^7.6.0"
|
|
48
|
+
"semver": "^7.6.0",
|
|
49
|
+
"tar-stream": "^3.1.8"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"dotenv": "^17.2.4"
|
package/src/api/repos.js
CHANGED
|
@@ -26,6 +26,7 @@ const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/$
|
|
|
26
26
|
const params = new URLSearchParams()
|
|
27
27
|
if (options.commit) params.set('commit', options.commit)
|
|
28
28
|
else if (ref) params.set('ref', ref)
|
|
29
|
+
if (options.format) params.set('format', options.format)
|
|
29
30
|
const qs = params.toString() ? `?${params}` : ''
|
|
30
31
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
|
|
31
32
|
if (errors) throw errors[errors.length - 1]
|
package/src/auth/token_store.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
59
|
-
if (
|
|
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
|
+
})
|
package/src/commands/publish.js
CHANGED
|
@@ -17,6 +17,7 @@ const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
|
17
17
|
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
18
18
|
const { validate_cross } = require('../validation/cross_rules')
|
|
19
19
|
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
20
|
+
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
20
21
|
const { create_spinner } = require('../ui/spinner')
|
|
21
22
|
const { print_help, print_success, print_error, print_warn, print_hint, print_json, code, summarize_warnings } = require('../ui/output')
|
|
22
23
|
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
@@ -103,8 +104,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
103
104
|
if (cross_err) throw cross_err
|
|
104
105
|
const [marker_err, marker_results] = await validate_no_conflict_markers(dir)
|
|
105
106
|
if (marker_err) throw marker_err
|
|
107
|
+
const [size_err, size_results] = await validate_file_sizes(dir)
|
|
108
|
+
if (size_err) throw size_err
|
|
106
109
|
|
|
107
|
-
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
|
|
110
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results]
|
|
108
111
|
const validation_errors = all_results.filter(r => r.severity === 'error')
|
|
109
112
|
const validation_warnings = all_results.filter(r => r.severity === 'warning')
|
|
110
113
|
|
package/src/commands/validate.js
CHANGED
|
@@ -4,6 +4,7 @@ const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
|
4
4
|
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
5
5
|
const { validate_cross } = require('../validation/cross_rules')
|
|
6
6
|
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
7
|
+
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
7
8
|
const { file_exists, read_json } = require('../utils/fs')
|
|
8
9
|
const { skills_dir, find_project_root } = require('../config/paths')
|
|
9
10
|
const { print_help, print_json } = require('../ui/output')
|
|
@@ -147,8 +148,10 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
147
148
|
if (cross_err) throw cross_err
|
|
148
149
|
const [marker_err, marker_results] = await validate_no_conflict_markers(skill_dir)
|
|
149
150
|
if (marker_err) throw marker_err
|
|
151
|
+
const [size_err, size_results] = await validate_file_sizes(skill_dir)
|
|
152
|
+
if (size_err) throw size_err
|
|
150
153
|
|
|
151
|
-
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results]
|
|
154
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results]
|
|
152
155
|
const type_label = is_kit ? ' [kit]' : ''
|
|
153
156
|
|
|
154
157
|
if (args.flags.json) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const zlib = require('zlib')
|
|
4
|
+
const { Readable } = require('stream')
|
|
5
|
+
const tar = require('tar-stream')
|
|
6
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
7
|
+
const repos_api = require('../api/repos')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Attempts an archive-based clone. Returns the extracted files or null if no archive is available.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} owner - Workspace slug
|
|
13
|
+
* @param {string} name - Repo name
|
|
14
|
+
* @param {string} ref - Git ref (e.g., refs/tags/v1.0.0)
|
|
15
|
+
* @param {string} dest_dir - Directory to extract files into
|
|
16
|
+
* @returns {[errors, {ref, commit}|null]} — null if no archive available (caller should fall back to JSON clone)
|
|
17
|
+
*/
|
|
18
|
+
const install_from_archive = (owner, name, ref, dest_dir) => catch_errors('Archive install failed', async () => {
|
|
19
|
+
// Request archive format
|
|
20
|
+
const [clone_err, clone_data] = await repos_api.clone(owner, name, ref, { format: 'archive' })
|
|
21
|
+
if (clone_err) return null
|
|
22
|
+
if (!clone_data || clone_data.format !== 'archive' || !clone_data.url) return null
|
|
23
|
+
|
|
24
|
+
// Download archive from presigned URL
|
|
25
|
+
const resp = await fetch(clone_data.url)
|
|
26
|
+
if (!resp.ok) throw new Error(`Archive download failed: ${resp.status}`)
|
|
27
|
+
|
|
28
|
+
const archive_buffer = Buffer.from(await resp.arrayBuffer())
|
|
29
|
+
|
|
30
|
+
// Extract tar.gz to dest_dir
|
|
31
|
+
await extract_tar_gz(archive_buffer, dest_dir)
|
|
32
|
+
|
|
33
|
+
return { ref: clone_data.ref, commit: clone_data.commit }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extracts a .tar.gz buffer into a directory.
|
|
38
|
+
*/
|
|
39
|
+
const extract_tar_gz = (buffer, dest_dir) => new Promise((resolve, reject) => {
|
|
40
|
+
const extract = tar.extract()
|
|
41
|
+
|
|
42
|
+
extract.on('entry', (header, stream, next) => {
|
|
43
|
+
if (header.type !== 'file') {
|
|
44
|
+
stream.resume()
|
|
45
|
+
return next()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Normalize path: strip leading ./
|
|
49
|
+
const file_path = header.name.replace(/^\.\//, '')
|
|
50
|
+
|
|
51
|
+
// Skip macOS metadata
|
|
52
|
+
const basename = file_path.split('/').pop()
|
|
53
|
+
if (basename.startsWith('._') || basename === '.DS_Store') {
|
|
54
|
+
stream.resume()
|
|
55
|
+
return next()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Path safety check
|
|
59
|
+
const resolved = path.resolve(dest_dir, file_path)
|
|
60
|
+
if (!resolved.startsWith(path.resolve(dest_dir) + path.sep) && resolved !== path.resolve(dest_dir)) {
|
|
61
|
+
stream.resume()
|
|
62
|
+
return next()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const chunks = []
|
|
66
|
+
stream.on('data', chunk => chunks.push(chunk))
|
|
67
|
+
stream.on('end', async () => {
|
|
68
|
+
try {
|
|
69
|
+
const dir = path.dirname(resolved)
|
|
70
|
+
await fs.promises.mkdir(dir, { recursive: true })
|
|
71
|
+
await fs.promises.writeFile(resolved, Buffer.concat(chunks))
|
|
72
|
+
next()
|
|
73
|
+
} catch (err) {
|
|
74
|
+
reject(err)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
stream.on('error', reject)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
extract.on('finish', resolve)
|
|
81
|
+
extract.on('error', reject)
|
|
82
|
+
|
|
83
|
+
const gunzip = zlib.createGunzip()
|
|
84
|
+
Readable.from(buffer).pipe(gunzip).pipe(extract)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
module.exports = { install_from_archive, extract_tar_gz }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const zlib = require('zlib')
|
|
7
|
+
const tar = require('tar-stream')
|
|
8
|
+
const { extract_tar_gz } = require('./archive_installer')
|
|
9
|
+
|
|
10
|
+
const create_tar_gz = (files) => new Promise((resolve, reject) => {
|
|
11
|
+
const pack = tar.pack()
|
|
12
|
+
for (const { name, content, type } of files) {
|
|
13
|
+
if (type) {
|
|
14
|
+
pack.entry({ name, type })
|
|
15
|
+
} else {
|
|
16
|
+
pack.entry({ name }, content)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
pack.finalize()
|
|
20
|
+
const chunks = []
|
|
21
|
+
const gz = zlib.createGzip()
|
|
22
|
+
pack.pipe(gz)
|
|
23
|
+
gz.on('data', chunk => chunks.push(chunk))
|
|
24
|
+
gz.on('end', () => resolve(Buffer.concat(chunks)))
|
|
25
|
+
gz.on('error', reject)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('extract_tar_gz', () => {
|
|
29
|
+
let tmp_dir
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
tmp_dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'archive-test-'))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await fs.promises.rm(tmp_dir, { recursive: true, force: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('extracts files to destination directory', async () => {
|
|
40
|
+
const buffer = await create_tar_gz([
|
|
41
|
+
{ name: 'hello.txt', content: 'Hello world' },
|
|
42
|
+
{ name: 'data.json', content: '{"ok":true}' }
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
await extract_tar_gz(buffer, tmp_dir)
|
|
46
|
+
|
|
47
|
+
const hello = await fs.promises.readFile(path.join(tmp_dir, 'hello.txt'), 'utf8')
|
|
48
|
+
const data = await fs.promises.readFile(path.join(tmp_dir, 'data.json'), 'utf8')
|
|
49
|
+
assert.equal(hello, 'Hello world')
|
|
50
|
+
assert.equal(data, '{"ok":true}')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('creates nested directories', async () => {
|
|
54
|
+
const buffer = await create_tar_gz([
|
|
55
|
+
{ name: 'sub/file.txt', content: 'nested content' }
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
await extract_tar_gz(buffer, tmp_dir)
|
|
59
|
+
|
|
60
|
+
const content = await fs.promises.readFile(path.join(tmp_dir, 'sub', 'file.txt'), 'utf8')
|
|
61
|
+
assert.equal(content, 'nested content')
|
|
62
|
+
|
|
63
|
+
const stat = await fs.promises.stat(path.join(tmp_dir, 'sub'))
|
|
64
|
+
assert.ok(stat.isDirectory())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('skips macOS metadata files', async () => {
|
|
68
|
+
const buffer = await create_tar_gz([
|
|
69
|
+
{ name: '._hidden', content: 'mac metadata' },
|
|
70
|
+
{ name: '.DS_Store', content: 'store data' },
|
|
71
|
+
{ name: 'sub/._resource', content: 'nested mac metadata' },
|
|
72
|
+
{ name: 'real.txt', content: 'keep me' }
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
await extract_tar_gz(buffer, tmp_dir)
|
|
76
|
+
|
|
77
|
+
assert.equal(fs.existsSync(path.join(tmp_dir, '._hidden')), false)
|
|
78
|
+
assert.equal(fs.existsSync(path.join(tmp_dir, '.DS_Store')), false)
|
|
79
|
+
assert.equal(fs.existsSync(path.join(tmp_dir, 'sub', '._resource')), false)
|
|
80
|
+
|
|
81
|
+
const content = await fs.promises.readFile(path.join(tmp_dir, 'real.txt'), 'utf8')
|
|
82
|
+
assert.equal(content, 'keep me')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('skips non-file entries', async () => {
|
|
86
|
+
const buffer = await create_tar_gz([
|
|
87
|
+
{ name: 'somedir/', type: 'directory' },
|
|
88
|
+
{ name: 'actual.txt', content: 'file content' }
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
await extract_tar_gz(buffer, tmp_dir)
|
|
92
|
+
|
|
93
|
+
const content = await fs.promises.readFile(path.join(tmp_dir, 'actual.txt'), 'utf8')
|
|
94
|
+
assert.equal(content, 'file content')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('rejects path traversal', async () => {
|
|
98
|
+
const buffer = await create_tar_gz([
|
|
99
|
+
{ name: '../evil.txt', content: 'malicious' },
|
|
100
|
+
{ name: 'safe.txt', content: 'safe content' }
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
await extract_tar_gz(buffer, tmp_dir)
|
|
104
|
+
|
|
105
|
+
assert.equal(fs.existsSync(path.join(tmp_dir, '..', 'evil.txt')), false)
|
|
106
|
+
|
|
107
|
+
const content = await fs.promises.readFile(path.join(tmp_dir, 'safe.txt'), 'utf8')
|
|
108
|
+
assert.equal(content, 'safe content')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('strips leading ./ from paths', async () => {
|
|
112
|
+
const buffer = await create_tar_gz([
|
|
113
|
+
{ name: './file.txt', content: 'dot-slash content' }
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
await extract_tar_gz(buffer, tmp_dir)
|
|
117
|
+
|
|
118
|
+
const content = await fs.promises.readFile(path.join(tmp_dir, 'file.txt'), 'utf8')
|
|
119
|
+
assert.equal(content, 'dot-slash content')
|
|
120
|
+
})
|
|
121
|
+
})
|
package/src/engine/installer.js
CHANGED
|
@@ -108,11 +108,24 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
108
108
|
const pkg_tmp = path.join(temp_dir, name)
|
|
109
109
|
|
|
110
110
|
spinner.update(`Downloading ${pkg.skill}@${pkg.version}...`)
|
|
111
|
-
const [dl_errors, clone_data] = await download(owner, name, pkg.ref)
|
|
112
|
-
if (dl_errors) { spinner.fail(`Download failed: ${pkg.skill}`); throw e(`Download ${pkg.skill} failed`, dl_errors) }
|
|
113
111
|
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
// Try archive-based install first (fast single download), fall back to JSON clone
|
|
113
|
+
const { install_from_archive } = require('./archive_installer')
|
|
114
|
+
const [arch_err, arch_result] = await install_from_archive(owner, name, pkg.ref, pkg_tmp)
|
|
115
|
+
let clone_ref, clone_commit
|
|
116
|
+
|
|
117
|
+
if (!arch_err && arch_result) {
|
|
118
|
+
// Archive install succeeded
|
|
119
|
+
clone_ref = arch_result.ref
|
|
120
|
+
clone_commit = arch_result.commit
|
|
121
|
+
} else {
|
|
122
|
+
// Fall back to JSON clone
|
|
123
|
+
const [dl_errors, clone_data] = await download(owner, name, pkg.ref)
|
|
124
|
+
if (dl_errors) { spinner.fail(`Download failed: ${pkg.skill}`); throw e(`Download ${pkg.skill} failed`, dl_errors) }
|
|
125
|
+
|
|
126
|
+
const [ext_errors] = await extract(clone_data, pkg_tmp)
|
|
127
|
+
if (ext_errors) { spinner.fail(`Extract failed: ${pkg.skill}`); throw e(`Extract ${pkg.skill} failed`, ext_errors) }
|
|
128
|
+
}
|
|
116
129
|
|
|
117
130
|
if (pkg.integrity) {
|
|
118
131
|
const [, valid] = await verify_integrity(pkg_tmp, pkg.integrity)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
|
|
5
|
+
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
|
|
6
|
+
|
|
7
|
+
const result = (file, actual_size) => ({
|
|
8
|
+
file,
|
|
9
|
+
field: null,
|
|
10
|
+
rule: 'max_file_size',
|
|
11
|
+
severity: 'error',
|
|
12
|
+
message: `File exceeds 1MB limit (${(actual_size / (1024 * 1024)).toFixed(2)}MB)`
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scans all files in a skill directory and checks that none exceed the max file size.
|
|
17
|
+
* Returns error results for each file that exceeds the limit.
|
|
18
|
+
* Skips dotfiles and common non-publishable directories (consistent with hash_directory exclusions).
|
|
19
|
+
*
|
|
20
|
+
* @param {string} skill_dir - Absolute path to the skill directory
|
|
21
|
+
* @returns {[errors, results[]]} — results with severity 'error' for each oversized file
|
|
22
|
+
*/
|
|
23
|
+
const validate_file_sizes = (skill_dir) => catch_errors('Failed to check file sizes', async () => {
|
|
24
|
+
const results = []
|
|
25
|
+
const walk = async (dir, prefix) => {
|
|
26
|
+
let items
|
|
27
|
+
try { items = await fs.promises.readdir(dir, { withFileTypes: true }) } catch { return }
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
if (item.name.startsWith('.')) continue
|
|
30
|
+
if (item.name === 'node_modules') continue
|
|
31
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name
|
|
32
|
+
const full = path.join(dir, item.name)
|
|
33
|
+
if (item.isDirectory()) {
|
|
34
|
+
await walk(full, rel)
|
|
35
|
+
} else {
|
|
36
|
+
let stat
|
|
37
|
+
try { stat = await fs.promises.stat(full) } catch { continue }
|
|
38
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
39
|
+
results.push(result(rel, stat.size))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await walk(skill_dir, '')
|
|
45
|
+
return results
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
module.exports = { validate_file_sizes }
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const { validate_file_sizes } = require('./file_size_rules')
|
|
7
|
+
|
|
8
|
+
const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1MB
|
|
9
|
+
|
|
10
|
+
const make_temp_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'file-size-test-'))
|
|
11
|
+
const clean = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
|
|
13
|
+
describe('validate_file_sizes', () => {
|
|
14
|
+
let dir
|
|
15
|
+
|
|
16
|
+
beforeEach(() => { dir = make_temp_dir() })
|
|
17
|
+
afterEach(() => { clean(dir) })
|
|
18
|
+
|
|
19
|
+
it('returns empty for files under 1MB limit', async () => {
|
|
20
|
+
fs.writeFileSync(path.join(dir, 'small.txt'), 'hello world')
|
|
21
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
22
|
+
assert.strictEqual(err, null)
|
|
23
|
+
assert.strictEqual(results.length, 0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns error for file exceeding 1MB', async () => {
|
|
27
|
+
fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
|
|
28
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
29
|
+
assert.strictEqual(err, null)
|
|
30
|
+
assert.strictEqual(results.length, 1)
|
|
31
|
+
assert.strictEqual(results[0].severity, 'error')
|
|
32
|
+
assert.strictEqual(results[0].rule, 'max_file_size')
|
|
33
|
+
assert.strictEqual(results[0].file, 'big.txt')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('handles multiple oversized files', async () => {
|
|
37
|
+
fs.writeFileSync(path.join(dir, 'big1.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
|
|
38
|
+
fs.writeFileSync(path.join(dir, 'big2.txt'), Buffer.alloc(MAX_FILE_SIZE + 100))
|
|
39
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
40
|
+
assert.strictEqual(err, null)
|
|
41
|
+
assert.strictEqual(results.length, 2)
|
|
42
|
+
assert.ok(results.every(r => r.severity === 'error'))
|
|
43
|
+
assert.ok(results.every(r => r.rule === 'max_file_size'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('reports correct relative path for nested files', async () => {
|
|
47
|
+
const sub = path.join(dir, 'sub')
|
|
48
|
+
fs.mkdirSync(sub)
|
|
49
|
+
fs.writeFileSync(path.join(sub, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
|
|
50
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
51
|
+
assert.strictEqual(err, null)
|
|
52
|
+
assert.strictEqual(results.length, 1)
|
|
53
|
+
assert.strictEqual(results[0].file, 'sub/big.txt')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('skips dotfiles', async () => {
|
|
57
|
+
fs.writeFileSync(path.join(dir, '.hidden'), Buffer.alloc(MAX_FILE_SIZE + 1))
|
|
58
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
59
|
+
assert.strictEqual(err, null)
|
|
60
|
+
assert.strictEqual(results.length, 0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('skips node_modules', async () => {
|
|
64
|
+
const nm = path.join(dir, 'node_modules')
|
|
65
|
+
fs.mkdirSync(nm)
|
|
66
|
+
fs.writeFileSync(path.join(nm, 'big.txt'), Buffer.alloc(MAX_FILE_SIZE + 1))
|
|
67
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
68
|
+
assert.strictEqual(err, null)
|
|
69
|
+
assert.strictEqual(results.length, 0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('passes for file exactly at 1MB limit', async () => {
|
|
73
|
+
fs.writeFileSync(path.join(dir, 'exact.txt'), Buffer.alloc(MAX_FILE_SIZE))
|
|
74
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
75
|
+
assert.strictEqual(err, null)
|
|
76
|
+
assert.strictEqual(results.length, 0)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handles empty directory', async () => {
|
|
80
|
+
const [err, results] = await validate_file_sizes(dir)
|
|
81
|
+
assert.strictEqual(err, null)
|
|
82
|
+
assert.strictEqual(results.length, 0)
|
|
83
|
+
})
|
|
84
|
+
})
|