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:
@@ -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
|
* @param {string} pluginDirectory
|
||||||
* @returns {((id: string) => unknown) | null}
|
* @returns {((id: string) => unknown) | null}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ function ntfyHeaders(ctx) {
|
|||||||
headers.Title = ctx.data.title;
|
headers.Title = ctx.data.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.config?.markdown === true) {
|
||||||
|
headers.md = "true";
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +153,11 @@ ntfy.meta = {
|
|||||||
default: "https://ntfy.sh/jerapah-flow",
|
default: "https://ntfy.sh/jerapah-flow",
|
||||||
description: "ntfy topic URL",
|
description: "ntfy topic URL",
|
||||||
},
|
},
|
||||||
|
markdown: {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "Send as Markdown (ntfy md header)",
|
||||||
|
},
|
||||||
fingerprint: {
|
fingerprint: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import * as fsStore from "../../fs-store.js";
|
import * as fsStore from "../../fs-store.js";
|
||||||
import {
|
import {
|
||||||
forkCoreScript,
|
forkCoreScript,
|
||||||
|
duplicatePlugin,
|
||||||
listCoreScriptNames,
|
listCoreScriptNames,
|
||||||
listInstalledPlugins,
|
listInstalledPlugins,
|
||||||
resolveScriptRef,
|
resolveScriptRef,
|
||||||
@@ -29,7 +30,7 @@ import { normalizeStepResult } from "../../step-result.js";
|
|||||||
import { resolveConfigRefs } from "../../config-refs.js";
|
import { resolveConfigRefs } from "../../config-refs.js";
|
||||||
import { getAppVersion } from "../../app-version.js";
|
import { getAppVersion } from "../../app-version.js";
|
||||||
import { EXAMPLE_PLUGINS_DIR } from "../../paths.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 { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
|
||||||
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
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) => {
|
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
|
||||||
const rawName = decodeURIComponent(
|
const rawName = decodeURIComponent(
|
||||||
/** @type {{ name: string }} */ (req.params).name,
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { migrate, db } from "../db.js";
|
|||||||
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
||||||
import {
|
import {
|
||||||
forkCoreScript,
|
forkCoreScript,
|
||||||
|
duplicatePlugin,
|
||||||
resolveScriptRef,
|
resolveScriptRef,
|
||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
listInstalledPlugins,
|
listInstalledPlugins,
|
||||||
@@ -83,6 +84,26 @@ async function main() {
|
|||||||
);
|
);
|
||||||
assert.equal(blankRun.output.ok, true);
|
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;
|
let hit = false;
|
||||||
try {
|
try {
|
||||||
forkCoreScript("ntfy.js", "ntfy");
|
forkCoreScript("ntfy.js", "ntfy");
|
||||||
@@ -102,6 +123,7 @@ async function main() {
|
|||||||
|
|
||||||
uninstallPlugin("jsonata-smoke-fork");
|
uninstallPlugin("jsonata-smoke-fork");
|
||||||
uninstallPlugin("blank-smoke");
|
uninstallPlugin("blank-smoke");
|
||||||
|
uninstallPlugin("blank-smoke-copy");
|
||||||
uninstallPlugin("get-current-time");
|
uninstallPlugin("get-current-time");
|
||||||
|
|
||||||
console.log("plugins-smoke: ok");
|
console.log("plugins-smoke: ok");
|
||||||
|
|||||||
@@ -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() {
|
export function useInstallPlugin() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
|
|||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import {
|
import {
|
||||||
useCreatePlugin,
|
useCreatePlugin,
|
||||||
|
useDuplicatePlugin,
|
||||||
useForkScript,
|
useForkScript,
|
||||||
useSaveScript,
|
useSaveScript,
|
||||||
useScript,
|
useScript,
|
||||||
@@ -119,9 +120,11 @@ export function ScriptEditPage() {
|
|||||||
const existing = useScript(name);
|
const existing = useScript(name);
|
||||||
const save = useSaveScript();
|
const save = useSaveScript();
|
||||||
const fork = useForkScript();
|
const fork = useForkScript();
|
||||||
|
const duplicate = useDuplicatePlugin();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [contentReady, setContentReady] = useState(false);
|
const [contentReady, setContentReady] = useState(false);
|
||||||
const [forkId, setForkId] = useState("");
|
const [forkId, setForkId] = useState("");
|
||||||
|
const [duplicateId, setDuplicateId] = useState("");
|
||||||
|
|
||||||
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
|
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) {
|
if (existing.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[12rem] items-center justify-center">
|
<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>
|
<span className="text-error text-sm">{errorMessage(fork.error)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : (
|
||||||
|
<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">
|
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icon
|
|||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import {
|
import {
|
||||||
useDeleteScript,
|
useDeleteScript,
|
||||||
|
useDuplicatePlugin,
|
||||||
useForkScript,
|
useForkScript,
|
||||||
useInstallPlugin,
|
useInstallPlugin,
|
||||||
useScripts,
|
useScripts,
|
||||||
@@ -23,8 +24,11 @@ export function ScriptsPage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [forkFor, setForkFor] = useState(null);
|
const [forkFor, setForkFor] = useState(null);
|
||||||
const [forkId, setForkId] = useState("");
|
const [forkId, setForkId] = useState("");
|
||||||
|
const [duplicateFor, setDuplicateFor] = useState(null);
|
||||||
|
const [duplicateId, setDuplicateId] = useState("");
|
||||||
const del = useDeleteScript();
|
const del = useDeleteScript();
|
||||||
const fork = useForkScript();
|
const fork = useForkScript();
|
||||||
|
const duplicate = useDuplicatePlugin();
|
||||||
const install = useInstallPlugin();
|
const install = useInstallPlugin();
|
||||||
const { notify } = useNotifications();
|
const { notify } = useNotifications();
|
||||||
|
|
||||||
@@ -182,14 +186,33 @@ export function ScriptsPage() {
|
|||||||
<LuCopy className="size-4" />
|
<LuCopy className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<>
|
||||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
<Link
|
||||||
className="btn btn-ghost btn-xs"
|
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||||
title="Edit"
|
className="btn btn-ghost btn-xs"
|
||||||
aria-label="Edit"
|
title="Edit"
|
||||||
>
|
aria-label="Edit"
|
||||||
<LuPencil className="size-4" />
|
>
|
||||||
</Link>
|
<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 ? (
|
{!isCore ? (
|
||||||
<button
|
<button
|
||||||
@@ -264,6 +287,60 @@ export function ScriptsPage() {
|
|||||||
</dialog>
|
</dialog>
|
||||||
) : null}
|
) : 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/<id></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
|
<ConfirmDialog
|
||||||
open={Boolean(confirmDelete)}
|
open={Boolean(confirmDelete)}
|
||||||
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
||||||
|
|||||||
Reference in New Issue
Block a user