Files
jerapah-flow/packages/server/plugin-store.js
nsrb a20b26b4c7 feat(plugins): add duplicate plugin functionality and related API endpoints
- Implemented a new `duplicatePlugin` function to allow copying of installed plugins with a new ID.
- Added API endpoint for duplicating plugins, including error handling for various edge cases.
- Introduced frontend hooks and UI components for duplicating plugins in the script management interface.
- Enhanced tests to validate the duplication process and ensure proper error handling.
2026-08-30 19:33:36 +07:00

547 lines
14 KiB
JavaScript

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 });
}
}
/**
* 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}
*/
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 });
}
}