happyskills 0.48.0 → 0.50.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.
@@ -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
+ })