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:
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<id></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">
|
||||
|
||||
@@ -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/<id></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, {
|
||||
|
||||
Reference in New Issue
Block a user