@danieljvdm/dev-kit 0.6.0 → 0.7.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/README.md +123 -56
- package/dev-kit.example.jsonc +7 -3
- package/package.json +19 -16
- package/schema/dev-kit.schema.json +38 -0
- package/skill-sources.jsonc +8 -12
- package/skill-sources.lock.json +3 -9
- package/skills/dev-kit/SKILL.md +52 -17
- package/skills/effect-ts/agents/openai.yaml +0 -1
- package/skills/effect-ts/references/audit-services.md +11 -11
- package/skills/effect-ts/references/guide-effect.md +56 -69
- package/skills/effect-ts/references/guide-error-handling.md +64 -73
- package/skills/effect-ts/references/guide-layers.md +187 -215
- package/skills/effect-ts/references/guide-observability.md +91 -116
- package/skills/effect-ts/references/guide-retries.md +32 -44
- package/skills/effect-ts/references/guide-schedule.md +26 -40
- package/skills/effect-ts/references/guide-schema.md +50 -57
- package/skills/effect-ts/references/guide-sql.md +47 -50
- package/skills/effect-ts/references/guide-testing.md +96 -98
- package/skills/effect-ts/references/guide-type-safety-and-boundaries.md +7 -7
- package/skills/effect-ts/references/version-and-source.md +0 -1
- package/src/bin/dev-kit.ts +61 -28
- package/src/catalog-manager.ts +86 -34
- package/src/catalog.ts +71 -33
- package/src/cli-ui.ts +20 -16
- package/src/effect-source.ts +49 -19
- package/src/effect-tsgo.ts +66 -35
- package/src/gitignore.ts +19 -6
- package/src/index.ts +6 -0
- package/src/manifest.ts +38 -3
- package/src/node-symbolic-link.ts +3 -0
- package/src/oxlint-plugin-effect.js +3 -0
- package/src/oxlint-plugin-style.d.ts +8 -0
- package/src/oxlint-plugin-style.js +8 -0
- package/src/oxlint.js +14 -0
- package/src/oxlint.ts +14 -0
- package/src/package-skill-source.ts +189 -52
- package/src/path-digest.ts +47 -13
- package/src/project-package.ts +44 -19
- package/src/project-process-lock.ts +19 -12
- package/src/project-state.ts +11 -0
- package/src/skill-manager.ts +134 -55
- package/src/skill-selector.ts +8 -2
- package/src/source-manifest.ts +2 -6
- package/src/sync.ts +417 -107
- package/src/vendor.ts +112 -42
- package/src/vite-plus-hooks.ts +174 -0
- package/src/vite-plus-quality.ts +49 -0
- package/templates/vite-plus/github-actions-check.yml +44 -0
- package/templates/vite-plus/vite.config.ts +22 -0
|
@@ -9,6 +9,7 @@ export type SymbolicLinkObservation =
|
|
|
9
9
|
const isNotSymbolicLink = (error: PlatformError.PlatformError): boolean => {
|
|
10
10
|
if (error.reason._tag !== "Unknown") return false;
|
|
11
11
|
const cause = error.reason.cause;
|
|
12
|
+
|
|
12
13
|
return cause instanceof Error && "code" in cause && cause.code === "EINVAL";
|
|
13
14
|
};
|
|
14
15
|
|
|
@@ -16,6 +17,7 @@ export const observeSymbolicLink = Effect.fn("observeSymbolicLink")(function* (
|
|
|
16
17
|
absolutePath: string,
|
|
17
18
|
) {
|
|
18
19
|
const fs = yield* FileSystem.FileSystem;
|
|
20
|
+
|
|
19
21
|
return yield* fs.readLink(absolutePath).pipe(
|
|
20
22
|
Effect.map((target): SymbolicLinkObservation => ({ kind: "symlink", target })),
|
|
21
23
|
Effect.catch((error) => {
|
|
@@ -25,6 +27,7 @@ export const observeSymbolicLink = Effect.fn("observeSymbolicLink")(function* (
|
|
|
25
27
|
if (isNotSymbolicLink(error)) {
|
|
26
28
|
return Effect.succeed<SymbolicLinkObservation>({ kind: "not-symlink" });
|
|
27
29
|
}
|
|
30
|
+
|
|
28
31
|
return Effect.fail(error);
|
|
29
32
|
}),
|
|
30
33
|
);
|
|
@@ -33,6 +33,7 @@ const noPromiseAtomMode = {
|
|
|
33
33
|
const isMode =
|
|
34
34
|
(key.type === "Identifier" && key.name === "mode") ||
|
|
35
35
|
(key.type === "Literal" && key.value === "mode");
|
|
36
|
+
|
|
36
37
|
if (isMode && value.type === "Literal" && value.value === "promise") {
|
|
37
38
|
context.report({ node, messageId: "noPromiseAtomMode" });
|
|
38
39
|
}
|
|
@@ -117,8 +118,10 @@ const noAsyncWorkflow = {
|
|
|
117
118
|
parent?.type === "Property" &&
|
|
118
119
|
((parent.key.type === "Identifier" && parent.key.name === "try") ||
|
|
119
120
|
(parent.key.type === "Literal" && parent.key.value === "try"));
|
|
121
|
+
|
|
120
122
|
if (!isCapturedTryThunk) context.report({ node, messageId: "noAsyncWorkflow" });
|
|
121
123
|
};
|
|
124
|
+
|
|
122
125
|
return {
|
|
123
126
|
ArrowFunctionExpression: check,
|
|
124
127
|
FunctionDeclaration: check,
|
package/src/oxlint.js
CHANGED
|
@@ -13,6 +13,10 @@ export const recommendedOxlintConfig = {
|
|
|
13
13
|
name: "effect",
|
|
14
14
|
specifier: "@danieljvdm/dev-kit/oxlint-plugin-effect",
|
|
15
15
|
},
|
|
16
|
+
{
|
|
17
|
+
name: "stylistic",
|
|
18
|
+
specifier: "@danieljvdm/dev-kit/oxlint-plugin-style",
|
|
19
|
+
},
|
|
16
20
|
],
|
|
17
21
|
plugins: ["import", "react", "vitest"],
|
|
18
22
|
rules: {
|
|
@@ -24,6 +28,16 @@ export const recommendedOxlintConfig = {
|
|
|
24
28
|
"import/no-self-import": "error",
|
|
25
29
|
"react/exhaustive-deps": "error",
|
|
26
30
|
"react/rules-of-hooks": "error",
|
|
31
|
+
"stylistic/padding-line-between-statements": [
|
|
32
|
+
"error",
|
|
33
|
+
{ blankLine: "always", prev: ["const", "let", "var"], next: "*" },
|
|
34
|
+
{
|
|
35
|
+
blankLine: "any",
|
|
36
|
+
prev: ["const", "let", "var"],
|
|
37
|
+
next: ["const", "let", "var"],
|
|
38
|
+
},
|
|
39
|
+
{ blankLine: "always", prev: "*", next: "return" },
|
|
40
|
+
],
|
|
27
41
|
"typescript/consistent-type-imports": "error",
|
|
28
42
|
"typescript/no-floating-promises": "off",
|
|
29
43
|
"typescript/no-explicit-any": "error",
|
package/src/oxlint.ts
CHANGED
|
@@ -16,6 +16,10 @@ export const recommendedOxlintConfig = {
|
|
|
16
16
|
name: "effect",
|
|
17
17
|
specifier: "@danieljvdm/dev-kit/oxlint-plugin-effect",
|
|
18
18
|
},
|
|
19
|
+
{
|
|
20
|
+
name: "stylistic",
|
|
21
|
+
specifier: "@danieljvdm/dev-kit/oxlint-plugin-style",
|
|
22
|
+
},
|
|
19
23
|
],
|
|
20
24
|
plugins: ["import", "react", "vitest"],
|
|
21
25
|
rules: {
|
|
@@ -27,6 +31,16 @@ export const recommendedOxlintConfig = {
|
|
|
27
31
|
"import/no-self-import": "error",
|
|
28
32
|
"react/exhaustive-deps": "error",
|
|
29
33
|
"react/rules-of-hooks": "error",
|
|
34
|
+
"stylistic/padding-line-between-statements": [
|
|
35
|
+
"error",
|
|
36
|
+
{ blankLine: "always", prev: ["const", "let", "var"], next: "*" },
|
|
37
|
+
{
|
|
38
|
+
blankLine: "any",
|
|
39
|
+
prev: ["const", "let", "var"],
|
|
40
|
+
next: ["const", "let", "var"],
|
|
41
|
+
},
|
|
42
|
+
{ blankLine: "always", prev: "*", next: "return" },
|
|
43
|
+
],
|
|
30
44
|
"typescript/consistent-type-imports": "error",
|
|
31
45
|
"typescript/no-floating-promises": "off",
|
|
32
46
|
"typescript/no-explicit-any": "error",
|
|
@@ -25,65 +25,88 @@ export type DiscoveredPackageSkill = {
|
|
|
25
25
|
readonly linkPath: string;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
const PackageMetadataSchema = Schema.fromJsonString(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const PackageMetadataSchema = Schema.fromJsonString(
|
|
29
|
+
Schema.Struct({
|
|
30
|
+
name: Schema.String,
|
|
31
|
+
version: Schema.String,
|
|
32
|
+
intent: Schema.optional(Schema.Unknown),
|
|
33
|
+
repository: Schema.optional(Schema.Unknown),
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
34
36
|
|
|
35
37
|
const nonEmptyString = (value: unknown): value is string =>
|
|
36
38
|
typeof value === "string" && value.trim().length > 0;
|
|
37
39
|
|
|
38
40
|
const hasIntentDiscoveryMetadata = (metadata: typeof PackageMetadataSchema.Type): boolean => {
|
|
39
41
|
const intent = metadata.intent;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
typeof intent === "object" &&
|
|
45
|
+
intent !== null &&
|
|
46
|
+
"version" in intent &&
|
|
47
|
+
intent.version === 1 &&
|
|
48
|
+
"repo" in intent &&
|
|
49
|
+
nonEmptyString(intent.repo) &&
|
|
50
|
+
"docs" in intent &&
|
|
51
|
+
nonEmptyString(intent.docs)
|
|
52
|
+
) {
|
|
44
53
|
return true;
|
|
45
54
|
}
|
|
46
55
|
const repository = metadata.repository;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
nonEmptyString(repository) ||
|
|
59
|
+
(typeof repository === "object" &&
|
|
60
|
+
repository !== null &&
|
|
61
|
+
"url" in repository &&
|
|
62
|
+
nonEmptyString(repository.url))
|
|
63
|
+
);
|
|
50
64
|
};
|
|
51
65
|
|
|
52
66
|
const isSafePackageVersion = (value: string): boolean =>
|
|
53
|
-
value.length > 0 &&
|
|
67
|
+
value.length > 0 &&
|
|
68
|
+
value.trim() === value &&
|
|
69
|
+
![...value].some((character) => {
|
|
54
70
|
const code = character.charCodeAt(0);
|
|
71
|
+
|
|
55
72
|
return code <= 32 || (code >= 127 && code <= 159);
|
|
56
73
|
});
|
|
57
74
|
|
|
58
75
|
const isContained = (path: Path.Path, root: string, candidate: string): boolean => {
|
|
59
76
|
const relative = path.relative(root, candidate);
|
|
77
|
+
|
|
60
78
|
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
61
79
|
};
|
|
62
80
|
|
|
63
81
|
const frontmatterScalar = (document: string, key: string): string | undefined => {
|
|
64
82
|
const body = document.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
83
|
+
|
|
65
84
|
if (body === undefined) return undefined;
|
|
66
85
|
const lines = body.split(/\r?\n/);
|
|
67
86
|
const index = lines.findIndex((line) => line.startsWith(`${key}:`));
|
|
87
|
+
|
|
68
88
|
if (index < 0) return undefined;
|
|
69
89
|
const raw = lines[index]?.slice(key.length + 1).trim() ?? "";
|
|
70
90
|
const block = raw.match(/^([|>])(?:[1-9][+-]?|[+-][1-9]?)?$/)?.[1];
|
|
91
|
+
|
|
71
92
|
if (block !== undefined) {
|
|
72
93
|
const values: Array<string> = [];
|
|
94
|
+
|
|
73
95
|
for (const line of lines.slice(index + 1)) {
|
|
74
96
|
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
75
97
|
values.push(line.trim());
|
|
76
98
|
}
|
|
77
99
|
const value = block === "|" ? values.join("\n").trim() : values.join(" ").trim();
|
|
100
|
+
|
|
78
101
|
return value.length > 0 ? value : undefined;
|
|
79
102
|
}
|
|
80
103
|
const quoted = raw.match(/^(['"])([\s\S]*?)\1(?:\s+#.*)?$/)?.[2];
|
|
81
104
|
const value = (quoted ?? raw.replace(/\s+#.*$/, "")).trim();
|
|
105
|
+
|
|
82
106
|
return value.length > 0 ? value : undefined;
|
|
83
107
|
};
|
|
84
108
|
|
|
85
|
-
const skillName = (document: string): string | undefined =>
|
|
86
|
-
frontmatterScalar(document, "name");
|
|
109
|
+
const skillName = (document: string): string | undefined => frontmatterScalar(document, "name");
|
|
87
110
|
|
|
88
111
|
const skillDescription = (document: string): string | undefined =>
|
|
89
112
|
frontmatterScalar(document, "description");
|
|
@@ -92,19 +115,35 @@ const rejectNestedSymlinks = Effect.fn("rejectPackageSkillSymlinks")(function* (
|
|
|
92
115
|
const fs = yield* FileSystem.FileSystem;
|
|
93
116
|
const path = yield* Path.Path;
|
|
94
117
|
const pending = [skillRoot];
|
|
118
|
+
|
|
95
119
|
while (pending.length > 0) {
|
|
96
120
|
const current = pending.pop();
|
|
121
|
+
|
|
97
122
|
if (current === undefined) continue;
|
|
98
123
|
if ((yield* observeSymbolicLink(current)).kind === "symlink") {
|
|
99
|
-
return yield* new PackageSkillSourceError({
|
|
124
|
+
return yield* new PackageSkillSourceError({
|
|
125
|
+
message: `package skill contains a symlink: ${current}`,
|
|
126
|
+
});
|
|
100
127
|
}
|
|
101
|
-
const info = yield* fs
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
const info = yield* fs
|
|
129
|
+
.stat(current)
|
|
130
|
+
.pipe(
|
|
131
|
+
Effect.mapError(
|
|
132
|
+
() =>
|
|
133
|
+
new PackageSkillSourceError({ message: `could not inspect package skill: ${current}` }),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
|
|
104
137
|
if (info.type !== "Directory") continue;
|
|
105
|
-
for (const entry of yield* fs
|
|
106
|
-
|
|
107
|
-
|
|
138
|
+
for (const entry of yield* fs
|
|
139
|
+
.readDirectory(current)
|
|
140
|
+
.pipe(
|
|
141
|
+
Effect.mapError(
|
|
142
|
+
() =>
|
|
143
|
+
new PackageSkillSourceError({ message: `could not read package skill: ${current}` }),
|
|
144
|
+
),
|
|
145
|
+
))
|
|
146
|
+
pending.push(path.join(current, entry));
|
|
108
147
|
}
|
|
109
148
|
});
|
|
110
149
|
|
|
@@ -124,33 +163,85 @@ const loadInstalledPackageSkills = Effect.fn("loadInstalledPackageSkills")(funct
|
|
|
124
163
|
const path = yield* Path.Path;
|
|
125
164
|
const packageLink = path.join(projectDir, "node_modules", ...packageName.split("/"));
|
|
126
165
|
const packageRoot = yield* fs.realPath(packageLink).pipe(
|
|
127
|
-
Effect.mapError(
|
|
166
|
+
Effect.mapError(
|
|
167
|
+
() =>
|
|
168
|
+
new PackageSkillSourceError({
|
|
169
|
+
message: `package skill package is not installed: ${packageName}`,
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
128
172
|
);
|
|
129
173
|
const packageInfo = yield* fs.stat(packageRoot).pipe(
|
|
130
|
-
Effect.mapError(
|
|
174
|
+
Effect.mapError(
|
|
175
|
+
() =>
|
|
176
|
+
new PackageSkillSourceError({
|
|
177
|
+
message: `could not inspect package skill package: ${packageName}`,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
131
180
|
);
|
|
132
|
-
|
|
181
|
+
|
|
182
|
+
if (packageInfo.type !== "Directory")
|
|
183
|
+
return yield* new PackageSkillSourceError({
|
|
184
|
+
message: `package skill package is not a directory: ${packageName}`,
|
|
185
|
+
});
|
|
133
186
|
const metadata = yield* fs.readFileString(path.join(packageRoot, "package.json")).pipe(
|
|
134
187
|
Effect.flatMap(Schema.decodeUnknownEffect(PackageMetadataSchema)),
|
|
135
|
-
Effect.mapError(
|
|
188
|
+
Effect.mapError(
|
|
189
|
+
() =>
|
|
190
|
+
new PackageSkillSourceError({
|
|
191
|
+
message: `invalid package.json for package skill package: ${packageName}`,
|
|
192
|
+
}),
|
|
193
|
+
),
|
|
136
194
|
);
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
195
|
+
|
|
196
|
+
if (metadata.name !== packageName)
|
|
197
|
+
return yield* new PackageSkillSourceError({
|
|
198
|
+
message: `package.json name does not match package skill package: ${packageName}`,
|
|
199
|
+
});
|
|
200
|
+
if (!isSafePackageVersion(metadata.version))
|
|
201
|
+
return yield* new PackageSkillSourceError({
|
|
202
|
+
message: `package.json has an invalid version for package skill package: ${packageName}`,
|
|
203
|
+
});
|
|
204
|
+
if (!hasIntentDiscoveryMetadata(metadata))
|
|
205
|
+
return yield* new PackageSkillSourceError({
|
|
206
|
+
message: `package does not declare Intent-compatible discovery metadata: ${packageName}`,
|
|
207
|
+
});
|
|
140
208
|
const skillsPath = "skills";
|
|
141
209
|
const skillsLink = path.join(packageLink, skillsPath);
|
|
142
|
-
|
|
210
|
+
|
|
211
|
+
if ((yield* observeSymbolicLink(skillsLink)).kind === "symlink")
|
|
212
|
+
return yield* new PackageSkillSourceError({
|
|
213
|
+
message: `package skills path is a symlink: ${packageName}/${skillsPath}`,
|
|
214
|
+
});
|
|
143
215
|
const skillsRoot = yield* fs.realPath(skillsLink).pipe(
|
|
144
|
-
Effect.mapError(
|
|
216
|
+
Effect.mapError(
|
|
217
|
+
() =>
|
|
218
|
+
new PackageSkillSourceError({
|
|
219
|
+
message: `package skill package has no skills directory: ${packageName}`,
|
|
220
|
+
}),
|
|
221
|
+
),
|
|
145
222
|
);
|
|
146
|
-
|
|
223
|
+
|
|
224
|
+
if (!isContained(path, packageRoot, skillsRoot))
|
|
225
|
+
return yield* new PackageSkillSourceError({
|
|
226
|
+
message: `package skills path resolves outside package root: ${packageName}/${skillsPath}`,
|
|
227
|
+
});
|
|
147
228
|
const skillsInfo = yield* fs.stat(skillsRoot);
|
|
148
|
-
|
|
229
|
+
|
|
230
|
+
if (skillsInfo.type !== "Directory")
|
|
231
|
+
return yield* new PackageSkillSourceError({
|
|
232
|
+
message: `package skills path is not a directory: ${packageName}/${skillsPath}`,
|
|
233
|
+
});
|
|
149
234
|
const names = (yield* fs.readDirectory(skillsRoot).pipe(
|
|
150
|
-
Effect.mapError(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
235
|
+
Effect.mapError(
|
|
236
|
+
() =>
|
|
237
|
+
new PackageSkillSourceError({
|
|
238
|
+
message: `package skill package has no readable skills directory: ${packageName}`,
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
))
|
|
242
|
+
.filter(isSkillName)
|
|
243
|
+
.sort();
|
|
244
|
+
|
|
154
245
|
return {
|
|
155
246
|
package: packageName,
|
|
156
247
|
version: metadata.version,
|
|
@@ -166,36 +257,57 @@ const inspectPackageSkill = Effect.fn("inspectInstalledPackageSkill")(function*
|
|
|
166
257
|
) {
|
|
167
258
|
const fs = yield* FileSystem.FileSystem;
|
|
168
259
|
const path = yield* Path.Path;
|
|
260
|
+
|
|
169
261
|
if (!isSkillName(name)) {
|
|
170
262
|
return yield* new PackageSkillSourceError({ message: `invalid package skill name: ${name}` });
|
|
171
263
|
}
|
|
172
264
|
const selector = `${installed.package}#${name}`;
|
|
173
265
|
const linkPath = path.join(installed.packageLink, "skills", name);
|
|
266
|
+
|
|
174
267
|
if ((yield* observeSymbolicLink(linkPath)).kind === "symlink") {
|
|
175
|
-
return yield* new PackageSkillSourceError({
|
|
268
|
+
return yield* new PackageSkillSourceError({
|
|
269
|
+
message: `package skill contains a symlink: ${selector}`,
|
|
270
|
+
});
|
|
176
271
|
}
|
|
177
|
-
const skillRoot = yield* fs
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
272
|
+
const skillRoot = yield* fs
|
|
273
|
+
.realPath(linkPath)
|
|
274
|
+
.pipe(
|
|
275
|
+
Effect.mapError(
|
|
276
|
+
() => new PackageSkillSourceError({ message: `package skill does not exist: ${selector}` }),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
!isContained(path, installed.skillsRoot, skillRoot) ||
|
|
282
|
+
(yield* fs.stat(skillRoot)).type !== "Directory"
|
|
283
|
+
) {
|
|
284
|
+
return yield* new PackageSkillSourceError({
|
|
285
|
+
message: `package skill is not a contained directory: ${selector}`,
|
|
286
|
+
});
|
|
183
287
|
}
|
|
184
288
|
yield* rejectNestedSymlinks(skillRoot);
|
|
185
289
|
const document = yield* fs.readFileString(path.join(skillRoot, "SKILL.md")).pipe(
|
|
186
|
-
Effect.mapError(
|
|
290
|
+
Effect.mapError(
|
|
291
|
+
() =>
|
|
292
|
+
new PackageSkillSourceError({
|
|
293
|
+
message: `package skill is missing SKILL.md: ${selector}`,
|
|
294
|
+
}),
|
|
295
|
+
),
|
|
187
296
|
);
|
|
297
|
+
|
|
188
298
|
if (skillName(document) !== name) {
|
|
189
299
|
return yield* new PackageSkillSourceError({
|
|
190
300
|
message: `package skill SKILL.md name must match directory: ${selector}`,
|
|
191
301
|
});
|
|
192
302
|
}
|
|
193
303
|
const description = skillDescription(document);
|
|
304
|
+
|
|
194
305
|
if (description === undefined) {
|
|
195
306
|
return yield* new PackageSkillSourceError({
|
|
196
307
|
message: `package skill SKILL.md must declare a description: ${selector}`,
|
|
197
308
|
});
|
|
198
309
|
}
|
|
310
|
+
|
|
199
311
|
return {
|
|
200
312
|
selector,
|
|
201
313
|
name,
|
|
@@ -208,41 +320,66 @@ const inspectPackageSkill = Effect.fn("inspectInstalledPackageSkill")(function*
|
|
|
208
320
|
});
|
|
209
321
|
|
|
210
322
|
/** Read direct project dependencies only; malformed packages are returned as diagnostics, never executed. */
|
|
211
|
-
export const discoverPackageSkills = Effect.fn("discoverInstalledPackageSkills")(function* (
|
|
323
|
+
export const discoverPackageSkills = Effect.fn("discoverInstalledPackageSkills")(function* (
|
|
324
|
+
projectDir: string,
|
|
325
|
+
) {
|
|
212
326
|
const fs = yield* FileSystem.FileSystem;
|
|
213
327
|
const path = yield* Path.Path;
|
|
214
328
|
const candidates: Array<DiscoveredPackageSkill> = [];
|
|
215
329
|
const diagnostics: Array<PackageSkillDiagnostic> = [];
|
|
330
|
+
|
|
216
331
|
if (!(yield* fs.exists(path.join(projectDir, "package.json")))) {
|
|
217
332
|
return { candidates, diagnostics };
|
|
218
333
|
}
|
|
219
334
|
for (const packageName of yield* readDirectDependencyNames(projectDir)) {
|
|
220
335
|
if (!isTypeScriptPackageName(packageName)) {
|
|
221
|
-
diagnostics.push({
|
|
336
|
+
diagnostics.push({
|
|
337
|
+
package: packageName,
|
|
338
|
+
message: `invalid direct dependency package name: ${packageName}`,
|
|
339
|
+
});
|
|
222
340
|
continue;
|
|
223
341
|
}
|
|
224
342
|
const skillsLink = path.join(projectDir, "node_modules", ...packageName.split("/"), "skills");
|
|
343
|
+
|
|
225
344
|
if (!(yield* fs.exists(skillsLink))) continue;
|
|
226
345
|
const installed = yield* Effect.result(loadInstalledPackageSkills(projectDir, packageName));
|
|
346
|
+
|
|
227
347
|
if (Result.isFailure(installed)) {
|
|
228
348
|
diagnostics.push({ package: packageName, message: installed.failure.message });
|
|
229
349
|
continue;
|
|
230
350
|
}
|
|
231
351
|
for (const name of installed.success.names) {
|
|
232
352
|
const inspected = yield* Effect.result(inspectPackageSkill(installed.success, name));
|
|
353
|
+
|
|
233
354
|
if (Result.isSuccess(inspected)) candidates.push(inspected.success);
|
|
234
355
|
else diagnostics.push({ package: packageName, message: inspected.failure.message });
|
|
235
356
|
}
|
|
236
357
|
}
|
|
237
|
-
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
candidates: candidates.sort((left, right) => left.selector.localeCompare(right.selector)),
|
|
361
|
+
diagnostics,
|
|
362
|
+
};
|
|
238
363
|
});
|
|
239
364
|
|
|
240
365
|
/** Resolve one explicitly selected package skill. Unlike browsing, every malformed or missing part is an error. */
|
|
241
|
-
export const resolvePackageSkillSelector = Effect.fn("resolvePackageSkillSelector")(function* (
|
|
366
|
+
export const resolvePackageSkillSelector = Effect.fn("resolvePackageSkillSelector")(function* (
|
|
367
|
+
projectDir: string,
|
|
368
|
+
selector: string,
|
|
369
|
+
) {
|
|
242
370
|
const parsed = parseSkillSelector(selector);
|
|
243
|
-
|
|
371
|
+
|
|
372
|
+
if (parsed?.type !== "package")
|
|
373
|
+
return yield* new PackageSkillSourceError({
|
|
374
|
+
message: `invalid package skill selector: ${selector}`,
|
|
375
|
+
});
|
|
244
376
|
const directDependencies = yield* readDirectDependencyNames(projectDir);
|
|
245
|
-
|
|
377
|
+
|
|
378
|
+
if (!directDependencies.includes(parsed.package))
|
|
379
|
+
return yield* new PackageSkillSourceError({
|
|
380
|
+
message: `package skill package is not a direct dependency: ${parsed.package}`,
|
|
381
|
+
});
|
|
382
|
+
|
|
246
383
|
return yield* inspectPackageSkill(
|
|
247
384
|
yield* loadInstalledPackageSkills(projectDir, parsed.package),
|
|
248
385
|
parsed.skill,
|
package/src/path-digest.ts
CHANGED
|
@@ -2,9 +2,7 @@ import { Crypto, Effect, Encoding, FileSystem, Path, type PlatformError, Schema
|
|
|
2
2
|
|
|
3
3
|
import { observeSymbolicLink } from "./node-symbolic-link.ts";
|
|
4
4
|
|
|
5
|
-
export const DigestSchema = Schema.String.check(
|
|
6
|
-
Schema.isPattern(/^sha256:[0-9a-f]{64}$/),
|
|
7
|
-
);
|
|
5
|
+
export const DigestSchema = Schema.String.check(Schema.isPattern(/^sha256:[0-9a-f]{64}$/));
|
|
8
6
|
export type Digest = typeof DigestSchema.Type;
|
|
9
7
|
|
|
10
8
|
export type ObservedPath =
|
|
@@ -26,21 +24,30 @@ export class PathInspectionError extends Schema.TaggedErrorClass<PathInspectionE
|
|
|
26
24
|
|
|
27
25
|
const textEncoder = new TextEncoder();
|
|
28
26
|
|
|
27
|
+
// Git preserves only the executable distinction for regular files. Canonicalizing
|
|
28
|
+
// the remaining bits keeps digests stable across checkout and copy umasks.
|
|
29
|
+
const canonicalFileMode = (mode: number): number => ((mode & 0o111) === 0 ? 0o644 : 0o755);
|
|
30
|
+
const rawFileMode = (mode: number): number => mode & 0o777;
|
|
31
|
+
|
|
29
32
|
const frame = (value: string | Uint8Array): Uint8Array => {
|
|
30
33
|
const bytes = typeof value === "string" ? textEncoder.encode(value) : value;
|
|
31
34
|
const framed = new Uint8Array(4 + bytes.length);
|
|
35
|
+
|
|
32
36
|
new DataView(framed.buffer).setUint32(0, bytes.length);
|
|
33
37
|
framed.set(bytes, 4);
|
|
38
|
+
|
|
34
39
|
return framed;
|
|
35
40
|
};
|
|
36
41
|
|
|
37
42
|
const concatenate = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
|
|
38
43
|
const combined = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.length, 0));
|
|
39
44
|
let offset = 0;
|
|
45
|
+
|
|
40
46
|
for (const chunk of chunks) {
|
|
41
47
|
combined.set(chunk, offset);
|
|
42
48
|
offset += chunk.length;
|
|
43
49
|
}
|
|
50
|
+
|
|
44
51
|
return combined;
|
|
45
52
|
};
|
|
46
53
|
|
|
@@ -48,10 +55,13 @@ const compareUtf8 = (left: string, right: string): number => {
|
|
|
48
55
|
const leftBytes = textEncoder.encode(left);
|
|
49
56
|
const rightBytes = textEncoder.encode(right);
|
|
50
57
|
const sharedLength = Math.min(leftBytes.length, rightBytes.length);
|
|
58
|
+
|
|
51
59
|
for (let index = 0; index < sharedLength; index += 1) {
|
|
52
60
|
const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0);
|
|
61
|
+
|
|
53
62
|
if (difference !== 0) return difference;
|
|
54
63
|
}
|
|
64
|
+
|
|
55
65
|
return leftBytes.length - rightBytes.length;
|
|
56
66
|
};
|
|
57
67
|
|
|
@@ -60,12 +70,18 @@ const digestFrames = Effect.fn("digestPathFrames")(function* (
|
|
|
60
70
|
) {
|
|
61
71
|
const crypto = yield* Crypto.Crypto;
|
|
62
72
|
const digest = yield* crypto.digest("SHA-256", concatenate(values.map(frame)));
|
|
73
|
+
|
|
63
74
|
return `sha256:${Encoding.encodeHex(digest)}`;
|
|
64
75
|
});
|
|
65
76
|
|
|
66
77
|
const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
|
|
67
78
|
absolutePath: string,
|
|
68
|
-
|
|
79
|
+
fileMode: (mode: number) => number,
|
|
80
|
+
): Effect.fn.Return<
|
|
81
|
+
ObservedPath,
|
|
82
|
+
PlatformError.PlatformError | PathInspectionError,
|
|
83
|
+
FileSystem.FileSystem | Path.Path | Crypto.Crypto
|
|
84
|
+
> {
|
|
69
85
|
const fs = yield* FileSystem.FileSystem;
|
|
70
86
|
const path = yield* Path.Path;
|
|
71
87
|
const symbolicLink = yield* observeSymbolicLink(absolutePath);
|
|
@@ -78,11 +94,14 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
|
|
|
78
94
|
};
|
|
79
95
|
}
|
|
80
96
|
|
|
81
|
-
const info = yield* fs
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
const info = yield* fs
|
|
98
|
+
.stat(absolutePath)
|
|
99
|
+
.pipe(
|
|
100
|
+
Effect.catch((error) =>
|
|
101
|
+
error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
|
|
86
105
|
if (info === undefined) return { kind: "missing" };
|
|
87
106
|
|
|
88
107
|
if (info.type === "File") {
|
|
@@ -90,7 +109,7 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
|
|
|
90
109
|
kind: "file",
|
|
91
110
|
digest: yield* digestFrames([
|
|
92
111
|
"file-v1",
|
|
93
|
-
String(info.mode
|
|
112
|
+
String(fileMode(info.mode)),
|
|
94
113
|
yield* fs.readFile(absolutePath),
|
|
95
114
|
]),
|
|
96
115
|
};
|
|
@@ -99,9 +118,11 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
|
|
|
99
118
|
if (info.type === "Directory") {
|
|
100
119
|
const entries = (yield* fs.readDirectory(absolutePath)).sort(compareUtf8);
|
|
101
120
|
const frames: Array<string | Uint8Array> = ["directory-v1"];
|
|
121
|
+
|
|
102
122
|
for (const entry of entries) {
|
|
103
123
|
const childPath = path.join(absolutePath, entry);
|
|
104
|
-
const child = yield* digestFileSystemPath(childPath);
|
|
124
|
+
const child = yield* digestFileSystemPath(childPath, fileMode);
|
|
125
|
+
|
|
105
126
|
if (child.kind === "missing") {
|
|
106
127
|
return yield* new PathInspectionError({
|
|
107
128
|
path: childPath,
|
|
@@ -111,6 +132,7 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
|
|
|
111
132
|
}
|
|
112
133
|
frames.push(entry, child.kind, child.digest);
|
|
113
134
|
}
|
|
135
|
+
|
|
114
136
|
return { kind: "directory", digest: yield* digestFrames(frames) };
|
|
115
137
|
}
|
|
116
138
|
|
|
@@ -122,7 +144,19 @@ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
|
|
|
122
144
|
});
|
|
123
145
|
|
|
124
146
|
export const observePath = Effect.fn("observeManagedPath")(function* (absolutePath: string) {
|
|
125
|
-
return yield* digestFileSystemPath(absolutePath).pipe(
|
|
147
|
+
return yield* digestFileSystemPath(absolutePath, canonicalFileMode).pipe(
|
|
148
|
+
Effect.mapError((cause) =>
|
|
149
|
+
cause instanceof PathInspectionError
|
|
150
|
+
? cause
|
|
151
|
+
: new PathInspectionError({ path: absolutePath, operation: "inspect", cause }),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export const observePathWithRawModes = Effect.fn("observeManagedPathWithRawModes")(function* (
|
|
157
|
+
absolutePath: string,
|
|
158
|
+
) {
|
|
159
|
+
return yield* digestFileSystemPath(absolutePath, rawFileMode).pipe(
|
|
126
160
|
Effect.mapError((cause) =>
|
|
127
161
|
cause instanceof PathInspectionError
|
|
128
162
|
? cause
|
|
@@ -139,7 +173,7 @@ export const digestFileContent = Effect.fn("digestFileContent")(function* (
|
|
|
139
173
|
value: string,
|
|
140
174
|
mode = 0o644,
|
|
141
175
|
) {
|
|
142
|
-
return yield* digestFrames(["file-v1", String(mode), value]);
|
|
176
|
+
return yield* digestFrames(["file-v1", String(canonicalFileMode(mode)), value]);
|
|
143
177
|
});
|
|
144
178
|
|
|
145
179
|
export const digestSymlinkTarget = Effect.fn("digestSymlinkTarget")(function* (target: string) {
|