Files
jerapah-flow/packages/server/plugin-manifest.js
T
Cursor Agentandnsrb 611511ae60 feat(plugins): core read-only scripts and user plugin system
Introduce plugin/<id> installs (zip, HTTPS git, example, fork),
jerapah-plugin.json manifests with jerapah semver ranges, isolated
plugin dirs under data/plugins, and app version 0.1.0. Core scripts
are non-editable; get-current-time moves to examples/plugins.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-19 16:04:00 +00:00

160 lines
4.2 KiB
JavaScript

import fs from "fs";
import path from "path";
import { getAppVersion, satisfiesRange } from "./app-version.js";
export const PLUGIN_MANIFEST = "jerapah-plugin.json";
export const PLUGIN_PREFIX = "plugin/";
/**
* @param {string} id
* @returns {string}
*/
export function assertPluginId(id) {
if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
const err = new Error(
"invalid plugin id (use lowercase letters, numbers, hyphens)",
);
err.statusCode = 400;
throw err;
}
if (id.length > 64) {
const err = new Error("plugin id too long");
err.statusCode = 400;
throw err;
}
return id;
}
/**
* @param {string} scriptRef e.g. plugin/foo or plugin/foo.js
* @returns {{ id: string, scriptRef: string } | null}
*/
export function parsePluginScriptRef(scriptRef) {
if (typeof scriptRef !== "string") return null;
let rest = scriptRef;
if (rest.startsWith(PLUGIN_PREFIX)) {
rest = rest.slice(PLUGIN_PREFIX.length);
} else {
return null;
}
if (rest.endsWith(".js")) rest = rest.slice(0, -3);
if (!rest || rest.includes("/") || rest.includes("\\")) return null;
try {
const id = assertPluginId(rest);
return { id, scriptRef: `${PLUGIN_PREFIX}${id}` };
} catch {
return null;
}
}
/**
* @param {string} id
* @returns {string}
*/
export function pluginScriptRef(id) {
return `${PLUGIN_PREFIX}${assertPluginId(id)}`;
}
/**
* @param {unknown} raw
* @returns {{
* id: string,
* name: string,
* version: string,
* jerapah: string,
* main: string,
* description: string | null,
* }}
*/
export function validateManifest(raw) {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
const err = new Error("manifest must be an object");
err.statusCode = 400;
throw err;
}
const id = assertPluginId(String(/** @type {any} */ (raw).id ?? ""));
const version = String(/** @type {any} */ (raw).version ?? "").trim();
if (!/^\d+\.\d+\.\d+/.test(version)) {
const err = new Error("manifest.version must be semver (e.g. 0.1.0)");
err.statusCode = 400;
throw err;
}
const jerapah = String(/** @type {any} */ (raw).jerapah ?? "").trim();
if (!jerapah) {
const err = new Error("manifest.jerapah range is required");
err.statusCode = 400;
throw err;
}
const main = String(/** @type {any} */ (raw).main ?? "script.js").trim();
if (!main || main.includes("..") || path.isAbsolute(main)) {
const err = new Error("manifest.main must be a relative file path");
err.statusCode = 400;
throw err;
}
const name =
String(/** @type {any} */ (raw).name ?? id).trim() || id;
const descriptionRaw = /** @type {any} */ (raw).description;
const description =
typeof descriptionRaw === "string" && descriptionRaw.trim()
? descriptionRaw.trim()
: null;
return { id, name, version, jerapah, main, description };
}
/**
* @param {string} pluginDir
*/
export function readManifestFile(pluginDir) {
const filePath = path.join(pluginDir, PLUGIN_MANIFEST);
if (!fs.existsSync(filePath)) {
const err = new Error(`missing ${PLUGIN_MANIFEST}`);
err.statusCode = 400;
throw err;
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
const err = new Error(`invalid ${PLUGIN_MANIFEST} JSON`);
err.statusCode = 400;
throw err;
}
return validateManifest(raw);
}
/**
* @param {ReturnType<typeof validateManifest>} manifest
* @returns {{ ok: true } | { ok: false, error: string }}
*/
export function checkJerapahCompat(manifest) {
const appVersion = getAppVersion();
if (!satisfiesRange(appVersion, manifest.jerapah)) {
return {
ok: false,
error: `plugin requires JerapahFlow ${manifest.jerapah}; app is ${appVersion}`,
};
}
return { ok: true };
}
/**
* @param {{
* id: string,
* name?: string,
* version?: string,
* jerapah?: string,
* main?: string,
* description?: string | null,
* }} opts
*/
export function buildManifest(opts) {
return validateManifest({
id: opts.id,
name: opts.name ?? opts.id,
version: opts.version ?? "0.1.0",
jerapah: opts.jerapah ?? ">=0.1.0 <1.0.0",
main: opts.main ?? "script.js",
description: opts.description ?? null,
});
}