@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 +22 -10
- package/src/dependencies.ts +120 -0
- package/src/integrity.ts +6 -0
- package/src/naming.ts +27 -0
- package/src/publish-manifest.ts +24 -0
- package/src/registry-deps.ts +41 -0
- package/src/semver.ts +73 -0
- package/src/update-check.ts +22 -0
- package/src/validation.ts +302 -0
- package/src/zip.ts +195 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/core",
|
|
3
|
-
"version": "
|
|
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
|
+
}
|
package/src/integrity.ts
ADDED
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
|
+
}
|