@vltpkg/graph 1.4.0 → 1.5.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/README.md +59 -3
- package/dist/actual/load.js +24 -12
- package/dist/build.js +30 -5
- package/dist/dependencies.d.ts +11 -0
- package/dist/dependencies.js +47 -7
- package/dist/filter-nodes-by-query.d.ts +39 -0
- package/dist/filter-nodes-by-query.js +100 -0
- package/dist/ideal/append-nodes.js +11 -6
- package/dist/ideal/get-importer-specs.js +17 -7
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/install.d.ts +23 -8
- package/dist/install.js +24 -1
- package/dist/lockfile/load-edges.js +10 -2
- package/dist/reify/build.d.ts +7 -0
- package/dist/reify/check-needed-build.d.ts +5 -0
- package/dist/reify/check-needed-build.js +13 -0
- package/dist/reify/index.js +4 -38
- package/dist/reify/update-importers-package-json.js +5 -9
- package/dist/skills/dirs.d.ts +58 -0
- package/dist/skills/dirs.js +211 -0
- package/dist/skills/discover.d.ts +73 -0
- package/dist/skills/discover.js +234 -0
- package/dist/skills/gitignore.d.ts +13 -0
- package/dist/skills/gitignore.js +53 -0
- package/dist/skills/index.d.ts +123 -0
- package/dist/skills/index.js +225 -0
- package/dist/skills/lazy.d.ts +8 -0
- package/dist/skills/lazy.js +8 -0
- package/dist/skills/mask.d.ts +26 -0
- package/dist/skills/mask.js +43 -0
- package/dist/skills/mounts.d.ts +64 -0
- package/dist/skills/mounts.js +136 -0
- package/dist/uninstall.d.ts +24 -7
- package/dist/uninstall.js +24 -1
- package/dist/update.d.ts +24 -4
- package/dist/update.js +24 -1
- package/dist/visualization/mermaid-output.d.ts +8 -0
- package/dist/visualization/mermaid-output.js +25 -8
- package/package.json +21 -21
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { splitDepID } from '@vltpkg/dep-id';
|
|
4
|
+
import { parse } from '@vltpkg/semver';
|
|
5
|
+
import { lstat, norm, packageKey, realOr } from "./mounts.js";
|
|
6
|
+
// safe single path segment, no dotfiles or scopes
|
|
7
|
+
const skillDirName = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
8
|
+
// agentskills.io `name`
|
|
9
|
+
const specName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const topKey = /^([A-Za-z0-9_-]+):/;
|
|
11
|
+
const blockIndicator = /^[>|][-+1-9]*$/;
|
|
12
|
+
const fold = (inline, more) => {
|
|
13
|
+
let value = [blockIndicator.test(inline) ? '' : inline, ...more]
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join(' ');
|
|
16
|
+
const q = value[0];
|
|
17
|
+
if (value.length > 1 &&
|
|
18
|
+
(q === '"' || q === "'") &&
|
|
19
|
+
value.endsWith(q)) {
|
|
20
|
+
value = value.slice(1, -1).trim();
|
|
21
|
+
}
|
|
22
|
+
return value || undefined;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Read `name` and `description` from a `SKILL.md` YAML frontmatter.
|
|
26
|
+
* Minimal line reader: top-level keys, plain/quoted/folded values.
|
|
27
|
+
*/
|
|
28
|
+
export const readFrontmatter = (file) => {
|
|
29
|
+
let text;
|
|
30
|
+
try {
|
|
31
|
+
text = readFileSync(file, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
const lines = text.split(/\r?\n/);
|
|
37
|
+
if (lines[0]?.trim() !== '---')
|
|
38
|
+
return {};
|
|
39
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
40
|
+
if (end === -1)
|
|
41
|
+
return {};
|
|
42
|
+
const fm = {};
|
|
43
|
+
let key;
|
|
44
|
+
let inline = '';
|
|
45
|
+
let more = [];
|
|
46
|
+
const flush = () => {
|
|
47
|
+
if (key)
|
|
48
|
+
fm[key] = fold(inline, more);
|
|
49
|
+
key = undefined;
|
|
50
|
+
};
|
|
51
|
+
for (const line of lines.slice(1, end)) {
|
|
52
|
+
if (!line.trim())
|
|
53
|
+
continue;
|
|
54
|
+
const m = topKey.exec(line);
|
|
55
|
+
if (m) {
|
|
56
|
+
flush();
|
|
57
|
+
const k = m[1];
|
|
58
|
+
if (k === 'name' || k === 'description') {
|
|
59
|
+
key = k;
|
|
60
|
+
inline = line.slice(k.length + 1).trim();
|
|
61
|
+
// drop a ` # comment` from unquoted values
|
|
62
|
+
const hash = inline.search(/(?:^|\s)#/);
|
|
63
|
+
if (hash !== -1 && !/^["']/.test(inline)) {
|
|
64
|
+
inline = inline.slice(0, hash).trim();
|
|
65
|
+
}
|
|
66
|
+
more = [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else if (/^\s/.test(line)) {
|
|
70
|
+
if (key)
|
|
71
|
+
more.push(line.trim());
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
flush();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
flush();
|
|
78
|
+
return fm;
|
|
79
|
+
};
|
|
80
|
+
// package-controlled text printed to terminals
|
|
81
|
+
const sanitize = (s) => {
|
|
82
|
+
const clean = s
|
|
83
|
+
?.replace(/\p{Cc}/gu, ' ')
|
|
84
|
+
.replace(/\s+/g, ' ')
|
|
85
|
+
.trim();
|
|
86
|
+
return clean ? clean.slice(0, 1024) : undefined;
|
|
87
|
+
};
|
|
88
|
+
const lc = (s) => s.toLowerCase();
|
|
89
|
+
const isDir = (path) => !!lstat(path)?.isDirectory();
|
|
90
|
+
const isFile = (path) => !!lstat(path)?.isFile();
|
|
91
|
+
// `skills/SKILL.md` would make the dir look like a skill
|
|
92
|
+
const isSkillMd = (name) => name.toLowerCase() === 'skill.md';
|
|
93
|
+
// where packages ship skills, in precedence order (a name found first
|
|
94
|
+
// wins), after a root SKILL.md
|
|
95
|
+
const shipDirs = ['skills', 'dist/skills', '.agents/skills'];
|
|
96
|
+
// a real dir, no symlink on the way: skills stay inside the package
|
|
97
|
+
const subdir = (dir, sub) => {
|
|
98
|
+
let path = dir;
|
|
99
|
+
for (const seg of sub.split('/')) {
|
|
100
|
+
path = resolve(path, seg);
|
|
101
|
+
if (!isDir(path))
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
return path;
|
|
105
|
+
};
|
|
106
|
+
const scan = (dir, pkgName) => {
|
|
107
|
+
const found = [];
|
|
108
|
+
const has = (skill) => found.some(f => lc(f.skill) === lc(skill));
|
|
109
|
+
const root = resolve(dir, 'SKILL.md');
|
|
110
|
+
if (isFile(root)) {
|
|
111
|
+
const { name, description } = readFrontmatter(root);
|
|
112
|
+
const skill = name && name.length <= 64 && specName.test(name) ?
|
|
113
|
+
name
|
|
114
|
+
: pkgName.slice(pkgName.lastIndexOf('/') + 1);
|
|
115
|
+
if (skillDirName.test(skill) && !isSkillMd(skill)) {
|
|
116
|
+
found.push({ skill, path: dir, name, description });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const sub of shipDirs) {
|
|
120
|
+
const skillsDir = subdir(dir, sub);
|
|
121
|
+
if (!skillsDir)
|
|
122
|
+
continue;
|
|
123
|
+
// one per name, any case
|
|
124
|
+
for (const d of readdirSync(skillsDir).sort()) {
|
|
125
|
+
if (!skillDirName.test(d) || isSkillMd(d) || has(d))
|
|
126
|
+
continue;
|
|
127
|
+
const path = resolve(skillsDir, d);
|
|
128
|
+
const file = resolve(path, 'SKILL.md');
|
|
129
|
+
if (!isDir(path) || !isFile(file))
|
|
130
|
+
continue;
|
|
131
|
+
const { name, description } = readFrontmatter(file);
|
|
132
|
+
found.push({ skill: d, path, name, description });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return found;
|
|
136
|
+
};
|
|
137
|
+
// 0: dep of the main importer, 1: of any importer, 2: transitive
|
|
138
|
+
const rank = (node) => {
|
|
139
|
+
let r = 2;
|
|
140
|
+
for (const { from } of node.edgesIn) {
|
|
141
|
+
if (from.mainImporter)
|
|
142
|
+
return 0;
|
|
143
|
+
if (from.importer)
|
|
144
|
+
r = 1;
|
|
145
|
+
}
|
|
146
|
+
return r;
|
|
147
|
+
};
|
|
148
|
+
const ver = (node) => node.version ? parse(node.version) : undefined;
|
|
149
|
+
// higher version first, if both valid
|
|
150
|
+
const byVersion = (a, b) => {
|
|
151
|
+
const va = ver(a);
|
|
152
|
+
const vb = ver(b);
|
|
153
|
+
return va && vb ? vb.compare(va) : 0;
|
|
154
|
+
};
|
|
155
|
+
const compare = (a, b) => rank(a) - rank(b) ||
|
|
156
|
+
byVersion(a, b) ||
|
|
157
|
+
a.id.localeCompare(b.id, 'en');
|
|
158
|
+
// the name a direct dep is installed under, if not its own
|
|
159
|
+
const aliasOf = (node) => {
|
|
160
|
+
const edges = [...node.edgesIn];
|
|
161
|
+
const n = (edges.find(e => e.from.mainImporter) ??
|
|
162
|
+
edges.find(e => e.from.importer))?.name;
|
|
163
|
+
return n === node.name ? undefined : n;
|
|
164
|
+
};
|
|
165
|
+
// store pkgs only: lockfile keeps a tarball's path as its location
|
|
166
|
+
const scannable = (node) => !node.importer &&
|
|
167
|
+
node.inVltStore() &&
|
|
168
|
+
splitDepID(node.id)[0] !== 'file';
|
|
169
|
+
/** Ships agent skills, as `discoverSkills` finds them. */
|
|
170
|
+
export const shipsSkills = (node) => scannable(node) &&
|
|
171
|
+
scan(realOr(resolve(node.projectRoot, node.location)), node.name)
|
|
172
|
+
.length > 0;
|
|
173
|
+
/**
|
|
174
|
+
* Find the agent skills shipped by the given installed packages.
|
|
175
|
+
*
|
|
176
|
+
* Only packages in the vlt store are scanned, for a root `SKILL.md`,
|
|
177
|
+
* then `<dir>/SKILL.md` in `skills/`, `dist/skills/` and
|
|
178
|
+
* `.agents/skills/`: the first skill of a name wins. One version per
|
|
179
|
+
* package is picked, even if it ships none: a dep of the main
|
|
180
|
+
* importer, else of any importer, else the highest version, then
|
|
181
|
+
* DepID. Same-name skills of different packages are all returned,
|
|
182
|
+
* sorted by rank then DepID (`linkSkills` resolves them). `mounts`
|
|
183
|
+
* set each skill's `linked` dirs.
|
|
184
|
+
*/
|
|
185
|
+
export const discoverSkills = (nodes, scurry, mounts = []) => {
|
|
186
|
+
const key = (name, path) => `${lc(name)}\0${norm(path)}`;
|
|
187
|
+
const linkedIn = new Map();
|
|
188
|
+
for (const m of mounts) {
|
|
189
|
+
const k = key(m.name, realOr(m.target));
|
|
190
|
+
const dirs = linkedIn.get(k) ?? [];
|
|
191
|
+
if (!dirs.includes(m.dir.dir))
|
|
192
|
+
dirs.push(m.dir.dir);
|
|
193
|
+
linkedIn.set(k, dirs);
|
|
194
|
+
}
|
|
195
|
+
const scanned = new Map();
|
|
196
|
+
const byPkg = new Map();
|
|
197
|
+
for (const node of nodes) {
|
|
198
|
+
if (!scannable(node))
|
|
199
|
+
continue;
|
|
200
|
+
const found = scan(realOr(node.resolvedLocation(scurry)), node.name);
|
|
201
|
+
scanned.set(node.id, found.map(({ skill, path }) => ({ skill, path })));
|
|
202
|
+
const key = packageKey(node.id);
|
|
203
|
+
const list = byPkg.get(key) ?? [];
|
|
204
|
+
list.push({ node, found });
|
|
205
|
+
byPkg.set(key, list);
|
|
206
|
+
}
|
|
207
|
+
const rows = [];
|
|
208
|
+
for (const list of byPkg.values()) {
|
|
209
|
+
const { node, found } = list.reduce((a, b) => compare(a.node, b.node) <= 0 ? a : b);
|
|
210
|
+
for (const { skill, path, name, description } of found) {
|
|
211
|
+
const fm = sanitize(name);
|
|
212
|
+
rows.push([
|
|
213
|
+
{
|
|
214
|
+
name: node.name,
|
|
215
|
+
alias: aliasOf(node),
|
|
216
|
+
version: node.version,
|
|
217
|
+
id: node.id,
|
|
218
|
+
skill,
|
|
219
|
+
...(fm && lc(fm) !== lc(skill) && { frontmatterName: fm }),
|
|
220
|
+
description: sanitize(description),
|
|
221
|
+
path,
|
|
222
|
+
linked: linkedIn.get(key(skill, path)) ?? [],
|
|
223
|
+
},
|
|
224
|
+
rank(node),
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const skills = rows
|
|
229
|
+
.sort(([a, ra], [b, rb]) => lc(a.skill).localeCompare(lc(b.skill), 'en') ||
|
|
230
|
+
ra - rb ||
|
|
231
|
+
a.id.localeCompare(b.id, 'en'))
|
|
232
|
+
.map(([s]) => s);
|
|
233
|
+
return { skills, scanned };
|
|
234
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const GITIGNORE_BEGIN = "# >>> vlt agent skills (links into node_modules)";
|
|
2
|
+
export declare const GITIGNORE_END = "# <<< vlt agent skills";
|
|
3
|
+
/**
|
|
4
|
+
* Keep a `/<name>` line per vlt link in a marked block of
|
|
5
|
+
* `<dir>/.gitignore`: add `add`, drop `remove`. Other lines are kept.
|
|
6
|
+
* Written only when changed, created only to add a line, never
|
|
7
|
+
* removed. `'refused'`: `.gitignore` is not a regular file, or an fs
|
|
8
|
+
* error.
|
|
9
|
+
*/
|
|
10
|
+
export declare const editGitignore: (dir: string, edit: {
|
|
11
|
+
add: string[];
|
|
12
|
+
remove: string[];
|
|
13
|
+
}) => "ok" | "refused";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const GITIGNORE_BEGIN = '# >>> vlt agent skills (links into node_modules)';
|
|
4
|
+
export const GITIGNORE_END = '# <<< vlt agent skills';
|
|
5
|
+
const sorted = (names) => [...names]
|
|
6
|
+
.sort((a, b) => a.localeCompare(b, 'en'))
|
|
7
|
+
.map(n => `/${n}`);
|
|
8
|
+
const update = (file, { add, remove }) => {
|
|
9
|
+
const st = lstatSync(file, { throwIfNoEntry: false });
|
|
10
|
+
if (st && !st.isFile())
|
|
11
|
+
return 'refused';
|
|
12
|
+
const text = st ? readFileSync(file, 'utf8') : '';
|
|
13
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
14
|
+
const lines = text.split(eol);
|
|
15
|
+
const begin = lines.findIndex(l => l.trim() === GITIGNORE_BEGIN);
|
|
16
|
+
const end = lines.findIndex((l, i) => begin !== -1 && i > begin && l.trim() === GITIGNORE_END);
|
|
17
|
+
let out;
|
|
18
|
+
if (end === -1) {
|
|
19
|
+
if (!add.length)
|
|
20
|
+
return 'ok';
|
|
21
|
+
out =
|
|
22
|
+
(text && !text.endsWith(eol) ? text + eol : text) +
|
|
23
|
+
[GITIGNORE_BEGIN, ...sorted(add), GITIGNORE_END].join(eol) +
|
|
24
|
+
eol;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const rm = new Set(sorted(remove));
|
|
28
|
+
const block = lines
|
|
29
|
+
.slice(begin + 1, end)
|
|
30
|
+
.filter(l => !rm.has(l.trim()));
|
|
31
|
+
const have = new Set(block.map(l => l.trim()));
|
|
32
|
+
lines.splice(begin + 1, end - begin - 1, ...block, ...sorted(add).filter(l => !have.has(l)));
|
|
33
|
+
out = lines.join(eol);
|
|
34
|
+
}
|
|
35
|
+
if (out !== text)
|
|
36
|
+
writeFileSync(file, out);
|
|
37
|
+
return 'ok';
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Keep a `/<name>` line per vlt link in a marked block of
|
|
41
|
+
* `<dir>/.gitignore`: add `add`, drop `remove`. Other lines are kept.
|
|
42
|
+
* Written only when changed, created only to add a line, never
|
|
43
|
+
* removed. `'refused'`: `.gitignore` is not a regular file, or an fs
|
|
44
|
+
* error.
|
|
45
|
+
*/
|
|
46
|
+
export const editGitignore = (dir, edit) => {
|
|
47
|
+
try {
|
|
48
|
+
return update(join(dir, '.gitignore'), edit);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return 'refused';
|
|
52
|
+
}
|
|
53
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { LoadOptions } from '../actual/load.ts';
|
|
2
|
+
import type { Graph } from '../graph.ts';
|
|
3
|
+
import type { Skill } from './discover.ts';
|
|
4
|
+
import type { UnmatchedPattern } from './mask.ts';
|
|
5
|
+
export * from './discover.ts';
|
|
6
|
+
export type { SkillMount, MountResult } from './mounts.ts';
|
|
7
|
+
export type { SkillsDir } from './dirs.ts';
|
|
8
|
+
export type { UnmatchedPattern } from './mask.ts';
|
|
9
|
+
/**
|
|
10
|
+
* Options for {@link listSkills} and {@link linkSkills}.
|
|
11
|
+
*/
|
|
12
|
+
export type SkillsOptions = LoadOptions & {
|
|
13
|
+
/**
|
|
14
|
+
* DSS query selecting packages, selectors may end with `:skill()`;
|
|
15
|
+
* default `*`
|
|
16
|
+
*/
|
|
17
|
+
target?: string;
|
|
18
|
+
/** graph to use instead of loading the actual one */
|
|
19
|
+
graph?: Graph;
|
|
20
|
+
/**
|
|
21
|
+
* agent skill dirs, relative to projectRoot. Unset: detect; `[]`:
|
|
22
|
+
* none
|
|
23
|
+
*/
|
|
24
|
+
skillsDirs?: string[];
|
|
25
|
+
/** env read by detection; default `process.env` */
|
|
26
|
+
env?: Record<string, string | undefined>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Options for {@link linkSkills}.
|
|
30
|
+
*/
|
|
31
|
+
export type LinkSkillsOptions = SkillsOptions & {
|
|
32
|
+
/** DSS query selecting packages */
|
|
33
|
+
target: string;
|
|
34
|
+
/**
|
|
35
|
+
* also remove every vlt link this selection does not make, in every
|
|
36
|
+
* scanned dir (`vlt skills link`). Default: only add links and remove
|
|
37
|
+
* links whose skill is gone (installs)
|
|
38
|
+
*/
|
|
39
|
+
apply?: boolean;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A skill linked in one dir.
|
|
43
|
+
*/
|
|
44
|
+
export type SkillRow = Skill & {
|
|
45
|
+
/** the dir */
|
|
46
|
+
dir: string;
|
|
47
|
+
/** `<dir>/<skill>` */
|
|
48
|
+
mount: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Skills left out as their packages have no security data (fail
|
|
52
|
+
* closed).
|
|
53
|
+
*/
|
|
54
|
+
export type UnscannedSkills = {
|
|
55
|
+
/** `<skill> (<package>)` */
|
|
56
|
+
unscanned?: string[];
|
|
57
|
+
/** why: the request timed out, or socket.dev has not scanned them */
|
|
58
|
+
unscannedReason?: 'timeout' | 'unscanned';
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Result of {@link listSkills}.
|
|
62
|
+
*/
|
|
63
|
+
export type ListSkillsResult = UnscannedSkills & {
|
|
64
|
+
/** main importer name, the tree root */
|
|
65
|
+
project: string;
|
|
66
|
+
/** the graph the skills were found in */
|
|
67
|
+
graph: Graph;
|
|
68
|
+
skills: Skill[];
|
|
69
|
+
/** target dirs */
|
|
70
|
+
dirs: string[];
|
|
71
|
+
/** targets dropped: `<dir> (<reason>)` */
|
|
72
|
+
skipped?: string[];
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Result of {@link linkSkills}.
|
|
76
|
+
*/
|
|
77
|
+
export type LinkSkillsResult = UnscannedSkills & {
|
|
78
|
+
/** links created or re-pointed */
|
|
79
|
+
linked: SkillRow[];
|
|
80
|
+
/** selected skills already linked */
|
|
81
|
+
unchanged: SkillRow[];
|
|
82
|
+
/**
|
|
83
|
+
* vlt links removed (skill gone, or with `apply`: not selected or dir
|
|
84
|
+
* no longer a target): `/`-separated relative paths
|
|
85
|
+
*/
|
|
86
|
+
removed: string[];
|
|
87
|
+
/**
|
|
88
|
+
* skipped per dir: path in use (`<mount>`), or name taken by another
|
|
89
|
+
* package (`<mount> (<package>)`)
|
|
90
|
+
*/
|
|
91
|
+
conflicts: string[];
|
|
92
|
+
/** target dirs */
|
|
93
|
+
dirs: string[];
|
|
94
|
+
/** targets dropped: `<dir> (<reason>)` */
|
|
95
|
+
skipped?: string[];
|
|
96
|
+
/** `<dir>/.gitignore` files not updated: not a regular file, fs error */
|
|
97
|
+
gitignore?: string[];
|
|
98
|
+
/** `:skill()` patterns that matched no skill of their packages */
|
|
99
|
+
unmatched?: UnmatchedPattern[];
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* List the agent skills shipped by installed packages (`:skill()`
|
|
103
|
+
* applied, `unscanned` left out), and the agent skill dirs. Creates
|
|
104
|
+
* nothing.
|
|
105
|
+
*/
|
|
106
|
+
export declare const listSkills: (o: SkillsOptions) => Promise<ListSkillsResult>;
|
|
107
|
+
/**
|
|
108
|
+
* Link the agent skills of the selected packages into each of the
|
|
109
|
+
* project's agent skill dirs (see `skillsDirs`), created only when a
|
|
110
|
+
* skill is picked. `target` selectors may end with `:skill()`. One
|
|
111
|
+
* package per name, among picked skills, in every dir: the package of
|
|
112
|
+
* the first existing link (in dir order), else rank (main importer
|
|
113
|
+
* dep, importer dep, transitive), then DepID; the others are
|
|
114
|
+
* reported. Installs keep a dir's link to another installed skill.
|
|
115
|
+
* A link follows its package to the picked version, in every scanned
|
|
116
|
+
* dir. Then remove vlt links whose skill is no longer installed (with
|
|
117
|
+
* `apply`: every vlt link not made here), and keep each dir's
|
|
118
|
+
* `.gitignore` block in sync. Linked packages get private copies of
|
|
119
|
+
* files hardlinked from the global store. Security queries fail
|
|
120
|
+
* closed: skills of packages with no security data are not linked,
|
|
121
|
+
* and reported in `unscanned` unless still linked.
|
|
122
|
+
*/
|
|
123
|
+
export declare const linkSkills: (o: LinkSkillsOptions) => Promise<LinkSkillsResult>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { load as loadActual } from "../actual/load.js";
|
|
3
|
+
import { filterNodesByQueryParts } from "../filter-nodes-by-query.js";
|
|
4
|
+
import { unshare } from "../reify/unshare.js";
|
|
5
|
+
import { skillsDirs } from "./dirs.js";
|
|
6
|
+
import { discoverSkills } from "./discover.js";
|
|
7
|
+
import { editGitignore } from "./gitignore.js";
|
|
8
|
+
import { maskSkills } from "./mask.js";
|
|
9
|
+
import { mountSkill, packageKey, readMounts, samePath, unmountSkill, } from "./mounts.js";
|
|
10
|
+
export * from "./discover.js";
|
|
11
|
+
const lc = (s) => s.toLowerCase();
|
|
12
|
+
const selectNodes = async (o) => {
|
|
13
|
+
const graph = o.graph ?? loadActual({ ...o, loadManifests: true });
|
|
14
|
+
const { ids, parts, unscanned } = await filterNodesByQueryParts(graph, o.target ?? '*');
|
|
15
|
+
const nodes = [...graph.nodes.values()].filter(n => ids.has(n.id));
|
|
16
|
+
return { graph, nodes, parts, unscanned };
|
|
17
|
+
};
|
|
18
|
+
const dirsOf = (o, create = false) => skillsDirs({
|
|
19
|
+
projectRoot: o.projectRoot,
|
|
20
|
+
configured: o.skillsDirs,
|
|
21
|
+
env: o.env,
|
|
22
|
+
create,
|
|
23
|
+
});
|
|
24
|
+
// packages behind live vlt links
|
|
25
|
+
const liveOwners = (mounts) => mounts.flatMap(m => m.owner && existsSync(m.target) ? [m.owner] : []);
|
|
26
|
+
// private copies of store-hardlinked files: edits via skill links
|
|
27
|
+
// must not reach the global store
|
|
28
|
+
const unshareSkills = async (graph, ids, scurry) => {
|
|
29
|
+
for (const id of new Set(ids)) {
|
|
30
|
+
const node = graph.nodes.get(id);
|
|
31
|
+
if (node?.inVltStore())
|
|
32
|
+
await unshare(node.resolvedLocation(scurry));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
// `vlt ls` style
|
|
36
|
+
const label = (s) => {
|
|
37
|
+
const pkg = s.version ? `${s.name}@${s.version}` : s.name;
|
|
38
|
+
return s.alias ? `${s.alias} (${pkg})` : pkg;
|
|
39
|
+
};
|
|
40
|
+
// apply the mask; fail closed: skills of `unscanned` pkgs are dropped,
|
|
41
|
+
// and reported unless another package's skill takes the name
|
|
42
|
+
const pick = (found, parts, unscanned) => {
|
|
43
|
+
const { skills: masked, unmatched } = maskSkills(found, parts);
|
|
44
|
+
const ok = (s) => !unscanned?.ids.has(s.id);
|
|
45
|
+
const picked = masked.filter(ok);
|
|
46
|
+
const names = new Set(picked.map(s => lc(s.skill)));
|
|
47
|
+
const dropped = masked.filter(s => !ok(s) && !names.has(lc(s.skill)));
|
|
48
|
+
return { skills: found.filter(ok), picked, unmatched, dropped };
|
|
49
|
+
};
|
|
50
|
+
const report = (dropped, unscanned) => unscanned && dropped.length ?
|
|
51
|
+
{
|
|
52
|
+
unscanned: dropped.map(s => `${s.skill} (${label(s)})`),
|
|
53
|
+
unscannedReason: unscanned.reason,
|
|
54
|
+
}
|
|
55
|
+
: {};
|
|
56
|
+
/**
|
|
57
|
+
* List the agent skills shipped by installed packages (`:skill()`
|
|
58
|
+
* applied, `unscanned` left out), and the agent skill dirs. Creates
|
|
59
|
+
* nothing.
|
|
60
|
+
*/
|
|
61
|
+
export const listSkills = async (o) => {
|
|
62
|
+
const { graph, nodes, parts, unscanned } = await selectNodes(o);
|
|
63
|
+
const { targets, scan, skipped } = dirsOf(o);
|
|
64
|
+
const { skills } = discoverSkills(nodes, o.scurry, readMounts(scan));
|
|
65
|
+
const { picked, dropped } = pick(skills, parts, unscanned);
|
|
66
|
+
return {
|
|
67
|
+
project: graph.mainImporter.name,
|
|
68
|
+
graph,
|
|
69
|
+
skills: picked,
|
|
70
|
+
dirs: targets.map(d => d.dir),
|
|
71
|
+
...(skipped.length ? { skipped } : null),
|
|
72
|
+
...report(dropped, unscanned),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Link the agent skills of the selected packages into each of the
|
|
77
|
+
* project's agent skill dirs (see `skillsDirs`), created only when a
|
|
78
|
+
* skill is picked. `target` selectors may end with `:skill()`. One
|
|
79
|
+
* package per name, among picked skills, in every dir: the package of
|
|
80
|
+
* the first existing link (in dir order), else rank (main importer
|
|
81
|
+
* dep, importer dep, transitive), then DepID; the others are
|
|
82
|
+
* reported. Installs keep a dir's link to another installed skill.
|
|
83
|
+
* A link follows its package to the picked version, in every scanned
|
|
84
|
+
* dir. Then remove vlt links whose skill is no longer installed (with
|
|
85
|
+
* `apply`: every vlt link not made here), and keep each dir's
|
|
86
|
+
* `.gitignore` block in sync. Linked packages get private copies of
|
|
87
|
+
* files hardlinked from the global store. Security queries fail
|
|
88
|
+
* closed: skills of packages with no security data are not linked,
|
|
89
|
+
* and reported in `unscanned` unless still linked.
|
|
90
|
+
*/
|
|
91
|
+
export const linkSkills = async (o) => {
|
|
92
|
+
const { graph, nodes, parts, unscanned } = await selectNodes(o);
|
|
93
|
+
const { skills: found, scanned } = discoverSkills(nodes, o.scurry);
|
|
94
|
+
const { skills, picked, unmatched, dropped } = pick(found, parts, unscanned);
|
|
95
|
+
const inMask = new Set(picked);
|
|
96
|
+
const { targets, scan, skipped } = dirsOf(o, picked.length > 0);
|
|
97
|
+
const res = {
|
|
98
|
+
linked: [],
|
|
99
|
+
unchanged: [],
|
|
100
|
+
removed: [],
|
|
101
|
+
conflicts: [],
|
|
102
|
+
dirs: targets.map(d => d.dir),
|
|
103
|
+
};
|
|
104
|
+
const mounts = readMounts(scan);
|
|
105
|
+
const byName = new Map(mounts.map(m => [`${m.dir.dir}\0${lc(m.name)}`, m]));
|
|
106
|
+
// the link's skill is no longer installed: dangling, orphan owner,
|
|
107
|
+
// or a scanned owner that no longer ships it
|
|
108
|
+
const stale = (m) => !m.owner ||
|
|
109
|
+
!graph.nodes.has(m.owner) ||
|
|
110
|
+
!existsSync(m.target) ||
|
|
111
|
+
scanned
|
|
112
|
+
.get(m.owner)
|
|
113
|
+
?.some(f => lc(f.skill) === lc(m.name) && samePath(f.path, m.target)) === false;
|
|
114
|
+
const groups = new Map();
|
|
115
|
+
for (const s of skills) {
|
|
116
|
+
const k = lc(s.skill);
|
|
117
|
+
groups.set(k, [...(groups.get(k) ?? []), s]);
|
|
118
|
+
}
|
|
119
|
+
// the link at `<dir>/<k>`, and its package's skill in `group`
|
|
120
|
+
const at = (dir, k, group) => {
|
|
121
|
+
const m = byName.get(`${dir.dir}\0${k}`);
|
|
122
|
+
const key = m?.owner && packageKey(m.owner);
|
|
123
|
+
return { m, own: group.find(s => packageKey(s.id) === key) };
|
|
124
|
+
};
|
|
125
|
+
// picked skills only: the others never take a name
|
|
126
|
+
const cands = (group) => group.filter(s => inMask.has(s));
|
|
127
|
+
// one per name: the first linked package keeps it, re-pointed if needed
|
|
128
|
+
const first = new Map([...groups].map(([k, group]) => {
|
|
129
|
+
const c = cands(group);
|
|
130
|
+
return [
|
|
131
|
+
k,
|
|
132
|
+
targets.map(d => at(d, k, c).own).find(s => !!s) ?? c[0],
|
|
133
|
+
];
|
|
134
|
+
}));
|
|
135
|
+
const isTarget = new Set(targets);
|
|
136
|
+
const picks = [];
|
|
137
|
+
for (const dir of scan) {
|
|
138
|
+
for (const [k, group] of groups) {
|
|
139
|
+
const { m, own } = at(dir, k, group);
|
|
140
|
+
if (isTarget.has(dir)) {
|
|
141
|
+
// installs keep a link to another installed skill
|
|
142
|
+
const winner = o.apply ?
|
|
143
|
+
first.get(k)
|
|
144
|
+
: (own ?? (m && !stale(m) ? undefined : first.get(k)));
|
|
145
|
+
picks.push({
|
|
146
|
+
dir,
|
|
147
|
+
winner,
|
|
148
|
+
losers: cands(group).filter(s => s !== winner),
|
|
149
|
+
m,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else if (own && !o.apply) {
|
|
153
|
+
// no longer a target: only re-pointed
|
|
154
|
+
picks.push({ dir, winner: own, losers: [], m });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
await unshareSkills(graph, [
|
|
159
|
+
...picks.flatMap(({ winner }) => (winner ? [winner.id] : [])),
|
|
160
|
+
...liveOwners(mounts),
|
|
161
|
+
], o.scurry);
|
|
162
|
+
const gone = [];
|
|
163
|
+
// names taken over by entries not vlt's
|
|
164
|
+
const foreign = [];
|
|
165
|
+
const kept = new Set();
|
|
166
|
+
const where = new Map();
|
|
167
|
+
for (const { dir, winner, losers, m } of picks) {
|
|
168
|
+
if (winner) {
|
|
169
|
+
// replaced link named in another case (case-sensitive fs)
|
|
170
|
+
if (m && m.name !== winner.skill) {
|
|
171
|
+
unmountSkill(m);
|
|
172
|
+
gone.push(m);
|
|
173
|
+
}
|
|
174
|
+
const mount = `${dir.dir}/${winner.skill}`;
|
|
175
|
+
const r = mountSkill(winner, dir);
|
|
176
|
+
if (r === 'conflict') {
|
|
177
|
+
res.conflicts.push(mount);
|
|
178
|
+
foreign.push({ dir, name: winner.skill });
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const k = `${winner.id}\0${winner.skill}`;
|
|
182
|
+
const linked = where.get(k) ?? [];
|
|
183
|
+
linked.push(dir.dir);
|
|
184
|
+
where.set(k, linked);
|
|
185
|
+
res[r].push({ ...winner, linked, dir: dir.dir, mount });
|
|
186
|
+
kept.add(mount);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const s of losers)
|
|
190
|
+
res.conflicts.push(`${dir.dir}/${s.skill} (${label(s)})`);
|
|
191
|
+
}
|
|
192
|
+
const after = readMounts(scan);
|
|
193
|
+
const present = [];
|
|
194
|
+
for (const m of after) {
|
|
195
|
+
if (o.apply ? kept.has(m.mount) : !stale(m)) {
|
|
196
|
+
present.push(m);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
unmountSkill(m);
|
|
200
|
+
gone.push(m);
|
|
201
|
+
res.removed.push(m.mount);
|
|
202
|
+
}
|
|
203
|
+
const gitignore = [];
|
|
204
|
+
for (const dir of scan) {
|
|
205
|
+
const add = present.filter(m => m.dir === dir).map(m => m.name);
|
|
206
|
+
const remove = [...gone, ...foreign]
|
|
207
|
+
.filter(m => m.dir === dir && !add.includes(m.name))
|
|
208
|
+
.map(m => m.name);
|
|
209
|
+
if ((add.length || remove.length) &&
|
|
210
|
+
editGitignore(dir.real, { add, remove }) === 'refused') {
|
|
211
|
+
gitignore.push(`${dir.dir}/.gitignore`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (skipped.length)
|
|
215
|
+
res.skipped = skipped;
|
|
216
|
+
if (gitignore.length)
|
|
217
|
+
res.gitignore = gitignore;
|
|
218
|
+
if (unmatched.length)
|
|
219
|
+
res.unmatched = unmatched;
|
|
220
|
+
// not left out: still linked in every dir (installs keep links)
|
|
221
|
+
const left = dropped.filter(s => !targets.every(d => present.some(m => m.dir === d &&
|
|
222
|
+
m.owner === s.id &&
|
|
223
|
+
lc(m.name) === lc(s.skill))));
|
|
224
|
+
return Object.assign(res, report(left, unscanned));
|
|
225
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HasSkills } from '@vltpkg/query';
|
|
2
|
+
import type * as Skills from './index.ts';
|
|
3
|
+
/** `listSkills`, loading the skills code on first use. */
|
|
4
|
+
export declare const listSkills: typeof Skills.listSkills;
|
|
5
|
+
/** `linkSkills`, loading the skills code on first use. */
|
|
6
|
+
export declare const linkSkills: typeof Skills.linkSkills;
|
|
7
|
+
/** `:skills` provider: loads the skills code on first `Node`. */
|
|
8
|
+
export declare const hasSkills: HasSkills;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { isNode } from "../node.js";
|
|
2
|
+
const load = () => import("./index.js");
|
|
3
|
+
/** `listSkills`, loading the skills code on first use. */
|
|
4
|
+
export const listSkills = async (o) => (await load()).listSkills(o);
|
|
5
|
+
/** `linkSkills`, loading the skills code on first use. */
|
|
6
|
+
export const linkSkills = async (o) => (await load()).linkSkills(o);
|
|
7
|
+
/** `:skills` provider: loads the skills code on first `Node`. */
|
|
8
|
+
export const hasSkills = async (node) => isNode(node) && (await load()).shipsSkills(node);
|