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
+41
View File
@@ -89,6 +89,47 @@ export function useSaveScript() {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useCreatePlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, content, description }) =>
(await api.post("/plugins/create", { id, content, description })).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useForkScript() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, id, description }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/fork`, {
id,
description,
})
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useInstallPlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body) => (await api.post("/plugins/install", body)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
export const NEW_WORKFLOW_YAML = `name: new workflow
scripts:
- get-current-time.js
- plugin/get-current-time
triggers:
- type: HTTP
method: POST
+119 -34
View File
@@ -1,39 +1,57 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import { useSaveScript, useScript } from "../api/hooks.js";
import {
useCreatePlugin,
useForkScript,
useSaveScript,
useScript,
} from "../api/hooks.js";
import { CodeEditor } from "../components/CodeEditor.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
import { NEW_SCRIPT_TEMPLATE } from "../lib/script.js";
import { useNotifications } from "../notifications.jsx";
function normalizePluginId(raw) {
return String(raw ?? "")
.trim()
.toLowerCase()
.replace(/\.js$/i, "")
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function ScriptNewPage() {
const navigate = useNavigate();
const location = useLocation();
const [name, setName] = useState("");
const [id, setId] = useState("");
const [content, setContent] = useState(
location.state?.content ?? NEW_SCRIPT_TEMPLATE,
);
const save = useSaveScript();
const create = useCreatePlugin();
const { notify } = useNotifications();
function onSave(e) {
e.preventDefault();
const file = normalizeScriptName(name);
if (!file) return;
save.mutate(
{ name: file, content },
const pluginId = normalizePluginId(id);
if (!pluginId) return;
create.mutate(
{ id: pluginId, content },
{
onSuccess: () => navigate(`/scripts/${encodeURIComponent(file)}/edit`),
onSuccess: (data) => {
notify.success("Plugin created — drain-restart to load workers");
navigate(`/scripts/${encodeURIComponent(data.scriptRef)}/edit`);
},
},
);
}
function openDryRun() {
const file = normalizeScriptName(name);
if (!file) return;
navigate(`/scripts/${encodeURIComponent(file)}/dry-run`, {
const pluginId = normalizePluginId(id);
if (!pluginId) return;
navigate(`/scripts/${encodeURIComponent(`plugin/${pluginId}`)}/dry-run`, {
state: { content },
});
}
@@ -44,12 +62,12 @@ export function ScriptNewPage() {
<Link to="/scripts" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
<LuArrowLeft className="size-4" />
</Link>
<h1 className="text-xl font-semibold">New script</h1>
<h1 className="text-xl font-semibold">New plugin</h1>
<div className="flex-1" />
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!normalizeScriptName(name)}
disabled={!normalizePluginId(id)}
onClick={openDryRun}
>
<LuPlay className="size-4" />
@@ -59,26 +77,34 @@ export function ScriptNewPage() {
type="submit"
form="script-edit-form"
className="btn btn-primary btn-sm"
disabled={save.isPending || !normalizeScriptName(name)}
disabled={create.isPending || !normalizePluginId(id)}
>
<LuSave className="size-4" />
Save
Create
</button>
</div>
<div className="alert alert-warning text-sm py-2">
<span>
User scripts are plugins (<code>plugin/&lt;id&gt;</code>). Core scripts
are read-only — fork them instead. Installing plugins runs code as the
JerapahFlow process user.
</span>
</div>
<form id="script-edit-form" onSubmit={onSave} className="flex min-h-0 flex-1 flex-col gap-3">
<input
className="input input-sm w-full max-w-md shrink-0"
placeholder="name.js"
value={name}
onChange={(e) => setName(e.target.value)}
className="input input-sm w-full max-w-md shrink-0 font-mono"
placeholder="my-script (becomes plugin/my-script)"
value={id}
onChange={(e) => setId(e.target.value)}
required
/>
<div className="min-h-0 flex-1">
<CodeEditor language="javascript" value={content} onChange={setContent} height="100%" />
</div>
{save.isError ? (
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
{create.isError ? (
<p className="text-error text-sm shrink-0">{errorMessage(create.error)}</p>
) : null}
</form>
</div>
@@ -92,8 +118,12 @@ export function ScriptEditPage() {
const { notify } = useNotifications();
const existing = useScript(name);
const save = useSaveScript();
const fork = useForkScript();
const [content, setContent] = useState("");
const [contentReady, setContentReady] = useState(false);
const [forkId, setForkId] = useState("");
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
useEffect(() => {
if (existing.isLoading) return;
@@ -105,9 +135,10 @@ export function ScriptEditPage() {
function onSave(e) {
e.preventDefault();
if (isCore) return;
save.mutate(
{ name, content },
{ onSuccess: () => notify.success("Script saved") },
{ onSuccess: () => notify.success("Plugin saved") },
);
}
@@ -117,6 +148,21 @@ export function ScriptEditPage() {
});
}
function onFork(e) {
e.preventDefault();
const id = normalizePluginId(forkId);
if (!id) return;
fork.mutate(
{ name, id },
{
onSuccess: (data) => {
notify.success("Forked to plugin — drain-restart recommended");
navigate(`/scripts/${encodeURIComponent(data.scriptRef)}/edit`);
},
},
);
}
if (existing.isLoading) {
return (
<div className="flex min-h-[12rem] items-center justify-center">
@@ -145,24 +191,63 @@ export function ScriptEditPage() {
</Link>
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
<h1 className="truncate font-mono text-xl font-semibold">{name}</h1>
<span className={`badge badge-sm ${isCore ? "badge-info" : "badge-accent"}`}>
{isCore ? "core" : "plugin"}
</span>
<div className="flex-1" />
<button type="button" className="btn btn-outline btn-sm" onClick={openDryRun}>
<LuPlay className="size-4" />
Dry run
</button>
<button
type="submit"
form="script-edit-form"
className="btn btn-primary btn-sm"
disabled={save.isPending || !contentReady}
>
<LuSave className="size-4" />
Save
</button>
{!isCore ? (
<button
type="submit"
form="script-edit-form"
className="btn btn-primary btn-sm"
disabled={save.isPending || !contentReady}
>
<LuSave className="size-4" />
Save
</button>
) : null}
</div>
{isCore ? (
<div className="alert alert-info text-sm py-2">
<span>Core scripts are read-only. Fork to create an editable plugin copy.</span>
</div>
) : null}
{isCore ? (
<form onSubmit={onFork} className="flex flex-wrap items-center gap-2">
<input
className="input input-sm font-mono w-56"
placeholder="fork id (e.g. fetch-http-copy)"
value={forkId}
onChange={(e) => setForkId(e.target.value)}
/>
<button
type="submit"
className="btn btn-sm"
disabled={fork.isPending || !normalizePluginId(forkId)}
>
<LuCopy className="size-4" />
Fork to plugin
</button>
{fork.isError ? (
<span className="text-error text-sm">{errorMessage(fork.error)}</span>
) : null}
</form>
) : null}
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
<CodeEditor language="javascript" value={content} onChange={setContent} height="100%" />
<CodeEditor
language="javascript"
value={content}
onChange={isCore ? () => {} : setContent}
height="100%"
readOnly={isCore}
/>
</form>
<details className="collapse collapse-arrow shrink-0 border border-base-300 bg-base-100">
+148 -25
View File
@@ -1,11 +1,17 @@
import { useMemo, useState } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import { useDeleteScript, useScripts } from "../api/hooks.js";
import {
useDeleteScript,
useForkScript,
useInstallPlugin,
useScripts,
} from "../api/hooks.js";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { TagBadge } from "../components/TagBadge.jsx";
import { scriptTags } from "../lib/script.js";
import { useNotifications } from "../notifications.jsx";
export function ScriptsPage() {
const [params] = useSearchParams();
@@ -14,7 +20,12 @@ export function ScriptsPage() {
const { data: scripts = [], isLoading } = useScripts();
const [confirmDelete, setConfirmDelete] = useState(null);
const [query, setQuery] = useState("");
const [forkFor, setForkFor] = useState(null);
const [forkId, setForkId] = useState("");
const del = useDeleteScript();
const fork = useForkScript();
const install = useInstallPlugin();
const { notify } = useNotifications();
const visible = useMemo(() => {
const term = query.trim().toLowerCase();
@@ -23,9 +34,11 @@ export function ScriptsPage() {
const name = typeof s === "string" ? s : s.name ?? "";
const description = typeof s === "string" ? "" : s.meta?.description ?? "";
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
const kind = typeof s === "string" ? "" : s.kind ?? "";
return (
name.toLowerCase().includes(term) ||
description.toLowerCase().includes(term) ||
kind.toLowerCase().includes(term) ||
tags.some((t) => t.toLowerCase().includes(term))
);
});
@@ -50,13 +63,33 @@ export function ScriptsPage() {
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={install.isPending}
onClick={() =>
install.mutate(
{ source: "example", exampleId: "get-current-time", overwrite: true },
{
onSuccess: () =>
notify.success("Installed example plugin/get-current-time"),
},
)
}
>
Install example
</button>
<Link to="/scripts/new" className="btn btn-primary btn-sm">
<LuPlus className="size-4" />
Add
Add plugin
</Link>
</div>
</div>
{install.isError ? (
<p className="text-error text-sm">{errorMessage(install.error)}</p>
) : null}
{isLoading ? (
<span className="loading loading-spinner" />
) : scripts.length === 0 ? (
@@ -71,6 +104,8 @@ export function ScriptsPage() {
const metaError = typeof s === "string" ? null : s.metaError;
const hasIcon = typeof s === "string" ? undefined : s.hasIcon;
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
const kind = typeof s === "string" ? "core" : s.kind ?? "core";
const isCore = kind === "core";
return (
<article
key={name}
@@ -90,6 +125,13 @@ export function ScriptsPage() {
</span>
</h2>
</Link>
<div className="flex justify-center">
<span
className={`badge badge-xs ${isCore ? "badge-info" : "badge-accent"}`}
>
{kind}
</span>
</div>
<p className="text-xs opacity-80 line-clamp-2 min-h-8">
{metaError ? (
<span className="text-error">{metaError}</span>
@@ -114,25 +156,47 @@ export function ScriptsPage() {
type="button"
className="btn btn-ghost btn-xs"
title="Dry run"
onClick={() => navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)}
onClick={() =>
navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)
}
>
<LuPlay className="size-4" />
</button>
<Link
to={`/scripts/${encodeURIComponent(name)}/edit`}
className="btn btn-ghost btn-xs"
title="Edit"
>
<LuPencil className="size-4" />
</Link>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
onClick={() => setConfirmDelete(name)}
>
<LuTrash2 className="size-4" />
</button>
{isCore ? (
<button
type="button"
className="btn btn-ghost btn-xs"
title="Fork"
onClick={() => {
setForkFor(name);
setForkId(
String(name)
.replace(/\.js$/i, "")
.toLowerCase() + "-copy",
);
}}
>
<LuCopy className="size-4" />
</button>
) : (
<Link
to={`/scripts/${encodeURIComponent(name)}/edit`}
className="btn btn-ghost btn-xs"
title="Edit"
>
<LuPencil className="size-4" />
</Link>
)}
{!isCore ? (
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
onClick={() => setConfirmDelete(name)}
>
<LuTrash2 className="size-4" />
</button>
) : null}
</div>
</div>
</article>
@@ -141,20 +205,79 @@ export function ScriptsPage() {
</div>
)}
{confirmDelete ? (
{forkFor ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold">Delete {confirmDelete}?</h3>
{del.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
<h3 className="font-semibold">Fork {forkFor}</h3>
<p className="text-sm opacity-70 py-2">
Creates <code>plugin/&lt;id&gt;</code> from this core script.
</p>
<input
className="input input-bordered input-sm w-full font-mono"
value={forkId}
onChange={(e) => setForkId(e.target.value)}
/>
{fork.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(fork.error)}</p>
) : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setForkFor(null)}
>
Cancel
</button>
<button
type="button"
className="btn btn-error"
className="btn btn-primary btn-sm"
disabled={fork.isPending || !forkId.trim()}
onClick={() =>
fork.mutate(
{ name: forkFor, id: forkId.trim() },
{
onSuccess: (data) => {
setForkFor(null);
notify.success("Forked — drain-restart recommended");
navigate(
`/scripts/${encodeURIComponent(data.scriptRef)}/edit`,
);
},
},
)
}
>
Fork
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setForkFor(null)}>
close
</button>
</form>
</dialog>
) : null}
{confirmDelete ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-semibold">Delete {confirmDelete}?</h3>
<p className="text-sm opacity-70 py-2">This uninstalls the plugin.</p>
{del.isError ? (
<p className="text-error text-sm">{errorMessage(del.error)}</p>
) : null}
<div className="modal-action">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setConfirmDelete(null)}
>
Cancel
</button>
<button
type="button"
className="btn btn-error btn-sm"
disabled={del.isPending}
onClick={() =>
del.mutate(confirmDelete, {