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:
@@ -31,6 +31,26 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
|
||||
`pnpm dev:pm2` is the mode for Ops (start/stop HTTP, scale workers, drain restart). Control owns SQLite migrations; HTTP/workers do not migrate.
|
||||
|
||||
## Scripts (core vs plugins)
|
||||
|
||||
| Kind | Name in YAML | Editable | Location |
|
||||
|---|---|---|---|
|
||||
| **Core** | `fetch-http.js`, `s3.js`, … | No (fork only) | `packages/server/scripts/` |
|
||||
| **Plugin** | `plugin/<id>` | Yes | `data/plugins/<id>/` |
|
||||
|
||||
- App version is **`0.1.0`** (root `package.json`). Plugin manifests declare `jerapah: ">=0.1.0 <1.0.0"`.
|
||||
- Install plugins via admin API: zip (base64), HTTPS git URL, example, or fork a core script.
|
||||
- Install/update/uninstall sets **restart-needed** — drain-restart HTTP + workers under `pnpm dev:pm2`.
|
||||
- Example plugin: `examples/plugins/get-current-time` → `plugin/get-current-time`.
|
||||
|
||||
```bash
|
||||
# Smoke
|
||||
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||
node packages/server/test/plugins-smoke.js
|
||||
```
|
||||
|
||||
|
||||
## Script contract
|
||||
|
||||
Each script is `async function main(ctx)` and **must** return:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "get-current-time",
|
||||
"name": "Get current time",
|
||||
"version": "0.1.0",
|
||||
"jerapah": ">=0.1.0 <1.0.0",
|
||||
"main": "script.js",
|
||||
"description": "Example user plugin: return the current time as output.datetime"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "jflow-plugin-get-current-time",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Example JerapahFlow plugin (no npm dependencies)"
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jerapah-flow",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Script/workflow runner with admin UI",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { SERVER_ROOT } from "./paths.js";
|
||||
|
||||
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json");
|
||||
|
||||
/**
|
||||
* JerapahFlow app version from the monorepo root package.json.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAppVersion() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(ROOT_PKG, "utf8"));
|
||||
if (typeof raw.version === "string" && raw.version.trim()) {
|
||||
return raw.version.trim();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return "0.1.0";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} version
|
||||
* @returns {[number, number, number]}
|
||||
*/
|
||||
function parseSemver(version) {
|
||||
const cleaned = String(version).trim().replace(/^v/i, "");
|
||||
const core = cleaned.split("-")[0].split("+")[0];
|
||||
const parts = core.split(".").map((p) => Number(p));
|
||||
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {[number, number, number]} a
|
||||
* @param {[number, number, number]} b
|
||||
*/
|
||||
function cmp(a, b) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal semver range check for patterns used in manifests:
|
||||
* `1.2.3`, `>=0.1.0`, `<1.0.0`, `>=0.1.0 <1.0.0`
|
||||
*
|
||||
* @param {string} version
|
||||
* @param {string} range
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function satisfiesRange(version, range) {
|
||||
if (typeof range !== "string" || !range.trim()) return false;
|
||||
const ver = parseSemver(version);
|
||||
const tokens = range.trim().split(/\s+/);
|
||||
for (const token of tokens) {
|
||||
if (/^\d+\.\d+\.\d+/.test(token) && !token.startsWith(">") && !token.startsWith("<")) {
|
||||
if (cmp(ver, parseSemver(token)) !== 0) return false;
|
||||
continue;
|
||||
}
|
||||
const m = /^(>=|<=|>|<|=)?\s*v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/.exec(
|
||||
token,
|
||||
);
|
||||
if (!m) return false;
|
||||
const op = m[1] || "=";
|
||||
const bound = parseSemver(m[2]);
|
||||
const c = cmp(ver, bound);
|
||||
if (op === ">=" && c < 0) return false;
|
||||
if (op === "<=" && c > 0) return false;
|
||||
if (op === ">" && c <= 0) return false;
|
||||
if (op === "<" && c >= 0) return false;
|
||||
if (op === "=" && c !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void fileURLToPath;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jerapah-flow/server",
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "runner.js",
|
||||
@@ -11,7 +11,8 @@
|
||||
"start:api": "node server.js",
|
||||
"start:worker": "node worker.js",
|
||||
"start:control": "node control.js",
|
||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\""
|
||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
||||
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1111.0",
|
||||
|
||||
@@ -5,5 +5,13 @@ export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
||||
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
||||
/** Installed user plugins (outside the pnpm workspace). */
|
||||
export const PLUGINS_DIR =
|
||||
process.env.JFLOW_PLUGINS_DIR ?? path.join(DATA_DIR, "plugins");
|
||||
/** Example plugin sources shipped with the repo. */
|
||||
export const EXAMPLE_PLUGINS_DIR = path.resolve(
|
||||
SERVER_ROOT,
|
||||
"../../examples/plugins",
|
||||
);
|
||||
export const LOGS_DIR = path.join(SERVER_ROOT, "logs");
|
||||
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { EXAMPLE_PLUGINS_DIR, PLUGINS_DIR } from "./paths.js";
|
||||
import {
|
||||
installPluginFromDirectory,
|
||||
pluginDir,
|
||||
} from "./plugin-store.js";
|
||||
import { readManifestFile } from "./plugin-manifest.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
export function assertHttpsGitUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
const err = new Error("invalid git URL");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
const err = new Error("git URL must use https://");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pnpm install in a plugin directory (ignore lifecycle scripts).
|
||||
* @param {string} dir
|
||||
*/
|
||||
export async function pnpmInstallPlugin(dir) {
|
||||
const pkg = path.join(dir, "package.json");
|
||||
if (!fs.existsSync(pkg)) return { skipped: true };
|
||||
let hasDeps = false;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(pkg, "utf8"));
|
||||
hasDeps = Boolean(
|
||||
(raw.dependencies && Object.keys(raw.dependencies).length) ||
|
||||
(raw.optionalDependencies &&
|
||||
Object.keys(raw.optionalDependencies).length),
|
||||
);
|
||||
} catch {
|
||||
hasDeps = true;
|
||||
}
|
||||
if (!hasDeps) return { skipped: true };
|
||||
|
||||
await execFileAsync(
|
||||
"pnpm",
|
||||
["install", "--dir", dir, "--ignore-scripts", "--prefer-offline"],
|
||||
{
|
||||
cwd: dir,
|
||||
env: { ...process.env, CI: "1" },
|
||||
timeout: 5 * 60_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
return { skipped: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {{ ref?: string, overwrite?: boolean }} [opts]
|
||||
*/
|
||||
export async function installPluginFromGit(url, opts = {}) {
|
||||
const httpsUrl = assertHttpsGitUrl(url);
|
||||
const staging = path.join(
|
||||
PLUGINS_DIR,
|
||||
`.staging-git-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
try {
|
||||
const args = ["clone", "--depth", "1"];
|
||||
if (opts.ref) {
|
||||
args.push("--branch", String(opts.ref));
|
||||
}
|
||||
args.push(httpsUrl, staging);
|
||||
await execFileAsync("git", args, {
|
||||
timeout: 5 * 60_000,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
// Remove .git to keep plugins lean
|
||||
fs.rmSync(path.join(staging, ".git"), { recursive: true, force: true });
|
||||
const installed = installPluginFromDirectory(staging, {
|
||||
overwrite: Boolean(opts.overwrite),
|
||||
markRestart: false,
|
||||
});
|
||||
await pnpmInstallPlugin(installed.dir);
|
||||
const { bumpGeneration } = await import("./control-state.js");
|
||||
bumpGeneration(`plugin:${installed.id} installed from git`);
|
||||
return installed;
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} zipPath
|
||||
* @param {{ overwrite?: boolean }} [opts]
|
||||
*/
|
||||
export async function installPluginFromZipFile(zipPath, opts = {}) {
|
||||
const abs = path.resolve(zipPath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
const err = new Error("zip file not found");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const staging = path.join(
|
||||
PLUGINS_DIR,
|
||||
`.staging-zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const extractDir = path.join(staging, "extract");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
try {
|
||||
await execFileAsync("unzip", ["-q", abs, "-d", extractDir], {
|
||||
timeout: 120_000,
|
||||
});
|
||||
const root = findPluginRoot(extractDir);
|
||||
const installed = installPluginFromDirectory(root, {
|
||||
overwrite: Boolean(opts.overwrite),
|
||||
markRestart: false,
|
||||
});
|
||||
await pnpmInstallPlugin(installed.dir);
|
||||
const { bumpGeneration } = await import("./control-state.js");
|
||||
bumpGeneration(`plugin:${installed.id} installed from zip`);
|
||||
return installed;
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} buffer
|
||||
* @param {{ overwrite?: boolean }} [opts]
|
||||
*/
|
||||
export async function installPluginFromZipBuffer(buffer, opts = {}) {
|
||||
const tmp = path.join(
|
||||
os.tmpdir(),
|
||||
`jflow-plugin-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`,
|
||||
);
|
||||
fs.writeFileSync(tmp, buffer);
|
||||
try {
|
||||
return await installPluginFromZipFile(tmp, opts);
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find directory containing jerapah-plugin.json (zip may have a single top folder).
|
||||
* @param {string} extractDir
|
||||
*/
|
||||
function findPluginRoot(extractDir) {
|
||||
const direct = path.join(extractDir, "jerapah-plugin.json");
|
||||
if (fs.existsSync(direct)) return extractDir;
|
||||
const entries = fs.readdirSync(extractDir, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
|
||||
if (dirs.length === 1) {
|
||||
const nested = path.join(extractDir, dirs[0].name);
|
||||
if (fs.existsSync(path.join(nested, "jerapah-plugin.json"))) return nested;
|
||||
}
|
||||
for (const e of dirs) {
|
||||
const nested = path.join(extractDir, e.name);
|
||||
if (fs.existsSync(path.join(nested, "jerapah-plugin.json"))) return nested;
|
||||
}
|
||||
const err = new Error("zip missing jerapah-plugin.json");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a shipped example plugin by id (from examples/plugins/<id>).
|
||||
* @param {string} exampleId
|
||||
* @param {{ overwrite?: boolean }} [opts]
|
||||
*/
|
||||
export async function installExamplePlugin(exampleId, opts = {}) {
|
||||
const src = path.join(EXAMPLE_PLUGINS_DIR, exampleId);
|
||||
if (!fs.existsSync(src)) {
|
||||
const err = new Error(`example plugin not found: ${exampleId}`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const installed = installPluginFromDirectory(src, {
|
||||
overwrite: Boolean(opts.overwrite),
|
||||
markRestart: false,
|
||||
});
|
||||
await pnpmInstallPlugin(installed.dir);
|
||||
const { bumpGeneration } = await import("./control-state.js");
|
||||
bumpGeneration(`plugin:${installed.id} installed from example`);
|
||||
return installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy example into plugins if missing (used by smoke / first-run helpers).
|
||||
* @param {string} exampleId
|
||||
*/
|
||||
export function ensureExampleInstalled(exampleId) {
|
||||
const dest = pluginDir(exampleId);
|
||||
if (fs.existsSync(dest)) {
|
||||
return { id: exampleId, already: true, dir: dest };
|
||||
}
|
||||
const src = path.join(EXAMPLE_PLUGINS_DIR, exampleId);
|
||||
const installed = installPluginFromDirectory(src, {
|
||||
overwrite: false,
|
||||
markRestart: false,
|
||||
});
|
||||
return { ...installed, already: false };
|
||||
}
|
||||
|
||||
void readManifestFile;
|
||||
@@ -0,0 +1,159 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getAppVersion, satisfiesRange } from "./app-version.js";
|
||||
|
||||
export const PLUGIN_MANIFEST = "jerapah-plugin.json";
|
||||
export const PLUGIN_PREFIX = "plugin/";
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertPluginId(id) {
|
||||
if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
|
||||
const err = new Error(
|
||||
"invalid plugin id (use lowercase letters, numbers, hyphens)",
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (id.length > 64) {
|
||||
const err = new Error("plugin id too long");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} scriptRef e.g. plugin/foo or plugin/foo.js
|
||||
* @returns {{ id: string, scriptRef: string } | null}
|
||||
*/
|
||||
export function parsePluginScriptRef(scriptRef) {
|
||||
if (typeof scriptRef !== "string") return null;
|
||||
let rest = scriptRef;
|
||||
if (rest.startsWith(PLUGIN_PREFIX)) {
|
||||
rest = rest.slice(PLUGIN_PREFIX.length);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (rest.endsWith(".js")) rest = rest.slice(0, -3);
|
||||
if (!rest || rest.includes("/") || rest.includes("\\")) return null;
|
||||
try {
|
||||
const id = assertPluginId(rest);
|
||||
return { id, scriptRef: `${PLUGIN_PREFIX}${id}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function pluginScriptRef(id) {
|
||||
return `${PLUGIN_PREFIX}${assertPluginId(id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* version: string,
|
||||
* jerapah: string,
|
||||
* main: string,
|
||||
* description: string | null,
|
||||
* }}
|
||||
*/
|
||||
export function validateManifest(raw) {
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
const err = new Error("manifest must be an object");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const id = assertPluginId(String(/** @type {any} */ (raw).id ?? ""));
|
||||
const version = String(/** @type {any} */ (raw).version ?? "").trim();
|
||||
if (!/^\d+\.\d+\.\d+/.test(version)) {
|
||||
const err = new Error("manifest.version must be semver (e.g. 0.1.0)");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const jerapah = String(/** @type {any} */ (raw).jerapah ?? "").trim();
|
||||
if (!jerapah) {
|
||||
const err = new Error("manifest.jerapah range is required");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const main = String(/** @type {any} */ (raw).main ?? "script.js").trim();
|
||||
if (!main || main.includes("..") || path.isAbsolute(main)) {
|
||||
const err = new Error("manifest.main must be a relative file path");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const name =
|
||||
String(/** @type {any} */ (raw).name ?? id).trim() || id;
|
||||
const descriptionRaw = /** @type {any} */ (raw).description;
|
||||
const description =
|
||||
typeof descriptionRaw === "string" && descriptionRaw.trim()
|
||||
? descriptionRaw.trim()
|
||||
: null;
|
||||
return { id, name, version, jerapah, main, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pluginDir
|
||||
*/
|
||||
export function readManifestFile(pluginDir) {
|
||||
const filePath = path.join(pluginDir, PLUGIN_MANIFEST);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const err = new Error(`missing ${PLUGIN_MANIFEST}`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
const err = new Error(`invalid ${PLUGIN_MANIFEST} JSON`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return validateManifest(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof validateManifest>} manifest
|
||||
* @returns {{ ok: true } | { ok: false, error: string }}
|
||||
*/
|
||||
export function checkJerapahCompat(manifest) {
|
||||
const appVersion = getAppVersion();
|
||||
if (!satisfiesRange(appVersion, manifest.jerapah)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `plugin requires JerapahFlow ${manifest.jerapah}; app is ${appVersion}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* id: string,
|
||||
* name?: string,
|
||||
* version?: string,
|
||||
* jerapah?: string,
|
||||
* main?: string,
|
||||
* description?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
export function buildManifest(opts) {
|
||||
return validateManifest({
|
||||
id: opts.id,
|
||||
name: opts.name ?? opts.id,
|
||||
version: opts.version ?? "0.1.0",
|
||||
jerapah: opts.jerapah ?? ">=0.1.0 <1.0.0",
|
||||
main: opts.main ?? "script.js",
|
||||
description: opts.description ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { createRequire } from "node:module";
|
||||
import { PLUGINS_DIR, SCRIPTS_DIR } from "./paths.js";
|
||||
import {
|
||||
PLUGIN_MANIFEST,
|
||||
assertPluginId,
|
||||
buildManifest,
|
||||
checkJerapahCompat,
|
||||
parsePluginScriptRef,
|
||||
pluginScriptRef,
|
||||
readManifestFile,
|
||||
validateManifest,
|
||||
} from "./plugin-manifest.js";
|
||||
import { listScriptFiles } from "./fs-store.js";
|
||||
import { bumpGeneration } from "./control-state.js";
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function pluginDir(id) {
|
||||
return path.join(PLUGINS_DIR, assertPluginId(id));
|
||||
}
|
||||
|
||||
export function ensurePluginsDir() {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Core script file names (*.js) currently shipped under SCRIPTS_DIR.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function listCoreScriptNames() {
|
||||
return listScriptFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare core names without .js (for collision checks).
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function coreBareNames() {
|
||||
return new Set(
|
||||
listCoreScriptNames().map((n) => (n.endsWith(".js") ? n.slice(0, -3) : n)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<{
|
||||
* id: string,
|
||||
* scriptRef: string,
|
||||
* dir: string,
|
||||
* manifest: ReturnType<typeof readManifestFile>,
|
||||
* compatible: boolean,
|
||||
* compatError: string | null,
|
||||
* disabled: boolean,
|
||||
* }>}
|
||||
*/
|
||||
export function listInstalledPlugins() {
|
||||
ensurePluginsDir();
|
||||
if (!fs.existsSync(PLUGINS_DIR)) return [];
|
||||
/** @type {Array<any>} */
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
let id;
|
||||
try {
|
||||
id = assertPluginId(entry.name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const dir = pluginDir(id);
|
||||
try {
|
||||
const manifest = readManifestFile(dir);
|
||||
if (manifest.id !== id) {
|
||||
out.push({
|
||||
id,
|
||||
scriptRef: pluginScriptRef(id),
|
||||
dir,
|
||||
manifest,
|
||||
compatible: false,
|
||||
compatError: `manifest id "${manifest.id}" does not match folder "${id}"`,
|
||||
disabled: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const compat = checkJerapahCompat(manifest);
|
||||
const disabledFlag = fs.existsSync(path.join(dir, ".disabled"));
|
||||
out.push({
|
||||
id,
|
||||
scriptRef: pluginScriptRef(id),
|
||||
dir,
|
||||
manifest,
|
||||
compatible: compat.ok,
|
||||
compatError: compat.ok ? null : compat.error,
|
||||
disabled: disabledFlag || !compat.ok,
|
||||
});
|
||||
} catch (err) {
|
||||
out.push({
|
||||
id,
|
||||
scriptRef: pluginScriptRef(id),
|
||||
dir,
|
||||
manifest: null,
|
||||
compatible: false,
|
||||
compatError: err instanceof Error ? err.message : String(err),
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function getInstalledPlugin(id) {
|
||||
const needle = assertPluginId(id);
|
||||
return listInstalledPlugins().find((p) => p.id === needle) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workflow script ref to a filesystem path + kind.
|
||||
*
|
||||
* @param {string} scriptRef
|
||||
* @returns {{
|
||||
* kind: "core" | "plugin",
|
||||
* scriptRef: string,
|
||||
* filePath: string,
|
||||
* pluginId?: string,
|
||||
* pluginDir?: string,
|
||||
* disabled?: boolean,
|
||||
* error?: string,
|
||||
* }}
|
||||
*/
|
||||
export function resolveScriptRef(scriptRef) {
|
||||
if (typeof scriptRef !== "string" || !scriptRef.trim()) {
|
||||
return {
|
||||
kind: "core",
|
||||
scriptRef: String(scriptRef),
|
||||
filePath: "",
|
||||
error: "invalid script ref",
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = parsePluginScriptRef(scriptRef);
|
||||
if (plugin) {
|
||||
const installed = getInstalledPlugin(plugin.id);
|
||||
if (!installed) {
|
||||
return {
|
||||
kind: "plugin",
|
||||
scriptRef: plugin.scriptRef,
|
||||
pluginId: plugin.id,
|
||||
filePath: "",
|
||||
error: `plugin not installed: ${plugin.scriptRef}`,
|
||||
};
|
||||
}
|
||||
if (installed.disabled) {
|
||||
return {
|
||||
kind: "plugin",
|
||||
scriptRef: plugin.scriptRef,
|
||||
pluginId: plugin.id,
|
||||
pluginDir: installed.dir,
|
||||
filePath: "",
|
||||
disabled: true,
|
||||
error:
|
||||
installed.compatError ||
|
||||
`plugin disabled: ${plugin.scriptRef}`,
|
||||
};
|
||||
}
|
||||
const mainPath = path.join(installed.dir, installed.manifest.main);
|
||||
if (!fs.existsSync(mainPath)) {
|
||||
return {
|
||||
kind: "plugin",
|
||||
scriptRef: plugin.scriptRef,
|
||||
pluginId: plugin.id,
|
||||
pluginDir: installed.dir,
|
||||
filePath: "",
|
||||
error: `plugin main missing: ${installed.manifest.main}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "plugin",
|
||||
scriptRef: plugin.scriptRef,
|
||||
pluginId: plugin.id,
|
||||
pluginDir: installed.dir,
|
||||
filePath: mainPath,
|
||||
};
|
||||
}
|
||||
|
||||
// Core: must be a plain *.js filename
|
||||
if (
|
||||
scriptRef.includes("/") ||
|
||||
scriptRef.includes("\\") ||
|
||||
scriptRef.includes("..")
|
||||
) {
|
||||
return {
|
||||
kind: "core",
|
||||
scriptRef,
|
||||
filePath: "",
|
||||
error: "invalid core script name",
|
||||
};
|
||||
}
|
||||
const name = scriptRef.endsWith(".js") ? scriptRef : `${scriptRef}.js`;
|
||||
const filePath = path.join(SCRIPTS_DIR, name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
kind: "core",
|
||||
scriptRef: name,
|
||||
filePath: "",
|
||||
error: `core script not found: ${name}`,
|
||||
};
|
||||
}
|
||||
return { kind: "core", scriptRef: name, filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a prepared plugin directory into PLUGINS_DIR.
|
||||
*
|
||||
* @param {string} sourceDir directory containing jerapah-plugin.json
|
||||
* @param {{ overwrite?: boolean, markRestart?: boolean, reason?: string }} [opts]
|
||||
*/
|
||||
export function installPluginFromDirectory(sourceDir, opts = {}) {
|
||||
const abs = path.resolve(sourceDir);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
||||
const err = new Error("plugin source directory not found");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const manifest = readManifestFile(abs);
|
||||
const compat = checkJerapahCompat(manifest);
|
||||
if (!compat.ok) {
|
||||
const err = new Error(compat.error);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
if (coreBareNames().has(manifest.id)) {
|
||||
const err = new Error(
|
||||
`plugin id "${manifest.id}" collides with a core script name`,
|
||||
);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const mainPath = path.join(abs, manifest.main);
|
||||
if (!fs.existsSync(mainPath)) {
|
||||
const err = new Error(`manifest.main not found: ${manifest.main}`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
ensurePluginsDir();
|
||||
const dest = pluginDir(manifest.id);
|
||||
if (fs.existsSync(dest)) {
|
||||
if (!opts.overwrite) {
|
||||
const err = new Error(`plugin already installed: ${manifest.id}`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
fs.cpSync(abs, dest, { recursive: true });
|
||||
|
||||
// Ensure package.json exists (fork / thin plugins).
|
||||
const pkgPath = path.join(dest, "package.json");
|
||||
if (!fs.existsSync(pkgPath)) {
|
||||
fs.writeFileSync(
|
||||
pkgPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `jflow-plugin-${manifest.id}`,
|
||||
version: manifest.version,
|
||||
private: true,
|
||||
type: "module",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.markRestart !== false) {
|
||||
bumpGeneration(opts.reason ?? `plugin:${manifest.id} installed`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: manifest.id,
|
||||
scriptRef: pluginScriptRef(manifest.id),
|
||||
dir: dest,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{ markRestart?: boolean }} [opts]
|
||||
*/
|
||||
export function uninstallPlugin(id, opts = {}) {
|
||||
const pluginId = assertPluginId(id);
|
||||
const dir = pluginDir(pluginId);
|
||||
if (!fs.existsSync(dir)) {
|
||||
const err = new Error("plugin not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (opts.markRestart !== false) {
|
||||
bumpGeneration(`plugin:${pluginId} uninstalled`);
|
||||
}
|
||||
return { ok: true, id: pluginId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a core script into a new plugin.
|
||||
*
|
||||
* @param {string} coreName e.g. fetch-http.js
|
||||
* @param {string} newId
|
||||
* @param {{ description?: string }} [opts]
|
||||
*/
|
||||
export function forkCoreScript(coreName, newId, opts = {}) {
|
||||
const id = assertPluginId(newId);
|
||||
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 coreFile = coreName.endsWith(".js") ? coreName : `${coreName}.js`;
|
||||
const src = path.join(SCRIPTS_DIR, coreFile);
|
||||
if (!fs.existsSync(src)) {
|
||||
const err = new Error(`core script not found: ${coreFile}`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const staging = path.join(PLUGINS_DIR, `.staging-fork-${id}-${Date.now()}`);
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
try {
|
||||
const main = "script.js";
|
||||
fs.copyFileSync(src, path.join(staging, main));
|
||||
const manifest = buildManifest({
|
||||
id,
|
||||
name: id,
|
||||
version: "0.1.0",
|
||||
jerapah: ">=0.1.0 <1.0.0",
|
||||
main,
|
||||
description:
|
||||
opts.description ?? `Fork of core script ${coreFile}`,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(staging, PLUGIN_MANIFEST),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(staging, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `jflow-plugin-${id}`,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return installPluginFromDirectory(staging, {
|
||||
overwrite: false,
|
||||
reason: `plugin:${id} forked from ${coreFile}`,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pluginDirectory
|
||||
* @returns {((id: string) => unknown) | null}
|
||||
*/
|
||||
export function createPluginRequire(pluginDirectory) {
|
||||
const pkg = path.resolve(pluginDirectory, "package.json");
|
||||
if (!fs.existsSync(pkg)) return null;
|
||||
return createRequire(pkg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty plugin from the new-script template.
|
||||
* @param {string} newId
|
||||
* @param {string} source
|
||||
* @param {{ description?: string }} [opts]
|
||||
*/
|
||||
export function createBlankPlugin(newId, source, opts = {}) {
|
||||
const id = assertPluginId(newId);
|
||||
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;
|
||||
}
|
||||
if (typeof source !== "string") {
|
||||
const err = new Error("source content is required");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const staging = path.join(PLUGINS_DIR, `.staging-new-${id}-${Date.now()}`);
|
||||
fs.mkdirSync(staging, { recursive: true });
|
||||
try {
|
||||
const main = "script.js";
|
||||
fs.writeFileSync(path.join(staging, main), source, "utf8");
|
||||
const manifest = buildManifest({
|
||||
id,
|
||||
name: id,
|
||||
version: "0.1.0",
|
||||
jerapah: ">=0.1.0 <1.0.0",
|
||||
main,
|
||||
description: opts.description ?? null,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(staging, PLUGIN_MANIFEST),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(staging, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `jflow-plugin-${id}`,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return installPluginFromDirectory(staging, {
|
||||
overwrite: false,
|
||||
reason: `plugin:${id} created`,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import axios from "axios";
|
||||
import pino from "pino";
|
||||
import { createKvApi } from "./kv-store.js";
|
||||
import { createFingerprintApi } from "./script-fingerprint.js";
|
||||
import { SCRIPTS_DIR } from "./paths.js";
|
||||
import { resolveScriptRef, createPluginRequire } from "./plugin-store.js";
|
||||
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
||||
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
||||
import { getSecretPlaintext } from "./secrets-store.js";
|
||||
@@ -296,15 +296,60 @@ function createScreenedAxios(log) {
|
||||
});
|
||||
}
|
||||
|
||||
function createRestrictedRequire(screenedAxios) {
|
||||
const BLOCKED_PLUGIN_MODULES = new Set([
|
||||
"child_process",
|
||||
"node:child_process",
|
||||
"cluster",
|
||||
"node:cluster",
|
||||
"fs",
|
||||
"node:fs",
|
||||
"fs/promises",
|
||||
"node:fs/promises",
|
||||
"module",
|
||||
"node:module",
|
||||
"vm",
|
||||
"node:vm",
|
||||
"worker_threads",
|
||||
"node:worker_threads",
|
||||
"v8",
|
||||
"node:v8",
|
||||
"inspector",
|
||||
"node:inspector",
|
||||
"sqlite",
|
||||
"node:sqlite",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {import("axios").AxiosInstance} screenedAxios
|
||||
* @param {string | null} [pluginDirectory]
|
||||
*/
|
||||
function createRestrictedRequire(screenedAxios, pluginDirectory = null) {
|
||||
const pluginRequire = pluginDirectory
|
||||
? createPluginRequire(pluginDirectory)
|
||||
: null;
|
||||
|
||||
return function restrictedRequire(id) {
|
||||
if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) {
|
||||
if (typeof id !== "string") {
|
||||
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||
}
|
||||
if (id === "axios") {
|
||||
return screenedAxios;
|
||||
if (BLOCKED_PLUGIN_MODULES.has(id)) {
|
||||
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||
}
|
||||
if (ALLOWED_MODULES.has(id)) {
|
||||
if (id === "axios") return screenedAxios;
|
||||
return hostRequire(id);
|
||||
}
|
||||
if (pluginRequire) {
|
||||
try {
|
||||
return pluginRequire(id);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`require(${JSON.stringify(id)}) failed in plugin: ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,6 +436,7 @@ const $workflowsStub = {
|
||||
* workflowName: string,
|
||||
* owner?: string,
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* pluginDir?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
function createScriptSandbox({
|
||||
@@ -399,6 +445,7 @@ function createScriptSandbox({
|
||||
workflowName,
|
||||
owner = "default",
|
||||
$workflows = $workflowsStub,
|
||||
pluginDir = null,
|
||||
}) {
|
||||
const scriptLog = log.child({ workflow: workflowName, script });
|
||||
const $axios = createScreenedAxios(scriptLog);
|
||||
@@ -418,7 +465,7 @@ function createScriptSandbox({
|
||||
$vars,
|
||||
$responses,
|
||||
$workflows,
|
||||
require: createRestrictedRequire($axios),
|
||||
require: createRestrictedRequire($axios, pluginDir),
|
||||
};
|
||||
|
||||
vm.createContext(sandbox, {
|
||||
@@ -470,8 +517,29 @@ export function extractScriptMeta(fn) {
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* }} opts
|
||||
*/
|
||||
function instantiateCompiled(compiled, { log, script, workflowName, owner, $workflows }) {
|
||||
const sandbox = createScriptSandbox({ log, script, workflowName, owner, $workflows });
|
||||
/**
|
||||
* @param {import("vm").Script} compiled
|
||||
* @param {{
|
||||
* log: import("pino").Logger,
|
||||
* script: string,
|
||||
* workflowName: string,
|
||||
* owner?: string,
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* pluginDir?: string | null,
|
||||
* }} opts
|
||||
*/
|
||||
function instantiateCompiled(
|
||||
compiled,
|
||||
{ log, script, workflowName, owner, $workflows, pluginDir = null },
|
||||
) {
|
||||
const sandbox = createScriptSandbox({
|
||||
log,
|
||||
script,
|
||||
workflowName,
|
||||
owner,
|
||||
$workflows,
|
||||
pluginDir,
|
||||
});
|
||||
return compiled.runInContext(sandbox);
|
||||
}
|
||||
|
||||
@@ -486,6 +554,7 @@ function instantiateCompiled(compiled, { log, script, workflowName, owner, $work
|
||||
* workflowName?: string,
|
||||
* owner?: string,
|
||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||
* pluginDir?: string | null,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function instantiateScriptSource(script, source, opts = {}) {
|
||||
@@ -496,6 +565,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
|
||||
workflowName: opts.workflowName ?? "inspect",
|
||||
owner: opts.owner ?? "default",
|
||||
$workflows: opts.$workflows,
|
||||
pluginDir: opts.pluginDir ?? null,
|
||||
});
|
||||
return { fn, ...extractScriptMeta(fn) };
|
||||
}
|
||||
@@ -519,17 +589,28 @@ export function inspectScriptSource(script, source) {
|
||||
}
|
||||
|
||||
function loadCompiledScript(script) {
|
||||
const filePath = path.join(SCRIPTS_DIR, script);
|
||||
const resolved = resolveScriptRef(script);
|
||||
if (resolved.error || !resolved.filePath) {
|
||||
throw new Error(resolved.error || `script not found: ${script}`);
|
||||
}
|
||||
const filePath = resolved.filePath;
|
||||
const { mtimeMs } = fs.statSync(filePath);
|
||||
const cached = scriptCache.get(script);
|
||||
const cacheKey = `${resolved.kind}:${resolved.scriptRef}:${filePath}`;
|
||||
const cached = scriptCache.get(cacheKey);
|
||||
if (cached && cached.mtimeMs === mtimeMs) {
|
||||
return cached.compiled;
|
||||
return cached;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const compiled = compileScriptSource(source, filePath);
|
||||
scriptCache.set(script, { compiled, mtimeMs });
|
||||
return compiled;
|
||||
const entry = {
|
||||
compiled,
|
||||
mtimeMs,
|
||||
pluginDir: resolved.pluginDir ?? null,
|
||||
scriptRef: resolved.scriptRef,
|
||||
};
|
||||
scriptCache.set(cacheKey, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,13 +626,14 @@ function loadCompiledScript(script) {
|
||||
* }} opts
|
||||
*/
|
||||
export async function runScript(script, ctx, { log, workflowName, owner, $workflows }) {
|
||||
const compiled = loadCompiledScript(script);
|
||||
const fn = instantiateCompiled(compiled, {
|
||||
const loaded = loadCompiledScript(script);
|
||||
const fn = instantiateCompiled(loaded.compiled, {
|
||||
log,
|
||||
script,
|
||||
script: loaded.scriptRef,
|
||||
workflowName,
|
||||
owner,
|
||||
$workflows,
|
||||
pluginDir: loaded.pluginDir,
|
||||
});
|
||||
return await fn(ctx);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
clearScriptCache,
|
||||
inspectScriptSource,
|
||||
instantiateScriptSource,
|
||||
} from "../../script-sandbox.js";
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
forkCoreScript,
|
||||
getInstalledPlugin,
|
||||
listCoreScriptNames,
|
||||
listInstalledPlugins,
|
||||
resolveScriptRef,
|
||||
uninstallPlugin,
|
||||
createBlankPlugin,
|
||||
} from "../../plugin-store.js";
|
||||
import {
|
||||
installExamplePlugin,
|
||||
installPluginFromDirectory,
|
||||
installPluginFromGit,
|
||||
installPluginFromZipBuffer,
|
||||
} from "../../plugin-install.js";
|
||||
import { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||
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";
|
||||
|
||||
/**
|
||||
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||
@@ -18,19 +37,58 @@ export default function scriptsPluginFactory(registry) {
|
||||
*/
|
||||
return async function scriptsPlugin(fastify) {
|
||||
fastify.get("/scripts", async () => {
|
||||
const scripts = fsStore.listScriptFiles().map((name) => {
|
||||
const core = listCoreScriptNames().map((name) => {
|
||||
const content = fsStore.readScript(name);
|
||||
const inspected =
|
||||
content == null
|
||||
? { meta: null, metaError: "script not found" }
|
||||
: inspectScriptSource(name, content);
|
||||
return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected };
|
||||
return {
|
||||
name,
|
||||
kind: "core",
|
||||
editable: false,
|
||||
hasIcon: fsStore.scriptHasIcon(name),
|
||||
...inspected,
|
||||
};
|
||||
});
|
||||
return { scripts };
|
||||
|
||||
const plugins = listInstalledPlugins().map((p) => {
|
||||
let inspected = { meta: null, metaError: null };
|
||||
if (!p.disabled && p.manifest) {
|
||||
try {
|
||||
const mainPath = path.join(p.dir, p.manifest.main);
|
||||
const content = fs.readFileSync(mainPath, "utf8");
|
||||
inspected = inspectScriptSource(p.scriptRef, content);
|
||||
} catch (err) {
|
||||
inspected = {
|
||||
meta: null,
|
||||
metaError: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
} else if (p.compatError) {
|
||||
inspected = { meta: null, metaError: p.compatError };
|
||||
}
|
||||
return {
|
||||
name: p.scriptRef,
|
||||
kind: "plugin",
|
||||
editable: true,
|
||||
pluginId: p.id,
|
||||
disabled: p.disabled,
|
||||
version: p.manifest?.version ?? null,
|
||||
hasIcon: false,
|
||||
...inspected,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
scripts: [...core, ...plugins],
|
||||
appVersion: getAppVersion(),
|
||||
};
|
||||
});
|
||||
|
||||
fastify.get("/scripts/:name/icon", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
// Icons only for core scripts today
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
@@ -46,64 +104,152 @@ export default function scriptsPluginFactory(registry) {
|
||||
});
|
||||
|
||||
fastify.get("/scripts/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
const rawName = decodeURIComponent(
|
||||
/** @type {{ name: string }} */ (req.params).name,
|
||||
);
|
||||
const resolved = resolveScriptRef(rawName);
|
||||
if (resolved.error || !resolved.filePath) {
|
||||
return reply
|
||||
.code(404)
|
||||
.send({ error: resolved.error || "script not found" });
|
||||
}
|
||||
const content = fsStore.readScript(name);
|
||||
if (content == null) return reply.code(404).send({ error: "script not found" });
|
||||
return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) };
|
||||
const content = fs.readFileSync(resolved.filePath, "utf8");
|
||||
const inspected = inspectScriptSource(resolved.scriptRef, content);
|
||||
return {
|
||||
name: resolved.scriptRef,
|
||||
kind: resolved.kind,
|
||||
editable: resolved.kind === "plugin",
|
||||
pluginId: resolved.pluginId ?? null,
|
||||
content,
|
||||
hasIcon:
|
||||
resolved.kind === "core"
|
||||
? fsStore.scriptHasIcon(resolved.scriptRef)
|
||||
: false,
|
||||
...inspected,
|
||||
};
|
||||
});
|
||||
|
||||
// Core scripts are read-only. Creating/editing bare *.js writes is disabled.
|
||||
// New user scripts must be plugins (fork / zip / git).
|
||||
fastify.put("/scripts/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
const rawName = decodeURIComponent(
|
||||
/** @type {{ name: string }} */ (req.params).name,
|
||||
);
|
||||
const pluginRef = resolveScriptRef(rawName);
|
||||
if (pluginRef.kind === "core" || !rawName.startsWith("plugin/")) {
|
||||
// Attempt to treat as core name
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
fsStore.assertScriptName(
|
||||
rawName.endsWith(".js") ? rawName : `${rawName}.js`,
|
||||
);
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
if (!rawName.startsWith("plugin/")) {
|
||||
return reply.code(403).send({
|
||||
error:
|
||||
"core scripts are read-only; fork to a plugin or install a plugin",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const body = /** @type {{ content?: string }} */ (req.body ?? {});
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
const existed = fsStore.readScript(name) != null;
|
||||
fsStore.writeScript(name, body.content);
|
||||
|
||||
const resolved = resolveScriptRef(rawName);
|
||||
if (resolved.kind !== "plugin" || !resolved.filePath || !resolved.pluginDir) {
|
||||
return reply.code(404).send({
|
||||
error: resolved.error || "plugin not found (install or fork first)",
|
||||
});
|
||||
}
|
||||
if (resolved.disabled) {
|
||||
return reply.code(409).send({ error: resolved.error || "plugin disabled" });
|
||||
}
|
||||
|
||||
fs.writeFileSync(resolved.filePath, body.content, "utf8");
|
||||
clearScriptCache();
|
||||
return reply.code(existed ? 200 : 201).send({
|
||||
name,
|
||||
...inspectScriptSource(name, body.content),
|
||||
return reply.send({
|
||||
name: resolved.scriptRef,
|
||||
kind: "plugin",
|
||||
editable: true,
|
||||
...inspectScriptSource(resolved.scriptRef, body.content),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.delete("/scripts/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
const rawName = decodeURIComponent(
|
||||
/** @type {{ name: string }} */ (req.params).name,
|
||||
);
|
||||
if (!rawName.startsWith("plugin/")) {
|
||||
return reply.code(403).send({
|
||||
error: "core scripts cannot be deleted",
|
||||
});
|
||||
}
|
||||
if (registry.referencedScripts().has(name)) {
|
||||
const resolved = resolveScriptRef(rawName);
|
||||
const id = resolved.pluginId;
|
||||
if (!id) {
|
||||
// may be installed but disabled — still allow uninstall via plugin id parse
|
||||
const installed = listInstalledPlugins().find(
|
||||
(p) => p.scriptRef === rawName || `plugin/${p.id}` === rawName,
|
||||
);
|
||||
if (!installed) {
|
||||
return reply.code(404).send({ error: "plugin not found" });
|
||||
}
|
||||
if (registry.referencedScripts().has(installed.scriptRef)) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "script is referenced by a workflow" });
|
||||
}
|
||||
if (!fsStore.deleteScript(name)) {
|
||||
return reply.code(404).send({ error: "script not found" });
|
||||
.send({ error: "plugin is referenced by a workflow" });
|
||||
}
|
||||
uninstallPlugin(installed.id);
|
||||
clearScriptCache();
|
||||
return { ok: true };
|
||||
return { ok: true, restartNeeded: true };
|
||||
}
|
||||
if (registry.referencedScripts().has(resolved.scriptRef)) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "plugin is referenced by a workflow" });
|
||||
}
|
||||
uninstallPlugin(id);
|
||||
clearScriptCache();
|
||||
return { ok: true, restartNeeded: true };
|
||||
});
|
||||
|
||||
fastify.post("/scripts/:name/fork", 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" });
|
||||
}
|
||||
try {
|
||||
const coreName = rawName.endsWith(".js") ? rawName : `${rawName}.js`;
|
||||
fsStore.assertScriptName(coreName);
|
||||
const installed = forkCoreScript(coreName, 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 { name } = /** @type {{ name: string }} */ (req.params);
|
||||
try {
|
||||
fsStore.assertScriptName(name);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
const rawName = decodeURIComponent(
|
||||
/** @type {{ name: string }} */ (req.params).name,
|
||||
);
|
||||
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
@@ -121,10 +267,18 @@ export default function scriptsPluginFactory(registry) {
|
||||
}
|
||||
|
||||
const incomingContext =
|
||||
body.context != null && typeof body.context === "object" && !Array.isArray(body.context)
|
||||
body.context != null &&
|
||||
typeof body.context === "object" &&
|
||||
!Array.isArray(body.context)
|
||||
? body.context
|
||||
: {};
|
||||
|
||||
const resolved = resolveScriptRef(rawName);
|
||||
const pluginDir =
|
||||
resolved.kind === "plugin" && !resolved.error
|
||||
? resolved.pluginDir ?? null
|
||||
: null;
|
||||
|
||||
const { log, logs } = createDryRunLogger();
|
||||
const started = Date.now();
|
||||
|
||||
@@ -139,13 +293,22 @@ export default function scriptsPluginFactory(registry) {
|
||||
context: incomingContext,
|
||||
config,
|
||||
};
|
||||
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
|
||||
const { fn, meta, metaError } = instantiateScriptSource(
|
||||
resolved.scriptRef || rawName,
|
||||
body.content,
|
||||
{
|
||||
log,
|
||||
workflowName: "dry-run",
|
||||
owner,
|
||||
});
|
||||
pluginDir,
|
||||
},
|
||||
);
|
||||
const raw = await fn(ctx);
|
||||
const result = normalizeStepResult(raw, incomingContext, name);
|
||||
const result = normalizeStepResult(
|
||||
raw,
|
||||
incomingContext,
|
||||
resolved.scriptRef || rawName,
|
||||
);
|
||||
return {
|
||||
status: "success",
|
||||
output: safeSerialize(result.output),
|
||||
@@ -158,7 +321,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
metaError,
|
||||
};
|
||||
} catch (err) {
|
||||
const inspected = inspectScriptSource(name, body.content);
|
||||
const inspected = inspectScriptSource(rawName, body.content);
|
||||
return {
|
||||
status: "failed",
|
||||
output: null,
|
||||
@@ -171,5 +334,148 @@ export default function scriptsPluginFactory(registry) {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// --- Plugins ---
|
||||
|
||||
fastify.get("/plugins", async () => {
|
||||
return {
|
||||
appVersion: getAppVersion(),
|
||||
plugins: listInstalledPlugins(),
|
||||
warning:
|
||||
"Installing plugins runs third-party code as the JerapahFlow OS user.",
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
"/plugins/create",
|
||||
{ onRequest: [fastify.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.id !== "string" || !body.id.trim()) {
|
||||
return reply.code(400).send({ error: "id is required" });
|
||||
}
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
try {
|
||||
const installed = createBlankPlugin(body.id.trim(), body.content, {
|
||||
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(
|
||||
"/plugins/install",
|
||||
{ onRequest: [fastify.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const body = /** @type {{
|
||||
source?: string,
|
||||
url?: string,
|
||||
ref?: string,
|
||||
path?: string,
|
||||
exampleId?: string,
|
||||
zipBase64?: string,
|
||||
overwrite?: boolean,
|
||||
}} */ (req.body ?? {});
|
||||
|
||||
try {
|
||||
let installed;
|
||||
if (body.source === "git") {
|
||||
if (!body.url) {
|
||||
return reply.code(400).send({ error: "url is required" });
|
||||
}
|
||||
installed = await installPluginFromGit(body.url, {
|
||||
ref: body.ref,
|
||||
overwrite: Boolean(body.overwrite),
|
||||
});
|
||||
} else if (body.source === "example") {
|
||||
const id = body.exampleId || "get-current-time";
|
||||
installed = await installExamplePlugin(id, {
|
||||
overwrite: Boolean(body.overwrite),
|
||||
});
|
||||
} else if (body.source === "dir") {
|
||||
if (!body.path) {
|
||||
return reply.code(400).send({ error: "path is required" });
|
||||
}
|
||||
// Only allow examples/ or existing staging under plugins for safety
|
||||
const abs = path.resolve(body.path);
|
||||
const allowed =
|
||||
abs.startsWith(EXAMPLE_PLUGINS_DIR + path.sep) ||
|
||||
abs.startsWith(EXAMPLE_PLUGINS_DIR);
|
||||
if (!allowed) {
|
||||
return reply.code(403).send({
|
||||
error: "dir install only allowed under examples/plugins",
|
||||
});
|
||||
}
|
||||
installed = installPluginFromDirectory(abs, {
|
||||
overwrite: Boolean(body.overwrite),
|
||||
});
|
||||
} else if (body.source === "zip") {
|
||||
if (!body.zipBase64) {
|
||||
return reply.code(400).send({ error: "zipBase64 is required" });
|
||||
}
|
||||
const buf = Buffer.from(body.zipBase64, "base64");
|
||||
installed = await installPluginFromZipBuffer(buf, {
|
||||
overwrite: Boolean(body.overwrite),
|
||||
});
|
||||
} else {
|
||||
return reply.code(400).send({
|
||||
error: "source must be git | zip | example | dir",
|
||||
});
|
||||
}
|
||||
clearScriptCache();
|
||||
return reply.code(201).send({
|
||||
...installed,
|
||||
scriptRef: pluginScriptRef(installed.id),
|
||||
restartNeeded: true,
|
||||
warning:
|
||||
"Plugins run as the JerapahFlow process user. Review code before install. Drain-restart workers to load new dependencies.",
|
||||
});
|
||||
} catch (err) {
|
||||
return reply
|
||||
.code(/** @type {any} */ (err).statusCode ?? 500)
|
||||
.send({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fastify.delete(
|
||||
"/plugins/:id",
|
||||
{ onRequest: [fastify.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
try {
|
||||
const scriptRef = pluginScriptRef(id);
|
||||
if (registry.referencedScripts().has(scriptRef)) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: "plugin is referenced by a workflow" });
|
||||
}
|
||||
uninstallPlugin(id);
|
||||
clearScriptCache();
|
||||
return { ok: true, restartNeeded: true };
|
||||
} catch (err) {
|
||||
return reply
|
||||
.code(/** @type {any} */ (err).statusCode ?? 500)
|
||||
.send({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
void getInstalledPlugin;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Smoke: core vs plugin scripts, fork, example install, resolve, run.
|
||||
*
|
||||
* Run:
|
||||
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
|
||||
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
|
||||
* node packages/server/test/plugins-smoke.js
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "fs";
|
||||
import { migrate, db } from "../db.js";
|
||||
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
||||
import {
|
||||
forkCoreScript,
|
||||
resolveScriptRef,
|
||||
uninstallPlugin,
|
||||
listInstalledPlugins,
|
||||
createBlankPlugin,
|
||||
} from "../plugin-store.js";
|
||||
import { installExamplePlugin } from "../plugin-install.js";
|
||||
import {
|
||||
runScript,
|
||||
clearScriptCache,
|
||||
inspectScriptSource,
|
||||
} from "../script-sandbox.js";
|
||||
import { PLUGINS_DIR } from "../paths.js";
|
||||
import pino from "pino";
|
||||
|
||||
const silent = pino({ level: "silent" });
|
||||
|
||||
async function main() {
|
||||
assert.equal(getAppVersion(), "0.1.0");
|
||||
assert.equal(satisfiesRange("0.1.0", ">=0.1.0 <1.0.0"), true);
|
||||
assert.equal(satisfiesRange("1.0.0", ">=0.1.0 <1.0.0"), false);
|
||||
assert.equal(satisfiesRange("0.2.0", ">=0.1.0 <1.0.0"), true);
|
||||
|
||||
await migrate();
|
||||
|
||||
if (fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.rmSync(PLUGINS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
|
||||
const core = resolveScriptRef("fetch-http.js");
|
||||
assert.equal(core.kind, "core");
|
||||
assert.ok(core.filePath && fs.existsSync(core.filePath));
|
||||
assert.ok(resolveScriptRef("nope.js").error?.includes("not found"));
|
||||
|
||||
const example = await installExamplePlugin("get-current-time", {
|
||||
overwrite: true,
|
||||
});
|
||||
assert.equal(example.id, "get-current-time");
|
||||
assert.equal(example.scriptRef, "plugin/get-current-time");
|
||||
|
||||
clearScriptCache();
|
||||
const pluginResolved = resolveScriptRef("plugin/get-current-time");
|
||||
assert.equal(pluginResolved.kind, "plugin");
|
||||
assert.ok(pluginResolved.filePath);
|
||||
|
||||
const result = await runScript(
|
||||
"plugin/get-current-time",
|
||||
{ data: null, context: {}, config: null },
|
||||
{ log: silent, workflowName: "smoke", owner: "default" },
|
||||
);
|
||||
assert.ok(result?.output?.datetime);
|
||||
|
||||
const forked = forkCoreScript("jsonata.js", "jsonata-smoke-fork");
|
||||
assert.equal(forked.scriptRef, "plugin/jsonata-smoke-fork");
|
||||
clearScriptCache();
|
||||
assert.equal(resolveScriptRef("plugin/jsonata-smoke-fork").kind, "plugin");
|
||||
|
||||
const blank = createBlankPlugin(
|
||||
"blank-smoke",
|
||||
`export default async function main(ctx) { return { output: { ok: true }, context: ctx.context ?? {} }; }`,
|
||||
);
|
||||
assert.equal(blank.scriptRef, "plugin/blank-smoke");
|
||||
clearScriptCache();
|
||||
const blankRun = await runScript(
|
||||
"plugin/blank-smoke",
|
||||
{ data: 1, context: {}, config: null },
|
||||
{ log: silent, workflowName: "smoke", owner: "default" },
|
||||
);
|
||||
assert.equal(blankRun.output.ok, true);
|
||||
|
||||
let hit = false;
|
||||
try {
|
||||
forkCoreScript("ntfy.js", "ntfy");
|
||||
} catch (err) {
|
||||
hit = true;
|
||||
assert.match(String(err.message), /collides/);
|
||||
}
|
||||
assert.equal(hit, true);
|
||||
|
||||
const meta = inspectScriptSource(
|
||||
"fetch-http.js",
|
||||
fs.readFileSync(core.filePath, "utf8"),
|
||||
);
|
||||
assert.ok(meta);
|
||||
|
||||
assert.ok(listInstalledPlugins().some((p) => p.id === "get-current-time"));
|
||||
|
||||
uninstallPlugin("jsonata-smoke-fork");
|
||||
uninstallPlugin("blank-smoke");
|
||||
uninstallPlugin("get-current-time");
|
||||
|
||||
console.log("plugins-smoke: ok");
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
try {
|
||||
await db.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2,7 +2,7 @@ name: cron example
|
||||
description: |
|
||||
this workflow triggered by cron
|
||||
scripts:
|
||||
- script: get-current-time.js
|
||||
- script: plugin/get-current-time
|
||||
- set:
|
||||
expression: '{"message": context.datetime}'
|
||||
- script: ntfy.js
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: jsonata
|
||||
scripts:
|
||||
- get-current-time.js
|
||||
- plugin/get-current-time
|
||||
- script: jsonata.js
|
||||
config:
|
||||
expression: '{"message": data.datetime & " " & data.processId}'
|
||||
|
||||
@@ -3,7 +3,7 @@ description: >
|
||||
Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy.
|
||||
scripts:
|
||||
- id: time
|
||||
script: get-current-time.js
|
||||
script: plugin/get-current-time
|
||||
|
||||
- id: comic
|
||||
script: fetch-html.js
|
||||
|
||||
@@ -2,7 +2,7 @@ name: time to ntfy example
|
||||
description: |
|
||||
this workflow will send a message to ntfy with the current time
|
||||
scripts:
|
||||
- script: get-current-time.js
|
||||
- script: plugin/get-current-time
|
||||
config:
|
||||
key: ""
|
||||
- script: ntfy.js
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@jerapah-flow/web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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,11 +191,15 @@ 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>
|
||||
{!isCore ? (
|
||||
<button
|
||||
type="submit"
|
||||
form="script-edit-form"
|
||||
@@ -159,10 +209,45 @@ export function ScriptEditPage() {
|
||||
<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,10 +156,29 @@ 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>
|
||||
{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"
|
||||
@@ -125,6 +186,8 @@ export function ScriptsPage() {
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
{!isCore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
@@ -133,6 +196,7 @@ export function ScriptsPage() {
|
||||
>
|
||||
<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