Author SHA1 Message Date
nsrb a20b26b4c7 feat(plugins): add duplicate plugin functionality and related API endpoints
- Implemented a new `duplicatePlugin` function to allow copying of installed plugins with a new ID.
- Added API endpoint for duplicating plugins, including error handling for various edge cases.
- Introduced frontend hooks and UI components for duplicating plugins in the script management interface.
- Enhanced tests to validate the duplication process and ensure proper error handling.
2026-08-30 19:33:36 +07:00
nsrb f1cdb7ac68 chore(paths): refactor data directory structure and update references
- Updated paths for instance data, workflows, and logs to use a unified `data/` directory.
- Adjusted related documentation in AGENTS.md and README.md to reflect the new data structure.
- Refactored path handling in server files to ensure consistency and clarity in data management.
- Enhanced test scripts to align with the new directory structure for improved organization.
2026-08-30 15:51:33 +07:00
nsrb 3f0774813d refactor(config): remove legacy config reference handling and streamline YAML management
- Deleted legacy config reference handling functions and related migration scripts to simplify the codebase.
- Updated documentation to reflect the removal of legacy YAML paths and configurations.
- Adjusted existing YAML management processes to ensure compatibility with the new mustache-style syntax.
- Removed tests related to legacy config references, focusing on current functionality and ensuring clarity in the testing suite.
2026-08-30 15:37:58 +07:00
nsrb 5615d989fa feat(migration): migrate legacy owner data and update config reference handling
- Implemented a migration process to move resources from the legacy owner "default" to the new default owner "local", ensuring no data loss during the transition.
- Updated configuration reference handling to replace legacy `$VAR_`, `$SECRET_`, and `$CONTEXT_` prefixes with mustache-style `{{ vars.name }}`, `{{ secrets.name }}`, and `{{ context.name }}`.
- Enhanced YAML configuration files and scripts to reflect the new mustache syntax, improving consistency across the application.
- Added tests to validate the migration process and ensure proper handling of legacy references.
- Updated documentation to guide users on the new configuration reference format.
2026-08-30 05:32:48 +07:00
nsrbandCursor e2e70a5aad fix(workflows): unregister missing yaml on trash
Ghost register entries returned 404 on delete and stayed in the list. Unregister first so move-to-trash still removes them when the file is gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 09:17:10 +07:00
nsrb dd98421b08 fix(server): refine error handling and PM2 connection logic
Deploy to Raspberry Pi / deploy (push) Canceled after 1m16s
- Reduced the maximum restarts for PM2 processes from 20 to 8 and added a restart delay of 2000ms for better stability.
- Improved error handling during server startup and PM2 connection, ensuring clearer logging and graceful exits on failure.
- Introduced a utility function to filter out PM2 metadata from environment variables, preventing conflicts during process management.
2026-08-23 08:45:26 +07:00
nsrb 69312c9080 feat(pm2): update PM2 configuration and introduce new start script
Deploy to Raspberry Pi / deploy (push) Canceled after 37s
- Updated PM2 version to 6.0.14 in package.json and pnpm-lock.yaml for improved stability.
- Changed interpreter for PM2 processes to use `process.execPath` for consistency.
- Added a new `start:pm2` script in package.json to streamline PM2 process management.
- Updated README to reflect changes in PM2 usage and instructions for starting the application.
2026-08-23 07:25:25 +07:00
nsrb c8697532f9 feat(ecosystem): add exec_mode to PM2 configuration for control and web server
Deploy to Raspberry Pi / deploy (push) Failing after 10s
- Introduced `exec_mode: "fork"` for both the control plane and web server processes to enhance performance and resource management.
2026-08-22 23:11:48 +07:00
nsrb be8122f9e5 feat(ecosystem): update PM2 configuration for control plane and web server
Deploy to Raspberry Pi / deploy (push) Successful in 52s
- Renamed the control process to `jflow-control` and updated the script path to `control.js`.
- Introduced a new `jflow-web` process for the production UI, serving on port 8500.
- Enhanced environment variable handling for both processes, ensuring proper port assignments.
- Updated README to reflect new process architecture and usage instructions for starting the application.
2026-08-22 22:32:04 +07:00
nsrb 0a8463d8f4 Merge branch 'main' into dev
Deploy to Raspberry Pi / deploy (push) Successful in 4m3s
2026-08-22 21:48:51 +07:00
nsrb 721f15e900 Merge branch 'dev' of https://git.home.0dev.web.id/nsrb/jerapah-flow into dev
Deploy to Raspberry Pi / deploy (push) Successful in 1m44s
2026-08-21 10:34:34 +07:00
nsrb 877ea9e3f8 Merge branch 'main' into dev 2026-08-21 09:31:01 +07:00
nsrb 93b51dcaaa Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Successful in 1m43s
2026-08-20 18:56:34 -04:00
nsrb eb7c318c13 Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Failing after 4s
2026-08-20 18:40:36 -04:00
nsrb 864427b45c feat(workflows): add auto-disable feature for workflows on consecutive failures
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
- Introduced `disableOnConsecutiveFailures` option in workflow triggers to automatically disable workflows after reaching a specified failure threshold.
- Updated related functions to handle the new feature, including persistence of the disabled state and reloading of registries.
- Enhanced UI components to support the new option, allowing users to toggle the auto-disable feature in the workflow configuration.
2026-08-21 05:37:24 +07:00
nsrb 46aa8ca327 add deploy.yml for branch dev pushes
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
2026-08-20 18:09:14 -04:00
59 changed files with 1419 additions and 519 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy to Raspberry Pi
on:
push:
branches: [dev]
jobs:
deploy:
runs-on: home # must match a label on your act_runner
steps:
- name: Deploy
run: |
set -euo pipefail
# act_runner uses bash --noprofile --norc; load nvm/pnpm explicitly
export NVM_DIR="/home/nsrb/.nvm"
export PNPM_HOME="/home/nsrb/.local/share/pnpm"
# shellcheck disable=SC1091
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export PATH="$PNPM_HOME/bin:$PATH"
APP_DIR=/home/nsrb/apps/jerapah-flow
cd "$APP_DIR"
git fetch origin dev
git checkout dev
git pull --ff-only origin dev
pnpm install --frozen-lockfile
pnpm build
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
+3 -1
View File
@@ -1,6 +1,8 @@
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
# Instance data (SQLite, live workflows, control-state, backups)
data/ data/
# Process logs
logs/ logs/
*.db *.db
*.db-* *.db-*
@@ -10,7 +12,7 @@ packages/web/dist
# Personal/local scripts and workflows (not for the repo) # Personal/local scripts and workflows (not for the repo)
debug-*.js debug-*.js
# Legacy live workflow tree (migrated to packages/server/data/workflows/) # Legacy live workflow trees
packages/server/workflows/ packages/server/workflows/
# Plugin install staging and per-plugin deps # Plugin install staging and per-plugin deps
+6 -6
View File
@@ -4,14 +4,14 @@ This file tells agents how to add a **user plugin**. Do not put personal or site
## Workflows (instance data) ## Workflows (instance data)
Live workflows are **not** product source. They live under `packages/server/data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`). Live workflows are **not** product source. They live under `data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
| Kind | In git? | Path | | Kind | In git? | Path |
|---|---|---| |---|---|---|
| Live / personal YAML | No | `packages/server/data/workflows/<owner>/` | | Live / personal YAML | No | `data/workflows/<owner>/` |
| Example presets | Yes | `examples/workflows/*.yaml` (copy into editor only; runner does not load them) | | Example presets | Yes | `examples/workflows/*.yaml` (copy into editor only; runner does not load them) |
Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or the legacy `packages/server/workflows/` tree. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install). Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or `data/workflows/`. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install).
## Where things live ## Where things live
@@ -120,7 +120,7 @@ Return `{ output, context?, skipRemaining? }`. Do not return `ctx`. Mutations of
|---|---| |---|---|
| `ctx.data` | Step input (trigger payload, previous `output`, or DAG `needs`) | | `ctx.data` | Step input (trigger payload, previous `output`, or DAG `needs`) |
| `ctx.context` | Run clipboard (plain object) | | `ctx.context` | Run clipboard (plain object) |
| `ctx.config` | YAML `config` (secrets already unwrapped from `$SECRET_name`) | | `ctx.config` | YAML `config` (mustache refs like `{{ secrets.name }}` already resolved) |
| `output` | Next step’s `data` | | `output` | Next step’s `data` |
| `context` | Next clipboard. Omit to keep incoming | | `context` | Next clipboard. Omit to keep incoming |
@@ -142,7 +142,7 @@ scripts:
script: plugin/my-plugin script: plugin/my-plugin
config: config:
url: http://10.8.0.6:3030/notes url: http://10.8.0.6:3030/notes
token: $SECRET_joplin_api_token token: "{{ secrets.joplin_api_token }}"
``` ```
Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional). Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional).
@@ -153,4 +153,4 @@ Optional `name` is the display title in the editor and graph (falls back to the
- Use an id that matches a core script file (`ntfy`, `jsonata`, …). - Use an id that matches a core script file (`ntfy`, `jsonata`, …).
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled). - Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`. - Commit `plugins/.staging-*` or `plugins/*/node_modules/`.
- Put secrets in `script.js`; use YAML `$SECRET_name` / `$VAR_name`. - Put secrets in `script.js`; use YAML `{{ secrets.name }}` / `{{ vars.name }}` (quote if the value starts with `{`).
+61 -16
View File
@@ -55,16 +55,16 @@ pnpm --dir packages/server reset-admin -- --username admin --password 'your-pass
| Kind | Loaded by runner? | Location | | Kind | Loaded by runner? | Location |
|---|---|---| |---|---|---|
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) | | **Live workflows** | Yes | `data/workflows/<owner>/` (gitignored) |
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow | | **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
- Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it). - Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it).
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save). - New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`. - Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
```bash ```bash
# Smoke # Smoke (isolated under packages/server/data — not the live instance tree)
JFLOW_DATA_DIR=packages/server/data \
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \ JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \ JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
node packages/server/test/plugins-smoke.js node packages/server/test/plugins-smoke.js
@@ -83,13 +83,34 @@ Each script is `async function main(ctx)` and **must** return:
|---|---| |---|---|
| `ctx.data` | This step’s input (trigger payload, previous `output`, or DAG `needs`) | | `ctx.data` | This step’s input (trigger payload, previous `output`, or DAG `needs`) |
| `ctx.context` | Run clipboard (plain object, default `{}`) | | `ctx.context` | Run clipboard (plain object, default `{}`) |
| `ctx.config` | This step’s YAML config | | `ctx.config` | This step’s YAML config (mustache refs already resolved) |
| `output` | Becomes the **next** step’s `data` | | `output` | Becomes the **next** step’s `data` |
| `context` | Next snapshot of the bag. Omitted → keep incoming | | `context` | Next snapshot of the bag. Omitted → keep incoming |
| `skipRemaining` | Stop later steps. Sibling of `output`/`context`, not inside `output` | | `skipRemaining` | Stop later steps. Sibling of `output`/`context`, not inside `output` |
Returning the full `ctx` is an error. Mutating `ctx.data` or `ctx.context` does not persist unless returned. Returning the full `ctx` is an error. Mutating `ctx.data` or `ctx.context` does not persist unless returned.
### Config interpolation
YAML `config` strings may use mustache paths. Quote values that start with `{`.
```yaml
url: "{{ vars.ntfy_channel }}"
token: "{{ secrets.joplin_api_token }}"
id: "{{ context.user.id }}"
title: "{{ data.httpResponse.data.date }}"
topic: "{{ vars.ntfy_prefix }}/{{ data.channel }}"
```
| Root | Meaning |
|---|---|
| `vars` | Owner variable; remaining segments are the flat name (`{{ vars.foo.bar }}` → variable `foo.bar`) |
| `secrets` | Same for secrets |
| `context` | Run clipboard (nested) |
| `data` | This step’s input (nested; numeric segments index arrays) |
A string that is exactly one `{{ path }}` keeps the native type (object/array/number/boolean). Mixed strings concatenate as text. Bare name fields such as `passwordSecret: gmail_app_password` stay names for `$secrets.get` — do not wrap them in `{{ secrets.… }}`. Script APIs `$vars.get` / `$secrets.get` are unchanged.
YAML **SET** evaluates JSONata against the full `ctx`; the result is `output` (the next step’s data). `jsonata.js` does the same. YAML **SET** evaluates JSONata against the full `ctx`; the result is `output` (the next step’s data). `jsonata.js` does the same.
DAG `needs` assemble this step’s `data` from upstream **outputs**. Independent steps in the same wave share a context snapshot; sibling writes to the same context key fail the run. DAG `needs` assemble this step’s `data` from upstream **outputs**. Independent steps in the same wave share a context snapshot; sibling writes to the same context key fail the run.
@@ -105,8 +126,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
| `pnpm dev:server` | Monolith API/runner only | | `pnpm dev:server` | Monolith API/runner only |
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) | | `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
| `pnpm build` | Production UI build | | `pnpm build` | Production UI build |
| `pnpm start` | Monolith: API + worker + built UI | | `pnpm start` | Monolith: API + worker + built UI (serves `dist` on :8700) |
| `pnpm start:control` | Control plane only (migrates, manages PM2 children) | | `pnpm start:control` | Control plane only (migrates, manages PM2 children) |
| `pnpm start:web` | Production UI on :8500 (`dist` + proxies to control/HTTP) |
| `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) | | `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
| `pnpm start:worker` | BullMQ worker only | | `pnpm start:worker` | BullMQ worker only |
| `pnpm migrate` | Apply SQLite migrations | | `pnpm migrate` | Apply SQLite migrations |
@@ -123,7 +145,7 @@ Admin UI route **Ops** (`/ops`) talks to the control process.
| Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume | | Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
| Force restart | Same without waiting (interrupts active runs; orphans marked `worker_lost`) | | 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. Desired state is stored in `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
@@ -131,8 +153,10 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
|---|---|---| |---|---|---|
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. | | `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
| `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. | | `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. | | `JFLOW_DATA_DIR` | `data/` | Instance data root (SQLite, workflows, control-state, backups, trash). Falls back to `packages/server/data` if that tree still has the db or workflows. |
| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). | | `JFLOW_DB_PATH` | `data/jerapah-flow.db` | SQLite file. |
| `JFLOW_WORKFLOWS_DIR` | `data/workflows` | Live workflow YAML (instance data). |
| `JFLOW_LOGS_DIR` | `logs/` | Rolling process logs. |
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. | | `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
| `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. |
@@ -140,24 +164,45 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
| `JFLOW_ROLE` | `all` | `all` (HTTP + cron + worker), `api`, or `worker`. Prefer `pnpm start:api` / `start:worker` under control. | | `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_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. | | `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
| `JFLOW_UI_PORT` | `8500` | Production UI server (`web-server.js`) port. |
| `JFLOW_HTTP_PORT` | `8700` | HTTP API port (PM2 children / UI proxy target). |
| `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:8500` | Vite origin in dev | | `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) |
| `PORT` | `8700` | HTTP API port | | `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) |
| `NODE_ENV` | — | Set `production` for secure cookies | | `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
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.
## Production ## Production
Control-plane topology (same ports as `pnpm dev:pm2`):
| Process | Port | Role |
|---|---|---|
| `jflow-web` | **8500** | Built UI + proxies `/api` → :8700, `/ops` + `/api/auth` → :8600 |
| `jflow-control` | **8600** | Migrations, Ops API, starts/stops PM2 HTTP + workers |
| `jflow-http` | **8700** | API + cron enqueue (managed by control) |
| `jflow-worker` | — | BullMQ workers (managed by control) |
```bash ```bash
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)
# Recommended: run control (migrates + manages PM2 HTTP/workers) # Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …)
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control # Use in-tree PM2 6.x (same module control.js requires). A global `pm2` 7.x
# Or monolith (dev-style): # against a 6.x daemon pegs CPU even when ls shows only 2 fork instances.
# ... pnpm start pnpm start:pm2
# UI: http://localhost:8500
# If you already mixed versions: pnpm pm2 -- kill && pnpm start:pm2
``` ```
With control, serve the built UI from Vite preview, a reverse proxy, or set `JFLOW_SERVE_UI=1` on the HTTP process. Or without the ecosystem file:
```bash
NODE_ENV=production pnpm start:control # :8600 + PM2 children
NODE_ENV=production pnpm start:web # :8500
```
Monolith (no Ops stop/scale): `pnpm build && pnpm start` serves the UI from the API process on :8700. Optional `JFLOW_SERVE_UI=1` on `start:api` does the same when you run HTTP alone — do **not** use that under control-plane mode (stopping HTTP would take down the UI).
+35 -8
View File
@@ -1,7 +1,12 @@
/** /**
* Production PM2 ecosystem (monolith runner). * Production PM2 ecosystem (control plane + UI).
* Use for deployed/single-process starts. For local multi-process Ops UI, use * Starts always-on processes only; HTTP (:8700) and workers are owned by
* `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead. * control.js via PM2 (same as `pnpm dev:pm2`).
*
* Use `pnpm start:pm2` (in-tree PM2 6.x). Do not use a global `pm2` 7.x —
* a CLI/daemon version mismatch pegs CPU even with instances: 1.
*
* Prerequisites: `pnpm build` (packages/web/dist), Redis, .env secrets.
*/ */
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -28,20 +33,42 @@ function loadEnv(file) {
} }
const root = __dirname; const root = __dirname;
const env = {
NODE_ENV: "production",
...loadEnv(path.join(root, ".env")),
};
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "jerapah-flow", name: "jflow-control",
cwd: root, cwd: root,
script: "packages/server/runner.js", script: "packages/server/control.js",
interpreter: "node", interpreter: process.execPath,
instances: 1, instances: 1,
exec_mode: "fork",
autorestart: true,
max_restarts: 8,
restart_delay: 2000,
env: {
...env,
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
},
},
{
name: "jflow-web",
cwd: root,
script: "packages/server/web-server.js",
interpreter: process.execPath,
instances: 1,
exec_mode: "fork",
autorestart: true, autorestart: true,
max_restarts: 20, max_restarts: 20,
env: { env: {
NODE_ENV: "production", ...env,
...loadEnv(path.join(root, ".env")), JFLOW_UI_PORT: env.JFLOW_UI_PORT ?? "8500",
JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600",
JFLOW_HTTP_PORT: env.JFLOW_HTTP_PORT ?? "8700",
}, },
}, },
], ],
@@ -1,6 +1,6 @@
name: Comic - monkeyuser to ntfy name: Comic - monkeyuser to ntfy
description: | description: |
Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel). Scrape the latest MonkeyUser comic and send it to ntfy (requires {{ vars.ntfy_channel }}).
scripts: scripts:
- script: fetch-html.js - script: fetch-html.js
config: config:
@@ -20,7 +20,7 @@ scripts:
- script: fetch-binary.js - script: fetch-binary.js
- script: ntfy.js - script: ntfy.js
config: config:
url: $VAR_ntfy_channel url: "{{ vars.ntfy_channel }}"
triggers: triggers:
- type: HTTP - type: HTTP
method: POST method: POST
+3
View File
@@ -14,6 +14,9 @@
"start:api": "pnpm --filter @jerapah-flow/server start:api", "start:api": "pnpm --filter @jerapah-flow/server start:api",
"start:worker": "pnpm --filter @jerapah-flow/server start:worker", "start:worker": "pnpm --filter @jerapah-flow/server start:worker",
"start:control": "pnpm --filter @jerapah-flow/server start:control", "start:control": "pnpm --filter @jerapah-flow/server start:control",
"start:web": "pnpm --filter @jerapah-flow/server start:web",
"pm2": "node scripts/pm2.mjs",
"start:pm2": "node scripts/pm2.mjs start ecosystem.config.cjs",
"migrate": "pnpm --filter @jerapah-flow/server migrate", "migrate": "pnpm --filter @jerapah-flow/server migrate",
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test", "test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
"lint": "pnpm --filter @jerapah-flow/web lint" "lint": "pnpm --filter @jerapah-flow/web lint"
+2 -2
View File
@@ -1,8 +1,8 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { SERVER_ROOT } from "./paths.js"; import { REPO_ROOT } from "./paths.js";
const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json"); const ROOT_PKG = path.join(REPO_ROOT, "package.json");
/** /**
* JerapahFlow app version from the monorepo root package.json. * JerapahFlow app version from the monorepo root package.json.
+161 -77
View File
@@ -3,36 +3,24 @@ import { assertSecretName, getSecretPlaintext } from "./secrets-store.js";
import { isSecret } from "./secret-value.js"; import { isSecret } from "./secret-value.js";
import { assertVariableName, getVariablePlain } from "./variables-store.js"; import { assertVariableName, getVariablePlain } from "./variables-store.js";
const PREFIXES = [ const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
{ kind: "context", prefix: "$CONTEXT_" }, const MUSTACHE_TOKEN_RE =
{ kind: "secret", prefix: "$SECRET_" }, /\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
{ kind: "var", prefix: "$VAR_" }, const WHOLE_MUSTACHE_RE =
]; /^\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}$/;
/** /**
* @typedef {{ kind: "secret" | "context" | "var", name: string, raw: string }} ConfigRef * @typedef {{
* @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx * owner: string,
* workflowKey?: string,
* context?: unknown,
* data?: unknown,
* }} ConfigRefCtx
*/ */
/** /**
* Parse a whole-value config placeholder. Returns null for literals. * Walk config (objects/arrays) and interpolate `{{ path }}` strings.
* @param {unknown} value * Does not walk trigger data.
* @returns {ConfigRef | null}
*/
export function parseConfigRef(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
for (const { kind, prefix } of PREFIXES) {
if (trimmed.startsWith(prefix)) {
return { kind, name: trimmed.slice(prefix.length), raw: trimmed };
}
}
return null;
}
/**
* Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$CONTEXT_` / `$VAR_`
* strings. Does not walk trigger data.
* *
* @param {unknown} value * @param {unknown} value
* @param {ConfigRefCtx} ctx * @param {ConfigRefCtx} ctx
@@ -68,83 +56,179 @@ export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
/** /**
* @param {string} value * @param {string} value
* @param {ConfigRefCtx} ctx * @param {ConfigRefCtx} ctx
* @returns {Promise<string | number | boolean>} * @returns {Promise<unknown>}
*/ */
async function resolveStringRef(value, ctx) { async function resolveStringRef(value, ctx) {
const ref = parseConfigRef(value); const whole = WHOLE_MUSTACHE_RE.exec(value);
if (!ref) return value; if (whole && whole[0] === value) {
return resolvePath(whole[1], ctx, { raw: value, allowObject: true });
}
if (ref.kind === "secret") { if (!value.includes("{{")) {
return resolveSecretRef(ref, ctx); return value;
} }
if (ref.kind === "var") {
return resolveVarRef(ref, ctx); MUSTACHE_TOKEN_RE.lastIndex = 0;
let out = "";
let lastIndex = 0;
let match;
while ((match = MUSTACHE_TOKEN_RE.exec(value)) != null) {
out += value.slice(lastIndex, match.index);
const resolved = await resolvePath(match[1], ctx, {
raw: match[0],
allowObject: false,
});
out += stringifyScalar(resolved, match[0]);
lastIndex = match.index + match[0].length;
} }
return resolveContextRef(ref, ctx); out += value.slice(lastIndex);
return out;
} }
/** /**
* @param {ConfigRef} ref * @param {string} pathExpr
* @param {ConfigRefCtx} ctx * @param {ConfigRefCtx} ctx
* @returns {Promise<string>} * @param {{ raw: string, allowObject: boolean }} opts
*/ */
async function resolveSecretRef(ref, ctx) { async function resolvePath(pathExpr, ctx, opts) {
try { const segments = pathExpr.split(".");
assertSecretName(ref.name); if (segments.length === 0 || segments.some((s) => !s)) {
} catch { throw new Error(`config ref ${opts.raw}: empty path`);
throw new Error(`config ref ${ref.raw}: invalid secret name`);
} }
const plaintext = await getSecretPlaintext(ctx.owner, ref.name); for (const seg of segments) {
if (FORBIDDEN_SEGMENTS.has(seg)) {
throw new Error(`config ref ${opts.raw}: forbidden path segment "${seg}"`);
}
}
const root = segments[0];
const rest = segments.slice(1);
if (root === "vars") {
return resolveNamedStore("var", rest, ctx, opts);
}
if (root === "secrets") {
return resolveNamedStore("secret", rest, ctx, opts);
}
if (root === "context") {
return walkObject(ctx.context, rest, opts);
}
if (root === "data") {
return walkObject(ctx.data, rest, opts);
}
throw new Error(
`config ref ${opts.raw}: unknown root "${root}" (use vars, secrets, context, or data)`,
);
}
/**
* @param {"var" | "secret"} kind
* @param {string[]} rest
* @param {ConfigRefCtx} ctx
* @param {{ raw: string, allowObject: boolean }} opts
*/
async function resolveNamedStore(kind, rest, ctx, opts) {
if (rest.length === 0) {
throw new Error(`config ref ${opts.raw}: empty ${kind} name`);
}
const name = rest.join(".");
try {
if (kind === "secret") assertSecretName(name);
else assertVariableName(name);
} catch {
throw new Error(`config ref ${opts.raw}: invalid ${kind} name`);
}
if (kind === "secret") {
const plaintext = await getSecretPlaintext(ctx.owner, name);
if (plaintext == null) { if (plaintext == null) {
throw new Error(`config ref ${ref.raw}: secret "${ref.name}" not found`); throw new Error(`config ref ${opts.raw}: secret "${name}" not found`);
} }
return plaintext; return plaintext;
} }
/** const value = await getVariablePlain(ctx.owner, name);
* @param {ConfigRef} ref
* @param {ConfigRefCtx} ctx
* @returns {Promise<string | number | boolean>}
*/
async function resolveVarRef(ref, ctx) {
if (ref.name.length === 0) {
throw new Error(`config ref ${ref.raw}: empty variable name`);
}
try {
assertVariableName(ref.name);
} catch {
throw new Error(`config ref ${ref.raw}: invalid variable name`);
}
const value = await getVariablePlain(ctx.owner, ref.name);
if (value == null) { if (value == null) {
throw new Error(`config ref ${ref.raw}: variable "${ref.name}" not found`); throw new Error(`config ref ${opts.raw}: variable "${name}" not found`);
} }
return value; return value;
} }
/** /**
* @param {ConfigRef} ref * @param {unknown} root
* @param {ConfigRefCtx} ctx * @param {string[]} rest
* @returns {string} * @param {{ raw: string, allowObject: boolean }} opts
*/ */
function resolveContextRef(ref, ctx) { function walkObject(root, rest, opts) {
if (ref.name.length === 0) { if (rest.length === 0) {
throw new Error(`config ref ${ref.raw}: empty context key`); return unwrapValue(root, opts);
} }
const bag =
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context) let cur = root;
? /** @type {Record<string, unknown>} */ (ctx.context) for (const seg of rest) {
: {}; if (cur == null || typeof cur !== "object") {
if (!Object.prototype.hasOwnProperty.call(bag, ref.name)) { throw new Error(`config ref ${opts.raw}: path not found`);
throw new Error(`config ref ${ref.raw}: context "${ref.name}" not found`);
} }
const raw = bag[ref.name]; if (Array.isArray(cur)) {
if (isSecret(raw)) { if (!/^\d+$/.test(seg)) {
return raw.reveal(); throw new Error(`config ref ${opts.raw}: path not found`);
} }
const coerced = coerceCredentialString(raw); const idx = Number(seg);
if (coerced == null) { if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) {
throw new Error(`config ref ${ref.raw}: context "${ref.name}" is not a scalar`); throw new Error(`config ref ${opts.raw}: path not found`);
} }
return coerced; cur = cur[idx];
continue;
}
const bag = /** @type {Record<string, unknown>} */ (cur);
if (!Object.prototype.hasOwnProperty.call(bag, seg)) {
throw new Error(`config ref ${opts.raw}: path not found`);
}
cur = bag[seg];
}
return unwrapValue(cur, opts);
}
/**
* @param {unknown} value
* @param {{ raw: string, allowObject: boolean }} opts
*/
function unwrapValue(value, opts) {
if (isSecret(value)) {
return value.reveal();
}
if (!opts.allowObject && value != null && typeof value === "object") {
throw new Error(`config ref ${opts.raw}: value is not a scalar`);
}
// Whole-value context/data may be any JSON type; mixed strings need scalars only.
if (opts.allowObject) {
if (value != null && typeof value === "object") return value;
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
// Prefer credential coercion for odd primitives (e.g. bigint) when whole-value.
const coerced = coerceCredentialString(value);
if (coerced != null) return coerced;
return value;
}
return value;
}
/**
* @param {unknown} value
* @param {string} raw
*/
function stringifyScalar(value, raw) {
if (value == null) {
throw new Error(`config ref ${raw}: value is null`);
}
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
throw new Error(`config ref ${raw}: value is not a scalar`);
} }
+18 -15
View File
@@ -64,13 +64,6 @@ try {
process.exit(1); 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() { async function applyDesiredState() {
const state = readControlState(); const state = readControlState();
await ensureHttp({ await ensureHttp({
@@ -98,8 +91,6 @@ async function applyDesiredState() {
); );
} }
await applyDesiredState();
const server = fastify({ loggerInstance: log }); const server = fastify({ loggerInstance: log });
await server.register(cookie); await server.register(cookie);
await server.register(jwt, { await server.register(jwt, {
@@ -484,12 +475,24 @@ process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown); process.on("SIGTERM", shutdown);
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600); const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
server try {
.listen({ host: "0.0.0.0", port }) await server.listen({ host: "0.0.0.0", port });
.then(() => {
log.info(`Control is running on port ${port}`); log.info(`Control is running on port ${port}`);
}) } catch (err) {
.catch((err) => {
log.error({ err }, "failed to start control"); log.error({ err }, "failed to start control");
process.exit(1); process.exit(1);
}); }
try {
await connectPm2();
} catch (err) {
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
process.exit(1);
}
try {
await applyDesiredState();
} catch (err) {
log.error({ err }, "failed to apply desired state");
process.exit(1);
}
@@ -1,24 +0,0 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.alterTable("workflow_runs", (t) => {
t.text("job_id");
t.text("queued_at");
});
await knex.schema.raw(
"CREATE INDEX workflow_runs_job_id_idx ON workflow_runs (job_id)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_job_id_idx");
await knex.schema.alterTable("workflow_runs", (t) => {
t.dropColumn("job_id");
t.dropColumn("queued_at");
});
}
@@ -1,21 +0,0 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.alterTable("workflow_runs", (t) => {
t.integer("workflow_revision");
});
await knex.schema.raw(
"CREATE INDEX workflow_runs_workflow_revision_idx ON workflow_runs (workflow, workflow_revision)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_workflow_revision_idx");
await knex.schema.alterTable("workflow_runs", (t) => {
t.dropColumn("workflow_revision");
});
}
@@ -17,6 +17,9 @@ export async function up(knex) {
t.text("output"); t.text("output");
t.text("error"); t.text("error");
t.text("parent_run_id").references("id").inTable("workflow_runs"); t.text("parent_run_id").references("id").inTable("workflow_runs");
t.text("job_id");
t.text("queued_at");
t.integer("workflow_revision");
}); });
await knex.schema.raw( await knex.schema.raw(
@@ -28,45 +31,11 @@ export async function up(knex) {
await knex.schema.raw( await knex.schema.raw(
"CREATE INDEX workflow_runs_status_started_at_idx ON workflow_runs (status, started_at DESC)", "CREATE INDEX workflow_runs_status_started_at_idx ON workflow_runs (status, started_at DESC)",
); );
await knex.schema.createTable("step_runs", (t) => {
t.text("id").primary();
t.text("run_id")
.notNullable()
.references("id")
.inTable("workflow_runs")
.onDelete("CASCADE");
t.integer("step_index").notNullable();
t.text("script").notNullable();
t.text("config");
t.text("status").notNullable();
t.text("started_at").notNullable();
t.text("finished_at");
t.integer("duration_ms");
t.text("output");
t.text("error");
});
await knex.schema.raw( await knex.schema.raw(
"CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)", "CREATE INDEX workflow_runs_job_id_idx ON workflow_runs (job_id)",
); );
await knex.schema.createTable("logs", (t) => {
t.increments("id").primary();
t.text("run_id")
.notNullable()
.references("id")
.inTable("workflow_runs")
.onDelete("CASCADE");
t.text("step_id");
t.text("ts").notNullable();
t.integer("level").notNullable();
t.text("msg");
t.text("payload");
});
await knex.schema.raw( await knex.schema.raw(
"CREATE INDEX logs_run_id_ts_idx ON logs (run_id, ts)", "CREATE INDEX workflow_runs_workflow_revision_idx ON workflow_runs (workflow, workflow_revision)",
); );
} }
@@ -74,7 +43,5 @@ export async function up(knex) {
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function down(knex) { export async function down(knex) {
await knex.schema.dropTableIfExists("logs");
await knex.schema.dropTableIfExists("step_runs");
await knex.schema.dropTableIfExists("workflow_runs"); await knex.schema.dropTableIfExists("workflow_runs");
} }
@@ -0,0 +1,33 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("step_runs", (t) => {
t.text("id").primary();
t.text("run_id")
.notNullable()
.references("id")
.inTable("workflow_runs")
.onDelete("CASCADE");
t.integer("step_index").notNullable();
t.text("script").notNullable();
t.text("config");
t.text("status").notNullable();
t.text("started_at").notNullable();
t.text("finished_at");
t.integer("duration_ms");
t.text("output");
t.text("error");
});
await knex.schema.raw(
"CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("step_runs");
}
@@ -0,0 +1,29 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("logs", (t) => {
t.increments("id").primary();
t.text("run_id")
.notNullable()
.references("id")
.inTable("workflow_runs")
.onDelete("CASCADE");
t.text("step_id");
t.text("ts").notNullable();
t.integer("level").notNullable();
t.text("msg");
t.text("payload");
});
await knex.schema.raw(
"CREATE INDEX logs_run_id_ts_idx ON logs (run_id, ts)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("logs");
}
@@ -29,14 +29,19 @@ const DEFAULT_EMAIL_TEMPLATE = `<!DOCTYPE html>
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function up(knex) { export async function up(knex) {
await knex.schema.alterTable("http_pages", (t) => { await knex.schema.createTable("http_pages", (t) => {
t.text("id").primary();
t.text("name").notNullable().unique();
t.text("content").notNullable();
t.text("mime").notNullable();
t.integer("status").notNullable().defaultTo(200);
t.text("kind").notNullable().defaultTo("response"); t.text("kind").notNullable().defaultTo("response");
t.integer("system").notNullable().defaultTo(0); t.integer("system").notNullable().defaultTo(0);
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
const existing = await knex("http_pages").where({ name: "email-default" }).first();
if (!existing) {
await knex("http_pages").insert({ await knex("http_pages").insert({
id: "00000000-0000-4000-8000-000000000001", id: "00000000-0000-4000-8000-000000000001",
name: "email-default", name: "email-default",
@@ -49,15 +54,10 @@ export async function up(knex) {
updated_at: now, updated_at: now,
}); });
} }
}
/** /**
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function down(knex) { export async function down(knex) {
await knex("http_pages").where({ name: "email-default", system: 1 }).del(); await knex.schema.dropTableIfExists("http_pages");
await knex.schema.alterTable("http_pages", (t) => {
t.dropColumn("kind");
t.dropColumn("system");
});
} }
@@ -2,21 +2,11 @@
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function up(knex) { export async function up(knex) {
await knex.schema.createTable("http_pages", (t) => {
t.text("id").primary();
t.text("name").notNullable().unique();
t.text("content").notNullable();
t.text("mime").notNullable(); // html | json
t.integer("status").notNullable().defaultTo(200);
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
});
await knex.schema.createTable("http_auths", (t) => { await knex.schema.createTable("http_auths", (t) => {
t.text("id").primary(); t.text("id").primary();
t.text("name").notNullable().unique(); t.text("name").notNullable().unique();
t.text("type").notNullable(); // bearer | basic | header t.text("type").notNullable();
t.text("config").notNullable(); // JSON t.text("config").notNullable();
t.integer("unauthorized_status").nullable(); t.integer("unauthorized_status").nullable();
t.text("unauthorized_response").nullable(); t.text("unauthorized_response").nullable();
t.text("created_at").notNullable(); t.text("created_at").notNullable();
@@ -29,5 +19,4 @@ export async function up(knex) {
*/ */
export async function down(knex) { export async function down(knex) {
await knex.schema.dropTableIfExists("http_auths"); await knex.schema.dropTableIfExists("http_auths");
await knex.schema.dropTableIfExists("http_pages");
} }
@@ -21,29 +21,11 @@ export async function up(knex) {
await knex.schema.raw( await knex.schema.raw(
"CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)", "CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)",
); );
await knex.schema.createTable("workflow_trash", (t) => {
t.text("id").primary();
t.text("workflow_id").notNullable();
t.text("owner").notNullable();
t.text("file").notNullable();
t.text("name");
t.text("deleted_at").notNullable();
t.text("trash_path").notNullable();
});
await knex.schema.raw(
"CREATE INDEX workflow_trash_deleted_at_idx ON workflow_trash (deleted_at ASC)",
);
await knex.schema.raw(
"CREATE UNIQUE INDEX workflow_trash_owner_file_idx ON workflow_trash (owner, file)",
);
} }
/** /**
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function down(knex) { export async function down(knex) {
await knex.schema.dropTableIfExists("workflow_trash");
await knex.schema.dropTableIfExists("workflow_revisions"); await knex.schema.dropTableIfExists("workflow_revisions");
} }
@@ -0,0 +1,28 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("workflow_trash", (t) => {
t.text("id").primary();
t.text("workflow_id").notNullable();
t.text("owner").notNullable();
t.text("file").notNullable();
t.text("name");
t.text("deleted_at").notNullable();
t.text("trash_path").notNullable();
});
await knex.schema.raw(
"CREATE INDEX workflow_trash_deleted_at_idx ON workflow_trash (deleted_at ASC)",
);
await knex.schema.raw(
"CREATE UNIQUE INDEX workflow_trash_owner_file_idx ON workflow_trash (owner, file)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("workflow_trash");
}
@@ -14,7 +14,9 @@ export async function up(knex) {
t.unique(["owner", "name"]); t.unique(["owner", "name"]);
}); });
await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)"); await knex.schema.raw(
"CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)",
);
} }
/** /**
+97
View File
@@ -0,0 +1,97 @@
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
"content-length",
]);
const REPLY_SKIP = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"content-encoding",
"content-length",
]);
/**
* Control origin used when the UI or HTTP process proxies to control.
* @returns {string}
*/
export function controlOrigin() {
const explicit = process.env.JFLOW_CONTROL_URL?.trim();
if (explicit) return explicit.replace(/\/$/, "");
const port = Number(process.env.JFLOW_CONTROL_PORT ?? 8600);
return `http://127.0.0.1:${port}`;
}
/**
* HTTP API origin (workflow triggers + REST).
* @returns {string}
*/
export function httpOrigin() {
const explicit = process.env.JFLOW_HTTP_URL?.trim();
if (explicit) return explicit.replace(/\/$/, "");
const port = Number(process.env.JFLOW_HTTP_PORT ?? process.env.PORT ?? 8700);
return `http://127.0.0.1:${port}`;
}
/**
* Forward the incoming request to `origin`, preserving path + query.
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
* @param {string} origin
* @param {{ unreachableMessage?: string }} [opts]
*/
export async function proxyToOrigin(req, reply, origin, opts = {}) {
const target = `${origin.replace(/\/$/, "")}${req.raw.url ?? "/"}`;
const headers = {};
for (const [key, value] of Object.entries(req.headers)) {
if (value == null || HOP_BY_HOP.has(key.toLowerCase())) continue;
headers[key] = Array.isArray(value) ? value.join(", ") : String(value);
}
const method = req.method.toUpperCase();
const hasBody = method !== "GET" && method !== "HEAD";
let body;
if (hasBody) {
if (Buffer.isBuffer(req.body)) body = req.body;
else if (typeof req.body === "string") body = req.body;
else if (req.body != null) {
body = JSON.stringify(req.body);
if (!headers["content-type"]) headers["content-type"] = "application/json";
}
}
let res;
try {
res = await fetch(target, { method, headers, body });
} catch (err) {
const message = opts.unreachableMessage ?? "upstream unreachable";
req.log.warn({ err, target }, `proxy: ${message}`);
return reply.code(502).send({ error: message });
}
reply.code(res.status);
res.headers.forEach((value, key) => {
if (REPLY_SKIP.has(key.toLowerCase())) return;
reply.header(key, value);
});
return reply.send(Buffer.from(await res.arrayBuffer()));
}
/**
* Forward `/ops/*` to the control plane (same-origin UI in production).
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
export async function proxyOpsToControl(req, reply) {
return proxyToOrigin(req, reply, controlOrigin(), {
unreachableMessage: "control plane unreachable",
});
}
+6 -4
View File
@@ -11,12 +11,14 @@
"start:api": "node server.js", "start:api": "node server.js",
"start:worker": "node worker.js", "start:worker": "node worker.js",
"start:control": "node control.js", "start:control": "node control.js",
"start:web": "node web-server.js",
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", "test:plugins": "JFLOW_DATA_DIR=./data JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js", "test:workflow-history": "JFLOW_DATA_DIR=./data JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
"reset-admin": "node reset-admin.js", "reset-admin": "node reset-admin.js",
"test:profiles": "node test/profiles-smoke.js", "test:profiles": "node test/profiles-smoke.js",
"test:set-dry-run": "node test/set-dry-run-smoke.js" "test:set-dry-run": "node test/set-dry-run-smoke.js",
"test:config-refs": "node test/config-refs-smoke.js"
}, },
"dependencies": { "dependencies": {
"@jerapah-flow/shared": "workspace:*", "@jerapah-flow/shared": "workspace:*",
@@ -41,7 +43,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", "pm2": "6.0.14",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"ssh2-sftp-client": "^12.1.1", "ssh2-sftp-client": "^12.1.1",
"webdav": "^5.10.0", "webdav": "^5.10.0",
+39 -17
View File
@@ -1,27 +1,49 @@
import fs from "fs";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url)); export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
export const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts"); export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
export const DATA_DIR = path.join(SERVER_ROOT, "data");
/** Prefer `preferred` unless only `legacy` already has files. */
function existingDir(preferred, legacy, probe) {
const has = (dir) =>
probe ? probe(dir) : fs.existsSync(dir);
if (has(preferred) || !has(legacy)) return preferred;
return legacy;
}
function hasInstanceData(dir) {
return (
fs.existsSync(path.join(dir, "jerapah-flow.db")) ||
fs.existsSync(path.join(dir, "workflows"))
);
}
/** Instance data (SQLite, live workflows, control-state). Not product source. */
export const DATA_DIR = path.resolve(
process.env.JFLOW_DATA_DIR ??
existingDir(
path.join(REPO_ROOT, "data"),
path.join(SERVER_ROOT, "data"),
hasInstanceData,
),
);
/** Live instance workflows (not shipped in git). Override for tests. */ /** Live instance workflows (not shipped in git). Override for tests. */
export const WORKFLOWS_DIR = export const WORKFLOWS_DIR = path.resolve(
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows"); process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows"),
/** Pre-0.1 layout; used only for one-shot migrate into WORKFLOWS_DIR. */ );
export const LEGACY_WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
/** User plugins (repo-root /plugins, outside the pnpm workspace). */ /** User plugins (repo-root /plugins, outside the pnpm workspace). */
export const PLUGINS_DIR = export const PLUGINS_DIR = path.resolve(
process.env.JFLOW_PLUGINS_DIR ?? process.env.JFLOW_PLUGINS_DIR ?? path.join(REPO_ROOT, "plugins"),
path.resolve(SERVER_ROOT, "../../plugins"); );
/** Example plugin sources shipped with the repo. */ /** Example plugin sources shipped with the repo. */
export const EXAMPLE_PLUGINS_DIR = path.resolve( export const EXAMPLE_PLUGINS_DIR = path.join(REPO_ROOT, "examples/plugins");
SERVER_ROOT,
"../../examples/plugins",
);
/** Example workflow YAML presets (not loaded by the runner). */ /** Example workflow YAML presets (not loaded by the runner). */
export const EXAMPLE_WORKFLOWS_DIR = path.resolve( export const EXAMPLE_WORKFLOWS_DIR = path.join(REPO_ROOT, "examples/workflows");
SERVER_ROOT, export const LOGS_DIR = path.resolve(
"../../examples/workflows", process.env.JFLOW_LOGS_DIR ??
existingDir(path.join(REPO_ROOT, "logs"), path.join(SERVER_ROOT, "logs")),
); );
export const LOGS_DIR = path.join(SERVER_ROOT, "logs"); export const WEB_DIST = path.join(REPO_ROOT, "packages/web/dist");
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
+90
View File
@@ -380,6 +380,96 @@ export function forkCoreScript(coreName, newId, opts = {}) {
} }
} }
/**
* Copy an installed plugin to a new plugin id.
*
* @param {string} sourceId
* @param {string} newId
* @param {{ description?: string }} [opts]
*/
export function duplicatePlugin(sourceId, newId, opts = {}) {
const fromId = assertPluginId(sourceId);
const id = assertPluginId(newId);
if (id === fromId) {
const err = new Error("cannot duplicate onto itself");
err.statusCode = 400;
throw err;
}
if (coreBareNames().has(id)) {
const err = new Error(`plugin id collides with core script: ${id}`);
err.statusCode = 409;
throw err;
}
if (fs.existsSync(pluginDir(id))) {
const err = new Error(`plugin already exists: ${id}`);
err.statusCode = 409;
throw err;
}
const source = getInstalledPlugin(fromId);
if (!source) {
const err = new Error(`plugin not found: ${fromId}`);
err.statusCode = 404;
throw err;
}
if (!source.manifest) {
const err = new Error(
source.compatError || `plugin has no valid manifest: ${fromId}`,
);
err.statusCode = 400;
throw err;
}
const staging = path.join(PLUGINS_DIR, `.staging-dup-${id}-${Date.now()}`);
fs.mkdirSync(staging, { recursive: true });
try {
fs.cpSync(source.dir, staging, {
recursive: true,
filter: (src) => {
const base = path.basename(src);
return base !== "node_modules" && base !== ".disabled";
},
});
const manifest = buildManifest({
id,
name: id,
version: source.manifest.version,
jerapah: source.manifest.jerapah,
main: source.manifest.main,
description:
opts.description ?? source.manifest.description ?? null,
});
fs.writeFileSync(
path.join(staging, PLUGIN_MANIFEST),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const pkgPath = path.join(staging, "package.json");
if (fs.existsSync(pkgPath)) {
let pkg = {};
try {
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
} catch {
pkg = {};
}
if (pkg == null || typeof pkg !== "object" || Array.isArray(pkg)) {
pkg = {};
}
pkg.name = `jflow-plugin-${id}`;
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
}
return installPluginFromDirectory(staging, {
overwrite: false,
reason: `plugin:${id} duplicated from ${fromId}`,
});
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
/** /**
* @param {string} pluginDirectory * @param {string} pluginDirectory
* @returns {((id: string) => unknown) | null} * @returns {((id: string) => unknown) | null}
+46 -4
View File
@@ -1,8 +1,6 @@
import path from "path"; import path from "path";
import pm2 from "pm2"; import pm2 from "pm2";
import { SERVER_ROOT } from "./paths.js"; import { REPO_ROOT, SERVER_ROOT } from "./paths.js";
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
export const PM2_HTTP_NAME = "jflow-http"; export const PM2_HTTP_NAME = "jflow-http";
export const PM2_WORKER_NAME = "jflow-worker"; export const PM2_WORKER_NAME = "jflow-worker";
@@ -164,13 +162,57 @@ export async function restartPm2Process(pmId) {
return { name: proc.name, pmId: id }; return { name: proc.name, pmId: id };
} }
/**
* PM2 injects these into process.env of a managed app. Spreading them into
* `pm2.start({ env })` overwrites `name` / `pm_exec_path` so God restarts
* jflow-control instead of launching http/worker (EADDRINUSE :8600 loop).
*/
const PM2_META_KEYS = new Set([
"name",
"namespace",
"exec_mode",
"exec_interpreter",
"instances",
"instance_var",
"node_app_instance",
"unique_id",
"status",
"username",
"windowsHide",
"merge_logs",
"vizion",
"vizion_running",
"autostart",
"autorestart",
"automation",
"km_link",
]);
/**
* @param {NodeJS.ProcessEnv} env
* @returns {NodeJS.ProcessEnv}
*/
export function withoutPm2Meta(env) {
/** @type {NodeJS.ProcessEnv} */
const out = {};
for (const [key, val] of Object.entries(env)) {
if (val == null) continue;
if (PM2_META_KEYS.has(key)) continue;
if (key.startsWith("pm_") || key.startsWith("axm_") || key.startsWith("PM2_")) {
continue;
}
out[key] = val;
}
return out;
}
/** /**
* Shared env for child processes. * Shared env for child processes.
* @param {{ generation: number }} opts * @param {{ generation: number }} opts
*/ */
export function childEnv(opts) { export function childEnv(opts) {
return { return {
...process.env, ...withoutPm2Meta(process.env),
JFLOW_CONFIG_GENERATION: String(opts.generation), JFLOW_CONFIG_GENERATION: String(opts.generation),
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500", JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
PORT: process.env.JFLOW_HTTP_PORT ?? "8700", PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
+54 -1
View File
@@ -36,7 +36,9 @@ import {
resolveFailureTriggerConfig, resolveFailureTriggerConfig,
} from "./trigger-failure.js"; } from "./trigger-failure.js";
import { enqueueWorkflowJob } from "./workflow-queue.js"; import { enqueueWorkflowJob } from "./workflow-queue.js";
import { ensureInitialRevision } from "./workflow-history.js"; import { ensureInitialRevision, recordRevision } from "./workflow-history.js";
import { workflowIdFromFile } from "./workflow-normalize.js";
import { publishReload } from "./control-bus.js";
/** /**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry * @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
@@ -509,6 +511,7 @@ export function createRegistry(server, opts = {}) {
owner, owner,
workflowKey: key, workflowKey: key,
context: incomingContext, context: incomingContext,
data: ctx.data,
}); });
const stepCtx = { const stepCtx = {
data: ctx.data, data: ctx.data,
@@ -553,6 +556,45 @@ export function createRegistry(server, opts = {}) {
} }
} }
/**
* Persist `enabled: false` for a workflow and reload registries across processes.
* @param {string} owner
* @param {string} file
* @param {string} key
*/
async function disableWorkflowForConsecutiveFailures(owner, file, key) {
const content = fsStore.readWorkflowYaml(owner, file);
if (content == null) {
throw new Error(`workflow file missing for ${key}`);
}
const doc = yaml.parseDocument(content);
if (doc.errors?.length) {
throw new Error(doc.errors[0]?.message ?? "invalid yaml");
}
const parsed = doc.toJSON();
if (parsed?.enabled === false) {
log.debug({ workflow: key }, "workflow already disabled");
return;
}
doc.set("enabled", false);
const nextContent = String(doc);
fsStore.writeWorkflowYaml(owner, file, nextContent);
await recordRevision({
workflowId: workflowIdFromFile(file),
owner,
file,
content: nextContent,
reason: "disable-on-consecutive-failures",
});
reregister();
try {
await publishReload({ type: "workflows" });
} catch {
// Redis may be briefly unavailable; local reload already applied.
}
log.warn({ workflow: key }, "disabled workflow after consecutive failures");
}
/** /**
* @param {{ * @param {{
* key: string, * key: string,
@@ -589,6 +631,17 @@ export function createRegistry(server, opts = {}) {
return; return;
} }
if (failureConfig.disableOnConsecutiveFailures) {
const entry = workflows.get(opts.key);
if (entry) {
await disableWorkflowForConsecutiveFailures(entry.owner, entry.file, opts.key);
} else {
log.warn({ workflow: opts.key }, "cannot disable missing workflow entry");
}
}
if (!failureConfig.workflowName) return;
const destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName); const destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName);
const alertData = buildFailureAlertData({ const alertData = buildFailureAlertData({
sourceKey: opts.key, sourceKey: opts.key,
+3 -2
View File
@@ -11,6 +11,7 @@ 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";
import { getVariablePlain } from "./variables-store.js"; import { getVariablePlain } from "./variables-store.js";
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
const hostRequire = createRequire(import.meta.url); const hostRequire = createRequire(import.meta.url);
@@ -443,7 +444,7 @@ function createScriptSandbox({
log, log,
script, script,
workflowName, workflowName,
owner = "default", owner = DEFAULT_OWNER,
$workflows = $workflowsStub, $workflows = $workflowsStub,
pluginDir = null, pluginDir = null,
}) { }) {
@@ -563,7 +564,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
log: opts.log ?? inspectLog, log: opts.log ?? inspectLog,
script, script,
workflowName: opts.workflowName ?? "inspect", workflowName: opts.workflowName ?? "inspect",
owner: opts.owner ?? "default", owner: opts.owner ?? DEFAULT_OWNER,
$workflows: opts.$workflows, $workflows: opts.$workflows,
pluginDir: opts.pluginDir ?? null, pluginDir: opts.pluginDir ?? null,
}); });
+9
View File
@@ -15,6 +15,10 @@ function ntfyHeaders(ctx) {
headers.Title = ctx.data.title; headers.Title = ctx.data.title;
} }
if (ctx.config?.markdown === true) {
headers.md = "true";
}
return headers; return headers;
} }
@@ -149,6 +153,11 @@ ntfy.meta = {
default: "https://ntfy.sh/jerapah-flow", default: "https://ntfy.sh/jerapah-flow",
description: "ntfy topic URL", description: "ntfy topic URL",
}, },
markdown: {
type: "boolean",
default: false,
description: "Send as Markdown (ntfy md header)",
},
fingerprint: { fingerprint: {
type: "string", type: "string",
required: false, required: false,
+40 -2
View File
@@ -8,6 +8,7 @@ import {
import * as fsStore from "../../fs-store.js"; import * as fsStore from "../../fs-store.js";
import { import {
forkCoreScript, forkCoreScript,
duplicatePlugin,
listCoreScriptNames, listCoreScriptNames,
listInstalledPlugins, listInstalledPlugins,
resolveScriptRef, resolveScriptRef,
@@ -29,8 +30,9 @@ 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 { getAppVersion } from "../../app-version.js";
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js"; import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
import { pluginScriptRef } from "../../plugin-manifest.js"; import { parsePluginScriptRef, pluginScriptRef } from "../../plugin-manifest.js";
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js"; import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
/** /**
* @param {{ referencedScripts: () => Set<string> }} registry * @param {{ referencedScripts: () => Set<string> }} registry
@@ -250,6 +252,40 @@ export default function scriptsPluginFactory(registry) {
} }
}); });
fastify.post("/scripts/:name/duplicate", async (req, reply) => {
const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name,
);
const body = /** @type {{ id?: string, description?: string }} */ (
req.body ?? {}
);
if (typeof body.id !== "string" || !body.id.trim()) {
return reply.code(400).send({ error: "id is required" });
}
const parsed = parsePluginScriptRef(rawName);
if (!parsed) {
return reply
.code(400)
.send({ error: "name must be a plugin ref (plugin/<id>)" });
}
try {
const installed = duplicatePlugin(parsed.id, body.id.trim(), {
description: body.description,
});
clearScriptCache();
return reply.code(201).send({
...installed,
restartNeeded: true,
warning:
"Plugins run as the JerapahFlow process user. Review code before install.",
});
} catch (err) {
return reply
.code(/** @type {any} */ (err).statusCode ?? 500)
.send({ error: err instanceof Error ? err.message : String(err) });
}
});
fastify.post("/scripts/:name/dry-run", async (req, reply) => { fastify.post("/scripts/:name/dry-run", async (req, reply) => {
const rawName = decodeURIComponent( const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name, /** @type {{ name: string }} */ (req.params).name,
@@ -258,7 +294,7 @@ export default function scriptsPluginFactory(registry) {
req.body ?? {} req.body ?? {}
); );
let owner = "local"; let owner = DEFAULT_OWNER;
if (body.owner != null && body.owner !== "") { if (body.owner != null && body.owner !== "") {
try { try {
owner = fsStore.assertOwner(String(body.owner)); owner = fsStore.assertOwner(String(body.owner));
@@ -304,6 +340,7 @@ export default function scriptsPluginFactory(registry) {
owner, owner,
workflowKey: "dry-run", workflowKey: "dry-run",
context: incomingContext, context: incomingContext,
data: incomingData,
}, },
); );
const ctx = { const ctx = {
@@ -365,6 +402,7 @@ export default function scriptsPluginFactory(registry) {
owner, owner,
workflowKey: "dry-run", workflowKey: "dry-run",
context: incomingContext, context: incomingContext,
data: incomingData,
}); });
const ctx = { const ctx = {
data: incomingData, data: incomingData,
+3 -3
View File
@@ -76,6 +76,7 @@ function triggerSummary(owner, workflow, nameById) {
schedule: t?.schedule ?? null, schedule: t?.schedule ?? null,
onConsecutiveFailures: t?.onConsecutiveFailures ?? null, onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
onFailureWorkflow: t?.onFailureWorkflow ?? null, onFailureWorkflow: t?.onFailureWorkflow ?? null,
disableOnConsecutiveFailures: t?.disableOnConsecutiveFailures === true,
auth: isHttp ? authLabel(t?.auth, nameById) : null, auth: isHttp ? authLabel(t?.auth, nameById) : null,
}; };
}); });
@@ -705,16 +706,15 @@ export default function workflowsPluginFactory(registry) {
return reply.code(err.statusCode ?? 400).send({ error: err.message }); return reply.code(err.statusCode ?? 400).send({ error: err.message });
} }
const raw = fsStore.readWorkflowYaml(owner, file); const raw = fsStore.readWorkflowYaml(owner, file);
if (raw == null) {
return reply.code(404).send({ error: "workflow not found" });
}
let name = null; let name = null;
if (raw != null) {
try { try {
const parsed = yaml.parse(raw); const parsed = yaml.parse(raw);
name = parsed?.name ?? null; name = parsed?.name ?? null;
} catch { } catch {
// ignore // ignore
} }
}
try { try {
const item = await moveWorkflowToTrash({ const item = await moveWorkflowToTrash({
workflowId: workflowIdFromFile(file), workflowId: workflowIdFromFile(file),
-7
View File
@@ -31,7 +31,6 @@ import {
getRedisUrlForLog, getRedisUrlForLog,
} from "./workflow-queue.js"; } from "./workflow-queue.js";
import { purgeExpiredTrash } from "./workflow-trash.js"; import { purgeExpiredTrash } from "./workflow-trash.js";
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
import { import {
getConfigGeneration, getConfigGeneration,
startHeartbeatLoop, startHeartbeatLoop,
@@ -129,12 +128,6 @@ export async function startApp(opts = {}) {
} }
}); });
try {
migrateLegacyWorkflowsIfNeeded();
} catch (err) {
log.warn({ err }, "legacy workflow migrate failed");
}
const registry = createRegistry(server, { const registry = createRegistry(server, {
queue: workflowQueue, queue: workflowQueue,
// Cron + HTTP triggers enqueue jobs; only the API process may own them. // Cron + HTTP triggers enqueue jobs; only the API process may own them.
+86 -86
View File
@@ -2,7 +2,7 @@ import { migrate, db } from "../db.js";
import { upsertSecret, deleteSecret } from "../secrets-store.js"; import { upsertSecret, deleteSecret } from "../secrets-store.js";
import { deleteVariable, upsertVariable } from "../variables-store.js"; import { deleteVariable, upsertVariable } from "../variables-store.js";
import { Secret } from "../secret-value.js"; import { Secret } from "../secret-value.js";
import { parseConfigRef, resolveConfigRefs } from "../config-refs.js"; import { resolveConfigRefs } from "../config-refs.js";
await migrate(); await migrate();
@@ -23,126 +23,138 @@ async function assertRejects(fn, match) {
throw new Error(`expected to reject (${match ?? "any error"})`); throw new Error(`expected to reject (${match ?? "any error"})`);
} }
const owner = "default"; const owner = "config_refs_smoke_owner";
const workflowKey = "default/config-refs-smoke.yaml"; const ctx = { owner, workflowKey: `${owner}/config-refs-smoke.yaml`, context: {}, data: {} };
const ctx = { owner, workflowKey, context: {} };
function assertParse(value, expected) {
const got = parseConfigRef(value);
if (expected == null) {
assert(got == null, `expected null parse for ${JSON.stringify(value)}, got ${JSON.stringify(got)}`);
return;
}
assert(got != null, `expected parse for ${JSON.stringify(value)}`);
assert(got.kind === expected.kind, `kind ${got.kind} !== ${expected.kind}`);
assert(got.name === expected.name, `name ${JSON.stringify(got.name)} !== ${JSON.stringify(expected.name)}`);
}
assertParse("password123", null);
assertParse("$FOO_bar", null);
assertParse("$SECRET", null);
assertParse(" password123 ", null);
assertParse("$SECRET_zte_modem_password", { kind: "secret", name: "zte_modem_password" });
assertParse(" $SECRET_zte_modem_password ", { kind: "secret", name: "zte_modem_password" });
assertParse("Bearer $SECRET_x", null);
assertParse("$KV_modem password", null);
assertParse("$VAR_ntfy_url", { kind: "var", name: "ntfy_url" });
assertParse("$CONTEXT_token", { kind: "context", name: "token" });
assertParse("$SECRET_", { kind: "secret", name: "" });
assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" });
{ {
const literal = await resolveConfigRefs("password123", ctx); const literal = await resolveConfigRefs("password123", ctx);
assert(literal === "password123", "literal passthrough"); assert(literal === "password123", "literal passthrough");
const unknown = await resolveConfigRefs("$FOO_bar", ctx); const unknown = await resolveConfigRefs("$FOO_bar", ctx);
assert(unknown === "$FOO_bar", "$FOO_bar stays literal"); assert(unknown === "$FOO_bar", "$FOO_bar stays literal");
const kvLiteral = await resolveConfigRefs("$KV_modem_password", ctx);
assert(kvLiteral === "$KV_modem_password", "$KV_ stays literal");
const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx); const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx);
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal"); assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
const number = await resolveConfigRefs(42, ctx); const number = await resolveConfigRefs(42, ctx);
assert(number === 42, "number passthrough"); assert(number === 42, "number passthrough");
} }
const created = [];
try {
const secret = await upsertSecret({ const secret = await upsertSecret({
owner, owner,
name: "config_refs_smoke_token", name: "config_refs_smoke_token",
value: "s3cret-ok", value: "s3cret-ok",
}); });
created.push(["secret", secret.id]);
const varUrl = await upsertVariable({ const varUrl = await upsertVariable({
owner, owner,
name: "config_refs_smoke_url", name: "config_refs_smoke_url",
type: "string", type: "string",
value: "https://example.test", value: "https://example.test",
}); });
created.push(["var", varUrl.id]);
const varRetry = await upsertVariable({ const varRetry = await upsertVariable({
owner, owner,
name: "config_refs_smoke_retry", name: "config_refs_smoke_retry",
type: "number", type: "number",
value: 3, value: 3,
}); });
created.push(["var", varRetry.id]);
const varDebug = await upsertVariable({ const varDebug = await upsertVariable({
owner, owner,
name: "config_refs_smoke_debug", name: "config_refs_smoke_debug",
type: "boolean", type: "boolean",
value: false, value: false,
}); });
created.push(["var", varDebug.id]);
try {
{ {
const resolved = await resolveConfigRefs("$SECRET_config_refs_smoke_token", ctx); const resolved = await resolveConfigRefs("{{ secrets.config_refs_smoke_token }}", ctx);
assert(resolved === "s3cret-ok", "secret resolve"); assert(resolved === "s3cret-ok", "secret whole-value");
} }
{ {
const resolved = await resolveConfigRefs(" $SECRET_config_refs_smoke_token ", ctx); const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_url }}", ctx);
assert(resolved === "s3cret-ok", "secret resolve trimmed");
}
{
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_url", ctx);
assert(resolved === "https://example.test", "var string"); assert(resolved === "https://example.test", "var string");
} }
{ {
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_retry", ctx); const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_retry }}", ctx);
assert(resolved === 3, "var number stays number"); assert(resolved === 3, "var number keeps type");
assert(typeof resolved === "number", "var number type");
} }
{ {
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_debug", ctx); const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_debug }}", ctx);
assert(resolved === false, "var boolean stays false"); assert(resolved === false, "var boolean keeps type");
assert(typeof resolved === "boolean", "var boolean type");
} }
{ {
const resolved = await resolveConfigRefs("$CONTEXT_token", { const resolved = await resolveConfigRefs("{{ context.token }}", {
...ctx, ...ctx,
context: { token: "ctx-token-ok" }, context: { token: "ctx-token-ok" },
}); });
assert(resolved === "ctx-token-ok", "context string"); assert(resolved === "ctx-token-ok", "context string");
} }
{ {
const resolved = await resolveConfigRefs("$CONTEXT_n", { const resolved = await resolveConfigRefs("{{ context.n }}", {
...ctx, ...ctx,
context: { n: 7 }, context: { n: 7 },
}); });
assert(resolved === "7", "context number stringify"); assert(resolved === 7, "context number keeps type");
} }
{ {
const wrapped = new Secret("wrapped-secret-ok"); const wrapped = new Secret("wrapped-secret-ok");
const resolved = await resolveConfigRefs("$CONTEXT_tok", { const resolved = await resolveConfigRefs("{{ context.tok }}", {
...ctx, ...ctx,
context: { tok: wrapped }, context: { tok: wrapped },
}); });
assert(resolved === "wrapped-secret-ok", "context Secret unwrap"); assert(resolved === "wrapped-secret-ok", "context Secret unwrap");
} }
{
const resolved = await resolveConfigRefs("{{ context.user }}", {
...ctx,
context: { user: { id: "u1", role: "admin" } },
});
assert(
resolved && typeof resolved === "object" && resolved.id === "u1",
"whole-value object pass-through",
);
}
{
const resolved = await resolveConfigRefs("{{ context.user.id }}", {
...ctx,
context: { user: { id: "nested-id" } },
});
assert(resolved === "nested-id", "nested context path");
}
{
const resolved = await resolveConfigRefs("{{ data.items.0.id }}", {
...ctx,
data: { items: [{ id: "row-0" }] },
});
assert(resolved === "row-0", "array index path");
}
{
const resolved = await resolveConfigRefs(
"{{ vars.config_refs_smoke_url }}/{{ data.channel }}",
{ ...ctx, data: { channel: "alerts" } },
);
assert(resolved === "https://example.test/alerts", "concatenation");
}
{
const resolved = await resolveConfigRefs("Bearer {{ context.token }}", {
...ctx,
context: { token: "abc" },
});
assert(resolved === "Bearer abc", "mixed string");
}
{ {
const nested = await resolveConfigRefs( const nested = await resolveConfigRefs(
{ {
url: "$VAR_config_refs_smoke_url", url: "{{ vars.config_refs_smoke_url }}",
retry: "$VAR_config_refs_smoke_retry", retry: "{{ vars.config_refs_smoke_retry }}",
debug: "$VAR_config_refs_smoke_debug", debug: "{{ vars.config_refs_smoke_debug }}",
password: "$SECRET_config_refs_smoke_token", password: "{{ secrets.config_refs_smoke_token }}",
headers: { Authorization: "$KV_modem_password" }, headers: { Authorization: "$KV_modem_password" },
extra: ["$CONTEXT_token", "plain"], extra: ["{{ context.token }}", "plain"],
}, },
{ ...ctx, context: { token: "ctx-token-ok" } }, { ...ctx, context: { token: "ctx-token-ok" } },
); );
@@ -155,59 +167,47 @@ try {
assert(nested.extra[1] === "plain", "nested array literal"); assert(nested.extra[1] === "plain", "nested array literal");
} }
const data = { password: "$SECRET_config_refs_smoke_token" }; const data = { password: "{{ secrets.config_refs_smoke_token }}" };
const config = { password: "$SECRET_config_refs_smoke_token" }; const config = { password: "{{ secrets.config_refs_smoke_token }}" };
const resolvedConfig = await resolveConfigRefs(config, ctx); const resolvedConfig = await resolveConfigRefs(config, ctx);
assert(resolvedConfig.password === "s3cret-ok", "config resolved"); assert(resolvedConfig.password === "s3cret-ok", "config resolved");
assert(data.password === "$SECRET_config_refs_smoke_token", "data not walked"); assert(data.password === "{{ secrets.config_refs_smoke_token }}", "data not walked");
assert(config.password === "$SECRET_config_refs_smoke_token", "input config not mutated"); assert(config.password === "{{ secrets.config_refs_smoke_token }}", "input config not mutated");
await assertRejects( await assertRejects(
() => resolveConfigRefs("$SECRET_does_not_exist_xyz", ctx), () => resolveConfigRefs("{{ secrets.does_not_exist_xyz }}", ctx),
'secret "does_not_exist_xyz" not found', 'secret "does_not_exist_xyz" not found',
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$SECRET_not valid", ctx), () => resolveConfigRefs("{{ context.missing }}", ctx),
"invalid secret name", "path not found",
);
await assertRejects(
() => resolveConfigRefs("$SECRET_", ctx),
"invalid secret name",
);
await assertRejects(
() => resolveConfigRefs("$CONTEXT_missing", ctx),
'context "missing" not found',
); );
await assertRejects( await assertRejects(
() => () =>
resolveConfigRefs("$CONTEXT_obj", { resolveConfigRefs("Bearer {{ context.obj }}", {
...ctx, ...ctx,
context: { obj: { a: 1 } }, context: { obj: { a: 1 } },
}), }),
'context "obj" is not a scalar', "not a scalar",
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$CONTEXT_", ctx), () => resolveConfigRefs("{{ vars }}", ctx),
"empty context key", "empty var name",
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx), () => resolveConfigRefs("{{ title }}", ctx),
'variable "does_not_exist_xyz" not found', "unknown root",
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$VAR_not valid", ctx), () => resolveConfigRefs("{{ context.__proto__.x }}", ctx),
"invalid variable name", "forbidden path segment",
);
await assertRejects(
() => resolveConfigRefs("$VAR_", ctx),
"empty variable name",
); );
} finally { } finally {
await deleteSecret(secret.id); for (const [kind, id] of created.reverse()) {
await deleteVariable(varUrl.id); if (kind === "secret") await deleteSecret(id);
await deleteVariable(varRetry.id); else await deleteVariable(id);
await deleteVariable(varDebug.id); }
await db.destroy();
} }
console.log("config-refs smoke test passed"); console.log("config-refs smoke test passed");
await db.destroy();
+23
View File
@@ -2,6 +2,7 @@
* Smoke: core vs plugin scripts, fork, example install, resolve, run. * Smoke: core vs plugin scripts, fork, example install, resolve, run.
* *
* Run: * Run:
* JFLOW_DATA_DIR=packages/server/data \
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \ * JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \ * JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
* node packages/server/test/plugins-smoke.js * node packages/server/test/plugins-smoke.js
@@ -12,6 +13,7 @@ import { migrate, db } from "../db.js";
import { getAppVersion, satisfiesRange } from "../app-version.js"; import { getAppVersion, satisfiesRange } from "../app-version.js";
import { import {
forkCoreScript, forkCoreScript,
duplicatePlugin,
resolveScriptRef, resolveScriptRef,
uninstallPlugin, uninstallPlugin,
listInstalledPlugins, listInstalledPlugins,
@@ -82,6 +84,26 @@ async function main() {
); );
assert.equal(blankRun.output.ok, true); assert.equal(blankRun.output.ok, true);
const duplicated = duplicatePlugin("blank-smoke", "blank-smoke-copy");
assert.equal(duplicated.scriptRef, "plugin/blank-smoke-copy");
clearScriptCache();
assert.equal(resolveScriptRef("plugin/blank-smoke-copy").kind, "plugin");
const dupRun = await runScript(
"plugin/blank-smoke-copy",
{ data: 1, context: {}, config: null },
{ log: silent, workflowName: "smoke", owner: "default" },
);
assert.equal(dupRun.output.ok, true);
let hitDupSelf = false;
try {
duplicatePlugin("blank-smoke", "blank-smoke");
} catch (err) {
hitDupSelf = true;
assert.match(String(err.message), /itself/);
}
assert.equal(hitDupSelf, true);
let hit = false; let hit = false;
try { try {
forkCoreScript("ntfy.js", "ntfy"); forkCoreScript("ntfy.js", "ntfy");
@@ -101,6 +123,7 @@ async function main() {
uninstallPlugin("jsonata-smoke-fork"); uninstallPlugin("jsonata-smoke-fork");
uninstallPlugin("blank-smoke"); uninstallPlugin("blank-smoke");
uninstallPlugin("blank-smoke-copy");
uninstallPlugin("get-current-time"); uninstallPlugin("get-current-time");
console.log("plugins-smoke: ok"); console.log("plugins-smoke: ok");
+2 -2
View File
@@ -71,11 +71,11 @@ const created = await upsertProfile({
owner, owner,
name, name,
script: "ntfy.js", script: "ntfy.js",
config: { url: "$VAR_ntfy_channel" }, config: { url: "{{ vars.ntfy_channel }}" },
description: "smoke", description: "smoke",
}); });
assert(created.name === name, "created"); assert(created.name === name, "created");
assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip"); assert(created.config.url === "{{ vars.ntfy_channel }}", "config roundtrip");
assert(created.script === "ntfy.js", "script locked on profile"); assert(created.script === "ntfy.js", "script locked on profile");
const fetched = await getProfilePlain(owner, name); const fetched = await getProfilePlain(owner, name);
@@ -136,6 +136,18 @@ const warnings = collectWorkflowWarnings(
); );
assert.ok(warnings.warnings.some((w) => w.code === "unknown_script")); assert.ok(warnings.warnings.some((w) => w.code === "unknown_script"));
const ghostFile = newWorkflowFilename();
fsStore.writeRegisters(owner, [file, ghostFile]);
const ghostTrash = await moveWorkflowToTrash({
workflowId: workflowIdFromFile(ghostFile),
owner,
file: ghostFile,
name: null,
});
assert.equal(ghostTrash, null);
assert.ok(!fsStore.readRegisters(owner).includes(ghostFile));
assert.ok(fsStore.readRegisters(owner).includes(file));
const trashed = await moveWorkflowToTrash({ const trashed = await moveWorkflowToTrash({
workflowId, workflowId,
owner, owner,
+19 -5
View File
@@ -47,12 +47,18 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
const threshold = Number(spec.onConsecutiveFailures); const threshold = Number(spec.onConsecutiveFailures);
const workflowName = onFailureWorkflowName(spec); const workflowName = onFailureWorkflowName(spec);
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) { const disableOnConsecutiveFailures = isDisableOnConsecutiveFailures(spec);
if (
!Number.isFinite(threshold) ||
threshold < 1 ||
(workflowName.length === 0 && !disableOnConsecutiveFailures)
) {
return null; return null;
} }
return { return {
threshold: Math.floor(threshold), threshold: Math.floor(threshold),
workflowName, workflowName: workflowName.length > 0 ? workflowName : null,
disableOnConsecutiveFailures,
}; };
} }
return null; return null;
@@ -66,6 +72,13 @@ function onFailureWorkflowName(trigger) {
return typeof value === "string" ? value.trim() : ""; return typeof value === "string" ? value.trim() : "";
} }
/**
* @param {Record<string, unknown>} trigger
*/
export function isDisableOnConsecutiveFailures(trigger) {
return trigger?.disableOnConsecutiveFailures === true;
}
/** /**
* @param {unknown} workflow * @param {unknown} workflow
*/ */
@@ -81,12 +94,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
const hasThreshold = const hasThreshold =
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== ""; trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
const hasWorkflow = onFailureWorkflowName(trigger).length > 0; const hasWorkflow = onFailureWorkflowName(trigger).length > 0;
const hasDisable = isDisableOnConsecutiveFailures(trigger);
if (!hasThreshold && !hasWorkflow) continue; if (!hasThreshold && !hasWorkflow && !hasDisable) continue;
if (!hasThreshold || !hasWorkflow) { if (!hasThreshold || (!hasWorkflow && !hasDisable)) {
const err = new Error( const err = new Error(
"onConsecutiveFailures and onFailureWorkflow must both be set on a trigger", "onConsecutiveFailures requires onFailureWorkflow and/or disableOnConsecutiveFailures",
); );
err.statusCode = 400; err.statusCode = 400;
throw err; throw err;
+116
View File
@@ -0,0 +1,116 @@
/**
* Production UI server (:8500).
* Serves packages/web/dist and proxies /api, /ops, /admin, /u like Vite in dev.
* Always-on — survives Ops stop of jflow-http.
*/
import fs from "fs";
import fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { log } from "./logger.js";
import { WEB_DIST } from "./paths.js";
import {
controlOrigin,
httpOrigin,
proxyToOrigin,
} from "./ops-proxy.js";
if (!fs.existsSync(WEB_DIST)) {
log.error(
{ WEB_DIST },
"web dist missing — run `pnpm build` before starting the UI server",
);
process.exit(1);
}
const port = Number(process.env.JFLOW_UI_PORT ?? 8500);
const control = controlOrigin();
const http = httpOrigin();
const server = fastify({ loggerInstance: log });
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function proxyApi(req, reply) {
const url = req.raw.url ?? "";
// Match Vite: /api/auth → control (login works when HTTP is stopped).
if (url === "/api/auth" || url.startsWith("/api/auth/") || url.startsWith("/api/auth?")) {
return proxyToOrigin(req, reply, control, {
unreachableMessage: "control plane unreachable",
});
}
return proxyToOrigin(req, reply, http, {
unreachableMessage: "HTTP API unreachable",
});
}
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function proxyOps(req, reply) {
return proxyToOrigin(req, reply, control, {
unreachableMessage: "control plane unreachable",
});
}
/**
* @param {import("fastify").FastifyRequest} req
* @param {import("fastify").FastifyReply} reply
*/
async function proxyHttp(req, reply) {
return proxyToOrigin(req, reply, http, {
unreachableMessage: "HTTP API unreachable",
});
}
server.all("/api", proxyApi);
server.all("/api/*", proxyApi);
server.all("/ops", proxyOps);
server.all("/ops/*", proxyOps);
server.all("/admin", proxyHttp);
server.all("/admin/*", proxyHttp);
server.all("/u", proxyHttp);
server.all("/u/*", proxyHttp);
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");
});
async function shutdown() {
try {
await server.close();
} catch (err) {
log.error({ err }, "web-server shutdown error");
}
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
try {
await server.listen({ host: "0.0.0.0", port });
log.info(
{ port, WEB_DIST, control, http },
"UI server listening (static + proxy)",
);
} catch (err) {
log.error({ err }, "failed to start UI server");
process.exit(1);
}
+9 -3
View File
@@ -1,4 +1,4 @@
import { HTTP_METHODS } from "@jerapah-flow/shared"; import { HTTP_METHODS, DEFAULT_OWNER } from "@jerapah-flow/shared";
import { import {
checkAnyHttpAuth, checkAnyHttpAuth,
resolveAuthMechanisms, resolveAuthMechanisms,
@@ -85,8 +85,14 @@ export function createHttpTriggerHandler({
return async function dispatchHttpTrigger(req, reply) { return async function dispatchHttpTrigger(req, reply) {
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? ""; const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`; const url = `/u/${String(wildcard).replace(/^\/+/, "")}`;
// Compat: leftover webhooks still hitting /u/default/... after owner rename.
const compatUrl = url.startsWith("/u/default/")
? `/u/${DEFAULT_OWNER}/${url.slice("/u/default/".length)}`
: url === "/u/default"
? `/u/${DEFAULT_OWNER}`
: url;
const method = String(req.method ?? "GET").toUpperCase(); const method = String(req.method ?? "GET").toUpperCase();
const routeKey = `${method} ${url}`; const routeKey = `${method} ${compatUrl}`;
const mapped = httpRoutes.get(routeKey); const mapped = httpRoutes.get(routeKey);
if (!mapped) { if (!mapped) {
@@ -104,7 +110,7 @@ export function createHttpTriggerHandler({
if (t?.type !== "HTTP") return false; if (t?.type !== "HTTP") return false;
const m = String(t.method ?? "POST").toUpperCase(); const m = String(t.method ?? "POST").toUpperCase();
const p = namespacedPath(entry.owner, t.path); const p = namespacedPath(entry.owner, t.path);
return m === method && p === url; return m === method && p === compatUrl;
}) ?? mapped.trigger; }) ?? mapped.trigger;
if ( if (
-51
View File
@@ -1,51 +0,0 @@
import fs from "fs";
import path from "path";
import { LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR } from "./paths.js";
import { log } from "./logger.js";
/**
* Recursively copy a directory.
* @param {string} src
* @param {string} dest
*/
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(from, to);
else fs.copyFileSync(from, to);
}
}
/**
* True when WORKFLOWS_DIR has no owner subdirectories.
* @param {string} dir
*/
function isEmptyWorkflowsDir(dir) {
if (!fs.existsSync(dir)) return true;
const entries = fs.readdirSync(dir, { withFileTypes: true });
return !entries.some((e) => e.isDirectory());
}
/**
* One-shot: copy packages/server/workflows → data/workflows when the new
* store is empty and the legacy tree still exists.
* Does not copy from examples/workflows.
*/
export function migrateLegacyWorkflowsIfNeeded() {
if (!isEmptyWorkflowsDir(WORKFLOWS_DIR)) return false;
if (!fs.existsSync(LEGACY_WORKFLOWS_DIR)) return false;
const legacyEntries = fs.readdirSync(LEGACY_WORKFLOWS_DIR, {
withFileTypes: true,
});
if (!legacyEntries.some((e) => e.isDirectory())) return false;
fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
copyDir(LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR);
log.info(
{ from: LEGACY_WORKFLOWS_DIR, to: WORKFLOWS_DIR },
"migrated legacy workflows into instance store",
);
return true;
}
+26 -6
View File
@@ -62,29 +62,49 @@ export async function isInTrash(owner, file) {
return Boolean(row); return Boolean(row);
} }
function unregisterWorkflow(owner, file) {
const registered = fsStore.readRegisters(owner).filter((f) => f !== file);
fsStore.writeRegisters(owner, registered);
}
/** /**
* Soft-delete: move YAML to trash dir, unregister, keep revision history. * Soft-delete: unregister, move YAML to trash when present, keep revision history.
* Missing YAML (ghost register entries) is still unregistered so it leaves the list.
* @param {{ * @param {{
* workflowId: string, * workflowId: string,
* owner: string, * owner: string,
* file: string, * file: string,
* name?: string | null, * name?: string | null,
* }} opts * }} opts
* @returns {Promise<ReturnType<typeof rowToItem> | null>} trash item, or null if there was no file to keep
*/ */
export async function moveWorkflowToTrash(opts) { export async function moveWorkflowToTrash(opts) {
unregisterWorkflow(opts.owner, opts.file);
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file); const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
if (!fs.existsSync(sourcePath)) { if (!fs.existsSync(sourcePath)) {
const err = new Error("workflow not found"); const existing = await db("workflow_trash")
err.statusCode = 404; .where({ owner: opts.owner, file: opts.file })
throw err; .first();
return existing ? rowToItem(existing) : null;
} }
const trashPath = trashFilePath(opts.owner, opts.file); const trashPath = trashFilePath(opts.owner, opts.file);
fs.mkdirSync(path.dirname(trashPath), { recursive: true }); fs.mkdirSync(path.dirname(trashPath), { recursive: true });
fs.renameSync(sourcePath, trashPath); fs.renameSync(sourcePath, trashPath);
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file); const existing = await db("workflow_trash")
fsStore.writeRegisters(opts.owner, registered); .where({ owner: opts.owner, file: opts.file })
.first();
if (existing) {
await db("workflow_trash").where({ id: existing.id }).update({
workflow_id: opts.workflowId,
name: opts.name ?? existing.name ?? null,
deleted_at: nowIso(),
trash_path: trashPath,
});
return rowToItem(await db("workflow_trash").where({ id: existing.id }).first());
}
const id = randomUUID(); const id = randomUUID();
const deleted_at = nowIso(); const deleted_at = nowIso();
+1
View File
@@ -2,3 +2,4 @@ export { isPlainObject } from "./is-plain-object.js";
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js"; export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js";
export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js"; export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js";
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js"; export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js";
export { DEFAULT_OWNER } from "./tenant.js";
+2
View File
@@ -0,0 +1,2 @@
/** Default owner folder for new resources (latent tenant id). */
export const DEFAULT_OWNER = "local";
+17
View File
@@ -59,6 +59,23 @@ export function useForkScript() {
}); });
} }
export function useDuplicatePlugin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ name, id, description }) =>
(
await api.post(`/scripts/${encodeURIComponent(name)}/duplicate`, {
id,
description,
})
).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scripts"] });
qc.invalidateQueries({ queryKey: ["ops-status"] });
},
});
}
export function useInstallPlugin() { export function useInstallPlugin() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
@@ -66,7 +66,8 @@ export function SecretEditorModal({ mode, initial, onClose, onSaved }) {
</label> </label>
<p className="text-xs opacity-60"> <p className="text-xs opacity-60">
Values are encrypted at rest and never shown again after save. Values shorter than 8 Values are encrypted at rest and never shown again after save. Values shorter than 8
characters are not redacted from logs. characters are not redacted from logs. In workflows use{" "}
<span className="font-mono">{"{{ secrets.name }}"}</span> (quote in YAML).
</p> </p>
{upsert.isError ? ( {upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p> <p className="text-error text-sm">{errorMessage(upsert.error)}</p>
@@ -129,7 +129,9 @@ export function VariableEditorModal({ mode, initial, onClose, onSaved }) {
</label> </label>
<p className="text-xs opacity-60"> <p className="text-xs opacity-60">
Stored in plaintext. Use Secrets for credentials. In workflows use{" "} Stored in plaintext. Use Secrets for credentials. In workflows use{" "}
<span className="font-mono">$VAR_name</span> as a whole field. <span className="font-mono">{"{{ vars.name }}"}</span> (quote strings that
start with <span className="font-mono">{"{"}</span>). Nested:{" "}
<span className="font-mono">{"{{ context.user.id }}"}</span>.
</p> </p>
{formError ? <p className="text-error text-sm">{formError}</p> : null} {formError ? <p className="text-error text-sm">{formError}</p> : null}
{upsert.isError ? ( {upsert.isError ? (
@@ -4,22 +4,27 @@ import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
import { useVariables } from "../../api/hooks.js"; import { useVariables } from "../../api/hooks.js";
const VAR_PEEK_MAX = 48; const VAR_PEEK_MAX = 48;
const MUSTACHE_RE =
/\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
const CONFIG_REF_PREFIXES = [ /**
{ prefix: "$SECRET_", label: "secret" }, * @param {unknown} value
{ prefix: "$CONTEXT_", label: "context" }, * @returns {{
{ prefix: "$VAR_", label: "variable" }, * kind: "mustache",
]; * path?: string,
* root?: string,
* name?: string,
* } | null}
*/
function describeConfigRef(value) { function describeConfigRef(value) {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
for (const { prefix, label } of CONFIG_REF_PREFIXES) { MUSTACHE_RE.lastIndex = 0;
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) { const match = MUSTACHE_RE.exec(trimmed);
return { label, name: trimmed.slice(prefix.length), kind: prefix }; if (!match) return null;
} const path = match[1];
} const root = path.split(".")[0];
return null; return { kind: "mustache", path, root, name: path.slice(root.length + 1) };
} }
function formatVarDisplay(value) { function formatVarDisplay(value) {
@@ -33,7 +38,7 @@ function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
return `${text.slice(0, Math.max(0, maxLen - 1))}…`; return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
} }
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */ /** Edit-time lookup for `{{ vars.name }}` against workflow owner (not a runtime guarantee). */
function lookupVariable(variables, owner, name) { function lookupVariable(variables, owner, name) {
const list = Array.isArray(variables) ? variables : []; const list = Array.isArray(variables) ? variables : [];
const match = list.find((v) => v.owner === owner && v.name === name); const match = list.find((v) => v.owner === owner && v.name === name);
@@ -62,7 +67,7 @@ function variablesDeepLink({ owner, name, missing }) {
export function ConfigRefHint({ value, owner }) { export function ConfigRefHint({ value, owner }) {
const ref = describeConfigRef(value); const ref = describeConfigRef(value);
const isVar = ref?.kind === "$VAR_"; const isVar = ref?.kind === "mustache" && ref.root === "vars" && Boolean(ref.name);
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar }); const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
@@ -73,9 +78,17 @@ export function ConfigRefHint({ value, owner }) {
if (!ref) return null; if (!ref) return null;
if (!isVar) { if (!isVar) {
const label =
ref.root === "secrets"
? "secret"
: ref.root === "context"
? "context"
: ref.root === "data"
? "data"
: "expression";
return ( return (
<p className="text-xs opacity-60"> <p className="text-xs opacity-60">
from {ref.label} <span className="font-mono">{ref.name}</span> from {label} <span className="font-mono">{ref.path}</span>
</p> </p>
); );
} }
@@ -143,10 +143,15 @@ function TriggerCardView({
function triggerSummary(trigger, owner) { function triggerSummary(trigger, owner) {
const type = trigger?.type; const type = trigger?.type;
const failure = /** @type {string[]} */
trigger.onConsecutiveFailures && trigger.onFailureWorkflow const failureParts = [];
? ` · onFailure@${trigger.onFailureWorkflow}` if (trigger.onConsecutiveFailures && trigger.onFailureWorkflow) {
: ""; failureParts.push(`onFailure@${trigger.onFailureWorkflow}`);
}
if (trigger.disableOnConsecutiveFailures) {
failureParts.push("auto-disable");
}
const failure = failureParts.length ? ` · ${failureParts.join(", ")}` : "";
if (type === "HTTP") { if (type === "HTTP") {
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`; return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`;
} }
@@ -383,6 +388,20 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
) : null} ) : null}
</FormSelect> </FormSelect>
</Field> </Field>
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={Boolean(trigger.disableOnConsecutiveFailures)}
disabled={disabled}
onChange={(e) =>
onChange({ ...trigger, disableOnConsecutiveFailures: e.target.checked })
}
/>
<span className="label-text">
Disable this workflow when the consecutive failure threshold is reached
</span>
</label>
</div> </div>
); );
} }
+1 -2
View File
@@ -1,2 +1 @@
/** Default owner folder for new resources (latent tenant id). */ export { DEFAULT_OWNER } from "@jerapah-flow/shared";
export const DEFAULT_OWNER = "local";
+15 -1
View File
@@ -171,6 +171,7 @@ export function newHttpTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -186,6 +187,7 @@ export function newCronTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -201,6 +203,7 @@ export function newWorkflowTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -303,9 +306,16 @@ function normalizeTrigger(raw) {
"unauthorized", "unauthorized",
"onConsecutiveFailures", "onConsecutiveFailures",
"onFailureWorkflow", "onFailureWorkflow",
"disableOnConsecutiveFailures",
]) ])
: type === "cron" : type === "cron"
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"]) ? new Set([
"type",
"schedule",
"onConsecutiveFailures",
"onFailureWorkflow",
"disableOnConsecutiveFailures",
])
: new Set(["type"]); : new Set(["type"]);
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const extra = {}; const extra = {};
@@ -323,6 +333,7 @@ function normalizeTrigger(raw) {
? "" ? ""
: String(raw.onConsecutiveFailures), : String(raw.onConsecutiveFailures),
onFailureWorkflow: readOnFailureWorkflow(raw), onFailureWorkflow: readOnFailureWorkflow(raw),
disableOnConsecutiveFailures: raw.disableOnConsecutiveFailures === true,
auth: Array.isArray(raw.auth) ? raw.auth : null, auth: Array.isArray(raw.auth) ? raw.auth : null,
response: typeof raw.response === "string" ? raw.response : "", response: typeof raw.response === "string" ? raw.response : "",
unauthorized: raw.unauthorized ?? null, unauthorized: raw.unauthorized ?? null,
@@ -377,6 +388,9 @@ function dumpFailureTriggerFields(t, out) {
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) { if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
out.onFailureWorkflow = t.onFailureWorkflow.trim(); out.onFailureWorkflow = t.onFailureWorkflow.trim();
} }
if (t.disableOnConsecutiveFailures === true) {
out.disableOnConsecutiveFailures = true;
}
} }
function dumpTrigger(t) { function dumpTrigger(t) {
+38
View File
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useCreatePlugin, useCreatePlugin,
useDuplicatePlugin,
useForkScript, useForkScript,
useSaveScript, useSaveScript,
useScript, useScript,
@@ -119,9 +120,11 @@ export function ScriptEditPage() {
const existing = useScript(name); const existing = useScript(name);
const save = useSaveScript(); const save = useSaveScript();
const fork = useForkScript(); const fork = useForkScript();
const duplicate = useDuplicatePlugin();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [contentReady, setContentReady] = useState(false); const [contentReady, setContentReady] = useState(false);
const [forkId, setForkId] = useState(""); const [forkId, setForkId] = useState("");
const [duplicateId, setDuplicateId] = useState("");
const isCore = existing.data?.kind === "core" || existing.data?.editable === false; const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
@@ -163,6 +166,21 @@ export function ScriptEditPage() {
); );
} }
function onDuplicate(e) {
e.preventDefault();
const id = normalizePluginId(duplicateId);
if (!id) return;
duplicate.mutate(
{ name, id },
{
onSuccess: (data) => {
notify.success("Duplicated — drain-restart recommended");
navigate(`/scripts/${encodeURIComponent(data.scriptRef)}/edit`);
},
},
);
}
if (existing.isLoading) { 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">
@@ -238,7 +256,27 @@ export function ScriptEditPage() {
<span className="text-error text-sm">{errorMessage(fork.error)}</span> <span className="text-error text-sm">{errorMessage(fork.error)}</span>
) : null} ) : null}
</form> </form>
) : (
<form onSubmit={onDuplicate} className="flex flex-wrap items-center gap-2">
<input
className="input input-sm font-mono w-56"
placeholder="duplicate id (e.g. my-plugin-copy)"
value={duplicateId}
onChange={(e) => setDuplicateId(e.target.value)}
/>
<button
type="submit"
className="btn btn-sm"
disabled={duplicate.isPending || !normalizePluginId(duplicateId)}
>
<LuCopy className="size-4" />
Duplicate plugin
</button>
{duplicate.isError ? (
<span className="text-error text-sm">{errorMessage(duplicate.error)}</span>
) : null} ) : null}
</form>
)}
<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 <CodeEditor
+77
View File
@@ -4,6 +4,7 @@ import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icon
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useDeleteScript, useDeleteScript,
useDuplicatePlugin,
useForkScript, useForkScript,
useInstallPlugin, useInstallPlugin,
useScripts, useScripts,
@@ -23,8 +24,11 @@ export function ScriptsPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [forkFor, setForkFor] = useState(null); const [forkFor, setForkFor] = useState(null);
const [forkId, setForkId] = useState(""); const [forkId, setForkId] = useState("");
const [duplicateFor, setDuplicateFor] = useState(null);
const [duplicateId, setDuplicateId] = useState("");
const del = useDeleteScript(); const del = useDeleteScript();
const fork = useForkScript(); const fork = useForkScript();
const duplicate = useDuplicatePlugin();
const install = useInstallPlugin(); const install = useInstallPlugin();
const { notify } = useNotifications(); const { notify } = useNotifications();
@@ -182,6 +186,7 @@ export function ScriptsPage() {
<LuCopy className="size-4" /> <LuCopy className="size-4" />
</button> </button>
) : ( ) : (
<>
<Link <Link
to={`/scripts/${encodeURIComponent(name)}/edit`} to={`/scripts/${encodeURIComponent(name)}/edit`}
className="btn btn-ghost btn-xs" className="btn btn-ghost btn-xs"
@@ -190,6 +195,24 @@ export function ScriptsPage() {
> >
<LuPencil className="size-4" /> <LuPencil className="size-4" />
</Link> </Link>
<button
type="button"
className="btn btn-ghost btn-xs"
title="Duplicate"
aria-label="Duplicate"
onClick={() => {
setDuplicateFor(name);
setDuplicateId(
String(name)
.replace(/^plugin\//i, "")
.replace(/\.js$/i, "")
.toLowerCase() + "-copy",
);
}}
>
<LuCopy className="size-4" />
</button>
</>
)} )}
{!isCore ? ( {!isCore ? (
<button <button
@@ -264,6 +287,60 @@ export function ScriptsPage() {
</dialog> </dialog>
) : null} ) : null}
{duplicateFor ? (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-semibold">Duplicate {duplicateFor}</h3>
<p className="text-sm opacity-70 py-2">
Creates <code>plugin/&lt;id&gt;</code> from this plugin.
</p>
<input
className="input input-bordered input-sm w-full font-mono"
value={duplicateId}
onChange={(e) => setDuplicateId(e.target.value)}
/>
{duplicate.isError ? (
<p className="text-error text-sm mt-2">{errorMessage(duplicate.error)}</p>
) : null}
<div className="modal-action">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setDuplicateFor(null)}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={duplicate.isPending || !duplicateId.trim()}
onClick={() =>
duplicate.mutate(
{ name: duplicateFor, id: duplicateId.trim() },
{
onSuccess: (data) => {
setDuplicateFor(null);
notify.success("Duplicated — drain-restart recommended");
navigate(
`/scripts/${encodeURIComponent(data.scriptRef)}/edit`,
);
},
},
)
}
>
Duplicate
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={() => setDuplicateFor(null)}>
close
</button>
</form>
</dialog>
) : null}
<ConfirmDialog <ConfirmDialog
open={Boolean(confirmDelete)} open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete}?` : ""} title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
+1 -1
View File
@@ -179,7 +179,7 @@ export function VariablesPage() {
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""} title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message={ message={
confirmDelete confirmDelete
? `This cannot be undone. Workflows that reference $VAR_${confirmDelete.name} will fail.` ? `This cannot be undone. Workflows that reference {{ vars.${confirmDelete.name} }} will fail.`
: "" : ""
} }
error={del.isError ? errorMessage(del.error) : null} error={del.isError ? errorMessage(del.error) : null}
+2 -2
View File
@@ -198,7 +198,7 @@ joplinHttp.meta = {
token: { token: {
type: "string", type: "string",
required: true, required: true,
description: "Bearer token ($SECRET_); Bearer prefix is added if missing", description: "Bearer token ({{ secrets.* }}); Bearer prefix is added if missing",
}, },
timeoutMs: { timeoutMs: {
type: "number", type: "number",
@@ -237,7 +237,7 @@ joplinHttp.meta = {
config: { config: {
url: "http://10.8.0.6:3030/notes", url: "http://10.8.0.6:3030/notes",
method: "GET", method: "GET",
token: "$SECRET_joplin_api_token", token: "{{ secrets.joplin_api_token }}",
timeoutMs: 60000, timeoutMs: 60000,
}, },
}, },
+1 -1
View File
@@ -77,7 +77,7 @@ importers:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
pm2: pm2:
specifier: ^6.0.13 specifier: 6.0.14
version: 6.0.14(supports-color@7.2.0) version: 6.0.14(supports-color@7.2.0)
rss-parser: rss-parser:
specifier: ^3.13.0 specifier: ^3.13.0
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Run the same PM2 binary control.js `require("pm2")` uses.
* A global `pm2` 7.x talking to an in-memory 6.x daemon pegs CPU on start.
*/
import { spawn, spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const require = createRequire(
path.join(root, "packages/server/package.json"),
);
const pm2Root = path.dirname(require.resolve("pm2/package.json"));
const pm2Bin = path.join(pm2Root, "bin/pm2");
const args = process.argv.slice(2);
function run(pm2Args, opts = {}) {
return spawnSync(process.execPath, [pm2Bin, ...pm2Args], {
cwd: root,
encoding: "utf8",
...opts,
});
}
const cmd = args[0];
if (cmd === "start" || cmd === "restart" || cmd === "reload") {
const probe = run(["ls"], { stdio: ["ignore", "pipe", "pipe"] });
const text = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
const mem = text.match(/In memory PM2 version:\s*(\S+)/);
const loc = text.match(/Local PM2 version:\s*(\S+)/);
if (mem && loc && mem[1] !== loc[1]) {
console.error(
`[jflow] PM2 daemon ${mem[1]} != CLI ${loc[1]}. Killing the daemon so control.js and the CLI share one version.`,
);
run(["kill"], { stdio: "inherit" });
}
}
const child = spawn(process.execPath, [pm2Bin, ...args], {
cwd: root,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 1);
});