feat(plugins): add duplicate plugin functionality and related API endpoints

- Implemented a new `duplicatePlugin` function to allow copying of installed plugins with a new ID.
- Added API endpoint for duplicating plugins, including error handling for various edge cases.
- Introduced frontend hooks and UI components for duplicating plugins in the script management interface.
- Enhanced tests to validate the duplication process and ensure proper error handling.
This commit is contained in:
2026-08-30 19:33:36 +07:00
parent f1cdb7ac68
commit a20b26b4c7
7 changed files with 298 additions and 10 deletions
+90
View File
@@ -380,6 +380,96 @@ export function forkCoreScript(coreName, newId, opts = {}) {
}
}
/**
* Copy an installed plugin to a new plugin id.
*
* @param {string} sourceId
* @param {string} newId
* @param {{ description?: string }} [opts]
*/
export function duplicatePlugin(sourceId, newId, opts = {}) {
const fromId = assertPluginId(sourceId);
const id = assertPluginId(newId);
if (id === fromId) {
const err = new Error("cannot duplicate onto itself");
err.statusCode = 400;
throw err;
}
if (coreBareNames().has(id)) {
const err = new Error(`plugin id collides with core script: ${id}`);
err.statusCode = 409;
throw err;
}
if (fs.existsSync(pluginDir(id))) {
const err = new Error(`plugin already exists: ${id}`);
err.statusCode = 409;
throw err;
}
const source = getInstalledPlugin(fromId);
if (!source) {
const err = new Error(`plugin not found: ${fromId}`);
err.statusCode = 404;
throw err;
}
if (!source.manifest) {
const err = new Error(
source.compatError || `plugin has no valid manifest: ${fromId}`,
);
err.statusCode = 400;
throw err;
}
const staging = path.join(PLUGINS_DIR, `.staging-dup-${id}-${Date.now()}`);
fs.mkdirSync(staging, { recursive: true });
try {
fs.cpSync(source.dir, staging, {
recursive: true,
filter: (src) => {
const base = path.basename(src);
return base !== "node_modules" && base !== ".disabled";
},
});
const manifest = buildManifest({
id,
name: id,
version: source.manifest.version,
jerapah: source.manifest.jerapah,
main: source.manifest.main,
description:
opts.description ?? source.manifest.description ?? null,
});
fs.writeFileSync(
path.join(staging, PLUGIN_MANIFEST),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const pkgPath = path.join(staging, "package.json");
if (fs.existsSync(pkgPath)) {
let pkg = {};
try {
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
} catch {
pkg = {};
}
if (pkg == null || typeof pkg !== "object" || Array.isArray(pkg)) {
pkg = {};
}
pkg.name = `jflow-plugin-${id}`;
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
}
return installPluginFromDirectory(staging, {
overwrite: false,
reason: `plugin:${id} duplicated from ${fromId}`,
});
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
/**
* @param {string} pluginDirectory
* @returns {((id: string) => unknown) | null}
+9
View File
@@ -15,6 +15,10 @@ function ntfyHeaders(ctx) {
headers.Title = ctx.data.title;
}
if (ctx.config?.markdown === true) {
headers.md = "true";
}
return headers;
}
@@ -149,6 +153,11 @@ ntfy.meta = {
default: "https://ntfy.sh/jerapah-flow",
description: "ntfy topic URL",
},
markdown: {
type: "boolean",
default: false,
description: "Send as Markdown (ntfy md header)",
},
fingerprint: {
type: "string",
required: false,
+36 -1
View File
@@ -8,6 +8,7 @@ import {
import * as fsStore from "../../fs-store.js";
import {
forkCoreScript,
duplicatePlugin,
listCoreScriptNames,
listInstalledPlugins,
resolveScriptRef,
@@ -29,7 +30,7 @@ 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";
import { parsePluginScriptRef, pluginScriptRef } from "../../plugin-manifest.js";
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
@@ -251,6 +252,40 @@ export default function scriptsPluginFactory(registry) {
}
});
fastify.post("/scripts/:name/duplicate", 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" });
}
const parsed = parsePluginScriptRef(rawName);
if (!parsed) {
return reply
.code(400)
.send({ error: "name must be a plugin ref (plugin/<id>)" });
}
try {
const installed = duplicatePlugin(parsed.id, 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 rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
+22
View File
@@ -13,6 +13,7 @@ import { migrate, db } from "../db.js";
import { getAppVersion, satisfiesRange } from "../app-version.js";
import {
forkCoreScript,
duplicatePlugin,
resolveScriptRef,
uninstallPlugin,
listInstalledPlugins,
@@ -83,6 +84,26 @@ async function main() {
);
assert.equal(blankRun.output.ok, true);
const duplicated = duplicatePlugin("blank-smoke", "blank-smoke-copy");
assert.equal(duplicated.scriptRef, "plugin/blank-smoke-copy");
clearScriptCache();
assert.equal(resolveScriptRef("plugin/blank-smoke-copy").kind, "plugin");
const dupRun = await runScript(
"plugin/blank-smoke-copy",
{ data: 1, context: {}, config: null },
{ log: silent, workflowName: "smoke", owner: "default" },
);
assert.equal(dupRun.output.ok, true);
let hitDupSelf = false;
try {
duplicatePlugin("blank-smoke", "blank-smoke");
} catch (err) {
hitDupSelf = true;
assert.match(String(err.message), /itself/);
}
assert.equal(hitDupSelf, true);
let hit = false;
try {
forkCoreScript("ntfy.js", "ntfy");
@@ -102,6 +123,7 @@ async function main() {
uninstallPlugin("jsonata-smoke-fork");
uninstallPlugin("blank-smoke");
uninstallPlugin("blank-smoke-copy");
uninstallPlugin("get-current-time");
console.log("plugins-smoke: ok");
+17
View File
@@ -59,6 +59,23 @@ export function useForkScript() {
});
}
export function useDuplicatePlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, id, description }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/duplicate`, {
id,
description,
})
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useInstallPlugin() {
const qc = useQueryClient();
return useMutation({
+38
View File
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useCreatePlugin,
useDuplicatePlugin,
useForkScript,
useSaveScript,
useScript,
@@ -119,9 +120,11 @@ export function ScriptEditPage() {
const existing = useScript(name);
const save = useSaveScript();
const fork = useForkScript();
const duplicate = useDuplicatePlugin();
const [content, setContent] = useState("");
const [contentReady, setContentReady] = useState(false);
const [forkId, setForkId] = useState("");
const [duplicateId, setDuplicateId] = useState("");
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
@@ -163,6 +166,21 @@ export function ScriptEditPage() {
);
}
function onDuplicate(e) {
e.preventDefault();
const id = normalizePluginId(duplicateId);
if (!id) return;
duplicate.mutate(
{ name, id },
{
onSuccess: (data) => {
notify.success("Duplicated — drain-restart recommended");
navigate(`/scripts/${encodeURIComponent(data.scriptRef)}/edit`);
},
},
);
}
if (existing.isLoading) {
return (
<div className="flex min-h-[12rem] items-center justify-center">
@@ -238,7 +256,27 @@ export function ScriptEditPage() {
<span className="text-error text-sm">{errorMessage(fork.error)}</span>
) : null}
</form>
) : (
<form onSubmit={onDuplicate} className="flex flex-wrap items-center gap-2">
<input
className="input input-sm font-mono w-56"
placeholder="duplicate id (e.g. my-plugin-copy)"
value={duplicateId}
onChange={(e) => setDuplicateId(e.target.value)}
/>
<button
type="submit"
className="btn btn-sm"
disabled={duplicate.isPending || !normalizePluginId(duplicateId)}
>
<LuCopy className="size-4" />
Duplicate plugin
</button>
{duplicate.isError ? (
<span className="text-error text-sm">{errorMessage(duplicate.error)}</span>
) : null}
</form>
)}
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
<CodeEditor
+77
View File
@@ -4,6 +4,7 @@ import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icon
import { errorMessage } from "../api/client.js";
import {
useDeleteScript,
useDuplicatePlugin,
useForkScript,
useInstallPlugin,
useScripts,
@@ -23,8 +24,11 @@ export function ScriptsPage() {
const [query, setQuery] = useState("");
const [forkFor, setForkFor] = useState(null);
const [forkId, setForkId] = useState("");
const [duplicateFor, setDuplicateFor] = useState(null);
const [duplicateId, setDuplicateId] = useState("");
const del = useDeleteScript();
const fork = useForkScript();
const duplicate = useDuplicatePlugin();
const install = useInstallPlugin();
const { notify } = useNotifications();
@@ -182,6 +186,7 @@ export function ScriptsPage() {
<LuCopy className="size-4" />
</button>
) : (
<>
<Link
to={`/scripts/${encodeURIComponent(name)}/edit`}
className="btn btn-ghost btn-xs"
@@ -190,6 +195,24 @@ export function ScriptsPage() {
>
<LuPencil className="size-4" />
</Link>
<button
type="button"
className="btn btn-ghost btn-xs"
title="Duplicate"
aria-label="Duplicate"
onClick={() => {
setDuplicateFor(name);
setDuplicateId(
String(name)
.replace(/^plugin\//i, "")
.replace(/\.js$/i, "")
.toLowerCase() + "-copy",
);
}}
>
<LuCopy className="size-4" />
</button>
</>
)}
{!isCore ? (
<button
@@ -264,6 +287,60 @@ export function ScriptsPage() {
</dialog>
) : null}
{duplicateFor ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-semibold">Duplicate {duplicateFor}</h3>
<p className="text-sm opacity-70 py-2">
Creates <code>plugin/&lt;id&gt;</code> from this plugin.
</p>
<input
className="input input-bordered input-sm w-full font-mono"
value={duplicateId}
onChange={(e) => setDuplicateId(e.target.value)}
/>
{duplicate.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(duplicate.error)}</p>
) : null}
<div className="modal-action">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setDuplicateFor(null)}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={duplicate.isPending || !duplicateId.trim()}
onClick={() =>
duplicate.mutate(
{ name: duplicateFor, id: duplicateId.trim() },
{
onSuccess: (data) => {
setDuplicateFor(null);
notify.success("Duplicated — drain-restart recommended");
navigate(
`/scripts/${encodeURIComponent(data.scriptRef)}/edit`,
);
},
},
)
}
>
Duplicate
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setDuplicateFor(null)}>
close
</button>
</form>
</dialog>
) : null}
<ConfirmDialog
open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}