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>
This commit is contained in:
Cursor Agent
2026-08-19 16:04:00 +00:00
co-authored by nsrb
parent f66d0931bd
commit 611511ae60
23 changed files with 1843 additions and 132 deletions
+353 -47
View File
@@ -1,13 +1,32 @@
import fs from "fs";
import path from "path";
import {
clearScriptCache,
inspectScriptSource,
instantiateScriptSource,
} from "../../script-sandbox.js";
import * as fsStore from "../../fs-store.js";
import {
forkCoreScript,
getInstalledPlugin,
listCoreScriptNames,
listInstalledPlugins,
resolveScriptRef,
uninstallPlugin,
createBlankPlugin,
} from "../../plugin-store.js";
import {
installExamplePlugin,
installPluginFromDirectory,
installPluginFromGit,
installPluginFromZipBuffer,
} from "../../plugin-install.js";
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
import { normalizeStepResult } from "../../step-result.js";
import { resolveConfigRefs } from "../../config-refs.js";
import { getAppVersion } from "../../app-version.js";
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
import { pluginScriptRef } from "../../plugin-manifest.js";
/**
* @param {{ referencedScripts: () => Set<string> }} registry
@@ -18,19 +37,58 @@ export default function scriptsPluginFactory(registry) {
*/
return async function scriptsPlugin(fastify) {
fastify.get("/scripts", async () => {
const scripts = fsStore.listScriptFiles().map((name) => {
const core = listCoreScriptNames().map((name) => {
const content = fsStore.readScript(name);
const inspected =
content == null
? { meta: null, metaError: "script not found" }
: inspectScriptSource(name, content);
return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected };
return {
name,
kind: "core",
editable: false,
hasIcon: fsStore.scriptHasIcon(name),
...inspected,
};
});
return { scripts };
const plugins = listInstalledPlugins().map((p) => {
let inspected = { meta: null, metaError: null };
if (!p.disabled && p.manifest) {
try {
const mainPath = path.join(p.dir, p.manifest.main);
const content = fs.readFileSync(mainPath, "utf8");
inspected = inspectScriptSource(p.scriptRef, content);
} catch (err) {
inspected = {
meta: null,
metaError: err instanceof Error ? err.message : String(err),
};
}
} else if (p.compatError) {
inspected = { meta: null, metaError: p.compatError };
}
return {
name: p.scriptRef,
kind: "plugin",
editable: true,
pluginId: p.id,
disabled: p.disabled,
version: p.manifest?.version ?? null,
hasIcon: false,
...inspected,
};
});
return {
scripts: [...core, ...plugins],
appVersion: getAppVersion(),
};
});
fastify.get("/scripts/:name/icon", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
// Icons only for core scripts today
try {
fsStore.assertScriptName(name);
} catch (err) {
@@ -46,64 +104,152 @@ export default function scriptsPluginFactory(registry) {
});
fastify.get("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const resolved = resolveScriptRef(rawName);
if (resolved.error || !resolved.filePath) {
return reply
.code(404)
.send({ error: resolved.error || "script not found" });
}
const content = fsStore.readScript(name);
if (content == null) return reply.code(404).send({ error: "script not found" });
return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) };
const content = fs.readFileSync(resolved.filePath, "utf8");
const inspected = inspectScriptSource(resolved.scriptRef, content);
return {
name: resolved.scriptRef,
kind: resolved.kind,
editable: resolved.kind === "plugin",
pluginId: resolved.pluginId ?? null,
content,
hasIcon:
resolved.kind === "core"
? fsStore.scriptHasIcon(resolved.scriptRef)
: false,
...inspected,
};
});
// Core scripts are read-only. Creating/editing bare *.js writes is disabled.
// New user scripts must be plugins (fork / zip / git).
fastify.put("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const pluginRef = resolveScriptRef(rawName);
if (pluginRef.kind === "core" || !rawName.startsWith("plugin/")) {
// Attempt to treat as core name
try {
fsStore.assertScriptName(
rawName.endsWith(".js") ? rawName : `${rawName}.js`,
);
} catch {
// continue
}
if (!rawName.startsWith("plugin/")) {
return reply.code(403).send({
error:
"core scripts are read-only; fork to a plugin or install a plugin",
});
}
}
const body = /** @type {{ content?: string }} */ (req.body ?? {});
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
const existed = fsStore.readScript(name) != null;
fsStore.writeScript(name, body.content);
const resolved = resolveScriptRef(rawName);
if (resolved.kind !== "plugin" || !resolved.filePath || !resolved.pluginDir) {
return reply.code(404).send({
error: resolved.error || "plugin not found (install or fork first)",
});
}
if (resolved.disabled) {
return reply.code(409).send({ error: resolved.error || "plugin disabled" });
}
fs.writeFileSync(resolved.filePath, body.content, "utf8");
clearScriptCache();
return reply.code(existed ? 200 : 201).send({
name,
...inspectScriptSource(name, body.content),
return reply.send({
name: resolved.scriptRef,
kind: "plugin",
editable: true,
...inspectScriptSource(resolved.scriptRef, body.content),
});
});
fastify.delete("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
if (!rawName.startsWith("plugin/")) {
return reply.code(403).send({
error: "core scripts cannot be deleted",
});
}
if (registry.referencedScripts().has(name)) {
const resolved = resolveScriptRef(rawName);
const id = resolved.pluginId;
if (!id) {
// may be installed but disabled — still allow uninstall via plugin id parse
const installed = listInstalledPlugins().find(
(p) => p.scriptRef === rawName || `plugin/${p.id}` === rawName,
);
if (!installed) {
return reply.code(404).send({ error: "plugin not found" });
}
if (registry.referencedScripts().has(installed.scriptRef)) {
return reply
.code(409)
.send({ error: "plugin is referenced by a workflow" });
}
uninstallPlugin(installed.id);
clearScriptCache();
return { ok: true, restartNeeded: true };
}
if (registry.referencedScripts().has(resolved.scriptRef)) {
return reply
.code(409)
.send({ error: "script is referenced by a workflow" });
}
if (!fsStore.deleteScript(name)) {
return reply.code(404).send({ error: "script not found" });
.send({ error: "plugin is referenced by a workflow" });
}
uninstallPlugin(id);
clearScriptCache();
return { ok: true };
return { ok: true, restartNeeded: true };
});
fastify.post("/scripts/:name/fork", async (req, reply) => {
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const body = /** @type {{ id?: string, description?: string }} */ (
req.body ?? {}
);
if (typeof body.id !== "string" || !body.id.trim()) {
return reply.code(400).send({ error: "id is required" });
}
try {
const coreName = rawName.endsWith(".js") ? rawName : `${rawName}.js`;
fsStore.assertScriptName(coreName);
const installed = forkCoreScript(coreName, body.id.trim(), {
description: body.description,
});
clearScriptCache();
return reply.code(201).send({
...installed,
restartNeeded: true,
warning:
"Plugins run as the JerapahFlow process user. Review code before install.",
});
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
});
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
req.body ?? {}
);
@@ -121,10 +267,18 @@ export default function scriptsPluginFactory(registry) {
}
const incomingContext =
body.context != null && typeof body.context === "object" && !Array.isArray(body.context)
body.context != null &&
typeof body.context === "object" &&
!Array.isArray(body.context)
? body.context
: {};
const resolved = resolveScriptRef(rawName);
const pluginDir =
resolved.kind === "plugin" && !resolved.error
? resolved.pluginDir ?? null
: null;
const { log, logs } = createDryRunLogger();
const started = Date.now();
@@ -139,13 +293,22 @@ export default function scriptsPluginFactory(registry) {
context: incomingContext,
config,
};
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
log,
workflowName: "dry-run",
owner,
});
const { fn, meta, metaError } = instantiateScriptSource(
resolved.scriptRef || rawName,
body.content,
{
log,
workflowName: "dry-run",
owner,
pluginDir,
},
);
const raw = await fn(ctx);
const result = normalizeStepResult(raw, incomingContext, name);
const result = normalizeStepResult(
raw,
incomingContext,
resolved.scriptRef || rawName,
);
return {
status: "success",
output: safeSerialize(result.output),
@@ -158,7 +321,7 @@ export default function scriptsPluginFactory(registry) {
metaError,
};
} catch (err) {
const inspected = inspectScriptSource(name, body.content);
const inspected = inspectScriptSource(rawName, body.content);
return {
status: "failed",
output: null,
@@ -171,5 +334,148 @@ export default function scriptsPluginFactory(registry) {
};
}
});
// --- Plugins ---
fastify.get("/plugins", async () => {
return {
appVersion: getAppVersion(),
plugins: listInstalledPlugins(),
warning:
"Installing plugins runs third-party code as the JerapahFlow OS user.",
};
});
fastify.post(
"/plugins/create",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
req.body ?? {}
);
if (typeof body.id !== "string" || !body.id.trim()) {
return reply.code(400).send({ error: "id is required" });
}
if (typeof body.content !== "string") {
return reply.code(400).send({ error: "content is required" });
}
try {
const installed = createBlankPlugin(body.id.trim(), body.content, {
description: body.description,
});
clearScriptCache();
return reply.code(201).send({
...installed,
restartNeeded: true,
warning:
"Plugins run as the JerapahFlow process user. Review code before install.",
});
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
},
);
fastify.post(
"/plugins/install",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const body = /** @type {{
source?: string,
url?: string,
ref?: string,
path?: string,
exampleId?: string,
zipBase64?: string,
overwrite?: boolean,
}} */ (req.body ?? {});
try {
let installed;
if (body.source === "git") {
if (!body.url) {
return reply.code(400).send({ error: "url is required" });
}
installed = await installPluginFromGit(body.url, {
ref: body.ref,
overwrite: Boolean(body.overwrite),
});
} else if (body.source === "example") {
const id = body.exampleId || "get-current-time";
installed = await installExamplePlugin(id, {
overwrite: Boolean(body.overwrite),
});
} else if (body.source === "dir") {
if (!body.path) {
return reply.code(400).send({ error: "path is required" });
}
// Only allow examples/ or existing staging under plugins for safety
const abs = path.resolve(body.path);
const allowed =
abs.startsWith(EXAMPLE_PLUGINS_DIR + path.sep) ||
abs.startsWith(EXAMPLE_PLUGINS_DIR);
if (!allowed) {
return reply.code(403).send({
error: "dir install only allowed under examples/plugins",
});
}
installed = installPluginFromDirectory(abs, {
overwrite: Boolean(body.overwrite),
});
} else if (body.source === "zip") {
if (!body.zipBase64) {
return reply.code(400).send({ error: "zipBase64 is required" });
}
const buf = Buffer.from(body.zipBase64, "base64");
installed = await installPluginFromZipBuffer(buf, {
overwrite: Boolean(body.overwrite),
});
} else {
return reply.code(400).send({
error: "source must be git | zip | example | dir",
});
}
clearScriptCache();
return reply.code(201).send({
...installed,
scriptRef: pluginScriptRef(installed.id),
restartNeeded: true,
warning:
"Plugins run as the JerapahFlow process user. Review code before install. Drain-restart workers to load new dependencies.",
});
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
},
);
fastify.delete(
"/plugins/:id",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
try {
const scriptRef = pluginScriptRef(id);
if (registry.referencedScripts().has(scriptRef)) {
return reply
.code(409)
.send({ error: "plugin is referenced by a workflow" });
}
uninstallPlugin(id);
clearScriptCache();
return { ok: true, restartNeeded: true };
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
},
);
void getInstalledPlugin;
};
}