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.
- package/CHANGELOG.md +66 -0
- package/package.json +5 -1
- package/src/api/client.js +5 -2
- package/src/api/feedback.js +71 -0
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/feedback.js +260 -0
- 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 +5 -1
- package/src/index.js +4 -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/utils/image_compression.js +70 -0
- package/src/utils/scrub_secrets.js +49 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
package/src/commands/install.js
CHANGED
|
@@ -3,9 +3,15 @@ const { install, install_from_manifest, install_from_lock } = require('../engine
|
|
|
3
3
|
const { read_lock } = require('../lock/reader')
|
|
4
4
|
const { read_manifest } = require('../manifest/reader')
|
|
5
5
|
const { print_help, print_hint, print_json, print_warn, code } = require('../ui/output')
|
|
6
|
-
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
7
|
-
const { find_project_root } = require('../config/paths')
|
|
6
|
+
const { exit_with_error, UsageError, CliError, AuthError } = require('../utils/errors')
|
|
7
|
+
const { find_project_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
8
8
|
const { EXIT_CODES } = require('../constants')
|
|
9
|
+
const { get_refs } = require('../api/repos')
|
|
10
|
+
const { extract_version } = require('./versions')
|
|
11
|
+
const { file_exists } = require('../utils/fs')
|
|
12
|
+
const { hash_directory } = require('../lock/integrity')
|
|
13
|
+
const { get_all_locked_skills } = require('../lock/reader')
|
|
14
|
+
const snapshot_storage = require('../snapshot/storage')
|
|
9
15
|
|
|
10
16
|
const HELP_TEXT = `Usage: happyskills install [<owner>/<skill>[@<version>]] [...] [options]
|
|
11
17
|
|
|
@@ -101,6 +107,79 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
101
107
|
throw new UsageError('--version flag cannot be used with multiple skills. Use inline @version syntax instead (e.g., acme/foo@1.2.0 acme/bar@2.0.0).')
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
// § 8.5 — Pre-flight hardening for --fresh.
|
|
111
|
+
// Closes the § 2 root-cause B (silent fallback to latest when the requested
|
|
112
|
+
// version isn't on the registry). Done at the command level so the failure
|
|
113
|
+
// mode surfaces BEFORE any disk mutation.
|
|
114
|
+
const fresh_snapshots = []
|
|
115
|
+
if (base_options.fresh) {
|
|
116
|
+
for (const { skill, version: inline_version } of parsed) {
|
|
117
|
+
const requested = flag_version || inline_version
|
|
118
|
+
if (!requested || requested === 'latest') continue
|
|
119
|
+
|
|
120
|
+
// (1) Verify the version exists on the registry. Hard-fail if not.
|
|
121
|
+
const [owner, name] = skill.split('/')
|
|
122
|
+
const [refs_err, refs] = await get_refs(owner, name)
|
|
123
|
+
if (refs_err) {
|
|
124
|
+
// Network failure or skill-not-found. Treat both as VERSION_NOT_FOUND
|
|
125
|
+
// because we cannot prove the version exists.
|
|
126
|
+
throw new UsageError(
|
|
127
|
+
`VERSION_NOT_FOUND: Cannot verify that ${skill}@${requested} exists on the registry (registry unreachable or skill unknown). ` +
|
|
128
|
+
`--fresh would wipe local content; refusing without confirmation.`
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
const available = (refs || []).map(r => extract_version(r.name)).filter(Boolean)
|
|
132
|
+
if (!available.includes(requested)) {
|
|
133
|
+
throw new UsageError(
|
|
134
|
+
`VERSION_NOT_FOUND: ${skill}@${requested} is not on the registry. ` +
|
|
135
|
+
`Available versions: ${available.join(', ') || '(none)'}. ` +
|
|
136
|
+
`This was previously a silent-fallback footgun (issue 260523-02 § 2.3) — --fresh now hard-fails.`
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// (2) Snapshot before wiping. Always — so a failed install is reversible.
|
|
141
|
+
const base_dir = skills_dir(base_options.global, base_options.project_root)
|
|
142
|
+
const skill_dir = skill_install_dir(base_dir, name)
|
|
143
|
+
const [, dir_present] = await file_exists(skill_dir)
|
|
144
|
+
if (dir_present) {
|
|
145
|
+
// (3) Local-edit check. Compare on-disk hash to lock baseline; if
|
|
146
|
+
// they disagree, require explicit --force-discard-local.
|
|
147
|
+
const [, lock_data_pre] = await read_lock(base_options.project_root)
|
|
148
|
+
let has_local_edits = false
|
|
149
|
+
if (lock_data_pre) {
|
|
150
|
+
const all = get_all_locked_skills(lock_data_pre)
|
|
151
|
+
const lock_entry_pre = all[skill] || null
|
|
152
|
+
if (lock_entry_pre?.base_integrity) {
|
|
153
|
+
const [, current_hash] = await hash_directory(skill_dir)
|
|
154
|
+
has_local_edits = current_hash && current_hash !== lock_entry_pre.base_integrity
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (has_local_edits && !args.flags['force-discard-local']) {
|
|
158
|
+
throw new UsageError(
|
|
159
|
+
`LOCAL_EDITS_PRESENT: ${skill} has local edits that would be destroyed by --fresh. ` +
|
|
160
|
+
`Snapshot first (\`happyskills snapshot create ${skill}\`) and pass --force-discard-local to proceed.`
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
const [snap_err, snap] = await snapshot_storage.create({
|
|
164
|
+
skill_dir,
|
|
165
|
+
workspace: owner,
|
|
166
|
+
skill: name,
|
|
167
|
+
lock_entry: lock_data_pre ? get_all_locked_skills(lock_data_pre)[skill] : null,
|
|
168
|
+
note: `pre-install-fresh: ${requested}`,
|
|
169
|
+
is_global: base_options.global,
|
|
170
|
+
project_root: base_options.project_root
|
|
171
|
+
})
|
|
172
|
+
if (snap_err) {
|
|
173
|
+
throw new CliError(
|
|
174
|
+
`SNAPSHOT_FAILED: Could not snapshot ${skill} before --fresh wipe. Refusing to proceed.`,
|
|
175
|
+
EXIT_CODES.ERROR
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
fresh_snapshots.push({ skill, snapshot_id: snap.snapshot_id, path: snap.path })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
104
183
|
const results = []
|
|
105
184
|
const failures = []
|
|
106
185
|
for (const { skill, version: inline_version } of parsed) {
|
|
@@ -121,16 +200,22 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
121
200
|
throw new Error(detail)
|
|
122
201
|
}
|
|
123
202
|
|
|
203
|
+
const snapshot_by_skill = Object.fromEntries(fresh_snapshots.map(s => [s.skill, s.snapshot_id]))
|
|
204
|
+
|
|
124
205
|
if (args.flags.json) {
|
|
125
|
-
const items = results.map(({ skill, result }) =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
206
|
+
const items = results.map(({ skill, result }) => {
|
|
207
|
+
const item = {
|
|
208
|
+
skill,
|
|
209
|
+
version: result.version,
|
|
210
|
+
installed: result.installed || [],
|
|
211
|
+
skipped: result.skipped || [],
|
|
212
|
+
skipped_deps: result.skipped_deps || [],
|
|
213
|
+
warnings: result.warnings || [],
|
|
214
|
+
forced: result.forced || []
|
|
215
|
+
}
|
|
216
|
+
if (snapshot_by_skill[skill]) item.snapshot_id = snapshot_by_skill[skill]
|
|
217
|
+
return item
|
|
218
|
+
})
|
|
134
219
|
const data = results.length === 1 && failures.length === 0 ? items[0] : items
|
|
135
220
|
if (failures.length > 0) {
|
|
136
221
|
print_json({ data, errors: failures })
|
package/src/commands/list.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
3
|
-
const { verify_lock_disk_consistency } = require('../lock/verify')
|
|
3
|
+
const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
|
|
4
4
|
const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
|
|
5
5
|
const { file_exists, read_json } = require('../utils/fs')
|
|
6
6
|
const { scan_skills_dir, scan_agent_orphan_skills } = require('../utils/skill_scanner')
|
|
@@ -76,14 +76,25 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
76
76
|
return manifest?.type || SKILL_TYPES.SKILL
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
// Compute drift up-front for every managed entry. Cheap
|
|
80
|
-
//
|
|
79
|
+
// Compute drift AND ahead state up-front for every managed entry. Cheap
|
|
80
|
+
// (one skill.json read per skill, plus an optional CHANGELOG read) and lets
|
|
81
|
+
// both the JSON and human paths report identically.
|
|
82
|
+
//
|
|
83
|
+
// §10.5: drift is narrowed to genuine inconsistency (regression, missing
|
|
84
|
+
// files); the disk-greater-than-lock case is reported under top-level
|
|
85
|
+
// status:ahead, not under drift.
|
|
81
86
|
const drift_by_skill = {}
|
|
87
|
+
const ahead_by_skill = {}
|
|
82
88
|
await Promise.all(managed_entries.map(async ([name, data]) => {
|
|
83
89
|
const short = name.split('/')[1]
|
|
84
90
|
const dir = skill_install_dir(base_dir, short)
|
|
85
91
|
const [, drift] = await verify_lock_disk_consistency(data, dir)
|
|
86
|
-
if (drift && !drift.ok)
|
|
92
|
+
if (drift && !drift.ok) {
|
|
93
|
+
drift_by_skill[name] = drift
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const [, ahead] = await detect_ahead_state(data, dir)
|
|
97
|
+
if (ahead && ahead.ahead) ahead_by_skill[name] = ahead
|
|
87
98
|
}))
|
|
88
99
|
|
|
89
100
|
if (args.flags.json) {
|
|
@@ -93,12 +104,23 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
93
104
|
const dir = skill_install_dir(base_dir, short)
|
|
94
105
|
const [, exists] = await file_exists(dir)
|
|
95
106
|
const drift = drift_by_skill[name]
|
|
96
|
-
const
|
|
107
|
+
const ahead = ahead_by_skill[name]
|
|
108
|
+
let status
|
|
109
|
+
if (drift) status = 'drift'
|
|
110
|
+
else if (ahead) status = 'ahead'
|
|
111
|
+
else if (exists) status = 'installed'
|
|
112
|
+
else status = 'missing'
|
|
97
113
|
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
98
114
|
const type = await resolve_type(name, data)
|
|
99
115
|
const enabled = enabled_map?.get(short) ?? true
|
|
100
116
|
const entry = { version: data.version, type, source, status, enabled }
|
|
101
117
|
if (drift) entry.drift = { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
|
|
118
|
+
if (ahead) entry.ahead = {
|
|
119
|
+
lock_version: ahead.lock_version,
|
|
120
|
+
disk_version: ahead.disk_version,
|
|
121
|
+
has_changelog_entry: ahead.has_changelog_entry || false,
|
|
122
|
+
changelog_version: ahead.changelog_version || null
|
|
123
|
+
}
|
|
102
124
|
skills_map[name] = entry
|
|
103
125
|
}
|
|
104
126
|
const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
|
|
@@ -117,9 +139,12 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
117
139
|
const dir = skill_install_dir(base_dir, short)
|
|
118
140
|
const [, exists] = await file_exists(dir)
|
|
119
141
|
const drift = drift_by_skill[name]
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
const ahead = ahead_by_skill[name]
|
|
143
|
+
let status_label
|
|
144
|
+
if (drift) status_label = red(`drift (${drift.reason})`)
|
|
145
|
+
else if (ahead) status_label = `ahead (disk ${ahead.disk_version})`
|
|
146
|
+
else if (exists) status_label = 'installed'
|
|
147
|
+
else status_label = yellow('missing')
|
|
123
148
|
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
124
149
|
const type = await resolve_type(name, data)
|
|
125
150
|
const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
|
|
@@ -140,10 +165,15 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
140
165
|
print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
|
|
141
166
|
|
|
142
167
|
const drift_count = Object.keys(drift_by_skill).length
|
|
168
|
+
const ahead_count = Object.keys(ahead_by_skill).length
|
|
143
169
|
if (drift_count > 0) {
|
|
144
170
|
console.log()
|
|
145
171
|
print_warn(`${drift_count} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
146
|
-
print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills
|
|
172
|
+
print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills reconcile <skill>')} to repair.`)
|
|
173
|
+
}
|
|
174
|
+
if (ahead_count > 0) {
|
|
175
|
+
console.log()
|
|
176
|
+
print_info(`${ahead_count} skill(s) ahead of lock — bumped locally, not yet published. Run publish when ready.`)
|
|
147
177
|
}
|
|
148
178
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
149
179
|
|
package/src/commands/publish.js
CHANGED
|
@@ -260,45 +260,52 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
260
260
|
|
|
261
261
|
spinner.succeed(`Published ${workspace.slug}/${manifest.name}@${manifest.version}`)
|
|
262
262
|
|
|
263
|
-
// Update lock file: set base_commit and base_integrity to new values
|
|
263
|
+
// Update lock file: set base_commit and base_integrity to new values.
|
|
264
|
+
// When no lock file exists yet (first publish in a fresh project), we
|
|
265
|
+
// still create one — otherwise downstream `list`/`status`/`check` would
|
|
266
|
+
// treat the just-published skill as external. This was a latent bug
|
|
267
|
+
// pre-260523-02; the spec's lock-as-registry-view principle (§ 4.5) makes
|
|
268
|
+
// it especially important to keep the lock authoritative.
|
|
264
269
|
const full_name = full_name_pre
|
|
265
270
|
let post_publish_entry = null
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
base_commit: push_data?.commit || null,
|
|
294
|
-
base_integrity: (!hash_err && integrity) ? integrity : null,
|
|
295
|
-
requested_by: ['__root__'],
|
|
296
|
-
dependencies: manifest.dependencies || {}
|
|
297
|
-
}
|
|
298
|
-
const updated_skills = update_lock_skills(lock_data, { [full_name]: new_entry })
|
|
299
|
-
await write_lock(project_root, updated_skills)
|
|
300
|
-
post_publish_entry = new_entry
|
|
271
|
+
const [hash_err, integrity] = await hash_directory(dir)
|
|
272
|
+
|
|
273
|
+
const new_entry = {
|
|
274
|
+
version: manifest.version,
|
|
275
|
+
ref: push_data?.ref || `refs/tags/v${manifest.version}`,
|
|
276
|
+
commit: push_data?.commit || null,
|
|
277
|
+
integrity: (!hash_err && integrity) ? integrity : null,
|
|
278
|
+
base_commit: push_data?.commit || null,
|
|
279
|
+
base_integrity: (!hash_err && integrity) ? integrity : null,
|
|
280
|
+
requested_by: ['__root__'],
|
|
281
|
+
dependencies: manifest.dependencies || {}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const lock_data_to_use = (!lock_err && lock_data) ? lock_data : { lockVersion: 2, generatedAt: new Date().toISOString(), skills: {} }
|
|
285
|
+
const all_skills = get_all_locked_skills(lock_data_to_use)
|
|
286
|
+
const suffix = `/${skill_name}`
|
|
287
|
+
const existing_lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
288
|
+
|
|
289
|
+
if (existing_lock_key && all_skills[existing_lock_key]) {
|
|
290
|
+
const updated_entry = {
|
|
291
|
+
...all_skills[existing_lock_key],
|
|
292
|
+
version: manifest.version,
|
|
293
|
+
ref: push_data?.ref || `refs/tags/v${manifest.version}`,
|
|
294
|
+
commit: push_data?.commit || null,
|
|
295
|
+
base_commit: push_data?.commit || null,
|
|
296
|
+
base_integrity: (!hash_err && integrity) ? integrity : null,
|
|
297
|
+
dependencies: manifest.dependencies || {}
|
|
301
298
|
}
|
|
299
|
+
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
300
|
+
delete updated_entry.merge_parents
|
|
301
|
+
delete updated_entry.conflict_files
|
|
302
|
+
const updated_skills = update_lock_skills(lock_data_to_use, { [existing_lock_key]: updated_entry })
|
|
303
|
+
await write_lock(project_root, updated_skills)
|
|
304
|
+
post_publish_entry = updated_entry
|
|
305
|
+
} else {
|
|
306
|
+
const updated_skills = update_lock_skills(lock_data_to_use, { [full_name]: new_entry })
|
|
307
|
+
await write_lock(project_root, updated_skills)
|
|
308
|
+
post_publish_entry = new_entry
|
|
302
309
|
}
|
|
303
310
|
|
|
304
311
|
// Post-write verification — confirm the lock entry now agrees with the
|
package/src/commands/pull.js
CHANGED
|
@@ -33,6 +33,7 @@ Options:
|
|
|
33
33
|
--theirs [files] Take remote version on conflicts (all, or comma-separated file list)
|
|
34
34
|
--ours [files] Keep local version on conflicts (all, or comma-separated file list)
|
|
35
35
|
--force Discard all local changes, take remote entirely
|
|
36
|
+
--rebase Rebase local edits onto the remote head (snapshot-backed, structured rejection envelope on failure)
|
|
36
37
|
-g, --global Pull globally installed skill
|
|
37
38
|
--strict Fail on incompatible dependency ranges instead of warning
|
|
38
39
|
--json Output as JSON
|
|
@@ -40,6 +41,7 @@ Options:
|
|
|
40
41
|
|
|
41
42
|
Examples:
|
|
42
43
|
happyskills pull acme/deploy-aws
|
|
44
|
+
happyskills pull acme/deploy-aws --rebase --json
|
|
43
45
|
happyskills pull acme/deploy-aws --theirs
|
|
44
46
|
happyskills pull acme/deploy-aws --theirs SKILL.md,skill.json --ours references/foo.md
|
|
45
47
|
happyskills pull acme/deploy-aws --json --full-report`
|
|
@@ -111,6 +113,44 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
111
113
|
const is_global = args.flags.global || false
|
|
112
114
|
const full_report = !!(args.flags.json && args.flags['full-report'])
|
|
113
115
|
|
|
116
|
+
// § 8.3 — rebase-style pull. Delegates to merge/rebase.js for the snapshot-
|
|
117
|
+
// first capture/fast-forward/reapply flow with structured rejection envelopes.
|
|
118
|
+
if (args.flags.rebase) {
|
|
119
|
+
const { rebase_pull } = require('../merge/rebase')
|
|
120
|
+
const [err, result] = await rebase_pull(skill_name, { project_root: find_project_root(), is_global })
|
|
121
|
+
if (err) throw err
|
|
122
|
+
if (args.flags.json) {
|
|
123
|
+
print_json({
|
|
124
|
+
data: result.data || null,
|
|
125
|
+
next_step: result.next_step || null,
|
|
126
|
+
error: result.error || null
|
|
127
|
+
})
|
|
128
|
+
if (result.next_step) process.exit(EXIT_CODES.ERROR)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (result.error) {
|
|
132
|
+
print_warn(result.error.message || 'Pull --rebase failed')
|
|
133
|
+
if (result.next_step) print_hint(`Next: ${result.next_step.action}`)
|
|
134
|
+
process.exit(EXIT_CODES.ERROR)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (result.next_step?.action === 'resolve_patch_rejections') {
|
|
138
|
+
print_warn(`Pull --rebase: ${result.data.patches_rejected.length} patch(es) need resolution.`)
|
|
139
|
+
for (const r of result.data.patches_rejected) {
|
|
140
|
+
print_info(` - ${r.file}: ${r.reason}`)
|
|
141
|
+
}
|
|
142
|
+
print_hint(`Restore the pre-rebase state with ${code(`happyskills snapshot restore ${result.data.snapshot_id}`)} if needed.`)
|
|
143
|
+
process.exit(EXIT_CODES.ERROR)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (result.data?.status === 'up_to_date') {
|
|
147
|
+
print_info(`${skill_name} is already up to date.`)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
print_success(`Rebased ${skill_name} → ${result.data.version || 'latest'}`)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
114
154
|
// Parse per-file strategies: --theirs SKILL.md,skill.json --ours references/foo.md
|
|
115
155
|
const theirs_files = typeof args.flags.theirs === 'string'
|
|
116
156
|
? new Set(args.flags.theirs.split(',').map(s => s.trim()))
|
|
@@ -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 }
|