From 7456dece0026b2ec73995f521a3b6c000926148d Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 22 Aug 2026 18:09:22 +0700 Subject: [PATCH] feat(workflows): migrate legacy workflows and enhance example handling - Migrated legacy workflow files from `packages/server/workflows/` to `packages/server/data/workflows/` when the new store is empty. - Introduced new API endpoints to list and retrieve example workflows. - Added a dialog component for selecting example workflows when creating new workflows. - Updated workflow paths and configurations to reflect the new structure. - Enhanced the README and AGENTS.md documentation to clarify workflow management and usage. Co-authored-by: Nasyarobby Putra --- .gitignore | 6 +- AGENTS.md | 11 ++ README.md | 13 ++ .../workflows/comic-monkeyuser-to-ntfy.yaml | 27 +++ .../workflows/detect-example-changes.yaml | 22 +++ packages/server/package.json | 2 +- packages/server/paths.js | 11 +- packages/server/src/api/workflows.js | 21 +++ packages/server/start-app.js | 7 + packages/server/workflow-examples.js | 79 ++++++++ packages/server/workflow-migrate.js | 51 ++++++ .../server/workflows/default/registers.yaml | 1 + packages/web/src/api/hooks/workflows.js | 7 + .../components/NewWorkflowPresetDialog.jsx | 168 ++++++++++++++++++ packages/web/src/lib/workflow-doc.js | 8 +- packages/web/src/pages/WorkflowEditPage.jsx | 36 +++- plugins/get-current-time/jerapah-plugin.json | 8 + plugins/get-current-time/package.json | 7 + plugins/get-current-time/script.js | 34 ++++ 19 files changed, 504 insertions(+), 15 deletions(-) create mode 100644 examples/workflows/comic-monkeyuser-to-ntfy.yaml create mode 100644 examples/workflows/detect-example-changes.yaml create mode 100644 packages/server/workflow-examples.js create mode 100644 packages/server/workflow-migrate.js create mode 100644 packages/web/src/components/NewWorkflowPresetDialog.jsx create mode 100644 plugins/get-current-time/jerapah-plugin.json create mode 100644 plugins/get-current-time/package.json create mode 100644 plugins/get-current-time/script.js diff --git a/.gitignore b/.gitignore index 11d68b4..a71135d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,11 +10,11 @@ packages/web/dist # Personal/local scripts and workflows (not for the repo) debug-*.js -# Site-specific workflows (keep examples in workflows/default/) -packages/server/workflows/local/ +# Legacy live workflow tree (migrated to packages/server/data/workflows/) +packages/server/workflows/ # Plugin install staging and per-plugin deps plugins/.staging-* plugins/*/node_modules/ -.gitea/workflows/ \ No newline at end of file +.gitea/workflows/ diff --git a/AGENTS.md b/AGENTS.md index 62d0cb9..34ab958 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,17 @@ This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only). +## Workflows (instance data) + +Live workflows are **not** product source. They live under `packages/server/data/workflows//` (gitignored; override with `JFLOW_WORKFLOWS_DIR`). + +| Kind | In git? | Path | +|---|---|---| +| Live / personal YAML | No | `packages/server/data/workflows//` | +| 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). + ## Where things live | Kind | YAML `script` | Editable | Path | diff --git a/README.md b/README.md index a59c4f0..bb9f08f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,18 @@ The first account created becomes **admin**. Later accounts are created from Use - Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`. - `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins//`. +## Workflows (instance data vs examples) + +| Kind | Loaded by runner? | Location | +|---|---|---| +| **Live workflows** | Yes | `packages/server/data/workflows//` (gitignored) | +| **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. Prefer owner `local` for personal workflows. +- 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). +- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`. + ```bash # Smoke JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \ @@ -114,6 +126,7 @@ Desired state is stored in `packages/server/data/control-state.json` (generation | `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_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. | +| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). | | `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. | | `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. | diff --git a/examples/workflows/comic-monkeyuser-to-ntfy.yaml b/examples/workflows/comic-monkeyuser-to-ntfy.yaml new file mode 100644 index 0000000..ac6c20f --- /dev/null +++ b/examples/workflows/comic-monkeyuser-to-ntfy.yaml @@ -0,0 +1,27 @@ +name: Comic - monkeyuser to ntfy +description: | + Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel). +scripts: + - script: fetch-html.js + config: + url: https://www.monkeyuser.com/ + outputVar: comic + selector: .comic img + jsonata: | + {"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]} + - script: jsonata.js + config: + expression: | + { + "title": data.comic.title, + "message": data.comic.title, + "attach": data.comic.url + } + - script: fetch-binary.js + - script: ntfy.js + config: + url: $VAR_ntfy_channel +triggers: + - type: HTTP + method: POST + path: /comic-monkeyuser diff --git a/examples/workflows/detect-example-changes.yaml b/examples/workflows/detect-example-changes.yaml new file mode 100644 index 0000000..e9d3dc6 --- /dev/null +++ b/examples/workflows/detect-example-changes.yaml @@ -0,0 +1,22 @@ +name: detect example.com changes +description: | + Fetch example.com, fingerprint the response, and report whether it changed + since the previous run. Uses detect-url-changes with a transform that builds + a human-readable message into ctx.data.message. +scripts: + - script: detect-url-changes.js + config: + url: https://example.com/ + outputVar: message + transform: | + data.hasChanges + ? "example.com changed (fingerprint " & data.fingerprint & ")" + : "example.com unchanged since " & data.fingerprintAt +triggers: + - type: HTTP + method: POST + path: /detect-example + - type: cron + schedule: "* * * * *" + onConsecutiveFailures: 3 +enabled: false diff --git a/packages/server/package.json b/packages/server/package.json index 44d1c59..0c8e20c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,7 +13,7 @@ "start:control": "node control.js", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", - "test:workflow-history": "node test/workflow-history-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:profiles": "node test/profiles-smoke.js", "test:set-dry-run": "node test/set-dry-run-smoke.js" }, diff --git a/packages/server/paths.js b/packages/server/paths.js index f591164..3e8fd9c 100644 --- a/packages/server/paths.js +++ b/packages/server/paths.js @@ -3,8 +3,12 @@ import { fileURLToPath } from "url"; export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url)); export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts"); -export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows"); export const DATA_DIR = path.join(SERVER_ROOT, "data"); +/** Live instance workflows (not shipped in git). Override for tests. */ +export const WORKFLOWS_DIR = + 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). */ export const PLUGINS_DIR = process.env.JFLOW_PLUGINS_DIR ?? @@ -14,5 +18,10 @@ export const EXAMPLE_PLUGINS_DIR = path.resolve( SERVER_ROOT, "../../examples/plugins", ); +/** Example workflow YAML presets (not loaded by the runner). */ +export const EXAMPLE_WORKFLOWS_DIR = path.resolve( + SERVER_ROOT, + "../../examples/workflows", +); export const LOGS_DIR = path.join(SERVER_ROOT, "logs"); export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist"); diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index d5cae01..534f0aa 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -45,6 +45,11 @@ import { createWorkflowBackupBuffer, restoreWorkflowBackup, } from "../../workflow-backup.js"; +import { + listExampleWorkflows, + readExampleWorkflow, + assertExampleWorkflowId, +} from "../../workflow-examples.js"; /** * Reload this process and notify other HTTP/worker processes via Redis. @@ -246,6 +251,22 @@ export default function workflowsPluginFactory(registry) { return { owners: fsStore.listOwners() }; }); + fastify.get("/workflow-examples", async () => { + return { examples: listExampleWorkflows() }; + }); + + fastify.get("/workflow-examples/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + if (!assertExampleWorkflowId(id)) { + return reply.code(400).send({ error: "invalid example id" }); + } + const example = readExampleWorkflow(id); + if (!example) { + return reply.code(404).send({ error: "example not found" }); + } + return example; + }); + fastify.get("/workflows/trash", async () => { return { items: await listTrash() }; }); diff --git a/packages/server/start-app.js b/packages/server/start-app.js index 623ac9e..249d2d1 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -31,6 +31,7 @@ import { getRedisUrlForLog, } from "./workflow-queue.js"; import { purgeExpiredTrash } from "./workflow-trash.js"; +import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js"; import { getConfigGeneration, startHeartbeatLoop, @@ -128,6 +129,12 @@ export async function startApp(opts = {}) { } }); + try { + migrateLegacyWorkflowsIfNeeded(); + } catch (err) { + log.warn({ err }, "legacy workflow migrate failed"); + } + const registry = createRegistry(server, { queue: workflowQueue, // Cron + HTTP triggers enqueue jobs; only the API process may own them. diff --git a/packages/server/workflow-examples.js b/packages/server/workflow-examples.js new file mode 100644 index 0000000..fda497b --- /dev/null +++ b/packages/server/workflow-examples.js @@ -0,0 +1,79 @@ +import fs from "fs"; +import path from "path"; +import yaml from "yaml"; +import { EXAMPLE_WORKFLOWS_DIR } from "./paths.js"; + +/** + * @param {string} id + * @returns {string | null} safe basename without extension, or null if invalid + */ +export function assertExampleWorkflowId(id) { + if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(id)) { + return null; + } + return id; +} + +/** + * Absolute path to an example YAML, or null if missing/unsafe. + * @param {string} id + */ +export function exampleWorkflowPath(id) { + const safe = assertExampleWorkflowId(id); + if (!safe) return null; + const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, `${safe}.yaml`); + const resolved = path.resolve(filePath); + if ( + resolved !== EXAMPLE_WORKFLOWS_DIR && + !resolved.startsWith(EXAMPLE_WORKFLOWS_DIR + path.sep) + ) { + return null; + } + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + return null; + } + return resolved; +} + +/** + * @returns {{ id: string, name: string, description: string }[]} + */ +export function listExampleWorkflows() { + if (!fs.existsSync(EXAMPLE_WORKFLOWS_DIR)) return []; + return fs + .readdirSync(EXAMPLE_WORKFLOWS_DIR) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .sort() + .map((f) => { + const id = f.replace(/\.ya?ml$/i, ""); + const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, f); + let name = id; + let description = ""; + try { + const parsed = yaml.parse(fs.readFileSync(filePath, "utf8")) ?? {}; + if (typeof parsed.name === "string" && parsed.name.trim()) { + name = parsed.name.trim(); + } + if (parsed.description != null) { + description = String(parsed.description).trim(); + } + } catch { + // keep id as name + } + return { id, name, description }; + }); +} + +/** + * @param {string} id + * @returns {{ id: string, content: string } | null} + */ +export function readExampleWorkflow(id) { + const filePath = exampleWorkflowPath(id); + if (!filePath) return null; + const safe = assertExampleWorkflowId(id); + return { + id: /** @type {string} */ (safe), + content: fs.readFileSync(filePath, "utf8"), + }; +} diff --git a/packages/server/workflow-migrate.js b/packages/server/workflow-migrate.js new file mode 100644 index 0000000..b159de4 --- /dev/null +++ b/packages/server/workflow-migrate.js @@ -0,0 +1,51 @@ +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; +} diff --git a/packages/server/workflows/default/registers.yaml b/packages/server/workflows/default/registers.yaml index 339feee..4fdbdf1 100644 --- a/packages/server/workflows/default/registers.yaml +++ b/packages/server/workflows/default/registers.yaml @@ -3,3 +3,4 @@ scripts: - detect-example-changes.yaml - time-to-ntfy-example.yaml - comic-monkeyuser-to-ntfy.yaml + - afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml diff --git a/packages/web/src/api/hooks/workflows.js b/packages/web/src/api/hooks/workflows.js index 3f45102..4945fe0 100644 --- a/packages/web/src/api/hooks/workflows.js +++ b/packages/web/src/api/hooks/workflows.js @@ -28,6 +28,13 @@ export function useOwners() { }); } +export function useWorkflowExamples() { + return useQuery({ + queryKey: ["workflow-examples"], + queryFn: async () => (await api.get("/workflow-examples")).data.examples, + }); +} + export function useSaveWorkflow() { const qc = useQueryClient(); return useMutation({ diff --git a/packages/web/src/components/NewWorkflowPresetDialog.jsx b/packages/web/src/components/NewWorkflowPresetDialog.jsx new file mode 100644 index 0000000..c2fcf3a --- /dev/null +++ b/packages/web/src/components/NewWorkflowPresetDialog.jsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { api, errorMessage } from "../api/client.js"; +import { useWorkflowExamples } from "../api/hooks.js"; + +export const SKIP_NEW_WORKFLOW_PRESET_KEY = "jflow.skipNewWorkflowPreset"; + +export function shouldSkipNewWorkflowPreset() { + if (typeof localStorage === "undefined") return false; + return localStorage.getItem(SKIP_NEW_WORKFLOW_PRESET_KEY) === "1"; +} + +export function setSkipNewWorkflowPreset(skip) { + if (typeof localStorage === "undefined") return; + if (skip) localStorage.setItem(SKIP_NEW_WORKFLOW_PRESET_KEY, "1"); + else localStorage.removeItem(SKIP_NEW_WORKFLOW_PRESET_KEY); +} + +/** + * @param {{ + * onChoose: (content: string) => void, + * onCancel: () => void, + * }} props + */ +export function NewWorkflowPresetDialog({ onChoose, onCancel }) { + const { data: examples = [], isLoading, isError, error } = useWorkflowExamples(); + const [mode, setMode] = useState(/** @type {"empty" | "example"} */ ("empty")); + const [exampleId, setExampleId] = useState(""); + const [dontAsk, setDontAsk] = useState(false); + const [pending, setPending] = useState(false); + const [loadError, setLoadError] = useState(/** @type {string | null} */ (null)); + + async function onContinue(e) { + e.preventDefault(); + setLoadError(null); + if (dontAsk) setSkipNewWorkflowPreset(true); + + if (mode === "empty") { + onChoose(null); + return; + } + + if (!exampleId) { + setLoadError("Select an example"); + return; + } + + setPending(true); + try { + const { data } = await api.get( + `/workflow-examples/${encodeURIComponent(exampleId)}`, + ); + onChoose(typeof data?.content === "string" ? data.content : ""); + } catch (err) { + setLoadError(errorMessage(err)); + } finally { + setPending(false); + } + } + + return ( + +
+

New workflow

+

+ Start empty, or copy a shipped example into the editor. Nothing is saved until you click + Save. +

+
+
+ + +
+ + {mode === "example" ? ( +
+ {isLoading ? : null} + {isError ? ( +

{errorMessage(error)}

+ ) : null} + {!isLoading && !isError && examples.length === 0 ? ( +

No examples available.

+ ) : null} + {examples.map((ex) => ( + + ))} +
+ ) : null} + + + + {loadError ?

{loadError}

: null} + +
+ + +
+
+
+
+ +
+
+ ); +} diff --git a/packages/web/src/lib/workflow-doc.js b/packages/web/src/lib/workflow-doc.js index c46fd57..652065e 100644 --- a/packages/web/src/lib/workflow-doc.js +++ b/packages/web/src/lib/workflow-doc.js @@ -10,12 +10,8 @@ const KNOWN_TOP = new Set(["name", "description", "enabled", "scripts", "trigger export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger }; export const NEW_WORKFLOW_YAML = `name: new workflow -scripts: - - plugin/get-current-time -triggers: - - type: HTTP - method: POST - path: /new +scripts: [] +triggers: [] `; let uidSeq = 0; diff --git a/packages/web/src/pages/WorkflowEditPage.jsx b/packages/web/src/pages/WorkflowEditPage.jsx index aabd1d0..87178c2 100644 --- a/packages/web/src/pages/WorkflowEditPage.jsx +++ b/packages/web/src/pages/WorkflowEditPage.jsx @@ -11,6 +11,7 @@ import { useWorkflowRevision, } from "../api/hooks.js"; import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; +import { NewWorkflowPresetDialog, shouldSkipNewWorkflowPreset } from "../components/NewWorkflowPresetDialog.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx"; import { WorkflowHistoryPanel } from "../components/workflow/WorkflowHistoryPanel.jsx"; @@ -100,15 +101,21 @@ export function WorkflowNewPage() { const navigate = useNavigate(); const { notify } = useNotifications(); const { data: owners = [] } = useOwners(); - const [owner, setOwner] = useState(""); + const [owner, setOwner] = useState("local"); + const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset()); const [content, setContent] = useState(NEW_WORKFLOW_YAML); - const [savedYaml] = useState(NEW_WORKFLOW_YAML); + const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML); const [saveWarnings, setSaveWarnings] = useState(null); const create = useCreateWorkflow(); useEffect(() => { - if (!owner && owners[0]) setOwner(owners[0]); - }, [owner, owners]); + // Prefer local when it exists; otherwise first owner. Do not clobber a typed value. + setOwner((prev) => { + if (prev && prev !== "local") return prev; + if (owners.includes("local") || owners.length === 0) return "local"; + return owners[0]; + }); + }, [owners]); function onSave(saveAnyway = false) { create.mutate( @@ -132,6 +139,20 @@ export function WorkflowNewPage() { ); } + if (!ready) { + return ( + navigate("/workflows")} + onChoose={(yaml) => { + const initial = yaml == null || yaml === "" ? NEW_WORKFLOW_YAML : yaml; + setContent(initial); + setSavedYaml(initial); + setReady(true); + }} + /> + ); + } + return ( <> setOwner(e.target.value)} + list="new-workflow-owners" required /> } /> + + {owners.includes("local") ? null : {saveWarnings ? ( diff --git a/plugins/get-current-time/jerapah-plugin.json b/plugins/get-current-time/jerapah-plugin.json new file mode 100644 index 0000000..35294d2 --- /dev/null +++ b/plugins/get-current-time/jerapah-plugin.json @@ -0,0 +1,8 @@ +{ + "id": "get-current-time", + "name": "Get current time", + "version": "0.1.0", + "jerapah": ">=0.1.0 <1.0.0", + "main": "script.js", + "description": "Example user plugin: return the current time as output.datetime" +} diff --git a/plugins/get-current-time/package.json b/plugins/get-current-time/package.json new file mode 100644 index 0000000..2ca2114 --- /dev/null +++ b/plugins/get-current-time/package.json @@ -0,0 +1,7 @@ +{ + "name": "jflow-plugin-get-current-time", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Example JerapahFlow plugin (no npm dependencies)" +} diff --git a/plugins/get-current-time/script.js b/plugins/get-current-time/script.js new file mode 100644 index 0000000..ad34931 --- /dev/null +++ b/plugins/get-current-time/script.js @@ -0,0 +1,34 @@ +function passContext(ctx) { + if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) { + return { ...ctx.context }; + } + return {}; +} + +function getCurrentTime(ctx) { + const output = { + datetime: new Date().toISOString(), + processId: "1234", + }; + return { output, context: { ...passContext(ctx), ...output } }; +} + +getCurrentTime.meta = { + description: "Return the current time as output.datetime", + config: {}, + input: {}, + output: { + datetime: { type: "string", description: "ISO timestamp" }, + processId: { type: "string" }, + }, + context: { + datetime: { type: "string", description: "ISO timestamp" }, + processId: { type: "string" }, + }, + example: { + data: {}, + config: {}, + }, +}; + +export default getCurrentTime;