skillspub 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/catalog.js +352 -0
- package/dist/cli.js +1562 -0
- package/dist/core.js +31 -0
- package/dist/explain.js +307 -0
- package/dist/harnesses/claude.js +95 -0
- package/dist/harnesses/grok.js +746 -0
- package/dist/harnesses/pi.js +1380 -0
- package/dist/harnesses/registry.js +30 -0
- package/dist/harnesses/target.js +5 -0
- package/dist/harnesses/types.js +1 -0
- package/dist/inventory.js +1437 -0
- package/dist/npx-skills.js +273 -0
- package/dist/reconcile.js +1014 -0
- package/dist/shared.js +1411 -0
- package/dist/source-verification.js +297 -0
- package/dist/targets/shared.js +13 -0
- package/dist/tui.js +2373 -0
- package/dist/view.js +321 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AlligatorT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SkillsPub
|
|
2
|
+
|
|
3
|
+
SkillsPub switches Agent Skills on and off across AI coding harnesses — Pi, Claude Code, and Grok Build — from one place. It reads the disk as the source of truth, shows what each harness will pick up on its next load, and prints an immutable preview before any change is written.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 22.20 or newer
|
|
8
|
+
|
|
9
|
+
## Compatibility
|
|
10
|
+
|
|
11
|
+
Remote Source operations go through one pinned Vercel `skills` release (`skills@1.5.21`), so release behavior does not drift with upstream `latest`.
|
|
12
|
+
|
|
13
|
+
Harness support levels are enforced by `npm run audit:release`. The verified-version evidence each adapter was accepted against:
|
|
14
|
+
|
|
15
|
+
| Harness | Support | Verified against |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| Pi | `managed` | v0.85.1 |
|
|
18
|
+
| Claude Code | `managed` | docs snapshot 2026-08-12 |
|
|
19
|
+
| Grok Build | `managed/excluded` | settings reference @ `19d42e35` |
|
|
20
|
+
|
|
21
|
+
`discoverable` candidates (Codex, OpenCode, Kimi Code, TraeCode) are observed only and carry no verified-version claim.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install --global skillspub
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or without a global install: `npx skillspub targets`.
|
|
30
|
+
|
|
31
|
+
## First run
|
|
32
|
+
|
|
33
|
+
There is no setup step. SkillsPub discovers installed skills and harnesses by scanning the disk, so open the TUI and everything is already there:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
skillspub
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
All browsing is read-only; nothing changes without an explicit confirmed plan. One optional configuration exists: Pi's Shared-skills isolation, applied only when you ask for it:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
skillspub harnesses pi setup # prints a preview; rerun with --yes to apply
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Claude Code and Grok Build need no setup.
|
|
46
|
+
|
|
47
|
+
## TUI
|
|
48
|
+
|
|
49
|
+
`skillspub` in a terminal opens the full-screen browser (same as `skillspub tui`; add `--project [path]` for a project-scope view). It is keyboard-driven: browse the skill × Target matrix, toggle skills, manage Tags, Bundles, and Presets, and run the Source lifecycle (find, add, update, remove) with preview → confirm → verify for every mutation.
|
|
50
|
+
|
|
51
|
+
## CLI
|
|
52
|
+
|
|
53
|
+
Everything the TUI does is also a scriptable command:
|
|
54
|
+
|
|
55
|
+
| Group | Commands |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Inspect | `scan` · `ls` · `status <skill>` · `explain <skill>` · `targets` · `doctor` |
|
|
58
|
+
| Toggle | `on\|off <selector> <target...> [--yes]` |
|
|
59
|
+
| Organize | `tag` · `bundle` · `preset` (persistent always-on policy) |
|
|
60
|
+
| Sources | `shared find\|describe\|refresh\|outdated\|add\|update\|remove` |
|
|
61
|
+
| Harnesses | `harnesses [name inspect\|setup\|reconcile [--yes]]` |
|
|
62
|
+
| Project scope | prefix any of the above with `project <exact-path>` |
|
|
63
|
+
|
|
64
|
+
Every mutation prints its plan first and applies only with `--yes`.
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
npm ci
|
|
70
|
+
npm run typecheck
|
|
71
|
+
npm test
|
|
72
|
+
npm run build
|
|
73
|
+
npm run audit:release
|
|
74
|
+
npm run check:package
|
|
75
|
+
npm pack --ignore-scripts
|
|
76
|
+
npm run check:package -- ./skillspub-0.1.0.tgz
|
|
77
|
+
npm pack --dry-run --ignore-scripts
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`check:package` packs twice, verifies byte-for-byte deterministic tarballs and the exact file allowlist, installs one tarball into a clean temporary prefix, then runs the installed CLI and a read-only JSON command. Pass a candidate tarball path to require a byte-for-byte match with the verified package.
|
|
81
|
+
|
|
82
|
+
Release prerequisites and the cutover checklist are in [`docs/releasing.md`](docs/releasing.md).
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT © 2026 AlligatorT
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readStateFile, writeStateFile, } from "./inventory.js";
|
|
4
|
+
export function statePath(home) {
|
|
5
|
+
return path.join(home.configDir, 'state.json');
|
|
6
|
+
}
|
|
7
|
+
export function readState(home) {
|
|
8
|
+
return readStateFile(statePath(home));
|
|
9
|
+
}
|
|
10
|
+
export function writeState(home, state) {
|
|
11
|
+
writeStateFile(statePath(home), state);
|
|
12
|
+
}
|
|
13
|
+
export function readBundles(state) {
|
|
14
|
+
const value = state.bundles ?? {};
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
16
|
+
Object.values(value).some((members) => !Array.isArray(members) || members.some((member) => typeof member !== 'string')))
|
|
17
|
+
throw new Error('invalid state bundles');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
export function readTags(state) {
|
|
21
|
+
const value = state.tags ?? {};
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
23
|
+
Object.values(value).some((tags) => !Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string')))
|
|
24
|
+
throw new Error('invalid state tags');
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function resources(state) {
|
|
28
|
+
return state.targetInventory?.resources ?? {};
|
|
29
|
+
}
|
|
30
|
+
function assertManualBundle(name) {
|
|
31
|
+
if (!name || name.startsWith('repo:'))
|
|
32
|
+
throw new Error('manual bundle names must be non-empty and cannot start with repo:');
|
|
33
|
+
}
|
|
34
|
+
function resourceId(selector, state, existingMembers = []) {
|
|
35
|
+
const value = selector.startsWith('skill:') ? selector.slice('skill:'.length) : selector;
|
|
36
|
+
if (!value)
|
|
37
|
+
throw new Error(`invalid skill selector: ${selector}`);
|
|
38
|
+
if (existingMembers.includes(value))
|
|
39
|
+
return value;
|
|
40
|
+
const entries = Object.entries(resources(state));
|
|
41
|
+
if (resources(state)[value])
|
|
42
|
+
return value;
|
|
43
|
+
const matches = entries.filter(([, metadata]) => metadata.name === value);
|
|
44
|
+
if (matches.length === 1)
|
|
45
|
+
return matches[0][0];
|
|
46
|
+
if (matches.length > 1) {
|
|
47
|
+
throw new Error(`skill name "${value}" is ambiguous:\n${matches
|
|
48
|
+
.map(([id, metadata]) => ` - ${metadata.name}: skill:${id}`)
|
|
49
|
+
.join('\n')}\nUse one of the explicit selectors above.`);
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`skill not found: ${selector}; run skillspub scan first`);
|
|
52
|
+
}
|
|
53
|
+
export function listBundles(home) {
|
|
54
|
+
const bundles = readBundles(readState(home));
|
|
55
|
+
return Object.entries(bundles)
|
|
56
|
+
.map(([name, members]) => ({ name, members: members.length }))
|
|
57
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
58
|
+
}
|
|
59
|
+
export function showBundle(home, name) {
|
|
60
|
+
const state = readState(home);
|
|
61
|
+
const members = readBundles(state)[name];
|
|
62
|
+
if (!members)
|
|
63
|
+
throw new Error(`unknown bundle: ${name}`);
|
|
64
|
+
const current = resources(state);
|
|
65
|
+
return members.map((id) => ({
|
|
66
|
+
id,
|
|
67
|
+
name: current[id]?.name,
|
|
68
|
+
stale: current[id] === undefined || !fs.existsSync(id),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
export function createBundle(home, name, selectors) {
|
|
72
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
73
|
+
operation: 'bundle.create', name, selectors,
|
|
74
|
+
});
|
|
75
|
+
applyCatalogMutation(home, plan);
|
|
76
|
+
return plan.changed;
|
|
77
|
+
}
|
|
78
|
+
export function addBundleMembers(home, name, selectors) {
|
|
79
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
80
|
+
operation: 'bundle.add', name, selectors,
|
|
81
|
+
});
|
|
82
|
+
applyCatalogMutation(home, plan);
|
|
83
|
+
return plan.changed;
|
|
84
|
+
}
|
|
85
|
+
export function removeBundleMembers(home, name, selectors) {
|
|
86
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
87
|
+
operation: 'bundle.rm', name, selectors: selectors ?? [],
|
|
88
|
+
});
|
|
89
|
+
applyCatalogMutation(home, plan);
|
|
90
|
+
return selectors?.length ? plan.changed : undefined;
|
|
91
|
+
}
|
|
92
|
+
function assertTagNames(tags) {
|
|
93
|
+
if (tags.length === 0 || tags.some((tag) => !tag))
|
|
94
|
+
throw new Error('at least one non-empty tag is required');
|
|
95
|
+
}
|
|
96
|
+
export function addResourceTags(home, selector, names) {
|
|
97
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
98
|
+
operation: 'tag.add', resource: selector, names,
|
|
99
|
+
});
|
|
100
|
+
applyCatalogMutation(home, plan);
|
|
101
|
+
return plan.changed;
|
|
102
|
+
}
|
|
103
|
+
export function removeResourceTags(home, selector, names) {
|
|
104
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
105
|
+
operation: 'tag.rm', resource: selector, names: names ?? [],
|
|
106
|
+
});
|
|
107
|
+
applyCatalogMutation(home, plan);
|
|
108
|
+
return plan.changed;
|
|
109
|
+
}
|
|
110
|
+
export function listTags(home) {
|
|
111
|
+
const counts = new Map();
|
|
112
|
+
for (const names of Object.values(readTags(readState(home))))
|
|
113
|
+
for (const name of names)
|
|
114
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
115
|
+
return [...counts].map(([name, resources]) => ({ name, resources }))
|
|
116
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
117
|
+
}
|
|
118
|
+
export function tagsForResource(home, selector) {
|
|
119
|
+
const state = readState(home);
|
|
120
|
+
const tags = readTags(state);
|
|
121
|
+
const id = resourceId(selector, state, Object.keys(tags));
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
name: resources(state)[id]?.name,
|
|
125
|
+
tags: tags[id] ?? [],
|
|
126
|
+
stale: resources(state)[id] === undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function readPresets(state) {
|
|
130
|
+
const value = state.presets ?? {};
|
|
131
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
132
|
+
throw new Error('invalid state presets');
|
|
133
|
+
for (const preset of Object.values(value)) {
|
|
134
|
+
if (!preset || typeof preset !== 'object' || Array.isArray(preset) ||
|
|
135
|
+
!Array.isArray(preset.selectors) ||
|
|
136
|
+
preset.selectors.some((selector) => typeof selector !== 'string'))
|
|
137
|
+
throw new Error('invalid state presets');
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
export function assertPresetName(name) {
|
|
142
|
+
if (!name)
|
|
143
|
+
throw new Error('preset name must be non-empty');
|
|
144
|
+
}
|
|
145
|
+
function normalizePresetSelector(selector, state) {
|
|
146
|
+
if (selector.startsWith('bundle:') || selector.startsWith('tag:'))
|
|
147
|
+
return selector;
|
|
148
|
+
if (selector.startsWith('skill:') || !selector.includes(':'))
|
|
149
|
+
return `skill:${resourceId(selector, state)}`;
|
|
150
|
+
throw new Error(`invalid Preset selector: ${selector}`);
|
|
151
|
+
}
|
|
152
|
+
export function listPresets(home) {
|
|
153
|
+
return Object.entries(readPresets(readState(home)))
|
|
154
|
+
.map(([name, preset]) => ({ name, selectors: preset.selectors.length }))
|
|
155
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
156
|
+
}
|
|
157
|
+
export function showPreset(home, name) {
|
|
158
|
+
const preset = readPresets(readState(home))[name];
|
|
159
|
+
if (!preset)
|
|
160
|
+
throw new Error(`unknown preset: ${name}`);
|
|
161
|
+
return preset.selectors.map((selector) => ({ selector }));
|
|
162
|
+
}
|
|
163
|
+
export function createPreset(home, name, selectors) {
|
|
164
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
165
|
+
operation: 'preset.create', name, selectors,
|
|
166
|
+
});
|
|
167
|
+
applyCatalogMutation(home, plan);
|
|
168
|
+
return plan.changed;
|
|
169
|
+
}
|
|
170
|
+
export function addPresetSelectors(home, name, selectors) {
|
|
171
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
172
|
+
operation: 'preset.add', name, selectors,
|
|
173
|
+
});
|
|
174
|
+
applyCatalogMutation(home, plan);
|
|
175
|
+
return plan.changed;
|
|
176
|
+
}
|
|
177
|
+
export function removePresetSelectors(home, name, selectors) {
|
|
178
|
+
const plan = planCatalogMutation(home, undefined, {
|
|
179
|
+
operation: 'preset.rm', name, selectors: selectors ?? [],
|
|
180
|
+
});
|
|
181
|
+
applyCatalogMutation(home, plan);
|
|
182
|
+
return plan.changed;
|
|
183
|
+
}
|
|
184
|
+
function catalogPlanningState(home, report) {
|
|
185
|
+
const state = readState(home);
|
|
186
|
+
if (!report)
|
|
187
|
+
return state;
|
|
188
|
+
return {
|
|
189
|
+
...state,
|
|
190
|
+
targetInventory: {
|
|
191
|
+
resources: Object.fromEntries(report.resources.map(({ id, name }) => [id, { name }])),
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function planCatalogMutation(home, report, mutation) {
|
|
196
|
+
const state = catalogPlanningState(home, report);
|
|
197
|
+
if (mutation.operation === 'bundle.create' ||
|
|
198
|
+
mutation.operation === 'bundle.add' || mutation.operation === 'bundle.rm') {
|
|
199
|
+
assertManualBundle(mutation.name);
|
|
200
|
+
const expected = readBundles(state);
|
|
201
|
+
const current = expected[mutation.name];
|
|
202
|
+
let members;
|
|
203
|
+
if (mutation.operation === 'bundle.create') {
|
|
204
|
+
if (current)
|
|
205
|
+
throw new Error(`bundle already exists: ${mutation.name}`);
|
|
206
|
+
members = [...new Set(mutation.selectors.map((selector) => resourceId(selector, state)))]
|
|
207
|
+
.sort((a, b) => a.localeCompare(b));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
if (!current)
|
|
211
|
+
throw new Error(`unknown bundle: ${mutation.name}`);
|
|
212
|
+
if (mutation.operation === 'bundle.add') {
|
|
213
|
+
if (mutation.selectors.length === 0)
|
|
214
|
+
throw new Error('bundle add requires at least one skill selector');
|
|
215
|
+
members = [...new Set([
|
|
216
|
+
...current,
|
|
217
|
+
...mutation.selectors.map((selector) => resourceId(selector, state)),
|
|
218
|
+
])].sort((a, b) => a.localeCompare(b));
|
|
219
|
+
}
|
|
220
|
+
else if (mutation.selectors.length > 0) {
|
|
221
|
+
const removed = new Set(mutation.selectors.map((selector) => resourceId(selector, state, current)));
|
|
222
|
+
members = current.filter((member) => !removed.has(member));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const { [mutation.name]: _, ...remaining } = expected;
|
|
226
|
+
const next = members === undefined
|
|
227
|
+
? remaining
|
|
228
|
+
: { ...expected, [mutation.name]: members };
|
|
229
|
+
return {
|
|
230
|
+
operation: mutation.operation,
|
|
231
|
+
collection: 'bundles',
|
|
232
|
+
expected,
|
|
233
|
+
next,
|
|
234
|
+
changed: current ? Math.abs(current.length - (members?.length ?? 0)) : members?.length ?? 0,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (mutation.operation === 'tag.add' || mutation.operation === 'tag.rm') {
|
|
238
|
+
if (mutation.operation === 'tag.add')
|
|
239
|
+
assertTagNames(mutation.names);
|
|
240
|
+
else if (mutation.names.some((name) => !name))
|
|
241
|
+
throw new Error('tags must be non-empty');
|
|
242
|
+
const expected = readTags(state);
|
|
243
|
+
const id = resourceId(mutation.resource, state, Object.keys(expected));
|
|
244
|
+
const current = expected[id] ?? [];
|
|
245
|
+
const names = mutation.operation === 'tag.add'
|
|
246
|
+
? [...new Set([...current, ...mutation.names])].sort((a, b) => a.localeCompare(b))
|
|
247
|
+
: current.filter((name) => !(mutation.names.length > 0
|
|
248
|
+
? new Set(mutation.names)
|
|
249
|
+
: new Set(current)).has(name));
|
|
250
|
+
const { [id]: _, ...remaining } = expected;
|
|
251
|
+
const next = names.length > 0 ? { ...remaining, [id]: names } : remaining;
|
|
252
|
+
return {
|
|
253
|
+
operation: mutation.operation,
|
|
254
|
+
collection: 'tags',
|
|
255
|
+
expected,
|
|
256
|
+
next,
|
|
257
|
+
changed: Math.abs(current.length - names.length),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
assertPresetName(mutation.name);
|
|
261
|
+
const expected = readPresets(state);
|
|
262
|
+
const current = expected[mutation.name];
|
|
263
|
+
let selectors;
|
|
264
|
+
if (mutation.operation === 'preset.create') {
|
|
265
|
+
if (current)
|
|
266
|
+
throw new Error(`preset already exists: ${mutation.name}`);
|
|
267
|
+
selectors = [...new Set(mutation.selectors.map((selector) => normalizePresetSelector(selector, state)))].sort((a, b) => a.localeCompare(b));
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
if (!current)
|
|
271
|
+
throw new Error(`unknown preset: ${mutation.name}`);
|
|
272
|
+
if (mutation.operation === 'preset.add') {
|
|
273
|
+
if (mutation.selectors.length === 0)
|
|
274
|
+
throw new Error('preset add requires at least one selector');
|
|
275
|
+
selectors = [...new Set([
|
|
276
|
+
...current.selectors,
|
|
277
|
+
...mutation.selectors.map((selector) => normalizePresetSelector(selector, state)),
|
|
278
|
+
])].sort((a, b) => a.localeCompare(b));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
if (mutation.selectors.length === 0)
|
|
282
|
+
throw new Error('removing a Preset definition requires: skillspub preset delete <name> [--yes]');
|
|
283
|
+
const removed = new Set(mutation.selectors.map((selector) => {
|
|
284
|
+
try {
|
|
285
|
+
return normalizePresetSelector(selector, state);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return selector;
|
|
289
|
+
}
|
|
290
|
+
}));
|
|
291
|
+
selectors = current.selectors.filter((selector) => !removed.has(selector));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const next = { ...expected, [mutation.name]: { selectors } };
|
|
295
|
+
return {
|
|
296
|
+
operation: mutation.operation,
|
|
297
|
+
collection: 'presets',
|
|
298
|
+
expected,
|
|
299
|
+
next,
|
|
300
|
+
changed: current ? Math.abs(current.selectors.length - selectors.length) : selectors.length,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
export function applyCatalogMutation(home, plan) {
|
|
304
|
+
const state = readState(home);
|
|
305
|
+
const current = plan.collection === 'bundles'
|
|
306
|
+
? readBundles(state)
|
|
307
|
+
: plan.collection === 'tags'
|
|
308
|
+
? readTags(state)
|
|
309
|
+
: readPresets(state);
|
|
310
|
+
if (JSON.stringify(current) !== JSON.stringify(plan.expected))
|
|
311
|
+
throw Object.assign(new Error(`${plan.collection} changed after preview; preview again`), { code: 'concurrent_modification' });
|
|
312
|
+
if (plan.collection === 'bundles')
|
|
313
|
+
state.bundles = plan.next;
|
|
314
|
+
else if (plan.collection === 'tags')
|
|
315
|
+
state.tags = plan.next;
|
|
316
|
+
else
|
|
317
|
+
state.presets = plan.next;
|
|
318
|
+
writeState(home, state);
|
|
319
|
+
const written = readState(home)[plan.collection] ?? {};
|
|
320
|
+
if (JSON.stringify(written) !== JSON.stringify(plan.next))
|
|
321
|
+
throw Object.assign(new Error(`${plan.collection} verification failed`), { code: 'apply_failed', details: { partialEffects: 'unknown' } });
|
|
322
|
+
return { changed: plan.changed };
|
|
323
|
+
}
|
|
324
|
+
export function expandSelector(home, selector, report) {
|
|
325
|
+
const state = readState(home);
|
|
326
|
+
let resourceIds;
|
|
327
|
+
if (selector.startsWith('bundle:')) {
|
|
328
|
+
const name = selector.slice('bundle:'.length);
|
|
329
|
+
const members = readBundles(state)[name];
|
|
330
|
+
if (!members)
|
|
331
|
+
throw new Error(`unknown bundle: ${name}`);
|
|
332
|
+
resourceIds = members;
|
|
333
|
+
}
|
|
334
|
+
else if (selector.startsWith('tag:')) {
|
|
335
|
+
const name = selector.slice('tag:'.length);
|
|
336
|
+
if (!name)
|
|
337
|
+
throw new Error(`invalid Tag selector: ${selector}`);
|
|
338
|
+
resourceIds = Object.entries(readTags(state))
|
|
339
|
+
.filter(([, tags]) => tags.includes(name))
|
|
340
|
+
.map(([id]) => id);
|
|
341
|
+
if (resourceIds.length === 0)
|
|
342
|
+
throw new Error(`unknown Tag: ${name}`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
resourceIds = [resourceId(selector, state)];
|
|
346
|
+
}
|
|
347
|
+
const live = new Set(report.resources.map(({ id }) => id));
|
|
348
|
+
return {
|
|
349
|
+
resourceIds: [...new Set(resourceIds)],
|
|
350
|
+
staleResourceIds: resourceIds.filter((id) => !live.has(id)),
|
|
351
|
+
};
|
|
352
|
+
}
|