@appstrate/core 1.0.1 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appstrate/core",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "files": ["src"],
6
6
  "exports": {
@@ -8,23 +8,27 @@
8
8
  "./rate-limit": "./src/rate-limit.ts",
9
9
  "./env": "./src/env.ts",
10
10
  "./storage": "./src/storage.ts",
11
- "./errors": "./src/errors.ts"
11
+ "./errors": "./src/errors.ts",
12
+ "./validation": "./src/validation.ts",
13
+ "./zip": "./src/zip.ts",
14
+ "./naming": "./src/naming.ts",
15
+ "./dependencies": "./src/dependencies.ts",
16
+ "./integrity": "./src/integrity.ts",
17
+ "./semver": "./src/semver.ts",
18
+ "./registry-deps": "./src/registry-deps.ts",
19
+ "./update-check": "./src/update-check.ts",
20
+ "./publish-manifest": "./src/publish-manifest.ts"
12
21
  },
13
22
  "dependencies": {
23
+ "fflate": "^0.8.0",
14
24
  "pino": "^10.3.1",
25
+ "semver": "^7.7.1",
15
26
  "zod": "^4.3.6"
16
27
  },
17
- "scripts": {
18
- "check": "tsc --noEmit && eslint src/ && prettier --check src/",
19
- "lint": "eslint src/",
20
- "lint:fix": "eslint src/ --fix && prettier --write src/",
21
- "format": "prettier --write src/",
22
- "format:check": "prettier --check src/",
23
- "test": "bun test"
24
- },
25
28
  "devDependencies": {
26
29
  "@eslint/js": "^10.0.1",
27
30
  "@types/bun": "latest",
31
+ "@types/semver": "^7.5.8",
28
32
  "eslint": "^10.0.0",
29
33
  "eslint-config-prettier": "^10.1.8",
30
34
  "globals": "^17.3.0",
@@ -33,5 +37,13 @@
33
37
  },
34
38
  "peerDependencies": {
35
39
  "typescript": "^5"
40
+ },
41
+ "scripts": {
42
+ "check": "tsc --noEmit && eslint src/ && prettier --check src/",
43
+ "lint": "eslint src/",
44
+ "lint:fix": "eslint src/ --fix && prettier --write src/",
45
+ "format": "prettier --write src/",
46
+ "format:check": "prettier --check src/",
47
+ "test": "bun test"
36
48
  }
37
49
  }
@@ -0,0 +1,120 @@
1
+ import { parseScopedName } from "./naming.ts";
2
+
3
+ export interface DepEntry {
4
+ depScope: string;
5
+ depName: string;
6
+ depType: "skill" | "extension";
7
+ versionRange: string;
8
+ }
9
+
10
+ export function extractDependencies(manifest: Record<string, unknown>): DepEntry[] {
11
+ const registryDependencies = manifest.registryDependencies as
12
+ | {
13
+ skills?: Record<string, string>;
14
+ extensions?: Record<string, string>;
15
+ }
16
+ | undefined;
17
+
18
+ if (!registryDependencies) return [];
19
+
20
+ const deps: DepEntry[] = [];
21
+
22
+ const { skills = {}, extensions = {} } = registryDependencies;
23
+
24
+ const maps: [Record<string, string>, DepEntry["depType"]][] = [
25
+ [skills, "skill"],
26
+ [extensions, "extension"],
27
+ ];
28
+
29
+ for (const [map, depType] of maps) {
30
+ for (const [fullName, versionRange] of Object.entries(map)) {
31
+ const parsed = parseScopedName(fullName);
32
+ if (!parsed) {
33
+ throw new Error(`Invalid scoped package name: ${fullName}`);
34
+ }
35
+ deps.push({ depScope: `@${parsed.scope}`, depName: parsed.name, depType, versionRange });
36
+ }
37
+ }
38
+
39
+ return deps;
40
+ }
41
+
42
+ export interface CycleCheckResult {
43
+ hasCycle: boolean;
44
+ /** The cycle path if found, e.g. ["@a/pkg", "@b/pkg", "@a/pkg"] */
45
+ cyclePath?: string[];
46
+ }
47
+
48
+ /**
49
+ * BFS-based circular dependency detection.
50
+ * @param publishingId — The package being published/installed (e.g. "@scope/name")
51
+ * @param directDeps — Its direct dependencies
52
+ * @param resolveDeps — Async callback to fetch transitive deps of a package
53
+ */
54
+ export async function detectCycle(
55
+ publishingId: string,
56
+ directDeps: DepEntry[],
57
+ resolveDeps: (scope: string, name: string) => Promise<DepEntry[]>,
58
+ ): Promise<CycleCheckResult> {
59
+ // Fast path: self-reference
60
+ for (const dep of directDeps) {
61
+ const depId = `${dep.depScope}/${dep.depName}`;
62
+ if (depId === publishingId) {
63
+ return { hasCycle: true, cyclePath: [publishingId, depId] };
64
+ }
65
+ }
66
+
67
+ // BFS traversal
68
+ const visited = new Set<string>();
69
+ const parent = new Map<string, string>();
70
+ const queue: string[] = directDeps.map((d) => `${d.depScope}/${d.depName}`);
71
+
72
+ for (const depId of queue) {
73
+ parent.set(depId, publishingId);
74
+ }
75
+
76
+ while (queue.length > 0) {
77
+ const current = queue.shift()!;
78
+ if (visited.has(current)) continue;
79
+ visited.add(current);
80
+
81
+ // Parse scope/name from the key (format: "@scope/name")
82
+ const slashIdx = current.indexOf("/", 1); // skip @ prefix
83
+ if (slashIdx === -1) continue;
84
+ const scope = current.slice(0, slashIdx);
85
+ const name = current.slice(slashIdx + 1);
86
+
87
+ let transitiveDeps: DepEntry[];
88
+ try {
89
+ transitiveDeps = await resolveDeps(scope, name);
90
+ } catch {
91
+ continue;
92
+ }
93
+
94
+ for (const dep of transitiveDeps) {
95
+ const depId = `${dep.depScope}/${dep.depName}`;
96
+
97
+ if (depId === publishingId) {
98
+ // Reconstruct cycle path
99
+ const path: string[] = [publishingId];
100
+ let node: string | undefined = current;
101
+ const chain: string[] = [];
102
+ while (node && node !== publishingId) {
103
+ chain.unshift(node);
104
+ node = parent.get(node);
105
+ }
106
+ path.push(...chain, depId);
107
+ return { hasCycle: true, cyclePath: path };
108
+ }
109
+
110
+ if (!visited.has(depId)) {
111
+ queue.push(depId);
112
+ if (!parent.has(depId)) {
113
+ parent.set(depId, current);
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ return { hasCycle: false };
120
+ }
@@ -0,0 +1,6 @@
1
+ export function computeIntegrity(data: Uint8Array | Buffer): string {
2
+ const hash = new Bun.CryptoHasher("sha256");
3
+ hash.update(data);
4
+ const base64 = hash.digest("base64");
5
+ return `sha256-${base64}`;
6
+ }
package/src/naming.ts ADDED
@@ -0,0 +1,27 @@
1
+ export const SLUG_PATTERN = "[a-z0-9]([a-z0-9-]*[a-z0-9])?";
2
+ export const SLUG_REGEX = new RegExp(`^${SLUG_PATTERN}$`);
3
+
4
+ export function normalizeScope(scope: string): string {
5
+ if (!scope) throw new Error("Scope cannot be empty");
6
+ return scope.startsWith("@") ? scope : `@${scope}`;
7
+ }
8
+
9
+ export function stripScope(scope: string): string {
10
+ return scope.startsWith("@") ? scope.slice(1) : scope;
11
+ }
12
+
13
+ /** Parse "@scope/name" into { scope, name } or null if invalid.
14
+ * Both scope and name must be valid slugs (lowercase alphanumeric + hyphens). */
15
+ const SCOPED_NAME_REGEX = new RegExp(`^@(${SLUG_PATTERN})\\/(${SLUG_PATTERN})$`);
16
+
17
+ export function parseScopedName(scopedName: string): { scope: string; name: string } | null {
18
+ const match = scopedName.match(SCOPED_NAME_REGEX);
19
+ if (!match) return null;
20
+ return { scope: match[1]!, name: match[3]! };
21
+ }
22
+
23
+ /** Build a packageId from separated scope + name. */
24
+ export function buildPackageId(scope: string, name: string): string {
25
+ const s = stripScope(scope);
26
+ return `@${s}/${name}`;
27
+ }
@@ -0,0 +1,24 @@
1
+ import type { RegistryDependencies } from "./registry-deps.ts";
2
+
3
+ /** Prepare a manifest for registry publication by overriding name, version, and deps. */
4
+ export function prepareManifestForPublish(
5
+ currentManifest: Record<string, unknown>,
6
+ scope: string,
7
+ name: string,
8
+ version: string,
9
+ registryDeps: RegistryDependencies | null,
10
+ ): Record<string, unknown> {
11
+ const manifest: Record<string, unknown> = {
12
+ ...currentManifest,
13
+ name: `@${scope}/${name}`,
14
+ version,
15
+ };
16
+
17
+ if (registryDeps) {
18
+ manifest.registryDependencies = registryDeps;
19
+ } else {
20
+ delete manifest.registryDependencies;
21
+ }
22
+
23
+ return manifest;
24
+ }
@@ -0,0 +1,41 @@
1
+ export interface PackageRegistryRow {
2
+ type: string;
3
+ registryScope: string | null;
4
+ registryName: string | null;
5
+ lastPublishedVersion: string | null;
6
+ }
7
+
8
+ export interface RegistryDependencies {
9
+ skills?: Record<string, string>;
10
+ extensions?: Record<string, string>;
11
+ }
12
+
13
+ /** Transform DB rows into registryDependencies format for manifest.
14
+ * Skips rows without registryScope/registryName.
15
+ * Returns null if no registry-linked dependencies found. */
16
+ export function buildRegistryDepsFromRows(rows: PackageRegistryRow[]): RegistryDependencies | null {
17
+ const skills: Record<string, string> = {};
18
+ const extensions: Record<string, string> = {};
19
+
20
+ for (const row of rows) {
21
+ if (!row.registryScope || !row.registryName) continue;
22
+
23
+ const scopedName = `@${row.registryScope}/${row.registryName}`;
24
+ const version = row.lastPublishedVersion || "*";
25
+
26
+ if (row.type === "skill") {
27
+ skills[scopedName] = version;
28
+ } else if (row.type === "extension") {
29
+ extensions[scopedName] = version;
30
+ }
31
+ }
32
+
33
+ const hasSkills = Object.keys(skills).length > 0;
34
+ const hasExtensions = Object.keys(extensions).length > 0;
35
+ if (!hasSkills && !hasExtensions) return null;
36
+
37
+ const result: RegistryDependencies = {};
38
+ if (hasSkills) result.skills = skills;
39
+ if (hasExtensions) result.extensions = extensions;
40
+ return result;
41
+ }
package/src/semver.ts ADDED
@@ -0,0 +1,73 @@
1
+ import semver from "semver";
2
+
3
+ /** Check whether `v` is a valid semver version string. */
4
+ export function isValidVersion(v: string): boolean {
5
+ return semver.valid(v) !== null;
6
+ }
7
+
8
+ /** Check whether `v` is a valid semver range string. */
9
+ export function isValidRange(v: string): boolean {
10
+ return semver.validRange(v) !== null;
11
+ }
12
+
13
+ /** Return `true` if version `a` is strictly greater than version `b`. Returns `false` if either is invalid. */
14
+ export function versionGt(a: string, b: string): boolean {
15
+ try {
16
+ return semver.gt(a, b);
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /** Comparator for sorting versions in descending order (highest first). */
23
+ export function compareVersionsDesc(a: string, b: string): number {
24
+ return semver.rcompare(a, b);
25
+ }
26
+
27
+ /** Find the highest version in `versions` that satisfies `range`, or `null` if none match. */
28
+ export function matchVersion(versions: string[], range: string): string | null {
29
+ return semver.maxSatisfying(versions, range);
30
+ }
31
+
32
+ /** Return `true` if `v` is a prerelease version (e.g. `1.0.0-beta.1`). */
33
+ export function isPrerelease(v: string): boolean {
34
+ return semver.prerelease(v) !== null;
35
+ }
36
+
37
+ /** Returns true if `remote` is a strictly newer version than `installed`.
38
+ * Returns false if either is null/undefined or invalid. */
39
+ export function hasNewerVersion(
40
+ installed: string | null | undefined,
41
+ remote: string | null | undefined,
42
+ ): boolean {
43
+ if (!installed || !remote) return false;
44
+ if (!isValidVersion(installed) || !isValidVersion(remote)) return false;
45
+ return versionGt(remote, installed);
46
+ }
47
+
48
+ export interface VersionWithId {
49
+ id: number;
50
+ version: string;
51
+ }
52
+
53
+ export interface DistTagEntry {
54
+ tag: string;
55
+ versionId: number;
56
+ }
57
+
58
+ /** Resolve latest version from dist-tags + versions list.
59
+ * Priority: "latest" dist-tag → last entry in versions array. */
60
+ export function resolveLatestVersion(
61
+ versions: VersionWithId[],
62
+ distTags: DistTagEntry[],
63
+ ): string | null {
64
+ if (versions.length === 0) return null;
65
+
66
+ const latestTag = distTags.find((t) => t.tag === "latest");
67
+ if (latestTag) {
68
+ const tagged = versions.find((v) => v.id === latestTag.versionId);
69
+ if (tagged) return tagged.version;
70
+ }
71
+
72
+ return versions.at(-1)?.version ?? null;
73
+ }
@@ -0,0 +1,22 @@
1
+ import { hasNewerVersion, resolveLatestVersion } from "./semver.ts";
2
+ import type { VersionWithId, DistTagEntry } from "./semver.ts";
3
+
4
+ export type { VersionWithId, DistTagEntry };
5
+
6
+ export interface UpdateCheckInput {
7
+ installedVersion: string | null;
8
+ remoteVersions: VersionWithId[];
9
+ remoteDistTags: DistTagEntry[];
10
+ }
11
+
12
+ export interface UpdateCheckResult {
13
+ latestVersion: string | null;
14
+ updateAvailable: boolean;
15
+ }
16
+
17
+ /** Check if an update is available for an installed package. */
18
+ export function checkUpdateAvailable(input: UpdateCheckInput): UpdateCheckResult {
19
+ const latestVersion = resolveLatestVersion(input.remoteVersions, input.remoteDistTags);
20
+ const updateAvailable = hasNewerVersion(input.installedVersion, latestVersion);
21
+ return { latestVersion, updateAvailable };
22
+ }
@@ -0,0 +1,302 @@
1
+ import { z } from "zod";
2
+ import semver from "semver";
3
+ import { SLUG_REGEX, SLUG_PATTERN } from "./naming.ts";
4
+
5
+ export { SLUG_REGEX };
6
+
7
+ const flowFieldTypeEnum = z.enum(["string", "number", "boolean", "array", "object", "file"]);
8
+
9
+ export const jsonSchemaPropertySchema = z.object({
10
+ type: flowFieldTypeEnum,
11
+ description: z.string().optional(),
12
+ default: z.unknown().optional(),
13
+ enum: z.array(z.unknown()).optional(),
14
+ format: z.string().optional(),
15
+ placeholder: z.string().optional(),
16
+ accept: z.string().optional(),
17
+ maxSize: z.number().positive().optional(),
18
+ multiple: z.boolean().optional(),
19
+ maxFiles: z.number().int().positive().optional(),
20
+ });
21
+
22
+ export const jsonSchemaObjectSchema = z.object({
23
+ type: z.literal("object"),
24
+ properties: z.record(z.string(), jsonSchemaPropertySchema),
25
+ required: z.array(z.string()).optional(),
26
+ propertyOrder: z.array(z.string()).optional(),
27
+ });
28
+
29
+ export const serviceRequirementSchema = z.looseObject({
30
+ id: z.string().min(1).regex(SLUG_REGEX, {
31
+ error: "Must be a valid slug (a-z, 0-9, hyphens, no leading/trailing hyphen)",
32
+ }),
33
+ provider: z.string(),
34
+ description: z.string().optional(),
35
+ scopes: z.array(z.string()).optional(),
36
+ connectionMode: z.enum(["user", "admin"]).optional(),
37
+ });
38
+
39
+ export const PACKAGE_REF_REGEX = new RegExp(`^(?:@${SLUG_PATTERN}\\/)?${SLUG_PATTERN}$`);
40
+
41
+ const packageRefString = z.string().min(1).regex(PACKAGE_REF_REGEX, {
42
+ error: "Must be a valid slug or scoped name (@scope/name)",
43
+ });
44
+
45
+ const semverRangeString = z.string().refine((val) => semver.validRange(val) !== null, {
46
+ error: "Must be a valid semver range (e.g. ^1.0.0, ~2.1, >=3.0.0)",
47
+ });
48
+
49
+ const registryDependenciesSchema = z
50
+ .looseObject({
51
+ skills: z.record(z.string(), semverRangeString).optional(),
52
+ extensions: z.record(z.string(), semverRangeString).optional(),
53
+ })
54
+ .optional();
55
+
56
+ // ─────────────────────────────────────────────
57
+ // Base manifest schema — common fields for all package types
58
+ // ─────────────────────────────────────────────
59
+
60
+ export const scopedNameRegex = new RegExp(`^@${SLUG_PATTERN}\\/${SLUG_PATTERN}$`);
61
+ export const packageTypeEnum = z.enum(["flow", "skill", "extension"]);
62
+ export type PackageType = z.infer<typeof packageTypeEnum>;
63
+
64
+ export const manifestSchema = z.looseObject({
65
+ name: z.string().regex(scopedNameRegex, { error: "Must follow the format @scope/package-name" }),
66
+ version: z.string().refine((v) => semver.valid(v) !== null, {
67
+ error: "Must be a valid semver version",
68
+ }),
69
+ type: packageTypeEnum,
70
+ displayName: z.string().optional().catch(undefined),
71
+ description: z.string().optional().catch(undefined),
72
+ keywords: z.array(z.string()).optional().catch(undefined),
73
+ license: z.string().optional().catch(undefined),
74
+ repository: z.string().optional().catch(undefined),
75
+ readme: z.string().optional().catch(undefined),
76
+ registryDependencies: registryDependenciesSchema,
77
+ });
78
+
79
+ export type Manifest = z.infer<typeof manifestSchema>;
80
+
81
+ // ─── Sub-schema inferred types ───────────────
82
+
83
+ export type FlowServiceRequirement = z.infer<typeof serviceRequirementSchema>;
84
+ export type FlowJsonSchemaProperty = z.infer<typeof jsonSchemaPropertySchema>;
85
+ export type FlowJsonSchemaObject = z.infer<typeof jsonSchemaObjectSchema>;
86
+
87
+ // ─────────────────────────────────────────────
88
+ // Shared flow fields
89
+ // ─────────────────────────────────────────────
90
+
91
+ const flowSharedFields = {
92
+ requires: z.looseObject({
93
+ services: z.array(serviceRequirementSchema),
94
+ skills: z.array(packageRefString).optional(),
95
+ extensions: z.array(packageRefString).optional(),
96
+ }),
97
+ input: z
98
+ .object({
99
+ schema: jsonSchemaObjectSchema,
100
+ })
101
+ .optional(),
102
+ output: z
103
+ .object({
104
+ schema: jsonSchemaObjectSchema,
105
+ })
106
+ .optional(),
107
+ config: z
108
+ .object({
109
+ schema: jsonSchemaObjectSchema,
110
+ })
111
+ .optional(),
112
+ execution: z
113
+ .looseObject({
114
+ timeout: z.number().optional(),
115
+ outputRetries: z.number().min(0).max(5).optional(),
116
+ })
117
+ .optional(),
118
+ } as const;
119
+
120
+ // ─────────────────────────────────────────────
121
+ // Flow manifest schema — extends base with flow-specific fields
122
+ // ─────────────────────────────────────────────
123
+
124
+ export const flowManifestSchema = manifestSchema.extend({
125
+ $schema: z.string().optional(),
126
+ schemaVersion: z.string(),
127
+ displayName: z.string().min(1),
128
+ author: z.string(),
129
+ tags: z.array(z.string()).optional(),
130
+ ...flowSharedFields,
131
+ });
132
+
133
+ export type FlowManifest = z.infer<typeof flowManifestSchema>;
134
+
135
+ // ─────────────────────────────────────────────
136
+ // Unified validateManifest — dispatches by type
137
+ // ─────────────────────────────────────────────
138
+
139
+ export type ValidateManifestResult =
140
+ | { valid: true; errors: []; manifest: Manifest | FlowManifest }
141
+ | { valid: false; errors: string[]; manifest?: undefined };
142
+
143
+ function parseWithSchema(
144
+ schema: typeof manifestSchema | typeof flowManifestSchema,
145
+ raw: unknown,
146
+ ): ValidateManifestResult {
147
+ const result = schema.safeParse(raw);
148
+ if (result.success) {
149
+ return { valid: true, errors: [], manifest: result.data };
150
+ }
151
+ const errors = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
152
+ return { valid: false, errors };
153
+ }
154
+
155
+ export function validateManifest(raw: unknown): ValidateManifestResult {
156
+ if (raw && typeof raw === "object" && "type" in raw) {
157
+ const schema =
158
+ (raw as Record<string, unknown>).type === "flow" ? flowManifestSchema : manifestSchema;
159
+ return parseWithSchema(schema, raw);
160
+ }
161
+ return { valid: false, errors: ["type: Required field is missing"] };
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("No YAML frontmatter detected (expected --- ... --- block)");
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("Missing 'name' field in YAML frontmatter");
196
+ }
197
+ if (!description) {
198
+ warnings.push("Missing 'description' field in YAML frontmatter");
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
+ export interface ManifestMetadata {
229
+ description?: string;
230
+ keywords?: string[];
231
+ license?: string;
232
+ repositoryUrl?: string;
233
+ readme?: string;
234
+ }
235
+
236
+ /** Extract optional metadata fields from a manifest.
237
+ * Maps `repository` to `repositoryUrl` to match the DB column convention. */
238
+ export function extractManifestMetadata(manifest: Partial<Manifest>): ManifestMetadata {
239
+ const metadata: ManifestMetadata = {};
240
+ if (manifest.description !== undefined) metadata.description = manifest.description;
241
+ if (manifest.keywords !== undefined) metadata.keywords = manifest.keywords;
242
+ if (manifest.license !== undefined) metadata.license = manifest.license as string;
243
+ if (manifest.repository !== undefined) metadata.repositoryUrl = manifest.repository as string;
244
+ if (manifest.readme !== undefined) metadata.readme = manifest.readme as string;
245
+ return metadata;
246
+ }
247
+
248
+ export function validateExtensionSource(source: string): ExtensionValidationResult {
249
+ const errors: string[] = [];
250
+ const warnings: string[] = [];
251
+
252
+ if (source.trim().length === 0) {
253
+ return { valid: false, errors: ["Extension content is empty"], warnings };
254
+ }
255
+
256
+ if (!/export\s+default\b/.test(source)) {
257
+ errors.push(
258
+ "Extension must have an `export default function`. " +
259
+ "Example: export default function(pi: ExtensionAPI) { ... }",
260
+ );
261
+ }
262
+
263
+ if (!/\.registerTool\s*\(/.test(source)) {
264
+ warnings.push(
265
+ "Extension does not call `pi.registerTool()`. " + "Make sure to register at least one tool.",
266
+ );
267
+ }
268
+
269
+ const cleaned = stripLineComments(source);
270
+ const executeMatches = [...cleaned.matchAll(/execute\s*\(([^)]*)\)/g)];
271
+ for (const match of executeMatches) {
272
+ const paramStr = match[1]!;
273
+ const paramCount = countParams(paramStr);
274
+ if (paramCount === 1) {
275
+ errors.push(
276
+ "The `execute` signature has only one parameter. " +
277
+ "The Pi SDK calls execute(toolCallId, params, signal) — with a single parameter, " +
278
+ "your function will receive the toolCallId (string) instead of params. " +
279
+ "Fix: execute(_toolCallId, params, signal) { ... }",
280
+ );
281
+ break;
282
+ }
283
+ }
284
+
285
+ if (executeMatches.length > 0 && !/content\s*:/.test(cleaned)) {
286
+ warnings.push(
287
+ "The `execute` function does not seem to return `{ content: [...] }`. " +
288
+ 'Expected format: { content: [{ type: "text", text: "..." }] }',
289
+ );
290
+ }
291
+
292
+ let braceCount = 0;
293
+ for (const ch of cleaned) {
294
+ if (ch === "{") braceCount++;
295
+ else if (ch === "}") braceCount--;
296
+ }
297
+ if (braceCount !== 0) {
298
+ errors.push(`Probable syntax error: braces are not balanced (difference: ${braceCount})`);
299
+ }
300
+
301
+ return { valid: errors.length === 0, errors, warnings };
302
+ }
package/src/zip.ts ADDED
@@ -0,0 +1,195 @@
1
+ import { unzipSync, zipSync, type Zippable } from "fflate";
2
+ import {
3
+ validateManifest,
4
+ extractSkillMeta,
5
+ validateExtensionSource,
6
+ type Manifest,
7
+ type FlowManifest,
8
+ } from "./validation.ts";
9
+
10
+ export type { Zippable };
11
+
12
+ export function zipArtifact(
13
+ entries: Zippable,
14
+ level: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 = 6,
15
+ ): Uint8Array {
16
+ return zipSync(entries, { level });
17
+ }
18
+
19
+ interface UnzippedArtifact {
20
+ files: Record<string, Uint8Array>;
21
+ prefix: string;
22
+ }
23
+
24
+ export function unzipArtifact(artifact: Uint8Array): UnzippedArtifact {
25
+ let rawFiles: Record<string, Uint8Array>;
26
+ try {
27
+ rawFiles = unzipSync(artifact);
28
+ } catch {
29
+ throw new Error("Failed to decompress ZIP artifact");
30
+ }
31
+
32
+ // Sanitize: filter out path traversal, absolute paths, null bytes, __MACOSX, and directory entries
33
+ const files: Record<string, Uint8Array> = {};
34
+ for (const [key, value] of Object.entries(rawFiles)) {
35
+ if (key.includes("..") || key.startsWith("/") || key.includes("\0")) continue;
36
+ if (key.startsWith("__MACOSX/") || key.endsWith("/")) continue;
37
+ files[key] = value;
38
+ }
39
+
40
+ const entries = Object.keys(files);
41
+ const prefix = detectFolderWrapper(entries);
42
+
43
+ return { files, prefix };
44
+ }
45
+
46
+ export function detectFolderWrapper(entries: string[]): string {
47
+ // Filter out __MACOSX and directory entries before detection
48
+ const filtered = entries.filter((e) => !e.startsWith("__MACOSX/") && !e.endsWith("/"));
49
+ if (filtered.length === 0) return "";
50
+ const first = filtered[0];
51
+ if (!first) return "";
52
+ const firstDir = first.split("/")[0];
53
+ if (!firstDir) return "";
54
+ return filtered.every((e) => e.startsWith(firstDir + "/")) ? firstDir + "/" : "";
55
+ }
56
+
57
+ function getFileText(
58
+ files: Record<string, Uint8Array>,
59
+ prefix: string,
60
+ name: string,
61
+ ): string | undefined {
62
+ const data = files[prefix + name] ?? files[name];
63
+ return data ? new TextDecoder().decode(data) : undefined;
64
+ }
65
+
66
+ // ─────────────────────────────────────────────
67
+ // Unified package ZIP parser — handles flow, skill, extension
68
+ // ─────────────────────────────────────────────
69
+
70
+ export interface ParsedPackageZip {
71
+ manifest: Manifest | FlowManifest;
72
+ content: string;
73
+ files: Record<string, Uint8Array>;
74
+ type: "flow" | "skill" | "extension";
75
+ }
76
+
77
+ export class PackageZipError extends Error {
78
+ constructor(
79
+ public code: string,
80
+ message: string,
81
+ public details?: unknown,
82
+ ) {
83
+ super(message);
84
+ this.name = "PackageZipError";
85
+ }
86
+ }
87
+
88
+ const DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10 MB
89
+
90
+ export function parsePackageZip(zipBuffer: Uint8Array, maxSize?: number): ParsedPackageZip {
91
+ const limit = maxSize ?? DEFAULT_MAX_SIZE;
92
+ if (zipBuffer.length > limit) {
93
+ throw new PackageZipError(
94
+ "FILE_TOO_LARGE",
95
+ `ZIP exceeds maximum size of ${limit / 1024 / 1024} MB`,
96
+ );
97
+ }
98
+
99
+ let files: Record<string, Uint8Array>;
100
+ let prefix: string;
101
+ try {
102
+ const result = unzipArtifact(zipBuffer);
103
+ files = result.files;
104
+ prefix = result.prefix;
105
+ } catch {
106
+ throw new PackageZipError("ZIP_INVALID", "Failed to decompress ZIP artifact");
107
+ }
108
+
109
+ // Zip bomb protection: check total decompressed size
110
+ const MAX_DECOMPRESSED = 50 * 1024 * 1024; // 50 MB
111
+ const totalSize = Object.values(files).reduce((sum, buf) => sum + buf.length, 0);
112
+ if (totalSize > MAX_DECOMPRESSED) {
113
+ throw new PackageZipError(
114
+ "ZIP_BOMB",
115
+ `Decompressed size (${(totalSize / 1024 / 1024).toFixed(1)} MB) exceeds limit`,
116
+ );
117
+ }
118
+
119
+ // Parse manifest.json
120
+ const manifestText = getFileText(files, prefix, "manifest.json");
121
+ if (!manifestText) {
122
+ throw new PackageZipError("MISSING_MANIFEST", "manifest.json not found in ZIP");
123
+ }
124
+
125
+ let manifestRaw: unknown;
126
+ try {
127
+ manifestRaw = JSON.parse(manifestText);
128
+ } catch {
129
+ throw new PackageZipError("INVALID_MANIFEST", "manifest.json is not valid JSON");
130
+ }
131
+
132
+ const validation = validateManifest(manifestRaw);
133
+ if (!validation.valid) {
134
+ const detail = validation.errors.join("; ");
135
+ throw new PackageZipError(
136
+ "INVALID_MANIFEST",
137
+ detail ? `Manifest validation failed: ${detail}` : "Manifest validation failed",
138
+ validation.errors,
139
+ );
140
+ }
141
+
142
+ const manifest = validation.manifest!;
143
+
144
+ const type = manifest.type;
145
+
146
+ // Extract primary content based on type
147
+ let content: string;
148
+
149
+ switch (type) {
150
+ case "flow": {
151
+ const promptMd = getFileText(files, prefix, "prompt.md");
152
+ if (!promptMd || promptMd.trim().length === 0) {
153
+ throw new PackageZipError("MISSING_CONTENT", "Flow package must contain prompt.md");
154
+ }
155
+ content = promptMd;
156
+ break;
157
+ }
158
+ case "skill": {
159
+ const skillMd = getFileText(files, prefix, "SKILL.md");
160
+ if (!skillMd) {
161
+ throw new PackageZipError("MISSING_CONTENT", "Skill package must contain SKILL.md");
162
+ }
163
+ const meta = extractSkillMeta(skillMd);
164
+ if (!meta.name) {
165
+ throw new PackageZipError(
166
+ "INVALID_CONTENT",
167
+ "SKILL.md must contain a 'name' in YAML frontmatter",
168
+ );
169
+ }
170
+ content = skillMd;
171
+ break;
172
+ }
173
+ case "extension": {
174
+ const tsEntry = Object.keys(files).find((f) => {
175
+ const name = prefix && f.startsWith(prefix) ? f.slice(prefix.length) : f;
176
+ return name.endsWith(".ts") && !name.endsWith(".d.ts") && !name.includes("/");
177
+ });
178
+ if (!tsEntry) {
179
+ throw new PackageZipError("MISSING_CONTENT", "Extension package must contain a .ts file");
180
+ }
181
+ const source = new TextDecoder().decode(files[tsEntry]!);
182
+ const extValidation = validateExtensionSource(source);
183
+ if (!extValidation.valid) {
184
+ throw new PackageZipError(
185
+ "INVALID_CONTENT",
186
+ `Extension validation failed: ${extValidation.errors.join("; ")}`,
187
+ );
188
+ }
189
+ content = source;
190
+ break;
191
+ }
192
+ }
193
+
194
+ return { manifest, content, files, type };
195
+ }