happyskills 0.20.0 → 0.22.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 +21 -0
- package/package.json +1 -1
- package/src/commands/bump.js +31 -17
- package/src/commands/check.js +18 -7
- package/src/commands/publish.js +4 -3
- package/src/commands/pull.js +64 -14
- package/src/commands/refresh.js +35 -8
- package/src/lock/integrity.js +10 -2
- package/src/merge/detector.test.js +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.22.0] - 2026-03-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add remote version check to `bump` — warns if the target version already exists on the registry before proceeding
|
|
14
|
+
- Add per-file merge strategy to `pull` — use `--theirs SKILL.md,skill.json --ours references/foo.md` to resolve conflicts file-by-file instead of all-or-nothing
|
|
15
|
+
- Add `--strict` flag to `pull` — fails on incompatible dependency ranges instead of warning
|
|
16
|
+
- Add dependency range validation to `pull` — warns when an installed dependency version falls outside the constraint declared in the merged `skill.json`
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Move auth verification earlier in `publish` — `list_workspaces()` now runs immediately after `require_token()`, failing fast on invalid/expired tokens before validation or upload
|
|
20
|
+
- Cache `hash_directory` results in memory within a single command invocation — prevents redundant disk reads when `status` or `refresh` checks multiple skills
|
|
21
|
+
|
|
22
|
+
## [0.21.0] - 2026-03-30
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Upgrade `check` and `refresh` from semver-only to commit-level comparison (`base_commit` vs remote head) for accurate divergence detection — catches divergence even when version strings match (e.g., after force-publish). Falls back to version comparison for old lock files without `base_commit`.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- Add `conflicts` status to `check` command — detects unresolved merge conflicts from the lock file's `conflict_files` field without disk I/O. JSON output now includes `conflicts_count`.
|
|
29
|
+
- Add local modification protection to `refresh` — skills with local edits are skipped with a warning and `happyskills pull` suggestion instead of being silently overwritten. JSON output includes `skipped` array with `{ skill, reason, suggestion }` per entry.
|
|
30
|
+
|
|
10
31
|
## [0.20.0] - 2026-03-30
|
|
11
32
|
|
|
12
33
|
### Added
|
package/package.json
CHANGED
package/src/commands/bump.js
CHANGED
|
@@ -8,7 +8,8 @@ const { find_project_root } = require('../config/paths')
|
|
|
8
8
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
9
9
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
10
10
|
const { hash_directory } = require('../lock/integrity')
|
|
11
|
-
const
|
|
11
|
+
const repos_api = require('../api/repos')
|
|
12
|
+
const { print_help, print_success, print_warn, print_json } = require('../ui/output')
|
|
12
13
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
13
14
|
const { EXIT_CODES } = require('../constants')
|
|
14
15
|
|
|
@@ -50,6 +51,17 @@ const run = (args) => catch_errors('Bump failed', async () => {
|
|
|
50
51
|
|
|
51
52
|
const old_version = manifest.version
|
|
52
53
|
|
|
54
|
+
// Read lock file early to resolve full skill name for remote check + lock update
|
|
55
|
+
const project_root = find_project_root()
|
|
56
|
+
const [lock_err, lock_data] = await read_lock(project_root)
|
|
57
|
+
let lock_key = null
|
|
58
|
+
let all_skills = null
|
|
59
|
+
if (!lock_err && lock_data) {
|
|
60
|
+
all_skills = get_all_locked_skills(lock_data)
|
|
61
|
+
const suffix = `/${skill_name}`
|
|
62
|
+
lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix)) || null
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
if (BUMP_TYPES.includes(input)) {
|
|
54
66
|
const new_version = inc(old_version, input)
|
|
55
67
|
if (!new_version) throw new Error(`Failed to bump version from ${old_version}`)
|
|
@@ -60,6 +72,15 @@ const run = (args) => catch_errors('Bump failed', async () => {
|
|
|
60
72
|
manifest.version = cleaned
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
// Warn if target version already exists remotely
|
|
76
|
+
if (lock_key) {
|
|
77
|
+
const [, batch_data] = await repos_api.check_updates([lock_key])
|
|
78
|
+
const info = batch_data?.results?.[lock_key]
|
|
79
|
+
if (info?.latest_version === manifest.version) {
|
|
80
|
+
print_warn(`Version ${manifest.version} already exists remotely for ${lock_key}. Publishing will fail with VERSION_EXISTS.`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
const validation = validate_manifest(manifest)
|
|
64
85
|
if (!validation.valid) {
|
|
65
86
|
throw new Error(`Invalid manifest after bump: ${validation.errors.join(', ')}`)
|
|
@@ -68,23 +89,16 @@ const run = (args) => catch_errors('Bump failed', async () => {
|
|
|
68
89
|
const [write_err] = await write_manifest(dir, manifest)
|
|
69
90
|
if (write_err) throw e('Failed to update skill.json', write_err)
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (lock_key && all_skills[lock_key]) {
|
|
78
|
-
const [hash_err, integrity] = await hash_directory(dir)
|
|
79
|
-
const updated_entry = {
|
|
80
|
-
...all_skills[lock_key],
|
|
81
|
-
version: manifest.version,
|
|
82
|
-
ref: `refs/tags/v${manifest.version}`
|
|
83
|
-
}
|
|
84
|
-
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
85
|
-
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
86
|
-
await write_lock(project_root, updated_skills)
|
|
92
|
+
if (lock_key && all_skills?.[lock_key]) {
|
|
93
|
+
const [hash_err, integrity] = await hash_directory(dir)
|
|
94
|
+
const updated_entry = {
|
|
95
|
+
...all_skills[lock_key],
|
|
96
|
+
version: manifest.version,
|
|
97
|
+
ref: `refs/tags/v${manifest.version}`
|
|
87
98
|
}
|
|
99
|
+
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
100
|
+
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
101
|
+
await write_lock(project_root, updated_skills)
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
if (args.flags.json) {
|
package/src/commands/check.js
CHANGED
|
@@ -1,7 +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
3
|
const repos_api = require('../api/repos')
|
|
4
|
-
const { gt } = require('../utils/semver')
|
|
5
4
|
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
6
5
|
const { green, yellow, red } = require('../ui/colors')
|
|
7
6
|
const { exit_with_error } = require('../utils/errors')
|
|
@@ -68,13 +67,17 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
68
67
|
} else {
|
|
69
68
|
for (const [name, data] of to_check) {
|
|
70
69
|
const info = batch_data?.results?.[name]
|
|
71
|
-
|
|
70
|
+
const has_conflicts = (data.conflict_files || []).length > 0
|
|
71
|
+
if (has_conflicts) {
|
|
72
|
+
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts' })
|
|
73
|
+
} else if (info?.access_denied) {
|
|
72
74
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
|
|
73
75
|
} else if (!info || !info.latest_version) {
|
|
74
76
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
|
|
75
|
-
} else if (info.
|
|
76
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: '
|
|
77
|
-
} else if (
|
|
77
|
+
} else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
|
|
78
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
79
|
+
} else if (!data.base_commit && info.latest_version !== data.version) {
|
|
80
|
+
// Fallback to version comparison for old lock files without base_commit
|
|
78
81
|
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
79
82
|
} else {
|
|
80
83
|
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
|
|
@@ -85,13 +88,15 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
85
88
|
if (args.flags.json) {
|
|
86
89
|
const outdated_count = results.filter(r => r.status === 'outdated').length
|
|
87
90
|
const up_to_date_count = results.filter(r => r.status === 'up-to-date').length
|
|
88
|
-
|
|
91
|
+
const conflicts_count = results.filter(r => r.status === 'conflicts').length
|
|
92
|
+
print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count } })
|
|
89
93
|
return
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
const status_colors = {
|
|
93
97
|
'up-to-date': green,
|
|
94
98
|
'outdated': yellow,
|
|
99
|
+
'conflicts': red,
|
|
95
100
|
'no-access': yellow,
|
|
96
101
|
'error': red,
|
|
97
102
|
'unknown': (s) => s
|
|
@@ -107,11 +112,17 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
107
112
|
print_table(['Skill', 'Installed', 'Latest', 'Status'], rows)
|
|
108
113
|
|
|
109
114
|
const outdated = results.filter(r => r.status === 'outdated')
|
|
115
|
+
const conflicts = results.filter(r => r.status === 'conflicts')
|
|
110
116
|
const no_access = results.filter(r => r.status === 'no-access')
|
|
117
|
+
if (conflicts.length > 0) {
|
|
118
|
+
console.log()
|
|
119
|
+
print_warn(`${conflicts.length} skill(s) have unresolved merge conflicts.`)
|
|
120
|
+
print_hint(`Run ${code('happyskills status')} for details.`)
|
|
121
|
+
}
|
|
111
122
|
if (outdated.length > 0) {
|
|
112
123
|
console.log()
|
|
113
124
|
print_info(`Run ${code('happyskills update')} to upgrade ${outdated.length} skill(s).`)
|
|
114
|
-
} else if (results.every(r => r.status === 'up-to-date')) {
|
|
125
|
+
} else if (conflicts.length === 0 && results.every(r => r.status === 'up-to-date')) {
|
|
115
126
|
console.log()
|
|
116
127
|
print_success('All skills are up to date.')
|
|
117
128
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -67,6 +67,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
67
67
|
|
|
68
68
|
await require_token()
|
|
69
69
|
|
|
70
|
+
// Verify auth early — fail fast before validation and upload
|
|
71
|
+
const [ws_err, workspaces] = await workspaces_api.list_workspaces()
|
|
72
|
+
if (ws_err) throw e('Authentication failed — run \'happyskills login\' to re-authenticate.', ws_err)
|
|
73
|
+
|
|
70
74
|
const [dir_err, dir] = await resolve_skill_dir(skill_name)
|
|
71
75
|
if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
|
|
72
76
|
|
|
@@ -133,9 +137,6 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
133
137
|
if (resolved_owner) owner = resolved_owner
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
const [ws_err, workspaces] = await workspaces_api.list_workspaces()
|
|
137
|
-
if (ws_err) { spinner.fail('Failed to list workspaces'); throw e('Failed to list workspaces', ws_err) }
|
|
138
|
-
|
|
139
140
|
const workspace = choose_workspace(workspaces, owner)
|
|
140
141
|
|
|
141
142
|
if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
|
package/src/commands/pull.js
CHANGED
|
@@ -10,6 +10,7 @@ const { three_way_merge } = require('../merge/text_merge')
|
|
|
10
10
|
const { merge_skill_json } = require('../merge/json_merge')
|
|
11
11
|
const { merge_changelog } = require('../merge/changelog_merge')
|
|
12
12
|
const { hash_blob } = require('../utils/git_hash')
|
|
13
|
+
const { satisfies } = require('../utils/semver')
|
|
13
14
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
14
15
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
15
16
|
const { hash_directory } = require('../lock/integrity')
|
|
@@ -28,16 +29,18 @@ Arguments:
|
|
|
28
29
|
owner/skill Skill to pull (required)
|
|
29
30
|
|
|
30
31
|
Options:
|
|
31
|
-
--theirs
|
|
32
|
-
--ours
|
|
32
|
+
--theirs [files] Take remote version on conflicts (all, or comma-separated file list)
|
|
33
|
+
--ours [files] Keep local version on conflicts (all, or comma-separated file list)
|
|
33
34
|
--force Discard all local changes, take remote entirely
|
|
34
35
|
-g, --global Pull globally installed skill
|
|
36
|
+
--strict Fail on incompatible dependency ranges instead of warning
|
|
35
37
|
--json Output as JSON
|
|
36
38
|
--full-report Include inline file content and resolution steps in JSON output (for AI agent review)
|
|
37
39
|
|
|
38
40
|
Examples:
|
|
39
41
|
happyskills pull acme/deploy-aws
|
|
40
42
|
happyskills pull acme/deploy-aws --theirs
|
|
43
|
+
happyskills pull acme/deploy-aws --theirs SKILL.md,skill.json --ours references/foo.md
|
|
41
44
|
happyskills pull acme/deploy-aws --json --full-report`
|
|
42
45
|
|
|
43
46
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -105,8 +108,32 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
const is_global = args.flags.global || false
|
|
108
|
-
const strategy = args.flags.theirs ? 'theirs' : args.flags.ours ? 'ours' : args.flags.force ? 'force' : null
|
|
109
111
|
const full_report = !!(args.flags.json && args.flags['full-report'])
|
|
112
|
+
|
|
113
|
+
// Parse per-file strategies: --theirs SKILL.md,skill.json --ours references/foo.md
|
|
114
|
+
const theirs_files = typeof args.flags.theirs === 'string'
|
|
115
|
+
? new Set(args.flags.theirs.split(',').map(s => s.trim()))
|
|
116
|
+
: null
|
|
117
|
+
const ours_files = typeof args.flags.ours === 'string'
|
|
118
|
+
? new Set(args.flags.ours.split(',').map(s => s.trim()))
|
|
119
|
+
: null
|
|
120
|
+
|
|
121
|
+
// Validate no path appears in both --theirs and --ours
|
|
122
|
+
if (theirs_files && ours_files) {
|
|
123
|
+
for (const p of theirs_files) {
|
|
124
|
+
if (ours_files.has(p)) throw new UsageError(`"${p}" cannot be in both --theirs and --ours.`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Global strategy (boolean flags only, backward compatible)
|
|
129
|
+
const strategy = args.flags.theirs === true ? 'theirs' : args.flags.ours === true ? 'ours' : args.flags.force ? 'force' : null
|
|
130
|
+
|
|
131
|
+
// Per-file strategy resolver: per-file overrides take precedence over global
|
|
132
|
+
const file_strategy = (file_path) => {
|
|
133
|
+
if (theirs_files?.has(file_path)) return 'theirs'
|
|
134
|
+
if (ours_files?.has(file_path)) return 'ours'
|
|
135
|
+
return strategy
|
|
136
|
+
}
|
|
110
137
|
const project_root = find_project_root()
|
|
111
138
|
|
|
112
139
|
// 1. Read lock file
|
|
@@ -212,17 +239,33 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
212
239
|
const classified = classify_changes(base_files, local_files, remote_files)
|
|
213
240
|
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
214
241
|
|
|
242
|
+
// Validate per-file strategy paths against actual conflicts
|
|
243
|
+
if (theirs_files || ours_files) {
|
|
244
|
+
const both_paths = new Set(classified.both_modified.map(e => e.path))
|
|
245
|
+
for (const p of (theirs_files || [])) {
|
|
246
|
+
if (!both_paths.has(p)) print_warn(`--theirs path "${p}" not found in conflicted files — ignored.`)
|
|
247
|
+
}
|
|
248
|
+
for (const p of (ours_files || [])) {
|
|
249
|
+
if (!both_paths.has(p)) print_warn(`--ours path "${p}" not found in conflicted files — ignored.`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Split both_modified by resolved strategy
|
|
254
|
+
const auto_merge_entries = classified.both_modified.filter(e => !file_strategy(e.path))
|
|
255
|
+
const theirs_entries = classified.both_modified.filter(e => file_strategy(e.path) === 'theirs')
|
|
256
|
+
const ours_entries = classified.both_modified.filter(e => file_strategy(e.path) === 'ours')
|
|
257
|
+
|
|
215
258
|
// Apply changes
|
|
216
259
|
spinner.update('Applying changes...')
|
|
217
260
|
const base_file_map = new Map((base_clone.files || []).map(f => [f.path, f]))
|
|
218
261
|
const remote_file_map = new Map((remote_clone.files || []).map(f => [f.path, f]))
|
|
219
262
|
|
|
220
|
-
// Auto-merge both_modified files
|
|
263
|
+
// Auto-merge both_modified files that have no explicit strategy
|
|
221
264
|
const conflict_files = []
|
|
222
265
|
const json_conflicts = []
|
|
223
|
-
if (
|
|
266
|
+
if (auto_merge_entries.length > 0) {
|
|
224
267
|
spinner.update('Auto-merging...')
|
|
225
|
-
for (const entry of
|
|
268
|
+
for (const entry of auto_merge_entries) {
|
|
226
269
|
const report_file = report.files.find(f => f.path === entry.path)
|
|
227
270
|
|
|
228
271
|
// Delete-vs-modify — cannot auto-merge, keep what exists
|
|
@@ -333,9 +376,9 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
333
376
|
}
|
|
334
377
|
}
|
|
335
378
|
|
|
336
|
-
// Both-modified → apply strategy
|
|
337
|
-
if (
|
|
338
|
-
for (const entry of
|
|
379
|
+
// Both-modified → apply explicit strategy (global or per-file)
|
|
380
|
+
if (theirs_entries.length > 0) {
|
|
381
|
+
for (const entry of theirs_entries) {
|
|
339
382
|
if (full_report) {
|
|
340
383
|
const report_file = report.files.find(f => f.path === entry.path)
|
|
341
384
|
if (report_file) {
|
|
@@ -362,8 +405,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
362
405
|
}
|
|
363
406
|
}
|
|
364
407
|
}
|
|
365
|
-
if (
|
|
366
|
-
for (const entry of
|
|
408
|
+
if (ours_entries.length > 0 && full_report) {
|
|
409
|
+
for (const entry of ours_entries) {
|
|
367
410
|
const report_file = report.files.find(f => f.path === entry.path)
|
|
368
411
|
if (report_file) {
|
|
369
412
|
const content = { base: decode_b64(base_file_map.get(entry.path)), remote: decode_b64(remote_file_map.get(entry.path)) }
|
|
@@ -407,8 +450,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
407
450
|
if (report.summary.auto_merged > 0) {
|
|
408
451
|
print_info(`${report.summary.auto_merged} file(s) auto-applied (non-conflicting changes)`)
|
|
409
452
|
}
|
|
410
|
-
|
|
411
|
-
|
|
453
|
+
const resolved_count = theirs_entries.length + ours_entries.length
|
|
454
|
+
if (resolved_count > 0) {
|
|
455
|
+
const label = strategy ? `--${strategy}` : 'per-file strategy'
|
|
456
|
+
print_info(`${resolved_count} conflict(s) resolved with ${label}`)
|
|
412
457
|
}
|
|
413
458
|
const auto_merged_count = classified.both_modified.length - conflict_files.length
|
|
414
459
|
if (auto_merged_count > 0 && !strategy) {
|
|
@@ -457,7 +502,12 @@ const reconcile_dependencies = (skill_dir, skill_name, lock_data, is_global, pro
|
|
|
457
502
|
to_install.push(dep)
|
|
458
503
|
continue
|
|
459
504
|
}
|
|
460
|
-
//
|
|
505
|
+
// Check if installed version satisfies the new constraint
|
|
506
|
+
if (constraint && installed.version && !satisfies(installed.version, constraint)) {
|
|
507
|
+
const msg = `${dep}@${installed.version} is installed but ${skill_name} requires ${constraint}. Installed version is outside the declared range — verify compatibility.`
|
|
508
|
+
if (args.flags.strict) throw new Error(msg)
|
|
509
|
+
print_warn(msg)
|
|
510
|
+
}
|
|
461
511
|
}
|
|
462
512
|
|
|
463
513
|
if (to_install.length === 0) return
|
package/src/commands/refresh.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { install } = require('../engine/installer')
|
|
3
3
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
4
|
+
const { detect_status } = require('../merge/detector')
|
|
4
5
|
const repos_api = require('../api/repos')
|
|
5
|
-
const { gt } = require('../utils/semver')
|
|
6
6
|
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
7
7
|
const { green, yellow, red } = require('../ui/colors')
|
|
8
8
|
const { create_spinner } = require('../ui/spinner')
|
|
9
9
|
const { exit_with_error } = require('../utils/errors')
|
|
10
|
-
const { find_project_root, lock_root } = require('../config/paths')
|
|
10
|
+
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
11
11
|
const { EXIT_CODES } = require('../constants')
|
|
12
12
|
|
|
13
13
|
const HELP_TEXT = `Usage: happyskills refresh [options]
|
|
@@ -78,9 +78,10 @@ const run = (args) => catch_errors('Refresh failed', async () => {
|
|
|
78
78
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
|
|
79
79
|
} else if (!info || !info.latest_version) {
|
|
80
80
|
results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
|
|
81
|
-
} else if (info.
|
|
82
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: '
|
|
83
|
-
} else if (
|
|
81
|
+
} else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
|
|
82
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
83
|
+
} else if (!data.base_commit && info.latest_version !== data.version) {
|
|
84
|
+
// Fallback to version comparison for old lock files without base_commit
|
|
84
85
|
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
85
86
|
} else {
|
|
86
87
|
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
|
|
@@ -125,12 +126,29 @@ const run = (args) => catch_errors('Refresh failed', async () => {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
// 5.
|
|
129
|
+
// 5. Detect local modifications before updating
|
|
130
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
131
|
+
const skipped = []
|
|
132
|
+
const safe_to_update = []
|
|
133
|
+
|
|
134
|
+
for (const r of outdated) {
|
|
135
|
+
const lock_entry = skills[r.skill]
|
|
136
|
+
const short_name = r.skill.split('/')[1] || r.skill
|
|
137
|
+
const dir = skill_install_dir(base_dir, short_name)
|
|
138
|
+
const [, det] = await detect_status(lock_entry, dir)
|
|
139
|
+
if (det?.local_modified) {
|
|
140
|
+
skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
|
|
141
|
+
} else {
|
|
142
|
+
safe_to_update.push(r)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 6. Update safe skills
|
|
129
147
|
const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
|
|
130
148
|
const updated = []
|
|
131
149
|
const update_errors = []
|
|
132
150
|
|
|
133
|
-
for (const r of
|
|
151
|
+
for (const r of safe_to_update) {
|
|
134
152
|
const update_spinner = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
|
|
135
153
|
const [errors, result] = await install(r.skill, options)
|
|
136
154
|
if (errors) {
|
|
@@ -144,7 +162,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
|
|
|
144
162
|
}
|
|
145
163
|
}
|
|
146
164
|
|
|
147
|
-
//
|
|
165
|
+
// 7. Output results
|
|
148
166
|
if (args.flags.json) {
|
|
149
167
|
print_json({
|
|
150
168
|
data: {
|
|
@@ -152,6 +170,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
|
|
|
152
170
|
outdated_count: outdated.length,
|
|
153
171
|
up_to_date_count: up_to_date.length,
|
|
154
172
|
updated,
|
|
173
|
+
skipped,
|
|
155
174
|
already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
|
|
156
175
|
errors: update_errors
|
|
157
176
|
}
|
|
@@ -159,6 +178,14 @@ const run = (args) => catch_errors('Refresh failed', async () => {
|
|
|
159
178
|
return
|
|
160
179
|
}
|
|
161
180
|
|
|
181
|
+
if (skipped.length > 0) {
|
|
182
|
+
console.log()
|
|
183
|
+
print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
|
|
184
|
+
for (const s of skipped) {
|
|
185
|
+
print_info(` ${s.skill}`)
|
|
186
|
+
}
|
|
187
|
+
print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
|
|
188
|
+
}
|
|
162
189
|
if (updated.length > 0) {
|
|
163
190
|
console.log()
|
|
164
191
|
print_success(`Updated ${updated.length} skill(s).`)
|
package/src/lock/integrity.js
CHANGED
|
@@ -8,7 +8,13 @@ const hash_file = (file_path) => catch_errors(`Failed to hash file ${file_path}`
|
|
|
8
8
|
return crypto.createHash('sha256').update(content).digest('hex')
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
+
const _hash_cache = new Map()
|
|
12
|
+
|
|
13
|
+
const clear_integrity_cache = () => _hash_cache.clear()
|
|
14
|
+
|
|
11
15
|
const hash_directory = (dir_path) => catch_errors(`Failed to hash directory ${dir_path}`, async () => {
|
|
16
|
+
if (_hash_cache.has(dir_path)) return _hash_cache.get(dir_path)
|
|
17
|
+
|
|
12
18
|
const hash = crypto.createHash('sha256')
|
|
13
19
|
const entries = await collect_files(dir_path)
|
|
14
20
|
|
|
@@ -20,7 +26,9 @@ const hash_directory = (dir_path) => catch_errors(`Failed to hash directory ${di
|
|
|
20
26
|
hash.update(content)
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
const result = `sha256-${hash.digest('hex')}`
|
|
30
|
+
_hash_cache.set(dir_path, result)
|
|
31
|
+
return result
|
|
24
32
|
})
|
|
25
33
|
|
|
26
34
|
const collect_files = async (dir_path, base_path = dir_path) => {
|
|
@@ -51,4 +59,4 @@ const verify_integrity = (dir_path, expected_hash) => catch_errors('Integrity ve
|
|
|
51
59
|
return actual_hash === expected_hash
|
|
52
60
|
})
|
|
53
61
|
|
|
54
|
-
module.exports = { hash_file, hash_directory, verify_integrity }
|
|
62
|
+
module.exports = { hash_file, hash_directory, verify_integrity, clear_integrity_cache }
|
|
@@ -4,7 +4,7 @@ const fs = require('fs')
|
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const { detect_status } = require('./detector')
|
|
7
|
-
const { hash_directory } = require('../lock/integrity')
|
|
7
|
+
const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
|
|
8
8
|
|
|
9
9
|
const make_tmp = () => {
|
|
10
10
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-test-'))
|
|
@@ -39,8 +39,9 @@ describe('detect_status', () => {
|
|
|
39
39
|
|
|
40
40
|
const [, integrity] = await hash_directory(tmp_dir)
|
|
41
41
|
|
|
42
|
-
// Modify the file
|
|
42
|
+
// Modify the file and clear hash cache (cache is per-command-invocation optimization)
|
|
43
43
|
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'modified')
|
|
44
|
+
clear_integrity_cache()
|
|
44
45
|
|
|
45
46
|
const lock_entry = { base_integrity: integrity, base_commit: 'abc123' }
|
|
46
47
|
const [err, result] = await detect_status(lock_entry, tmp_dir)
|