happyskills 1.2.2 → 1.4.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 +19 -0
- package/package.json +1 -1
- package/src/api/repos.js +16 -1
- package/src/api/repos.test.js +48 -0
- package/src/auth/identity.js +58 -0
- package/src/commands/login.js +34 -25
- package/src/commands/search.js +21 -8
- package/src/commands/search.test.js +60 -1
- package/src/commands/star.js +82 -0
- package/src/commands/unstar.js +81 -0
- package/src/commands/whoami.js +6 -40
- package/src/constants.js +2 -0
- package/src/engine/installer.js +16 -1
- package/src/engine/resolver.js +2 -2
- package/src/index.js +2 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.0] - 2026-06-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `star <owner/name>` and `unstar <owner/name>` commands to curate your favorites. Both require auth and are idempotent (starring an already-starred skill, or unstarring one you haven't starred, succeeds without error).
|
|
15
|
+
- Add `--favorites` flag to `search` — query only the skills you've starred (`scope=favorites`). Routes through the smart dispatcher; a bare `search --favorites` (no query) browses all your starred skills. Requires auth; cannot be combined with `--exact`.
|
|
16
|
+
|
|
17
|
+
## [1.3.0] - 2026-06-03
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `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.
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- 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.
|
|
27
|
+
- `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`).
|
|
28
|
+
|
|
10
29
|
## [1.2.2] - 2026-06-02
|
|
11
30
|
|
|
12
31
|
### Changed
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -105,6 +105,21 @@ const patch_repo = (owner, name, fields) => catch_errors(`Failed to update ${own
|
|
|
105
105
|
return data
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
+
// Star / unstar a repo. Both endpoints are idempotent server-side (starring an
|
|
109
|
+
// already-starred repo, or unstarring an unstarred one, succeeds). The user's
|
|
110
|
+
// own stars power the `favorites` search scope.
|
|
111
|
+
const star = (owner, name) => catch_errors(`Failed to star ${owner}/${name}`, async () => {
|
|
112
|
+
const [errors, data] = await client.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/star`)
|
|
113
|
+
if (errors) throw errors[errors.length - 1]
|
|
114
|
+
return data
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const unstar = (owner, name) => catch_errors(`Failed to unstar ${owner}/${name}`, async () => {
|
|
118
|
+
const [errors, data] = await client.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/star`)
|
|
119
|
+
if (errors) throw errors[errors.length - 1]
|
|
120
|
+
return data
|
|
121
|
+
})
|
|
122
|
+
|
|
108
123
|
const compare = (owner, repo, base_commit) => catch_errors(`Compare ${owner}/${repo} failed`, async () => {
|
|
109
124
|
const [errors, data] = await client.post(`/repos/${owner}/${repo}/compare`, { base_commit })
|
|
110
125
|
if (errors) throw errors[errors.length - 1]
|
|
@@ -195,4 +210,4 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
|
|
|
195
210
|
return data
|
|
196
211
|
})
|
|
197
212
|
|
|
198
|
-
module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob, get_tree }
|
|
213
|
+
module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree }
|
package/src/api/repos.test.js
CHANGED
|
@@ -169,3 +169,51 @@ describe('repos.get_blob — content integrity verification', () => {
|
|
|
169
169
|
assert.strictEqual(data.size, content.length)
|
|
170
170
|
})
|
|
171
171
|
})
|
|
172
|
+
|
|
173
|
+
// Capturing client stub: records (method, path) for each call so star/unstar
|
|
174
|
+
// path construction can be asserted. Mirrors stub_client above.
|
|
175
|
+
const stub_client_capture = () => {
|
|
176
|
+
const calls = []
|
|
177
|
+
const record = (method) => async (path) => { calls.push([method, path]); return [null, { message: 'ok' }] }
|
|
178
|
+
const client_path = require.resolve('./client')
|
|
179
|
+
require.cache[client_path] = {
|
|
180
|
+
id: client_path,
|
|
181
|
+
filename: client_path,
|
|
182
|
+
loaded: true,
|
|
183
|
+
exports: {
|
|
184
|
+
get: record('get'),
|
|
185
|
+
post: record('post'),
|
|
186
|
+
put: record('put'),
|
|
187
|
+
patch: record('patch'),
|
|
188
|
+
del: record('del'),
|
|
189
|
+
request: record('request'),
|
|
190
|
+
get_base_url: () => 'http://test'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
delete require.cache[require.resolve('./repos')]
|
|
194
|
+
return { repos: require('./repos'), calls }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
describe('repos.star / repos.unstar — endpoint + path', () => {
|
|
198
|
+
afterEach(restore)
|
|
199
|
+
|
|
200
|
+
it('star() POSTs to /repos/<owner>/<name>/star', async () => {
|
|
201
|
+
const { repos, calls } = stub_client_capture()
|
|
202
|
+
const [err] = await repos.star('acme', 'deploy-aws')
|
|
203
|
+
assert.strictEqual(err, null)
|
|
204
|
+
assert.deepStrictEqual(calls[0], ['post', '/repos/acme/deploy-aws/star'])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('unstar() DELETEs /repos/<owner>/<name>/star', async () => {
|
|
208
|
+
const { repos, calls } = stub_client_capture()
|
|
209
|
+
const [err] = await repos.unstar('acme', 'deploy-aws')
|
|
210
|
+
assert.strictEqual(err, null)
|
|
211
|
+
assert.deepStrictEqual(calls[0], ['del', '/repos/acme/deploy-aws/star'])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('URL-encodes owner and name in the star path', async () => {
|
|
215
|
+
const { repos, calls } = stub_client_capture()
|
|
216
|
+
await repos.star('acme corp', 'deploy/aws')
|
|
217
|
+
assert.strictEqual(calls[0][1], `/repos/${encodeURIComponent('acme corp')}/${encodeURIComponent('deploy/aws')}/star`)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -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/search.js
CHANGED
|
@@ -16,12 +16,13 @@ fuzzy slug, and "workspace/skill" form goes to fuzzy scoped (both parts are
|
|
|
16
16
|
typo-tolerant). Use --exact to force keyword-only FTS instead.
|
|
17
17
|
|
|
18
18
|
Arguments:
|
|
19
|
-
query Search term (optional with --mine, --personal, or --
|
|
19
|
+
query Search term (optional with --mine, --personal, --workspace, or --favorites)
|
|
20
20
|
|
|
21
21
|
Options:
|
|
22
22
|
-w, --workspace <slug> Search within specific workspace(s) (comma-separated)
|
|
23
23
|
--mine Search across all your workspaces
|
|
24
24
|
--personal Search only your personal workspace
|
|
25
|
+
--favorites Search only skills you've starred (requires auth)
|
|
25
26
|
--tags <tags> Filter by tags (comma-separated)
|
|
26
27
|
--type <type> Filter by type (skill, kit)
|
|
27
28
|
--exact Force keyword-only FTS matching (skip smart routing)
|
|
@@ -42,6 +43,8 @@ Examples:
|
|
|
42
43
|
happyskills search deploy-aws --limit 10 # → fuzzy slug (typo-tolerant)
|
|
43
44
|
happyskills search letta-ai/remotion --limit 5 # → fuzzy scoped
|
|
44
45
|
happyskills search --mine --limit 20
|
|
46
|
+
happyskills search --favorites --limit 20 # → your starred skills
|
|
47
|
+
happyskills search deploy --favorites --limit 10 # → favorites matching "deploy"
|
|
45
48
|
happyskills search deploy --workspace acme --limit 50
|
|
46
49
|
happyskills search --type kit --limit 10
|
|
47
50
|
happyskills search deploy --exact --limit 10 # → keyword FTS only`
|
|
@@ -380,15 +383,19 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
380
383
|
}
|
|
381
384
|
|
|
382
385
|
const query = args._.join(' ') || null
|
|
383
|
-
const { mine, personal, workspace, tags, type } = args.flags
|
|
386
|
+
const { mine, personal, workspace, tags, type, favorites } = args.flags
|
|
384
387
|
const is_exact = !!args.flags.exact
|
|
385
388
|
const with_rerank = !!args.flags['with-rerank']
|
|
386
|
-
// --smart / -S accepted for backward compat (now the default when a query is provided)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
+
// --smart / -S accepted for backward compat (now the default when a query is provided).
|
|
390
|
+
// --favorites also forces the smart (POST /repos:search) path even with no query:
|
|
391
|
+
// favorites is a dispatcher-only scope, so it must never fall through to the
|
|
392
|
+
// legacy keyword GET, which doesn't understand it. A bare `--favorites` browses
|
|
393
|
+
// the user's starred skills (list mode).
|
|
394
|
+
const use_smart = (query || favorites) && !is_exact
|
|
395
|
+
const has_scope_flag = mine || personal || workspace || favorites
|
|
389
396
|
|
|
390
397
|
if (!query && !has_scope_flag && !tags && !type) {
|
|
391
|
-
throw new UsageError('Please provide a search query or use --mine, --personal, or --
|
|
398
|
+
throw new UsageError('Please provide a search query or use --mine, --personal, --workspace, or --favorites.')
|
|
392
399
|
}
|
|
393
400
|
|
|
394
401
|
if (!args.flags.limit) {
|
|
@@ -405,9 +412,15 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
405
412
|
throw new UsageError('--with-rerank cannot be combined with --exact. The rerank protocol applies only to semantic-mode dispatches.')
|
|
406
413
|
}
|
|
407
414
|
|
|
408
|
-
|
|
415
|
+
// Favorites is a dispatcher-only scope (POST /repos:search); --exact forces the
|
|
416
|
+
// legacy keyword endpoint, which doesn't support it. Refuse the combination.
|
|
417
|
+
if (favorites && is_exact) {
|
|
418
|
+
throw new UsageError('--favorites cannot be combined with --exact. Favorites search uses the smart dispatcher, not keyword-only FTS.')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const scope = favorites ? 'favorites' : mine ? 'mine' : personal ? 'personal' : undefined
|
|
409
422
|
|
|
410
|
-
if (mine || personal) {
|
|
423
|
+
if (mine || personal || favorites) {
|
|
411
424
|
const [, token_data] = await load_token()
|
|
412
425
|
if (!token_data) {
|
|
413
426
|
throw new AuthError('Authentication required. Run `happyskills login` first.')
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// short names (`clarify`, `present_to_user` alone) no longer apply where the
|
|
10
10
|
// new enum supersedes them.
|
|
11
11
|
|
|
12
|
-
const { describe, it } = require('node:test')
|
|
12
|
+
const { describe, it, afterEach } = require('node:test')
|
|
13
13
|
const assert = require('node:assert/strict')
|
|
14
14
|
const { build_search_next_step } = require('./search')
|
|
15
15
|
const {
|
|
@@ -146,3 +146,62 @@ describe('build_search_next_step — envelope shape', () => {
|
|
|
146
146
|
assert.strictEqual(opts[3].refined_query_hint, null)
|
|
147
147
|
})
|
|
148
148
|
})
|
|
149
|
+
|
|
150
|
+
// --- search --favorites wiring (spec 260601-02 § 4.3) ---------------------
|
|
151
|
+
// Stub the API client + token store so run() exercises the flag-routing logic
|
|
152
|
+
// without a network call. favorites is a dispatcher-only scope, so the key
|
|
153
|
+
// assertions are: it sets scope='favorites' on dispatch_search (POST) and never
|
|
154
|
+
// falls through to the keyword GET, and a bare --favorites (no query) is allowed.
|
|
155
|
+
|
|
156
|
+
const stub_search_deps = (capture) => {
|
|
157
|
+
const repos_path = require.resolve('../api/repos')
|
|
158
|
+
require.cache[repos_path] = {
|
|
159
|
+
id: repos_path, filename: repos_path, loaded: true,
|
|
160
|
+
exports: {
|
|
161
|
+
dispatch_search: async (query, opts) => {
|
|
162
|
+
capture.dispatch = { query, opts }
|
|
163
|
+
return [null, { data: [], mode: 'list', match_notice: null }]
|
|
164
|
+
},
|
|
165
|
+
search: async (query, opts) => { capture.keyword = { query, opts }; return [null, []] },
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
const token_path = require.resolve('../auth/token_store')
|
|
169
|
+
require.cache[token_path] = {
|
|
170
|
+
id: token_path, filename: token_path, loaded: true,
|
|
171
|
+
exports: {
|
|
172
|
+
load_token: async () => [null, { token: 'test-token' }],
|
|
173
|
+
require_token: async () => ({ token: 'test-token' }),
|
|
174
|
+
save_token: async () => {}, clear_token: async () => {},
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
delete require.cache[require.resolve('./search')]
|
|
178
|
+
return require('./search')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const restore_search_deps = () => {
|
|
182
|
+
delete require.cache[require.resolve('../api/repos')]
|
|
183
|
+
delete require.cache[require.resolve('../auth/token_store')]
|
|
184
|
+
delete require.cache[require.resolve('./search')]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
describe('search --favorites — scope wiring', () => {
|
|
188
|
+
afterEach(restore_search_deps)
|
|
189
|
+
|
|
190
|
+
it('bare --favorites (no query) is allowed and dispatches scope=favorites via POST, not keyword GET', async () => {
|
|
191
|
+
const capture = {}
|
|
192
|
+
const search = stub_search_deps(capture)
|
|
193
|
+
await search.run({ _: [], flags: { favorites: true, limit: '20', json: true } })
|
|
194
|
+
assert.ok(capture.dispatch, 'dispatch_search (POST) must be called for favorites')
|
|
195
|
+
assert.strictEqual(capture.keyword, undefined, 'keyword GET must NOT be used for favorites')
|
|
196
|
+
assert.strictEqual(capture.dispatch.opts.scope, 'favorites')
|
|
197
|
+
assert.strictEqual(capture.dispatch.query, null, 'bare --favorites sends a null query (list mode)')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('--favorites with a query keeps scope=favorites and forwards the query', async () => {
|
|
201
|
+
const capture = {}
|
|
202
|
+
const search = stub_search_deps(capture)
|
|
203
|
+
await search.run({ _: ['deploy'], flags: { favorites: true, limit: '10', json: true } })
|
|
204
|
+
assert.strictEqual(capture.dispatch.opts.scope, 'favorites')
|
|
205
|
+
assert.strictEqual(capture.dispatch.query, 'deploy')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { star } = require('../api/repos')
|
|
3
|
+
const { require_token } = require('../auth/token_store')
|
|
4
|
+
const { print_help, print_json, print_success } = require('../ui/output')
|
|
5
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
6
|
+
const { EXIT_CODES } = require('../constants')
|
|
7
|
+
|
|
8
|
+
const HELP_TEXT = `Usage: happyskills star <owner/name> [options]
|
|
9
|
+
|
|
10
|
+
Star a skill. Your starred skills are your favorites — search them with
|
|
11
|
+
\`happyskills search --favorites\`. Idempotent: starring an already-starred
|
|
12
|
+
skill succeeds without error.
|
|
13
|
+
|
|
14
|
+
Arguments:
|
|
15
|
+
owner/name Skill to star (e.g., acme/deploy-aws)
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--json Output as JSON
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
happyskills star acme/deploy-aws
|
|
22
|
+
happyskills star acme/deploy-aws --json`
|
|
23
|
+
|
|
24
|
+
const run = (args) => catch_errors('Star failed', async () => {
|
|
25
|
+
if (args.flags._show_help) {
|
|
26
|
+
print_help(HELP_TEXT)
|
|
27
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const skill = args._[0]
|
|
31
|
+
if (!skill) {
|
|
32
|
+
throw new UsageError('Please specify a skill to star (e.g., happyskills star acme/deploy-aws).')
|
|
33
|
+
}
|
|
34
|
+
if (!skill.includes('/')) {
|
|
35
|
+
throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await require_token()
|
|
39
|
+
|
|
40
|
+
const [owner, name] = skill.split('/')
|
|
41
|
+
const [errors, result] = await star(owner, name)
|
|
42
|
+
if (errors) throw errors[errors.length - 1]
|
|
43
|
+
|
|
44
|
+
if (args.flags.json) {
|
|
45
|
+
print_json({ data: { starred: true, skill, ...(result || {}) } })
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
print_success(`Starred "${skill}". Browse your favorites with: happyskills search --favorites`)
|
|
50
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
51
|
+
|
|
52
|
+
const schema = {
|
|
53
|
+
name: 'star',
|
|
54
|
+
audience: 'consumer',
|
|
55
|
+
purpose: 'Star a skill. Starred skills power the favorites search scope. Idempotent.',
|
|
56
|
+
mutation: true,
|
|
57
|
+
interactive_in_text_mode: false,
|
|
58
|
+
input: {
|
|
59
|
+
positional: [
|
|
60
|
+
{ name: 'skill', required: true, type: 'string', pattern: '<owner>/<name>' },
|
|
61
|
+
],
|
|
62
|
+
flags: [
|
|
63
|
+
{ name: 'json', type: 'boolean', default: false },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
output: {
|
|
67
|
+
data_shape: {
|
|
68
|
+
starred: 'boolean',
|
|
69
|
+
skill: 'string',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
errors: [
|
|
73
|
+
{ code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
|
|
74
|
+
{ code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
|
|
75
|
+
],
|
|
76
|
+
examples: [
|
|
77
|
+
'happyskills star acme/deploy-aws',
|
|
78
|
+
'happyskills star acme/deploy-aws --json',
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { run, schema }
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { unstar } = require('../api/repos')
|
|
3
|
+
const { require_token } = require('../auth/token_store')
|
|
4
|
+
const { print_help, print_json, print_success } = require('../ui/output')
|
|
5
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
6
|
+
const { EXIT_CODES } = require('../constants')
|
|
7
|
+
|
|
8
|
+
const HELP_TEXT = `Usage: happyskills unstar <owner/name> [options]
|
|
9
|
+
|
|
10
|
+
Remove a skill from your favorites. Idempotent: unstarring a skill you have
|
|
11
|
+
not starred succeeds without error.
|
|
12
|
+
|
|
13
|
+
Arguments:
|
|
14
|
+
owner/name Skill to unstar (e.g., acme/deploy-aws)
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--json Output as JSON
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
happyskills unstar acme/deploy-aws
|
|
21
|
+
happyskills unstar acme/deploy-aws --json`
|
|
22
|
+
|
|
23
|
+
const run = (args) => catch_errors('Unstar failed', async () => {
|
|
24
|
+
if (args.flags._show_help) {
|
|
25
|
+
print_help(HELP_TEXT)
|
|
26
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const skill = args._[0]
|
|
30
|
+
if (!skill) {
|
|
31
|
+
throw new UsageError('Please specify a skill to unstar (e.g., happyskills unstar acme/deploy-aws).')
|
|
32
|
+
}
|
|
33
|
+
if (!skill.includes('/')) {
|
|
34
|
+
throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await require_token()
|
|
38
|
+
|
|
39
|
+
const [owner, name] = skill.split('/')
|
|
40
|
+
const [errors, result] = await unstar(owner, name)
|
|
41
|
+
if (errors) throw errors[errors.length - 1]
|
|
42
|
+
|
|
43
|
+
if (args.flags.json) {
|
|
44
|
+
print_json({ data: { starred: false, skill, ...(result || {}) } })
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
print_success(`Unstarred "${skill}".`)
|
|
49
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
50
|
+
|
|
51
|
+
const schema = {
|
|
52
|
+
name: 'unstar',
|
|
53
|
+
audience: 'consumer',
|
|
54
|
+
purpose: 'Remove a skill from your favorites (unstar). Idempotent.',
|
|
55
|
+
mutation: true,
|
|
56
|
+
interactive_in_text_mode: false,
|
|
57
|
+
input: {
|
|
58
|
+
positional: [
|
|
59
|
+
{ name: 'skill', required: true, type: 'string', pattern: '<owner>/<name>' },
|
|
60
|
+
],
|
|
61
|
+
flags: [
|
|
62
|
+
{ name: 'json', type: 'boolean', default: false },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
output: {
|
|
66
|
+
data_shape: {
|
|
67
|
+
starred: 'boolean',
|
|
68
|
+
skill: 'string',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
errors: [
|
|
72
|
+
{ code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
|
|
73
|
+
{ code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
|
|
74
|
+
],
|
|
75
|
+
examples: [
|
|
76
|
+
'happyskills unstar acme/deploy-aws',
|
|
77
|
+
'happyskills unstar acme/deploy-aws --json',
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { run, schema }
|
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/constants.js
CHANGED
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 }
|
package/src/index.js
CHANGED
|
@@ -103,6 +103,8 @@ Commands:
|
|
|
103
103
|
visibility <owner/skill> Check or set visibility (alias: vis)
|
|
104
104
|
list List installed skills (alias: ls)
|
|
105
105
|
search <query> Search the registry (alias: s)
|
|
106
|
+
star <owner/skill> Star a skill (add to your favorites)
|
|
107
|
+
unstar <owner/skill> Remove a skill from your favorites
|
|
106
108
|
postlex Apply deterministic rerank finalization (agent-only; see happyskills-help skill)
|
|
107
109
|
versions <owner/skill> List all published versions of a skill
|
|
108
110
|
changelog <owner/skill> Print a skill's CHANGELOG.md
|
|
@@ -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)', () => {
|