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.
- package/CHANGELOG.md +48 -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/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/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,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 }
|