happyskills 0.27.1 → 0.28.0

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,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.28.0] - 2026-04-02
11
+
12
+ ### Added
13
+ - Add archive-based upload for large skill publishing — bundles all files into a single `.tar.gz` archive and uploads once via presigned URL, replacing hundreds of individual S3 PUTs for dramatically faster publishes of skills with many files
14
+
15
+ ## [0.27.2] - 2026-04-02
16
+
17
+ ### Fixed
18
+ - Fix `publish` not syncing dependency declarations to the lock file when publishing an existing skill — the lock entry's `dependencies` field was stale after publish
19
+ - Fix `refresh` not detecting dependency drift — skills whose lock `dependencies` diverged from the installed `skill.json` were incorrectly reported as up-to-date because the staleness check was purely commit-based
20
+
10
21
  ## [0.27.1] - 2026-04-02
11
22
 
12
23
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
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/push.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const repos_api = require('./repos')
3
- const { initiate_upload, complete_upload, upload_files_parallel } = require('./upload')
3
+ const { initiate_upload, complete_upload, upload_file_to_s3, upload_files_parallel } = require('./upload')
4
+ const { create_archive, cleanup_archive } = require('../utils/archive')
4
5
 
5
6
  const DIRECT_PUSH_THRESHOLD = 5 * 1024 * 1024 // 5MB
6
7
 
@@ -12,7 +13,47 @@ const estimate_payload_size = (files) => {
12
13
  return size
13
14
  }
14
15
 
15
- const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas }, on_progress) =>
16
+ const archive_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
17
+ catch_errors('Archive push failed', async () => {
18
+ const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
19
+
20
+ // Step 1: Create tar.gz archive
21
+ const [arch_err, archive] = await create_archive(source_dir)
22
+ if (arch_err) throw e('Failed to create archive', arch_err)
23
+
24
+ try {
25
+ // Step 2: Initiate with archive mode (single presigned URL)
26
+ const init_body = {
27
+ version, message, files: file_meta, visibility, base_commit, force,
28
+ archive: true, archive_sha: archive.sha, archive_size: archive.size
29
+ }
30
+ if (parent_shas) init_body.parent_shas = parent_shas
31
+ const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
32
+ if (init_err) throw e('Upload initiation failed', init_err)
33
+
34
+ // Step 3: Upload single archive
35
+ const url = init_data.presigned_urls[archive.sha]
36
+ if (!url) throw new Error('No presigned URL returned for archive')
37
+ const [upload_err] = await upload_file_to_s3(url, archive.buffer)
38
+ if (upload_err) throw e('Archive upload failed', upload_err)
39
+ if (on_progress) on_progress(1, 1)
40
+
41
+ // Step 4: Complete
42
+ const complete_body = {
43
+ upload_id: init_data.upload_id, archive_sha: archive.sha,
44
+ version, message, files: file_meta, base_commit, force
45
+ }
46
+ if (parent_shas) complete_body.parent_shas = parent_shas
47
+ const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
48
+ if (complete_err) throw e('Upload completion failed', complete_err)
49
+
50
+ return complete_data
51
+ } finally {
52
+ cleanup_archive(archive.path)
53
+ }
54
+ })
55
+
56
+ const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
16
57
  catch_errors('Smart push failed', async () => {
17
58
  const payload_size = estimate_payload_size(files)
18
59
 
@@ -25,7 +66,12 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
25
66
  return data
26
67
  }
27
68
 
28
- // Large payload — use presigned upload flow
69
+ // Large payload — prefer archive upload if source_dir is available
70
+ if (source_dir) {
71
+ return await archive_push(owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress)
72
+ }
73
+
74
+ // Fallback: per-file presigned upload
29
75
  const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
30
76
 
31
77
  // Step 1: Initiate
@@ -195,7 +195,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
195
195
  files: skill_files,
196
196
  visibility,
197
197
  base_commit: force ? null : base_commit,
198
- force
198
+ force,
199
+ source_dir: dir
199
200
  }
200
201
  if (merge_parents && !force) {
201
202
  push_options.parent_shas = merge_parents
@@ -229,7 +230,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
229
230
  ref: push_data?.ref || `refs/tags/v${manifest.version}`,
230
231
  commit: push_data?.commit || null,
231
232
  base_commit: push_data?.commit || null,
232
- base_integrity: (!hash_err && integrity) ? integrity : null
233
+ base_integrity: (!hash_err && integrity) ? integrity : null,
234
+ dependencies: manifest.dependencies || {}
233
235
  }
234
236
  if (!hash_err && integrity) updated_entry.integrity = integrity
235
237
  delete updated_entry.merge_parents
@@ -1,6 +1,7 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { install } = require('../engine/installer')
3
3
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
4
+ const { read_manifest } = require('../manifest/reader')
4
5
  const { detect_status } = require('../merge/detector')
5
6
  const repos_api = require('../api/repos')
6
7
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
@@ -26,6 +27,16 @@ Examples:
26
27
  happyskills refresh -y
27
28
  happyskills refresh -g -y --json`
28
29
 
30
+ /**
31
+ * Returns true if the lock entry's dependencies differ from the installed skill.json's dependencies.
32
+ */
33
+ const has_dependency_drift = (lock_entry, manifest) => {
34
+ if (!manifest) return false
35
+ const lock_deps = JSON.stringify(lock_entry.dependencies || {})
36
+ const manifest_deps = JSON.stringify(manifest.dependencies || {})
37
+ return lock_deps !== manifest_deps
38
+ }
39
+
29
40
  const confirm_prompt = (question) => new Promise((resolve) => {
30
41
  const readline = require('readline')
31
42
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
@@ -72,6 +83,15 @@ const run = (args) => catch_errors('Refresh failed', async () => {
72
83
  }
73
84
  } else {
74
85
  spinner?.succeed('Checked for updates')
86
+ const base_dir = skills_dir(is_global, project_root)
87
+ // Read all installed manifests in parallel to check for dependency drift
88
+ const manifest_map = {}
89
+ await Promise.all(to_check.map(async ([name]) => {
90
+ const short_name = name.split('/')[1] || name
91
+ const dir = skill_install_dir(base_dir, short_name)
92
+ const [, manifest] = await read_manifest(dir)
93
+ if (manifest) manifest_map[name] = manifest
94
+ }))
75
95
  for (const [name, data] of to_check) {
76
96
  const info = batch_data?.results?.[name]
77
97
  if (info?.access_denied) {
@@ -83,6 +103,9 @@ const run = (args) => catch_errors('Refresh failed', async () => {
83
103
  } else if (!data.base_commit && info.latest_version !== data.version) {
84
104
  // Fallback to version comparison for old lock files without base_commit
85
105
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
106
+ } else if (has_dependency_drift(data, manifest_map[name])) {
107
+ // Lock dependencies are stale compared to installed skill.json
108
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
86
109
  } else {
87
110
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
88
111
  }
@@ -0,0 +1,24 @@
1
+ const { execFile } = require('child_process')
2
+ const { promisify } = require('util')
3
+ const crypto = require('crypto')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const { error: { catch_errors } } = require('puffy-core')
8
+
9
+ const exec_file = promisify(execFile)
10
+
11
+ const create_archive = (source_dir) =>
12
+ catch_errors('Failed to create archive', async () => {
13
+ const archive_path = path.join(os.tmpdir(), `happyskills-${crypto.randomUUID()}.tar.gz`)
14
+ await exec_file('tar', ['czf', archive_path, '-C', source_dir, '.'])
15
+ const buffer = await fs.promises.readFile(archive_path)
16
+ const sha = crypto.createHash('sha256').update(buffer).digest('hex')
17
+ return { path: archive_path, buffer, sha, size: buffer.length }
18
+ })
19
+
20
+ const cleanup_archive = (archive_path) => {
21
+ fs.promises.unlink(archive_path).catch(() => {})
22
+ }
23
+
24
+ module.exports = { create_archive, cleanup_archive }