happyskills 0.47.1 → 0.49.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 +63 -0
- package/package.json +1 -1
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/postlex.js +101 -10
- package/src/commands/postlex.test.js +141 -0
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/search.js +2 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +4 -1
- package/src/index.js +3 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
4
|
+
const { read_manifest } = require('../manifest/reader')
|
|
5
|
+
const { write_manifest } = require('../manifest/writer')
|
|
6
|
+
const { read_file } = require('../utils/fs')
|
|
7
|
+
const { inc, valid, gt } = require('../utils/semver')
|
|
8
|
+
const { resolve_skill_dir } = require('../utils/resolve_skill')
|
|
9
|
+
const { find_project_root, skills_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
10
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
11
|
+
const { verify_lock_disk_consistency, detect_ahead_state, parse_changelog_top_version } = require('../lock/verify')
|
|
12
|
+
const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
13
|
+
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
14
|
+
const { validate_cross } = require('../validation/cross_rules')
|
|
15
|
+
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
16
|
+
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
17
|
+
const snapshot_storage = require('../snapshot/storage')
|
|
18
|
+
const { print_help, print_json, print_success, print_info, print_warn, print_hint, code } = require('../ui/output')
|
|
19
|
+
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
20
|
+
const { EXIT_CODES } = require('../constants')
|
|
21
|
+
|
|
22
|
+
const HELP_TEXT = `Usage: happyskills release <skill-name> [options]
|
|
23
|
+
|
|
24
|
+
Atomic release pipeline. Snapshots, validates, applies a bump (when needed),
|
|
25
|
+
verifies the CHANGELOG, resolves the workspace, and publishes — all as a
|
|
26
|
+
single deterministic command. On any failure, the snapshot is restored.
|
|
27
|
+
|
|
28
|
+
Recognizes the ahead state directly: if skill.json is already ahead of the
|
|
29
|
+
lock (\`bump\` or hand-edit already done), the disk version IS the version
|
|
30
|
+
to publish — no revert, no re-bump.
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--bump <type|version> patch | minor | major | explicit semver
|
|
34
|
+
--no-bump Refuse to bump; require disk to be already ahead
|
|
35
|
+
--changelog-from <auto|file> Source for the new CHANGELOG entry (default: read from CHANGELOG.md)
|
|
36
|
+
--workspace <slug> Target workspace
|
|
37
|
+
--public | --private Visibility on first publish only
|
|
38
|
+
--dry-run Validate + check status, do not mutate
|
|
39
|
+
--json Output as JSON
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
happyskills release my-skill --workspace acme --json
|
|
43
|
+
happyskills release my-skill --bump patch --workspace acme --json
|
|
44
|
+
happyskills release my-skill --no-bump --json # disk is already ahead`
|
|
45
|
+
|
|
46
|
+
const envelope_error = (code_str, message, extra = {}) => ({ error: { code: code_str, message, ...extra } })
|
|
47
|
+
|
|
48
|
+
const determine_target_version = async ({ manifest, lock_entry, skill_dir, bump_flag, no_bump }) => {
|
|
49
|
+
// § 4.5 + § 8.2 step 3 — handle ahead/clean/modified directly without
|
|
50
|
+
// reverting the disk version that's already been chosen.
|
|
51
|
+
const [verify_err, verify] = await verify_lock_disk_consistency(lock_entry, skill_dir)
|
|
52
|
+
if (verify_err) throw verify_err[0] || verify_err
|
|
53
|
+
if (!verify.ok) {
|
|
54
|
+
return {
|
|
55
|
+
status: 'drift',
|
|
56
|
+
drift: { reason: verify.reason, lock_version: verify.expected, disk_version: verify.actual }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [, ahead] = await detect_ahead_state(lock_entry, skill_dir)
|
|
61
|
+
if (ahead && ahead.ahead) {
|
|
62
|
+
// Disk already declares the next version.
|
|
63
|
+
const disk_version = manifest.version
|
|
64
|
+
if (bump_flag) {
|
|
65
|
+
// Sanity-check: if --bump would produce the same value the user
|
|
66
|
+
// already declared, that's fine; otherwise emit a disagreement
|
|
67
|
+
// next_step so the operator decides.
|
|
68
|
+
const computed = compute_bump(disk_version, bump_flag)
|
|
69
|
+
const lock_based = compute_bump(lock_entry?.version, bump_flag)
|
|
70
|
+
if (computed && computed === disk_version) return { status: 'ahead', target: disk_version }
|
|
71
|
+
if (lock_based && lock_based === disk_version) return { status: 'ahead', target: disk_version }
|
|
72
|
+
return {
|
|
73
|
+
status: 'bump_disagreement',
|
|
74
|
+
disk_version,
|
|
75
|
+
requested_bump: bump_flag,
|
|
76
|
+
lock_version: lock_entry?.version
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { status: 'ahead', target: disk_version }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// First-publish (no lock entry yet) → the disk version IS the version
|
|
83
|
+
// to publish. Treat as ahead-equivalent: no bump applied, disk wins.
|
|
84
|
+
// --bump can still override (with the same disagreement check).
|
|
85
|
+
if (!lock_entry || !lock_entry.version) {
|
|
86
|
+
if (bump_flag) {
|
|
87
|
+
const target = compute_bump(manifest.version, bump_flag)
|
|
88
|
+
if (!target) return { status: 'invalid_bump', bump: bump_flag }
|
|
89
|
+
if (target === manifest.version) return { status: 'ahead', target: manifest.version }
|
|
90
|
+
return { status: 'clean_with_bump', target, bump_flag }
|
|
91
|
+
}
|
|
92
|
+
return { status: 'ahead', target: manifest.version }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Clean or modified — disk version equals lock version.
|
|
96
|
+
if (no_bump) {
|
|
97
|
+
return { status: 'missing_version' }
|
|
98
|
+
}
|
|
99
|
+
if (bump_flag) {
|
|
100
|
+
const target = compute_bump(manifest.version, bump_flag)
|
|
101
|
+
if (!target) return { status: 'invalid_bump', bump: bump_flag }
|
|
102
|
+
return { status: 'clean_with_bump', target, bump_flag }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// No --bump and no ahead. Try to infer from CHANGELOG top entry.
|
|
106
|
+
const changelog_path = path.join(skill_dir, 'CHANGELOG.md')
|
|
107
|
+
const [, cl_content] = await read_file(changelog_path)
|
|
108
|
+
const cl_version = parse_changelog_top_version(cl_content)
|
|
109
|
+
if (cl_version && gt(cl_version, manifest.version)) {
|
|
110
|
+
return { status: 'inferred_from_changelog', target: cl_version }
|
|
111
|
+
}
|
|
112
|
+
return { status: 'specify_bump' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const compute_bump = (base_version, bump) => {
|
|
116
|
+
if (!base_version || !bump) return null
|
|
117
|
+
if (bump === 'patch' || bump === 'minor' || bump === 'major') {
|
|
118
|
+
return inc(base_version, bump) || null
|
|
119
|
+
}
|
|
120
|
+
if (valid(bump)) return bump
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const run_validation = (dir, skill_name) => catch_errors('Validation failed', async () => {
|
|
125
|
+
const [md_err, md_data] = await validate_skill_md(dir, path.basename(dir), null)
|
|
126
|
+
if (md_err) throw md_err
|
|
127
|
+
const [json_err, json_data] = await validate_skill_json(dir)
|
|
128
|
+
if (json_err) throw json_err
|
|
129
|
+
const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, json_data.manifest?.type)
|
|
130
|
+
if (cross_err) throw cross_err
|
|
131
|
+
const [marker_err, marker_results] = await validate_no_conflict_markers(dir)
|
|
132
|
+
if (marker_err) throw marker_err
|
|
133
|
+
const [size_err, size_results] = await validate_file_sizes(dir)
|
|
134
|
+
if (size_err) throw size_err
|
|
135
|
+
const all = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results]
|
|
136
|
+
const errors = all.filter(r => r.severity === 'error').map(({ severity, ...rest }) => rest)
|
|
137
|
+
const warnings = all.filter(r => r.severity === 'warning').map(({ severity, ...rest }) => rest)
|
|
138
|
+
return { errors, warnings }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const orchestrate = (args) => catch_errors('Release failed', async () => {
|
|
142
|
+
const skill_name = args._[0]
|
|
143
|
+
if (!skill_name) throw new UsageError('Usage: happyskills release <skill-name> [options]')
|
|
144
|
+
|
|
145
|
+
const project_root = find_project_root()
|
|
146
|
+
const [dir_err, dir] = await resolve_skill_dir(skill_name)
|
|
147
|
+
if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
|
|
148
|
+
|
|
149
|
+
const [manifest_err, manifest] = await read_manifest(dir)
|
|
150
|
+
if (manifest_err) throw e('No skill.json found', manifest_err)
|
|
151
|
+
|
|
152
|
+
// Resolve lock entry (if any) for ahead detection + version inference.
|
|
153
|
+
const [, lock_data] = await read_lock(project_root)
|
|
154
|
+
const all_locked = lock_data ? get_all_locked_skills(lock_data) : {}
|
|
155
|
+
const lock_key = Object.keys(all_locked).find(k => k.endsWith(`/${skill_name}`)) || skill_name
|
|
156
|
+
const lock_entry = all_locked[lock_key] || null
|
|
157
|
+
|
|
158
|
+
const workspace_flag = typeof args.flags.workspace === 'string' ? args.flags.workspace : null
|
|
159
|
+
const resolved_workspace = workspace_flag || (lock_key.includes('/') ? lock_key.split('/')[0] : null)
|
|
160
|
+
|
|
161
|
+
const bump_flag = typeof args.flags.bump === 'string' ? args.flags.bump : null
|
|
162
|
+
const no_bump = !!args.flags['no-bump']
|
|
163
|
+
const changelog_from = typeof args.flags['changelog-from'] === 'string' ? args.flags['changelog-from'] : null
|
|
164
|
+
const dry_run = !!args.flags['dry-run']
|
|
165
|
+
|
|
166
|
+
// (Step 1) Snapshot.
|
|
167
|
+
let snapshot_id = null
|
|
168
|
+
let snapshot_workspace = resolved_workspace || 'local'
|
|
169
|
+
if (!dry_run) {
|
|
170
|
+
const [snap_err, snap] = await snapshot_storage.create({
|
|
171
|
+
skill_dir: dir,
|
|
172
|
+
workspace: snapshot_workspace,
|
|
173
|
+
skill: skill_name,
|
|
174
|
+
lock_entry,
|
|
175
|
+
note: `pre-release: ${skill_name}`,
|
|
176
|
+
project_root
|
|
177
|
+
})
|
|
178
|
+
if (snap_err) throw e('Failed to capture pre-release snapshot', snap_err)
|
|
179
|
+
snapshot_id = snap.snapshot_id
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const restore_and = async (env) => {
|
|
183
|
+
if (snapshot_id) {
|
|
184
|
+
await snapshot_storage.restore(snapshot_id, { skill_dir: dir, project_root })
|
|
185
|
+
}
|
|
186
|
+
return env
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// (Step 2) Validate.
|
|
190
|
+
const [val_err, validation] = await run_validation(dir, skill_name)
|
|
191
|
+
if (val_err) {
|
|
192
|
+
const restored = await restore_and({
|
|
193
|
+
...envelope_error('VALIDATION_FAILED', `Validation failed: ${val_err[val_err.length - 1]?.message}`),
|
|
194
|
+
next_step: { action: 'fix_validation_errors', context: { skill: skill_name } }
|
|
195
|
+
})
|
|
196
|
+
return { code: EXIT_CODES.ERROR, envelope: restored }
|
|
197
|
+
}
|
|
198
|
+
if (validation.errors.length > 0) {
|
|
199
|
+
const restored = await restore_and({
|
|
200
|
+
...envelope_error('VALIDATION_FAILED', `Skill failed validation with ${validation.errors.length} error(s).`, {
|
|
201
|
+
validation_errors: validation.errors
|
|
202
|
+
}),
|
|
203
|
+
next_step: { action: 'fix_validation_errors', context: { skill: skill_name } }
|
|
204
|
+
})
|
|
205
|
+
return { code: EXIT_CODES.ERROR, envelope: restored }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// (Step 3) Determine target version.
|
|
209
|
+
const target_info = await determine_target_version({ manifest, lock_entry, skill_dir: dir, bump_flag, no_bump })
|
|
210
|
+
|
|
211
|
+
if (target_info.status === 'drift') {
|
|
212
|
+
const restored = await restore_and({
|
|
213
|
+
...envelope_error('DRIFT_DETECTED', `Genuine drift detected (${target_info.drift.reason}). Reconcile first.`, {
|
|
214
|
+
drift: target_info.drift
|
|
215
|
+
}),
|
|
216
|
+
next_step: {
|
|
217
|
+
action: 'reconcile_first',
|
|
218
|
+
context: {
|
|
219
|
+
reconcile_command: `npx happyskills reconcile ${lock_key} --json`,
|
|
220
|
+
skill: lock_key
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
return { code: EXIT_CODES.ERROR, envelope: restored }
|
|
225
|
+
}
|
|
226
|
+
if (target_info.status === 'missing_version') {
|
|
227
|
+
const restored = await restore_and({
|
|
228
|
+
...envelope_error('MISSING_VERSION', '--no-bump was passed but disk is not ahead of lock; nothing to publish.'),
|
|
229
|
+
next_step: { action: 'specify_bump_type', context: { current_version: manifest.version } }
|
|
230
|
+
})
|
|
231
|
+
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
232
|
+
}
|
|
233
|
+
if (target_info.status === 'invalid_bump') {
|
|
234
|
+
const restored = await restore_and({
|
|
235
|
+
...envelope_error('INVALID_BUMP', `--bump "${target_info.bump}" is not patch/minor/major or a valid semver.`),
|
|
236
|
+
next_step: { action: 'specify_bump_type', context: { current_version: manifest.version } }
|
|
237
|
+
})
|
|
238
|
+
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
239
|
+
}
|
|
240
|
+
if (target_info.status === 'specify_bump') {
|
|
241
|
+
const restored = await restore_and({
|
|
242
|
+
...envelope_error('MISSING_VERSION', 'No --bump provided and no CHANGELOG entry indicates an intended next version.'),
|
|
243
|
+
next_step: {
|
|
244
|
+
action: 'specify_bump_type',
|
|
245
|
+
context: { current_version: manifest.version, options: ['patch', 'minor', 'major', 'explicit-version'] }
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
249
|
+
}
|
|
250
|
+
if (target_info.status === 'bump_disagreement') {
|
|
251
|
+
const restored = await restore_and({
|
|
252
|
+
...envelope_error('BUMP_DISAGREEMENT', `--bump ${target_info.requested_bump} disagrees with the disk version ${target_info.disk_version}.`),
|
|
253
|
+
next_step: {
|
|
254
|
+
action: 'resolve_bump_disagreement',
|
|
255
|
+
context: {
|
|
256
|
+
disk_version: target_info.disk_version,
|
|
257
|
+
requested_bump: target_info.requested_bump,
|
|
258
|
+
lock_version: target_info.lock_version
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Determine the version to publish.
|
|
266
|
+
const target_version = target_info.target
|
|
267
|
+
|
|
268
|
+
// Apply the bump to skill.json if we computed one. For the ahead path,
|
|
269
|
+
// skill.json is already correct.
|
|
270
|
+
if (target_info.status === 'clean_with_bump' || target_info.status === 'inferred_from_changelog') {
|
|
271
|
+
if (!dry_run) {
|
|
272
|
+
manifest.version = target_version
|
|
273
|
+
const [w_err] = await write_manifest(dir, manifest)
|
|
274
|
+
if (w_err) {
|
|
275
|
+
const restored = await restore_and({
|
|
276
|
+
...envelope_error('WRITE_FAILED', 'Failed to write bumped skill.json'),
|
|
277
|
+
next_step: { action: 'retry' }
|
|
278
|
+
})
|
|
279
|
+
return { code: EXIT_CODES.ERROR, envelope: restored }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// (Step 4) Changelog handling.
|
|
285
|
+
const changelog_path = path.join(dir, 'CHANGELOG.md')
|
|
286
|
+
const [, cl_content_raw] = await read_file(changelog_path)
|
|
287
|
+
const cl_top = parse_changelog_top_version(cl_content_raw)
|
|
288
|
+
const changelog_ok = cl_top === target_version
|
|
289
|
+
let changelog_warning = null
|
|
290
|
+
if (!changelog_ok) {
|
|
291
|
+
if (changelog_from === 'auto') {
|
|
292
|
+
changelog_warning = `auto-draft mode was requested but is not implemented in this build — operator must edit CHANGELOG.md manually.`
|
|
293
|
+
} else if (changelog_from) {
|
|
294
|
+
// Read from file and prepend.
|
|
295
|
+
const [cf_err, cf_content] = await read_file(changelog_from)
|
|
296
|
+
if (cf_err || !cf_content) {
|
|
297
|
+
const restored = await restore_and({
|
|
298
|
+
...envelope_error('CHANGELOG_SOURCE_UNREADABLE', `Could not read --changelog-from "${changelog_from}".`),
|
|
299
|
+
next_step: { action: 'provide_changelog' }
|
|
300
|
+
})
|
|
301
|
+
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
302
|
+
}
|
|
303
|
+
if (!dry_run) {
|
|
304
|
+
const new_content = `${cf_content.trim()}\n\n${(cl_content_raw || '').trim()}\n`
|
|
305
|
+
await fs.promises.writeFile(changelog_path, new_content, 'utf-8')
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
const restored = await restore_and({
|
|
309
|
+
...envelope_error('MISSING_CHANGELOG_ENTRY', `CHANGELOG.md does not contain a ## [${target_version}] entry.`),
|
|
310
|
+
next_step: {
|
|
311
|
+
action: 'provide_changelog',
|
|
312
|
+
context: { target_version, current_top_entry: cl_top }
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// (Step 5–7) Status / workspace / visibility.
|
|
320
|
+
// For Phase 1 we accept --workspace flag verbatim. Pre-publish registry
|
|
321
|
+
// status check is delegated to publish.js (which already does it) — we
|
|
322
|
+
// surface its DIVERGED as REGISTRY_DIVERGED in the envelope.
|
|
323
|
+
if (!resolved_workspace) {
|
|
324
|
+
const restored = await restore_and({
|
|
325
|
+
...envelope_error('WORKSPACE_UNRESOLVED', 'Could not resolve target workspace.'),
|
|
326
|
+
next_step: { action: 'specify_workspace' }
|
|
327
|
+
})
|
|
328
|
+
return { code: EXIT_CODES.USAGE, envelope: restored }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// (Step 8–9) Publish — delegate to publish.js by spawning it. Easier to
|
|
332
|
+
// keep release as a thin orchestrator over the existing publish pipeline
|
|
333
|
+
// than to copy its push logic.
|
|
334
|
+
if (dry_run) {
|
|
335
|
+
await restore_and(null) // for dry-run, restore the snapshot if any
|
|
336
|
+
return {
|
|
337
|
+
code: EXIT_CODES.SUCCESS,
|
|
338
|
+
envelope: {
|
|
339
|
+
data: {
|
|
340
|
+
dry_run: true,
|
|
341
|
+
skill: skill_name,
|
|
342
|
+
workspace: resolved_workspace,
|
|
343
|
+
target_version,
|
|
344
|
+
ahead_recognized: target_info.status === 'ahead',
|
|
345
|
+
bump_applied: target_info.status === 'clean_with_bump' || target_info.status === 'inferred_from_changelog',
|
|
346
|
+
changelog_ok: changelog_ok || changelog_from !== null
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const { spawn } = require('child_process')
|
|
353
|
+
const publish_args = [path.resolve(__dirname, '../../bin/happyskills.js'), 'publish', skill_name, '--workspace', resolved_workspace, '--json']
|
|
354
|
+
if (args.flags.public) publish_args.push('--public')
|
|
355
|
+
if (args.flags.force) publish_args.push('--force')
|
|
356
|
+
|
|
357
|
+
const publish_result = await new Promise((res) => {
|
|
358
|
+
const child = spawn(process.execPath, publish_args, {
|
|
359
|
+
env: process.env,
|
|
360
|
+
stdio: ['inherit', 'pipe', 'inherit']
|
|
361
|
+
})
|
|
362
|
+
let stdout = ''
|
|
363
|
+
child.stdout.on('data', d => { stdout += d.toString() })
|
|
364
|
+
child.on('close', (code_) => res({ code: code_, stdout }))
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
let publish_envelope = null
|
|
368
|
+
try { publish_envelope = JSON.parse(publish_result.stdout) } catch { /* non-JSON */ }
|
|
369
|
+
|
|
370
|
+
if (publish_result.code !== 0) {
|
|
371
|
+
// Map publish error codes to the structured envelope.
|
|
372
|
+
const err_code = publish_envelope?.error?.code || 'PUBLISH_FAILED'
|
|
373
|
+
const message = publish_envelope?.error?.message || `publish exit ${publish_result.code}`
|
|
374
|
+
// REGISTRY_DIVERGED → REGISTRY_DIVERGED next_step
|
|
375
|
+
const next_step_action = /DIVERG|diverge/i.test(message) ? 'pull_rebase_first' : 'review_publish_error'
|
|
376
|
+
const restored = await restore_and({
|
|
377
|
+
...envelope_error(err_code, message, publish_envelope?.error?.validation_errors ? { validation_errors: publish_envelope.error.validation_errors } : {}),
|
|
378
|
+
next_step: { action: next_step_action, context: { publish_envelope } }
|
|
379
|
+
})
|
|
380
|
+
return { code: publish_result.code || EXIT_CODES.ERROR, envelope: restored }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// On success, delete the snapshot — the operation succeeded so no
|
|
384
|
+
// rollback is needed. (We keep snapshot_storage.remove best-effort; if
|
|
385
|
+
// it fails the snapshot just lingers, which is fine.)
|
|
386
|
+
if (snapshot_id) {
|
|
387
|
+
await snapshot_storage.remove(snapshot_id, { project_root })
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
code: EXIT_CODES.SUCCESS,
|
|
392
|
+
envelope: {
|
|
393
|
+
data: {
|
|
394
|
+
published: true,
|
|
395
|
+
skill: publish_envelope?.data?.skill || `${resolved_workspace}/${skill_name}`,
|
|
396
|
+
version: publish_envelope?.data?.version || target_version,
|
|
397
|
+
workspace: resolved_workspace,
|
|
398
|
+
commit: publish_envelope?.data?.commit || null,
|
|
399
|
+
ref: publish_envelope?.data?.ref || `refs/tags/v${target_version}`,
|
|
400
|
+
ahead_recognized: target_info.status === 'ahead',
|
|
401
|
+
bump_applied: target_info.status === 'clean_with_bump' || target_info.status === 'inferred_from_changelog',
|
|
402
|
+
warnings: publish_envelope?.data?.warnings || [],
|
|
403
|
+
snapshot_id_preserved: false
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
const run = (args) => catch_errors('Release wrapper failed', async () => {
|
|
410
|
+
if (args.flags._show_help) {
|
|
411
|
+
print_help(HELP_TEXT)
|
|
412
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const [err, result] = await orchestrate(args)
|
|
416
|
+
if (err) {
|
|
417
|
+
// Catastrophic failure — bubble up through the standard error path.
|
|
418
|
+
exit_with_error(err)
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (args.flags.json) {
|
|
423
|
+
print_json(result.envelope)
|
|
424
|
+
process.exit(result.code || 0)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (result.envelope?.data?.published) {
|
|
429
|
+
print_success(`Published ${result.envelope.data.skill}@${result.envelope.data.version}`)
|
|
430
|
+
if (result.envelope.data.ahead_recognized) {
|
|
431
|
+
print_info('Recognized ahead state — published the disk version directly.')
|
|
432
|
+
}
|
|
433
|
+
process.exit(0)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if (result.envelope?.data?.dry_run) {
|
|
437
|
+
print_info(`Dry run: would publish ${result.envelope.data.skill} @ ${result.envelope.data.target_version} to ${result.envelope.data.workspace}.`)
|
|
438
|
+
process.exit(0)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (result.envelope?.error) {
|
|
442
|
+
print_warn(`Release blocked: ${result.envelope.error.message}`)
|
|
443
|
+
if (result.envelope.next_step) {
|
|
444
|
+
print_hint(`Next step: ${result.envelope.next_step.action}`)
|
|
445
|
+
}
|
|
446
|
+
process.exit(result.code || EXIT_CODES.ERROR)
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
450
|
+
|
|
451
|
+
module.exports = { run, orchestrate, determine_target_version, compute_bump }
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
|
|
5
|
+
const { determine_target_version, compute_bump } = require('./release')
|
|
6
|
+
|
|
7
|
+
describe('release.compute_bump', () => {
|
|
8
|
+
it('handles patch/minor/major shortcuts', () => {
|
|
9
|
+
assert.strictEqual(compute_bump('1.2.3', 'patch'), '1.2.4')
|
|
10
|
+
assert.strictEqual(compute_bump('1.2.3', 'minor'), '1.3.0')
|
|
11
|
+
assert.strictEqual(compute_bump('1.2.3', 'major'), '2.0.0')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('passes through explicit semver', () => {
|
|
15
|
+
assert.strictEqual(compute_bump('1.2.3', '5.0.0'), '5.0.0')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns null for invalid bump and missing base', () => {
|
|
19
|
+
assert.strictEqual(compute_bump('1.2.3', 'gibberish'), null)
|
|
20
|
+
assert.strictEqual(compute_bump(null, 'patch'), null)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const os = require('os')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
|
|
28
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-release-test-'))
|
|
29
|
+
|
|
30
|
+
const write_skill = (dir, manifest, opts = {}) => {
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), opts.skill_md || `---\nname: ${manifest.name}\ndescription: release-test skill\n---\nbody\n`)
|
|
34
|
+
if (opts.changelog) {
|
|
35
|
+
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), opts.changelog)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('release.determine_target_version', () => {
|
|
40
|
+
it('identifies ahead and uses the disk version', async () => {
|
|
41
|
+
const root = make_tmp()
|
|
42
|
+
try {
|
|
43
|
+
const dir = path.join(root, 'ahead')
|
|
44
|
+
write_skill(dir, { name: 'ahead', version: '0.3.3' })
|
|
45
|
+
const lock_entry = { version: '0.3.2' }
|
|
46
|
+
const r = await determine_target_version({
|
|
47
|
+
manifest: { name: 'ahead', version: '0.3.3' },
|
|
48
|
+
lock_entry,
|
|
49
|
+
skill_dir: dir,
|
|
50
|
+
bump_flag: null,
|
|
51
|
+
no_bump: false
|
|
52
|
+
})
|
|
53
|
+
assert.strictEqual(r.status, 'ahead')
|
|
54
|
+
assert.strictEqual(r.target, '0.3.3')
|
|
55
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('refuses ahead when --bump disagrees with disk', async () => {
|
|
59
|
+
const root = make_tmp()
|
|
60
|
+
try {
|
|
61
|
+
const dir = path.join(root, 'ahead-bump-disagree')
|
|
62
|
+
write_skill(dir, { name: 'ahead-bump-disagree', version: '0.5.0' })
|
|
63
|
+
const r = await determine_target_version({
|
|
64
|
+
manifest: { name: 'ahead-bump-disagree', version: '0.5.0' },
|
|
65
|
+
lock_entry: { version: '0.3.2' },
|
|
66
|
+
skill_dir: dir,
|
|
67
|
+
bump_flag: 'patch', // would compute 0.3.3, disk is 0.5.0
|
|
68
|
+
no_bump: false
|
|
69
|
+
})
|
|
70
|
+
assert.strictEqual(r.status, 'bump_disagreement')
|
|
71
|
+
assert.strictEqual(r.disk_version, '0.5.0')
|
|
72
|
+
assert.strictEqual(r.requested_bump, 'patch')
|
|
73
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('flags drift on regression (disk < lock)', async () => {
|
|
77
|
+
const root = make_tmp()
|
|
78
|
+
try {
|
|
79
|
+
const dir = path.join(root, 'reg')
|
|
80
|
+
write_skill(dir, { name: 'reg', version: '0.3.0' })
|
|
81
|
+
const r = await determine_target_version({
|
|
82
|
+
manifest: { name: 'reg', version: '0.3.0' },
|
|
83
|
+
lock_entry: { version: '0.4.0' },
|
|
84
|
+
skill_dir: dir,
|
|
85
|
+
bump_flag: null,
|
|
86
|
+
no_bump: false
|
|
87
|
+
})
|
|
88
|
+
assert.strictEqual(r.status, 'drift')
|
|
89
|
+
assert.strictEqual(r.drift.reason, 'regression')
|
|
90
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('clean + --bump patch produces the new version', async () => {
|
|
94
|
+
const root = make_tmp()
|
|
95
|
+
try {
|
|
96
|
+
const dir = path.join(root, 'clean-bump')
|
|
97
|
+
write_skill(dir, { name: 'clean-bump', version: '1.0.0' })
|
|
98
|
+
const r = await determine_target_version({
|
|
99
|
+
manifest: { name: 'clean-bump', version: '1.0.0' },
|
|
100
|
+
lock_entry: { version: '1.0.0' },
|
|
101
|
+
skill_dir: dir,
|
|
102
|
+
bump_flag: 'patch',
|
|
103
|
+
no_bump: false
|
|
104
|
+
})
|
|
105
|
+
assert.strictEqual(r.status, 'clean_with_bump')
|
|
106
|
+
assert.strictEqual(r.target, '1.0.1')
|
|
107
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('clean + no bump + no CHANGELOG-hint → specify_bump', async () => {
|
|
111
|
+
const root = make_tmp()
|
|
112
|
+
try {
|
|
113
|
+
const dir = path.join(root, 'clean-no-bump')
|
|
114
|
+
write_skill(dir, { name: 'clean-no-bump', version: '1.0.0' })
|
|
115
|
+
const r = await determine_target_version({
|
|
116
|
+
manifest: { name: 'clean-no-bump', version: '1.0.0' },
|
|
117
|
+
lock_entry: { version: '1.0.0' },
|
|
118
|
+
skill_dir: dir,
|
|
119
|
+
bump_flag: null,
|
|
120
|
+
no_bump: false
|
|
121
|
+
})
|
|
122
|
+
assert.strictEqual(r.status, 'specify_bump')
|
|
123
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('clean + CHANGELOG-hint > current → inferred_from_changelog', async () => {
|
|
127
|
+
const root = make_tmp()
|
|
128
|
+
try {
|
|
129
|
+
const dir = path.join(root, 'cl-hint')
|
|
130
|
+
write_skill(dir, { name: 'cl-hint', version: '1.0.0' }, { changelog: '# Changelog\n\n## [1.1.0]\n- new\n' })
|
|
131
|
+
const r = await determine_target_version({
|
|
132
|
+
manifest: { name: 'cl-hint', version: '1.0.0' },
|
|
133
|
+
lock_entry: { version: '1.0.0' },
|
|
134
|
+
skill_dir: dir,
|
|
135
|
+
bump_flag: null,
|
|
136
|
+
no_bump: false
|
|
137
|
+
})
|
|
138
|
+
assert.strictEqual(r.status, 'inferred_from_changelog')
|
|
139
|
+
assert.strictEqual(r.target, '1.1.0')
|
|
140
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('--no-bump on clean returns missing_version', async () => {
|
|
144
|
+
const root = make_tmp()
|
|
145
|
+
try {
|
|
146
|
+
const dir = path.join(root, 'no-bump-clean')
|
|
147
|
+
write_skill(dir, { name: 'no-bump-clean', version: '1.0.0' })
|
|
148
|
+
const r = await determine_target_version({
|
|
149
|
+
manifest: { name: 'no-bump-clean', version: '1.0.0' },
|
|
150
|
+
lock_entry: { version: '1.0.0' },
|
|
151
|
+
skill_dir: dir,
|
|
152
|
+
bump_flag: null,
|
|
153
|
+
no_bump: true
|
|
154
|
+
})
|
|
155
|
+
assert.strictEqual(r.status, 'missing_version')
|
|
156
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('first-publish (no lock entry) treats the disk version as ahead — no --bump required', async () => {
|
|
160
|
+
const root = make_tmp()
|
|
161
|
+
try {
|
|
162
|
+
const dir = path.join(root, 'first-publish')
|
|
163
|
+
write_skill(dir, { name: 'first-publish', version: '0.1.0' })
|
|
164
|
+
const r = await determine_target_version({
|
|
165
|
+
manifest: { name: 'first-publish', version: '0.1.0' },
|
|
166
|
+
lock_entry: null, // no lock entry yet
|
|
167
|
+
skill_dir: dir,
|
|
168
|
+
bump_flag: null,
|
|
169
|
+
no_bump: false
|
|
170
|
+
})
|
|
171
|
+
assert.strictEqual(r.status, 'ahead')
|
|
172
|
+
assert.strictEqual(r.target, '0.1.0')
|
|
173
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('first-publish with --no-bump works (E2E gap from real-registry test)', async () => {
|
|
177
|
+
const root = make_tmp()
|
|
178
|
+
try {
|
|
179
|
+
const dir = path.join(root, 'first-publish-no-bump')
|
|
180
|
+
write_skill(dir, { name: 'first-publish-no-bump', version: '0.1.0' })
|
|
181
|
+
const r = await determine_target_version({
|
|
182
|
+
manifest: { name: 'first-publish-no-bump', version: '0.1.0' },
|
|
183
|
+
lock_entry: null,
|
|
184
|
+
skill_dir: dir,
|
|
185
|
+
bump_flag: null,
|
|
186
|
+
no_bump: true // explicitly no bump — should NOT error now
|
|
187
|
+
})
|
|
188
|
+
assert.strictEqual(r.status, 'ahead', 'first-publish with --no-bump must NOT emit MISSING_VERSION')
|
|
189
|
+
assert.strictEqual(r.target, '0.1.0')
|
|
190
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('--bump with garbage value returns invalid_bump', async () => {
|
|
194
|
+
const root = make_tmp()
|
|
195
|
+
try {
|
|
196
|
+
const dir = path.join(root, 'bad-bump')
|
|
197
|
+
write_skill(dir, { name: 'bad-bump', version: '1.0.0' })
|
|
198
|
+
const r = await determine_target_version({
|
|
199
|
+
manifest: { name: 'bad-bump', version: '1.0.0' },
|
|
200
|
+
lock_entry: { version: '1.0.0' },
|
|
201
|
+
skill_dir: dir,
|
|
202
|
+
bump_flag: 'gibberish',
|
|
203
|
+
no_bump: false
|
|
204
|
+
})
|
|
205
|
+
assert.strictEqual(r.status, 'invalid_bump')
|
|
206
|
+
assert.strictEqual(r.bump, 'gibberish')
|
|
207
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }) }
|
|
208
|
+
})
|
|
209
|
+
})
|
package/src/commands/search.js
CHANGED
|
@@ -103,12 +103,14 @@ const format_smart_result = (item, index) => {
|
|
|
103
103
|
|
|
104
104
|
const to_smart_json = (item) => ({
|
|
105
105
|
skill: `${item.workspace_slug}/${item.name}`,
|
|
106
|
+
name: item.name,
|
|
106
107
|
type: item.type || 'skill',
|
|
107
108
|
description: item.description || '',
|
|
108
109
|
version: item.latest_version || item.version || '-',
|
|
109
110
|
visibility: item.visibility || 'public',
|
|
110
111
|
workspace_slug: item.workspace_slug,
|
|
111
112
|
stars: item.star_count || 0,
|
|
113
|
+
star_count: item.star_count || 0,
|
|
112
114
|
quality_score: item.quality_score != null ? item.quality_score : null,
|
|
113
115
|
quality_tier: get_quality_tier_name(item.quality_score),
|
|
114
116
|
relevance_score: item.relevance_score != null ? item.relevance_score : null,
|