happyskills 1.10.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/engine/uninstaller.js +33 -2
- package/src/engine/uninstaller.test.js +120 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.10.1] - 2026-06-09
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fix `uninstall` leaving deep transitive dependencies stranded. Orphan pruning ran a single pass, so removing a skill (or kit / constellation core) pruned its direct dependencies but left behind any *grandchild* dependency whose only parent was one of those now-pruned dependencies — it accumulated as unused cruft in `.agents/skills/` and the lock file. Pruning now cascades to a fixed point, removing the full chain of newly-orphaned dependencies. The data-loss guard is preserved: a dependency that any surviving skill still declares (e.g. a shared satellite like `happyskills-design`) is never pruned, and directly-installed (`__root__`) skills are never swept up.
|
|
15
|
+
|
|
10
16
|
## [1.10.0] - 2026-06-06
|
|
11
17
|
|
|
12
18
|
### Added
|
package/package.json
CHANGED
|
@@ -36,6 +36,37 @@ const find_orphans = (skills, removed_skill) => {
|
|
|
36
36
|
return orphans
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Cascading orphan detection. `find_orphans` is single-pass by design (its unit
|
|
40
|
+
// contract): it only reports the DIRECT orphans of `removed_skill`. But pruning a
|
|
41
|
+
// direct orphan A can in turn strand A's own private sub-dependency B — a grandchild
|
|
42
|
+
// that nothing else needs once A is gone. A single pass leaves B behind because A is
|
|
43
|
+
// still in the map (so A both counts as a live requester of B and, via its
|
|
44
|
+
// `dependencies`, as a "surviving declarer" of B).
|
|
45
|
+
//
|
|
46
|
+
// This wrapper closes that gap WITHOUT weakening the data-loss guard. It runs
|
|
47
|
+
// `find_orphans` to a fixed point over a shrinking working copy: each round, the
|
|
48
|
+
// orphans found are physically removed from the map, so the next round sees them as
|
|
49
|
+
// gone — both as requesters (`skills[r]` is now false) and as declarers
|
|
50
|
+
// (`declared_by_survivor` no longer finds them). A skill that ANY surviving skill
|
|
51
|
+
// still declares is never pruned, exactly as before — only skills whose entire
|
|
52
|
+
// requirement chain has been removed cascade out. Terminates because every round
|
|
53
|
+
// deletes at least one entry from a finite map.
|
|
54
|
+
const find_orphans_cascading = (skills, removed_skill) => {
|
|
55
|
+
const working = { ...skills }
|
|
56
|
+
delete working[removed_skill]
|
|
57
|
+
|
|
58
|
+
const all_orphans = []
|
|
59
|
+
let round = find_orphans(working, removed_skill)
|
|
60
|
+
while (round.length > 0) {
|
|
61
|
+
for (const name of round) {
|
|
62
|
+
all_orphans.push(name)
|
|
63
|
+
delete working[name]
|
|
64
|
+
}
|
|
65
|
+
round = find_orphans(working, removed_skill)
|
|
66
|
+
}
|
|
67
|
+
return all_orphans
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', async () => {
|
|
40
71
|
const { global: is_global = false, project_root, agents: agents_flag } = options
|
|
41
72
|
|
|
@@ -64,7 +95,7 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
64
95
|
}
|
|
65
96
|
}
|
|
66
97
|
|
|
67
|
-
const orphans =
|
|
98
|
+
const orphans = find_orphans_cascading(all_skills, skill)
|
|
68
99
|
const to_remove = [skill, ...orphans]
|
|
69
100
|
|
|
70
101
|
for (const name of to_remove) {
|
|
@@ -97,4 +128,4 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
|
|
|
97
128
|
return { removed: to_remove, orphans_pruned: orphans }
|
|
98
129
|
})
|
|
99
130
|
|
|
100
|
-
module.exports = { uninstall, find_orphans }
|
|
131
|
+
module.exports = { uninstall, find_orphans, find_orphans_cascading }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { describe, it } = require('node:test')
|
|
2
2
|
const assert = require('node:assert')
|
|
3
|
-
const { find_orphans } = require('./uninstaller')
|
|
3
|
+
const { find_orphans, find_orphans_cascading } = require('./uninstaller')
|
|
4
4
|
|
|
5
5
|
describe('find_orphans', () => {
|
|
6
6
|
it('returns empty when all skills have requesters in the skills map', () => {
|
|
@@ -134,3 +134,122 @@ describe('find_orphans', () => {
|
|
|
134
134
|
assert.ok(result.includes('happyskillsai/happyskills-design'))
|
|
135
135
|
})
|
|
136
136
|
})
|
|
137
|
+
|
|
138
|
+
describe('find_orphans_cascading', () => {
|
|
139
|
+
it('cascades: prunes a grandchild stranded once its only parent is orphaned', () => {
|
|
140
|
+
// Kit K -> A -> B. B is A's private sub-dependency (not listed directly by the
|
|
141
|
+
// kit). Uninstalling K must prune A (direct orphan) AND B (cascaded orphan).
|
|
142
|
+
// This is the exact case single-pass find_orphans leaves stranded.
|
|
143
|
+
const skills = {
|
|
144
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
145
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
146
|
+
'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} }
|
|
147
|
+
}
|
|
148
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
149
|
+
assert.ok(result.includes('acme/a'), 'direct orphan A should be pruned')
|
|
150
|
+
assert.ok(result.includes('acme/b'), 'cascaded orphan B should be pruned')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('still protects a shared dependency a surviving skill declares (no over-pruning under cascade)', () => {
|
|
154
|
+
// The data-loss guard must survive the cascade: design is declared by the
|
|
155
|
+
// surviving happyskills, so uninstalling create-release-skill must NOT prune it
|
|
156
|
+
// even though we now iterate to a fixed point.
|
|
157
|
+
const skills = {
|
|
158
|
+
'happyskillsai/happyskills': {
|
|
159
|
+
requested_by: ['__root__'],
|
|
160
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.1.0' }
|
|
161
|
+
},
|
|
162
|
+
'nicolasdao/create-release-skill': {
|
|
163
|
+
requested_by: ['__root__'],
|
|
164
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
165
|
+
},
|
|
166
|
+
'happyskillsai/happyskills-design': {
|
|
167
|
+
requested_by: ['nicolasdao/create-release-skill']
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const result = find_orphans_cascading(skills, 'nicolasdao/create-release-skill')
|
|
171
|
+
assert.ok(!result.includes('happyskillsai/happyskills-design'))
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('never prunes a user-installed (__root__) skill, even mid-cascade', () => {
|
|
175
|
+
// K -> A, but A is ALSO directly installed by the user. Removing K orphans
|
|
176
|
+
// nothing the user still wants: A is __root__-anchored and stays.
|
|
177
|
+
const skills = {
|
|
178
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
179
|
+
'acme/a': { requested_by: ['__root__', 'acme/_kit-x'], dependencies: {} }
|
|
180
|
+
}
|
|
181
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
182
|
+
assert.deepStrictEqual(result, [])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('halts the cascade at a still-shared mid-chain dependency', () => {
|
|
186
|
+
// K -> A -> B, but B is ALSO required by an unrelated surviving skill S.
|
|
187
|
+
// Removing K prunes A, but B must stay because S still needs it.
|
|
188
|
+
const skills = {
|
|
189
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
|
|
190
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
191
|
+
'acme/s': { requested_by: ['__root__'], dependencies: { 'acme/b': '^1.0.0' } },
|
|
192
|
+
'acme/b': { requested_by: ['acme/_kit-x', 'acme/s'], dependencies: {} }
|
|
193
|
+
}
|
|
194
|
+
const result = find_orphans_cascading(skills, 'acme/_kit-x')
|
|
195
|
+
assert.ok(result.includes('acme/a'), 'A is orphaned')
|
|
196
|
+
assert.ok(!result.includes('acme/b'), 'B is still needed by surviving acme/s')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('constellation: uninstalling the core prunes bundled satellites, keeps shared + opt-in ones', () => {
|
|
200
|
+
// Models the real happyskills constellation: the core declares 5 bundled
|
|
201
|
+
// satellites as dependencies. happyskills-design is ALSO declared by a surviving
|
|
202
|
+
// skill (create-release-skill). collab + stats are OPT-IN — installed directly
|
|
203
|
+
// (__root__), never dependencies of the core.
|
|
204
|
+
const skills = {
|
|
205
|
+
'happyskillsai/happyskills': {
|
|
206
|
+
requested_by: ['__root__'],
|
|
207
|
+
dependencies: {
|
|
208
|
+
'happyskillsai/happyskills-design': '^0.1.0',
|
|
209
|
+
'happyskillsai/happyskills-publish': '^0.1.0',
|
|
210
|
+
'happyskillsai/happyskills-sync': '^0.1.0',
|
|
211
|
+
'happyskillsai/happyskills-search': '^0.1.0',
|
|
212
|
+
'happyskillsai/happyskills-help': '^0.1.0'
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
'nicolasdao/create-release-skill': {
|
|
216
|
+
requested_by: ['__root__'],
|
|
217
|
+
dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
|
|
218
|
+
},
|
|
219
|
+
'happyskillsai/happyskills-design': { requested_by: ['happyskillsai/happyskills', 'nicolasdao/create-release-skill'], dependencies: {} },
|
|
220
|
+
'happyskillsai/happyskills-publish': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
221
|
+
'happyskillsai/happyskills-sync': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
222
|
+
'happyskillsai/happyskills-search': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
223
|
+
'happyskillsai/happyskills-help': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
|
|
224
|
+
'happyskillsai/happyskills-collab': { requested_by: ['__root__'], dependencies: {} },
|
|
225
|
+
'happyskillsai/happyskills-stats': { requested_by: ['__root__'], dependencies: {} }
|
|
226
|
+
}
|
|
227
|
+
const result = find_orphans_cascading(skills, 'happyskillsai/happyskills').sort()
|
|
228
|
+
assert.deepStrictEqual(result, [
|
|
229
|
+
'happyskillsai/happyskills-help',
|
|
230
|
+
'happyskillsai/happyskills-publish',
|
|
231
|
+
'happyskillsai/happyskills-search',
|
|
232
|
+
'happyskillsai/happyskills-sync'
|
|
233
|
+
])
|
|
234
|
+
// design is shared (create-release-skill still declares it) → kept
|
|
235
|
+
assert.ok(!result.includes('happyskillsai/happyskills-design'))
|
|
236
|
+
// collab + stats are opt-in (__root__) → never swept up by a core uninstall
|
|
237
|
+
assert.ok(!result.includes('happyskillsai/happyskills-collab'))
|
|
238
|
+
assert.ok(!result.includes('happyskillsai/happyskills-stats'))
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('matches find_orphans for a flat kit (no nested sub-dependencies)', () => {
|
|
242
|
+
// The common curated-kit case: K directly lists A, B, C and none depend on each
|
|
243
|
+
// other. Single-pass and cascading must agree — cascade changes nothing here.
|
|
244
|
+
const skills = {
|
|
245
|
+
'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1', 'acme/b': '^1', 'acme/c': '^1' } },
|
|
246
|
+
'acme/a': { requested_by: ['acme/_kit-x'], dependencies: {} },
|
|
247
|
+
'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} },
|
|
248
|
+
'acme/c': { requested_by: ['acme/_kit-x'], dependencies: {} }
|
|
249
|
+
}
|
|
250
|
+
const single = find_orphans(skills, 'acme/_kit-x').sort()
|
|
251
|
+
const cascaded = find_orphans_cascading(skills, 'acme/_kit-x').sort()
|
|
252
|
+
assert.deepStrictEqual(cascaded, ['acme/a', 'acme/b', 'acme/c'])
|
|
253
|
+
assert.deepStrictEqual(cascaded, single)
|
|
254
|
+
})
|
|
255
|
+
})
|