Merge pull request #14 from nasyarobby/cursor/control-pm2-split-3038
feat(control): PM2 control plane with split HTTP and worker processes
This commit is contained in:
+4
-2
@@ -7,6 +7,8 @@ logs/
|
|||||||
packages/web/dist
|
packages/web/dist
|
||||||
|
|
||||||
# Personal/local scripts and workflows (not for the repo)
|
# Personal/local scripts and workflows (not for the repo)
|
||||||
packages/server/scripts/dev-*
|
|
||||||
packages/server/workflows/**/dev-*
|
|
||||||
debug-*.js
|
debug-*.js
|
||||||
|
|
||||||
|
# Plugin install staging and per-plugin deps
|
||||||
|
plugins/.staging-*
|
||||||
|
plugins/*/node_modules/
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# JerapahFlow plugins
|
||||||
|
|
||||||
|
This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only).
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
| Kind | YAML `script` | Editable | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Core | `fetch-http.js` | No | `packages/server/scripts/` |
|
||||||
|
| User plugin | `plugin/<id>` | Yes | `plugins/<id>/` |
|
||||||
|
| Example source | install → `plugin/<id>` | After install | `examples/plugins/<id>/` |
|
||||||
|
|
||||||
|
Runtime load path is repo-root `plugins/` (`PLUGINS_DIR` in `packages/server/paths.js`). Override with `JFLOW_PLUGINS_DIR` only in tests.
|
||||||
|
|
||||||
|
Plugins are **outside** the pnpm workspace (`packages/*`). Do not add `plugins/*` to `pnpm-workspace.yaml`.
|
||||||
|
|
||||||
|
## When to create a plugin
|
||||||
|
|
||||||
|
Create a user plugin when the script is:
|
||||||
|
|
||||||
|
- Site-specific (LAN IPs, personal modems, private APIs)
|
||||||
|
- A fork of a core script the user wants to edit
|
||||||
|
- Anything that should stay in git but not ship as core
|
||||||
|
|
||||||
|
Use native `fetch` (not `$axios`) when the URL is RFC1918 / WG (`10.x`, `192.168.x`, …). `$axios` is screened and **blocks** those hosts.
|
||||||
|
|
||||||
|
## Create a new plugin
|
||||||
|
|
||||||
|
1. Pick an id: lowercase letters, numbers, hyphens; max 64 chars; `^[a-z0-9]+(?:-[a-z0-9]+)*$`.
|
||||||
|
2. Folder name **must equal** manifest `id`. Example: `plugins/joplin-api`.
|
||||||
|
3. Id **must not** collide with a core script basename (`fetch-http`, `ntfy`, …).
|
||||||
|
4. Create three files (see below). Prefer `main: "script.js"`.
|
||||||
|
5. Point workflows at `plugin/<id>`.
|
||||||
|
6. Restart the runner (`pnpm dev` / drain-restart under `pnpm dev:pm2`) so the plugin is picked up.
|
||||||
|
|
||||||
|
Copy the layout from `plugins/joplin-api`, `plugins/send-sms`, or `examples/plugins/get-current-time`.
|
||||||
|
|
||||||
|
### `plugins/<id>/jerapah-plugin.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-plugin",
|
||||||
|
"name": "My plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"jerapah": ">=0.1.0 <1.0.0",
|
||||||
|
"main": "script.js",
|
||||||
|
"description": "One-line description"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `version` must be semver (`0.1.0`).
|
||||||
|
- `jerapah` must match the app (`0.1.0` in root `package.json`). Use `">=0.1.0 <1.0.0"` unless you know otherwise.
|
||||||
|
- `main` is a relative path; no `..`, not absolute.
|
||||||
|
|
||||||
|
### `plugins/<id>/package.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "jflow-plugin-my-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "JerapahFlow plugin: …"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `dependencies` only if the script `require()`s extra npm packages. Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline
|
||||||
|
```
|
||||||
|
|
||||||
|
`plugins/*/node_modules/` is gitignored. Host-allowlisted modules (`axios`, `jsonata`, …) come from the server; extra deps resolve from the plugin directory.
|
||||||
|
|
||||||
|
### `plugins/<id>/script.js`
|
||||||
|
|
||||||
|
Must `export default` a function. The sandbox rewrites ESM `import`/`export default` to CJS.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function passContext(ctx) {
|
||||||
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
|
return { ...ctx.context };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function myPlugin(ctx) {
|
||||||
|
const output = { ok: true };
|
||||||
|
return { output, context: { ...passContext(ctx), ...output } };
|
||||||
|
}
|
||||||
|
|
||||||
|
myPlugin.meta = {
|
||||||
|
description: "What this step does",
|
||||||
|
previewConfigKey: "url",
|
||||||
|
tags: ["HTTP"],
|
||||||
|
config: {},
|
||||||
|
input: {},
|
||||||
|
output: { ok: { type: "boolean" } },
|
||||||
|
context: { ok: { type: "boolean" } },
|
||||||
|
example: { data: {}, config: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default myPlugin;
|
||||||
|
```
|
||||||
|
|
||||||
|
Return `{ output, context?, skipRemaining? }`. Do not return `ctx`. Mutations of `ctx.data` / `ctx.context` are discarded unless returned.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `ctx.data` | Step input (trigger payload, previous `output`, or DAG `needs`) |
|
||||||
|
| `ctx.context` | Run clipboard (plain object) |
|
||||||
|
| `ctx.config` | YAML `config` (secrets already unwrapped from `$SECRET_name`) |
|
||||||
|
| `output` | Next step’s `data` |
|
||||||
|
| `context` | Next clipboard. Omit to keep incoming |
|
||||||
|
|
||||||
|
`fn.meta` must be JSON-serializable (UI + dry-run). Include `config` / `input` / `output` field schemas and an `example`.
|
||||||
|
|
||||||
|
## Sandbox globals (do not import these)
|
||||||
|
|
||||||
|
Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$fingerprint`, `$secrets`, `$vars`, `$responses`, `$workflows`.
|
||||||
|
|
||||||
|
- Use `log.info({ … }, "my-plugin: …")` — `log` is not an import.
|
||||||
|
- `require("axios")` is the screened `$axios` (RFC1918 blocked). Prefer `fetch` for LAN.
|
||||||
|
- Plugin `require("some-npm-dep")` uses the plugin’s `node_modules`.
|
||||||
|
|
||||||
|
## Workflow YAML
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scripts:
|
||||||
|
- script: plugin/my-plugin
|
||||||
|
config:
|
||||||
|
url: http://10.8.0.6:3030/notes
|
||||||
|
token: $SECRET_joplin_api_token
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||||
|
|
||||||
|
## Do not
|
||||||
|
|
||||||
|
- Add user plugins under `packages/server/scripts/` or `examples/plugins/`.
|
||||||
|
- Use an id that matches a core script file (`ntfy`, `jsonata`, …).
|
||||||
|
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
|
||||||
|
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`.
|
||||||
|
- Put secrets in `script.js`; use YAML `$SECRET_name` / `$VAR_name`.
|
||||||
@@ -13,14 +13,45 @@ Workflow runner with a sandboxed script engine, SQLite run history, and an admin
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
|
# Redis required for the workflow queue
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- UI (dev): http://localhost:8500
|
||||||
- API: http://localhost:8700
|
- API: http://localhost:8700
|
||||||
- UI (dev): http://localhost:5173
|
|
||||||
|
|
||||||
The first account created becomes **admin**. Later accounts are created from Users.
|
The first account created becomes **admin**. Later accounts are created from Users.
|
||||||
|
|
||||||
|
### Process modes
|
||||||
|
|
||||||
|
| Command | Processes | Ports |
|
||||||
|
|---|---|---|
|
||||||
|
| `pnpm dev` | Monolith (`runner.js` = API + worker) + Vite | UI **8500**, API **8700** |
|
||||||
|
| `pnpm dev:pm2` | Control + PM2 HTTP + PM2 workers + Vite | UI **8500**, control **8600**, API **8700** |
|
||||||
|
|
||||||
|
`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 | `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`.
|
||||||
|
- User plugins in this repo: `plugins/joplin-api` → `plugin/joplin-api`, `plugins/send-sms` → `plugin/send-sms`.
|
||||||
|
|
||||||
|
```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
|
## Script contract
|
||||||
|
|
||||||
Each script is `async function main(ctx)` and **must** return:
|
Each script is `async function main(ctx)` and **must** return:
|
||||||
@@ -50,13 +81,31 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pnpm dev` | Server + Vite together |
|
| `pnpm dev` | Monolith server + Vite (no PM2) |
|
||||||
| `pnpm dev:server` | API/runner only |
|
| `pnpm dev:pm2` | Control + PM2 HTTP/workers + Vite (Ops UI) |
|
||||||
| `pnpm dev:web` | UI only (proxies `/api` to :8700) |
|
| `pnpm dev:server` | Monolith API/runner only |
|
||||||
|
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
||||||
| `pnpm build` | Production UI build |
|
| `pnpm build` | Production UI build |
|
||||||
| `pnpm start` | Serve API and built UI from :8700 |
|
| `pnpm start` | Monolith: API + worker + built UI |
|
||||||
|
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
|
||||||
|
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
|
||||||
|
| `pnpm start:worker` | BullMQ worker only |
|
||||||
| `pnpm migrate` | Apply SQLite migrations |
|
| `pnpm migrate` | Apply SQLite migrations |
|
||||||
|
|
||||||
|
## Ops (control plane)
|
||||||
|
|
||||||
|
Admin UI route **Ops** (`/ops`) talks to the control process.
|
||||||
|
|
||||||
|
| Action | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Pause / resume | BullMQ `queue.pause()` / `resume()` — cron/HTTP still enqueue |
|
||||||
|
| Reload workflows | Redis pub/sub → all live HTTP/worker processes re-read YAML |
|
||||||
|
| Scale workers | PM2 scale; scale-down drains active jobs unless `force` |
|
||||||
|
| Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
|
||||||
|
| Force restart | Same without waiting (interrupts active runs; orphans marked `worker_lost`) |
|
||||||
|
|
||||||
|
Desired state is stored in `packages/server/data/control-state.json` (generation, worker count, restart-needed). Plugin installs (later) bump generation and set restart-needed; you apply with Drain restart.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
@@ -68,11 +117,13 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
|||||||
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
|
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
|
||||||
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
|
||||||
| `JFLOW_WORKER_CONCURRENCY` | `5` | Max parallel workflow jobs per worker process. |
|
| `JFLOW_WORKER_CONCURRENCY` | `5` | Max parallel workflow jobs per worker process. |
|
||||||
| `JFLOW_ROLE` | `all` | Which duties this process performs: `all` (HTTP/admin + cron producer + worker), `api` (HTTP/admin + cron enqueue only), or `worker` (consume queue only). Use separate processes in production when you want to scale workers independently. |
|
| `JFLOW_ROLE` | `all` | `all` (HTTP + cron + worker), `api`, or `worker`. Prefer `pnpm start:api` / `start:worker` under control. |
|
||||||
|
| `JFLOW_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
|
||||||
|
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
|
||||||
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
|
||||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
|
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
|
||||||
| `PORT` | `8700` | HTTP port |
|
| `PORT` | `8700` | HTTP API port |
|
||||||
| `NODE_ENV` | — | Set `production` for secure cookies |
|
| `NODE_ENV` | — | Set `production` for secure cookies |
|
||||||
|
|
||||||
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
|
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
|
||||||
@@ -83,7 +134,10 @@ Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 {
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
||||||
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start
|
# Recommended: run control (migrates + manages PM2 HTTP/workers)
|
||||||
|
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control
|
||||||
|
# Or monolith (dev-style):
|
||||||
|
# ... pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
The server serves `packages/web/dist` when that folder exists.
|
With control, serve the built UI from Vite preview, a reverse proxy, or set `JFLOW_SERVE_UI=1` on the HTTP process.
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
+5
-1
@@ -1,15 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "jerapah-flow",
|
"name": "jerapah-flow",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Script/workflow runner with admin UI",
|
"description": "Script/workflow runner with admin UI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter @jerapah-flow/server --filter @jerapah-flow/web --parallel dev",
|
"dev": "pnpm --filter @jerapah-flow/server --filter @jerapah-flow/web --parallel dev",
|
||||||
|
"dev:pm2": "pnpm --filter @jerapah-flow/server dev:pm2",
|
||||||
"dev:server": "pnpm --filter @jerapah-flow/server dev",
|
"dev:server": "pnpm --filter @jerapah-flow/server dev",
|
||||||
"dev:web": "pnpm --filter @jerapah-flow/web dev",
|
"dev:web": "pnpm --filter @jerapah-flow/web dev",
|
||||||
"build": "pnpm --filter @jerapah-flow/web build",
|
"build": "pnpm --filter @jerapah-flow/web build",
|
||||||
"start": "pnpm --filter @jerapah-flow/server start",
|
"start": "pnpm --filter @jerapah-flow/server start",
|
||||||
|
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
||||||
|
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||||
|
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
||||||
"migrate": "pnpm --filter @jerapah-flow/server migrate"
|
"migrate": "pnpm --filter @jerapah-flow/server migrate"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.25.0",
|
"packageManager": "pnpm@10.25.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import IORedis from "ioredis";
|
||||||
|
import { log } from "./logger.js";
|
||||||
|
import {
|
||||||
|
getRedisPassword,
|
||||||
|
getRedisUrl,
|
||||||
|
getSharedConnection,
|
||||||
|
} from "./workflow-queue.js";
|
||||||
|
|
||||||
|
export const CHANNEL_RELOAD = "jflow:reload";
|
||||||
|
export const HEARTBEAT_KEY = "jflow:heartbeats";
|
||||||
|
export const HEARTBEAT_TTL_SEC = 20;
|
||||||
|
export const HEARTBEAT_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getConfigGeneration() {
|
||||||
|
const raw = Number(process.env.JFLOW_CONFIG_GENERATION ?? 1);
|
||||||
|
if (!Number.isFinite(raw) || raw < 1) return 1;
|
||||||
|
return Math.floor(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated Redis connection for pub/sub (ioredis cannot mix pub/sub with other commands).
|
||||||
|
* @returns {IORedis}
|
||||||
|
*/
|
||||||
|
export function createPubSubConnection() {
|
||||||
|
/** @type {import("ioredis").RedisOptions} */
|
||||||
|
const options = { maxRetriesPerRequest: null, enableReadyCheck: true };
|
||||||
|
const password = getRedisPassword();
|
||||||
|
if (password) options.password = password;
|
||||||
|
const conn = new IORedis(getRedisUrl(), options);
|
||||||
|
conn.on("error", (err) => {
|
||||||
|
log.error({ err }, "redis pub/sub connection error");
|
||||||
|
});
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} role
|
||||||
|
* @param {{ pid?: number, hostname?: string }} [extra]
|
||||||
|
*/
|
||||||
|
export async function writeHeartbeat(role, extra = {}) {
|
||||||
|
const conn = getSharedConnection();
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
role,
|
||||||
|
pid: extra.pid ?? process.pid,
|
||||||
|
hostname: extra.hostname ?? process.env.HOSTNAME ?? "local",
|
||||||
|
generation: getConfigGeneration(),
|
||||||
|
ts: Date.now(),
|
||||||
|
});
|
||||||
|
const field = `${role}:${process.pid}`;
|
||||||
|
await conn.hset(HEARTBEAT_KEY, field, payload);
|
||||||
|
await conn.expire(HEARTBEAT_KEY, HEARTBEAT_TTL_SEC * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<Array<{
|
||||||
|
* field: string,
|
||||||
|
* role: string,
|
||||||
|
* pid: number,
|
||||||
|
* hostname: string,
|
||||||
|
* generation: number,
|
||||||
|
* ts: number,
|
||||||
|
* stale: boolean,
|
||||||
|
* }>>}
|
||||||
|
*/
|
||||||
|
export async function readHeartbeats() {
|
||||||
|
const conn = getSharedConnection();
|
||||||
|
const all = await conn.hgetall(HEARTBEAT_KEY);
|
||||||
|
const now = Date.now();
|
||||||
|
/** @type {Array<any>} */
|
||||||
|
const out = [];
|
||||||
|
for (const [field, raw] of Object.entries(all)) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const ts = Number(parsed.ts) || 0;
|
||||||
|
out.push({
|
||||||
|
field,
|
||||||
|
role: String(parsed.role ?? "unknown"),
|
||||||
|
pid: Number(parsed.pid) || 0,
|
||||||
|
hostname: String(parsed.hostname ?? ""),
|
||||||
|
generation: Number(parsed.generation) || 0,
|
||||||
|
ts,
|
||||||
|
stale: now - ts > HEARTBEAT_TTL_SEC * 1000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip bad rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ type?: string }} [payload]
|
||||||
|
*/
|
||||||
|
export async function publishReload(payload = { type: "workflows" }) {
|
||||||
|
const conn = getSharedConnection();
|
||||||
|
await conn.publish(CHANNEL_RELOAD, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {(msg: { type?: string }) => void | Promise<void>} handler
|
||||||
|
* @returns {Promise<{ stop: () => Promise<void> }>}
|
||||||
|
*/
|
||||||
|
export async function subscribeReload(handler) {
|
||||||
|
const sub = createPubSubConnection();
|
||||||
|
await sub.subscribe(CHANNEL_RELOAD);
|
||||||
|
sub.on("message", (_channel, message) => {
|
||||||
|
let parsed = { type: "workflows" };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(message);
|
||||||
|
} catch {
|
||||||
|
// use default
|
||||||
|
}
|
||||||
|
void Promise.resolve(handler(parsed)).catch((err) => {
|
||||||
|
log.error({ err }, "reload handler failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
async stop() {
|
||||||
|
await sub.unsubscribe(CHANNEL_RELOAD).catch(() => {});
|
||||||
|
await sub.quit().catch(() => sub.disconnect());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic heartbeats. Returns a stop function.
|
||||||
|
* @param {string} role
|
||||||
|
*/
|
||||||
|
export function startHeartbeatLoop(role) {
|
||||||
|
const tick = () => {
|
||||||
|
void writeHeartbeat(role).catch((err) => {
|
||||||
|
log.error({ err, role }, "heartbeat failed");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const timer = setInterval(tick, HEARTBEAT_INTERVAL_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { DATA_DIR } from "./paths.js";
|
||||||
|
|
||||||
|
const STATE_PATH = path.join(DATA_DIR, "control-state.json");
|
||||||
|
const LOCK_PATH = path.join(DATA_DIR, "ops.lock");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* http: "running" | "stopped",
|
||||||
|
* workers: number,
|
||||||
|
* queuePaused: boolean,
|
||||||
|
* generation: number,
|
||||||
|
* restartNeeded: boolean,
|
||||||
|
* restartReason: string | null,
|
||||||
|
* }} ControlState
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @returns {ControlState} */
|
||||||
|
export function defaultControlState() {
|
||||||
|
return {
|
||||||
|
http: "running",
|
||||||
|
workers: 1,
|
||||||
|
queuePaused: false,
|
||||||
|
generation: 1,
|
||||||
|
restartNeeded: false,
|
||||||
|
restartReason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {ControlState}
|
||||||
|
*/
|
||||||
|
export function readControlState() {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
if (!fs.existsSync(STATE_PATH)) {
|
||||||
|
const initial = defaultControlState();
|
||||||
|
writeControlState(initial);
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
|
||||||
|
const base = defaultControlState();
|
||||||
|
return {
|
||||||
|
http: raw.http === "stopped" ? "stopped" : "running",
|
||||||
|
workers: Math.max(0, Math.min(32, Number(raw.workers) || 1)),
|
||||||
|
queuePaused: Boolean(raw.queuePaused),
|
||||||
|
generation: Math.max(1, Math.floor(Number(raw.generation) || 1)),
|
||||||
|
restartNeeded: Boolean(raw.restartNeeded),
|
||||||
|
restartReason:
|
||||||
|
typeof raw.restartReason === "string" ? raw.restartReason : null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaultControlState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ControlState} state
|
||||||
|
*/
|
||||||
|
export function writeControlState(state) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const tmp = `${STATE_PATH}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||||
|
fs.renameSync(tmp, STATE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Partial<ControlState>} patch
|
||||||
|
* @returns {ControlState}
|
||||||
|
*/
|
||||||
|
export function patchControlState(patch) {
|
||||||
|
const next = { ...readControlState(), ...patch };
|
||||||
|
writeControlState(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump config generation and mark restart needed.
|
||||||
|
* @param {string} reason
|
||||||
|
* @returns {ControlState}
|
||||||
|
*/
|
||||||
|
export function bumpGeneration(reason) {
|
||||||
|
const cur = readControlState();
|
||||||
|
return patchControlState({
|
||||||
|
generation: cur.generation + 1,
|
||||||
|
restartNeeded: true,
|
||||||
|
restartReason: reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear restart-needed after processes match generation.
|
||||||
|
* @returns {ControlState}
|
||||||
|
*/
|
||||||
|
export function clearRestartNeeded() {
|
||||||
|
return patchControlState({
|
||||||
|
restartNeeded: false,
|
||||||
|
restartReason: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} owner
|
||||||
|
* @param {number} [ttlMs]
|
||||||
|
* @returns {{ ok: true, token: string } | { ok: false, error: string, holder?: string }}
|
||||||
|
*/
|
||||||
|
export function tryAcquireOpsLock(owner, ttlMs = 120_000) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const now = Date.now();
|
||||||
|
if (fs.existsSync(LOCK_PATH)) {
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(fs.readFileSync(LOCK_PATH, "utf8"));
|
||||||
|
if (existing.expiresAt > now) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "ops lock held",
|
||||||
|
holder: String(existing.owner ?? "unknown"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// stale/corrupt lock — overwrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const token = `${owner}:${now}:${Math.random().toString(36).slice(2)}`;
|
||||||
|
const payload = {
|
||||||
|
owner,
|
||||||
|
token,
|
||||||
|
expiresAt: now + ttlMs,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(LOCK_PATH, `${JSON.stringify(payload)}\n`, "utf8");
|
||||||
|
return { ok: true, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
*/
|
||||||
|
export function releaseOpsLock(token) {
|
||||||
|
if (!fs.existsSync(LOCK_PATH)) return;
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(fs.readFileSync(LOCK_PATH, "utf8"));
|
||||||
|
if (existing.token !== token) return;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
fs.unlinkSync(LOCK_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} token
|
||||||
|
* @param {number} [ttlMs]
|
||||||
|
*/
|
||||||
|
export function refreshOpsLock(token, ttlMs = 120_000) {
|
||||||
|
if (!fs.existsSync(LOCK_PATH)) return false;
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(fs.readFileSync(LOCK_PATH, "utf8"));
|
||||||
|
if (existing.token !== token) return false;
|
||||||
|
existing.expiresAt = Date.now() + ttlMs;
|
||||||
|
fs.writeFileSync(LOCK_PATH, `${JSON.stringify(existing)}\n`, "utf8");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import jwt from "@fastify/jwt";
|
||||||
|
import { migrate, db } from "./db.js";
|
||||||
|
import { log, enableLogPersistence, flushLogs } from "./logger.js";
|
||||||
|
import { COOKIE } from "./src/api/auth.js";
|
||||||
|
import {
|
||||||
|
clearRestartNeeded,
|
||||||
|
bumpGeneration,
|
||||||
|
patchControlState,
|
||||||
|
readControlState,
|
||||||
|
refreshOpsLock,
|
||||||
|
releaseOpsLock,
|
||||||
|
tryAcquireOpsLock,
|
||||||
|
} from "./control-state.js";
|
||||||
|
import {
|
||||||
|
getConfigGeneration,
|
||||||
|
publishReload,
|
||||||
|
readHeartbeats,
|
||||||
|
} from "./control-bus.js";
|
||||||
|
import {
|
||||||
|
closeRedis,
|
||||||
|
createWorkflowQueue,
|
||||||
|
getRedisUrlForLog,
|
||||||
|
} from "./workflow-queue.js";
|
||||||
|
import { reconcileOrphanRuns } from "./orphan-runs.js";
|
||||||
|
import {
|
||||||
|
connectPm2,
|
||||||
|
deletePm2App,
|
||||||
|
describeChildren,
|
||||||
|
disconnectPm2,
|
||||||
|
ensureHttp,
|
||||||
|
ensureWorkers,
|
||||||
|
PM2_HTTP_NAME,
|
||||||
|
PM2_WORKER_NAME,
|
||||||
|
recreateChildren,
|
||||||
|
stopPm2App,
|
||||||
|
} from "./pm2-bridge.js";
|
||||||
|
|
||||||
|
const DRAIN_DEFAULT_MS = 60_000;
|
||||||
|
const DRAIN_POLL_MS = 500;
|
||||||
|
|
||||||
|
const jwtSecret =
|
||||||
|
process.env.JFLOW_JWT_SECRET ??
|
||||||
|
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
|
||||||
|
|
||||||
|
if (!jwtSecret) {
|
||||||
|
log.error("JFLOW_JWT_SECRET is required in production");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await migrate();
|
||||||
|
enableLogPersistence();
|
||||||
|
|
||||||
|
log.info({ redis: getRedisUrlForLog() }, "starting jerapah-flow control");
|
||||||
|
|
||||||
|
const workflowQueue = createWorkflowQueue();
|
||||||
|
try {
|
||||||
|
await workflowQueue.waitUntilReady();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err, redis: getRedisUrlForLog() }, "failed to connect to Redis");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectPm2();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDesiredState() {
|
||||||
|
const state = readControlState();
|
||||||
|
await ensureHttp({
|
||||||
|
generation: state.generation,
|
||||||
|
running: state.http === "running",
|
||||||
|
});
|
||||||
|
await ensureWorkers({
|
||||||
|
generation: state.generation,
|
||||||
|
count: state.workers,
|
||||||
|
});
|
||||||
|
if (state.queuePaused) {
|
||||||
|
await workflowQueue.pause();
|
||||||
|
} else {
|
||||||
|
const paused = await workflowQueue.isPaused();
|
||||||
|
if (paused) await workflowQueue.resume();
|
||||||
|
}
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
http: state.http,
|
||||||
|
workers: state.workers,
|
||||||
|
generation: state.generation,
|
||||||
|
queuePaused: state.queuePaused,
|
||||||
|
},
|
||||||
|
"applied desired state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyDesiredState();
|
||||||
|
|
||||||
|
const server = fastify({ loggerInstance: log });
|
||||||
|
await server.register(cookie);
|
||||||
|
await server.register(jwt, {
|
||||||
|
secret: jwtSecret,
|
||||||
|
cookie: { cookieName: COOKIE, signed: false },
|
||||||
|
});
|
||||||
|
await server.register(cors, {
|
||||||
|
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.decorate("authenticate", async function authenticate(req, reply) {
|
||||||
|
try {
|
||||||
|
await req.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
|
||||||
|
if (req.user?.role !== "admin") {
|
||||||
|
return reply.code(403).send({ error: "forbidden" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} timeoutMs
|
||||||
|
* @param {string} lockToken
|
||||||
|
*/
|
||||||
|
async function waitUntilIdle(timeoutMs, lockToken) {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
refreshOpsLock(lockToken);
|
||||||
|
await reconcileOrphanRuns(workflowQueue);
|
||||||
|
const active = await workflowQueue.getActiveCount();
|
||||||
|
if (active === 0) return { ok: true, active: 0 };
|
||||||
|
await new Promise((r) => setTimeout(r, DRAIN_POLL_MS));
|
||||||
|
}
|
||||||
|
const active = await workflowQueue.getActiveCount();
|
||||||
|
return { ok: active === 0, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildStatus() {
|
||||||
|
const state = readControlState();
|
||||||
|
const children = await describeChildren();
|
||||||
|
const heartbeats = await readHeartbeats();
|
||||||
|
const live = heartbeats.filter((h) => !h.stale);
|
||||||
|
const queuePaused = await workflowQueue.isPaused();
|
||||||
|
const counts = await workflowQueue.getJobCounts(
|
||||||
|
"active",
|
||||||
|
"waiting",
|
||||||
|
"delayed",
|
||||||
|
"paused",
|
||||||
|
"failed",
|
||||||
|
"completed",
|
||||||
|
);
|
||||||
|
const orphans = await reconcileOrphanRuns(workflowQueue);
|
||||||
|
|
||||||
|
const expectedGen = state.generation;
|
||||||
|
const mismatched = live.filter((h) => h.generation !== expectedGen);
|
||||||
|
|
||||||
|
return {
|
||||||
|
control: {
|
||||||
|
pid: process.pid,
|
||||||
|
generation: getConfigGeneration(),
|
||||||
|
},
|
||||||
|
desired: state,
|
||||||
|
children,
|
||||||
|
heartbeats: live,
|
||||||
|
generationMismatch: mismatched.length > 0 || state.restartNeeded,
|
||||||
|
mismatched,
|
||||||
|
queue: {
|
||||||
|
paused: queuePaused,
|
||||||
|
counts,
|
||||||
|
},
|
||||||
|
orphans,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
server.get(
|
||||||
|
"/ops/status",
|
||||||
|
{ onRequest: [server.authenticate] },
|
||||||
|
async () => buildStatus(),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get("/ops/health", async () => ({ ok: true, role: "control" }));
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/pause",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
await workflowQueue.pause();
|
||||||
|
patchControlState({ queuePaused: true });
|
||||||
|
return reply.send({ ok: true, queuePaused: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/resume",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
await workflowQueue.resume();
|
||||||
|
patchControlState({ queuePaused: false });
|
||||||
|
return reply.send({ ok: true, queuePaused: false });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/reload",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
await publishReload({ type: "workflows" });
|
||||||
|
return reply.send({ ok: true, published: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/generation/bump",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = /** @type {{ reason?: string }} */ (req.body ?? {});
|
||||||
|
const reason = String(body.reason ?? "manual bump").slice(0, 200);
|
||||||
|
const state = bumpGeneration(reason);
|
||||||
|
return reply.send({ ok: true, desired: state });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/http/start",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const state = patchControlState({ http: "running" });
|
||||||
|
await ensureHttp({ generation: state.generation, running: true });
|
||||||
|
return reply.send({ ok: true, desired: state });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/http/stop",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const state = patchControlState({ http: "stopped" });
|
||||||
|
await stopPm2App(PM2_HTTP_NAME);
|
||||||
|
return reply.send({ ok: true, desired: state });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/restart",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = /** @type {{ force?: boolean, timeoutMs?: number }} */ (
|
||||||
|
req.body ?? {}
|
||||||
|
);
|
||||||
|
const force = Boolean(body.force);
|
||||||
|
const timeoutMs = Math.min(
|
||||||
|
Math.max(Number(body.timeoutMs) || DRAIN_DEFAULT_MS, 1_000),
|
||||||
|
10 * 60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lock = tryAcquireOpsLock(`restart:${process.pid}`);
|
||||||
|
if (!lock.ok) {
|
||||||
|
return reply.code(409).send({ error: lock.error, holder: lock.holder });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateBefore = readControlState();
|
||||||
|
const wantPaused = stateBefore.queuePaused;
|
||||||
|
try {
|
||||||
|
await workflowQueue.pause();
|
||||||
|
|
||||||
|
if (!force) {
|
||||||
|
const drained = await waitUntilIdle(timeoutMs, lock.token);
|
||||||
|
if (!drained.ok) {
|
||||||
|
if (!wantPaused) {
|
||||||
|
await workflowQueue.resume();
|
||||||
|
}
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "active jobs still running",
|
||||||
|
active: drained.active,
|
||||||
|
hint: "wait or retry with force=true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const desired = readControlState();
|
||||||
|
await stopPm2App(PM2_HTTP_NAME);
|
||||||
|
await deletePm2App(PM2_HTTP_NAME);
|
||||||
|
await stopPm2App(PM2_WORKER_NAME);
|
||||||
|
await deletePm2App(PM2_WORKER_NAME);
|
||||||
|
|
||||||
|
await reconcileOrphanRuns(workflowQueue);
|
||||||
|
// Drop stale heartbeats from killed PIDs
|
||||||
|
try {
|
||||||
|
const { HEARTBEAT_KEY } = await import("./control-bus.js");
|
||||||
|
const { getSharedConnection } = await import("./workflow-queue.js");
|
||||||
|
await getSharedConnection().del(HEARTBEAT_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
await migrate();
|
||||||
|
|
||||||
|
await recreateChildren({
|
||||||
|
generation: desired.generation,
|
||||||
|
http: desired.http === "running",
|
||||||
|
workers: desired.workers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wantPaused) {
|
||||||
|
await workflowQueue.pause();
|
||||||
|
patchControlState({ queuePaused: true });
|
||||||
|
} else {
|
||||||
|
await workflowQueue.resume();
|
||||||
|
patchControlState({ queuePaused: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
clearRestartNeeded();
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
forced: force,
|
||||||
|
desired: readControlState(),
|
||||||
|
status: await buildStatus(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "restart failed");
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
releaseOpsLock(lock.token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/ops/scale",
|
||||||
|
{ onRequest: [server.authenticate, server.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = /** @type {{ workers?: number, force?: boolean, timeoutMs?: number }} */ (
|
||||||
|
req.body ?? {}
|
||||||
|
);
|
||||||
|
const workers = Math.floor(Number(body.workers));
|
||||||
|
if (!Number.isFinite(workers) || workers < 0 || workers > 32) {
|
||||||
|
return reply.code(400).send({ error: "workers must be 0..32" });
|
||||||
|
}
|
||||||
|
const force = Boolean(body.force);
|
||||||
|
const timeoutMs = Math.min(
|
||||||
|
Math.max(Number(body.timeoutMs) || DRAIN_DEFAULT_MS, 1_000),
|
||||||
|
10 * 60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const current = readControlState();
|
||||||
|
const scalingDown = workers < current.workers;
|
||||||
|
|
||||||
|
const lock = tryAcquireOpsLock(`scale:${process.pid}`);
|
||||||
|
if (!lock.ok) {
|
||||||
|
return reply.code(409).send({ error: lock.error, holder: lock.holder });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasPaused = await workflowQueue.isPaused();
|
||||||
|
try {
|
||||||
|
if (scalingDown) {
|
||||||
|
await workflowQueue.pause();
|
||||||
|
if (!force) {
|
||||||
|
const drained = await waitUntilIdle(timeoutMs, lock.token);
|
||||||
|
if (!drained.ok) {
|
||||||
|
if (!wasPaused) {
|
||||||
|
await workflowQueue.resume();
|
||||||
|
patchControlState({ queuePaused: false });
|
||||||
|
}
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "active jobs still running",
|
||||||
|
active: drained.active,
|
||||||
|
hint: "wait or retry with force=true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = patchControlState({ workers });
|
||||||
|
await ensureWorkers({ generation: state.generation, count: workers });
|
||||||
|
|
||||||
|
if (scalingDown && !wasPaused && !state.queuePaused) {
|
||||||
|
await workflowQueue.resume();
|
||||||
|
patchControlState({ queuePaused: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ ok: true, desired: readControlState() });
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "scale failed");
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
releaseOpsLock(lock.token);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
try {
|
||||||
|
await workflowQueue.close();
|
||||||
|
await closeRedis();
|
||||||
|
await flushLogs();
|
||||||
|
await db.destroy();
|
||||||
|
disconnectPm2();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "control shutdown error");
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
|
||||||
|
server
|
||||||
|
.listen({ host: "0.0.0.0", port })
|
||||||
|
.then(() => {
|
||||||
|
log.info(`Control is running on port ${port}`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log.error({ err }, "failed to start control");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Start Vite (:8500) + control (:8600). Control connects to PM2 and starts HTTP/workers.
|
||||||
|
*
|
||||||
|
* Usage: pnpm dev:pm2
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const children = [];
|
||||||
|
|
||||||
|
function run(command, args, opts = {}) {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||||
|
JFLOW_CONTROL_PORT: process.env.JFLOW_CONTROL_PORT ?? "8600",
|
||||||
|
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
|
||||||
|
...opts.env,
|
||||||
|
},
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
children.push(child);
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
console.error(`[dev:pm2] ${command} ${args.join(" ")} exited (${code ?? signal})`);
|
||||||
|
shutdown(code ?? 1);
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function redisReachable() {
|
||||||
|
const url = process.env.REDIS_URL || "redis://127.0.0.1:6379";
|
||||||
|
let host = "127.0.0.1";
|
||||||
|
let port = 6379;
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
host = u.hostname || host;
|
||||||
|
port = Number(u.port || 6379);
|
||||||
|
} catch {
|
||||||
|
// keep defaults
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = net.connect({ host, port });
|
||||||
|
socket.setTimeout(1500);
|
||||||
|
socket.on("connect", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
socket.on("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
socket.on("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown(code = 0) {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
for (const child of children) {
|
||||||
|
if (!child.killed) child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
// Best-effort: stop jflow apps so the next run is clean
|
||||||
|
try {
|
||||||
|
const stop = spawn(
|
||||||
|
process.platform === "win32" ? "npx.cmd" : "npx",
|
||||||
|
["pm2", "delete", "jflow-http", "jflow-worker"],
|
||||||
|
{ cwd: root, stdio: "ignore", shell: false },
|
||||||
|
);
|
||||||
|
await new Promise((r) => stop.on("exit", r));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => void shutdown(0));
|
||||||
|
process.on("SIGTERM", () => void shutdown(0));
|
||||||
|
|
||||||
|
if (!(await redisReachable())) {
|
||||||
|
console.error(
|
||||||
|
"[dev:pm2] Redis is not reachable. Start Redis (default redis://127.0.0.1:6379) and retry.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[dev:pm2] starting control on :8600 (PM2 will start HTTP :8700 + workers)");
|
||||||
|
run(process.execPath, [path.join(root, "server/control.js")], {
|
||||||
|
env: {
|
||||||
|
JFLOW_CONTROL_PORT: "8600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give control a moment to migrate + spawn before Vite opens
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
|
||||||
|
console.log("[dev:pm2] starting Vite on :8500");
|
||||||
|
run(
|
||||||
|
process.platform === "win32" ? "pnpm.cmd" : "pnpm",
|
||||||
|
["--filter", "@jerapah-flow/web", "dev"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
// vite.config reads nothing; port is set in vite.config.js
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Dev ecosystem for `pnpm dev:pm2`.
|
||||||
|
* Prefer starting children via control.js (desired state) rather than this file.
|
||||||
|
* Kept as a reference / fallback: `pm2 start packages/server/ecosystem.dev.cjs`
|
||||||
|
*/
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "../..");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "jflow-http",
|
||||||
|
script: path.join(__dirname, "server.js"),
|
||||||
|
cwd: root,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
|
env: {
|
||||||
|
JFLOW_ROLE: "api",
|
||||||
|
PORT: "8700",
|
||||||
|
JFLOW_CORS_ORIGIN: "http://localhost:8500",
|
||||||
|
JFLOW_CONFIG_GENERATION: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "jflow-worker",
|
||||||
|
script: path.join(__dirname, "worker.js"),
|
||||||
|
cwd: root,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
|
env: {
|
||||||
|
JFLOW_ROLE: "worker",
|
||||||
|
JFLOW_CORS_ORIGIN: "http://localhost:8500",
|
||||||
|
JFLOW_CONFIG_GENERATION: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import * as store from "./store.js";
|
||||||
|
import { log } from "./logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark SQLite runs stuck in `running` when BullMQ no longer has them active.
|
||||||
|
*
|
||||||
|
* @param {import("bullmq").Queue} queue
|
||||||
|
* @returns {Promise<{ repaired: number, ids: string[] }>}
|
||||||
|
*/
|
||||||
|
export async function reconcileOrphanRuns(queue) {
|
||||||
|
const runs = await store.listRuns({ status: "running", limit: 200 });
|
||||||
|
if (!runs.length) return { repaired: 0, ids: [] };
|
||||||
|
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
const activeIds = new Set();
|
||||||
|
try {
|
||||||
|
const active = await queue.getJobs(["active"]);
|
||||||
|
for (const job of active) {
|
||||||
|
if (job?.id != null) activeIds.add(String(job.id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "failed to list active jobs for orphan reconcile");
|
||||||
|
return { repaired: 0, ids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
const ids = [];
|
||||||
|
for (const run of runs) {
|
||||||
|
const jobId = run.job_id ? String(run.job_id) : run.id;
|
||||||
|
if (activeIds.has(jobId)) continue;
|
||||||
|
|
||||||
|
let stillAlive = false;
|
||||||
|
try {
|
||||||
|
const job = await queue.getJob(jobId);
|
||||||
|
if (job) {
|
||||||
|
const state = await job.getState();
|
||||||
|
// waiting/delayed means not started as running worker — still orphan for SQLite running
|
||||||
|
stillAlive = state === "active";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
stillAlive = false;
|
||||||
|
}
|
||||||
|
if (stillAlive) continue;
|
||||||
|
|
||||||
|
await store.finishRun(
|
||||||
|
run.id,
|
||||||
|
"failed",
|
||||||
|
null,
|
||||||
|
new Error("worker_lost: run interrupted by process stop or crash"),
|
||||||
|
);
|
||||||
|
ids.push(run.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length) {
|
||||||
|
log.warn({ count: ids.length, ids }, "reconciled orphan running runs");
|
||||||
|
}
|
||||||
|
return { repaired: ids.length, ids };
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@jerapah-flow/server",
|
"name": "@jerapah-flow/server",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "runner.js",
|
"main": "runner.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch runner.js",
|
"dev": "node --watch runner.js",
|
||||||
|
"dev:pm2": "node dev-pm2.mjs",
|
||||||
"start": "node runner.js",
|
"start": "node runner.js",
|
||||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\""
|
"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)))\"",
|
||||||
|
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1111.0",
|
"@aws-sdk/client-s3": "^3.1111.0",
|
||||||
@@ -31,6 +36,7 @@
|
|||||||
"nodemailer": "^9.0.5",
|
"nodemailer": "^9.0.5",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-roll": "^4.0.0",
|
"pino-roll": "^4.0.0",
|
||||||
|
"pm2": "^6.0.13",
|
||||||
"ssh2-sftp-client": "^12.1.1",
|
"ssh2-sftp-client": "^12.1.1",
|
||||||
"webdav": "^5.10.0",
|
"webdav": "^5.10.0",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
|
|||||||
@@ -5,5 +5,14 @@ export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
|
||||||
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
|
||||||
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
export const DATA_DIR = path.join(SERVER_ROOT, "data");
|
||||||
|
/** User plugins (repo-root /plugins, outside the pnpm workspace). */
|
||||||
|
export const PLUGINS_DIR =
|
||||||
|
process.env.JFLOW_PLUGINS_DIR ??
|
||||||
|
path.resolve(SERVER_ROOT, "../../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 LOGS_DIR = path.join(SERVER_ROOT, "logs");
|
||||||
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import path from "path";
|
||||||
|
import pm2 from "pm2";
|
||||||
|
import { SERVER_ROOT } from "./paths.js";
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
|
||||||
|
|
||||||
|
export const PM2_HTTP_NAME = "jflow-http";
|
||||||
|
export const PM2_WORKER_NAME = "jflow-worker";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function connectPm2() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.connect((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectPm2() {
|
||||||
|
try {
|
||||||
|
pm2.disconnect();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<import("pm2").ProcessDescription[]>}
|
||||||
|
*/
|
||||||
|
export function listPm2() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.list((err, list) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(list ?? []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<import("pm2").ProcessDescription[]>}
|
||||||
|
*/
|
||||||
|
export async function listPm2ByName(name) {
|
||||||
|
const list = await listPm2();
|
||||||
|
return list.filter((p) => p.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} app
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function startPm2App(app) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.start(app, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function stopPm2App(name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.stop(name, (err) => {
|
||||||
|
if (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/not found|doesn't exist|process or namespace/i.test(msg)) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function deletePm2App(name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.delete(name, (err) => {
|
||||||
|
if (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/not found|doesn't exist|process or namespace/i.test(msg)) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {number} instances
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function scalePm2App(name, instances) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.scale(name, instances, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function restartPm2App(name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pm2.restart(name, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared env for child processes.
|
||||||
|
* @param {{ generation: number }} opts
|
||||||
|
*/
|
||||||
|
export function childEnv(opts) {
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
JFLOW_CONFIG_GENERATION: String(opts.generation),
|
||||||
|
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||||
|
PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure HTTP app exists and matches desired running/stopped state.
|
||||||
|
* @param {{ generation: number, running: boolean }} opts
|
||||||
|
*/
|
||||||
|
export async function ensureHttp(opts) {
|
||||||
|
const existing = await listPm2ByName(PM2_HTTP_NAME);
|
||||||
|
if (!opts.running) {
|
||||||
|
if (existing.length) await stopPm2App(PM2_HTTP_NAME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await startPm2App({
|
||||||
|
name: PM2_HTTP_NAME,
|
||||||
|
script: path.join(SERVER_ROOT, "server.js"),
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: "fork",
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 20,
|
||||||
|
env: {
|
||||||
|
...childEnv(opts),
|
||||||
|
JFLOW_ROLE: "api",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const online = existing.some((p) => p.pm2_env?.status === "online");
|
||||||
|
if (!online) {
|
||||||
|
await restartPm2App(PM2_HTTP_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure worker app has `count` online forks (0 = stopped/deleted).
|
||||||
|
* @param {{ generation: number, count: number }} opts
|
||||||
|
*/
|
||||||
|
export async function ensureWorkers(opts) {
|
||||||
|
const count = Math.max(0, Math.min(32, Math.floor(opts.count)));
|
||||||
|
const existing = await listPm2ByName(PM2_WORKER_NAME);
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
if (existing.length) {
|
||||||
|
await stopPm2App(PM2_WORKER_NAME);
|
||||||
|
await deletePm2App(PM2_WORKER_NAME);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await startPm2App({
|
||||||
|
name: PM2_WORKER_NAME,
|
||||||
|
script: path.join(SERVER_ROOT, "worker.js"),
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
instances: count,
|
||||||
|
exec_mode: "fork",
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 50,
|
||||||
|
env: {
|
||||||
|
...childEnv(opts),
|
||||||
|
JFLOW_ROLE: "worker",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = existing.length;
|
||||||
|
if (current !== count) {
|
||||||
|
await scalePm2App(PM2_WORKER_NAME, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopped = existing.filter((p) => p.pm2_env?.status !== "online");
|
||||||
|
if (stopped.length) {
|
||||||
|
await restartPm2App(PM2_WORKER_NAME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard recycle HTTP + workers with updated generation env.
|
||||||
|
* Children must be stopped first for a clean migrate window.
|
||||||
|
*
|
||||||
|
* @param {{ generation: number, http: boolean, workers: number }} opts
|
||||||
|
*/
|
||||||
|
export async function recreateChildren(opts) {
|
||||||
|
await stopPm2App(PM2_HTTP_NAME);
|
||||||
|
await deletePm2App(PM2_HTTP_NAME);
|
||||||
|
await stopPm2App(PM2_WORKER_NAME);
|
||||||
|
await deletePm2App(PM2_WORKER_NAME);
|
||||||
|
|
||||||
|
if (opts.http) {
|
||||||
|
await ensureHttp({ generation: opts.generation, running: true });
|
||||||
|
}
|
||||||
|
if (opts.workers > 0) {
|
||||||
|
await ensureWorkers({ generation: opts.generation, count: opts.workers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize PM2 process status for the ops UI.
|
||||||
|
*/
|
||||||
|
export async function describeChildren() {
|
||||||
|
const list = await listPm2();
|
||||||
|
const http = list.filter((p) => p.name === PM2_HTTP_NAME);
|
||||||
|
const workers = list.filter((p) => p.name === PM2_WORKER_NAME);
|
||||||
|
|
||||||
|
const mapOne = (p) => ({
|
||||||
|
name: p.name,
|
||||||
|
pmId: p.pm_id,
|
||||||
|
status: p.pm2_env?.status ?? "unknown",
|
||||||
|
pid: p.pid ?? null,
|
||||||
|
restarts: p.pm2_env?.restart_time ?? 0,
|
||||||
|
uptime: p.pm2_env?.pm_uptime ?? null,
|
||||||
|
generation: Number(p.pm2_env?.JFLOW_CONFIG_GENERATION ?? 0) || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
http: http.map(mapOne),
|
||||||
|
workers: workers.map(mapOne),
|
||||||
|
httpOnline: http.some((p) => p.pm2_env?.status === "online"),
|
||||||
|
workerOnlineCount: workers.filter((p) => p.pm2_env?.status === "online").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRepoRoot() {
|
||||||
|
return REPO_ROOT;
|
||||||
|
}
|
||||||
+6
-243
@@ -1,245 +1,8 @@
|
|||||||
import fs from "fs";
|
import { startApp } from "./start-app.js";
|
||||||
import fastify from "fastify";
|
|
||||||
import cookie from "@fastify/cookie";
|
|
||||||
import cors from "@fastify/cors";
|
|
||||||
import jwt from "@fastify/jwt";
|
|
||||||
import fastifyStatic from "@fastify/static";
|
|
||||||
import { migrate, db } from "./db.js";
|
|
||||||
import { log, enableLogPersistence, flushLogs } from "./logger.js";
|
|
||||||
import * as store from "./store.js";
|
|
||||||
import { createRegistry } from "./registry.js";
|
|
||||||
import { COOKIE, OPEN_API_ROUTES } from "./src/api/auth.js";
|
|
||||||
import authPlugin from "./src/api/auth.js";
|
|
||||||
import usersPlugin from "./src/api/users.js";
|
|
||||||
import scriptsPluginFactory from "./src/api/scripts.js";
|
|
||||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
|
||||||
import runsPlugin from "./src/api/runs.js";
|
|
||||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
|
||||||
import secretsPlugin from "./src/api/secrets.js";
|
|
||||||
import kvPlugin from "./src/api/kv.js";
|
|
||||||
import variablesPlugin from "./src/api/variables.js";
|
|
||||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
|
||||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
|
||||||
import { WEB_DIST } from "./paths.js";
|
|
||||||
import { resolveSecretsKeyMaterial } from "./secrets.js";
|
|
||||||
import {
|
|
||||||
closeRedis,
|
|
||||||
createWorkflowQueue,
|
|
||||||
createWorkflowWorker,
|
|
||||||
getRedisUrlForLog,
|
|
||||||
} from "./workflow-queue.js";
|
|
||||||
|
|
||||||
await migrate();
|
// Monolith / local `pnpm dev`: API + worker + migrate in one process.
|
||||||
enableLogPersistence();
|
await startApp({
|
||||||
|
role: process.env.JFLOW_ROLE || "all",
|
||||||
const jwtSecret =
|
migrate: true,
|
||||||
process.env.JFLOW_JWT_SECRET ??
|
serveStaticUi: true,
|
||||||
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
|
|
||||||
|
|
||||||
if (!jwtSecret) {
|
|
||||||
log.error("JFLOW_JWT_SECRET is required in production");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolveSecretsKeyMaterial();
|
|
||||||
} catch (err) {
|
|
||||||
log.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = (process.env.JFLOW_ROLE || "all").toLowerCase();
|
|
||||||
const runApi = role === "all" || role === "api";
|
|
||||||
const runWorker = role === "all" || role === "worker";
|
|
||||||
|
|
||||||
log.info({ redis: getRedisUrlForLog(), role }, "starting jerapah-flow");
|
|
||||||
|
|
||||||
const workflowQueue = createWorkflowQueue();
|
|
||||||
try {
|
|
||||||
await workflowQueue.waitUntilReady();
|
|
||||||
} catch (err) {
|
|
||||||
log.error({ err, redis: getRedisUrlForLog() }, "failed to connect to Redis");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = fastify({ loggerInstance: log });
|
|
||||||
|
|
||||||
await server.register(cookie);
|
|
||||||
await server.register(jwt, {
|
|
||||||
secret: jwtSecret,
|
|
||||||
cookie: {
|
|
||||||
cookieName: COOKIE,
|
|
||||||
signed: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await server.register(cors, {
|
|
||||||
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:5173",
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
server.decorate("authenticate", async function authenticate(req, reply) {
|
|
||||||
try {
|
|
||||||
await req.jwtVerify();
|
|
||||||
} catch {
|
|
||||||
return reply.code(401).send({ error: "unauthorized" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
|
|
||||||
if (req.user?.role !== "admin") {
|
|
||||||
return reply.code(403).send({ error: "forbidden" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const registry = createRegistry(server, { queue: workflowQueue });
|
|
||||||
registry.registerWorkflows();
|
|
||||||
if (runApi) {
|
|
||||||
registry.registerHttpTriggers();
|
|
||||||
registry.registerCronTriggers();
|
|
||||||
registry.registerPruneJob();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @type {import("bullmq").Worker | null} */
|
|
||||||
let workflowWorker = null;
|
|
||||||
if (runWorker) {
|
|
||||||
workflowWorker = createWorkflowWorker(async (job) => {
|
|
||||||
const data = /** @type {{ runId?: string, key?: string, depth?: number }} */ (
|
|
||||||
job.data ?? {}
|
|
||||||
);
|
|
||||||
if (typeof data.runId !== "string" || typeof data.key !== "string") {
|
|
||||||
throw new Error("invalid workflow job payload");
|
|
||||||
}
|
|
||||||
const result = await registry.executeQueuedRun({
|
|
||||||
runId: data.runId,
|
|
||||||
key: data.key,
|
|
||||||
depth: data.depth ?? 0,
|
|
||||||
});
|
|
||||||
if (result.status === "failed") {
|
|
||||||
throw new Error(result.error || "workflow failed");
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runApi) {
|
|
||||||
await server.register(
|
|
||||||
async (api) => {
|
|
||||||
api.addHook("onRequest", async (req, reply) => {
|
|
||||||
const raw = (req.url || "").split("?")[0];
|
|
||||||
const stripped = raw.replace(/^\/api/, "") || "/";
|
|
||||||
const routeUrl = req.routeOptions?.url || stripped;
|
|
||||||
const open =
|
|
||||||
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
|
|
||||||
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
|
|
||||||
if (open) return;
|
|
||||||
await server.authenticate(req, reply);
|
|
||||||
});
|
|
||||||
await api.register(authPlugin);
|
|
||||||
await api.register(usersPlugin);
|
|
||||||
await api.register(secretsPlugin);
|
|
||||||
await api.register(variablesPlugin);
|
|
||||||
await api.register(kvPlugin);
|
|
||||||
await api.register(httpPagesPlugin);
|
|
||||||
await api.register(httpAuthsPlugin);
|
|
||||||
await api.register(scriptsPluginFactory(registry));
|
|
||||||
await api.register(workflowsPluginFactory(registry));
|
|
||||||
await api.register(runsPlugin);
|
|
||||||
await api.register(dashboardPluginFactory(registry));
|
|
||||||
},
|
|
||||||
{ prefix: "/api" },
|
|
||||||
);
|
|
||||||
|
|
||||||
server.post(
|
|
||||||
"/admin/workflows/reregister",
|
|
||||||
{ onRequest: [server.authenticate] },
|
|
||||||
async (_req, reply) => {
|
|
||||||
registry.reregister();
|
|
||||||
return reply.send({ message: "Workflows refreshed" });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.get(
|
|
||||||
"/admin/runs",
|
|
||||||
{ onRequest: [server.authenticate] },
|
|
||||||
async (req, reply) => {
|
|
||||||
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
|
||||||
const limit = q.limit ? Number(q.limit) : undefined;
|
|
||||||
const runs = await store.listRuns({
|
|
||||||
owner: q.owner,
|
|
||||||
workflow: q.workflow,
|
|
||||||
status: q.status,
|
|
||||||
limit: Number.isFinite(limit) ? limit : undefined,
|
|
||||||
before: q.before,
|
|
||||||
});
|
|
||||||
return reply.send({ runs });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.get(
|
|
||||||
"/admin/runs/:id",
|
|
||||||
{ onRequest: [server.authenticate] },
|
|
||||||
async (req, reply) => {
|
|
||||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
|
||||||
const run = await store.getRun(id);
|
|
||||||
if (!run) {
|
|
||||||
return reply.code(404).send({ error: "run not found" });
|
|
||||||
}
|
|
||||||
return reply.send(run);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fs.existsSync(WEB_DIST)) {
|
|
||||||
await server.register(fastifyStatic, {
|
|
||||||
root: WEB_DIST,
|
|
||||||
wildcard: false,
|
|
||||||
});
|
|
||||||
server.setNotFoundHandler((req, reply) => {
|
|
||||||
const url = req.raw.url ?? "";
|
|
||||||
if (
|
|
||||||
url.startsWith("/api") ||
|
|
||||||
url.startsWith("/u/") ||
|
|
||||||
url.startsWith("/admin")
|
|
||||||
) {
|
|
||||||
return reply.code(404).send({ error: "not found" });
|
|
||||||
}
|
|
||||||
return reply.sendFile("index.html");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shutdown() {
|
|
||||||
try {
|
|
||||||
if (workflowWorker) {
|
|
||||||
await workflowWorker.close();
|
|
||||||
}
|
|
||||||
await workflowQueue.close();
|
|
||||||
await closeRedis();
|
|
||||||
await flushLogs();
|
|
||||||
await db.destroy();
|
|
||||||
} catch (err) {
|
|
||||||
log.error({ err }, "shutdown error");
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 8700);
|
|
||||||
|
|
||||||
if (runApi) {
|
|
||||||
server
|
|
||||||
.listen({
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
log.info(`Server is running on port ${port}`);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
log.error({ err }, "failed to start server");
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log.info("worker-only mode; HTTP server not started");
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import axios from "axios";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { createKvApi } from "./kv-store.js";
|
import { createKvApi } from "./kv-store.js";
|
||||||
import { createFingerprintApi } from "./script-fingerprint.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 { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
||||||
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
||||||
import { getSecretPlaintext } from "./secrets-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) {
|
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`);
|
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||||
}
|
}
|
||||||
if (id === "axios") {
|
if (BLOCKED_PLUGIN_MODULES.has(id)) {
|
||||||
return screenedAxios;
|
throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
|
||||||
}
|
}
|
||||||
return hostRequire(id);
|
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,
|
* workflowName: string,
|
||||||
* owner?: string,
|
* owner?: string,
|
||||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||||
|
* pluginDir?: string | null,
|
||||||
* }} opts
|
* }} opts
|
||||||
*/
|
*/
|
||||||
function createScriptSandbox({
|
function createScriptSandbox({
|
||||||
@@ -399,6 +445,7 @@ function createScriptSandbox({
|
|||||||
workflowName,
|
workflowName,
|
||||||
owner = "default",
|
owner = "default",
|
||||||
$workflows = $workflowsStub,
|
$workflows = $workflowsStub,
|
||||||
|
pluginDir = null,
|
||||||
}) {
|
}) {
|
||||||
const scriptLog = log.child({ workflow: workflowName, script });
|
const scriptLog = log.child({ workflow: workflowName, script });
|
||||||
const $axios = createScreenedAxios(scriptLog);
|
const $axios = createScreenedAxios(scriptLog);
|
||||||
@@ -418,7 +465,7 @@ function createScriptSandbox({
|
|||||||
$vars,
|
$vars,
|
||||||
$responses,
|
$responses,
|
||||||
$workflows,
|
$workflows,
|
||||||
require: createRestrictedRequire($axios),
|
require: createRestrictedRequire($axios, pluginDir),
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.createContext(sandbox, {
|
vm.createContext(sandbox, {
|
||||||
@@ -470,8 +517,29 @@ export function extractScriptMeta(fn) {
|
|||||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||||
* }} opts
|
* }} 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);
|
return compiled.runInContext(sandbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +554,7 @@ function instantiateCompiled(compiled, { log, script, workflowName, owner, $work
|
|||||||
* workflowName?: string,
|
* workflowName?: string,
|
||||||
* owner?: string,
|
* owner?: string,
|
||||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||||
|
* pluginDir?: string | null,
|
||||||
* }} [opts]
|
* }} [opts]
|
||||||
*/
|
*/
|
||||||
export function instantiateScriptSource(script, source, opts = {}) {
|
export function instantiateScriptSource(script, source, opts = {}) {
|
||||||
@@ -496,6 +565,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
|
|||||||
workflowName: opts.workflowName ?? "inspect",
|
workflowName: opts.workflowName ?? "inspect",
|
||||||
owner: opts.owner ?? "default",
|
owner: opts.owner ?? "default",
|
||||||
$workflows: opts.$workflows,
|
$workflows: opts.$workflows,
|
||||||
|
pluginDir: opts.pluginDir ?? null,
|
||||||
});
|
});
|
||||||
return { fn, ...extractScriptMeta(fn) };
|
return { fn, ...extractScriptMeta(fn) };
|
||||||
}
|
}
|
||||||
@@ -519,17 +589,28 @@ export function inspectScriptSource(script, source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadCompiledScript(script) {
|
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 { 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) {
|
if (cached && cached.mtimeMs === mtimeMs) {
|
||||||
return cached.compiled;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = fs.readFileSync(filePath, "utf8");
|
const source = fs.readFileSync(filePath, "utf8");
|
||||||
const compiled = compileScriptSource(source, filePath);
|
const compiled = compileScriptSource(source, filePath);
|
||||||
scriptCache.set(script, { compiled, mtimeMs });
|
const entry = {
|
||||||
return compiled;
|
compiled,
|
||||||
|
mtimeMs,
|
||||||
|
pluginDir: resolved.pluginDir ?? null,
|
||||||
|
scriptRef: resolved.scriptRef,
|
||||||
|
};
|
||||||
|
scriptCache.set(cacheKey, entry);
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -545,13 +626,14 @@ function loadCompiledScript(script) {
|
|||||||
* }} opts
|
* }} opts
|
||||||
*/
|
*/
|
||||||
export async function runScript(script, ctx, { log, workflowName, owner, $workflows }) {
|
export async function runScript(script, ctx, { log, workflowName, owner, $workflows }) {
|
||||||
const compiled = loadCompiledScript(script);
|
const loaded = loadCompiledScript(script);
|
||||||
const fn = instantiateCompiled(compiled, {
|
const fn = instantiateCompiled(loaded.compiled, {
|
||||||
log,
|
log,
|
||||||
script,
|
script: loaded.scriptRef,
|
||||||
workflowName,
|
workflowName,
|
||||||
owner,
|
owner,
|
||||||
$workflows,
|
$workflows,
|
||||||
|
pluginDir: loaded.pluginDir,
|
||||||
});
|
});
|
||||||
return await fn(ctx);
|
return await fn(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
process.env.JFLOW_ROLE = "api";
|
||||||
|
|
||||||
|
import { startApp } from "./start-app.js";
|
||||||
|
|
||||||
|
// HTTP API + cron enqueue only. Schema migrations are owned by control.
|
||||||
|
await startApp({
|
||||||
|
role: "api",
|
||||||
|
migrate: false,
|
||||||
|
// In PM2/split mode the Vite/control process serves the SPA.
|
||||||
|
serveStaticUi: process.env.JFLOW_SERVE_UI === "1",
|
||||||
|
});
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
import {
|
import {
|
||||||
clearScriptCache,
|
clearScriptCache,
|
||||||
inspectScriptSource,
|
inspectScriptSource,
|
||||||
instantiateScriptSource,
|
instantiateScriptSource,
|
||||||
} from "../../script-sandbox.js";
|
} from "../../script-sandbox.js";
|
||||||
import * as fsStore from "../../fs-store.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 { createDryRunLogger, safeSerialize } from "./dry-run-logger.js";
|
||||||
import { normalizeStepResult } from "../../step-result.js";
|
import { normalizeStepResult } from "../../step-result.js";
|
||||||
import { resolveConfigRefs } from "../../config-refs.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
|
* @param {{ referencedScripts: () => Set<string> }} registry
|
||||||
@@ -18,19 +37,58 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
*/
|
*/
|
||||||
return async function scriptsPlugin(fastify) {
|
return async function scriptsPlugin(fastify) {
|
||||||
fastify.get("/scripts", async () => {
|
fastify.get("/scripts", async () => {
|
||||||
const scripts = fsStore.listScriptFiles().map((name) => {
|
const core = listCoreScriptNames().map((name) => {
|
||||||
const content = fsStore.readScript(name);
|
const content = fsStore.readScript(name);
|
||||||
const inspected =
|
const inspected =
|
||||||
content == null
|
content == null
|
||||||
? { meta: null, metaError: "script not found" }
|
? { meta: null, metaError: "script not found" }
|
||||||
: inspectScriptSource(name, content);
|
: 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) => {
|
fastify.get("/scripts/:name/icon", async (req, reply) => {
|
||||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||||
|
// Icons only for core scripts today
|
||||||
try {
|
try {
|
||||||
fsStore.assertScriptName(name);
|
fsStore.assertScriptName(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -46,64 +104,152 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/scripts/:name", async (req, reply) => {
|
fastify.get("/scripts/:name", async (req, reply) => {
|
||||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
const rawName = decodeURIComponent(
|
||||||
try {
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
fsStore.assertScriptName(name);
|
);
|
||||||
} catch (err) {
|
const resolved = resolveScriptRef(rawName);
|
||||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
if (resolved.error || !resolved.filePath) {
|
||||||
|
return reply
|
||||||
|
.code(404)
|
||||||
|
.send({ error: resolved.error || "script not found" });
|
||||||
}
|
}
|
||||||
const content = fsStore.readScript(name);
|
const content = fs.readFileSync(resolved.filePath, "utf8");
|
||||||
if (content == null) return reply.code(404).send({ error: "script not found" });
|
const inspected = inspectScriptSource(resolved.scriptRef, content);
|
||||||
return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, 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) => {
|
fastify.put("/scripts/:name", async (req, reply) => {
|
||||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
const rawName = decodeURIComponent(
|
||||||
try {
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
fsStore.assertScriptName(name);
|
);
|
||||||
} catch (err) {
|
const pluginRef = resolveScriptRef(rawName);
|
||||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
if (pluginRef.kind === "core" || !rawName.startsWith("plugin/")) {
|
||||||
|
// Attempt to treat as core name
|
||||||
|
try {
|
||||||
|
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 ?? {});
|
const body = /** @type {{ content?: string }} */ (req.body ?? {});
|
||||||
if (typeof body.content !== "string") {
|
if (typeof body.content !== "string") {
|
||||||
return reply.code(400).send({ error: "content is required" });
|
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();
|
clearScriptCache();
|
||||||
return reply.code(existed ? 200 : 201).send({
|
return reply.send({
|
||||||
name,
|
name: resolved.scriptRef,
|
||||||
...inspectScriptSource(name, body.content),
|
kind: "plugin",
|
||||||
|
editable: true,
|
||||||
|
...inspectScriptSource(resolved.scriptRef, body.content),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.delete("/scripts/:name", async (req, reply) => {
|
fastify.delete("/scripts/:name", async (req, reply) => {
|
||||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
const rawName = decodeURIComponent(
|
||||||
try {
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
fsStore.assertScriptName(name);
|
);
|
||||||
} catch (err) {
|
if (!rawName.startsWith("plugin/")) {
|
||||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
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: "plugin is referenced by a workflow" });
|
||||||
|
}
|
||||||
|
uninstallPlugin(installed.id);
|
||||||
|
clearScriptCache();
|
||||||
|
return { ok: true, restartNeeded: true };
|
||||||
|
}
|
||||||
|
if (registry.referencedScripts().has(resolved.scriptRef)) {
|
||||||
return reply
|
return reply
|
||||||
.code(409)
|
.code(409)
|
||||||
.send({ error: "script is referenced by a workflow" });
|
.send({ error: "plugin is referenced by a workflow" });
|
||||||
}
|
|
||||||
if (!fsStore.deleteScript(name)) {
|
|
||||||
return reply.code(404).send({ error: "script not found" });
|
|
||||||
}
|
}
|
||||||
|
uninstallPlugin(id);
|
||||||
clearScriptCache();
|
clearScriptCache();
|
||||||
return { ok: true };
|
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) => {
|
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
|
||||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
const rawName = decodeURIComponent(
|
||||||
try {
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
fsStore.assertScriptName(name);
|
);
|
||||||
} catch (err) {
|
|
||||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
const body = /** @type {{ content?: string, data?: unknown, context?: unknown, config?: unknown, owner?: string }} */ (
|
||||||
req.body ?? {}
|
req.body ?? {}
|
||||||
);
|
);
|
||||||
@@ -121,10 +267,18 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const incomingContext =
|
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
|
? body.context
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
const resolved = resolveScriptRef(rawName);
|
||||||
|
const pluginDir =
|
||||||
|
resolved.kind === "plugin" && !resolved.error
|
||||||
|
? resolved.pluginDir ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
const { log, logs } = createDryRunLogger();
|
const { log, logs } = createDryRunLogger();
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
|
||||||
@@ -139,13 +293,22 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
context: incomingContext,
|
context: incomingContext,
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
|
const { fn, meta, metaError } = instantiateScriptSource(
|
||||||
log,
|
resolved.scriptRef || rawName,
|
||||||
workflowName: "dry-run",
|
body.content,
|
||||||
owner,
|
{
|
||||||
});
|
log,
|
||||||
|
workflowName: "dry-run",
|
||||||
|
owner,
|
||||||
|
pluginDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
const raw = await fn(ctx);
|
const raw = await fn(ctx);
|
||||||
const result = normalizeStepResult(raw, incomingContext, name);
|
const result = normalizeStepResult(
|
||||||
|
raw,
|
||||||
|
incomingContext,
|
||||||
|
resolved.scriptRef || rawName,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: "success",
|
status: "success",
|
||||||
output: safeSerialize(result.output),
|
output: safeSerialize(result.output),
|
||||||
@@ -158,7 +321,7 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
metaError,
|
metaError,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const inspected = inspectScriptSource(name, body.content);
|
const inspected = inspectScriptSource(rawName, body.content);
|
||||||
return {
|
return {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
output: null,
|
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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ import {
|
|||||||
ensureWorkflowFilename,
|
ensureWorkflowFilename,
|
||||||
suggestCopyFilename,
|
suggestCopyFilename,
|
||||||
} from "../../workflow-duplicate.js";
|
} from "../../workflow-duplicate.js";
|
||||||
|
import { publishReload } from "../../control-bus.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload this process and notify other HTTP/worker processes via Redis.
|
||||||
|
* @param {{ reregister: () => void }} registry
|
||||||
|
*/
|
||||||
|
async function reregisterAll(registry) {
|
||||||
|
registry.reregister();
|
||||||
|
try {
|
||||||
|
await publishReload({ type: "workflows" });
|
||||||
|
} catch {
|
||||||
|
// Redis may be briefly unavailable; local reload already applied.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerSummary(owner, workflow) {
|
function triggerSummary(owner, workflow) {
|
||||||
if (!workflow || typeof workflow !== "object") return [];
|
if (!workflow || typeof workflow !== "object") return [];
|
||||||
@@ -214,7 +228,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
registered.push(file);
|
registered.push(file);
|
||||||
fsStore.writeRegisters(owner, registered);
|
fsStore.writeRegisters(owner, registered);
|
||||||
}
|
}
|
||||||
registry.reregister();
|
await reregisterAll(registry);
|
||||||
return reply.code(existed ? 200 : 201).send({ owner, file });
|
return reply.code(existed ? 200 : 201).send({ owner, file });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +265,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
doc.set("enabled", false);
|
doc.set("enabled", false);
|
||||||
}
|
}
|
||||||
fsStore.writeWorkflowYaml(owner, file, String(doc));
|
fsStore.writeWorkflowYaml(owner, file, String(doc));
|
||||||
registry.reregister();
|
await reregisterAll(registry);
|
||||||
return { owner, file, enabled: body.enabled };
|
return { owner, file, enabled: body.enabled };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +284,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
|
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
|
||||||
fsStore.writeRegisters(owner, registered);
|
fsStore.writeRegisters(owner, registered);
|
||||||
registry.reregister();
|
await reregisterAll(registry);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,7 +386,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
registered.push(destFile);
|
registered.push(destFile);
|
||||||
fsStore.writeRegisters(destOwner, registered);
|
fsStore.writeRegisters(destOwner, registered);
|
||||||
}
|
}
|
||||||
registry.reregister();
|
await reregisterAll(registry);
|
||||||
return reply.code(201).send({ owner: destOwner, file: destFile });
|
return reply.code(201).send({ owner: destOwner, file: destFile });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -394,9 +408,9 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
if (!registered.includes(file)) {
|
if (!registered.includes(file)) {
|
||||||
registered.push(file);
|
registered.push(file);
|
||||||
fsStore.writeRegisters(owner, registered);
|
fsStore.writeRegisters(owner, registered);
|
||||||
registry.reregister();
|
await reregisterAll(registry);
|
||||||
} else if (!registry.workflows.has(key) && !registry.loadErrors.has(key)) {
|
} else if (!registry.workflows.has(key) && !registry.loadErrors.has(key)) {
|
||||||
registry.reregister();
|
await reregisterAll(registry);
|
||||||
}
|
}
|
||||||
if (!registry.workflows.has(key)) {
|
if (!registry.workflows.has(key)) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -424,7 +438,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/workflows/reregister", async () => {
|
fastify.post("/workflows/reregister", async () => {
|
||||||
registry.reregister();
|
await reregisterAll(registry);
|
||||||
return { message: "Workflows refreshed" };
|
return { message: "Workflows refreshed" };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import jwt from "@fastify/jwt";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import { migrate, db } from "./db.js";
|
||||||
|
import { log, enableLogPersistence, flushLogs } from "./logger.js";
|
||||||
|
import * as store from "./store.js";
|
||||||
|
import { createRegistry } from "./registry.js";
|
||||||
|
import { COOKIE, OPEN_API_ROUTES } from "./src/api/auth.js";
|
||||||
|
import authPlugin from "./src/api/auth.js";
|
||||||
|
import usersPlugin from "./src/api/users.js";
|
||||||
|
import scriptsPluginFactory from "./src/api/scripts.js";
|
||||||
|
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||||
|
import runsPlugin from "./src/api/runs.js";
|
||||||
|
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||||
|
import secretsPlugin from "./src/api/secrets.js";
|
||||||
|
import kvPlugin from "./src/api/kv.js";
|
||||||
|
import variablesPlugin from "./src/api/variables.js";
|
||||||
|
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||||
|
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||||
|
import { WEB_DIST } from "./paths.js";
|
||||||
|
import { resolveSecretsKeyMaterial } from "./secrets.js";
|
||||||
|
import {
|
||||||
|
closeRedis,
|
||||||
|
createWorkflowQueue,
|
||||||
|
createWorkflowWorker,
|
||||||
|
getRedisUrlForLog,
|
||||||
|
} from "./workflow-queue.js";
|
||||||
|
import {
|
||||||
|
getConfigGeneration,
|
||||||
|
startHeartbeatLoop,
|
||||||
|
subscribeReload,
|
||||||
|
} from "./control-bus.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* role?: string,
|
||||||
|
* migrate?: boolean,
|
||||||
|
* serveStaticUi?: boolean,
|
||||||
|
* }} [opts]
|
||||||
|
*/
|
||||||
|
export async function startApp(opts = {}) {
|
||||||
|
const role = (opts.role || process.env.JFLOW_ROLE || "all").toLowerCase();
|
||||||
|
const shouldMigrate = opts.migrate ?? role === "all";
|
||||||
|
const serveStaticUi = opts.serveStaticUi ?? (role === "all" || role === "api");
|
||||||
|
|
||||||
|
if (shouldMigrate) {
|
||||||
|
await migrate();
|
||||||
|
}
|
||||||
|
enableLogPersistence();
|
||||||
|
|
||||||
|
const jwtSecret =
|
||||||
|
process.env.JFLOW_JWT_SECRET ??
|
||||||
|
(process.env.NODE_ENV === "production" ? "" : "jflow-dev-secret");
|
||||||
|
|
||||||
|
if (!jwtSecret) {
|
||||||
|
log.error("JFLOW_JWT_SECRET is required in production");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolveSecretsKeyMaterial();
|
||||||
|
} catch (err) {
|
||||||
|
log.error(err instanceof Error ? err.message : String(err));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runApi = role === "all" || role === "api";
|
||||||
|
const runWorker = role === "all" || role === "worker";
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
redis: getRedisUrlForLog(),
|
||||||
|
role,
|
||||||
|
generation: getConfigGeneration(),
|
||||||
|
migrate: shouldMigrate,
|
||||||
|
},
|
||||||
|
"starting jerapah-flow",
|
||||||
|
);
|
||||||
|
|
||||||
|
const workflowQueue = createWorkflowQueue();
|
||||||
|
try {
|
||||||
|
await workflowQueue.waitUntilReady();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err, redis: getRedisUrlForLog() }, "failed to connect to Redis");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = fastify({ loggerInstance: log });
|
||||||
|
|
||||||
|
await server.register(cookie);
|
||||||
|
await server.register(jwt, {
|
||||||
|
secret: jwtSecret,
|
||||||
|
cookie: {
|
||||||
|
cookieName: COOKIE,
|
||||||
|
signed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await server.register(cors, {
|
||||||
|
origin: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.decorate("authenticate", async function authenticate(req, reply) {
|
||||||
|
try {
|
||||||
|
await req.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
|
||||||
|
if (req.user?.role !== "admin") {
|
||||||
|
return reply.code(403).send({ error: "forbidden" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const registry = createRegistry(server, { queue: workflowQueue });
|
||||||
|
registry.registerWorkflows();
|
||||||
|
if (runApi) {
|
||||||
|
registry.registerHttpTriggers();
|
||||||
|
registry.registerCronTriggers();
|
||||||
|
registry.registerPruneJob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {import("bullmq").Worker | null} */
|
||||||
|
let workflowWorker = null;
|
||||||
|
if (runWorker) {
|
||||||
|
workflowWorker = createWorkflowWorker(async (job) => {
|
||||||
|
const data = /** @type {{ runId?: string, key?: string, depth?: number }} */ (
|
||||||
|
job.data ?? {}
|
||||||
|
);
|
||||||
|
if (typeof data.runId !== "string" || typeof data.key !== "string") {
|
||||||
|
throw new Error("invalid workflow job payload");
|
||||||
|
}
|
||||||
|
const result = await registry.executeQueuedRun({
|
||||||
|
runId: data.runId,
|
||||||
|
key: data.key,
|
||||||
|
depth: data.depth ?? 0,
|
||||||
|
});
|
||||||
|
if (result.status === "failed") {
|
||||||
|
throw new Error(result.error || "workflow failed");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runApi) {
|
||||||
|
await server.register(
|
||||||
|
async (api) => {
|
||||||
|
api.addHook("onRequest", async (req, reply) => {
|
||||||
|
const raw = (req.url || "").split("?")[0];
|
||||||
|
const stripped = raw.replace(/^\/api/, "") || "/";
|
||||||
|
const routeUrl = req.routeOptions?.url || stripped;
|
||||||
|
const open =
|
||||||
|
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
|
||||||
|
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
|
||||||
|
if (open) return;
|
||||||
|
await server.authenticate(req, reply);
|
||||||
|
});
|
||||||
|
await api.register(authPlugin);
|
||||||
|
await api.register(usersPlugin);
|
||||||
|
await api.register(secretsPlugin);
|
||||||
|
await api.register(variablesPlugin);
|
||||||
|
await api.register(kvPlugin);
|
||||||
|
await api.register(httpPagesPlugin);
|
||||||
|
await api.register(httpAuthsPlugin);
|
||||||
|
await api.register(scriptsPluginFactory(registry));
|
||||||
|
await api.register(workflowsPluginFactory(registry));
|
||||||
|
await api.register(runsPlugin);
|
||||||
|
await api.register(dashboardPluginFactory(registry));
|
||||||
|
},
|
||||||
|
{ prefix: "/api" },
|
||||||
|
);
|
||||||
|
|
||||||
|
server.post(
|
||||||
|
"/admin/workflows/reregister",
|
||||||
|
{ onRequest: [server.authenticate] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
registry.reregister();
|
||||||
|
try {
|
||||||
|
const { publishReload } = await import("./control-bus.js");
|
||||||
|
await publishReload({ type: "workflows" });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return reply.send({ message: "Workflows refreshed" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get(
|
||||||
|
"/admin/runs",
|
||||||
|
{ onRequest: [server.authenticate] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
||||||
|
const limit = q.limit ? Number(q.limit) : undefined;
|
||||||
|
const runs = await store.listRuns({
|
||||||
|
owner: q.owner,
|
||||||
|
workflow: q.workflow,
|
||||||
|
status: q.status,
|
||||||
|
limit: Number.isFinite(limit) ? limit : undefined,
|
||||||
|
before: q.before,
|
||||||
|
});
|
||||||
|
return reply.send({ runs });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.get(
|
||||||
|
"/admin/runs/:id",
|
||||||
|
{ onRequest: [server.authenticate] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||||
|
const run = await store.getRun(id);
|
||||||
|
if (!run) {
|
||||||
|
return reply.code(404).send({ error: "run not found" });
|
||||||
|
}
|
||||||
|
return reply.send(run);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (serveStaticUi && fs.existsSync(WEB_DIST)) {
|
||||||
|
await server.register(fastifyStatic, {
|
||||||
|
root: WEB_DIST,
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
server.setNotFoundHandler((req, reply) => {
|
||||||
|
const url = req.raw.url ?? "";
|
||||||
|
if (
|
||||||
|
url.startsWith("/api") ||
|
||||||
|
url.startsWith("/u/") ||
|
||||||
|
url.startsWith("/admin") ||
|
||||||
|
url.startsWith("/ops")
|
||||||
|
) {
|
||||||
|
return reply.code(404).send({ error: "not found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopHeartbeat = startHeartbeatLoop(runApi && runWorker ? "all" : runApi ? "api" : "worker");
|
||||||
|
|
||||||
|
const reloadSub = await subscribeReload(async () => {
|
||||||
|
log.info("received reload signal");
|
||||||
|
registry.reregister();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
try {
|
||||||
|
stopHeartbeat();
|
||||||
|
await reloadSub.stop();
|
||||||
|
if (workflowWorker) {
|
||||||
|
await workflowWorker.close();
|
||||||
|
}
|
||||||
|
await workflowQueue.close();
|
||||||
|
await closeRedis();
|
||||||
|
await flushLogs();
|
||||||
|
await db.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "shutdown error");
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 8700);
|
||||||
|
|
||||||
|
if (runApi) {
|
||||||
|
try {
|
||||||
|
await server.listen({ host: "0.0.0.0", port });
|
||||||
|
log.info(`Server is running on port ${port}`);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "failed to start server");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("worker-only mode; HTTP server not started");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
server,
|
||||||
|
workflowQueue,
|
||||||
|
workflowWorker,
|
||||||
|
registry,
|
||||||
|
shutdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
process.env.JFLOW_ROLE = "worker";
|
||||||
|
|
||||||
|
import { startApp } from "./start-app.js";
|
||||||
|
|
||||||
|
// BullMQ worker only. Schema migrations are owned by control.
|
||||||
|
await startApp({
|
||||||
|
role: "worker",
|
||||||
|
migrate: false,
|
||||||
|
serveStaticUi: false,
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ name: cron example
|
|||||||
description: |
|
description: |
|
||||||
this workflow triggered by cron
|
this workflow triggered by cron
|
||||||
scripts:
|
scripts:
|
||||||
- script: get-current-time.js
|
- script: plugin/get-current-time
|
||||||
- set:
|
- set:
|
||||||
expression: '{"message": context.datetime}'
|
expression: '{"message": context.datetime}'
|
||||||
- script: ntfy.js
|
- script: ntfy.js
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Joplin nightly daily log
|
||||||
|
description: |
|
||||||
|
POST joplin-auto /api/logs/run at 04:00 (sync + yearly/monthly/today), then ntfy.
|
||||||
|
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
||||||
|
scripts:
|
||||||
|
- script: plugin/joplin-api
|
||||||
|
config:
|
||||||
|
url: http://10.8.0.6:3040/api/logs/run
|
||||||
|
method: POST
|
||||||
|
token: $SECRET_joplin_setup_token
|
||||||
|
timeoutMs: 600000
|
||||||
|
- set:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"title": data.ok ? "Bullet journal" : "Bullet journal failed",
|
||||||
|
"message": data.ok
|
||||||
|
? "The bullet journal for " & data.httpResponse.date & " has been created."
|
||||||
|
: data.message
|
||||||
|
}
|
||||||
|
- script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: $VAR_ntfy_channel
|
||||||
|
triggers:
|
||||||
|
- type: cron
|
||||||
|
schedule: "0 4 * * *"
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-joplin-daily
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Joplin get note
|
||||||
|
description: |
|
||||||
|
GET a Joplin note by id from joplin-api. Input (data): id (32-char hex).
|
||||||
|
Secret joplin_api_token (JOPLIN_API_TOKEN / API_KEYS). Returns the full note.
|
||||||
|
scripts:
|
||||||
|
- script: plugin/joplin-api
|
||||||
|
config:
|
||||||
|
url: http://10.8.0.6:3030/notes
|
||||||
|
method: GET
|
||||||
|
token: $SECRET_joplin_api_token
|
||||||
|
timeoutMs: 60000
|
||||||
|
triggers:
|
||||||
|
- type: workflow
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-joplin-get-note
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Joplin nightly sync
|
||||||
|
description: |
|
||||||
|
POST joplin-auto /api/sync at 03:00, then ntfy success or failure.
|
||||||
|
Secret joplin_setup_token (SETUP_API_TOKEN). Variable ntfy_channel.
|
||||||
|
scripts:
|
||||||
|
- script: plugin/joplin-api
|
||||||
|
config:
|
||||||
|
url: http://10.8.0.6:3040/api/sync
|
||||||
|
method: POST
|
||||||
|
token: $SECRET_joplin_setup_token
|
||||||
|
timeoutMs: 600000
|
||||||
|
- set:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"title": data.ok ? "Joplin sync ok" : "Joplin sync failed",
|
||||||
|
"message": data.message
|
||||||
|
}
|
||||||
|
- script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: $VAR_ntfy_channel
|
||||||
|
triggers:
|
||||||
|
- type: cron
|
||||||
|
schedule: "0 3 * * *"
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-joplin-sync
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: dev-zte-sms
|
||||||
|
description: |
|
||||||
|
Send an SMS via a ZTE modem web UI.
|
||||||
|
Input (data): to, message
|
||||||
|
Password is the named secret zte_modem_password ($SECRET_).
|
||||||
|
scripts:
|
||||||
|
- script: plugin/send-sms
|
||||||
|
config:
|
||||||
|
url: http://192.168.5.1/reqproc/proc_post
|
||||||
|
password: $SECRET_sms_secret
|
||||||
|
triggers:
|
||||||
|
- type: workflow
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /dev-zte-sms
|
||||||
|
auth: basic-auth
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: jsonata
|
name: jsonata
|
||||||
scripts:
|
scripts:
|
||||||
- get-current-time.js
|
- plugin/get-current-time
|
||||||
- script: jsonata.js
|
- script: jsonata.js
|
||||||
config:
|
config:
|
||||||
expression: '{"message": data.datetime & " " & data.processId}'
|
expression: '{"message": data.datetime & " " & data.processId}'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ description: >
|
|||||||
Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy.
|
Fan-in from two scripts (current time + monkeyuser comic), then send to ntfy.
|
||||||
scripts:
|
scripts:
|
||||||
- id: time
|
- id: time
|
||||||
script: get-current-time.js
|
script: plugin/get-current-time
|
||||||
|
|
||||||
- id: comic
|
- id: comic
|
||||||
script: fetch-html.js
|
script: fetch-html.js
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: time to ntfy example
|
|||||||
description: |
|
description: |
|
||||||
this workflow will send a message to ntfy with the current time
|
this workflow will send a message to ntfy with the current time
|
||||||
scripts:
|
scripts:
|
||||||
- script: get-current-time.js
|
- script: plugin/get-current-time
|
||||||
config:
|
config:
|
||||||
key: ""
|
key: ""
|
||||||
- script: ntfy.js
|
- script: ntfy.js
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@jerapah-flow/web",
|
"name": "@jerapah-flow/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
|||||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||||
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
||||||
|
import { OpsPage } from "./pages/OpsPage.jsx";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -71,11 +72,13 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/secrets" element={<SecretsPage />} />
|
<Route path="/secrets" element={<SecretsPage />} />
|
||||||
|
<Route path="/ops" element={<OpsPage />} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Route path="/users" element={<Navigate to="/" replace />} />
|
<Route path="/users" element={<Navigate to="/" replace />} />
|
||||||
<Route path="/secrets" element={<Navigate to="/" replace />} />
|
<Route path="/secrets" element={<Navigate to="/" replace />} />
|
||||||
|
<Route path="/ops" element={<Navigate to="/" replace />} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ export const api = axios.create({
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Control-plane ops API (proxied to :8600 in `pnpm dev:pm2`). */
|
||||||
|
export const opsApi = axios.create({
|
||||||
|
baseURL: "/ops",
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(err) => {
|
(err) => {
|
||||||
const url = err.config?.url ?? "";
|
const url = err.config?.url ?? "";
|
||||||
const isAuthCall = url.includes("/auth/login") || url.includes("/auth/register") || url.includes("/auth/me") || url.includes("/auth/bootstrap");
|
const isAuthCall =
|
||||||
|
url.includes("/auth/login") ||
|
||||||
|
url.includes("/auth/register") ||
|
||||||
|
url.includes("/auth/me") ||
|
||||||
|
url.includes("/auth/bootstrap");
|
||||||
if (err.response?.status === 401 && !isAuthCall) {
|
if (err.response?.status === 401 && !isAuthCall) {
|
||||||
window.dispatchEvent(new Event("jerapah-flow:unauthorized"));
|
window.dispatchEvent(new Event("jerapah-flow:unauthorized"));
|
||||||
}
|
}
|
||||||
@@ -17,6 +27,16 @@ api.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
opsApi.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
window.dispatchEvent(new Event("jerapah-flow:unauthorized"));
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export function errorMessage(err, fallback = "request failed") {
|
export function errorMessage(err, fallback = "request failed") {
|
||||||
return err?.response?.data?.error ?? err?.message ?? fallback;
|
return err?.response?.data?.error ?? err?.message ?? fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "./client.js";
|
import { api, opsApi } from "./client.js";
|
||||||
|
|
||||||
export function useBootstrap() {
|
export function useBootstrap() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -89,6 +89,47 @@ export function useSaveScript() {
|
|||||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
|
qc.invalidateQueries({ queryKey: ["scripts", vars.name] });
|
||||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
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"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -450,3 +491,84 @@ export function useDeleteHttpAuth() {
|
|||||||
export async function fetchHttpAuthLiterals(name) {
|
export async function fetchHttpAuthLiterals(name) {
|
||||||
return (await api.get(`/http-auths/${encodeURIComponent(name)}/reveal`)).data;
|
return (await api.get(`/http-auths/${encodeURIComponent(name)}/reveal`)).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOpsStatus(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ops-status"],
|
||||||
|
queryFn: async () => (await opsApi.get("/status")).data,
|
||||||
|
enabled,
|
||||||
|
retry: false,
|
||||||
|
refetchInterval: enabled ? 3000 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsPause() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/pause")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsResume() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/resume")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsReload() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/reload")).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsRestart() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ force = false } = {}) =>
|
||||||
|
(await opsApi.post("/restart", { force })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsScale() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ workers, force = false }) =>
|
||||||
|
(await opsApi.post("/scale", { workers, force })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsHttpStart() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/http/start")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsHttpStop() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => (await opsApi.post("/http/stop")).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpsBumpGeneration() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (reason) =>
|
||||||
|
(await opsApi.post("/generation/bump", { reason })).data,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavLink, useNavigate } from "react-router-dom";
|
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
LuActivity,
|
LuActivity,
|
||||||
LuCode,
|
LuCode,
|
||||||
@@ -10,12 +10,13 @@ import {
|
|||||||
LuLogOut,
|
LuLogOut,
|
||||||
LuMenu,
|
LuMenu,
|
||||||
LuMoon,
|
LuMoon,
|
||||||
|
LuServer,
|
||||||
LuShield,
|
LuShield,
|
||||||
LuSun,
|
LuSun,
|
||||||
LuTags,
|
LuTags,
|
||||||
LuUsers,
|
LuUsers,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { useLogout } from "../api/hooks.js";
|
import { useLogout, useOpsStatus } from "../api/hooks.js";
|
||||||
import { brandMark } from "../theme/brand.js";
|
import { brandMark } from "../theme/brand.js";
|
||||||
import { useTheme } from "../theme.jsx";
|
import { useTheme } from "../theme.jsx";
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ export function Layout({ user, children }) {
|
|||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const ops = useOpsStatus(user.role === "admin");
|
||||||
|
const restartNeeded = Boolean(ops.data?.desired?.restartNeeded);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
...links,
|
...links,
|
||||||
@@ -41,6 +44,7 @@ export function Layout({ user, children }) {
|
|||||||
? [
|
? [
|
||||||
{ to: "/secrets", label: "Secrets", icon: LuKey },
|
{ to: "/secrets", label: "Secrets", icon: LuKey },
|
||||||
{ to: "/users", label: "Users", icon: LuUsers },
|
{ to: "/users", label: "Users", icon: LuUsers },
|
||||||
|
{ to: "/ops", label: "Ops", icon: LuServer },
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
@@ -88,7 +92,24 @@ export function Layout({ user, children }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main className="flex-1 p-4">{children}</main>
|
<main className="flex-1 p-4">
|
||||||
|
{restartNeeded ? (
|
||||||
|
<div className="alert alert-warning mb-4 text-sm">
|
||||||
|
<span>
|
||||||
|
Config generation changed
|
||||||
|
{ops.data?.desired?.restartReason
|
||||||
|
? ` (${ops.data.desired.restartReason})`
|
||||||
|
: ""}
|
||||||
|
.{" "}
|
||||||
|
<Link to="/ops" className="link font-medium">
|
||||||
|
Drain restart from Ops
|
||||||
|
</Link>{" "}
|
||||||
|
to apply on HTTP + workers.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div className="drawer-side z-20">
|
<div className="drawer-side z-20">
|
||||||
<label htmlFor="nav-drawer" aria-label="Close menu" className="drawer-overlay" />
|
<label htmlFor="nav-drawer" aria-label="Close menu" className="drawer-overlay" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
|
|||||||
|
|
||||||
export const NEW_WORKFLOW_YAML = `name: new workflow
|
export const NEW_WORKFLOW_YAML = `name: new workflow
|
||||||
scripts:
|
scripts:
|
||||||
- get-current-time.js
|
- plugin/get-current-time
|
||||||
triggers:
|
triggers:
|
||||||
- type: HTTP
|
- type: HTTP
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
useOpsBumpGeneration,
|
||||||
|
useOpsHttpStart,
|
||||||
|
useOpsHttpStop,
|
||||||
|
useOpsPause,
|
||||||
|
useOpsReload,
|
||||||
|
useOpsRestart,
|
||||||
|
useOpsResume,
|
||||||
|
useOpsScale,
|
||||||
|
useOpsStatus,
|
||||||
|
} from "../api/hooks.js";
|
||||||
|
import { errorMessage } from "../api/client.js";
|
||||||
|
|
||||||
|
function Stat({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs uppercase tracking-wide opacity-60">{label}</span>
|
||||||
|
<span className="font-mono text-sm">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsPage() {
|
||||||
|
const status = useOpsStatus(true);
|
||||||
|
const pause = useOpsPause();
|
||||||
|
const resume = useOpsResume();
|
||||||
|
const reload = useOpsReload();
|
||||||
|
const restart = useOpsRestart();
|
||||||
|
const scale = useOpsScale();
|
||||||
|
const httpStart = useOpsHttpStart();
|
||||||
|
const httpStop = useOpsHttpStop();
|
||||||
|
const bump = useOpsBumpGeneration();
|
||||||
|
const [workerCount, setWorkerCount] = useState("");
|
||||||
|
const [msg, setMsg] = useState(null);
|
||||||
|
const [err, setErr] = useState(null);
|
||||||
|
|
||||||
|
const unavailable =
|
||||||
|
status.isError &&
|
||||||
|
(status.error?.code === "ERR_NETWORK" ||
|
||||||
|
status.error?.response?.status === 404 ||
|
||||||
|
status.error?.message?.includes("Network"));
|
||||||
|
|
||||||
|
function run(label, mutateAsync, args) {
|
||||||
|
setMsg(null);
|
||||||
|
setErr(null);
|
||||||
|
mutateAsync(args)
|
||||||
|
.then((data) => {
|
||||||
|
setMsg(`${label} ok`);
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(errorMessage(e, `${label} failed`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold">Ops</h1>
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<span>
|
||||||
|
Control plane is not reachable on <code>/ops</code>. Use{" "}
|
||||||
|
<code>pnpm dev:pm2</code> (Vite :8500, control :8600, HTTP :8700) to
|
||||||
|
manage processes. Plain <code>pnpm dev</code> runs the monolith and
|
||||||
|
has no control API.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = status.data;
|
||||||
|
const desired = data?.desired;
|
||||||
|
const queue = data?.queue;
|
||||||
|
const children = data?.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Ops</h1>
|
||||||
|
<p className="text-sm opacity-70">
|
||||||
|
Process control via PM2. Pause stops workers from opening jobs;
|
||||||
|
cron/HTTP can still enqueue. Restart drains active jobs unless forced.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => status.refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{desired?.restartNeeded ? (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<span>
|
||||||
|
Restart needed (generation {desired.generation})
|
||||||
|
{desired.restartReason ? `: ${desired.restartReason}` : ""}. Use
|
||||||
|
Drain restart after install/config changes.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{msg ? (
|
||||||
|
<div className="alert alert-success text-sm">
|
||||||
|
<span>{msg}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{err ? (
|
||||||
|
<div className="alert alert-error text-sm">
|
||||||
|
<span>{err}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status.isLoading && !data ? (
|
||||||
|
<span className="loading loading-spinner" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Stat label="HTTP desired" value={desired?.http ?? "—"} />
|
||||||
|
<Stat label="Workers desired" value={desired?.workers ?? "—"} />
|
||||||
|
<Stat label="Generation" value={desired?.generation ?? "—"} />
|
||||||
|
<Stat
|
||||||
|
label="Queue"
|
||||||
|
value={queue?.paused ? "paused" : "running"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Active jobs"
|
||||||
|
value={queue?.counts?.active ?? "—"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Waiting jobs"
|
||||||
|
value={queue?.counts?.waiting ?? "—"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="HTTP online"
|
||||||
|
value={children?.httpOnline ? "yes" : "no"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Workers online"
|
||||||
|
value={children?.workerOnlineCount ?? "—"}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">Queue</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${queue?.paused ? "btn-disabled" : ""}`}
|
||||||
|
disabled={pause.isPending || Boolean(queue?.paused)}
|
||||||
|
onClick={() => run("Pause", pause.mutateAsync)}
|
||||||
|
>
|
||||||
|
Pause workers
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${!queue?.paused ? "btn-disabled" : ""}`}
|
||||||
|
disabled={resume.isPending || !queue?.paused}
|
||||||
|
onClick={() => run("Resume", resume.mutateAsync)}
|
||||||
|
>
|
||||||
|
Resume workers
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={reload.isPending}
|
||||||
|
onClick={() => run("Reload", reload.mutateAsync)}
|
||||||
|
>
|
||||||
|
Reload workflows
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">HTTP process</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
disabled={httpStart.isPending}
|
||||||
|
onClick={() => run("HTTP start", httpStart.mutateAsync)}
|
||||||
|
>
|
||||||
|
Start HTTP
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
disabled={httpStop.isPending}
|
||||||
|
onClick={() => run("HTTP stop", httpStop.mutateAsync)}
|
||||||
|
>
|
||||||
|
Stop HTTP
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">Workers</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={32}
|
||||||
|
className="input input-bordered input-sm w-24"
|
||||||
|
placeholder={String(desired?.workers ?? 1)}
|
||||||
|
value={workerCount}
|
||||||
|
onChange={(e) => setWorkerCount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={scale.isPending || workerCount === ""}
|
||||||
|
onClick={() =>
|
||||||
|
run("Scale", scale.mutateAsync, {
|
||||||
|
workers: Number(workerCount),
|
||||||
|
force: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Scale (drain)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-warning"
|
||||||
|
disabled={scale.isPending || workerCount === ""}
|
||||||
|
onClick={() =>
|
||||||
|
run("Scale force", scale.mutateAsync, {
|
||||||
|
workers: Number(workerCount),
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Scale (force)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">Restart HTTP + workers</h2>
|
||||||
|
<p className="text-sm opacity-70">
|
||||||
|
Pauses the queue, waits until active jobs are 0, migrates DB, then
|
||||||
|
recreates PM2 processes. Force skips the wait (may interrupt runs).
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
disabled={restart.isPending}
|
||||||
|
onClick={() => run("Restart", restart.mutateAsync, { force: false })}
|
||||||
|
>
|
||||||
|
Drain restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-error"
|
||||||
|
disabled={restart.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Force restart will interrupt active jobs. Continue?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run("Force restart", restart.mutateAsync, { force: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Force restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
disabled={bump.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
run("Bump generation", bump.mutateAsync, "manual test flag")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Flag restart-needed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">Heartbeats</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>PID</th>
|
||||||
|
<th>Generation</th>
|
||||||
|
<th>Age (ms)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(data?.heartbeats ?? []).map((h) => (
|
||||||
|
<tr key={h.field}>
|
||||||
|
<td>{h.role}</td>
|
||||||
|
<td className="font-mono">{h.pid}</td>
|
||||||
|
<td className="font-mono">{h.generation}</td>
|
||||||
|
<td className="font-mono">
|
||||||
|
{Math.max(0, Date.now() - h.ts)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!data?.heartbeats?.length ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="opacity-60">
|
||||||
|
No live heartbeats yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">PM2 children</h2>
|
||||||
|
<pre className="bg-base-200 max-h-64 overflow-auto rounded-lg p-3 text-xs">
|
||||||
|
{JSON.stringify(children ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,57 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
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 { 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 { CodeEditor } from "../components/CodeEditor.jsx";
|
||||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.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";
|
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() {
|
export function ScriptNewPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [name, setName] = useState("");
|
const [id, setId] = useState("");
|
||||||
const [content, setContent] = useState(
|
const [content, setContent] = useState(
|
||||||
location.state?.content ?? NEW_SCRIPT_TEMPLATE,
|
location.state?.content ?? NEW_SCRIPT_TEMPLATE,
|
||||||
);
|
);
|
||||||
const save = useSaveScript();
|
const create = useCreatePlugin();
|
||||||
|
const { notify } = useNotifications();
|
||||||
|
|
||||||
function onSave(e) {
|
function onSave(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = normalizeScriptName(name);
|
const pluginId = normalizePluginId(id);
|
||||||
if (!file) return;
|
if (!pluginId) return;
|
||||||
save.mutate(
|
create.mutate(
|
||||||
{ name: file, content },
|
{ 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() {
|
function openDryRun() {
|
||||||
const file = normalizeScriptName(name);
|
const pluginId = normalizePluginId(id);
|
||||||
if (!file) return;
|
if (!pluginId) return;
|
||||||
navigate(`/scripts/${encodeURIComponent(file)}/dry-run`, {
|
navigate(`/scripts/${encodeURIComponent(`plugin/${pluginId}`)}/dry-run`, {
|
||||||
state: { content },
|
state: { content },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -44,12 +62,12 @@ export function ScriptNewPage() {
|
|||||||
<Link to="/scripts" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
<Link to="/scripts" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
||||||
<LuArrowLeft className="size-4" />
|
<LuArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-xl font-semibold">New script</h1>
|
<h1 className="text-xl font-semibold">New plugin</h1>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline btn-sm"
|
className="btn btn-outline btn-sm"
|
||||||
disabled={!normalizeScriptName(name)}
|
disabled={!normalizePluginId(id)}
|
||||||
onClick={openDryRun}
|
onClick={openDryRun}
|
||||||
>
|
>
|
||||||
<LuPlay className="size-4" />
|
<LuPlay className="size-4" />
|
||||||
@@ -59,26 +77,34 @@ export function ScriptNewPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
form="script-edit-form"
|
form="script-edit-form"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
disabled={save.isPending || !normalizeScriptName(name)}
|
disabled={create.isPending || !normalizePluginId(id)}
|
||||||
>
|
>
|
||||||
<LuSave className="size-4" />
|
<LuSave className="size-4" />
|
||||||
Save
|
Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<form id="script-edit-form" onSubmit={onSave} className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
<input
|
<input
|
||||||
className="input input-sm w-full max-w-md shrink-0"
|
className="input input-sm w-full max-w-md shrink-0 font-mono"
|
||||||
placeholder="name.js"
|
placeholder="my-script (becomes plugin/my-script)"
|
||||||
value={name}
|
value={id}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setId(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<CodeEditor language="javascript" value={content} onChange={setContent} height="100%" />
|
<CodeEditor language="javascript" value={content} onChange={setContent} height="100%" />
|
||||||
</div>
|
</div>
|
||||||
{save.isError ? (
|
{create.isError ? (
|
||||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
<p className="text-error text-sm shrink-0">{errorMessage(create.error)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,8 +118,12 @@ export function ScriptEditPage() {
|
|||||||
const { notify } = useNotifications();
|
const { notify } = useNotifications();
|
||||||
const existing = useScript(name);
|
const existing = useScript(name);
|
||||||
const save = useSaveScript();
|
const save = useSaveScript();
|
||||||
|
const fork = useForkScript();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [contentReady, setContentReady] = useState(false);
|
const [contentReady, setContentReady] = useState(false);
|
||||||
|
const [forkId, setForkId] = useState("");
|
||||||
|
|
||||||
|
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing.isLoading) return;
|
if (existing.isLoading) return;
|
||||||
@@ -105,9 +135,10 @@ export function ScriptEditPage() {
|
|||||||
|
|
||||||
function onSave(e) {
|
function onSave(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isCore) return;
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{ name, content },
|
{ 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) {
|
if (existing.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[12rem] items-center justify-center">
|
<div className="flex min-h-[12rem] items-center justify-center">
|
||||||
@@ -145,24 +191,63 @@ export function ScriptEditPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
|
<ScriptIcon name={name} hasIcon={existing.data?.hasIcon} className="size-8 shrink-0" />
|
||||||
<h1 className="truncate font-mono text-xl font-semibold">{name}</h1>
|
<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" />
|
<div className="flex-1" />
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={openDryRun}>
|
<button type="button" className="btn btn-outline btn-sm" onClick={openDryRun}>
|
||||||
<LuPlay className="size-4" />
|
<LuPlay className="size-4" />
|
||||||
Dry run
|
Dry run
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!isCore ? (
|
||||||
type="submit"
|
<button
|
||||||
form="script-edit-form"
|
type="submit"
|
||||||
className="btn btn-primary btn-sm"
|
form="script-edit-form"
|
||||||
disabled={save.isPending || !contentReady}
|
className="btn btn-primary btn-sm"
|
||||||
>
|
disabled={save.isPending || !contentReady}
|
||||||
<LuSave className="size-4" />
|
>
|
||||||
Save
|
<LuSave className="size-4" />
|
||||||
</button>
|
Save
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</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">
|
<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>
|
</form>
|
||||||
|
|
||||||
<details className="collapse collapse-arrow shrink-0 border border-base-300 bg-base-100">
|
<details className="collapse collapse-arrow shrink-0 border border-base-300 bg-base-100">
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
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 { 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 { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||||
import { TagBadge } from "../components/TagBadge.jsx";
|
import { TagBadge } from "../components/TagBadge.jsx";
|
||||||
import { scriptTags } from "../lib/script.js";
|
import { scriptTags } from "../lib/script.js";
|
||||||
|
import { useNotifications } from "../notifications.jsx";
|
||||||
|
|
||||||
export function ScriptsPage() {
|
export function ScriptsPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
@@ -14,7 +20,12 @@ export function ScriptsPage() {
|
|||||||
const { data: scripts = [], isLoading } = useScripts();
|
const { data: scripts = [], isLoading } = useScripts();
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [forkFor, setForkFor] = useState(null);
|
||||||
|
const [forkId, setForkId] = useState("");
|
||||||
const del = useDeleteScript();
|
const del = useDeleteScript();
|
||||||
|
const fork = useForkScript();
|
||||||
|
const install = useInstallPlugin();
|
||||||
|
const { notify } = useNotifications();
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const term = query.trim().toLowerCase();
|
const term = query.trim().toLowerCase();
|
||||||
@@ -23,9 +34,11 @@ export function ScriptsPage() {
|
|||||||
const name = typeof s === "string" ? s : s.name ?? "";
|
const name = typeof s === "string" ? s : s.name ?? "";
|
||||||
const description = typeof s === "string" ? "" : s.meta?.description ?? "";
|
const description = typeof s === "string" ? "" : s.meta?.description ?? "";
|
||||||
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
||||||
|
const kind = typeof s === "string" ? "" : s.kind ?? "";
|
||||||
return (
|
return (
|
||||||
name.toLowerCase().includes(term) ||
|
name.toLowerCase().includes(term) ||
|
||||||
description.toLowerCase().includes(term) ||
|
description.toLowerCase().includes(term) ||
|
||||||
|
kind.toLowerCase().includes(term) ||
|
||||||
tags.some((t) => t.toLowerCase().includes(term))
|
tags.some((t) => t.toLowerCase().includes(term))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -50,13 +63,33 @@ export function ScriptsPage() {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
||||||
<LuPlus className="size-4" />
|
<LuPlus className="size-4" />
|
||||||
Add
|
Add plugin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{install.isError ? (
|
||||||
|
<p className="text-error text-sm">{errorMessage(install.error)}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="loading loading-spinner" />
|
<span className="loading loading-spinner" />
|
||||||
) : scripts.length === 0 ? (
|
) : scripts.length === 0 ? (
|
||||||
@@ -71,6 +104,8 @@ export function ScriptsPage() {
|
|||||||
const metaError = typeof s === "string" ? null : s.metaError;
|
const metaError = typeof s === "string" ? null : s.metaError;
|
||||||
const hasIcon = typeof s === "string" ? undefined : s.hasIcon;
|
const hasIcon = typeof s === "string" ? undefined : s.hasIcon;
|
||||||
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
const tags = typeof s === "string" ? [] : scriptTags(s.meta);
|
||||||
|
const kind = typeof s === "string" ? "core" : s.kind ?? "core";
|
||||||
|
const isCore = kind === "core";
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={name}
|
key={name}
|
||||||
@@ -90,6 +125,13 @@ export function ScriptsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</Link>
|
</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">
|
<p className="text-xs opacity-80 line-clamp-2 min-h-8">
|
||||||
{metaError ? (
|
{metaError ? (
|
||||||
<span className="text-error">{metaError}</span>
|
<span className="text-error">{metaError}</span>
|
||||||
@@ -114,25 +156,47 @@ export function ScriptsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs"
|
className="btn btn-ghost btn-xs"
|
||||||
title="Dry run"
|
title="Dry run"
|
||||||
onClick={() => navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)}
|
onClick={() =>
|
||||||
|
navigate(`/scripts/${encodeURIComponent(name)}/dry-run`)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LuPlay className="size-4" />
|
<LuPlay className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
{isCore ? (
|
||||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
<button
|
||||||
className="btn btn-ghost btn-xs"
|
type="button"
|
||||||
title="Edit"
|
className="btn btn-ghost btn-xs"
|
||||||
>
|
title="Fork"
|
||||||
<LuPencil className="size-4" />
|
onClick={() => {
|
||||||
</Link>
|
setForkFor(name);
|
||||||
<button
|
setForkId(
|
||||||
type="button"
|
String(name)
|
||||||
className="btn btn-ghost btn-xs text-error"
|
.replace(/\.js$/i, "")
|
||||||
title="Delete"
|
.toLowerCase() + "-copy",
|
||||||
onClick={() => setConfirmDelete(name)}
|
);
|
||||||
>
|
}}
|
||||||
<LuTrash2 className="size-4" />
|
>
|
||||||
</button>
|
<LuCopy className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<LuPencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!isCore ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-error"
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => setConfirmDelete(name)}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -141,20 +205,79 @@ export function ScriptsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmDelete ? (
|
{forkFor ? (
|
||||||
<dialog className="modal modal-open">
|
<dialog className="modal modal-open">
|
||||||
<div className="modal-box">
|
<div className="modal-box">
|
||||||
<h3 className="font-bold">Delete {confirmDelete}?</h3>
|
<h3 className="font-semibold">Fork {forkFor}</h3>
|
||||||
{del.isError ? (
|
<p className="text-sm opacity-70 py-2">
|
||||||
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
|
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}
|
) : null}
|
||||||
<div className="modal-action">
|
<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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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}
|
disabled={del.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
del.mutate(confirmDelete, {
|
del.mutate(confirmDelete, {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 8500,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
"/api": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||||
"/admin": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
"/admin": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||||
|
"/u": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||||
|
"/ops": { target: "http://127.0.0.1:8600", changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "joplin-api",
|
||||||
|
"name": "Joplin API",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"jerapah": ">=0.1.0 <1.0.0",
|
||||||
|
"main": "script.js",
|
||||||
|
"description": "GET/POST a Joplin host with Bearer token using native fetch (LAN/WG IPs)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "jflow-plugin-joplin-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "JerapahFlow plugin: Joplin HTTP with Bearer token (native fetch)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||||
|
const DEFAULT_TIMEOUT_MS = 600_000;
|
||||||
|
const JOPLIN_ID_RE = /^[a-fA-F0-9]{32}$/;
|
||||||
|
|
||||||
|
function passContext(ctx) {
|
||||||
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
|
return { ...ctx.context };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapScalar(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
if (typeof value.reveal === "function") {
|
||||||
|
return unwrapScalar(value.reveal());
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(ctx, startedAt, message, extra = {}) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const output = {
|
||||||
|
ok: false,
|
||||||
|
status: extra.status ?? null,
|
||||||
|
httpResponse: extra.httpResponse ?? null,
|
||||||
|
durationMs,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
log.warn({ durationMs, status: output.status, message }, "joplin-api: failed");
|
||||||
|
return { output, context: { ...passContext(ctx), ...output } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function okResult(ctx, startedAt, status, httpResponse, message) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const output = {
|
||||||
|
ok: true,
|
||||||
|
status,
|
||||||
|
httpResponse,
|
||||||
|
durationMs,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
log.info({ durationMs, status }, "joplin-api: ok");
|
||||||
|
return { output, context: { ...passContext(ctx), ...output } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMethod(ctx) {
|
||||||
|
const raw = unwrapScalar(ctx.config?.method || ctx.data?.method || "POST");
|
||||||
|
const method = raw.toUpperCase();
|
||||||
|
if (!HTTP_METHODS.includes(method)) {
|
||||||
|
return { error: `unsupported method "${raw}"` };
|
||||||
|
}
|
||||||
|
return { method };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeoutMs(ctx) {
|
||||||
|
const raw = ctx.config?.timeoutMs ?? ctx.data?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteIdFromData(ctx) {
|
||||||
|
const id = unwrapScalar(ctx.data?.id).trim();
|
||||||
|
return id.length > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(base, id) {
|
||||||
|
const trimmed = base.replace(/\/+$/, "");
|
||||||
|
return `${trimmed}/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizationHeader(token) {
|
||||||
|
const trimmed = token.trim();
|
||||||
|
if (/^bearer\s+/i.test(trimmed)) return trimmed;
|
||||||
|
return `Bearer ${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSuccess(status, body, durationMs) {
|
||||||
|
if (body != null && typeof body === "object" && !Array.isArray(body)) {
|
||||||
|
if (typeof body.date === "string" && body.date) {
|
||||||
|
return `ok ${status} date=${body.date} in ${durationMs}ms`;
|
||||||
|
}
|
||||||
|
if (typeof body.status === "string" && body.status) {
|
||||||
|
return `ok ${status} ${body.status} in ${durationMs}ms`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `ok ${status} in ${durationMs}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeError(status, body) {
|
||||||
|
if (body != null && typeof body === "object" && typeof body.error === "string") {
|
||||||
|
return status != null ? `HTTP ${status}: ${body.error}` : body.error;
|
||||||
|
}
|
||||||
|
if (typeof body === "string" && body.length > 0) {
|
||||||
|
const clip = body.length > 300 ? `${body.slice(0, 300)}…` : body;
|
||||||
|
return status != null ? `HTTP ${status}: ${clip}` : clip;
|
||||||
|
}
|
||||||
|
return status != null ? `HTTP ${status}` : "request failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseBody(response) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.length === 0) return null;
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json") || /^[\[{]/.test(text.trim())) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joplinHttp(ctx) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const url = unwrapScalar(ctx.config?.url).trim();
|
||||||
|
if (!url) {
|
||||||
|
return fail(ctx, startedAt, "config.url is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = unwrapScalar(ctx.config?.token);
|
||||||
|
if (!token) {
|
||||||
|
return fail(ctx, startedAt, "config.token is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodResult = resolveMethod(ctx);
|
||||||
|
if (methodResult.error) {
|
||||||
|
return fail(ctx, startedAt, methodResult.error);
|
||||||
|
}
|
||||||
|
const { method } = methodResult;
|
||||||
|
|
||||||
|
const id = noteIdFromData(ctx);
|
||||||
|
if (id != null && !JOPLIN_ID_RE.test(id)) {
|
||||||
|
return fail(ctx, startedAt, "data.id must be a 32-character hex Joplin id");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = id != null ? joinUrl(url, id) : url;
|
||||||
|
const timeoutMs = resolveTimeoutMs(ctx);
|
||||||
|
|
||||||
|
log.info({ url: requestUrl, method, timeoutMs, hasId: id != null }, "joplin-api: request");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(requestUrl, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: authorizationHeader(token),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const httpResponse = await parseBody(response);
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
if (response.ok) {
|
||||||
|
return okResult(
|
||||||
|
ctx,
|
||||||
|
startedAt,
|
||||||
|
response.status,
|
||||||
|
httpResponse,
|
||||||
|
summarizeSuccess(response.status, httpResponse, durationMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fail(ctx, startedAt, summarizeError(response.status, httpResponse), {
|
||||||
|
status: response.status,
|
||||||
|
httpResponse,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const name = err instanceof Error ? err.name : "";
|
||||||
|
const message =
|
||||||
|
name === "TimeoutError" || name === "AbortError"
|
||||||
|
? `request timed out after ${timeoutMs}ms`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
return fail(ctx, startedAt, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
joplinHttp.meta = {
|
||||||
|
description:
|
||||||
|
"GET/POST a Joplin host with Bearer token using native fetch (LAN/WG IPs). Optional data.id is appended to the URL. Does not throw; ok=false on failure.",
|
||||||
|
previewConfigKey: "url",
|
||||||
|
tags: ["HTTP", "Joplin"],
|
||||||
|
config: {
|
||||||
|
url: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Request URL. data.id is appended as a path segment when set.",
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
type: "string",
|
||||||
|
default: "POST",
|
||||||
|
enum: HTTP_METHODS,
|
||||||
|
description: "HTTP method",
|
||||||
|
},
|
||||||
|
token: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Bearer token ($SECRET_); Bearer prefix is added if missing",
|
||||||
|
},
|
||||||
|
timeoutMs: {
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
description: "Abort timeout in milliseconds (default 600000)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "32-char hex Joplin note id; appended to config.url",
|
||||||
|
},
|
||||||
|
method: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "Fallback when config.method is omitted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
status: { type: "number", description: "HTTP status, or null on network error" },
|
||||||
|
httpResponse: { type: "any", description: "Parsed JSON or response text" },
|
||||||
|
durationMs: { type: "number" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
status: { type: "number" },
|
||||||
|
httpResponse: { type: "any" },
|
||||||
|
durationMs: { type: "number" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
data: { id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
||||||
|
config: {
|
||||||
|
url: "http://10.8.0.6:3030/notes",
|
||||||
|
method: "GET",
|
||||||
|
token: "$SECRET_joplin_api_token",
|
||||||
|
timeoutMs: 60000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default joplinHttp;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "send-sms",
|
||||||
|
"name": "Send SMS",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"jerapah": ">=0.1.0 <1.0.0",
|
||||||
|
"main": "script.js",
|
||||||
|
"description": "Log in to a ZTE modem web UI and send an SMS"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "jflow-plugin-send-sms",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "JerapahFlow plugin: send SMS via a ZTE modem web UI"
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
function passContext(ctx) {
|
||||||
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
|
return { ...ctx.context };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value, label) {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new Error(`${label} is required`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSmsTime(date = new Date()) {
|
||||||
|
const offsetMinutes = -date.getTimezoneOffset();
|
||||||
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||||
|
const absHours = Math.abs(offsetMinutes) / 60;
|
||||||
|
const tz = Number.isInteger(absHours) ? String(absHours) : String(absHours);
|
||||||
|
return [
|
||||||
|
pad2(date.getFullYear() % 100),
|
||||||
|
pad2(date.getMonth() + 1),
|
||||||
|
pad2(date.getDate()),
|
||||||
|
pad2(date.getHours()),
|
||||||
|
pad2(date.getMinutes()),
|
||||||
|
pad2(date.getSeconds()),
|
||||||
|
`${sign}${tz}`,
|
||||||
|
].join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeUtf16BeHex(text) {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
out += text.charCodeAt(i).toString(16).toUpperCase().padStart(4, "0");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGsm7(text) {
|
||||||
|
const basic =
|
||||||
|
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?" +
|
||||||
|
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
|
||||||
|
const ext = new Set(["\f", "^", "{", "}", "\\", "[", "~", "]", "|", "€"]);
|
||||||
|
for (const ch of text) {
|
||||||
|
if (!basic.includes(ch) && !ext.has(ch)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refererFromUrl(url) {
|
||||||
|
return `${new URL(url).origin}/index.html`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResult(text) {
|
||||||
|
const match = String(text).match(/\{[^{}]*"result"\s*:\s*"[^"]*"[^{}]*\}/);
|
||||||
|
if (!match) return { raw: String(text) };
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[0]);
|
||||||
|
} catch {
|
||||||
|
return { raw: String(text) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function leftoverFromError(err) {
|
||||||
|
let current = err;
|
||||||
|
for (let i = 0; i < 6 && current; i++) {
|
||||||
|
if (typeof current.data === "string") return current.data;
|
||||||
|
current = current.cause;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ztePost(url, fields) {
|
||||||
|
const body = new URLSearchParams(fields).toString();
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json, text/javascript, */*; q=0.01",
|
||||||
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"x-requested-with": "XMLHttpRequest",
|
||||||
|
Referer: refererFromUrl(url),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return parseResult(await response.text());
|
||||||
|
} catch (err) {
|
||||||
|
const leftover = leftoverFromError(err);
|
||||||
|
if (leftover != null) {
|
||||||
|
const parsed = parseResult(leftover);
|
||||||
|
log.info({ result: parsed.result ?? parsed }, "send-sms: recovered malformed response");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendZteSms(ctx) {
|
||||||
|
const url = requiredString(ctx.config?.url, "config.url");
|
||||||
|
const password = requiredString(ctx.config?.password, "config.password");
|
||||||
|
const to = requiredString(ctx.data?.to ?? ctx.data?.number, "data.to");
|
||||||
|
const message = requiredString(ctx.data?.message, "data.message");
|
||||||
|
|
||||||
|
log.info({ url }, "send-sms: logging in");
|
||||||
|
const login = await ztePost(url, {
|
||||||
|
goformId: "LOGIN",
|
||||||
|
password: btoa(password),
|
||||||
|
});
|
||||||
|
const loginResult = login.result;
|
||||||
|
if (loginResult !== "0" && loginResult !== 0 && loginResult !== "3" && loginResult !== 3 && loginResult !== "success") {
|
||||||
|
throw new Error(`ZTE login failed: ${JSON.stringify(login.result ?? login)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodeType = isGsm7(message) ? "GSM7_default" : "UNICODE";
|
||||||
|
const smsTime = formatSmsTime();
|
||||||
|
log.info({ url, to, encodeType, messageLength: message.length }, "send-sms: sending");
|
||||||
|
const sent = await ztePost(url, {
|
||||||
|
goformId: "SEND_SMS",
|
||||||
|
notCallback: "true",
|
||||||
|
Number: to,
|
||||||
|
sms_time: smsTime,
|
||||||
|
MessageBody: encodeUtf16BeHex(message),
|
||||||
|
ID: "-1",
|
||||||
|
encode_type: encodeType,
|
||||||
|
});
|
||||||
|
if (sent.result !== "success" && sent.result !== "0" && sent.result !== 0) {
|
||||||
|
throw new Error(`ZTE SEND_SMS failed: ${JSON.stringify(sent.result ?? sent)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ to, result: sent.result }, "send-sms: sent");
|
||||||
|
return {
|
||||||
|
output: { sent: true, to, result: sent.result },
|
||||||
|
context: passContext(ctx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sendZteSms.meta = {
|
||||||
|
description: "Log in to a ZTE modem web UI and send an SMS",
|
||||||
|
previewConfigKey: "url",
|
||||||
|
tags: ["channel"],
|
||||||
|
config: {
|
||||||
|
url: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
default: "http://192.168.5.1/reqproc/proc_post",
|
||||||
|
description: "Modem POST URL (LOGIN and SEND_SMS)",
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
description: "Web UI password (sent as base64)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
to: { type: "string", required: true, description: "Recipient phone number" },
|
||||||
|
message: { type: "string", required: true, description: "SMS text" },
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
sent: { type: "boolean" },
|
||||||
|
to: { type: "string" },
|
||||||
|
result: { type: "string" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
data: {
|
||||||
|
to: "082134336193",
|
||||||
|
message: "Test nomor kirim aja dari XL prioritas",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
url: "http://192.168.5.1/reqproc/proc_post",
|
||||||
|
password: "error403",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sendZteSms;
|
||||||
Generated
+976
-3
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user