happyskills 0.35.5 → 0.37.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 +25 -0
- package/package.json +1 -1
- package/src/api/repos.js +1 -1
- package/src/commands/diff.js +159 -9
- package/src/commands/install.js +5 -2
- package/src/commands/publish.js +39 -13
- package/src/commands/search.js +1 -1
- package/src/commands/validate.js +46 -3
- package/src/engine/installer.js +29 -3
- package/src/engine/resolver.js +2 -2
- package/src/utils/errors.js +25 -27
- package/src/utils/text_diff.js +247 -0
- package/src/validation/dependency_rules.js +309 -0
- package/src/validation/dependency_rules.test.js +231 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.37.0] - 2026-04-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add unified content diffs to `diff` command — fetches base and local/remote file content for changed files, computes line-by-line unified diffs using the Myers O(ND) algorithm, and includes them in both CLI and JSON output
|
|
14
|
+
- Add `--no-content` flag to `diff` command to skip content diffs and show only the file list (previous behavior)
|
|
15
|
+
- Add `text_diff.js` utility module — lightweight Myers diff with unified diff formatting, no external dependencies
|
|
16
|
+
- Add colored CLI diff output (red/green/cyan) matching standard unified diff conventions
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Change `diff` JSON output to include `diff` field on each changed file entry with the unified diff text. For `both_modified` files, `local_diff` and `remote_diff` fields are provided instead
|
|
20
|
+
|
|
21
|
+
## [0.36.0] - 2026-04-17
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Add 6 dependency validation rules to `validate` command: `dep-self-reference` (skill depends on itself), `dep-exists` (all declared deps exist in registry), `dep-visibility-direct` (public skill's direct deps must be public), `dep-visibility-transitive` (full transitive tree visibility check), `dep-visibility-workspace` (warning for workspace-scoped deps on public skills), `dep-circular` (DFS cycle detection on the full dependency tree). Registry checks run when authenticated; local-only checks run otherwise with an info message suggesting login.
|
|
25
|
+
- Add `extract_root_cause()` and `format_error_chain()` utilities in `utils/errors.js` for cleaner error diagnostics across all commands
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Replace ad-hoc dependency existence check in `publish` with the new validation rules — dependency visibility errors now block publish with clear, actionable messages
|
|
29
|
+
- Change `install` resolver to return `{ packages, skipped }` instead of a flat packages array. Installer prints categorized warnings for skipped deps after install: access-denied suggests `happyskills login`, not-found lists missing skill names. `skipped_deps` is included in the result object and JSON output.
|
|
30
|
+
- Show root cause in `install` error messages (e.g. "Access denied: nicolasdao/init-mission") instead of the generic wrapper ("Install failed"). JSON mode error output now includes a `details` array with the full error chain.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- Fix `search --workspace <slug>` triggering AuthError when not logged in — only `--mine` and `--personal` flags require authentication; `--workspace` returns public skills without auth
|
|
34
|
+
|
|
10
35
|
## [0.35.5] - 2026-04-11
|
|
11
36
|
|
|
12
37
|
### Fixed
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -10,7 +10,7 @@ const search = (query, options = {}) => catch_errors('Search failed', async () =
|
|
|
10
10
|
if (options.workspace) params.set('workspace', options.workspace)
|
|
11
11
|
if (options.tags) params.set('tags', options.tags)
|
|
12
12
|
if (options.type) params.set('type', options.type)
|
|
13
|
-
const needs_auth =
|
|
13
|
+
const needs_auth = options.scope && options.scope !== 'public'
|
|
14
14
|
const [errors, data] = await client.get(`/repos/search?${params}`, { auth: needs_auth || false })
|
|
15
15
|
if (errors) throw errors[errors.length - 1]
|
|
16
16
|
return data
|
package/src/commands/diff.js
CHANGED
|
@@ -2,19 +2,20 @@ const fs = require('fs')
|
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
4
4
|
const repos_api = require('../api/repos')
|
|
5
|
-
const { detect_status } = require('../merge/detector')
|
|
6
5
|
const { classify_changes } = require('../merge/comparator')
|
|
7
6
|
const { build_report } = require('../merge/report')
|
|
8
7
|
const { hash_blob } = require('../utils/git_hash')
|
|
8
|
+
const { unified_diff } = require('../utils/text_diff')
|
|
9
9
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
10
10
|
const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
|
|
11
11
|
const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
|
|
12
|
+
const { red, green, cyan, bold } = require('../ui/colors')
|
|
12
13
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
13
14
|
const { EXIT_CODES } = require('../constants')
|
|
14
15
|
|
|
15
16
|
const HELP_TEXT = `Usage: happyskills diff <owner/skill> [options]
|
|
16
17
|
|
|
17
|
-
Show file-level differences for a skill.
|
|
18
|
+
Show file-level differences for a skill with unified diffs.
|
|
18
19
|
|
|
19
20
|
Arguments:
|
|
20
21
|
owner/skill Skill to diff (required)
|
|
@@ -22,6 +23,7 @@ Arguments:
|
|
|
22
23
|
Options:
|
|
23
24
|
--remote Show base vs remote changes (default: local vs base)
|
|
24
25
|
--full Show three-way diff (base vs local vs remote)
|
|
26
|
+
--no-content Show only file list without content diffs
|
|
25
27
|
-g, --global Diff globally installed skill
|
|
26
28
|
--json Output as JSON
|
|
27
29
|
|
|
@@ -30,7 +32,8 @@ Aliases: d
|
|
|
30
32
|
Examples:
|
|
31
33
|
happyskills diff acme/deploy-aws
|
|
32
34
|
happyskills diff acme/deploy-aws --remote
|
|
33
|
-
happyskills diff acme/deploy-aws --full
|
|
35
|
+
happyskills diff acme/deploy-aws --full
|
|
36
|
+
happyskills diff acme/deploy-aws --no-content`
|
|
34
37
|
|
|
35
38
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
36
39
|
|
|
@@ -64,6 +67,98 @@ const STATUS_LABELS = {
|
|
|
64
67
|
local_only_deleted: 'D (local)'
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
const DIFF_CLASSIFICATIONS = new Set([
|
|
71
|
+
'local_only_modified', 'local_only_added', 'local_only_deleted',
|
|
72
|
+
'remote_only_modified', 'remote_only_added', 'remote_only_deleted',
|
|
73
|
+
'both_modified'
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
// ─── Content diff enrichment ─────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Enrich report file entries with unified diffs for changed files.
|
|
80
|
+
*
|
|
81
|
+
* Memory strategy:
|
|
82
|
+
* - Only decode/read content for files whose SHAs actually differ
|
|
83
|
+
* - Local file reads run concurrently via Promise.all
|
|
84
|
+
* - All content maps are cleared after diffs are computed
|
|
85
|
+
*
|
|
86
|
+
* @param {object} report - The merge report with file entries
|
|
87
|
+
* @param {Array} base_clone_files - Raw clone response files (with base64 content)
|
|
88
|
+
* @param {string|null} skill_dir - Local skill directory (null if local content not needed)
|
|
89
|
+
* @param {Array|null} remote_clone_files - Raw remote clone files (null if not needed)
|
|
90
|
+
*/
|
|
91
|
+
const enrich_with_diffs = (report, base_clone_files, skill_dir, remote_clone_files) => catch_errors('Failed to compute content diffs', async () => {
|
|
92
|
+
const changed = report.files.filter(f => DIFF_CLASSIFICATIONS.has(f.classification))
|
|
93
|
+
if (changed.length === 0) return
|
|
94
|
+
|
|
95
|
+
const changed_paths = new Set(changed.map(f => f.path))
|
|
96
|
+
|
|
97
|
+
// Build base content map — only decode changed paths from the clone response
|
|
98
|
+
const base_map = new Map()
|
|
99
|
+
for (const f of (base_clone_files || [])) {
|
|
100
|
+
if (changed_paths.has(f.path) && f.content) {
|
|
101
|
+
base_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build remote content map — same selective decode
|
|
106
|
+
const remote_map = new Map()
|
|
107
|
+
for (const f of (remote_clone_files || [])) {
|
|
108
|
+
if (changed_paths.has(f.path) && f.content) {
|
|
109
|
+
remote_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Read local content concurrently — only files that exist locally
|
|
114
|
+
const local_map = new Map()
|
|
115
|
+
if (skill_dir) {
|
|
116
|
+
const needs_local = changed.filter(f => f.local_sha)
|
|
117
|
+
const results = await Promise.all(needs_local.map(async f => {
|
|
118
|
+
try {
|
|
119
|
+
const content = await fs.promises.readFile(path.join(skill_dir, f.path), 'utf-8')
|
|
120
|
+
return { path: f.path, content }
|
|
121
|
+
} catch {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}))
|
|
125
|
+
for (const r of results) {
|
|
126
|
+
if (r) local_map.set(r.path, r.content)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Compute unified diffs — CPU-only, no I/O
|
|
131
|
+
for (const f of changed) {
|
|
132
|
+
const base = base_map.get(f.path) ?? ''
|
|
133
|
+
const local = local_map.get(f.path) ?? ''
|
|
134
|
+
const remote = remote_map.get(f.path) ?? ''
|
|
135
|
+
|
|
136
|
+
switch (f.classification) {
|
|
137
|
+
case 'local_only_modified':
|
|
138
|
+
case 'local_only_added':
|
|
139
|
+
case 'local_only_deleted':
|
|
140
|
+
f.diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
|
|
141
|
+
break
|
|
142
|
+
case 'remote_only_modified':
|
|
143
|
+
case 'remote_only_added':
|
|
144
|
+
case 'remote_only_deleted':
|
|
145
|
+
f.diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
|
|
146
|
+
break
|
|
147
|
+
case 'both_modified':
|
|
148
|
+
f.local_diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
|
|
149
|
+
f.remote_diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Flush content maps to release memory
|
|
155
|
+
base_map.clear()
|
|
156
|
+
remote_map.clear()
|
|
157
|
+
local_map.clear()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── CLI output ──────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
67
162
|
const print_file_table = (classified) => {
|
|
68
163
|
const lines = []
|
|
69
164
|
for (const [category, label] of Object.entries(STATUS_LABELS)) {
|
|
@@ -84,6 +179,44 @@ const print_file_table = (classified) => {
|
|
|
84
179
|
}
|
|
85
180
|
}
|
|
86
181
|
|
|
182
|
+
const print_colored_diff = (diff_text) => {
|
|
183
|
+
if (!diff_text) return
|
|
184
|
+
for (const line of diff_text.split('\n')) {
|
|
185
|
+
if (line.startsWith('---') || line.startsWith('+++')) {
|
|
186
|
+
console.log(bold(line))
|
|
187
|
+
} else if (line.startsWith('@@')) {
|
|
188
|
+
console.log(cyan(line))
|
|
189
|
+
} else if (line.startsWith('+')) {
|
|
190
|
+
console.log(green(line))
|
|
191
|
+
} else if (line.startsWith('-')) {
|
|
192
|
+
console.log(red(line))
|
|
193
|
+
} else {
|
|
194
|
+
console.log(line)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const print_report_diffs = (report) => {
|
|
200
|
+
const has_diffs = report.files.some(f => f.diff || f.local_diff || f.remote_diff)
|
|
201
|
+
if (!has_diffs) return
|
|
202
|
+
|
|
203
|
+
console.log('')
|
|
204
|
+
for (const f of report.files) {
|
|
205
|
+
if (f.diff) {
|
|
206
|
+
print_colored_diff(f.diff)
|
|
207
|
+
console.log('')
|
|
208
|
+
}
|
|
209
|
+
if (f.local_diff) {
|
|
210
|
+
print_colored_diff(f.local_diff)
|
|
211
|
+
console.log('')
|
|
212
|
+
}
|
|
213
|
+
if (f.remote_diff) {
|
|
214
|
+
print_colored_diff(f.remote_diff)
|
|
215
|
+
console.log('')
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
87
220
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
88
221
|
|
|
89
222
|
const run = (args) => catch_errors('Diff failed', async () => {
|
|
@@ -99,6 +232,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
99
232
|
|
|
100
233
|
const is_global = args.flags.global || false
|
|
101
234
|
const mode = args.flags.full ? 'full' : args.flags.remote ? 'remote' : 'local'
|
|
235
|
+
const skip_content = args.flags['no-content'] || false
|
|
102
236
|
const project_root = find_project_root()
|
|
103
237
|
|
|
104
238
|
// Read lock
|
|
@@ -114,29 +248,33 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
114
248
|
const base_dir = skills_dir(is_global, project_root)
|
|
115
249
|
const skill_dir = skill_install_dir(base_dir, repo)
|
|
116
250
|
|
|
117
|
-
// Always need base files
|
|
251
|
+
// Always need base files — keep full clone response for content diffs
|
|
118
252
|
const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
|
|
119
253
|
if (base_err) throw e('Failed to fetch base files', base_err)
|
|
120
254
|
const base_files = (base_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
|
|
121
255
|
|
|
122
256
|
if (mode === 'local') {
|
|
123
|
-
// Local vs base — use classify_changes with base as the "remote" side
|
|
124
257
|
const [local_err, local_files] = await build_local_entries(skill_dir)
|
|
125
258
|
if (local_err) throw e('Failed to read local files', local_err)
|
|
126
259
|
|
|
127
260
|
const classified = classify_changes(base_files, local_files, base_files)
|
|
261
|
+
const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
|
|
262
|
+
|
|
263
|
+
if (!skip_content) {
|
|
264
|
+
const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, null)
|
|
265
|
+
if (enrich_err) throw e('Content diff failed', enrich_err)
|
|
266
|
+
}
|
|
128
267
|
|
|
129
268
|
if (args.flags.json) {
|
|
130
|
-
const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
|
|
131
269
|
print_json({ data: { mode, report } })
|
|
132
270
|
} else {
|
|
133
271
|
print_file_table(classified)
|
|
272
|
+
print_report_diffs(report)
|
|
134
273
|
}
|
|
135
274
|
return
|
|
136
275
|
}
|
|
137
276
|
|
|
138
277
|
if (mode === 'remote') {
|
|
139
|
-
// Base vs remote — get head commit via compare, clone at that commit
|
|
140
278
|
const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
|
|
141
279
|
if (cmp_err) throw e('Compare failed', cmp_err)
|
|
142
280
|
|
|
@@ -145,17 +283,23 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
145
283
|
const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
|
|
146
284
|
|
|
147
285
|
const classified = classify_changes(base_files, base_files, remote_files)
|
|
286
|
+
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
287
|
+
|
|
288
|
+
if (!skip_content) {
|
|
289
|
+
const [enrich_err] = await enrich_with_diffs(report, base_clone.files, null, remote_clone.files)
|
|
290
|
+
if (enrich_err) throw e('Content diff failed', enrich_err)
|
|
291
|
+
}
|
|
148
292
|
|
|
149
293
|
if (args.flags.json) {
|
|
150
|
-
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
151
294
|
print_json({ data: { mode, report } })
|
|
152
295
|
} else {
|
|
153
296
|
print_file_table(classified)
|
|
297
|
+
print_report_diffs(report)
|
|
154
298
|
}
|
|
155
299
|
return
|
|
156
300
|
}
|
|
157
301
|
|
|
158
|
-
// Full three-way diff
|
|
302
|
+
// Full three-way diff
|
|
159
303
|
const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
|
|
160
304
|
if (cmp_err) throw e('Compare failed', cmp_err)
|
|
161
305
|
|
|
@@ -169,10 +313,16 @@ const run = (args) => catch_errors('Diff failed', async () => {
|
|
|
169
313
|
const classified = classify_changes(base_files, local_files, remote_files)
|
|
170
314
|
const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
|
|
171
315
|
|
|
316
|
+
if (!skip_content) {
|
|
317
|
+
const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, remote_clone.files)
|
|
318
|
+
if (enrich_err) throw e('Content diff failed', enrich_err)
|
|
319
|
+
}
|
|
320
|
+
|
|
172
321
|
if (args.flags.json) {
|
|
173
322
|
print_json({ data: { mode, report } })
|
|
174
323
|
} else {
|
|
175
324
|
print_file_table(classified)
|
|
325
|
+
print_report_diffs(report)
|
|
176
326
|
if (report.summary.conflicted > 0) {
|
|
177
327
|
console.error('')
|
|
178
328
|
print_warn(`${report.summary.conflicted} file(s) modified on both sides`)
|
package/src/commands/install.js
CHANGED
|
@@ -107,7 +107,8 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
107
107
|
const version = flag_version || inline_version
|
|
108
108
|
const [errors, result] = await install(skill, { ...base_options, version })
|
|
109
109
|
if (errors) {
|
|
110
|
-
const
|
|
110
|
+
const chain = errors?.map ? errors.map(x => x?.message).filter(Boolean) : [errors?.message || String(errors)]
|
|
111
|
+
const msg = chain[chain.length - 1] || chain[0] || 'Unknown error'
|
|
111
112
|
print_warn(`Failed to install ${skill}: ${msg}`)
|
|
112
113
|
failures.push({ skill, error: msg })
|
|
113
114
|
} else {
|
|
@@ -116,7 +117,8 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
if (results.length === 0 && failures.length > 0) {
|
|
119
|
-
|
|
120
|
+
const detail = failures.map(f => `${f.skill}: ${f.error}`).join('; ')
|
|
121
|
+
throw new Error(detail)
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
if (args.flags.json) {
|
|
@@ -125,6 +127,7 @@ const run = (args) => catch_errors('Install failed', async () => {
|
|
|
125
127
|
version: result.version,
|
|
126
128
|
installed: result.installed || [],
|
|
127
129
|
skipped: result.skipped || [],
|
|
130
|
+
skipped_deps: result.skipped_deps || [],
|
|
128
131
|
warnings: result.warnings || [],
|
|
129
132
|
forced: result.forced || []
|
|
130
133
|
}))
|
package/src/commands/publish.js
CHANGED
|
@@ -18,6 +18,7 @@ const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
|
18
18
|
const { validate_cross } = require('../validation/cross_rules')
|
|
19
19
|
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
20
20
|
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
21
|
+
const { validate_dependencies } = require('../validation/dependency_rules')
|
|
21
22
|
const { create_spinner } = require('../ui/spinner')
|
|
22
23
|
const { print_help, print_success, print_error, print_warn, print_hint, print_json, code, summarize_warnings } = require('../ui/output')
|
|
23
24
|
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
@@ -144,21 +145,46 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
144
145
|
|
|
145
146
|
if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
|
|
146
147
|
spinner.update('Checking dependencies...')
|
|
147
|
-
const
|
|
148
|
-
const dep_results = await
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
const full_name = `${workspace.slug}/${manifest.name}`
|
|
149
|
+
const [dep_err, dep_results] = await validate_dependencies(dir, manifest, {
|
|
150
|
+
registry: true,
|
|
151
|
+
visibility,
|
|
152
|
+
full_name
|
|
153
|
+
})
|
|
154
|
+
if (!dep_err && dep_results) {
|
|
155
|
+
const dep_errors = dep_results.filter(r => r.severity === 'error')
|
|
156
|
+
const dep_warnings = dep_results.filter(r => r.severity === 'warning')
|
|
157
|
+
|
|
158
|
+
if (dep_errors.length > 0) {
|
|
159
|
+
spinner.fail('Dependency check failed')
|
|
160
|
+
if (is_json_mode()) {
|
|
161
|
+
const structured = dep_errors.map(({ severity, ...rest }) => rest)
|
|
162
|
+
print_json({
|
|
163
|
+
error: {
|
|
164
|
+
code: 'DEPENDENCY_VALIDATION_FAILED',
|
|
165
|
+
message: `${dep_errors.length} dependency error(s) found. Fix these issues and try again.`,
|
|
166
|
+
exit_code: EXIT_CODES.ERROR,
|
|
167
|
+
validation_errors: structured
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
return process.exit(EXIT_CODES.ERROR)
|
|
171
|
+
}
|
|
172
|
+
for (const r of dep_errors) {
|
|
173
|
+
print_error(r.message)
|
|
174
|
+
}
|
|
175
|
+
return process.exit(EXIT_CODES.ERROR)
|
|
160
176
|
}
|
|
177
|
+
|
|
178
|
+
if (dep_warnings.length > 0 && !is_json_mode()) {
|
|
179
|
+
for (const r of dep_warnings) {
|
|
180
|
+
print_warn(r.message)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Accumulate dep warnings for JSON output
|
|
185
|
+
validation_warnings.push(...dep_warnings)
|
|
161
186
|
}
|
|
187
|
+
spinner.update('Preparing to publish...')
|
|
162
188
|
}
|
|
163
189
|
|
|
164
190
|
// Read base_commit and merge_parents from lock file for divergence check
|
package/src/commands/search.js
CHANGED
|
@@ -236,7 +236,7 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
236
236
|
|
|
237
237
|
const scope = mine ? 'mine' : personal ? 'personal' : undefined
|
|
238
238
|
|
|
239
|
-
if (
|
|
239
|
+
if (mine || personal) {
|
|
240
240
|
const [, token_data] = await load_token()
|
|
241
241
|
if (!token_data) {
|
|
242
242
|
throw new AuthError('Authentication required. Run `happyskills login` first.')
|
package/src/commands/validate.js
CHANGED
|
@@ -6,9 +6,12 @@ const { validate_cross } = require('../validation/cross_rules')
|
|
|
6
6
|
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
7
7
|
const { validate_file_sizes } = require('../validation/file_size_rules')
|
|
8
8
|
const { validate_changelog_version } = require('../validation/changelog_rules')
|
|
9
|
+
const { validate_dependencies } = require('../validation/dependency_rules')
|
|
9
10
|
const { file_exists, read_json } = require('../utils/fs')
|
|
10
|
-
const { skills_dir, find_project_root } = require('../config/paths')
|
|
11
|
-
const {
|
|
11
|
+
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
12
|
+
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
13
|
+
const { load_token } = require('../auth/token_store')
|
|
14
|
+
const { print_help, print_json, print_info } = require('../ui/output')
|
|
12
15
|
const { bold, green, yellow, red, dim } = require('../ui/colors')
|
|
13
16
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
14
17
|
const { EXIT_CODES, SKILL_MD, SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
@@ -154,7 +157,47 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
154
157
|
const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
|
|
155
158
|
if (cl_err) throw cl_err
|
|
156
159
|
|
|
157
|
-
|
|
160
|
+
// Dependency validation — registry checks when authenticated
|
|
161
|
+
let dep_results = []
|
|
162
|
+
const manifest = json_data.manifest || {}
|
|
163
|
+
if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
|
|
164
|
+
const [, token_data] = await load_token()
|
|
165
|
+
const has_registry = !!token_data
|
|
166
|
+
|
|
167
|
+
// Resolve the full owner/name for registry lookups
|
|
168
|
+
let full_name = null
|
|
169
|
+
let visibility = 'private'
|
|
170
|
+
if (has_registry) {
|
|
171
|
+
const project_root = find_project_root()
|
|
172
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
173
|
+
if (lock_data) {
|
|
174
|
+
const all_skills = get_all_locked_skills(lock_data)
|
|
175
|
+
const suffix = `/${skill_name}`
|
|
176
|
+
const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
177
|
+
if (lock_key) full_name = lock_key
|
|
178
|
+
}
|
|
179
|
+
// Try to get visibility from registry if we have the full name
|
|
180
|
+
if (full_name) {
|
|
181
|
+
const repos_api = require('../api/repos')
|
|
182
|
+
const [owner, name] = full_name.split('/')
|
|
183
|
+
const [, repo_data] = await repos_api.get_repo(owner, name, { auth: true })
|
|
184
|
+
if (repo_data) visibility = repo_data.visibility || 'private'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const [dep_err, dep_result] = await validate_dependencies(skill_dir, manifest, {
|
|
189
|
+
registry: has_registry,
|
|
190
|
+
visibility,
|
|
191
|
+
full_name
|
|
192
|
+
})
|
|
193
|
+
if (!dep_err && dep_result) dep_results = dep_result
|
|
194
|
+
|
|
195
|
+
if (!has_registry && !args.flags.json) {
|
|
196
|
+
print_info('Dependency registry checks skipped (not logged in). Log in for full validation.')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...dep_results]
|
|
158
201
|
const type_label = is_kit ? ' [kit]' : ''
|
|
159
202
|
|
|
160
203
|
if (args.flags.json) {
|
package/src/engine/installer.js
CHANGED
|
@@ -73,14 +73,17 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
73
73
|
const spinner = create_spinner(`Resolving dependencies for ${skill}...`)
|
|
74
74
|
|
|
75
75
|
let packages
|
|
76
|
+
let skipped_deps = []
|
|
76
77
|
if (force) {
|
|
77
78
|
const [errors, result] = await resolve_with_force(skill, version || 'latest', {})
|
|
78
79
|
if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
|
|
79
|
-
packages = result
|
|
80
|
+
packages = result.packages
|
|
81
|
+
skipped_deps = result.skipped || []
|
|
80
82
|
} else {
|
|
81
83
|
const [errors, result] = await resolve(skill, version || 'latest', {})
|
|
82
84
|
if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
|
|
83
|
-
packages = result
|
|
85
|
+
packages = result.packages
|
|
86
|
+
skipped_deps = result.skipped || []
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
// Filter out packages already installed at the resolved version (handles @latest efficiently)
|
|
@@ -270,6 +273,29 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
270
273
|
}
|
|
271
274
|
}
|
|
272
275
|
|
|
276
|
+
// Warn about skipped dependencies (access-denied, not found, etc.)
|
|
277
|
+
if (skipped_deps.length > 0) {
|
|
278
|
+
const access_denied = skipped_deps.filter(s => s.reason === 'access_denied')
|
|
279
|
+
const not_found = skipped_deps.filter(s => s.reason === 'not_found')
|
|
280
|
+
const other = skipped_deps.filter(s => s.reason !== 'access_denied' && s.reason !== 'not_found')
|
|
281
|
+
if (access_denied.length > 0) {
|
|
282
|
+
print_warn(`${access_denied.length} dependenc${access_denied.length === 1 ? 'y' : 'ies'} skipped (private, requires login):`)
|
|
283
|
+
for (const s of access_denied) {
|
|
284
|
+
print_warn(` ${s.skill} (required by ${s.required_by})`)
|
|
285
|
+
}
|
|
286
|
+
print_info(`Log in to install: happyskills login`)
|
|
287
|
+
}
|
|
288
|
+
if (not_found.length > 0) {
|
|
289
|
+
print_warn(`${not_found.length} dependenc${not_found.length === 1 ? 'y' : 'ies'} skipped (not found in registry):`)
|
|
290
|
+
for (const s of not_found) {
|
|
291
|
+
print_warn(` ${s.skill} (required by ${s.required_by})`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const s of other) {
|
|
295
|
+
print_warn(`Skipped ${s.skill}: ${s.message}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
273
299
|
const installed_set = new Set(packages_to_install.map(p => p.skill))
|
|
274
300
|
const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
|
|
275
301
|
const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
|
|
@@ -277,7 +303,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
277
303
|
const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
|
|
278
304
|
|
|
279
305
|
const linked_agents = agents.map(a => a.id)
|
|
280
|
-
return { skill, version: packages[0]?.version, no_op: false, installed, skipped, warnings, forced, packages: packages_to_install.length, linked_agents }
|
|
306
|
+
return { skill, version: packages[0]?.version, no_op: false, installed, skipped, skipped_deps, warnings, forced, packages: packages_to_install.length, linked_agents }
|
|
281
307
|
} catch (err) {
|
|
282
308
|
await remove_dir(temp_dir)
|
|
283
309
|
throw err
|
package/src/engine/resolver.js
CHANGED
|
@@ -12,7 +12,7 @@ const resolve = (skill, version, installed = {}) => catch_errors('Dependency res
|
|
|
12
12
|
throw new Error(`Dependency conflicts detected:\n${conflict_msg}\n\nUse --force to install anyway (takes highest version).`)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
return result.packages || []
|
|
15
|
+
return { packages: result.packages || [], skipped: result.skipped || [] }
|
|
16
16
|
})
|
|
17
17
|
|
|
18
18
|
const resolve_with_force = (skill, version, installed = {}) => catch_errors('Forced resolution failed', async () => {
|
|
@@ -30,7 +30,7 @@ const resolve_with_force = (skill, version, installed = {}) => catch_errors('For
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
return packages
|
|
33
|
+
return { packages, skipped: result.skipped || [] }
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
module.exports = { resolve, resolve_with_force }
|
package/src/utils/errors.js
CHANGED
|
@@ -49,15 +49,32 @@ const is_usage_error = (err) => {
|
|
|
49
49
|
return false
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const extract_root_cause = (err) => {
|
|
53
|
+
if (!Array.isArray(err)) return err?.message || 'An unexpected error occurred'
|
|
54
|
+
// Walk the error array to find the deepest, most specific message.
|
|
55
|
+
// catch_errors wraps errors outermost-first: [outermost, ..., root_cause].
|
|
56
|
+
// We prefer the last error (root cause) for the user-facing message,
|
|
57
|
+
// but fall back to the first CliError if one exists.
|
|
58
|
+
const messages = err.map(x => x?.message).filter(Boolean)
|
|
59
|
+
if (messages.length === 0) return 'An unexpected error occurred'
|
|
60
|
+
// The last message is typically the root cause (deepest in the chain)
|
|
61
|
+
return messages[messages.length - 1]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const format_error_chain = (err) => {
|
|
65
|
+
if (!Array.isArray(err)) return [err?.stack || err?.message || String(err)]
|
|
66
|
+
return err.map(x => x?.stack || x?.message || String(x)).filter(Boolean)
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
const get_error_info = (err) => {
|
|
53
70
|
let target = err
|
|
54
71
|
if (Array.isArray(err)) {
|
|
55
|
-
target = err.find(e => e instanceof CliError) || err[0]
|
|
72
|
+
target = err.find(e => e instanceof CliError) || err[err.length - 1] || err[0]
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
let code = 'ERROR'
|
|
59
76
|
let exit_code = EXIT_CODES.ERROR
|
|
60
|
-
const message =
|
|
77
|
+
const message = extract_root_cause(err)
|
|
61
78
|
|
|
62
79
|
if (target instanceof UsageError) {
|
|
63
80
|
code = 'USAGE_ERROR'; exit_code = EXIT_CODES.USAGE
|
|
@@ -80,7 +97,10 @@ const exit_with_error = (err) => {
|
|
|
80
97
|
|
|
81
98
|
if (is_json_mode()) {
|
|
82
99
|
const { code, message, exit_code } = get_error_info(err)
|
|
83
|
-
|
|
100
|
+
const chain = format_error_chain(err)
|
|
101
|
+
const json_err = { code, message, exit_code }
|
|
102
|
+
if (chain.length > 1) json_err.details = chain
|
|
103
|
+
console.log(JSON.stringify({ error: json_err }, null, 2))
|
|
84
104
|
process.exit(exit_code)
|
|
85
105
|
return
|
|
86
106
|
}
|
|
@@ -89,33 +109,11 @@ const exit_with_error = (err) => {
|
|
|
89
109
|
const { write_error_log } = require('./logger')
|
|
90
110
|
|
|
91
111
|
const log_path = is_usage_error(err) ? null : write_error_log(err)
|
|
112
|
+
const { message, exit_code } = get_error_info(err)
|
|
92
113
|
|
|
93
|
-
if (err instanceof CliError) {
|
|
94
|
-
print_error(err.message)
|
|
95
|
-
print_log_hint(log_path)
|
|
96
|
-
process.exit(err.exit_code)
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (Array.isArray(err)) {
|
|
101
|
-
for (const e of err) {
|
|
102
|
-
if (e instanceof CliError) {
|
|
103
|
-
print_error(e.message)
|
|
104
|
-
print_log_hint(log_path)
|
|
105
|
-
process.exit(e.exit_code)
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
print_error(err[0]?.message || 'An unexpected error occurred')
|
|
110
|
-
print_log_hint(log_path)
|
|
111
|
-
process.exit(EXIT_CODES.ERROR)
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const message = err.message || 'An unexpected error occurred'
|
|
116
114
|
print_error(message)
|
|
117
115
|
print_log_hint(log_path)
|
|
118
|
-
process.exit(
|
|
116
|
+
process.exit(exit_code)
|
|
119
117
|
}
|
|
120
118
|
|
|
121
119
|
module.exports = { CliError, UsageError, AuthError, NetworkError, ApiError, exit_with_error }
|