happyskills 0.38.0 → 0.39.1
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 +20 -0
- package/package.json +1 -1
- package/src/commands/pull.js +1 -1
- package/src/commands/status.js +2 -1
- package/src/commands/update.js +241 -46
- package/src/constants.js +0 -2
- package/src/index.js +1 -2
- package/src/integration/cli.test.js +7 -10
- package/src/merge/detector.js +73 -13
- package/src/merge/detector.test.js +122 -8
- package/src/merge/file_diff.js +68 -0
- package/src/commands/refresh.js +0 -256
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.39.1] - 2026-05-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Fix `status` command falsely reporting `local_modified: true` and `status: diverged` for skills whose on-disk content is byte-for-byte identical to the registry baseline. The previous single-stage aggregate-integrity check could disagree with `diff` whenever the lock's `base_integrity` drifted from what `hash_directory` produces today (e.g. server-side normalization of the file set, or differences between the JSON-clone and archive install paths). `detect_status` now falls through to a per-file content comparison against the registry manifest at `base_commit` when the aggregate hash disagrees: zero real diffs → reports clean and silently auto-heals the lock entry's `integrity` / `base_integrity`; one or more real diffs → reports modified and includes the list of differing paths in the new `modified_files` field of the JSON output. Fixes the false-positive that blocked `update` (and required `--force`) and `pull` for skills with stale lock entries. The fast path is unchanged when the aggregate hash matches.
|
|
14
|
+
|
|
15
|
+
## [0.39.0] - 2026-05-01
|
|
16
|
+
|
|
17
|
+
### Removed
|
|
18
|
+
- **BREAKING:** Remove the `refresh` command (and its `r` alias) entirely. Its smart-batch-check behavior is now the default for `update` — see the Changed entry below. The `r` alias is also removed. Anyone scripting `happyskills refresh ...` should switch to `happyskills update --all -y --json` (same outcome, same JSON shape under `.data.results`).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **BREAKING (behavior):** `update [--all]` is now smart by default. It runs one `POST /repos:check-updates` batch call against the registry and only re-installs skills that are actually outdated. Skills already at the latest version are left alone (no download). Previously `update --all` always re-installed every locked skill regardless of version. Pass `--force` to opt back into the legacy "always re-install" behavior (now also overwrites local modifications, replacing the previous narrow meaning of `--force`).
|
|
22
|
+
- For `--all`, only **root-level** skills are checked (transitive dependencies follow their parents through the install pipeline). For a specific target, any locked skill works.
|
|
23
|
+
- `update --json` output shape now mirrors what `refresh --json` returned: `{ results, outdated_count, up_to_date_count, updated, skipped, already_up_to_date, errors }` plus `symlink_repairs`. With `--force`, the shape is the simpler `{ updated, count, forced: true }`.
|
|
24
|
+
|
|
25
|
+
### Migration
|
|
26
|
+
- Replace `happyskills refresh` → `happyskills update --all`
|
|
27
|
+
- Replace `happyskills refresh -y --json` → `happyskills update --all -y --json`
|
|
28
|
+
- If you actually wanted the old `update --all` behavior (force re-install regardless of version), now use `happyskills update --all --force`
|
|
29
|
+
|
|
10
30
|
## [0.38.0] - 2026-05-01
|
|
11
31
|
|
|
12
32
|
### Changed
|
package/package.json
CHANGED
package/src/commands/pull.js
CHANGED
|
@@ -152,7 +152,7 @@ const run = (args) => catch_errors('Pull failed', async () => {
|
|
|
152
152
|
const spinner = create_spinner(`Pulling ${skill_name}...`)
|
|
153
153
|
|
|
154
154
|
// 2. Detect local modifications
|
|
155
|
-
const [det_err, det] = await detect_status(lock_entry, skill_dir)
|
|
155
|
+
const [det_err, det] = await detect_status(lock_entry, skill_dir, { skill_name, project_root, is_global })
|
|
156
156
|
if (det_err) { spinner.fail('Failed to detect local status'); throw det_err[0] }
|
|
157
157
|
|
|
158
158
|
// 3. Compare with remote
|
package/src/commands/status.js
CHANGED
|
@@ -74,7 +74,7 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
74
74
|
if (!data) return { name, data, det: null }
|
|
75
75
|
const short_name = name.split('/')[1] || name
|
|
76
76
|
const dir = skill_install_dir(base_dir, short_name)
|
|
77
|
-
return detect_status(data, dir).then(([, det]) => ({ name, data, det }))
|
|
77
|
+
return detect_status(data, dir, { skill_name: name, project_root, is_global }).then(([, det]) => ({ name, data, det }))
|
|
78
78
|
}))
|
|
79
79
|
|
|
80
80
|
const results = detections.map(({ name, data, det }) => {
|
|
@@ -85,6 +85,7 @@ const run = (args) => catch_errors('Status failed', async () => {
|
|
|
85
85
|
base_version: data.version || null,
|
|
86
86
|
base_commit: data.base_commit || null,
|
|
87
87
|
local_modified: det?.local_modified || false,
|
|
88
|
+
modified_files: det?.modified_files || null,
|
|
88
89
|
// remote_updated is populated below after API call
|
|
89
90
|
remote_updated: false,
|
|
90
91
|
remote_version: null,
|
package/src/commands/update.js
CHANGED
|
@@ -1,31 +1,65 @@
|
|
|
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, get_via } = require('../lock/reader')
|
|
4
|
+
const { read_manifest } = require('../manifest/reader')
|
|
4
5
|
const { detect_status } = require('../merge/detector')
|
|
5
|
-
const
|
|
6
|
+
const repos_api = require('../api/repos')
|
|
7
|
+
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
8
|
+
const { green, yellow, red } = require('../ui/colors')
|
|
9
|
+
const { create_spinner } = require('../ui/spinner')
|
|
6
10
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
7
11
|
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
8
|
-
const { EXIT_CODES } = require('../constants')
|
|
12
|
+
const { EXIT_CODES, SKILL_TYPES } = require('../constants')
|
|
13
|
+
const { resolve_agents, verify_and_repair_symlinks } = require('../agents')
|
|
14
|
+
const { is_skill_enabled } = require('../agents/status')
|
|
9
15
|
|
|
10
16
|
const HELP_TEXT = `Usage: happyskills update [owner/skill|--all] [options]
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
Bring installed skills up to date. By default, checks each skill against the
|
|
19
|
+
registry (one batch API call) and only re-installs skills that are actually
|
|
20
|
+
outdated. Skills already at the latest version are left alone — no download,
|
|
21
|
+
no work. Use --force to re-install regardless of version.
|
|
13
22
|
|
|
14
23
|
Arguments:
|
|
15
|
-
owner/skill Update specific skill (optional)
|
|
24
|
+
owner/skill Update a specific skill (optional)
|
|
16
25
|
|
|
17
26
|
Options:
|
|
18
|
-
--all Update all installed skills
|
|
27
|
+
--all Update all installed root-level skills
|
|
28
|
+
--force Re-install regardless of version. Also overwrites skills
|
|
29
|
+
with local modifications.
|
|
19
30
|
-g, --global Update globally installed skills
|
|
20
31
|
-y, --yes Skip confirmation prompts
|
|
32
|
+
--agents <list> Target specific agents (comma-separated). Default: auto-detect
|
|
21
33
|
--json Output as JSON
|
|
22
34
|
|
|
23
35
|
Aliases: up
|
|
24
36
|
|
|
25
37
|
Examples:
|
|
26
|
-
happyskills update acme/deploy-aws
|
|
27
|
-
happyskills up --all
|
|
28
|
-
happyskills up -
|
|
38
|
+
happyskills update acme/deploy-aws # update one skill if outdated
|
|
39
|
+
happyskills up --all # check + update all outdated skills
|
|
40
|
+
happyskills up --all --force # force re-install everything
|
|
41
|
+
happyskills up --all -y --json # batch mode, no prompt`
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns true if the lock entry's dependencies differ from the installed
|
|
45
|
+
* skill.json's dependencies — indicates the lock file is stale relative to
|
|
46
|
+
* the on-disk manifest.
|
|
47
|
+
*/
|
|
48
|
+
const has_dependency_drift = (lock_entry, manifest) => {
|
|
49
|
+
if (!manifest) return false
|
|
50
|
+
const lock_deps = JSON.stringify(lock_entry.dependencies || {})
|
|
51
|
+
const manifest_deps = JSON.stringify(manifest.dependencies || {})
|
|
52
|
+
return lock_deps !== manifest_deps
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const confirm_prompt = (question) => new Promise((resolve) => {
|
|
56
|
+
const readline = require('readline')
|
|
57
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
58
|
+
rl.question(question, (answer) => {
|
|
59
|
+
rl.close()
|
|
60
|
+
resolve(answer.trim().toLowerCase())
|
|
61
|
+
})
|
|
62
|
+
})
|
|
29
63
|
|
|
30
64
|
const run = (args) => catch_errors('Update failed', async () => {
|
|
31
65
|
if (args.flags._show_help) {
|
|
@@ -36,80 +70,241 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
36
70
|
const project_root = find_project_root()
|
|
37
71
|
const target_skill = args._[0]
|
|
38
72
|
const update_all = args.flags.all || false
|
|
73
|
+
const force = args.flags.force || false
|
|
74
|
+
const auto_yes = args.flags.yes || false
|
|
75
|
+
const is_global = args.flags.global || false
|
|
39
76
|
|
|
40
77
|
if (!target_skill && !update_all) {
|
|
41
78
|
throw new UsageError("Specify a skill to update or use --all (e.g., 'happyskills update acme/deploy-aws' or 'happyskills update --all').")
|
|
42
79
|
}
|
|
43
80
|
|
|
44
|
-
const is_global = args.flags.global || false
|
|
45
81
|
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
46
82
|
const skills = get_all_locked_skills(lock_data)
|
|
83
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
47
84
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
// Determine which skills to consider.
|
|
86
|
+
// For --all, only root-level skills (transitive deps come along through their parents).
|
|
87
|
+
// For a specific target, allow any locked skill.
|
|
88
|
+
const candidates = target_skill
|
|
89
|
+
? (skills[target_skill] ? [[target_skill, skills[target_skill]]] : [])
|
|
90
|
+
: Object.entries(skills).filter(([, data]) => data && data.requested_by?.includes('__root__'))
|
|
51
91
|
|
|
52
|
-
if (
|
|
92
|
+
if (candidates.length === 0) {
|
|
93
|
+
if (target_skill) {
|
|
94
|
+
throw new UsageError(`${target_skill} is not installed. Run 'happyskills install ${target_skill}' first.`)
|
|
95
|
+
}
|
|
53
96
|
if (args.flags.json) {
|
|
54
|
-
print_json({ data: { updated: [], already_up_to_date: [],
|
|
97
|
+
print_json({ data: { results: [], outdated_count: 0, up_to_date_count: 0, updated: [], already_up_to_date: [], errors: [] } })
|
|
55
98
|
return
|
|
56
99
|
}
|
|
57
|
-
print_info('No skills
|
|
100
|
+
print_info('No skills installed.')
|
|
58
101
|
return
|
|
59
102
|
}
|
|
60
103
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
fresh: true,
|
|
64
|
-
|
|
65
|
-
|
|
104
|
+
// ─── --force path: skip the version check, re-install everything ─────────
|
|
105
|
+
if (force) {
|
|
106
|
+
const options = { global: is_global, fresh: true, force: true, agents: args.flags.agents || undefined, project_root }
|
|
107
|
+
const updated = []
|
|
108
|
+
for (const [name, data] of candidates) {
|
|
109
|
+
const before_version = data?.version || null
|
|
110
|
+
const spinner = !args.flags.json ? create_spinner(`Re-installing ${name}…`) : null
|
|
111
|
+
const [errors, result] = await install(name, options)
|
|
112
|
+
if (errors) {
|
|
113
|
+
spinner?.fail(`Failed to re-install ${name}`)
|
|
114
|
+
throw e(`Update ${name} failed`, errors)
|
|
115
|
+
}
|
|
116
|
+
spinner?.succeed(`Re-installed ${name}@${result.version}`)
|
|
117
|
+
updated.push({ skill: name, from: before_version, to: result.version, via: get_via(data) })
|
|
118
|
+
}
|
|
119
|
+
if (args.flags.json) {
|
|
120
|
+
print_json({ data: { updated, count: updated.length, forced: true } })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (updated.length > 0) {
|
|
124
|
+
print_success(`Re-installed ${updated.length} skill(s) (--force).`)
|
|
125
|
+
}
|
|
126
|
+
return
|
|
66
127
|
}
|
|
67
128
|
|
|
68
|
-
|
|
69
|
-
const
|
|
129
|
+
// ─── Default smart path: batch-check, then install only outdated ─────────
|
|
130
|
+
const spinner = !args.flags.json ? create_spinner('Checking for updates…') : null
|
|
131
|
+
const skill_names = candidates.map(([name]) => name)
|
|
132
|
+
const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
|
|
70
133
|
|
|
71
|
-
const
|
|
72
|
-
|
|
134
|
+
const results = []
|
|
135
|
+
if (batch_err) {
|
|
136
|
+
spinner?.fail('Failed to check for updates')
|
|
137
|
+
for (const [name, data] of candidates) {
|
|
138
|
+
results.push({ skill: name, installed: data.version, latest: 'error', status: 'error' })
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
spinner?.succeed('Checked for updates')
|
|
73
142
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const detections = await Promise.all(to_update.map(([name, data]) => {
|
|
78
|
-
if (!data) return { name, modified: false }
|
|
143
|
+
// Read installed manifests in parallel for dependency-drift detection
|
|
144
|
+
const manifest_map = {}
|
|
145
|
+
await Promise.all(candidates.map(async ([name]) => {
|
|
79
146
|
const short_name = name.split('/')[1] || name
|
|
80
147
|
const dir = skill_install_dir(base_dir, short_name)
|
|
81
|
-
|
|
148
|
+
const [, manifest] = await read_manifest(dir)
|
|
149
|
+
if (manifest) manifest_map[name] = manifest
|
|
82
150
|
}))
|
|
83
|
-
|
|
84
|
-
|
|
151
|
+
|
|
152
|
+
for (const [name, data] of candidates) {
|
|
153
|
+
const info = batch_data?.results?.[name]
|
|
154
|
+
if (info?.access_denied) {
|
|
155
|
+
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
|
|
156
|
+
} else if (!info || !info.latest_version) {
|
|
157
|
+
results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
|
|
158
|
+
} else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
|
|
159
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
160
|
+
} else if (!data.base_commit && info.latest_version !== data.version) {
|
|
161
|
+
// Fallback to version comparison for old lock files without base_commit
|
|
162
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
163
|
+
} else if (has_dependency_drift(data, manifest_map[name])) {
|
|
164
|
+
// Lock dependencies are stale compared to installed skill.json
|
|
165
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
166
|
+
} else {
|
|
167
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
|
|
168
|
+
}
|
|
85
169
|
}
|
|
86
170
|
}
|
|
87
171
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
172
|
+
const outdated = results.filter(r => r.status === 'outdated')
|
|
173
|
+
const up_to_date = results.filter(r => r.status === 'up-to-date')
|
|
174
|
+
|
|
175
|
+
// Verify and repair symlinks (even when nothing is outdated)
|
|
176
|
+
const [, agents_data] = await resolve_agents(args.flags.agents)
|
|
177
|
+
const detected_agents = agents_data?.agents || []
|
|
178
|
+
let symlink_repairs = []
|
|
179
|
+
if (detected_agents.length > 0) {
|
|
180
|
+
const skills_to_check = []
|
|
181
|
+
for (const [name, data] of Object.entries(skills)) {
|
|
182
|
+
if (data?.type === SKILL_TYPES.KIT) continue
|
|
183
|
+
const short_name = name.split('/')[1] || name
|
|
184
|
+
const source_dir = skill_install_dir(base_dir, short_name)
|
|
185
|
+
const [, enabled] = await is_skill_enabled(short_name, detected_agents, is_global, project_root)
|
|
186
|
+
if (enabled) {
|
|
187
|
+
skills_to_check.push({ skill_name: short_name, source_dir })
|
|
188
|
+
}
|
|
92
189
|
}
|
|
190
|
+
if (skills_to_check.length > 0) {
|
|
191
|
+
const link_spinner = !args.flags.json ? create_spinner('Verifying symlinks…') : null
|
|
192
|
+
const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global: is_global, project_root })
|
|
193
|
+
symlink_repairs = repair_result?.repaired || []
|
|
194
|
+
if (symlink_repairs.length > 0) {
|
|
195
|
+
link_spinner?.succeed(`Repaired ${symlink_repairs.length} symlink(s)`)
|
|
196
|
+
} else {
|
|
197
|
+
link_spinner?.succeed('All symlinks verified')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
93
201
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
202
|
+
// If nothing to update, report and exit
|
|
203
|
+
if (outdated.length === 0) {
|
|
204
|
+
if (args.flags.json) {
|
|
205
|
+
print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } })
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
209
|
+
r.skill, r.installed, r.latest, green(r.status)
|
|
210
|
+
]))
|
|
211
|
+
console.log()
|
|
212
|
+
if (symlink_repairs.length > 0) {
|
|
213
|
+
print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
|
|
99
214
|
} else {
|
|
100
|
-
|
|
215
|
+
print_success('All skills are up to date.')
|
|
101
216
|
}
|
|
217
|
+
return
|
|
102
218
|
}
|
|
103
219
|
|
|
220
|
+
// Show results table (non-json)
|
|
221
|
+
if (!args.flags.json) {
|
|
222
|
+
const status_colors = { 'up-to-date': green, 'outdated': yellow, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
|
|
223
|
+
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
224
|
+
r.skill, r.installed, r.latest, (status_colors[r.status] || ((s) => s))(r.status)
|
|
225
|
+
]))
|
|
226
|
+
console.log()
|
|
227
|
+
print_info(`${outdated.length} skill(s) can be updated.`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Confirm
|
|
231
|
+
const should_update = auto_yes || !process.stdin.isTTY
|
|
232
|
+
if (!should_update && !args.flags.json) {
|
|
233
|
+
const answer = await confirm_prompt(`\nUpdate ${outdated.length} skill(s)? [y/N] `)
|
|
234
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
235
|
+
print_info('Skipped.')
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Detect local modifications on the outdated set
|
|
241
|
+
const detections = await Promise.all(outdated.map(r => {
|
|
242
|
+
const lock_entry = skills[r.skill]
|
|
243
|
+
const short_name = r.skill.split('/')[1] || r.skill
|
|
244
|
+
const dir = skill_install_dir(base_dir, short_name)
|
|
245
|
+
return detect_status(lock_entry, dir, { skill_name: r.skill, project_root, is_global }).then(([, det]) => ({ r, det }))
|
|
246
|
+
}))
|
|
247
|
+
|
|
248
|
+
const skipped = []
|
|
249
|
+
const safe_to_update = []
|
|
250
|
+
for (const { r, det } of detections) {
|
|
251
|
+
if (det?.local_modified) {
|
|
252
|
+
skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
|
|
253
|
+
} else {
|
|
254
|
+
safe_to_update.push(r)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Install only the outdated, non-modified skills
|
|
259
|
+
const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
|
|
260
|
+
const updated = []
|
|
261
|
+
const update_errors = []
|
|
262
|
+
|
|
263
|
+
for (const r of safe_to_update) {
|
|
264
|
+
const update_spinner = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
|
|
265
|
+
const [errors, result] = await install(r.skill, options)
|
|
266
|
+
if (errors) {
|
|
267
|
+
update_spinner?.fail(`Failed to update ${r.skill}`)
|
|
268
|
+
update_errors.push({ skill: r.skill, message: errors[errors.length - 1]?.message || 'Unknown error' })
|
|
269
|
+
} else if (!result.no_op) {
|
|
270
|
+
update_spinner?.succeed(`Updated ${r.skill} ${r.installed} → ${result.version}`)
|
|
271
|
+
updated.push({ skill: r.skill, from: r.installed, to: result.version })
|
|
272
|
+
} else {
|
|
273
|
+
update_spinner?.succeed(`${r.skill} already up to date`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Output
|
|
104
278
|
if (args.flags.json) {
|
|
105
|
-
print_json({
|
|
279
|
+
print_json({
|
|
280
|
+
data: {
|
|
281
|
+
results,
|
|
282
|
+
outdated_count: outdated.length,
|
|
283
|
+
up_to_date_count: up_to_date.length,
|
|
284
|
+
updated,
|
|
285
|
+
skipped,
|
|
286
|
+
already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
|
|
287
|
+
errors: update_errors
|
|
288
|
+
}
|
|
289
|
+
})
|
|
106
290
|
return
|
|
107
291
|
}
|
|
108
292
|
|
|
293
|
+
if (skipped.length > 0) {
|
|
294
|
+
console.log()
|
|
295
|
+
print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
|
|
296
|
+
for (const s of skipped) {
|
|
297
|
+
print_info(` ${s.skill}`)
|
|
298
|
+
}
|
|
299
|
+
print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
|
|
300
|
+
}
|
|
109
301
|
if (updated.length > 0) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
302
|
+
console.log()
|
|
303
|
+
print_success(`Updated ${updated.length} skill(s).`)
|
|
304
|
+
}
|
|
305
|
+
if (update_errors.length > 0) {
|
|
306
|
+
console.log()
|
|
307
|
+
print_info(`${update_errors.length} skill(s) failed to update. Run ${code('happyskills check')} for details.`)
|
|
113
308
|
}
|
|
114
309
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
115
310
|
|
package/src/constants.js
CHANGED
|
@@ -30,7 +30,6 @@ const COMMAND_ALIASES = {
|
|
|
30
30
|
remove: 'uninstall',
|
|
31
31
|
ls: 'list',
|
|
32
32
|
s: 'search',
|
|
33
|
-
r: 'refresh',
|
|
34
33
|
st: 'status',
|
|
35
34
|
d: 'diff',
|
|
36
35
|
up: 'update',
|
|
@@ -52,7 +51,6 @@ const COMMANDS = [
|
|
|
52
51
|
'list',
|
|
53
52
|
'search',
|
|
54
53
|
'check',
|
|
55
|
-
'refresh',
|
|
56
54
|
'status',
|
|
57
55
|
'pull',
|
|
58
56
|
'diff',
|
package/src/index.js
CHANGED
|
@@ -92,11 +92,10 @@ Commands:
|
|
|
92
92
|
list List installed skills (alias: ls)
|
|
93
93
|
search <query> Search the registry (alias: s)
|
|
94
94
|
check [owner/skill] Check for available updates
|
|
95
|
-
refresh Check + update all outdated skills (alias: r)
|
|
96
95
|
status [owner/skill] Show divergence status (alias: st)
|
|
97
96
|
pull <owner/skill> Pull remote changes and merge
|
|
98
97
|
diff <owner/skill> Show file-level differences (alias: d)
|
|
99
|
-
update [owner/skill]
|
|
98
|
+
update [owner/skill] Update outdated skills (alias: up). Smart by default; --force to re-install regardless
|
|
100
99
|
publish Push skill to registry (alias: pub)
|
|
101
100
|
validate <skill-name> Validate skill against all rules (alias: v)
|
|
102
101
|
fork <owner/skill> Fork a skill to your workspace
|
|
@@ -92,7 +92,7 @@ describe('CLI — global flags', () => {
|
|
|
92
92
|
|
|
93
93
|
it('--help lists all expected commands', () => {
|
|
94
94
|
const { stdout } = run(['--help'])
|
|
95
|
-
const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', '
|
|
95
|
+
const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'update', 'publish', 'validate', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
|
|
96
96
|
for (const cmd of expected_commands) {
|
|
97
97
|
assert.ok(stdout.includes(cmd), `help output should mention "${cmd}"`)
|
|
98
98
|
}
|
|
@@ -160,10 +160,8 @@ describe('CLI — command --help', () => {
|
|
|
160
160
|
['search', 'Arguments:'],
|
|
161
161
|
['search', 'Aliases:'],
|
|
162
162
|
['check', 'Examples:'],
|
|
163
|
-
['refresh', 'Options:'],
|
|
164
|
-
['refresh', 'Examples:'],
|
|
165
|
-
['refresh', 'Aliases:'],
|
|
166
163
|
['update', 'Aliases:'],
|
|
164
|
+
['update', '--force'],
|
|
167
165
|
['publish', 'Aliases:'],
|
|
168
166
|
['validate', 'Arguments:'],
|
|
169
167
|
['validate', 'Aliases:'],
|
|
@@ -199,7 +197,6 @@ describe('CLI — command aliases', () => {
|
|
|
199
197
|
['remove', 'uninstall'],
|
|
200
198
|
['ls', 'list'],
|
|
201
199
|
['s', 'search'],
|
|
202
|
-
['r', 'refresh'],
|
|
203
200
|
['up', 'update'],
|
|
204
201
|
['pub', 'publish'],
|
|
205
202
|
['v', 'validate'],
|
|
@@ -423,15 +420,15 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
|
|
|
423
420
|
})
|
|
424
421
|
})
|
|
425
422
|
|
|
426
|
-
// ───
|
|
423
|
+
// ─── update --all (smart batch-check) ─────────────────────────────────────────
|
|
427
424
|
|
|
428
|
-
describe('CLI — --json:
|
|
429
|
-
it('
|
|
425
|
+
describe('CLI — --json: update --all command', () => {
|
|
426
|
+
it('update --all --json with no installed skills returns { data: { results, outdated_count, ... } }', () => {
|
|
430
427
|
const tmp = make_tmp()
|
|
431
428
|
try {
|
|
432
|
-
const { stdout, code } = run(['
|
|
429
|
+
const { stdout, code } = run(['update', '--all', '--json'], {}, { cwd: tmp })
|
|
433
430
|
assert.strictEqual(code, 0)
|
|
434
|
-
const out = parse_json_output(stdout, '
|
|
431
|
+
const out = parse_json_output(stdout, 'update --all --json empty')
|
|
435
432
|
assert.ok('data' in out)
|
|
436
433
|
assert.ok(Array.isArray(out.data.results))
|
|
437
434
|
assert.strictEqual(out.data.outdated_count, 0)
|
package/src/merge/detector.js
CHANGED
|
@@ -1,31 +1,91 @@
|
|
|
1
1
|
const { error: { catch_errors } } = require('puffy-core')
|
|
2
2
|
const { hash_directory } = require('../lock/integrity')
|
|
3
|
+
const { find_modified_files } = require('./file_diff')
|
|
4
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
5
|
+
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
6
|
+
const { lock_root } = require('../config/paths')
|
|
7
|
+
|
|
8
|
+
// Serialize lock heals across concurrent detect_status calls so parallel
|
|
9
|
+
// read-modify-write sequences don't clobber each other's updates.
|
|
10
|
+
let heal_chain = Promise.resolve()
|
|
11
|
+
|
|
12
|
+
const queue_heal = (skill_name, current_integrity, project_root, is_global) => {
|
|
13
|
+
const next = heal_chain.then(async () => {
|
|
14
|
+
const root = lock_root(is_global, project_root)
|
|
15
|
+
const [, lock_data] = await read_lock(root)
|
|
16
|
+
if (!lock_data) return
|
|
17
|
+
const skills = get_all_locked_skills(lock_data)
|
|
18
|
+
const entry = skills[skill_name]
|
|
19
|
+
if (!entry) return
|
|
20
|
+
if (entry.base_integrity === current_integrity && entry.integrity === current_integrity) return
|
|
21
|
+
const updated = { ...entry, base_integrity: current_integrity, integrity: current_integrity }
|
|
22
|
+
const merged = update_lock_skills(lock_data, { [skill_name]: updated })
|
|
23
|
+
await write_lock(root, merged)
|
|
24
|
+
if (process.env.HAPPYSKILLS_DEBUG) {
|
|
25
|
+
process.stderr.write(`integrity drift auto-healed for ${skill_name}\n`)
|
|
26
|
+
}
|
|
27
|
+
}).catch(() => {})
|
|
28
|
+
heal_chain = next
|
|
29
|
+
return next
|
|
30
|
+
}
|
|
3
31
|
|
|
4
32
|
/**
|
|
5
33
|
* Detects whether a skill has been locally modified since install/pull.
|
|
6
34
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
35
|
+
* Two-stage check:
|
|
36
|
+
* 1. Fast path — compare hash_directory(disk) against lock.base_integrity.
|
|
37
|
+
* If they match, the skill is unmodified.
|
|
38
|
+
* 2. Fallback — when the aggregate hash disagrees, fetch the registry
|
|
39
|
+
* manifest at base_commit and compare per-file (git blob SHAs). The
|
|
40
|
+
* aggregate hash is only an optimization; the per-file comparison is
|
|
41
|
+
* the source of truth.
|
|
42
|
+
*
|
|
43
|
+
* - Zero files differ → the lock's integrity has drifted (e.g. server
|
|
44
|
+
* normalized which files are returned). Auto-heal the lock entry's
|
|
45
|
+
* integrity / base_integrity to the current value and report clean.
|
|
46
|
+
* - One or more files differ → real local modifications. Return them
|
|
47
|
+
* in `modified_files` for better diagnostics.
|
|
9
48
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
49
|
+
* The fallback requires `options.skill_name` and a base_commit on the lock;
|
|
50
|
+
* without them, the function falls back to the legacy pessimistic answer
|
|
51
|
+
* (local_modified: true on aggregate mismatch).
|
|
52
|
+
*
|
|
53
|
+
* @param {object} lock_entry
|
|
54
|
+
* @param {string} skill_dir
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {string} [options.skill_name] - "owner/repo" (enables fallback)
|
|
57
|
+
* @param {string} [options.project_root] - Project root for lock heal
|
|
58
|
+
* @param {boolean} [options.is_global] - Whether to heal the global lock
|
|
13
59
|
*/
|
|
14
|
-
const detect_status = (lock_entry, skill_dir) => catch_errors('Failed to detect status', async () => {
|
|
60
|
+
const detect_status = (lock_entry, skill_dir, options = {}) => catch_errors('Failed to detect status', async () => {
|
|
15
61
|
const base_commit = lock_entry?.base_commit || null
|
|
16
62
|
const base_integrity = lock_entry?.base_integrity || null
|
|
17
63
|
|
|
18
|
-
if (!base_integrity) return { local_modified: false, current_integrity: null, base_integrity: null, base_commit }
|
|
64
|
+
if (!base_integrity) return { local_modified: false, current_integrity: null, base_integrity: null, base_commit, modified_files: null }
|
|
19
65
|
|
|
20
66
|
const [hash_err, current_integrity] = await hash_directory(skill_dir)
|
|
21
|
-
if (hash_err) return { local_modified: false, current_integrity: null, base_integrity, base_commit }
|
|
67
|
+
if (hash_err) return { local_modified: false, current_integrity: null, base_integrity, base_commit, modified_files: null }
|
|
68
|
+
|
|
69
|
+
if (current_integrity === base_integrity) {
|
|
70
|
+
return { local_modified: false, current_integrity, base_integrity, base_commit, modified_files: [] }
|
|
71
|
+
}
|
|
22
72
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
current_integrity,
|
|
26
|
-
base_integrity,
|
|
27
|
-
base_commit
|
|
73
|
+
const { skill_name = null, project_root = null, is_global = false } = options
|
|
74
|
+
if (!skill_name || !base_commit) {
|
|
75
|
+
return { local_modified: true, current_integrity, base_integrity, base_commit, modified_files: null }
|
|
28
76
|
}
|
|
77
|
+
|
|
78
|
+
const [, modified_files] = await find_modified_files(skill_name, base_commit, skill_dir)
|
|
79
|
+
if (modified_files === null) {
|
|
80
|
+
return { local_modified: true, current_integrity, base_integrity, base_commit, modified_files: null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (modified_files.length === 0) {
|
|
84
|
+
if (project_root) await queue_heal(skill_name, current_integrity, project_root, is_global)
|
|
85
|
+
return { local_modified: false, current_integrity, base_integrity: current_integrity, base_commit, modified_files: [], healed: true }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { local_modified: true, current_integrity, base_integrity, base_commit, modified_files }
|
|
29
89
|
})
|
|
30
90
|
|
|
31
91
|
module.exports = { detect_status }
|
|
@@ -1,22 +1,40 @@
|
|
|
1
|
-
const { describe, it, afterEach } = require('node:test')
|
|
1
|
+
const { describe, it, afterEach, before, after } = require('node:test')
|
|
2
2
|
const assert = require('node:assert/strict')
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const os = require('os')
|
|
6
|
-
const { detect_status } = require('./detector')
|
|
7
6
|
const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
|
|
7
|
+
const { hash_blob } = require('../utils/git_hash')
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// Stub repos_api before file_diff/detector load it. find_modified_files
|
|
10
|
+
// uses repos_api.clone, so we control its return value via the stub state.
|
|
11
|
+
const repos_api_path = require.resolve('../api/repos')
|
|
12
|
+
const stub_state = { files: [], err: null }
|
|
13
|
+
require.cache[repos_api_path] = {
|
|
14
|
+
id: repos_api_path,
|
|
15
|
+
filename: repos_api_path,
|
|
16
|
+
loaded: true,
|
|
17
|
+
exports: {
|
|
18
|
+
clone: async () => stub_state.err
|
|
19
|
+
? [[stub_state.err], null]
|
|
20
|
+
: [null, { files: stub_state.files }]
|
|
21
|
+
}
|
|
12
22
|
}
|
|
13
23
|
|
|
24
|
+
const { detect_status } = require('./detector')
|
|
25
|
+
|
|
26
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'detector-test-'))
|
|
14
27
|
const rm = (dir) => { try { fs.rmSync(dir, { recursive: true }) } catch (_) {} }
|
|
15
28
|
|
|
16
29
|
describe('detect_status', () => {
|
|
17
30
|
let tmp_dir
|
|
18
31
|
|
|
19
|
-
afterEach(() => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (tmp_dir) { rm(tmp_dir); tmp_dir = null }
|
|
34
|
+
stub_state.files = []
|
|
35
|
+
stub_state.err = null
|
|
36
|
+
clear_integrity_cache()
|
|
37
|
+
})
|
|
20
38
|
|
|
21
39
|
it('returns local_modified: false when integrity matches', async () => {
|
|
22
40
|
tmp_dir = make_tmp()
|
|
@@ -33,13 +51,12 @@ describe('detect_status', () => {
|
|
|
33
51
|
assert.equal(result.base_commit, 'abc123')
|
|
34
52
|
})
|
|
35
53
|
|
|
36
|
-
it('returns local_modified: true when integrity differs', async () => {
|
|
54
|
+
it('returns local_modified: true when integrity differs and no fallback context is given', async () => {
|
|
37
55
|
tmp_dir = make_tmp()
|
|
38
56
|
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'hello')
|
|
39
57
|
|
|
40
58
|
const [, integrity] = await hash_directory(tmp_dir)
|
|
41
59
|
|
|
42
|
-
// Modify the file and clear hash cache (cache is per-command-invocation optimization)
|
|
43
60
|
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'modified')
|
|
44
61
|
clear_integrity_cache()
|
|
45
62
|
|
|
@@ -48,6 +65,7 @@ describe('detect_status', () => {
|
|
|
48
65
|
assert.equal(err, null)
|
|
49
66
|
assert.equal(result.local_modified, true)
|
|
50
67
|
assert.notEqual(result.current_integrity, integrity)
|
|
68
|
+
assert.equal(result.modified_files, null)
|
|
51
69
|
})
|
|
52
70
|
|
|
53
71
|
it('returns local_modified: false when base_integrity is missing', async () => {
|
|
@@ -75,4 +93,100 @@ describe('detect_status', () => {
|
|
|
75
93
|
assert.equal(err, null)
|
|
76
94
|
assert.equal(result.local_modified, false)
|
|
77
95
|
})
|
|
96
|
+
|
|
97
|
+
it('auto-heals the lock when aggregate integrity drifts but per-file content matches the registry', async () => {
|
|
98
|
+
tmp_dir = make_tmp()
|
|
99
|
+
const project_root = make_tmp()
|
|
100
|
+
|
|
101
|
+
const skill_content = 'hello'
|
|
102
|
+
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), skill_content)
|
|
103
|
+
|
|
104
|
+
// Stub registry to return the same content the disk has (same git blob sha).
|
|
105
|
+
stub_state.files = [{ path: 'SKILL.md', sha: hash_blob(Buffer.from(skill_content)) }]
|
|
106
|
+
|
|
107
|
+
// Lock has a stale base_integrity that does NOT match the current hash_directory.
|
|
108
|
+
const stale_integrity = 'sha256-stale0000000000000000000000000000000000000000000000000000000000'
|
|
109
|
+
const lock_path = path.join(project_root, 'skills-lock.json')
|
|
110
|
+
fs.writeFileSync(lock_path, JSON.stringify({
|
|
111
|
+
lockVersion: 2,
|
|
112
|
+
generatedAt: new Date().toISOString(),
|
|
113
|
+
skills: {
|
|
114
|
+
'acme/test-skill': {
|
|
115
|
+
version: '1.0.0',
|
|
116
|
+
base_commit: 'abc123',
|
|
117
|
+
base_integrity: stale_integrity,
|
|
118
|
+
integrity: stale_integrity
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}))
|
|
122
|
+
|
|
123
|
+
const lock_entry = { base_integrity: stale_integrity, base_commit: 'abc123' }
|
|
124
|
+
const [err, result] = await detect_status(lock_entry, tmp_dir, {
|
|
125
|
+
skill_name: 'acme/test-skill',
|
|
126
|
+
project_root,
|
|
127
|
+
is_global: false
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
assert.equal(err, null)
|
|
131
|
+
assert.equal(result.local_modified, false)
|
|
132
|
+
assert.equal(result.healed, true)
|
|
133
|
+
assert.deepEqual(result.modified_files, [])
|
|
134
|
+
|
|
135
|
+
// Wait for the heal write to complete (queued via promise chain).
|
|
136
|
+
await new Promise(r => setTimeout(r, 50))
|
|
137
|
+
const lock_after = JSON.parse(fs.readFileSync(lock_path, 'utf-8'))
|
|
138
|
+
assert.equal(lock_after.skills['acme/test-skill'].base_integrity, result.current_integrity)
|
|
139
|
+
assert.equal(lock_after.skills['acme/test-skill'].integrity, result.current_integrity)
|
|
140
|
+
|
|
141
|
+
rm(project_root)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reports real local modifications with a list of differing files when fallback runs', async () => {
|
|
145
|
+
tmp_dir = make_tmp()
|
|
146
|
+
const project_root = make_tmp()
|
|
147
|
+
|
|
148
|
+
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'edited locally')
|
|
149
|
+
fs.writeFileSync(path.join(tmp_dir, 'skill.json'), '{}')
|
|
150
|
+
|
|
151
|
+
// Registry says SKILL.md should have different content; skill.json matches.
|
|
152
|
+
stub_state.files = [
|
|
153
|
+
{ path: 'SKILL.md', sha: hash_blob(Buffer.from('original')) },
|
|
154
|
+
{ path: 'skill.json', sha: hash_blob(Buffer.from('{}')) }
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
fs.writeFileSync(path.join(project_root, 'skills-lock.json'), JSON.stringify({
|
|
158
|
+
lockVersion: 2,
|
|
159
|
+
generatedAt: new Date().toISOString(),
|
|
160
|
+
skills: {}
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
const lock_entry = { base_integrity: 'sha256-stale', base_commit: 'abc123' }
|
|
164
|
+
const [err, result] = await detect_status(lock_entry, tmp_dir, {
|
|
165
|
+
skill_name: 'acme/test-skill',
|
|
166
|
+
project_root,
|
|
167
|
+
is_global: false
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
assert.equal(err, null)
|
|
171
|
+
assert.equal(result.local_modified, true)
|
|
172
|
+
assert.deepEqual(result.modified_files, ['SKILL.md'])
|
|
173
|
+
|
|
174
|
+
rm(project_root)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('falls back to pessimistic local_modified: true when registry fetch fails', async () => {
|
|
178
|
+
tmp_dir = make_tmp()
|
|
179
|
+
fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'hello')
|
|
180
|
+
|
|
181
|
+
stub_state.err = new Error('network down')
|
|
182
|
+
|
|
183
|
+
const lock_entry = { base_integrity: 'sha256-stale', base_commit: 'abc123' }
|
|
184
|
+
const [err, result] = await detect_status(lock_entry, tmp_dir, {
|
|
185
|
+
skill_name: 'acme/test-skill'
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
assert.equal(err, null)
|
|
189
|
+
assert.equal(result.local_modified, true)
|
|
190
|
+
assert.equal(result.modified_files, null)
|
|
191
|
+
})
|
|
78
192
|
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
const repos_api = require('../api/repos')
|
|
5
|
+
const { hash_blob } = require('../utils/git_hash')
|
|
6
|
+
|
|
7
|
+
const build_local_file_shas = (skill_dir) => catch_errors('Failed to build local file shas', async () => {
|
|
8
|
+
const entries = []
|
|
9
|
+
const walk = async (dir, prefix) => {
|
|
10
|
+
let items
|
|
11
|
+
try {
|
|
12
|
+
items = await fs.promises.readdir(dir, { withFileTypes: true })
|
|
13
|
+
} catch {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
if (item.name.startsWith('.')) continue
|
|
18
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name
|
|
19
|
+
const full = path.join(dir, item.name)
|
|
20
|
+
if (item.isDirectory()) {
|
|
21
|
+
await walk(full, rel)
|
|
22
|
+
} else if (item.isFile()) {
|
|
23
|
+
const content = await fs.promises.readFile(full)
|
|
24
|
+
entries.push({ path: rel, sha: hash_blob(content) })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await walk(skill_dir, '')
|
|
29
|
+
return entries
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compares disk content against the registry baseline at base_commit and
|
|
34
|
+
* returns the list of paths whose content actually differs. An empty array
|
|
35
|
+
* means the disk is byte-identical to the registry (per Git blob hashing).
|
|
36
|
+
*
|
|
37
|
+
* Returns null when the comparison cannot be performed (network failure,
|
|
38
|
+
* missing inputs) so callers can fall back to a pessimistic answer.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} skill_name - "owner/repo"
|
|
41
|
+
* @param {string} base_commit
|
|
42
|
+
* @param {string} skill_dir - Absolute path to the on-disk skill
|
|
43
|
+
* @returns {[errors, string[]|null]}
|
|
44
|
+
*/
|
|
45
|
+
const find_modified_files = (skill_name, base_commit, skill_dir) => catch_errors('Failed to compare disk to registry', async () => {
|
|
46
|
+
if (!skill_name || !skill_name.includes('/') || !base_commit) return null
|
|
47
|
+
|
|
48
|
+
const [owner, repo] = skill_name.split('/')
|
|
49
|
+
const [clone_err, clone_data] = await repos_api.clone(owner, repo, null, { commit: base_commit })
|
|
50
|
+
if (clone_err) return null
|
|
51
|
+
|
|
52
|
+
const base_files = clone_data.files || []
|
|
53
|
+
const base_map = new Map(base_files.map(f => [f.path, f.sha]))
|
|
54
|
+
|
|
55
|
+
const [local_err, local_entries] = await build_local_file_shas(skill_dir)
|
|
56
|
+
if (local_err) return null
|
|
57
|
+
const local_map = new Map(local_entries.map(e => [e.path, e.sha]))
|
|
58
|
+
|
|
59
|
+
const all_paths = new Set([...base_map.keys(), ...local_map.keys()])
|
|
60
|
+
const modified = []
|
|
61
|
+
for (const p of all_paths) {
|
|
62
|
+
if (base_map.get(p) !== local_map.get(p)) modified.push(p)
|
|
63
|
+
}
|
|
64
|
+
modified.sort()
|
|
65
|
+
return modified
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
module.exports = { find_modified_files, build_local_file_shas }
|
package/src/commands/refresh.js
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
-
const { install } = require('../engine/installer')
|
|
3
|
-
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
4
|
-
const { read_manifest } = require('../manifest/reader')
|
|
5
|
-
const { detect_status } = require('../merge/detector')
|
|
6
|
-
const repos_api = require('../api/repos')
|
|
7
|
-
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
8
|
-
const { green, yellow, red } = require('../ui/colors')
|
|
9
|
-
const { create_spinner } = require('../ui/spinner')
|
|
10
|
-
const { exit_with_error } = require('../utils/errors')
|
|
11
|
-
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
12
|
-
const { EXIT_CODES, SKILL_TYPES } = require('../constants')
|
|
13
|
-
const { resolve_agents, verify_and_repair_symlinks } = require('../agents')
|
|
14
|
-
const { is_skill_enabled } = require('../agents/status')
|
|
15
|
-
|
|
16
|
-
const HELP_TEXT = `Usage: happyskills refresh [options]
|
|
17
|
-
|
|
18
|
-
Check all installed skills for updates and upgrade outdated ones.
|
|
19
|
-
|
|
20
|
-
Options:
|
|
21
|
-
-g, --global Refresh globally installed skills
|
|
22
|
-
-y, --yes Skip confirmation prompts
|
|
23
|
-
--json Output as JSON
|
|
24
|
-
|
|
25
|
-
Aliases: r
|
|
26
|
-
|
|
27
|
-
Examples:
|
|
28
|
-
happyskills refresh
|
|
29
|
-
happyskills refresh -y
|
|
30
|
-
happyskills refresh -g -y --json`
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Returns true if the lock entry's dependencies differ from the installed skill.json's dependencies.
|
|
34
|
-
*/
|
|
35
|
-
const has_dependency_drift = (lock_entry, manifest) => {
|
|
36
|
-
if (!manifest) return false
|
|
37
|
-
const lock_deps = JSON.stringify(lock_entry.dependencies || {})
|
|
38
|
-
const manifest_deps = JSON.stringify(manifest.dependencies || {})
|
|
39
|
-
return lock_deps !== manifest_deps
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const confirm_prompt = (question) => new Promise((resolve) => {
|
|
43
|
-
const readline = require('readline')
|
|
44
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
45
|
-
rl.question(question, (answer) => {
|
|
46
|
-
rl.close()
|
|
47
|
-
resolve(answer.trim().toLowerCase())
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
const run = (args) => catch_errors('Refresh failed', async () => {
|
|
52
|
-
if (args.flags._show_help) {
|
|
53
|
-
print_help(HELP_TEXT)
|
|
54
|
-
return process.exit(EXIT_CODES.SUCCESS)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const is_global = args.flags.global || false
|
|
58
|
-
const auto_yes = args.flags.yes || false
|
|
59
|
-
const project_root = find_project_root()
|
|
60
|
-
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
61
|
-
const skills = get_all_locked_skills(lock_data)
|
|
62
|
-
const entries = Object.entries(skills)
|
|
63
|
-
|
|
64
|
-
const to_check = entries.filter(([, data]) => data.requested_by?.includes('__root__'))
|
|
65
|
-
|
|
66
|
-
if (to_check.length === 0) {
|
|
67
|
-
if (args.flags.json) {
|
|
68
|
-
print_json({ data: { results: [], outdated_count: 0, up_to_date_count: 0, updated: [], already_up_to_date: [], errors: [] } })
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
print_info('No skills installed.')
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// 1. Check for updates
|
|
76
|
-
const spinner = !args.flags.json ? create_spinner('Checking for updates…') : null
|
|
77
|
-
const skill_names = to_check.map(([name]) => name)
|
|
78
|
-
const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
|
|
79
|
-
const base_dir = skills_dir(is_global, project_root)
|
|
80
|
-
|
|
81
|
-
const results = []
|
|
82
|
-
if (batch_err) {
|
|
83
|
-
spinner?.fail('Failed to check for updates')
|
|
84
|
-
for (const [name, data] of to_check) {
|
|
85
|
-
results.push({ skill: name, installed: data.version, latest: 'error', status: 'error' })
|
|
86
|
-
}
|
|
87
|
-
} else {
|
|
88
|
-
spinner?.succeed('Checked for updates')
|
|
89
|
-
// Read all installed manifests in parallel to check for dependency drift
|
|
90
|
-
const manifest_map = {}
|
|
91
|
-
await Promise.all(to_check.map(async ([name]) => {
|
|
92
|
-
const short_name = name.split('/')[1] || name
|
|
93
|
-
const dir = skill_install_dir(base_dir, short_name)
|
|
94
|
-
const [, manifest] = await read_manifest(dir)
|
|
95
|
-
if (manifest) manifest_map[name] = manifest
|
|
96
|
-
}))
|
|
97
|
-
for (const [name, data] of to_check) {
|
|
98
|
-
const info = batch_data?.results?.[name]
|
|
99
|
-
if (info?.access_denied) {
|
|
100
|
-
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
|
|
101
|
-
} else if (!info || !info.latest_version) {
|
|
102
|
-
results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
|
|
103
|
-
} else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
|
|
104
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
105
|
-
} else if (!data.base_commit && info.latest_version !== data.version) {
|
|
106
|
-
// Fallback to version comparison for old lock files without base_commit
|
|
107
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
108
|
-
} else if (has_dependency_drift(data, manifest_map[name])) {
|
|
109
|
-
// Lock dependencies are stale compared to installed skill.json
|
|
110
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
111
|
-
} else {
|
|
112
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const outdated = results.filter(r => r.status === 'outdated')
|
|
118
|
-
const up_to_date = results.filter(r => r.status === 'up-to-date')
|
|
119
|
-
|
|
120
|
-
// 2. Verify and repair symlinks for all skills (even when nothing is outdated)
|
|
121
|
-
const [, agents_data] = await resolve_agents(args.flags.agents)
|
|
122
|
-
const detected_agents = agents_data?.agents || []
|
|
123
|
-
let symlink_repairs = []
|
|
124
|
-
if (detected_agents.length > 0) {
|
|
125
|
-
const skills_to_check = []
|
|
126
|
-
for (const [name, data] of entries) {
|
|
127
|
-
if (data.type === SKILL_TYPES.KIT) continue
|
|
128
|
-
const short_name = name.split('/')[1] || name
|
|
129
|
-
const source_dir = skill_install_dir(base_dir, short_name)
|
|
130
|
-
const [, enabled] = await is_skill_enabled(short_name, detected_agents, is_global, project_root)
|
|
131
|
-
if (enabled) {
|
|
132
|
-
skills_to_check.push({ skill_name: short_name, source_dir })
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
if (skills_to_check.length > 0) {
|
|
136
|
-
const link_spinner = !args.flags.json ? create_spinner('Verifying symlinks…') : null
|
|
137
|
-
const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global: is_global, project_root })
|
|
138
|
-
symlink_repairs = repair_result?.repaired || []
|
|
139
|
-
if (symlink_repairs.length > 0) {
|
|
140
|
-
link_spinner?.succeed(`Repaired ${symlink_repairs.length} symlink(s)`)
|
|
141
|
-
} else {
|
|
142
|
-
link_spinner?.succeed('All symlinks verified')
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// 3. If nothing to update, report and exit
|
|
148
|
-
if (outdated.length === 0) {
|
|
149
|
-
if (args.flags.json) {
|
|
150
|
-
print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } })
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
154
|
-
r.skill, r.installed, r.latest, green(r.status)
|
|
155
|
-
]))
|
|
156
|
-
console.log()
|
|
157
|
-
if (symlink_repairs.length > 0) {
|
|
158
|
-
print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
|
|
159
|
-
} else {
|
|
160
|
-
print_success('All skills are up to date.')
|
|
161
|
-
}
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 4. Show results table (non-json)
|
|
166
|
-
if (!args.flags.json) {
|
|
167
|
-
const status_colors = { 'up-to-date': green, 'outdated': yellow, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
|
|
168
|
-
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
169
|
-
r.skill, r.installed, r.latest, (status_colors[r.status] || ((s) => s))(r.status)
|
|
170
|
-
]))
|
|
171
|
-
console.log()
|
|
172
|
-
print_info(`${outdated.length} skill(s) can be updated.`)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 5. Confirm update
|
|
176
|
-
const should_update = auto_yes || !process.stdin.isTTY
|
|
177
|
-
if (!should_update && !args.flags.json) {
|
|
178
|
-
const answer = await confirm_prompt(`\nUpdate ${outdated.length} skill(s)? [y/N] `)
|
|
179
|
-
if (answer !== 'y' && answer !== 'yes') {
|
|
180
|
-
print_info('Skipped.')
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// 6. Detect local modifications for all outdated skills in parallel
|
|
186
|
-
const detections = await Promise.all(outdated.map(r => {
|
|
187
|
-
const lock_entry = skills[r.skill]
|
|
188
|
-
const short_name = r.skill.split('/')[1] || r.skill
|
|
189
|
-
const dir = skill_install_dir(base_dir, short_name)
|
|
190
|
-
return detect_status(lock_entry, dir).then(([, det]) => ({ r, det }))
|
|
191
|
-
}))
|
|
192
|
-
|
|
193
|
-
const skipped = []
|
|
194
|
-
const safe_to_update = []
|
|
195
|
-
for (const { r, det } of detections) {
|
|
196
|
-
if (det?.local_modified) {
|
|
197
|
-
skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
|
|
198
|
-
} else {
|
|
199
|
-
safe_to_update.push(r)
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// 7. Update safe skills
|
|
204
|
-
const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
|
|
205
|
-
const updated = []
|
|
206
|
-
const update_errors = []
|
|
207
|
-
|
|
208
|
-
for (const r of safe_to_update) {
|
|
209
|
-
const update_spinner = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
|
|
210
|
-
const [errors, result] = await install(r.skill, options)
|
|
211
|
-
if (errors) {
|
|
212
|
-
update_spinner?.fail(`Failed to update ${r.skill}`)
|
|
213
|
-
update_errors.push({ skill: r.skill, message: errors[errors.length - 1]?.message || 'Unknown error' })
|
|
214
|
-
} else if (!result.no_op) {
|
|
215
|
-
update_spinner?.succeed(`Updated ${r.skill} ${r.installed} → ${result.version}`)
|
|
216
|
-
updated.push({ skill: r.skill, from: r.installed, to: result.version })
|
|
217
|
-
} else {
|
|
218
|
-
update_spinner?.succeed(`${r.skill} already up to date`)
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// 8. Output results
|
|
223
|
-
if (args.flags.json) {
|
|
224
|
-
print_json({
|
|
225
|
-
data: {
|
|
226
|
-
results,
|
|
227
|
-
outdated_count: outdated.length,
|
|
228
|
-
up_to_date_count: up_to_date.length,
|
|
229
|
-
updated,
|
|
230
|
-
skipped,
|
|
231
|
-
already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
|
|
232
|
-
errors: update_errors
|
|
233
|
-
}
|
|
234
|
-
})
|
|
235
|
-
return
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (skipped.length > 0) {
|
|
239
|
-
console.log()
|
|
240
|
-
print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
|
|
241
|
-
for (const s of skipped) {
|
|
242
|
-
print_info(` ${s.skill}`)
|
|
243
|
-
}
|
|
244
|
-
print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
|
|
245
|
-
}
|
|
246
|
-
if (updated.length > 0) {
|
|
247
|
-
console.log()
|
|
248
|
-
print_success(`Updated ${updated.length} skill(s).`)
|
|
249
|
-
}
|
|
250
|
-
if (update_errors.length > 0) {
|
|
251
|
-
console.log()
|
|
252
|
-
print_info(`${update_errors.length} skill(s) failed to update. Run ${code('happyskills check')} for details.`)
|
|
253
|
-
}
|
|
254
|
-
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
255
|
-
|
|
256
|
-
module.exports = { run }
|