happyskills 0.47.1 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/package.json +1 -1
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/postlex.js +101 -10
- package/src/commands/postlex.test.js +141 -0
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/search.js +2 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +4 -1
- package/src/index.js +3 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
|
@@ -16,6 +16,9 @@ const {
|
|
|
16
16
|
determine_next_step,
|
|
17
17
|
build_retry_envelope,
|
|
18
18
|
parse_input,
|
|
19
|
+
resolve_row_name,
|
|
20
|
+
normalize_data_rows,
|
|
21
|
+
extract_data_array_from_search_output,
|
|
19
22
|
} = require('./postlex')
|
|
20
23
|
|
|
21
24
|
// ─── Test fixtures ────────────────────────────────────────────────────────
|
|
@@ -283,6 +286,144 @@ describe('parse_input', () => {
|
|
|
283
286
|
|
|
284
287
|
// ─── build_final_ordering ─────────────────────────────────────────────────
|
|
285
288
|
|
|
289
|
+
// ─── resolve_row_name + normalize_data_rows (v0.48.0) ─────────────────────
|
|
290
|
+
|
|
291
|
+
describe('resolve_row_name', () => {
|
|
292
|
+
it('returns row.name when present', () => {
|
|
293
|
+
assert.equal(resolve_row_name({ name: 'foo', skill: 'acme/foo' }), 'foo')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('falls back to last segment of skill when name is missing', () => {
|
|
297
|
+
assert.equal(resolve_row_name({ skill: 'acme/deploy-aws' }), 'deploy-aws')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns null when neither name nor skill is present', () => {
|
|
301
|
+
assert.equal(resolve_row_name({ description: 'whatever' }), null)
|
|
302
|
+
assert.equal(resolve_row_name({}), null)
|
|
303
|
+
assert.equal(resolve_row_name(null), null)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('returns null when skill is malformed (no slash, just a bare value, returns the value itself)', () => {
|
|
307
|
+
// "deploy-aws" with no slash is a one-segment slug — last segment IS deploy-aws.
|
|
308
|
+
assert.equal(resolve_row_name({ skill: 'deploy-aws' }), 'deploy-aws')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('handles empty-string name by falling back to skill', () => {
|
|
312
|
+
assert.equal(resolve_row_name({ name: '', skill: 'acme/foo' }), 'foo')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe('normalize_data_rows', () => {
|
|
317
|
+
it('adds name to rows that only have skill', () => {
|
|
318
|
+
const data = [{ skill: 'acme/deploy-aws', workspace_slug: 'acme' }]
|
|
319
|
+
normalize_data_rows(data)
|
|
320
|
+
assert.equal(data[0].name, 'deploy-aws')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('leaves rows with existing name untouched', () => {
|
|
324
|
+
const data = [{ name: 'pre-existing', skill: 'acme/different' }]
|
|
325
|
+
normalize_data_rows(data)
|
|
326
|
+
assert.equal(data[0].name, 'pre-existing')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('is idempotent', () => {
|
|
330
|
+
const data = [{ skill: 'acme/deploy-aws' }]
|
|
331
|
+
normalize_data_rows(data)
|
|
332
|
+
normalize_data_rows(data)
|
|
333
|
+
assert.equal(data[0].name, 'deploy-aws')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('handles non-array input without crashing', () => {
|
|
337
|
+
normalize_data_rows(null)
|
|
338
|
+
normalize_data_rows('not an array')
|
|
339
|
+
normalize_data_rows({})
|
|
340
|
+
// no assertion — just confirming no throw
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
|
|
345
|
+
|
|
346
|
+
describe('extract_data_array_from_search_output', () => {
|
|
347
|
+
it('extracts data.results from the canonical envelope shape', () => {
|
|
348
|
+
const env = { data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] }, error: null, next_step: null }
|
|
349
|
+
const r = extract_data_array_from_search_output(env)
|
|
350
|
+
assert.equal(r.length, 1)
|
|
351
|
+
assert.equal(r[0].name, 'foo')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('handles legacy {data: [...]} (data is bare array)', () => {
|
|
355
|
+
const env = { data: [{ name: 'foo' }] }
|
|
356
|
+
assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('handles bare array', () => {
|
|
360
|
+
const env = [{ name: 'foo' }]
|
|
361
|
+
assert.deepEqual(extract_data_array_from_search_output(env), env)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('handles double-wrapped {data: {data: [...]}} (defensive)', () => {
|
|
365
|
+
const env = { data: { data: [{ name: 'foo' }] } }
|
|
366
|
+
assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('returns null when no data array can be found', () => {
|
|
370
|
+
assert.equal(extract_data_array_from_search_output({}), null)
|
|
371
|
+
assert.equal(extract_data_array_from_search_output({ data: null }), null)
|
|
372
|
+
assert.equal(extract_data_array_from_search_output('string'), null)
|
|
373
|
+
assert.equal(extract_data_array_from_search_output(null), null)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// ─── parse_input with --search-output path (v0.48.0) ──────────────────────
|
|
378
|
+
|
|
379
|
+
describe('parse_input with --search-output', () => {
|
|
380
|
+
it('extracts data.results from a full search envelope passed as the third argument', () => {
|
|
381
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
|
|
382
|
+
const search_out = JSON.stringify({
|
|
383
|
+
data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
|
|
384
|
+
error: null,
|
|
385
|
+
next_step: null,
|
|
386
|
+
})
|
|
387
|
+
const r = parse_input(ranking, null, search_out)
|
|
388
|
+
assert.equal(r.parse_error, null)
|
|
389
|
+
assert.equal(r.ranking[0].candidate_id, 1)
|
|
390
|
+
assert.equal(r.data[0].name, 'deploy-aws')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('search-output overrides inline data when both are provided', () => {
|
|
394
|
+
const combined = JSON.stringify({
|
|
395
|
+
ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }],
|
|
396
|
+
data: [{ name: 'stale-inline-name' }],
|
|
397
|
+
})
|
|
398
|
+
const search_out = JSON.stringify({ data: { results: [{ name: 'fresh-from-search-output' }] } })
|
|
399
|
+
const r = parse_input(combined, null, search_out)
|
|
400
|
+
assert.equal(r.parse_error, null)
|
|
401
|
+
assert.equal(r.data[0].name, 'fresh-from-search-output')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('normalizes rows that only have skill (no name) when sourced from search-output', () => {
|
|
405
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
|
|
406
|
+
// to_smart_json before v0.48.0 emitted `skill` without `name`. Simulate that.
|
|
407
|
+
const search_out = JSON.stringify({ data: { results: [{ skill: 'acme/legacy-row', workspace_slug: 'acme' }] } })
|
|
408
|
+
const r = parse_input(ranking, null, search_out)
|
|
409
|
+
assert.equal(r.parse_error, null)
|
|
410
|
+
assert.equal(r.data[0].name, 'legacy-row')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('errors when search-output does not contain a data array', () => {
|
|
414
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
|
|
415
|
+
const search_out = JSON.stringify({ data: { query: 'q' } })
|
|
416
|
+
const r = parse_input(ranking, null, search_out)
|
|
417
|
+
assert.match(r.parse_error, /does not contain a data\.results array/)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('errors with an actionable message when data is missing from all sources', () => {
|
|
421
|
+
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
|
|
422
|
+
const r = parse_input(ranking, null, null)
|
|
423
|
+
assert.match(r.parse_error, /--search-output/)
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
|
|
286
427
|
describe('build_final_ordering', () => {
|
|
287
428
|
it('joins ranking with data rows, producing slug + rationale', () => {
|
|
288
429
|
const data = make_data(['deploy-aws', 'serverless'])
|
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 }
|