@appstrate/validation 1.0.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 +1 -0
- package/package.json +36 -0
- package/src/dependencies.ts +43 -0
- package/src/index.ts +344 -0
- package/src/integrity.ts +6 -0
- package/src/naming.ts +11 -0
- package/src/zip.ts +190 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# validation
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@appstrate/validation",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": ["src"],
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./zip": "./src/zip.ts",
|
|
9
|
+
"./integrity": "./src/integrity.ts",
|
|
10
|
+
"./naming": "./src/naming.ts",
|
|
11
|
+
"./dependencies": "./src/dependencies.ts"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "bun test",
|
|
15
|
+
"check": "tsc --noEmit && eslint src/ && prettier --check src/",
|
|
16
|
+
"lint": "eslint src/",
|
|
17
|
+
"lint:fix": "eslint src/ --fix && prettier --write src/",
|
|
18
|
+
"format": "prettier --write src/",
|
|
19
|
+
"format:check": "prettier --check src/"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"fflate": "^0.8.0",
|
|
23
|
+
"semver": "^7.7.1",
|
|
24
|
+
"zod": "^4.3.6"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@eslint/js": "^10.0.1",
|
|
28
|
+
"@types/bun": "latest",
|
|
29
|
+
"@types/semver": "^7.5.8",
|
|
30
|
+
"eslint": "^10.0.0",
|
|
31
|
+
"eslint-config-prettier": "^10.1.8",
|
|
32
|
+
"globals": "^17.3.0",
|
|
33
|
+
"prettier": "^3.8.1",
|
|
34
|
+
"typescript-eslint": "^8.55.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface DepEntry {
|
|
2
|
+
depScope: string;
|
|
3
|
+
depName: string;
|
|
4
|
+
depType: "skill" | "extension";
|
|
5
|
+
versionRange: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function extractDependencies(manifest: Record<string, unknown>): DepEntry[] {
|
|
9
|
+
const requires = manifest.requires as
|
|
10
|
+
| {
|
|
11
|
+
registryDependencies?: {
|
|
12
|
+
skills?: Record<string, string>;
|
|
13
|
+
extensions?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
| undefined;
|
|
17
|
+
|
|
18
|
+
if (!requires?.registryDependencies) return [];
|
|
19
|
+
|
|
20
|
+
const deps: DepEntry[] = [];
|
|
21
|
+
|
|
22
|
+
const { skills = {}, extensions = {} } = requires.registryDependencies;
|
|
23
|
+
|
|
24
|
+
for (const [fullName, versionRange] of Object.entries(skills)) {
|
|
25
|
+
const { scope, name } = parseScopedName(fullName);
|
|
26
|
+
deps.push({ depScope: scope, depName: name, depType: "skill", versionRange });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const [fullName, versionRange] of Object.entries(extensions)) {
|
|
30
|
+
const { scope, name } = parseScopedName(fullName);
|
|
31
|
+
deps.push({ depScope: scope, depName: name, depType: "extension", versionRange });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return deps;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseScopedName(fullName: string): { scope: string; name: string } {
|
|
38
|
+
const match = fullName.match(/^(@[a-z0-9-]+)\/([a-z0-9-]+)$/);
|
|
39
|
+
if (!match) {
|
|
40
|
+
throw new Error(`Invalid scoped package name: ${fullName}`);
|
|
41
|
+
}
|
|
42
|
+
return { scope: match[1]!, name: match[2]! };
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import semver from "semver";
|
|
3
|
+
|
|
4
|
+
export const SLUG_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
5
|
+
|
|
6
|
+
const flowFieldTypeEnum = z.enum(["string", "number", "boolean", "array", "object", "file"]);
|
|
7
|
+
|
|
8
|
+
const jsonSchemaPropertySchema = z.object({
|
|
9
|
+
type: flowFieldTypeEnum,
|
|
10
|
+
description: z.string().optional(),
|
|
11
|
+
default: z.unknown().optional(),
|
|
12
|
+
enum: z.array(z.unknown()).optional(),
|
|
13
|
+
format: z.string().optional(),
|
|
14
|
+
placeholder: z.string().optional(),
|
|
15
|
+
accept: z.string().optional(),
|
|
16
|
+
maxSize: z.number().positive().optional(),
|
|
17
|
+
multiple: z.boolean().optional(),
|
|
18
|
+
maxFiles: z.number().int().positive().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const jsonSchemaObjectSchema = z.object({
|
|
22
|
+
type: z.literal("object"),
|
|
23
|
+
properties: z.record(z.string(), jsonSchemaPropertySchema),
|
|
24
|
+
required: z.array(z.string()).optional(),
|
|
25
|
+
propertyOrder: z.array(z.string()).optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const serviceRequirementSchema = z.object({
|
|
29
|
+
id: z.string().min(1).regex(SLUG_REGEX, {
|
|
30
|
+
error: "Doit etre un slug valide (a-z, 0-9, tirets, pas de tiret en debut/fin)",
|
|
31
|
+
}),
|
|
32
|
+
provider: z.string(),
|
|
33
|
+
scopes: z.array(z.string()).optional().default([]),
|
|
34
|
+
connectionMode: z.enum(["user", "admin"]).optional().default("user"),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const slugString = z.string().min(1).regex(SLUG_REGEX, {
|
|
38
|
+
error: "Doit etre un slug valide (a-z, 0-9, tirets, pas de tiret en debut/fin)",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const semverRangeString = z.string().refine((val) => semver.validRange(val) !== null, {
|
|
42
|
+
error: "Must be a valid semver range (e.g. ^1.0.0, ~2.1, >=3.0.0)",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const registryDependenciesSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
skills: z.record(z.string(), semverRangeString).optional().default({}),
|
|
48
|
+
extensions: z.record(z.string(), semverRangeString).optional().default({}),
|
|
49
|
+
})
|
|
50
|
+
.optional();
|
|
51
|
+
|
|
52
|
+
// ─────────────────────────────────────────────
|
|
53
|
+
// Base manifest schema — common fields for all package types
|
|
54
|
+
// ─────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const scopedNameRegex = /^@[a-z0-9]([a-z0-9-]*[a-z0-9])?\/[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
57
|
+
const packageTypeEnum = z.enum(["flow", "skill", "extension"]);
|
|
58
|
+
|
|
59
|
+
export const baseManifestSchema = z.object({
|
|
60
|
+
name: z.string().regex(scopedNameRegex, { error: "Must follow the format @scope/package-name" }),
|
|
61
|
+
version: z.string().refine((v) => semver.valid(v) !== null, {
|
|
62
|
+
error: "Must be a valid semver version",
|
|
63
|
+
}),
|
|
64
|
+
type: packageTypeEnum,
|
|
65
|
+
description: z.string().optional().catch(undefined),
|
|
66
|
+
keywords: z.array(z.string()).optional().catch(undefined),
|
|
67
|
+
license: z.string().optional().catch(undefined),
|
|
68
|
+
repository: z.string().optional().catch(undefined),
|
|
69
|
+
readme: z.string().optional().catch(undefined),
|
|
70
|
+
registryDependencies: registryDependenciesSchema,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export type BaseManifest = z.infer<typeof baseManifestSchema>;
|
|
74
|
+
|
|
75
|
+
// ─────────────────────────────────────────────
|
|
76
|
+
// Flow manifest schema — extends base with flow-specific fields
|
|
77
|
+
// ─────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export const flowManifestSchema = baseManifestSchema.extend({
|
|
80
|
+
$schema: z.string().optional(),
|
|
81
|
+
schemaVersion: z.string(),
|
|
82
|
+
displayName: z.string().min(1),
|
|
83
|
+
author: z.string(),
|
|
84
|
+
tags: z.array(z.string()).optional(),
|
|
85
|
+
requires: z.object({
|
|
86
|
+
services: z.array(serviceRequirementSchema),
|
|
87
|
+
skills: z.array(slugString).optional().default([]),
|
|
88
|
+
extensions: z.array(slugString).optional().default([]),
|
|
89
|
+
registryDependencies: registryDependenciesSchema,
|
|
90
|
+
}),
|
|
91
|
+
input: z
|
|
92
|
+
.object({
|
|
93
|
+
schema: jsonSchemaObjectSchema,
|
|
94
|
+
})
|
|
95
|
+
.optional(),
|
|
96
|
+
output: z
|
|
97
|
+
.object({
|
|
98
|
+
schema: jsonSchemaObjectSchema,
|
|
99
|
+
})
|
|
100
|
+
.optional(),
|
|
101
|
+
config: z
|
|
102
|
+
.object({
|
|
103
|
+
schema: jsonSchemaObjectSchema,
|
|
104
|
+
})
|
|
105
|
+
.optional(),
|
|
106
|
+
execution: z
|
|
107
|
+
.object({
|
|
108
|
+
timeout: z.number().optional(),
|
|
109
|
+
outputRetries: z.number().min(0).max(5).optional(),
|
|
110
|
+
})
|
|
111
|
+
.optional(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export type FlowManifest = z.infer<typeof flowManifestSchema>;
|
|
115
|
+
|
|
116
|
+
// Keep backward compat — the old manifestSchema was used for flow validation only
|
|
117
|
+
export const manifestSchema = flowManifestSchema;
|
|
118
|
+
export type ManifestSchema = FlowManifest;
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────
|
|
121
|
+
// Unified validateManifest — dispatches by type
|
|
122
|
+
// ─────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export function validateManifest(raw: unknown): {
|
|
125
|
+
valid: boolean;
|
|
126
|
+
errors: string[];
|
|
127
|
+
manifest?: unknown;
|
|
128
|
+
} {
|
|
129
|
+
// First, check if it has a type field to dispatch
|
|
130
|
+
if (raw && typeof raw === "object" && "type" in raw) {
|
|
131
|
+
const obj = raw as Record<string, unknown>;
|
|
132
|
+
|
|
133
|
+
if (obj.type === "flow") {
|
|
134
|
+
return validateFlowManifest(raw);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// For skill/extension, validate with base schema only
|
|
138
|
+
const result = baseManifestSchema.safeParse(raw);
|
|
139
|
+
if (result.success) {
|
|
140
|
+
return { valid: true, errors: [], manifest: result.data };
|
|
141
|
+
}
|
|
142
|
+
const errors = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
|
143
|
+
return { valid: false, errors };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// No type field — try flow manifest validation (backward compat for flow-only manifests
|
|
147
|
+
// that don't have name/version/type because they rely on the old format)
|
|
148
|
+
return validateFlowManifest(raw);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateFlowManifest(raw: unknown): {
|
|
152
|
+
valid: boolean;
|
|
153
|
+
errors: string[];
|
|
154
|
+
manifest?: unknown;
|
|
155
|
+
} {
|
|
156
|
+
const result = flowManifestSchema.safeParse(raw);
|
|
157
|
+
if (result.success) {
|
|
158
|
+
return { valid: true, errors: [], manifest: result.data };
|
|
159
|
+
}
|
|
160
|
+
const errors = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
|
161
|
+
return { valid: false, errors };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stripQuotes(value: string): string {
|
|
165
|
+
const trimmed = value.trim();
|
|
166
|
+
if (
|
|
167
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
168
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
169
|
+
) {
|
|
170
|
+
return trimmed.slice(1, -1);
|
|
171
|
+
}
|
|
172
|
+
return trimmed;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function extractSkillMeta(content: string): {
|
|
176
|
+
name: string;
|
|
177
|
+
description: string;
|
|
178
|
+
warnings: string[];
|
|
179
|
+
} {
|
|
180
|
+
const warnings: string[] = [];
|
|
181
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
182
|
+
if (!fmMatch) {
|
|
183
|
+
warnings.push("Pas de frontmatter YAML detecte (bloc --- ... --- attendu)");
|
|
184
|
+
return { name: "", description: "", warnings };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const fm = fmMatch[1]!;
|
|
188
|
+
const nameMatch = fm.match(/name:\s*(.+)/);
|
|
189
|
+
const descMatch = fm.match(/description:\s*(.+)/);
|
|
190
|
+
|
|
191
|
+
const name = nameMatch ? stripQuotes(nameMatch[1]!) : "";
|
|
192
|
+
const description = descMatch ? stripQuotes(descMatch[1]!) : "";
|
|
193
|
+
|
|
194
|
+
if (!name) {
|
|
195
|
+
warnings.push("Champ 'name' manquant dans le frontmatter YAML");
|
|
196
|
+
}
|
|
197
|
+
if (!description) {
|
|
198
|
+
warnings.push("Champ 'description' manquant dans le frontmatter YAML");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { name, description, warnings };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ExtensionValidationResult {
|
|
205
|
+
valid: boolean;
|
|
206
|
+
errors: string[];
|
|
207
|
+
warnings: string[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function stripLineComments(source: string): string {
|
|
211
|
+
return source.replace(/\/\/.*$/gm, "");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function countParams(paramStr: string): number {
|
|
215
|
+
const trimmed = paramStr.trim();
|
|
216
|
+
if (trimmed === "") return 0;
|
|
217
|
+
|
|
218
|
+
let depth = 0;
|
|
219
|
+
let count = 1;
|
|
220
|
+
for (const ch of trimmed) {
|
|
221
|
+
if (ch === "<" || ch === "(") depth++;
|
|
222
|
+
else if (ch === ">" || ch === ")") depth--;
|
|
223
|
+
else if (ch === "," && depth === 0) count++;
|
|
224
|
+
}
|
|
225
|
+
return count;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─────────────────────────────────────────────
|
|
229
|
+
// Local flow manifest schema — relaxed for strate (no scoped name, optional version)
|
|
230
|
+
// ─────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export const localFlowManifestSchema = z.looseObject({
|
|
233
|
+
$schema: z.string().optional(),
|
|
234
|
+
schemaVersion: z.string(),
|
|
235
|
+
name: slugString,
|
|
236
|
+
displayName: z.string().min(1),
|
|
237
|
+
description: z.string(),
|
|
238
|
+
author: z.string(),
|
|
239
|
+
license: z.string().optional(),
|
|
240
|
+
tags: z.array(z.string()).optional(),
|
|
241
|
+
type: z.string().optional(),
|
|
242
|
+
version: z.string().optional(),
|
|
243
|
+
requires: z.object({
|
|
244
|
+
services: z.array(serviceRequirementSchema),
|
|
245
|
+
skills: z.array(slugString).optional().default([]),
|
|
246
|
+
extensions: z.array(slugString).optional().default([]),
|
|
247
|
+
registryDependencies: registryDependenciesSchema,
|
|
248
|
+
}),
|
|
249
|
+
input: z
|
|
250
|
+
.object({
|
|
251
|
+
schema: jsonSchemaObjectSchema,
|
|
252
|
+
})
|
|
253
|
+
.optional(),
|
|
254
|
+
output: z
|
|
255
|
+
.object({
|
|
256
|
+
schema: jsonSchemaObjectSchema,
|
|
257
|
+
})
|
|
258
|
+
.optional(),
|
|
259
|
+
config: z
|
|
260
|
+
.object({
|
|
261
|
+
schema: jsonSchemaObjectSchema,
|
|
262
|
+
})
|
|
263
|
+
.optional(),
|
|
264
|
+
execution: z
|
|
265
|
+
.object({
|
|
266
|
+
timeout: z.number().optional(),
|
|
267
|
+
outputRetries: z.number().min(0).max(5).optional(),
|
|
268
|
+
})
|
|
269
|
+
.optional(),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
export type LocalFlowManifest = z.infer<typeof localFlowManifestSchema>;
|
|
273
|
+
|
|
274
|
+
export function validateLocalFlowManifest(raw: unknown): {
|
|
275
|
+
valid: boolean;
|
|
276
|
+
errors: string[];
|
|
277
|
+
manifest?: unknown;
|
|
278
|
+
} {
|
|
279
|
+
const result = localFlowManifestSchema.safeParse(raw);
|
|
280
|
+
if (result.success) {
|
|
281
|
+
return { valid: true, errors: [], manifest: result.data };
|
|
282
|
+
}
|
|
283
|
+
const errors = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
|
284
|
+
return { valid: false, errors };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function validateExtensionSource(source: string): ExtensionValidationResult {
|
|
288
|
+
const errors: string[] = [];
|
|
289
|
+
const warnings: string[] = [];
|
|
290
|
+
|
|
291
|
+
if (source.trim().length === 0) {
|
|
292
|
+
return { valid: false, errors: ["Le contenu de l'extension est vide"], warnings };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!/export\s+default\b/.test(source)) {
|
|
296
|
+
errors.push(
|
|
297
|
+
"L'extension doit avoir un `export default function`. " +
|
|
298
|
+
"Exemple : export default function(pi: ExtensionAPI) { ... }",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!/\.registerTool\s*\(/.test(source)) {
|
|
303
|
+
warnings.push(
|
|
304
|
+
"L'extension n'appelle pas `pi.registerTool()`. " +
|
|
305
|
+
"Assurez-vous d'enregistrer au moins un outil.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const cleaned = stripLineComments(source);
|
|
310
|
+
const executeMatches = [...cleaned.matchAll(/execute\s*\(([^)]*)\)/g)];
|
|
311
|
+
for (const match of executeMatches) {
|
|
312
|
+
const paramStr = match[1]!;
|
|
313
|
+
const paramCount = countParams(paramStr);
|
|
314
|
+
if (paramCount === 1) {
|
|
315
|
+
errors.push(
|
|
316
|
+
"La signature `execute` n'a qu'un seul parametre. " +
|
|
317
|
+
"Le Pi SDK appelle execute(toolCallId, params, signal) — avec un seul parametre, " +
|
|
318
|
+
"votre fonction recevra le toolCallId (string) au lieu des params. " +
|
|
319
|
+
"Corrigez : execute(_toolCallId, params, signal) { ... }",
|
|
320
|
+
);
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (executeMatches.length > 0 && !/content\s*:/.test(cleaned)) {
|
|
326
|
+
warnings.push(
|
|
327
|
+
"La fonction `execute` ne semble pas retourner `{ content: [...] }`. " +
|
|
328
|
+
'Le format attendu est : { content: [{ type: "text", text: "..." }] }',
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let braceCount = 0;
|
|
333
|
+
for (const ch of cleaned) {
|
|
334
|
+
if (ch === "{") braceCount++;
|
|
335
|
+
else if (ch === "}") braceCount--;
|
|
336
|
+
}
|
|
337
|
+
if (braceCount !== 0) {
|
|
338
|
+
errors.push(
|
|
339
|
+
`Erreur de syntaxe probable : les accolades ne sont pas equilibrees (difference: ${braceCount})`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
344
|
+
}
|
package/src/integrity.ts
ADDED
package/src/naming.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SLUG_REGEX } from "./index.ts";
|
|
2
|
+
|
|
3
|
+
export { SLUG_REGEX };
|
|
4
|
+
|
|
5
|
+
export function normalizeScope(scope: string): string {
|
|
6
|
+
return scope.startsWith("@") ? scope : `@${scope}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function stripScope(scope: string): string {
|
|
10
|
+
return scope.startsWith("@") ? scope.slice(1) : scope;
|
|
11
|
+
}
|
package/src/zip.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { unzipSync, zipSync, type Zippable } from "fflate";
|
|
2
|
+
import { validateManifest, extractSkillMeta, validateExtensionSource } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
export type { Zippable };
|
|
5
|
+
|
|
6
|
+
export function zipArtifact(
|
|
7
|
+
entries: Zippable,
|
|
8
|
+
level: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 = 6,
|
|
9
|
+
): Uint8Array {
|
|
10
|
+
return zipSync(entries, { level });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface UnzippedArtifact {
|
|
14
|
+
files: Record<string, Uint8Array>;
|
|
15
|
+
prefix: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function unzipArtifact(artifact: Uint8Array): UnzippedArtifact {
|
|
19
|
+
let rawFiles: Record<string, Uint8Array>;
|
|
20
|
+
try {
|
|
21
|
+
rawFiles = unzipSync(artifact);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error("Failed to decompress ZIP artifact");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Sanitize: filter out path traversal, absolute paths, null bytes, __MACOSX, and directory entries
|
|
27
|
+
const files: Record<string, Uint8Array> = {};
|
|
28
|
+
for (const [key, value] of Object.entries(rawFiles)) {
|
|
29
|
+
if (key.includes("..") || key.startsWith("/") || key.includes("\0")) continue;
|
|
30
|
+
if (key.startsWith("__MACOSX/") || key.endsWith("/")) continue;
|
|
31
|
+
files[key] = value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const entries = Object.keys(files);
|
|
35
|
+
const prefix = detectFolderWrapper(entries);
|
|
36
|
+
|
|
37
|
+
return { files, prefix };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function detectFolderWrapper(entries: string[]): string {
|
|
41
|
+
// Filter out __MACOSX and directory entries before detection
|
|
42
|
+
const filtered = entries.filter((e) => !e.startsWith("__MACOSX/") && !e.endsWith("/"));
|
|
43
|
+
if (filtered.length === 0) return "";
|
|
44
|
+
const first = filtered[0];
|
|
45
|
+
if (!first) return "";
|
|
46
|
+
const firstDir = first.split("/")[0];
|
|
47
|
+
if (!firstDir) return "";
|
|
48
|
+
return filtered.every((e) => e.startsWith(firstDir + "/")) ? firstDir + "/" : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getFileText(
|
|
52
|
+
files: Record<string, Uint8Array>,
|
|
53
|
+
prefix: string,
|
|
54
|
+
name: string,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
const data = files[prefix + name] ?? files[name];
|
|
57
|
+
return data ? new TextDecoder().decode(data) : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─────────────────────────────────────────────
|
|
61
|
+
// Unified package ZIP parser — handles flow, skill, extension
|
|
62
|
+
// ─────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export interface ParsedPackageZip {
|
|
65
|
+
manifest: Record<string, unknown>;
|
|
66
|
+
content: string;
|
|
67
|
+
files: Record<string, Uint8Array>;
|
|
68
|
+
type: "flow" | "skill" | "extension";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class PackageZipError extends Error {
|
|
72
|
+
constructor(
|
|
73
|
+
public code: string,
|
|
74
|
+
message: string,
|
|
75
|
+
public details?: unknown,
|
|
76
|
+
) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "PackageZipError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
83
|
+
|
|
84
|
+
export function parsePackageZip(zipBuffer: Uint8Array, maxSize?: number): ParsedPackageZip {
|
|
85
|
+
const limit = maxSize ?? DEFAULT_MAX_SIZE;
|
|
86
|
+
if (zipBuffer.length > limit) {
|
|
87
|
+
throw new PackageZipError(
|
|
88
|
+
"FILE_TOO_LARGE",
|
|
89
|
+
`ZIP exceeds maximum size of ${limit / 1024 / 1024} MB`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let files: Record<string, Uint8Array>;
|
|
94
|
+
let prefix: string;
|
|
95
|
+
try {
|
|
96
|
+
const result = unzipArtifact(zipBuffer);
|
|
97
|
+
files = result.files;
|
|
98
|
+
prefix = result.prefix;
|
|
99
|
+
} catch {
|
|
100
|
+
throw new PackageZipError("ZIP_INVALID", "Failed to decompress ZIP artifact");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Zip bomb protection: check total decompressed size
|
|
104
|
+
const MAX_DECOMPRESSED = 50 * 1024 * 1024; // 50 MB
|
|
105
|
+
const totalSize = Object.values(files).reduce((sum, buf) => sum + buf.length, 0);
|
|
106
|
+
if (totalSize > MAX_DECOMPRESSED) {
|
|
107
|
+
throw new PackageZipError(
|
|
108
|
+
"ZIP_BOMB",
|
|
109
|
+
`Decompressed size (${(totalSize / 1024 / 1024).toFixed(1)} MB) exceeds limit`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Parse manifest.json
|
|
114
|
+
const manifestText = getFileText(files, prefix, "manifest.json");
|
|
115
|
+
if (!manifestText) {
|
|
116
|
+
throw new PackageZipError("MISSING_MANIFEST", "manifest.json not found in ZIP");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let manifestRaw: unknown;
|
|
120
|
+
try {
|
|
121
|
+
manifestRaw = JSON.parse(manifestText);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new PackageZipError("INVALID_MANIFEST", "manifest.json is not valid JSON");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const validation = validateManifest(manifestRaw);
|
|
127
|
+
if (!validation.valid) {
|
|
128
|
+
const detail = validation.errors.join("; ");
|
|
129
|
+
throw new PackageZipError(
|
|
130
|
+
"INVALID_MANIFEST",
|
|
131
|
+
detail ? `Manifest validation failed: ${detail}` : "Manifest validation failed",
|
|
132
|
+
validation.errors,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const manifest = validation.manifest as Record<string, unknown>;
|
|
137
|
+
|
|
138
|
+
// Determine type (fallback "flow" for legacy manifests without type)
|
|
139
|
+
const type = (manifest.type as "flow" | "skill" | "extension") ?? "flow";
|
|
140
|
+
|
|
141
|
+
// Extract primary content based on type
|
|
142
|
+
let content: string;
|
|
143
|
+
|
|
144
|
+
switch (type) {
|
|
145
|
+
case "flow": {
|
|
146
|
+
const promptMd = getFileText(files, prefix, "prompt.md");
|
|
147
|
+
if (!promptMd || promptMd.trim().length === 0) {
|
|
148
|
+
throw new PackageZipError("MISSING_CONTENT", "Flow package must contain prompt.md");
|
|
149
|
+
}
|
|
150
|
+
content = promptMd;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case "skill": {
|
|
154
|
+
const skillMd = getFileText(files, prefix, "SKILL.md");
|
|
155
|
+
if (!skillMd) {
|
|
156
|
+
throw new PackageZipError("MISSING_CONTENT", "Skill package must contain SKILL.md");
|
|
157
|
+
}
|
|
158
|
+
const meta = extractSkillMeta(skillMd);
|
|
159
|
+
if (!meta.name) {
|
|
160
|
+
throw new PackageZipError(
|
|
161
|
+
"INVALID_CONTENT",
|
|
162
|
+
"SKILL.md must contain a 'name' in YAML frontmatter",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
content = skillMd;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "extension": {
|
|
169
|
+
const tsEntry = Object.keys(files).find((f) => {
|
|
170
|
+
const name = prefix && f.startsWith(prefix) ? f.slice(prefix.length) : f;
|
|
171
|
+
return name.endsWith(".ts") && !name.endsWith(".d.ts") && !name.includes("/");
|
|
172
|
+
});
|
|
173
|
+
if (!tsEntry) {
|
|
174
|
+
throw new PackageZipError("MISSING_CONTENT", "Extension package must contain a .ts file");
|
|
175
|
+
}
|
|
176
|
+
const source = new TextDecoder().decode(files[tsEntry]!);
|
|
177
|
+
const extValidation = validateExtensionSource(source);
|
|
178
|
+
if (!extValidation.valid) {
|
|
179
|
+
throw new PackageZipError(
|
|
180
|
+
"INVALID_CONTENT",
|
|
181
|
+
`Extension validation failed: ${extValidation.errors.join("; ")}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
content = source;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { manifest, content, files, type };
|
|
190
|
+
}
|