Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20b26b4c7 | ||
|
|
f1cdb7ac68 | ||
|
|
3f0774813d | ||
|
|
5615d989fa | ||
|
|
e2e70a5aad |
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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 `{`).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -124,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
|
||||||
|
|
||||||
@@ -132,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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+162
-78
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+5
-38
@@ -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");
|
||||||
|
}
|
||||||
+9
-9
@@ -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",
|
||||||
@@ -48,16 +53,11 @@ export async function up(knex) {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
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
-13
@@ -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");
|
|
||||||
}
|
}
|
||||||
-18
@@ -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");
|
||||||
|
}
|
||||||
+3
-1
@@ -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)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,11 +13,12 @@
|
|||||||
"start:control": "node control.js",
|
"start:control": "node control.js",
|
||||||
"start:web": "node web-server.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:*",
|
||||||
|
|||||||
+39
-17
@@ -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");
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -511,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -706,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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 secret = await upsertSecret({
|
const created = [];
|
||||||
|
try {
|
||||||
|
const secret = await upsertSecret({
|
||||||
owner,
|
owner,
|
||||||
name: "config_refs_smoke_token",
|
name: "config_refs_smoke_token",
|
||||||
value: "s3cret-ok",
|
value: "s3cret-ok",
|
||||||
});
|
});
|
||||||
const varUrl = await upsertVariable({
|
created.push(["secret", secret.id]);
|
||||||
|
|
||||||
|
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",
|
||||||
});
|
});
|
||||||
const varRetry = await upsertVariable({
|
created.push(["var", varUrl.id]);
|
||||||
|
|
||||||
|
const varRetry = await upsertVariable({
|
||||||
owner,
|
owner,
|
||||||
name: "config_refs_smoke_retry",
|
name: "config_refs_smoke_retry",
|
||||||
type: "number",
|
type: "number",
|
||||||
value: 3,
|
value: 3,
|
||||||
});
|
});
|
||||||
const varDebug = await upsertVariable({
|
created.push(["var", varRetry.id]);
|
||||||
|
|
||||||
|
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();
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Default owner folder for new resources (latent tenant id). */
|
||||||
|
export const DEFAULT_OWNER = "local";
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
/** Default owner folder for new resources (latent tenant id). */
|
export { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||||
export const DEFAULT_OWNER = "local";
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/<id></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}?` : ""}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user