@appstrate/validation 1.0.1 → 1.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/README.md +165 -1
- package/package.json +1 -1
- package/src/dependencies.ts +9 -9
- package/src/index.ts +28 -29
- package/src/naming.ts +22 -0
package/README.md
CHANGED
|
@@ -1 +1,165 @@
|
|
|
1
|
-
# validation
|
|
1
|
+
# @appstrate/validation
|
|
2
|
+
|
|
3
|
+
Shared validation library for the Appstrate ecosystem. Provides manifest schemas (Zod), package ZIP parsing, naming helpers, dependency extraction, and integrity hashing — used by both the [Appstrate platform](https://github.com/appstrate/strate) and the [Appstrate [registry]](https://github.com/appstrate/registry).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
# From npm
|
|
9
|
+
bun add @appstrate/validation
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Exports
|
|
13
|
+
|
|
14
|
+
The package exposes five entry points:
|
|
15
|
+
|
|
16
|
+
| Import path | Description |
|
|
17
|
+
| ------------------------------------ | ----------------------------------------------------- |
|
|
18
|
+
| `@appstrate/validation` | Manifest schemas, validators, skill/extension helpers |
|
|
19
|
+
| `@appstrate/validation/zip` | ZIP parsing, artifact creation, package extraction |
|
|
20
|
+
| `@appstrate/validation/naming` | Scoped name helpers, slug regex, packageId conversion |
|
|
21
|
+
| `@appstrate/validation/dependencies` | Registry dependency extraction from manifests |
|
|
22
|
+
| `@appstrate/validation/integrity` | SHA256 integrity hash computation |
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Manifest Validation
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { validateManifest, baseManifestSchema, flowManifestSchema } from "@appstrate/validation";
|
|
30
|
+
|
|
31
|
+
// Validate any manifest (auto-dispatches by type)
|
|
32
|
+
const result = validateManifest(rawJson);
|
|
33
|
+
if (result.valid) {
|
|
34
|
+
console.log(result.manifest); // typed BaseManifest | FlowManifest
|
|
35
|
+
} else {
|
|
36
|
+
console.error(result.errors); // string[]
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Three manifest schemas are available:
|
|
41
|
+
|
|
42
|
+
- **`baseManifestSchema`** — Common fields for all types: `name` (`@scope/package-name`), `version` (semver), `type` (`flow` | `skill` | `extension`), optional `description`, `keywords`, `license`, `registryDependencies`
|
|
43
|
+
- **`flowManifestSchema`** — Extends base with flow-specific fields: `schemaVersion`, `displayName`, `author`, `requires` (services, skills, extensions), `input`/`output`/`config` schemas, `execution` options
|
|
44
|
+
- **`localFlowManifestSchema`** — Relaxed schema for strate built-in flows (slug name instead of scoped, optional version)
|
|
45
|
+
|
|
46
|
+
### Skill & Extension Validation
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { extractSkillMeta, validateExtensionSource } from "@appstrate/validation";
|
|
50
|
+
|
|
51
|
+
// Extract YAML frontmatter from SKILL.md
|
|
52
|
+
const meta = extractSkillMeta(skillMdContent);
|
|
53
|
+
// { name: "my-skill", description: "...", warnings: [] }
|
|
54
|
+
|
|
55
|
+
// Validate TypeScript extension source
|
|
56
|
+
const result = validateExtensionSource(tsSource);
|
|
57
|
+
// { valid: true, errors: [], warnings: [] }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### ZIP Parsing
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { parsePackageZip, PackageZipError } from "@appstrate/validation/zip";
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const parsed = parsePackageZip(zipBuffer);
|
|
67
|
+
// { manifest, content, files, type }
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err instanceof PackageZipError) {
|
|
70
|
+
console.error(err.code, err.message);
|
|
71
|
+
// Codes: FILE_TOO_LARGE, ZIP_INVALID, ZIP_BOMB, MISSING_MANIFEST,
|
|
72
|
+
// INVALID_MANIFEST, MISSING_CONTENT, INVALID_CONTENT
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Lower-level ZIP utilities:
|
|
78
|
+
|
|
79
|
+
- **`zipArtifact(entries, level?)`** — Create a ZIP from file entries
|
|
80
|
+
- **`unzipArtifact(buffer)`** — Decompress with path sanitization (filters `..`, `__MACOSX`, absolute paths)
|
|
81
|
+
- **`detectFolderWrapper(entries)`** — Detect single-folder wrapping in ZIP
|
|
82
|
+
|
|
83
|
+
### Naming Helpers
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import {
|
|
87
|
+
SLUG_REGEX,
|
|
88
|
+
normalizeScope,
|
|
89
|
+
stripScope,
|
|
90
|
+
scopedNameToPackageId,
|
|
91
|
+
packageIdToScopedName,
|
|
92
|
+
depEntryToPackageId,
|
|
93
|
+
} from "@appstrate/validation/naming";
|
|
94
|
+
|
|
95
|
+
normalizeScope("myscope"); // "@myscope"
|
|
96
|
+
stripScope("@myscope"); // "myscope"
|
|
97
|
+
scopedNameToPackageId("@scope/name"); // "scope--name"
|
|
98
|
+
packageIdToScopedName("scope--name"); // "@scope/name"
|
|
99
|
+
packageIdToScopedName("local-slug"); // null
|
|
100
|
+
depEntryToPackageId("@scope", "name"); // "scope--name"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Dependency Extraction
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { extractDependencies } from "@appstrate/validation/dependencies";
|
|
107
|
+
import type { DepEntry } from "@appstrate/validation/dependencies";
|
|
108
|
+
|
|
109
|
+
const deps: DepEntry[] = extractDependencies(manifest);
|
|
110
|
+
// [{ depScope: "@scope", depName: "tool", depType: "skill", versionRange: "^1.0.0" }]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Integrity
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { computeIntegrity } from "@appstrate/validation/integrity";
|
|
117
|
+
|
|
118
|
+
const sri = computeIntegrity(zipBuffer);
|
|
119
|
+
// "sha256-abc123..."
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Project Structure
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
@appstrate/validation/
|
|
126
|
+
├── src/
|
|
127
|
+
│ ├── index.ts # Manifest schemas (Zod), validateManifest, extractSkillMeta, validateExtensionSource
|
|
128
|
+
│ ├── zip.ts # ZIP parse/create, parsePackageZip, PackageZipError
|
|
129
|
+
│ ├── naming.ts # SLUG_REGEX, normalizeScope, stripScope, scopedNameToPackageId, packageIdToScopedName
|
|
130
|
+
│ ├── dependencies.ts # extractDependencies, DepEntry type
|
|
131
|
+
│ └── integrity.ts # computeIntegrity (SHA256 SRI hash)
|
|
132
|
+
│
|
|
133
|
+
├── tests/ # bun:test (63 tests across 5 files)
|
|
134
|
+
│ ├── index.test.ts # Manifest validation (flow, skill, extension, base)
|
|
135
|
+
│ ├── zip.test.ts # ZIP parsing, folder detection, error codes, security
|
|
136
|
+
│ ├── naming.test.ts # Naming helpers, packageId conversion
|
|
137
|
+
│ ├── dependencies.test.ts # Dependency extraction from manifests
|
|
138
|
+
│ └── integrity.test.ts # SHA256 integrity computation
|
|
139
|
+
│
|
|
140
|
+
├── package.json
|
|
141
|
+
├── tsconfig.json
|
|
142
|
+
└── eslint.config.js
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
bun test # Run all tests (63 tests, 5 files)
|
|
149
|
+
bun run check # TypeScript type-check + ESLint + Prettier
|
|
150
|
+
bun run lint # ESLint only
|
|
151
|
+
bun run format # Prettier format
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Tech Stack
|
|
155
|
+
|
|
156
|
+
- **Runtime**: Bun
|
|
157
|
+
- **Validation**: Zod 4
|
|
158
|
+
- **Semver**: semver (range validation, version parsing)
|
|
159
|
+
- **ZIP**: fflate (compression/decompression)
|
|
160
|
+
- **Testing**: bun:test (built-in)
|
|
161
|
+
- **Code quality**: ESLint + Prettier + TypeScript strict mode
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
package/package.json
CHANGED
package/src/dependencies.ts
CHANGED
|
@@ -6,20 +6,18 @@ export interface DepEntry {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function extractDependencies(manifest: Record<string, unknown>): DepEntry[] {
|
|
9
|
-
const
|
|
9
|
+
const registryDependencies = manifest.registryDependencies as
|
|
10
10
|
| {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
extensions?: Record<string, string>;
|
|
14
|
-
};
|
|
11
|
+
skills?: Record<string, string>;
|
|
12
|
+
extensions?: Record<string, string>;
|
|
15
13
|
}
|
|
16
14
|
| undefined;
|
|
17
15
|
|
|
18
|
-
if (!
|
|
16
|
+
if (!registryDependencies) return [];
|
|
19
17
|
|
|
20
18
|
const deps: DepEntry[] = [];
|
|
21
19
|
|
|
22
|
-
const { skills = {}, extensions = {} } =
|
|
20
|
+
const { skills = {}, extensions = {} } = registryDependencies;
|
|
23
21
|
|
|
24
22
|
for (const [fullName, versionRange] of Object.entries(skills)) {
|
|
25
23
|
const { scope, name } = parseScopedName(fullName);
|
|
@@ -35,9 +33,11 @@ export function extractDependencies(manifest: Record<string, unknown>): DepEntry
|
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
function parseScopedName(fullName: string): { scope: string; name: string } {
|
|
38
|
-
const match = fullName.match(
|
|
36
|
+
const match = fullName.match(
|
|
37
|
+
/^(@[a-z0-9]([a-z0-9-]*[a-z0-9])?)\/([a-z0-9]([a-z0-9-]*[a-z0-9])?)$/,
|
|
38
|
+
);
|
|
39
39
|
if (!match) {
|
|
40
40
|
throw new Error(`Invalid scoped package name: ${fullName}`);
|
|
41
41
|
}
|
|
42
|
-
return { scope: match[1]!, name: match[
|
|
42
|
+
return { scope: match[1]!, name: match[3]! };
|
|
43
43
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { SLUG_REGEX };
|
|
|
6
6
|
|
|
7
7
|
const flowFieldTypeEnum = z.enum(["string", "number", "boolean", "array", "object", "file"]);
|
|
8
8
|
|
|
9
|
-
const jsonSchemaPropertySchema = z.object({
|
|
9
|
+
export const jsonSchemaPropertySchema = z.object({
|
|
10
10
|
type: flowFieldTypeEnum,
|
|
11
11
|
description: z.string().optional(),
|
|
12
12
|
default: z.unknown().optional(),
|
|
@@ -19,16 +19,16 @@ const jsonSchemaPropertySchema = z.object({
|
|
|
19
19
|
maxFiles: z.number().int().positive().optional(),
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
const jsonSchemaObjectSchema = z.object({
|
|
22
|
+
export const jsonSchemaObjectSchema = z.object({
|
|
23
23
|
type: z.literal("object"),
|
|
24
24
|
properties: z.record(z.string(), jsonSchemaPropertySchema),
|
|
25
25
|
required: z.array(z.string()).optional(),
|
|
26
26
|
propertyOrder: z.array(z.string()).optional(),
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
const serviceRequirementSchema = z.object({
|
|
29
|
+
export const serviceRequirementSchema = z.object({
|
|
30
30
|
id: z.string().min(1).regex(SLUG_REGEX, {
|
|
31
|
-
error: "
|
|
31
|
+
error: "Must be a valid slug (a-z, 0-9, hyphens, no leading/trailing hyphen)",
|
|
32
32
|
}),
|
|
33
33
|
provider: z.string(),
|
|
34
34
|
scopes: z.array(z.string()).optional().default([]),
|
|
@@ -36,7 +36,7 @@ const serviceRequirementSchema = z.object({
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
const slugString = z.string().min(1).regex(SLUG_REGEX, {
|
|
39
|
-
error: "
|
|
39
|
+
error: "Must be a valid slug (a-z, 0-9, hyphens, no leading/trailing hyphen)",
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
const semverRangeString = z.string().refine((val) => semver.validRange(val) !== null, {
|
|
@@ -54,8 +54,9 @@ const registryDependenciesSchema = z
|
|
|
54
54
|
// Base manifest schema — common fields for all package types
|
|
55
55
|
// ─────────────────────────────────────────────
|
|
56
56
|
|
|
57
|
-
const scopedNameRegex = /^@[a-z0-9]([a-z0-9-]*[a-z0-9])?\/[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
58
|
-
const packageTypeEnum = z.enum(["flow", "skill", "extension"]);
|
|
57
|
+
export const scopedNameRegex = /^@[a-z0-9]([a-z0-9-]*[a-z0-9])?\/[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
58
|
+
export const packageTypeEnum = z.enum(["flow", "skill", "extension"]);
|
|
59
|
+
export type PackageType = z.infer<typeof packageTypeEnum>;
|
|
59
60
|
|
|
60
61
|
export const baseManifestSchema = z.object({
|
|
61
62
|
name: z.string().regex(scopedNameRegex, { error: "Must follow the format @scope/package-name" }),
|
|
@@ -73,6 +74,12 @@ export const baseManifestSchema = z.object({
|
|
|
73
74
|
|
|
74
75
|
export type BaseManifest = z.infer<typeof baseManifestSchema>;
|
|
75
76
|
|
|
77
|
+
// ─── Sub-schema inferred types ───────────────
|
|
78
|
+
|
|
79
|
+
export type FlowServiceRequirement = z.infer<typeof serviceRequirementSchema>;
|
|
80
|
+
export type FlowJsonSchemaProperty = z.infer<typeof jsonSchemaPropertySchema>;
|
|
81
|
+
export type FlowJsonSchemaObject = z.infer<typeof jsonSchemaObjectSchema>;
|
|
82
|
+
|
|
76
83
|
// ─────────────────────────────────────────────
|
|
77
84
|
// Shared flow fields — used by both flowManifestSchema and localFlowManifestSchema
|
|
78
85
|
// ─────────────────────────────────────────────
|
|
@@ -82,7 +89,6 @@ const flowSharedFields = {
|
|
|
82
89
|
services: z.array(serviceRequirementSchema),
|
|
83
90
|
skills: z.array(slugString).optional().default([]),
|
|
84
91
|
extensions: z.array(slugString).optional().default([]),
|
|
85
|
-
registryDependencies: registryDependenciesSchema,
|
|
86
92
|
}),
|
|
87
93
|
input: z
|
|
88
94
|
.object({
|
|
@@ -122,10 +128,6 @@ export const flowManifestSchema = baseManifestSchema.extend({
|
|
|
122
128
|
|
|
123
129
|
export type FlowManifest = z.infer<typeof flowManifestSchema>;
|
|
124
130
|
|
|
125
|
-
// Keep backward compat — the old manifestSchema was used for flow validation only
|
|
126
|
-
export const manifestSchema = flowManifestSchema;
|
|
127
|
-
export type ManifestSchema = FlowManifest;
|
|
128
|
-
|
|
129
131
|
// ─────────────────────────────────────────────
|
|
130
132
|
// Unified validateManifest — dispatches by type
|
|
131
133
|
// ─────────────────────────────────────────────
|
|
@@ -185,7 +187,7 @@ export function extractSkillMeta(content: string): {
|
|
|
185
187
|
const warnings: string[] = [];
|
|
186
188
|
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
187
189
|
if (!fmMatch) {
|
|
188
|
-
warnings.push("
|
|
190
|
+
warnings.push("No YAML frontmatter detected (expected --- ... --- block)");
|
|
189
191
|
return { name: "", description: "", warnings };
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -197,10 +199,10 @@ export function extractSkillMeta(content: string): {
|
|
|
197
199
|
const description = descMatch ? stripQuotes(descMatch[1]!) : "";
|
|
198
200
|
|
|
199
201
|
if (!name) {
|
|
200
|
-
warnings.push("
|
|
202
|
+
warnings.push("Missing 'name' field in YAML frontmatter");
|
|
201
203
|
}
|
|
202
204
|
if (!description) {
|
|
203
|
-
warnings.push("
|
|
205
|
+
warnings.push("Missing 'description' field in YAML frontmatter");
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
return { name, description, warnings };
|
|
@@ -268,20 +270,19 @@ export function validateExtensionSource(source: string): ExtensionValidationResu
|
|
|
268
270
|
const warnings: string[] = [];
|
|
269
271
|
|
|
270
272
|
if (source.trim().length === 0) {
|
|
271
|
-
return { valid: false, errors: ["
|
|
273
|
+
return { valid: false, errors: ["Extension content is empty"], warnings };
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
if (!/export\s+default\b/.test(source)) {
|
|
275
277
|
errors.push(
|
|
276
|
-
"
|
|
277
|
-
"
|
|
278
|
+
"Extension must have an `export default function`. " +
|
|
279
|
+
"Example: export default function(pi: ExtensionAPI) { ... }",
|
|
278
280
|
);
|
|
279
281
|
}
|
|
280
282
|
|
|
281
283
|
if (!/\.registerTool\s*\(/.test(source)) {
|
|
282
284
|
warnings.push(
|
|
283
|
-
"
|
|
284
|
-
"Assurez-vous d'enregistrer au moins un outil.",
|
|
285
|
+
"Extension does not call `pi.registerTool()`. " + "Make sure to register at least one tool.",
|
|
285
286
|
);
|
|
286
287
|
}
|
|
287
288
|
|
|
@@ -292,10 +293,10 @@ export function validateExtensionSource(source: string): ExtensionValidationResu
|
|
|
292
293
|
const paramCount = countParams(paramStr);
|
|
293
294
|
if (paramCount === 1) {
|
|
294
295
|
errors.push(
|
|
295
|
-
"
|
|
296
|
-
"
|
|
297
|
-
"
|
|
298
|
-
"
|
|
296
|
+
"The `execute` signature has only one parameter. " +
|
|
297
|
+
"The Pi SDK calls execute(toolCallId, params, signal) — with a single parameter, " +
|
|
298
|
+
"your function will receive the toolCallId (string) instead of params. " +
|
|
299
|
+
"Fix: execute(_toolCallId, params, signal) { ... }",
|
|
299
300
|
);
|
|
300
301
|
break;
|
|
301
302
|
}
|
|
@@ -303,8 +304,8 @@ export function validateExtensionSource(source: string): ExtensionValidationResu
|
|
|
303
304
|
|
|
304
305
|
if (executeMatches.length > 0 && !/content\s*:/.test(cleaned)) {
|
|
305
306
|
warnings.push(
|
|
306
|
-
"
|
|
307
|
-
'
|
|
307
|
+
"The `execute` function does not seem to return `{ content: [...] }`. " +
|
|
308
|
+
'Expected format: { content: [{ type: "text", text: "..." }] }',
|
|
308
309
|
);
|
|
309
310
|
}
|
|
310
311
|
|
|
@@ -314,9 +315,7 @@ export function validateExtensionSource(source: string): ExtensionValidationResu
|
|
|
314
315
|
else if (ch === "}") braceCount--;
|
|
315
316
|
}
|
|
316
317
|
if (braceCount !== 0) {
|
|
317
|
-
errors.push(
|
|
318
|
-
`Erreur de syntaxe probable : les accolades ne sont pas equilibrees (difference: ${braceCount})`,
|
|
319
|
-
);
|
|
318
|
+
errors.push(`Probable syntax error: braces are not balanced (difference: ${braceCount})`);
|
|
320
319
|
}
|
|
321
320
|
|
|
322
321
|
return { valid: errors.length === 0, errors, warnings };
|
package/src/naming.ts
CHANGED
|
@@ -8,3 +8,25 @@ export function normalizeScope(scope: string): string {
|
|
|
8
8
|
export function stripScope(scope: string): string {
|
|
9
9
|
return scope.startsWith("@") ? scope.slice(1) : scope;
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/** Convert "@scope/name" to strate packageId "scope--name" */
|
|
13
|
+
export function scopedNameToPackageId(scopedName: string): string {
|
|
14
|
+
const match = scopedName.match(/^@([a-z0-9][a-z0-9-]*[a-z0-9]?)\/([a-z0-9][a-z0-9-]*[a-z0-9]?)$/);
|
|
15
|
+
if (!match) throw new Error(`Invalid scoped package name: ${scopedName}`);
|
|
16
|
+
return `${match[1]}--${match[2]}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Convert strate packageId "scope--name" to "@scope/name", or null for local slugs */
|
|
20
|
+
export function packageIdToScopedName(packageId: string): string | null {
|
|
21
|
+
const idx = packageId.indexOf("--");
|
|
22
|
+
if (idx === -1) return null;
|
|
23
|
+
const scope = packageId.slice(0, idx);
|
|
24
|
+
const name = packageId.slice(idx + 2);
|
|
25
|
+
if (!scope || !name) return null;
|
|
26
|
+
return `@${scope}/${name}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Convert separated scope + name to strate packageId */
|
|
30
|
+
export function depEntryToPackageId(depScope: string, depName: string): string {
|
|
31
|
+
return `${stripScope(depScope)}--${depName}`;
|
|
32
|
+
}
|