diff --git a/packages/server/plugin-store.js b/packages/server/plugin-store.js index e10201c..d02fb25 100644 --- a/packages/server/plugin-store.js +++ b/packages/server/plugin-store.js @@ -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} diff --git a/packages/server/scripts/ntfy.js b/packages/server/scripts/ntfy.js index 3c589f5..0584d2a 100644 --- a/packages/server/scripts/ntfy.js +++ b/packages/server/scripts/ntfy.js @@ -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, diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index 4ca421c..cdb8720 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -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/)" }); + } + 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, diff --git a/packages/server/test/plugins-smoke.js b/packages/server/test/plugins-smoke.js index c0fc7ba..674ce88 100644 --- a/packages/server/test/plugins-smoke.js +++ b/packages/server/test/plugins-smoke.js @@ -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"); diff --git a/packages/web/src/api/hooks/scripts.js b/packages/web/src/api/hooks/scripts.js index cef656e..0e5c128 100644 --- a/packages/web/src/api/hooks/scripts.js +++ b/packages/web/src/api/hooks/scripts.js @@ -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({ diff --git a/packages/web/src/pages/ScriptEditPage.jsx b/packages/web/src/pages/ScriptEditPage.jsx index 63bb4f1..7f7edf9 100644 --- a/packages/web/src/pages/ScriptEditPage.jsx +++ b/packages/web/src/pages/ScriptEditPage.jsx @@ -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 (
@@ -238,7 +256,27 @@ export function ScriptEditPage() { {errorMessage(fork.error)} ) : null} - ) : null} + ) : ( +
+ setDuplicateId(e.target.value)} + /> + + {duplicate.isError ? ( + {errorMessage(duplicate.error)} + ) : null} +
+ )}
) : ( - - - + <> + + + + + )} {!isCore ? ( + +
+ + + + + + ) : null} +