happyskills 1.2.1 → 1.3.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 +18 -0
- package/package.json +1 -1
- package/src/auth/identity.js +58 -0
- package/src/commands/login.js +34 -25
- package/src/commands/whoami.js +6 -40
- package/src/engine/installer.js +16 -1
- package/src/engine/resolver.js +2 -2
- package/src/integration/cli.test.js +4 -0
- package/src/validation/dependency_rules.js +32 -15
- package/src/validation/dependency_rules.test.js +24 -0
- package/src/validation/skill_json_rules.js +0 -23
- package/src/validation/skill_json_rules.test.js +0 -26
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.0] - 2026-06-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `login` now returns the full identity payload — `username`, `email`, and `workspaces` — on every success path (`logged_in` and `already_logged_in`), so an agent no longer needs a follow-up `whoami` call after authenticating. Both commands build the block from one shared assembler, so their shapes stay identical. The workspace lookup is best-effort: a failed `GET /workspaces` degrades to an empty list plus `workspace_error` and never fails the login.
|
|
15
|
+
- Detect circular dependencies in the dependency graph. `validate` and `publish` run a `dep-circular` rule that resolves each dependency's published subtree, grafts the local skill's own edges, and DFS-searches for cycles — so it also catches a cycle created by the edge being published. A detected cycle is a hard error that blocks publish. Registry-scoped checks run only when authenticated.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Text-mode `login` now short-circuits when a valid session already exists — it prints the identity block instead of launching a fresh auth flow, mirroring the `--json` path. Run `happyskills logout` to force a fresh login.
|
|
20
|
+
- `install` is now loop-safe: a cycle among already-published skills never causes infinite recursion. Every skill in the tree is still installed, and the CLI prints a circular-dependency warning (also surfaced in `--json` under `warnings`).
|
|
21
|
+
|
|
22
|
+
## [1.2.2] - 2026-06-02
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `validate` no longer warns when `skill.json` `keywords` is missing or omits a canonical slug. The `keywords` field is deprecated — it is not used for search, ranking, or discovery — so the warning and the canonical-slug check were removed. The field is still accepted in the manifest.
|
|
27
|
+
|
|
10
28
|
## [1.2.1] - 2026-06-02
|
|
11
29
|
|
|
12
30
|
### Fixed
|
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const workspaces_api = require('../api/workspaces')
|
|
3
|
+
const { print_label, print_warn, print_hint } = require('../ui/output')
|
|
4
|
+
|
|
5
|
+
// Decode a JWT payload for display only — no signature verification. Returns
|
|
6
|
+
// null on any malformed input.
|
|
7
|
+
const decode_jwt_payload = (token) => {
|
|
8
|
+
try {
|
|
9
|
+
const parts = (token || '').split('.')
|
|
10
|
+
if (parts.length !== 3) return null
|
|
11
|
+
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
|
|
12
|
+
return JSON.parse(payload)
|
|
13
|
+
} catch {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Assemble the canonical identity payload shared by `whoami` and `login`, so
|
|
19
|
+
// the two commands can never disagree on shape or content. Identity fields
|
|
20
|
+
// (username/email) come straight off the in-hand id_token; workspaces are
|
|
21
|
+
// fetched best-effort — a failed lookup degrades to an empty list plus a
|
|
22
|
+
// `workspace_error` string and NEVER fails the caller. A network hiccup must
|
|
23
|
+
// not turn a valid session into an error: the user is logged in regardless.
|
|
24
|
+
const resolve_identity = (token_data) => catch_errors('Resolve identity failed', async () => {
|
|
25
|
+
const payload = decode_jwt_payload(token_data?.id_token)
|
|
26
|
+
const email = payload?.email || 'unknown'
|
|
27
|
+
const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
|
|
28
|
+
|
|
29
|
+
const [ws_err, workspaces] = await workspaces_api.list_workspaces()
|
|
30
|
+
const identity = { username, email, workspaces: ws_err ? [] : (workspaces || []) }
|
|
31
|
+
if (ws_err) identity.workspace_error = ws_err.map(er => er.message).join(': ')
|
|
32
|
+
return identity
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Render an identity payload to the human (text-mode) surface. Both `whoami`
|
|
36
|
+
// and `login` use this so the warm output stays content-equal with `--json`:
|
|
37
|
+
// the format differs, the information does not.
|
|
38
|
+
const render_identity_text = (identity) => {
|
|
39
|
+
print_label('Username', identity.username)
|
|
40
|
+
print_label('Email', identity.email)
|
|
41
|
+
|
|
42
|
+
if (identity.workspace_error) {
|
|
43
|
+
console.log()
|
|
44
|
+
print_warn(`Failed to list workspaces: ${identity.workspace_error}`)
|
|
45
|
+
print_hint('Check your authentication with happyskills login')
|
|
46
|
+
} else if (identity.workspaces && identity.workspaces.length > 0) {
|
|
47
|
+
console.log()
|
|
48
|
+
print_label('Workspaces', '')
|
|
49
|
+
for (const ws of identity.workspaces) {
|
|
50
|
+
const type_label = ws.is_owner
|
|
51
|
+
? ws.type === 'personal' ? ' (personal)' : ' (organization)'
|
|
52
|
+
: ''
|
|
53
|
+
console.log(` ${ws.slug}${type_label}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { decode_jwt_payload, resolve_identity, render_identity_text }
|
package/src/commands/login.js
CHANGED
|
@@ -4,8 +4,9 @@ const readline = require('readline')
|
|
|
4
4
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
5
5
|
const auth_api = require('../api/auth')
|
|
6
6
|
const { save_token, load_token } = require('../auth/token_store')
|
|
7
|
+
const { resolve_identity, render_identity_text } = require('../auth/identity')
|
|
7
8
|
const { create_spinner } = require('../ui/spinner')
|
|
8
|
-
const { print_help, print_success, print_info,
|
|
9
|
+
const { print_help, print_success, print_info, print_json } = require('../ui/output')
|
|
9
10
|
const { exit_with_error } = require('../utils/errors')
|
|
10
11
|
const { EXIT_CODES, CLI_VERSION } = require('../constants')
|
|
11
12
|
const { run_browser_flow } = require('./login_device')
|
|
@@ -136,17 +137,6 @@ const run_password_flow = () => catch_errors('Password login failed', async () =
|
|
|
136
137
|
await handle_post_login_telemetry()
|
|
137
138
|
})
|
|
138
139
|
|
|
139
|
-
const decode_jwt_payload = (token) => {
|
|
140
|
-
try {
|
|
141
|
-
const parts = token.split('.')
|
|
142
|
-
if (parts.length !== 3) return null
|
|
143
|
-
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
|
|
144
|
-
return JSON.parse(payload)
|
|
145
|
-
} catch {
|
|
146
|
-
return null
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
140
|
const run = (args) => catch_errors('Login failed', async () => {
|
|
151
141
|
if (args.flags._show_help) {
|
|
152
142
|
print_help(HELP_TEXT)
|
|
@@ -156,10 +146,8 @@ const run = (args) => catch_errors('Login failed', async () => {
|
|
|
156
146
|
if (args.flags.json) {
|
|
157
147
|
const [, token_data] = await load_token()
|
|
158
148
|
if (token_data) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
|
|
162
|
-
print_json({ data: { status: 'already_logged_in', username, email } })
|
|
149
|
+
const [, identity] = await resolve_identity(token_data)
|
|
150
|
+
print_json({ data: { status: 'already_logged_in', ...identity } })
|
|
163
151
|
return
|
|
164
152
|
}
|
|
165
153
|
|
|
@@ -181,10 +169,8 @@ const run = (args) => catch_errors('Login failed', async () => {
|
|
|
181
169
|
// Browser flow succeeded — read saved token
|
|
182
170
|
const [, new_token] = await load_token()
|
|
183
171
|
if (new_token) {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
|
|
187
|
-
print_json({ data: { status: 'logged_in', username, email } })
|
|
172
|
+
const [, identity] = await resolve_identity(new_token)
|
|
173
|
+
print_json({ data: { status: 'logged_in', ...identity } })
|
|
188
174
|
} else {
|
|
189
175
|
print_json({ error: { code: 'AUTH_FAILED', message: 'Login flow completed but token could not be loaded', exit_code: EXIT_CODES.ERROR } })
|
|
190
176
|
process.exit(EXIT_CODES.ERROR)
|
|
@@ -192,6 +178,20 @@ const run = (args) => catch_errors('Login failed', async () => {
|
|
|
192
178
|
return
|
|
193
179
|
}
|
|
194
180
|
|
|
181
|
+
// Text mode short-circuits on an existing session, mirroring the --json
|
|
182
|
+
// path (which returns `already_logged_in` before consulting any flow flag).
|
|
183
|
+
// "Already logged in" must mean the same thing in text as in JSON — same
|
|
184
|
+
// information, different encoding. To force a fresh login, run `logout`
|
|
185
|
+
// first. The workspace lookup stays best-effort and never fails the check.
|
|
186
|
+
const [, existing_token] = await load_token()
|
|
187
|
+
if (existing_token) {
|
|
188
|
+
const [, identity] = await resolve_identity(existing_token)
|
|
189
|
+
print_info('You are already logged in.')
|
|
190
|
+
console.log()
|
|
191
|
+
render_identity_text(identity)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
195
|
let use_password = args.flags.password === true
|
|
196
196
|
let use_browser = args.flags.browser === true
|
|
197
197
|
|
|
@@ -214,8 +214,16 @@ const run = (args) => catch_errors('Login failed', async () => {
|
|
|
214
214
|
await handle_post_login_telemetry()
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
// Show the same identity block `whoami` shows, so login fully answers
|
|
218
|
+
// "who am I" on the human surface too — no redundant follow-up call. This
|
|
219
|
+
// keeps text mode content-equal with `--json` (same information, different
|
|
220
|
+
// encoding). Best-effort: a workspace lookup failure never fails the login.
|
|
221
|
+
const [, token_data] = await load_token()
|
|
222
|
+
if (token_data) {
|
|
223
|
+
const [, identity] = await resolve_identity(token_data)
|
|
224
|
+
console.log()
|
|
225
|
+
render_identity_text(identity)
|
|
226
|
+
}
|
|
219
227
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
220
228
|
|
|
221
229
|
const schema = {
|
|
@@ -234,9 +242,10 @@ const schema = {
|
|
|
234
242
|
},
|
|
235
243
|
output: {
|
|
236
244
|
data_shape: {
|
|
237
|
-
status:
|
|
238
|
-
username:
|
|
239
|
-
email:
|
|
245
|
+
status: 'string',
|
|
246
|
+
username: 'string',
|
|
247
|
+
email: 'string',
|
|
248
|
+
workspaces: 'array',
|
|
240
249
|
},
|
|
241
250
|
},
|
|
242
251
|
errors: [
|
package/src/commands/whoami.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
const { error: { catch_errors
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
2
|
const { require_token } = require('../auth/token_store')
|
|
3
|
-
const
|
|
4
|
-
const { print_help,
|
|
3
|
+
const { resolve_identity, render_identity_text } = require('../auth/identity')
|
|
4
|
+
const { print_help, print_json } = require('../ui/output')
|
|
5
5
|
const { exit_with_error } = require('../utils/errors')
|
|
6
6
|
const { EXIT_CODES } = require('../constants')
|
|
7
7
|
|
|
@@ -16,17 +16,6 @@ Examples:
|
|
|
16
16
|
happyskills whoami
|
|
17
17
|
happyskills whoami --json`
|
|
18
18
|
|
|
19
|
-
const decode_jwt_payload = (token) => {
|
|
20
|
-
try {
|
|
21
|
-
const parts = token.split('.')
|
|
22
|
-
if (parts.length !== 3) return null
|
|
23
|
-
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
|
|
24
|
-
return JSON.parse(payload)
|
|
25
|
-
} catch {
|
|
26
|
-
return null
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
19
|
const run = (args) => catch_errors('Whoami failed', async () => {
|
|
31
20
|
if (args.flags._show_help) {
|
|
32
21
|
print_help(HELP_TEXT)
|
|
@@ -34,37 +23,14 @@ const run = (args) => catch_errors('Whoami failed', async () => {
|
|
|
34
23
|
}
|
|
35
24
|
|
|
36
25
|
const token_data = await require_token()
|
|
37
|
-
|
|
38
|
-
const payload = decode_jwt_payload(token_data.id_token)
|
|
39
|
-
const email = payload?.email || 'unknown'
|
|
40
|
-
const username = payload?.['cognito:username'] || payload?.sub || 'unknown'
|
|
41
|
-
|
|
42
|
-
const [ws_err, workspaces] = await workspaces_api.list_workspaces()
|
|
26
|
+
const [, identity] = await resolve_identity(token_data)
|
|
43
27
|
|
|
44
28
|
if (args.flags.json) {
|
|
45
|
-
|
|
46
|
-
if (ws_err) json_data.workspace_error = ws_err.map(er => er.message).join(': ')
|
|
47
|
-
print_json({ data: json_data })
|
|
29
|
+
print_json({ data: identity })
|
|
48
30
|
return
|
|
49
31
|
}
|
|
50
32
|
|
|
51
|
-
|
|
52
|
-
print_label('Email', email)
|
|
53
|
-
|
|
54
|
-
if (ws_err) {
|
|
55
|
-
console.log()
|
|
56
|
-
print_warn(`Failed to list workspaces: ${ws_err.map(er => er.message).join(': ')}`)
|
|
57
|
-
print_hint('Check your authentication with happyskills login')
|
|
58
|
-
} else if (workspaces && workspaces.length > 0) {
|
|
59
|
-
console.log()
|
|
60
|
-
print_label('Workspaces', '')
|
|
61
|
-
for (const ws of workspaces) {
|
|
62
|
-
const type_label = ws.is_owner
|
|
63
|
-
? ws.type === 'personal' ? ' (personal)' : ' (organization)'
|
|
64
|
-
: ''
|
|
65
|
-
console.log(` ${ws.slug}${type_label}`)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
33
|
+
render_identity_text(identity)
|
|
68
34
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
69
35
|
|
|
70
36
|
const schema = {
|
package/src/engine/installer.js
CHANGED
|
@@ -22,6 +22,13 @@ const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
|
|
|
22
22
|
return `Missing system dependency: ${dep.name} required by ${dep.skill}${hint}`
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
// Circular dependencies the resolver broke automatically. The install is safe and terminates,
|
|
26
|
+
// but the author should remove one edge — so we surface a warning rather than failing silently.
|
|
27
|
+
const _format_cycle_warnings = (cycles) => (cycles || []).map(({ from, to }) =>
|
|
28
|
+
`Circular dependency detected: ${from} depends on ${to}, which depends back on ${from}. ` +
|
|
29
|
+
`The install completed safely (the cycle was broken automatically), but you should remove one ` +
|
|
30
|
+
`direction of the dependency.`)
|
|
31
|
+
|
|
25
32
|
// Order-preserving, de-duplicated union of requester lists.
|
|
26
33
|
const _union = (...lists) => {
|
|
27
34
|
const seen = new Set()
|
|
@@ -103,16 +110,19 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
103
110
|
|
|
104
111
|
let packages
|
|
105
112
|
let skipped_deps = []
|
|
113
|
+
let cycles = []
|
|
106
114
|
if (force) {
|
|
107
115
|
const [errors, result] = await resolve_with_force(skill, version || 'latest', {})
|
|
108
116
|
if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
|
|
109
117
|
packages = result.packages
|
|
110
118
|
skipped_deps = result.skipped || []
|
|
119
|
+
cycles = result.cycles || []
|
|
111
120
|
} else {
|
|
112
121
|
const [errors, result] = await resolve(skill, version || 'latest', {})
|
|
113
122
|
if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
|
|
114
123
|
packages = result.packages
|
|
115
124
|
skipped_deps = result.skipped || []
|
|
125
|
+
cycles = result.cycles || []
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
// Filter out packages already installed at the resolved version (handles @latest efficiently)
|
|
@@ -343,10 +353,15 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
343
353
|
}
|
|
344
354
|
}
|
|
345
355
|
|
|
356
|
+
// Warn about circular dependencies. The resolver broke the cycle automatically (so the
|
|
357
|
+
// install is safe and terminates), but the author should still remove one edge.
|
|
358
|
+
const cycle_warnings = _format_cycle_warnings(cycles)
|
|
359
|
+
for (const w of cycle_warnings) print_warn(w)
|
|
360
|
+
|
|
346
361
|
const installed_set = new Set(packages_to_install.map(p => p.skill))
|
|
347
362
|
const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
|
|
348
363
|
const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
|
|
349
|
-
const warnings = _format_warnings(missing_deps)
|
|
364
|
+
const warnings = [..._format_warnings(missing_deps), ...cycle_warnings]
|
|
350
365
|
const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
|
|
351
366
|
|
|
352
367
|
const linked_agents = agents.map(a => a.id)
|
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 { packages: result.packages || [], skipped: result.skipped || [] }
|
|
15
|
+
return { packages: result.packages || [], skipped: result.skipped || [], cycles: result.cycles || [] }
|
|
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, skipped: result.skipped || [] }
|
|
33
|
+
return { packages, skipped: result.skipped || [], cycles: result.cycles || [] }
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
module.exports = { resolve, resolve_with_force }
|
|
@@ -403,6 +403,10 @@ describe('CLI — --json: success envelopes', () => {
|
|
|
403
403
|
assert.strictEqual(env.data.status, 'already_logged_in')
|
|
404
404
|
assert.strictEqual(env.data.username, 'testuser')
|
|
405
405
|
assert.strictEqual(env.data.email, 'test@example.com')
|
|
406
|
+
// Content parity with whoami: login carries the workspaces block too.
|
|
407
|
+
// The dummy API host (localhost:0) is unreachable, so the best-effort
|
|
408
|
+
// lookup degrades to an empty list — it must never fail the login.
|
|
409
|
+
assert.ok(Array.isArray(env.data.workspaces), 'login data.workspaces must be an array')
|
|
406
410
|
} finally {
|
|
407
411
|
fs.rmSync(tmp_xdg, { recursive: true, force: true })
|
|
408
412
|
}
|
|
@@ -172,27 +172,40 @@ const check_visibility_transitive = async (manifest, visibility, repos_api) => {
|
|
|
172
172
|
const check_circular = async (manifest, repos_api) => {
|
|
173
173
|
const results = []
|
|
174
174
|
const deps = manifest.dependencies || {}
|
|
175
|
+
const dep_names = Object.keys(deps)
|
|
175
176
|
|
|
176
|
-
if (
|
|
177
|
+
if (dep_names.length === 0) return results
|
|
177
178
|
|
|
178
179
|
const skill_name = manifest._full_name
|
|
179
180
|
if (!skill_name) return results
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
182
|
+
// Resolve each DIRECT dependency's published subtree — NOT the root's. The root's published
|
|
183
|
+
// manifest can be stale: when you publish the very edge that closes a cycle, the registry's
|
|
184
|
+
// view of the root does not yet contain that edge, so resolving the root would hide the
|
|
185
|
+
// cycle. Resolving the dependencies' subtrees and then grafting the LOCAL (about-to-be-
|
|
186
|
+
// published) root edges on top reflects the graph as it will be AFTER this publish — which
|
|
187
|
+
// is exactly what we must validate.
|
|
188
|
+
const subtrees = await Promise.all(
|
|
189
|
+
dep_names.map(dep => repos_api.resolve_dependencies(dep, 'latest', {}))
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// Merge every resolved package into one adjacency map (the registry's current view).
|
|
187
193
|
const adj = {}
|
|
188
|
-
for (const
|
|
189
|
-
|
|
194
|
+
for (const [sub_err, data] of subtrees) {
|
|
195
|
+
if (sub_err || !data) continue
|
|
196
|
+
for (const pkg of (data.packages || [])) {
|
|
197
|
+
if (adj[pkg.skill] === undefined) adj[pkg.skill] = Object.keys(pkg.dependencies || {})
|
|
198
|
+
}
|
|
190
199
|
}
|
|
191
200
|
|
|
192
|
-
//
|
|
201
|
+
// Graft the LOCAL edges for the root. This overrides any stale published view of the root
|
|
202
|
+
// that a dependency's subtree may have included, making a cycle the new edges create visible.
|
|
203
|
+
adj[skill_name] = dep_names
|
|
204
|
+
|
|
205
|
+
// DFS cycle detection (WHITE/GRAY/BLACK colouring). Start from the root so reported cycles
|
|
206
|
+
// are rooted at the skill being published; then cover any unreachable nodes defensively.
|
|
193
207
|
const WHITE = 0, GRAY = 1, BLACK = 2
|
|
194
208
|
const color = {}
|
|
195
|
-
const parent = {}
|
|
196
209
|
const cycles = []
|
|
197
210
|
|
|
198
211
|
const dfs = (node, path_so_far) => {
|
|
@@ -213,16 +226,20 @@ const check_circular = async (manifest, repos_api) => {
|
|
|
213
226
|
color[node] = BLACK
|
|
214
227
|
}
|
|
215
228
|
|
|
229
|
+
dfs(skill_name, [skill_name])
|
|
216
230
|
for (const node of Object.keys(adj)) {
|
|
217
|
-
if ((color[node] || WHITE) === WHITE)
|
|
218
|
-
dfs(node, [node])
|
|
219
|
-
}
|
|
231
|
+
if ((color[node] || WHITE) === WHITE) dfs(node, [node])
|
|
220
232
|
}
|
|
221
233
|
|
|
222
234
|
if (cycles.length > 0) {
|
|
235
|
+
// De-duplicate identical cycle paths (the same cycle can be reached from multiple starts).
|
|
236
|
+
const seen = new Set()
|
|
223
237
|
for (const cycle of cycles) {
|
|
238
|
+
const key = cycle.join(' → ')
|
|
239
|
+
if (seen.has(key)) continue
|
|
240
|
+
seen.add(key)
|
|
224
241
|
results.push(result('dependencies', 'dep-circular', 'error',
|
|
225
|
-
`Circular dependency detected: ${
|
|
242
|
+
`Circular dependency detected: ${key}. ` +
|
|
226
243
|
`Remove one direction of the dependency to break the cycle.`,
|
|
227
244
|
cycle))
|
|
228
245
|
}
|
|
@@ -158,6 +158,30 @@ describe('dep-circular (registry)', () => {
|
|
|
158
158
|
const check = results.find(r => r.rule === 'dep-circular')
|
|
159
159
|
assert.strictEqual(check.severity, 'pass')
|
|
160
160
|
})
|
|
161
|
+
|
|
162
|
+
it('detects a cycle created by the very edge being published (stale root view)', async () => {
|
|
163
|
+
// Registry view: the dependency (acme/b) depends back on the root (acme/a), but the
|
|
164
|
+
// root's PUBLISHED manifest still has empty deps — the cycle-closing edge (acme/a → acme/b)
|
|
165
|
+
// exists only in the LOCAL manifest being validated. The check must graft the local edge
|
|
166
|
+
// onto the resolved subtree, otherwise this cycle slips into the registry undetected.
|
|
167
|
+
const manifest = { name: 'a', version: '0.2.0', dependencies: { 'acme/b': '*' } }
|
|
168
|
+
const resolve_result = {
|
|
169
|
+
packages: [
|
|
170
|
+
{ skill: 'acme/b', version: '1.0.0', dependencies: { 'acme/a': '*' } },
|
|
171
|
+
{ skill: 'acme/a', version: '1.0.0', dependencies: {} } // stale: published root has no deps yet
|
|
172
|
+
],
|
|
173
|
+
skipped: []
|
|
174
|
+
}
|
|
175
|
+
const api = make_mock_api({ 'acme/b': { visibility: 'public' } }, resolve_result)
|
|
176
|
+
const [err, results] = await validate_dependencies(tmp, manifest, {
|
|
177
|
+
registry: true, full_name: 'acme/a', repos_api: api
|
|
178
|
+
})
|
|
179
|
+
assert.ifError(err)
|
|
180
|
+
const check = results.find(r => r.rule === 'dep-circular' && r.severity === 'error')
|
|
181
|
+
assert.ok(check, 'should detect the cycle created by the local closing edge')
|
|
182
|
+
assert.ok(check.message.includes('acme/a'))
|
|
183
|
+
assert.ok(check.message.includes('acme/b'))
|
|
184
|
+
})
|
|
161
185
|
})
|
|
162
186
|
|
|
163
187
|
describe('dep-visibility-transitive (registry)', () => {
|
|
@@ -6,7 +6,6 @@ const { SKILL_JSON, VALID_SKILL_TYPES, SKILL_TYPES, KIT_PREFIX } = require('../c
|
|
|
6
6
|
|
|
7
7
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
8
8
|
const KIT_NAME_PATTERN = /^_kit-[a-z0-9][a-z0-9-]*$/
|
|
9
|
-
const CANONICAL_SLUGS = ['deployment', 'database', 'security', 'ai', 'api', 'monitoring', 'testing', 'devops', 'cloud', 'analytics']
|
|
10
9
|
const REQUIRED_PLATFORMS = ['darwin', 'linux', 'win32']
|
|
11
10
|
|
|
12
11
|
const result = (field, rule, severity, message, value) => ({
|
|
@@ -90,27 +89,6 @@ const validate_description = (manifest) => {
|
|
|
90
89
|
return results
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
const validate_keywords = (manifest) => {
|
|
94
|
-
const results = []
|
|
95
|
-
const kw = manifest.keywords
|
|
96
|
-
|
|
97
|
-
if (!kw || !Array.isArray(kw)) {
|
|
98
|
-
results.push(result('keywords', 'recommended_field', 'warning', 'Keywords array is recommended — include at least one canonical slug'))
|
|
99
|
-
return results
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
results.push(result('keywords', 'recommended_field', 'pass', `keywords: ${kw.length} entries`))
|
|
103
|
-
|
|
104
|
-
const has_slug = kw.some(k => CANONICAL_SLUGS.includes(k))
|
|
105
|
-
if (!has_slug) {
|
|
106
|
-
results.push(result('keywords', 'canonical_slug', 'warning', `Keywords should include at least one canonical slug: ${CANONICAL_SLUGS.join(', ')}`))
|
|
107
|
-
} else {
|
|
108
|
-
results.push(result('keywords', 'canonical_slug', 'pass', 'Contains canonical slug'))
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return results
|
|
112
|
-
}
|
|
113
|
-
|
|
114
92
|
const validate_type = (manifest) => {
|
|
115
93
|
const results = []
|
|
116
94
|
const type = manifest.type
|
|
@@ -229,7 +207,6 @@ const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skil
|
|
|
229
207
|
results.push(...validate_version(manifest))
|
|
230
208
|
results.push(...validate_type(manifest))
|
|
231
209
|
results.push(...validate_description(manifest))
|
|
232
|
-
results.push(...validate_keywords(manifest))
|
|
233
210
|
results.push(...validate_dependencies(manifest))
|
|
234
211
|
results.push(...validate_system_dependencies(manifest))
|
|
235
212
|
|
|
@@ -121,32 +121,6 @@ describe('validate_skill_json — description', () => {
|
|
|
121
121
|
})
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
-
describe('validate_skill_json — keywords', () => {
|
|
125
|
-
it('warns when keywords are missing', async () => {
|
|
126
|
-
write_json(tmp, { name: 'my-skill', version: '1.0.0' })
|
|
127
|
-
const [err, data] = await validate_skill_json(tmp)
|
|
128
|
-
assert.ifError(err)
|
|
129
|
-
const check = data.results.find(r => r.field === 'keywords' && r.rule === 'recommended_field')
|
|
130
|
-
assert.strictEqual(check.severity, 'warning')
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('warns when no canonical slug is present', async () => {
|
|
134
|
-
write_json(tmp, { name: 'my-skill', version: '1.0.0', keywords: ['custom'] })
|
|
135
|
-
const [err, data] = await validate_skill_json(tmp)
|
|
136
|
-
assert.ifError(err)
|
|
137
|
-
const check = data.results.find(r => r.rule === 'canonical_slug')
|
|
138
|
-
assert.strictEqual(check.severity, 'warning')
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
it('passes when a canonical slug is present', async () => {
|
|
142
|
-
write_json(tmp, { name: 'my-skill', version: '1.0.0', keywords: ['deployment', 'custom'] })
|
|
143
|
-
const [err, data] = await validate_skill_json(tmp)
|
|
144
|
-
assert.ifError(err)
|
|
145
|
-
const check = data.results.find(r => r.rule === 'canonical_slug')
|
|
146
|
-
assert.strictEqual(check.severity, 'pass')
|
|
147
|
-
})
|
|
148
|
-
})
|
|
149
|
-
|
|
150
124
|
describe('validate_skill_json — dependencies', () => {
|
|
151
125
|
it('errors when dependencies is an array', async () => {
|
|
152
126
|
write_json(tmp, { name: 'my-skill', version: '1.0.0', dependencies: ['acme/deploy'] })
|