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:
Cursor Agent
2026-08-19 16:04:00 +00:00
co-authored by nsrb
parent f66d0931bd
commit 611511ae60
23 changed files with 1843 additions and 132 deletions
+79
View File
@@ -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;