happyskills 0.48.0 → 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.
@@ -0,0 +1,229 @@
1
+ const path = require('path')
2
+ const fs = require('fs')
3
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
5
+ const { write_lock, update_lock_skills } = require('../lock/writer')
6
+ const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
7
+ const { write_manifest } = require('../manifest/writer')
8
+ const { read_manifest } = require('../manifest/reader')
9
+ const { find_project_root, skills_dir, skill_install_dir, lock_root } = require('../config/paths')
10
+ const { print_help, print_info, print_json, print_warn, print_success, print_hint, code } = require('../ui/output')
11
+ const { exit_with_error, UsageError } = require('../utils/errors')
12
+ const { EXIT_CODES } = require('../constants')
13
+
14
+ const HELP_TEXT = `Usage: happyskills reconcile <owner/skill> [--apply <action>] [options]
15
+
16
+ Diagnose drift and apply a deterministic repair, or emit structured options
17
+ for the operator to choose from. Reserved for GENUINE drift only —
18
+ regression, missing files, or unparseable skill.json. The "ahead" state
19
+ (disk version > lock version) is NOT drift; reconcile reports it as a
20
+ non-event and points back to publish.
21
+
22
+ Arguments:
23
+ owner/skill Skill to inspect
24
+
25
+ Options:
26
+ --apply <action> Execute one of the recommended options directly
27
+ (skips the next_step round-trip).
28
+ -g, --global Operate on globally-installed skills
29
+ --json Output as JSON
30
+
31
+ Examples:
32
+ happyskills reconcile acme/deploy-aws --json
33
+ happyskills reconcile acme/deploy-aws --apply restore_from_lock_version --json`
34
+
35
+ const SUBTYPE_OPTIONS = {
36
+ regression: ['restore_from_lock_version', 'accept_disk_as_explicit_downgrade', 'investigate_with_diff'],
37
+ missing_skill_json: ['restore_from_registry_at_lock_version', 'restore_from_git', 'abandon'],
38
+ missing_dir: ['reinstall_at_lock_version', 'abandon']
39
+ }
40
+
41
+ const ACTION_NEXT_STEPS = {
42
+ regression: 'resolve_regression',
43
+ missing_skill_json: 'resolve_missing_skill_json',
44
+ missing_dir: 'resolve_missing_dir'
45
+ }
46
+
47
+ const resolve_lock_entry = (raw, project_root, is_global) => catch_errors('Failed to resolve lock entry', async () => {
48
+ const [, lock_data] = await read_lock(lock_root(is_global, project_root))
49
+ if (!lock_data) return { full: null, lock_entry: null, lock_data: null }
50
+ const all = get_all_locked_skills(lock_data)
51
+ let full = raw
52
+ let lock_entry = all[full] || null
53
+ if (!lock_entry && !raw.includes('/')) {
54
+ const suffix = `/${raw}`
55
+ full = Object.keys(all).find(k => k.endsWith(suffix)) || raw
56
+ lock_entry = all[full] || null
57
+ }
58
+ return { full, lock_entry, lock_data }
59
+ })
60
+
61
+ const apply_restore_from_lock_version = ({ skill_dir, lock_entry }) => catch_errors('Failed to restore from lock', async () => {
62
+ const [read_err, manifest] = await read_manifest(skill_dir)
63
+ if (read_err) throw e('Failed to read skill.json', read_err)
64
+ manifest.version = lock_entry.version
65
+ const [write_err] = await write_manifest(skill_dir, manifest)
66
+ if (write_err) throw e('Failed to write skill.json', write_err)
67
+ return { applied: 'restore_from_lock_version', new_disk_version: lock_entry.version }
68
+ })
69
+
70
+ const reconcile_one = (full, lock_entry, skill_dir, options) => catch_errors('Failed to reconcile', async () => {
71
+ if (!lock_entry) {
72
+ return {
73
+ data: { skill: full, no_drift: true, status: 'not_found' },
74
+ next_step: { action: 'install_first', context: { skill: full, hint: `Skill is not in the lock file. Install it first.` } }
75
+ }
76
+ }
77
+
78
+ const [verify_err, verify] = await verify_lock_disk_consistency(lock_entry, skill_dir)
79
+ if (verify_err) throw verify_err[0] || verify_err
80
+
81
+ if (verify.ok) {
82
+ // No genuine drift. Could be clean, modified, or ahead. Reconcile only
83
+ // reports — it doesn't touch any of these.
84
+ const [, ahead] = await detect_ahead_state(lock_entry, skill_dir)
85
+ if (ahead && ahead.ahead) {
86
+ return {
87
+ data: {
88
+ skill: full,
89
+ no_drift: true,
90
+ status: 'ahead',
91
+ ahead: {
92
+ lock_version: ahead.lock_version,
93
+ disk_version: ahead.disk_version,
94
+ has_changelog_entry: ahead.has_changelog_entry || false,
95
+ changelog_version: ahead.changelog_version || null
96
+ }
97
+ },
98
+ next_step: null,
99
+ hint: `ahead is a valid precondition for publish, not drift — use \`release\` or \`publish\` to advance the lock, or \`pull\` if the registry has advanced past lock_version`
100
+ }
101
+ }
102
+ return {
103
+ data: { skill: full, no_drift: true, status: 'clean' },
104
+ next_step: null
105
+ }
106
+ }
107
+
108
+ // Genuine drift — handle by subtype.
109
+ const drift = { reason: verify.reason, lock_version: verify.expected, disk_version: verify.actual }
110
+ const apply = options.apply || null
111
+
112
+ if (verify.reason === 'regression') {
113
+ if (apply === 'restore_from_lock_version') {
114
+ const [apply_err, applied] = await apply_restore_from_lock_version({ skill_dir, lock_entry })
115
+ if (apply_err) throw e('Apply failed', apply_err)
116
+ return {
117
+ data: { skill: full, drift_state: 'regression', applied, lock_version: lock_entry.version, disk_version: applied.new_disk_version },
118
+ next_step: null
119
+ }
120
+ }
121
+ return {
122
+ data: { skill: full, drift_state: 'regression', lock_version: lock_entry.version, disk_version: drift.disk_version },
123
+ next_step: {
124
+ action: 'resolve_regression',
125
+ context: {
126
+ skill: full,
127
+ drift,
128
+ options: SUBTYPE_OPTIONS.regression,
129
+ hint: 'Choose: restore_from_lock_version (Edit skill.json back to lock), accept_disk_as_explicit_downgrade (publish disk version as a downgrade), or investigate_with_diff (run happyskills diff first).'
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (verify.reason === 'missing_skill_json') {
136
+ return {
137
+ data: { skill: full, drift_state: 'missing_skill_json', lock_version: lock_entry.version },
138
+ next_step: {
139
+ action: 'resolve_missing_skill_json',
140
+ context: {
141
+ skill: full,
142
+ drift,
143
+ options: SUBTYPE_OPTIONS.missing_skill_json,
144
+ hint: 'Choose: restore_from_git (if tracked), restore_from_registry_at_lock_version (verify lock_version is published first), or abandon.'
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ if (verify.reason === 'missing_dir') {
151
+ return {
152
+ data: { skill: full, drift_state: 'missing_dir', lock_version: lock_entry.version },
153
+ next_step: {
154
+ action: 'resolve_missing_dir',
155
+ context: {
156
+ skill: full,
157
+ drift,
158
+ options: SUBTYPE_OPTIONS.missing_dir,
159
+ hint: 'Choose: reinstall_at_lock_version (no --fresh needed; the directory is missing), or abandon (uninstall to clear the stale lock entry).'
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ return {
166
+ data: { skill: full, drift_state: verify.reason || 'unknown', drift },
167
+ next_step: {
168
+ action: 'resolve_unknown_drift',
169
+ context: { skill: full, drift, hint: 'Unrecognized drift subtype. Surface to the user for manual decision.' }
170
+ }
171
+ }
172
+ })
173
+
174
+ const run = (args) => catch_errors('Reconcile failed', async () => {
175
+ if (args.flags._show_help) {
176
+ print_help(HELP_TEXT)
177
+ return process.exit(EXIT_CODES.SUCCESS)
178
+ }
179
+
180
+ const raw = args._[0]
181
+ if (!raw) throw new UsageError('Usage: happyskills reconcile <owner/skill> [--apply <action>]')
182
+
183
+ const project_root = find_project_root()
184
+ const is_global = !!args.flags.global
185
+ const base_dir = skills_dir(is_global, project_root)
186
+ const apply = typeof args.flags.apply === 'string' ? args.flags.apply : null
187
+
188
+ const [resolve_err, resolved] = await resolve_lock_entry(raw, project_root, is_global)
189
+ if (resolve_err) throw e('Failed to resolve skill', resolve_err)
190
+
191
+ const full = resolved.full
192
+ const lock_entry = resolved.lock_entry
193
+ const short = full && full.includes('/') ? full.split('/')[1] : (raw.includes('/') ? raw.split('/')[1] : raw)
194
+ const skill_dir = skill_install_dir(base_dir, short)
195
+
196
+ const [recon_err, result] = await reconcile_one(full, lock_entry, skill_dir, { apply })
197
+ if (recon_err) throw e('Reconcile failed', recon_err)
198
+
199
+ if (args.flags.json) {
200
+ const envelope = { data: result.data }
201
+ if (result.next_step !== undefined) envelope.next_step = result.next_step
202
+ if (result.hint !== undefined) envelope.hint = result.hint
203
+ print_json(envelope)
204
+ return
205
+ }
206
+
207
+ // Human output — terse, lead with plain meaning.
208
+ if (result.data?.no_drift) {
209
+ if (result.data.status === 'ahead') {
210
+ print_info(`${full} is ahead (lock ${result.data.ahead.lock_version}, disk ${result.data.ahead.disk_version}).`)
211
+ print_hint(`Publish with ${code(`happyskills publish ${full}`)}.`)
212
+ return
213
+ }
214
+ print_success(`${full} is ${result.data.status} — nothing to reconcile.`)
215
+ return
216
+ }
217
+ if (result.data?.applied) {
218
+ print_success(`Applied ${result.data.applied.applied} — skill.json now at ${result.data.applied.new_disk_version}.`)
219
+ return
220
+ }
221
+ if (result.next_step) {
222
+ print_warn(`${full} has drift (${result.data.drift_state || 'unknown'}).`)
223
+ const opts = result.next_step.context?.options || []
224
+ for (const o of opts) print_info(` - ${o}`)
225
+ print_hint(`Pick one and re-run: ${code(`happyskills reconcile ${full} --apply <option>`)}`)
226
+ }
227
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
228
+
229
+ module.exports = { run, reconcile_one, SUBTYPE_OPTIONS, ACTION_NEXT_STEPS }
@@ -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 }