@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appstrate/validation",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "files": ["src"],
6
6
  "exports": {
@@ -6,20 +6,18 @@ export interface DepEntry {
6
6
  }
7
7
 
8
8
  export function extractDependencies(manifest: Record<string, unknown>): DepEntry[] {
9
- const requires = manifest.requires as
9
+ const registryDependencies = manifest.registryDependencies as
10
10
  | {
11
- registryDependencies?: {
12
- skills?: Record<string, string>;
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 (!requires?.registryDependencies) return [];
16
+ if (!registryDependencies) return [];
19
17
 
20
18
  const deps: DepEntry[] = [];
21
19
 
22
- const { skills = {}, extensions = {} } = requires.registryDependencies;
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(/^(@[a-z0-9-]+)\/([a-z0-9-]+)$/);
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[2]! };
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: "Doit etre un slug valide (a-z, 0-9, tirets, pas de tiret en debut/fin)",
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: "Doit etre un slug valide (a-z, 0-9, tirets, pas de tiret en debut/fin)",
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("Pas de frontmatter YAML detecte (bloc --- ... --- attendu)");
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("Champ 'name' manquant dans le frontmatter YAML");
202
+ warnings.push("Missing 'name' field in YAML frontmatter");
201
203
  }
202
204
  if (!description) {
203
- warnings.push("Champ 'description' manquant dans le frontmatter YAML");
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: ["Le contenu de l'extension est vide"], warnings };
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
- "L'extension doit avoir un `export default function`. " +
277
- "Exemple : export default function(pi: ExtensionAPI) { ... }",
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
- "L'extension n'appelle pas `pi.registerTool()`. " +
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
- "La signature `execute` n'a qu'un seul parametre. " +
296
- "Le Pi SDK appelle execute(toolCallId, params, signal) — avec un seul parametre, " +
297
- "votre fonction recevra le toolCallId (string) au lieu des params. " +
298
- "Corrigez : execute(_toolCallId, params, signal) { ... }",
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
- "La fonction `execute` ne semble pas retourner `{ content: [...] }`. " +
307
- 'Le format attendu est : { content: [{ type: "text", text: "..." }] }',
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
+ }