happyskills 0.39.1 → 0.40.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,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.40.0] - 2026-05-03
11
+
12
+ ### Added
13
+ - Propagate the fork relationship to the server on the first `publish` after `happyskills fork`. The CLI now reads `manifest.forked_from.repo` (an `owner/name` string written by the fork command), splits it into `{ workspace, name }`, and includes it in the push payload across all three transport modes: direct push, archive upload, and per-file presigned upload. The API resolves the parent and persists `repos.forked_from`, which feeds the duplicate-detection heuristic (forks-of-active-parents are ineligible to be elected source-of-truth, and the publish-time `duplicates` warning is suppressed when the only match is the fork's parent). Subsequent publishes of an existing repo are unaffected — the field is honored only on the create-repo path. Requires API ≥ 2.4.0.
14
+
15
+ ### Fixed
16
+ - Fix `publish` crashing with a `ReferenceError`/TDZ on `visibility` when validation runs before the spinner update — the `const visibility = args.flags.public ? 'public' : 'private'` declaration was below its first use. Move the declaration ahead of all reads.
17
+ - Fix `validate_manifest` rejecting kit names like `_kit-foo` ("Invalid name … Must start with a letter or number"). Kits are required by `init --kit` and `validate_skill_json` to start with `_kit-`, so the leading-character rule is now carved out for `manifest.type === 'kit'` via a separate `KIT_NAME_PATTERN`. Bumping a kit no longer fails its own pre-publish manifest validation.
18
+
10
19
  ## [0.39.1] - 2026-05-01
11
20
 
12
21
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.39.1",
3
+ "version": "0.40.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
@@ -13,7 +13,7 @@ const estimate_payload_size = (files) => {
13
13
  return size
14
14
  }
15
15
 
16
- const archive_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
16
+ const archive_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress) =>
17
17
  catch_errors('Archive push failed', async () => {
18
18
  const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
19
19
 
@@ -28,6 +28,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
28
28
  archive: true, archive_sha: archive.sha, archive_size: archive.size
29
29
  }
30
30
  if (parent_shas) init_body.parent_shas = parent_shas
31
+ if (forked_from) init_body.forked_from = forked_from
31
32
  const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
32
33
  if (init_err) throw e('Upload initiation failed', init_err)
33
34
 
@@ -44,6 +45,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
44
45
  version, message, files: file_meta, base_commit, force
45
46
  }
46
47
  if (parent_shas) complete_body.parent_shas = parent_shas
48
+ if (forked_from) complete_body.forked_from = forked_from
47
49
  const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
48
50
  if (complete_err) throw e('Upload completion failed', complete_err)
49
51
 
@@ -62,7 +64,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
62
64
  }
63
65
  })
64
66
 
65
- const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
67
+ const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress) =>
66
68
  catch_errors('Smart push failed', async () => {
67
69
  const payload_size = estimate_payload_size(files)
68
70
 
@@ -70,6 +72,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
70
72
  // Small payload — use direct push
71
73
  const body = { version, message, files, visibility, base_commit, force }
72
74
  if (parent_shas) body.parent_shas = parent_shas
75
+ if (forked_from) body.forked_from = forked_from
73
76
  const [err, data] = await repos_api.push(owner, repo, body)
74
77
  if (err) throw e('Direct push failed', err)
75
78
  return data
@@ -77,7 +80,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
77
80
 
78
81
  // Large payload — prefer archive upload if source_dir is available
79
82
  if (source_dir) {
80
- return await archive_push(owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress)
83
+ return await archive_push(owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress)
81
84
  }
82
85
 
83
86
  // Fallback: per-file presigned upload
@@ -86,6 +89,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
86
89
  // Step 1: Initiate
87
90
  const init_body = { version, message, files: file_meta, visibility, base_commit, force }
88
91
  if (parent_shas) init_body.parent_shas = parent_shas
92
+ if (forked_from) init_body.forked_from = forked_from
89
93
  const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
90
94
  if (init_err) throw e('Upload initiation failed', init_err)
91
95
 
@@ -111,6 +115,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
111
115
  // Step 3: Complete
112
116
  const complete_body = { upload_id, version, message, files: file_meta, base_commit, force }
113
117
  if (parent_shas) complete_body.parent_shas = parent_shas
118
+ if (forked_from) complete_body.forked_from = forked_from
114
119
  const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
115
120
  if (complete_err) throw e('Upload completion failed', complete_err)
116
121
 
@@ -135,6 +135,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
135
135
 
136
136
  const spinner = create_spinner('Preparing to publish...')
137
137
 
138
+ const visibility = args.flags.public ? 'public' : 'private'
139
+
138
140
  let owner = args.flags.workspace
139
141
  if (!owner) {
140
142
  const [, resolved_owner] = await resolve_skill_owner(skill_name)
@@ -221,7 +223,6 @@ const run = (args) => catch_errors('Publish failed', async () => {
221
223
  spinner.update(`Uploading files (${completed}/${total})...`)
222
224
  }
223
225
  }
224
- const visibility = args.flags.public ? 'public' : 'private'
225
226
  const push_options = {
226
227
  version: manifest.version,
227
228
  message: `Release ${manifest.version}`,
@@ -234,6 +235,15 @@ const run = (args) => catch_errors('Publish failed', async () => {
234
235
  if (merge_parents && !force) {
235
236
  push_options.parent_shas = merge_parents
236
237
  }
238
+
239
+ // Propagate fork relationship to the server on the first publish.
240
+ // Only the create-repo path uses this; existing repos already have forked_from set.
241
+ if (manifest.forked_from && typeof manifest.forked_from.repo === 'string' && manifest.forked_from.repo.includes('/')) {
242
+ const [parent_workspace, parent_name] = manifest.forked_from.repo.split('/')
243
+ if (parent_workspace && parent_name) {
244
+ push_options.forked_from = { workspace: parent_workspace, name: parent_name }
245
+ }
246
+ }
237
247
  const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, push_options, on_progress)
238
248
  if (push_err) {
239
249
  const last = push_err[push_err.length - 1]
@@ -0,0 +1,21 @@
1
+ const { describe, it } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+
6
+ describe('publish.js — Bug 1 regression: visibility temporal-dead-zone', () => {
7
+ const src = fs.readFileSync(path.join(__dirname, 'publish.js'), 'utf8')
8
+
9
+ it('declares `visibility` before passing it to validate_dependencies', () => {
10
+ const decl_idx = src.search(/const\s+visibility\s*=\s*args\.flags\.public/)
11
+ const use_idx = src.search(/validate_dependencies\([\s\S]*?visibility/)
12
+ assert.notStrictEqual(decl_idx, -1, '`const visibility = args.flags.public ? ...` declaration not found')
13
+ assert.notStrictEqual(use_idx, -1, '`validate_dependencies(... visibility ...)` call not found')
14
+ assert.ok(
15
+ decl_idx < use_idx,
16
+ `visibility must be declared (idx ${decl_idx}) before it is read inside validate_dependencies (idx ${use_idx}). ` +
17
+ 'The current ordering causes `ReferenceError: Cannot access \'visibility\' before initialization` ' +
18
+ 'whenever skill.json declares one or more dependencies.'
19
+ )
20
+ })
21
+ })
@@ -2,6 +2,9 @@ const { valid } = require('../utils/semver')
2
2
 
3
3
  const REQUIRED_FIELDS = ['name', 'version']
4
4
  const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
5
+ // Kits are required to start with `_kit-` (enforced by `init --kit` and
6
+ // validate_skill_json). Carve out the leading-character rule for them.
7
+ const KIT_NAME_PATTERN = /^_kit-[a-z0-9][a-z0-9-]*$/
5
8
 
6
9
  const validate_manifest = (manifest) => {
7
10
  const errors = []
@@ -12,7 +15,8 @@ const validate_manifest = (manifest) => {
12
15
  }
13
16
  }
14
17
 
15
- if (manifest.name && !NAME_PATTERN.test(manifest.name)) {
18
+ const name_pattern = manifest.type === 'kit' ? KIT_NAME_PATTERN : NAME_PATTERN
19
+ if (manifest.name && !name_pattern.test(manifest.name)) {
16
20
  errors.push(`Invalid name "${manifest.name}". Use lowercase letters, numbers, hyphens, and underscores. Must start with a letter or number.`)
17
21
  }
18
22
 
@@ -98,4 +98,26 @@ describe('validate_manifest', () => {
98
98
  const result = validate_manifest({ name: 'my-skill', version: '1.0.0' })
99
99
  assert.strictEqual(result.valid, true)
100
100
  })
101
+
102
+ // Bug 2 regression — kit names start with `_kit-` (enforced by `init --kit` and
103
+ // `validate_skill_json` for type==='kit'). The shared `validate_manifest` used
104
+ // by `bump` must accept this convention or the standard release workflow breaks.
105
+ describe('kit name convention', () => {
106
+ it('accepts a kit name beginning with _kit- when type is "kit"', () => {
107
+ const result = validate_manifest({ name: '_kit-mykit', version: '1.0.0', type: 'kit' })
108
+ assert.strictEqual(result.valid, true, `expected valid, got errors: ${result.errors.join(', ')}`)
109
+ })
110
+
111
+ it('still rejects a non-kit manifest whose name starts with _', () => {
112
+ const result = validate_manifest({ name: '_oops', version: '1.0.0' })
113
+ assert.strictEqual(result.valid, false)
114
+ assert.ok(result.errors.some(e => e.includes('Invalid name')))
115
+ })
116
+
117
+ it('still rejects a non-kit manifest whose name starts with _ even if type=skill', () => {
118
+ const result = validate_manifest({ name: '_oops', version: '1.0.0', type: 'skill' })
119
+ assert.strictEqual(result.valid, false)
120
+ assert.ok(result.errors.some(e => e.includes('Invalid name')))
121
+ })
122
+ })
101
123
  })