Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20b26b4c7 |
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<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
|
||||
open={Boolean(confirmDelete)}
|
||||
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
||||
|
||||
Reference in New Issue
Block a user