commit 5dc992a51727f47ee8c557f9765dd16d642e6e3c Author: Nasyarobby Putra Date: Fri Aug 14 11:36:54 2026 +0700 Add initial project structure with database setup, logging, and workflow management diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..921cc58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/ +logs/ +*.db +*.db-* +.DS_Store diff --git a/db.js b/db.js new file mode 100644 index 0000000..ff94bb1 --- /dev/null +++ b/db.js @@ -0,0 +1,11 @@ +import knex from "knex"; +import config from "./knexfile.js"; + +export const db = knex(config); + +/** + * Apply pending migrations. Call once before registering workflows. + */ +export async function migrate() { + await db.migrate.latest(); +} diff --git a/docs/mt.http b/docs/mt.http new file mode 100644 index 0000000..eed9934 --- /dev/null +++ b/docs/mt.http @@ -0,0 +1,35 @@ +### Manual trigger (default owner) +POST http://localhost:9000/u/default/mt +Content-Type: application/json + +0 + +HTTP/1.1 200 - OK +content-type: application/json; charset=utf-8 +content-length: 73 +date: Fri, 14 Aug 2026 04:34:56 GMT +connection: close +### +POST http://localhost:9000/u/default/time-to-ntfy +Content-Type: application/json + +{} + +HTTP/1.1 500 - Internal Server Error +content-type: application/json; charset=utf-8 +content-length: 97 +date: Fri, 14 Aug 2026 04:11:52 GMT +connection: close +### +GET http://localhost:9000/admin/runs?owner=default&limit=20 +### +POST http://localhost:9000/admin/workflows/reregister +Content-Type: application/json + +{} + +HTTP/1.1 200 - OK +content-type: application/json; charset=utf-8 +content-length: 33 +date: Fri, 14 Aug 2026 04:34:22 GMT +connection: close \ No newline at end of file diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 0000000..6016e81 --- /dev/null +++ b/knexfile.js @@ -0,0 +1,33 @@ +import fs from "fs"; +import path from "path"; + +const dbPath = process.env.SCRUNNER_DB_PATH ?? path.resolve("data/scrunner.db"); +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + +/** @type {import("knex").Knex.Config} */ +const config = { + client: "better-sqlite3", + connection: { + filename: dbPath, + }, + useNullAsDefault: true, + migrations: { + directory: "./migrations", + extension: "js", + loadExtensions: [".js"], + }, + pool: { + afterCreate(conn, done) { + try { + conn.pragma("journal_mode = WAL"); + conn.pragma("busy_timeout = 5000"); + conn.pragma("foreign_keys = ON"); + done(null, conn); + } catch (err) { + done(err, conn); + } + }, + }, +}; + +export default config; diff --git a/logger.js b/logger.js new file mode 100644 index 0000000..5c79427 --- /dev/null +++ b/logger.js @@ -0,0 +1,125 @@ +import fs from "fs"; +import path from "path"; +import pino from "pino"; +import * as store from "./store.js"; + +const LEVEL_TO_NUM = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, +}; + +const FLUSH_MS = 200; + +/** @type {Array<{ runId: string, stepId?: string | null, ts: string, level: number, msg?: string | null, payload?: unknown }>} */ +let buffer = []; +let flushChain = Promise.resolve(); +let ready = false; +let timer = null; + +/** + * @param {string} line + */ +function enqueueLine(line) { + let record; + try { + record = JSON.parse(line); + } catch { + return; + } + if (!record.runId) return; + + const { + runId, + stepId = null, + time, + level, + msg = null, + ...rest + } = record; + + const ts = + typeof time === "number" + ? new Date(time).toISOString() + : typeof time === "string" + ? time + : new Date().toISOString(); + + const levelNum = + typeof level === "number" + ? level + : LEVEL_TO_NUM[level] ?? LEVEL_TO_NUM.info; + + const payload = Object.keys(rest).length ? rest : null; + buffer.push({ runId, stepId, ts, level: levelNum, msg, payload }); +} + +function scheduleFlush() { + if (timer) return; + timer = setTimeout(() => { + timer = null; + void flush(); + }, FLUSH_MS); + timer.unref?.(); +} + +async function flush() { + if (!ready || buffer.length === 0) return; + const batch = buffer; + buffer = []; + flushChain = flushChain + .then(() => store.insertLogs(batch)) + .catch((err) => { + // Re-queue failed batch so we don't lose run-scoped logs on transient errors. + buffer = batch.concat(buffer); + console.error("failed to flush logs to sqlite", err); + }); + await flushChain; +} + +const sqliteStream = { + write(line) { + enqueueLine(typeof line === "string" ? line : String(line)); + scheduleFlush(); + }, +}; + +fs.mkdirSync("logs", { recursive: true }); + +const rollingFile = pino.transport({ + target: "pino-roll", + options: { + file: path.resolve("logs/scrunner.log"), + size: "10m", + mkdir: true, + limit: { count: 5 }, + }, +}); + +export const log = pino( + { level: process.env.SCRUNNER_LOG_LEVEL ?? "debug" }, + pino.multistream([ + { level: "debug", stream: process.stdout }, + { level: "debug", stream: rollingFile }, + { level: "debug", stream: sqliteStream }, + ]), +); + +/** Allow the SQLite log writer to flush after migrations have completed. */ +export function enableLogPersistence() { + ready = true; + void flush(); +} + +/** Flush remaining buffered log lines (call on shutdown). */ +export async function flushLogs() { + if (timer) { + clearTimeout(timer); + timer = null; + } + await flush(); + await flushChain; +} diff --git a/migrations/20260814000000_init.js b/migrations/20260814000000_init.js new file mode 100644 index 0000000..c824918 --- /dev/null +++ b/migrations/20260814000000_init.js @@ -0,0 +1,80 @@ +/** + * @param {import("knex").Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable("workflow_runs", (t) => { + t.text("id").primary(); + t.text("owner").notNullable(); + t.text("workflow").notNullable(); + t.text("workflow_name"); + t.text("trigger_type").notNullable(); + t.text("trigger_detail"); + t.text("status").notNullable(); + t.text("started_at").notNullable(); + t.text("finished_at"); + t.integer("duration_ms"); + t.text("input"); + t.text("output"); + t.text("error"); + t.text("parent_run_id").references("id").inTable("workflow_runs"); + }); + + await knex.schema.raw( + "CREATE INDEX workflow_runs_owner_started_at_idx ON workflow_runs (owner, started_at DESC)", + ); + await knex.schema.raw( + "CREATE INDEX workflow_runs_workflow_started_at_idx ON workflow_runs (workflow, started_at DESC)", + ); + await knex.schema.raw( + "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( + "CREATE INDEX step_runs_run_id_step_index_idx ON step_runs (run_id, step_index)", + ); + + 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"); + await knex.schema.dropTableIfExists("step_runs"); + await knex.schema.dropTableIfExists("workflow_runs"); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b9e7b28 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "scrunner", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.25.0", + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ] + }, + "dependencies": { + "axios": "^1.19.0", + "better-sqlite3": "^13.0.3", + "fastify": "^5.12.0", + "jsonata": "^2.2.2", + "knex": "^3.3.0", + "node-cron": "^4.6.0", + "pino": "^10.3.1", + "pino-roll": "^4.0.0", + "yaml": "^2.9.0" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e55a8bf --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,842 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + axios: + specifier: ^1.19.0 + version: 1.19.0 + better-sqlite3: + specifier: ^13.0.3 + version: 13.0.3 + fastify: + specifier: ^5.12.0 + version: 5.12.0 + jsonata: + specifier: ^2.2.2 + version: 2.2.2 + knex: + specifier: ^3.3.0 + version: 3.3.0(better-sqlite3@13.0.3) + node-cron: + specifier: ^4.6.0 + version: 4.6.0 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-roll: + specifier: ^4.0.0 + version: 4.0.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + +packages: + + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + + better-sqlite3@13.0.3: + resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} + engines: {node: '>=22'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + colorette@2.0.19: + resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fast-uri@4.1.2: + resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} + + fastify@5.12.0: + resolution: {integrity: sha512-A3RNEaDIHWaxFW8n8rNJaW1wQ+XAXuoU71llfUQJjuh5WaYLmKfRhhanaJBOx8m2EBPQkR6sDBovYdHNe9F6rA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + getopts@2.3.0: + resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + interpret@2.2.0: + resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonata@2.2.2: + resolution: {integrity: sha512-XDFH2PuaAXv0AXJEWwElXbqADmKFGKMLMkFb+qOz0EhjQW2EIJ6pgtordLU+4u3IVERyBfqy6bi6kZKEncvRqA==} + engines: {node: '>= 8'} + + knex@3.3.0: + resolution: {integrity: sha512-LgWl031hNuLv9Lhxdd9093zULa2aNcoP04Bk29e5NidgRcEr/T0rAr2WYi4BhjTiCDoQiRU56meUE5rppLKZzQ==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + better-sqlite3: '*' + mariadb: '*' + mysql: '*' + mysql2: '*' + pg: '*' + pg-native: '*' + pg-query-stream: ^4.14.0 + sqlite3: '*' + tedious: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + mariadb: + optional: true + mysql: + optional: true + mysql2: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + sqlite3: + optional: true + tedious: + optional: true + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + + node-cron@4.6.0: + resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==} + engines: {node: '>=20'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pg-connection-string@2.6.2: + resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-roll@4.0.0: + resolution: {integrity: sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tarn@3.1.2: + resolution: {integrity: sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==} + engines: {node: '>=8.0.0'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tildify@2.0.0: + resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} + engines: {node: '>=8'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.2 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + + '@pinojs/redact@0.4.0': {} + + abstract-logging@2.0.1: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios@1.19.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + better-sqlite3@13.0.3: + dependencies: + node-addon-api: 8.9.2 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + colorette@2.0.19: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: {} + + cookie@1.1.1: {} + + date-fns@4.4.0: {} + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escalade@3.2.0: {} + + esm@3.2.25: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.5: {} + + fast-uri@4.1.2: {} + + fastify@5.12.0: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.7.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + getopts@2.3.0: {} + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + interpret@2.2.0: {} + + ipaddr.js@2.5.0: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + jsonata@2.2.2: {} + + knex@3.3.0(better-sqlite3@13.0.3): + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.18.1 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.1.2 + tildify: 2.0.0 + optionalDependencies: + better-sqlite3: 13.0.3 + transitivePeerDependencies: + - supports-color + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lodash@4.18.1: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.2: {} + + ms@2.1.3: {} + + node-addon-api@8.9.2: {} + + node-cron@4.6.0: {} + + on-exit-leak-free@2.1.2: {} + + path-parse@1.0.7: {} + + pg-connection-string@2.6.2: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-roll@4.0.0: + dependencies: + date-fns: 4.4.0 + sonic-boom: 4.2.1 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + + proxy-from-env@2.1.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + + require-from-string@2.0.2: {} + + resolve-from@5.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + tarn@3.1.2: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tildify@2.0.0: {} + + toad-cache@3.7.4: {} + + yaml@2.9.0: {} diff --git a/runner.js b/runner.js new file mode 100644 index 0000000..eab615f --- /dev/null +++ b/runner.js @@ -0,0 +1,350 @@ +import fs from "fs"; +import path from "path"; +import yaml from "yaml"; +import fastify from "fastify"; +import cron from "node-cron"; +import { migrate, db } from "./db.js"; +import { log, enableLogPersistence, flushLogs } from "./logger.js"; +import * as store from "./store.js"; +import { clearScriptCache, runScript } from "./script-sandbox.js"; + +await migrate(); +enableLogPersistence(); + +/** + * @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry + */ + +/** + * @type {Map} + */ +const workflows = new Map(); + +/** + * @type {import("node-cron").ScheduledTask[]} + */ +const cronTasks = []; + +/** + * @type {import("node-cron").ScheduledTask | null} + */ +let pruneTask = null; + +/** + * @type {Set} + */ +const registeredHttpRoutes = new Set(); + +function parseScriptStep(step) { + if (typeof step === "string") return { script: step, config: null }; + if (step?.script) return { script: step.script, config: step.config ?? null }; + throw new Error(`Invalid script step: ${JSON.stringify(step)}`); +} + +/** + * Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify + * @param {string} owner + * @param {string} triggerPath + */ +function namespacedPath(owner, triggerPath) { + const cleaned = String(triggerPath).replace(/^\/+/, ""); + return `/u/${owner}/${cleaned}`; +} + +function registerWorkflows() { + workflows.clear(); + clearScriptCache(); + + const workflowsRoot = "workflows"; + if (!fs.existsSync(workflowsRoot)) { + log.warn("workflows directory missing"); + return; + } + + const owners = fs + .readdirSync(workflowsRoot, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const owner of owners) { + const registersPath = path.join(workflowsRoot, owner, "registers.yaml"); + if (!fs.existsSync(registersPath)) { + log.warn(`Skipping owner "${owner}": no registers.yaml`); + continue; + } + + let workflowFiles = []; + try { + const registerData = fs.readFileSync(registersPath, "utf8"); + const parsed = yaml.parse(registerData) ?? {}; + workflowFiles = parsed.scripts ?? []; + } catch (err) { + log.error({ err, owner }, "failed to parse registers.yaml"); + continue; + } + + const ownerDir = path.join(workflowsRoot, owner); + const onDisk = fs + .readdirSync(ownerDir) + .filter((f) => f.endsWith(".yaml") && f !== "registers.yaml"); + for (const file of onDisk) { + if (!workflowFiles.includes(file)) { + log.warn( + `Workflow file not in registers.yaml: ${owner}/${file}`, + ); + } + } + + for (const file of workflowFiles) { + const key = `${owner}/${file}`; + const filePath = path.join(ownerDir, file); + try { + const workflowData = fs.readFileSync(filePath, "utf8"); + const workflow = yaml.parse(workflowData); + workflows.set(key, { owner, file, workflow }); + } catch (err) { + log.error({ err, workflow: key }, "failed to load workflow; skipping"); + } + } + } + + log.debug({ count: workflows.size }, "workflows loaded"); +} + +function registerHttpTriggers() { + const seen = new Set(); + + for (const [key, { owner, workflow }] of workflows) { + if (workflow.enabled === false) { + log.debug(`Skipping disabled workflow HTTP triggers (${key})`); + continue; + } + + for (const trigger of workflow.triggers ?? []) { + if (trigger.type !== "HTTP") continue; + + const method = String(trigger.method ?? "POST").toUpperCase(); + const url = namespacedPath(owner, trigger.path); + const routeKey = `${method} ${url}`; + + if (seen.has(routeKey)) { + log.warn(`Skipping duplicate HTTP trigger ${routeKey} (${key})`); + continue; + } + seen.add(routeKey); + + if (registeredHttpRoutes.has(routeKey)) { + continue; + } + registeredHttpRoutes.add(routeKey); + + server.route({ + method, + url, + handler: async (req, reply) => { + const result = await runWorkflow( + key, + { data: req.body }, + { type: "http", detail: `${method} ${url}` }, + ); + if (result.status === "failed") { + return reply.code(500).send({ + runId: result.runId, + error: result.error, + }); + } + return reply.send({ + runId: result.runId, + result: result.result, + }); + }, + }); + log.debug(`Registered HTTP trigger ${routeKey} (${key})`); + } + } +} + +function registerCronTriggers() { + for (const task of cronTasks) { + task.destroy(); + } + cronTasks.length = 0; + + for (const [key, { workflow }] of workflows) { + if (workflow.enabled === false) { + log.debug(`Skipping disabled workflow cron triggers (${key})`); + continue; + } + + for (const trigger of workflow.triggers ?? []) { + if (trigger.type !== "cron") continue; + + const schedule = trigger.schedule; + if (!schedule || !cron.validate(schedule)) { + log.warn(`Skipping invalid cron schedule "${schedule}" (${key})`); + continue; + } + + const task = cron.schedule( + schedule, + () => { + log.debug(`cron firing ${key} (${schedule})`); + return runWorkflow( + key, + { data: workflow.data ?? null }, + { type: "cron", detail: schedule }, + ); + }, + { name: `${key}:${schedule}`, noOverlap: true }, + ); + cronTasks.push(task); + log.debug(`Registered cron trigger ${schedule} (${key})`); + } + } +} + +function registerPruneJob() { + if (pruneTask) { + pruneTask.destroy(); + pruneTask = null; + } + const days = Number(process.env.SCRUNNER_RETENTION_DAYS ?? 30); + pruneTask = cron.schedule( + "0 0 * * *", + async () => { + try { + const deleted = await store.pruneOlderThan(days); + log.info({ deleted, days }, "pruned old workflow runs"); + } catch (err) { + log.error({ err }, "failed to prune old workflow runs"); + } + }, + { name: "prune-runs" }, + ); +} + +/** + * @param {string} key + * @param {{ data?: unknown }} context + * @param {{ type: string, detail?: string | null }} trigger + */ +async function runWorkflow(key, context, trigger) { + const entry = workflows.get(key); + if (!entry) { + log.error({ workflow: key }, "workflow not found"); + return { runId: null, status: "failed", error: "workflow not found" }; + } + + const { owner, workflow } = entry; + const run = await store.startRun({ + owner, + workflow: key, + workflowName: workflow?.name, + trigger, + input: context.data, + }); + const runLog = log.child({ runId: run.id, owner, workflow: key }); + runLog.debug("running workflow"); + + let ctx = { + ...context, + data: context.data ?? workflow.data ?? null, + }; + + try { + for (const [index, rawStep] of (workflow.scripts ?? []).entries()) { + const { script, config } = parseScriptStep(rawStep); + const step = await store.startStep({ + runId: run.id, + index, + script, + config, + }); + const stepLog = runLog.child({ stepId: step.id, script }); + try { + ctx = await runScript(script, { ...ctx, config }, { + log: stepLog, + workflowName: key, + }); + await store.finishStep(step.id, "success", ctx); + } catch (err) { + await store.finishStep(step.id, "failed", null, err); + throw err; + } + } + await store.finishRun(run.id, "success", ctx); + return { runId: run.id, status: "success", result: ctx }; + } catch (err) { + runLog.error({ err }, "workflow failed"); + await store.finishRun(run.id, "failed", null, err); + return { + runId: run.id, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }; + } +} + +registerWorkflows(); + +const server = fastify({ loggerInstance: log }); + +registerHttpTriggers(); +registerCronTriggers(); +registerPruneJob(); + +server.post("/admin/workflows/reregister", async (_req, reply) => { + registerWorkflows(); + // Fastify cannot remove routes; new routes are added, cron is rebuilt. + registerHttpTriggers(); + registerCronTriggers(); + return reply.send({ message: "Workflows refreshed" }); +}); + +server.get("/admin/runs", async (req, reply) => { + const q = /** @type {Record} */ (req.query); + const limit = q.limit ? Number(q.limit) : undefined; + const runs = await store.listRuns({ + owner: q.owner, + workflow: q.workflow, + status: q.status, + limit: Number.isFinite(limit) ? limit : undefined, + before: q.before, + }); + return reply.send({ runs }); +}); + +server.get("/admin/runs/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + const run = await store.getRun(id); + if (!run) { + return reply.code(404).send({ error: "run not found" }); + } + return reply.send(run); +}); + +async function shutdown() { + try { + await flushLogs(); + await db.destroy(); + } catch (err) { + log.error({ err }, "shutdown error"); + } + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +server + .listen({ + host: "0.0.0.0", + port: 9000, + }) + .then(() => { + log.info("Server is running on port 9000"); + }) + .catch((err) => { + log.error({ err }, "failed to start server"); + process.exit(1); + }); diff --git a/script-sandbox.js b/script-sandbox.js new file mode 100644 index 0000000..ddb2d21 --- /dev/null +++ b/script-sandbox.js @@ -0,0 +1,245 @@ +import fs from "fs"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const hostRequire = createRequire(import.meta.url); + +const ALLOWED_MODULES = new Set(["axios", "jsonata"]); + +const BUILTIN_NAMES = [ + "Infinity", + "NaN", + "undefined", + "Object", + "Array", + "String", + "Boolean", + "Number", + "Symbol", + "BigInt", + "Error", + "AggregateError", + "EvalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", + "Math", + "Date", + "JSON", + "RegExp", + "Map", + "Set", + "WeakMap", + "WeakSet", + "WeakRef", + "FinalizationRegistry", + "ArrayBuffer", + "DataView", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float16Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "Promise", + "Proxy", + "Reflect", + "Intl", + "URL", + "URLSearchParams", + "Headers", + "Request", + "Response", + "FormData", + "Blob", + "File", + "AbortController", + "AbortSignal", + "TextEncoder", + "TextDecoder", + "Atomics", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURI", + "encodeURIComponent", + "decodeURI", + "decodeURIComponent", + "setTimeout", + "setInterval", + "clearTimeout", + "clearInterval", + "setImmediate", + "clearImmediate", + "queueMicrotask", + "structuredClone", + "fetch", + "atob", + "btoa", + "Buffer", + "crypto", + "performance", +]; + +/** + * @type {Map} + */ +const scriptCache = new Map(); + +export function clearScriptCache() { + scriptCache.clear(); +} + +function pickBuiltins() { + /** @type {Record} */ + const builtins = {}; + for (const name of BUILTIN_NAMES) { + if (typeof globalThis[name] !== "undefined") { + builtins[name] = globalThis[name]; + } + } + return builtins; +} + +function transformEsmToCjs(source) { + let code = source; + + code = code.replace( + /^\s*import\s+(\w+)\s+from\s+["']([^"']+)["'];?\s*$/gm, + 'const $1 = require("$2");', + ); + code = code.replace( + /^\s*import\s+\*\s+as\s+(\w+)\s+from\s+["']([^"']+)["'];?\s*$/gm, + 'const $1 = require("$2");', + ); + code = code.replace( + /^\s*import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["'];?\s*$/gm, + "const {$1} = require(\"$2\");", + ); + code = code.replace( + /^\s*import\s+["']([^"']+)["'];?\s*$/gm, + 'require("$1");', + ); + + const replaced = code.replace( + /^\s*export\s+default\s+/m, + "module.exports.default = ", + ); + if (replaced === code) { + throw new Error("script must have a default export"); + } + return replaced; +} + +function wrapScriptSource(source, filename) { + const body = transformEsmToCjs(source); + return `"use strict"; +var module = { exports: {} }; +var exports = module.exports; +${body} +if (typeof module.exports.default !== "function") { + throw new TypeError(${JSON.stringify(filename)} + " default export must be a function"); +} +module.exports.default; +`; +} + +/** + * @param {import("pino").Logger} logger + */ +function createConsole(logger) { + const write = (level) => (...args) => { + if (args.length === 0) { + logger[level](""); + return; + } + const [first, ...rest] = args; + if (typeof first === "string") { + if (rest.length === 0) { + logger[level](first); + } else { + logger[level]({ args: rest }, first); + } + return; + } + logger[level]({ args }); + }; + + return { + log: write("info"), + info: write("info"), + warn: write("warn"), + error: write("error"), + debug: write("debug"), + trace: write("trace"), + }; +} + +function createRestrictedRequire() { + return function restrictedRequire(id) { + if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) { + throw new Error(`require(${JSON.stringify(id)}) is not allowed`); + } + return hostRequire(id); + }; +} + +/** + * @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts + */ +function createScriptSandbox({ log, script, workflowName }) { + const scriptLog = log.child({ workflow: workflowName, script }); + const sandbox = { + ...pickBuiltins(), + log: scriptLog, + console: createConsole(scriptLog), + require: createRestrictedRequire(), + }; + + vm.createContext(sandbox, { + name: `scrunner:${workflowName}:${script}`, + codeGeneration: { strings: false, wasm: false }, + }); + + return sandbox; +} + +function loadCompiledScript(script) { + const filePath = fileURLToPath(new URL(`./scripts/${script}`, import.meta.url)); + const { mtimeMs } = fs.statSync(filePath); + const cached = scriptCache.get(script); + if (cached && cached.mtimeMs === mtimeMs) { + return cached.compiled; + } + + const source = fs.readFileSync(filePath, "utf8"); + const compiled = new vm.Script(wrapScriptSource(source, script), { + filename: filePath, + }); + scriptCache.set(script, { compiled, mtimeMs }); + return compiled; +} + +/** + * Evaluate a workflow script's default export inside a restricted vm context. + * + * @param {string} script + * @param {unknown} ctx + * @param {{ log: import("pino").Logger, workflowName: string }} opts + */ +export async function runScript(script, ctx, { log, workflowName }) { + const compiled = loadCompiledScript(script); + const sandbox = createScriptSandbox({ log, script, workflowName }); + const fn = compiled.runInContext(sandbox); + return await fn(ctx); +} diff --git a/scripts/add-one.js b/scripts/add-one.js new file mode 100644 index 0000000..6d52af2 --- /dev/null +++ b/scripts/add-one.js @@ -0,0 +1,4 @@ +export default function main(context) { + log.info({ data: context.data }, "add-one"); + return { data: (context.data || 0) + 1 }; +} diff --git a/scripts/add-ten.js b/scripts/add-ten.js new file mode 100644 index 0000000..6ac3dbd --- /dev/null +++ b/scripts/add-ten.js @@ -0,0 +1,4 @@ +export default function main(context) { + log.info({ input: context.input }, "add-ten"); + return context.input + 10; +} diff --git a/scripts/get-current-time.js b/scripts/get-current-time.js new file mode 100644 index 0000000..8d01a3a --- /dev/null +++ b/scripts/get-current-time.js @@ -0,0 +1,10 @@ +// this script will get current time + +export default function getCurrentTime() { + return { + data: { + datetime: new Date().toISOString(), + processId: "1234" + } + } +} \ No newline at end of file diff --git a/scripts/jsonata.js b/scripts/jsonata.js new file mode 100644 index 0000000..b0177e5 --- /dev/null +++ b/scripts/jsonata.js @@ -0,0 +1,6 @@ +import jsonata from "jsonata"; + +export default function jsonataFn(context) { + const expression = jsonata(context.config.expression); + return expression.evaluate(context.data) +} \ No newline at end of file diff --git a/scripts/ntfy.js b/scripts/ntfy.js new file mode 100644 index 0000000..9f581ec --- /dev/null +++ b/scripts/ntfy.js @@ -0,0 +1,14 @@ +import axios from "axios"; +export default function ntfy(ctx) { + log.info({ ctx }, "ntfy"); + const headers = {} + + if(ctx.data?.title) { + headers.Title = ctx.data.title + } + + axios.post(ctx.config?.url || "https://ntfy.sh/scrunner", ctx.data?.message || "Hello from scrunner", { + headers: headers + }) + return {sent: "true"} +} diff --git a/store.js b/store.js new file mode 100644 index 0000000..38a7bea --- /dev/null +++ b/store.js @@ -0,0 +1,237 @@ +import { randomUUID } from "node:crypto"; +import { db } from "./db.js"; + +const MAX_JSON_BYTES = 64 * 1024; + +/** + * @param {unknown} value + * @returns {string | null} + */ +export function serialize(value) { + if (value === undefined || value === null) return null; + let json; + try { + json = JSON.stringify(value); + } catch { + json = JSON.stringify({ truncated: true, reason: "unserializable" }); + } + if (Buffer.byteLength(json, "utf8") <= MAX_JSON_BYTES) return json; + return JSON.stringify({ + truncated: true, + preview: json.slice(0, 1024), + }); +} + +/** + * @param {string | null} value + * @returns {unknown} + */ +function deserialize(value) { + if (value == null) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {{ + * owner: string, + * workflow: string, + * workflowName?: string | null, + * trigger: { type: string, detail?: string | null }, + * input?: unknown, + * parentRunId?: string | null, + * }} opts + */ +export async function startRun({ + owner, + workflow, + workflowName = null, + trigger, + input = null, + parentRunId = null, +}) { + const id = randomUUID(); + const started_at = nowIso(); + await db("workflow_runs").insert({ + id, + owner, + workflow, + workflow_name: workflowName ?? null, + trigger_type: trigger.type, + trigger_detail: trigger.detail ?? null, + status: "running", + started_at, + input: serialize(input), + parent_run_id: parentRunId ?? null, + }); + return { id, started_at }; +} + +/** + * @param {string} id + * @param {"success" | "failed"} status + * @param {unknown} [output] + * @param {Error | unknown} [err] + */ +export async function finishRun(id, status, output = null, err = null) { + const finished_at = nowIso(); + const row = await db("workflow_runs").where({ id }).first("started_at"); + const duration_ms = row + ? Date.parse(finished_at) - Date.parse(row.started_at) + : null; + await db("workflow_runs") + .where({ id }) + .update({ + status, + finished_at, + duration_ms, + output: serialize(output), + error: err + ? err instanceof Error + ? err.message + : String(err) + : null, + }); +} + +/** + * @param {{ + * runId: string, + * index: number, + * script: string, + * config?: unknown, + * }} opts + */ +export async function startStep({ runId, index, script, config = null }) { + const id = randomUUID(); + const started_at = nowIso(); + await db("step_runs").insert({ + id, + run_id: runId, + step_index: index, + script, + config: serialize(config), + status: "running", + started_at, + }); + return { id, started_at }; +} + +/** + * @param {string} id + * @param {"success" | "failed"} status + * @param {unknown} [output] + * @param {Error | unknown} [err] + */ +export async function finishStep(id, status, output = null, err = null) { + const finished_at = nowIso(); + const row = await db("step_runs").where({ id }).first("started_at"); + const duration_ms = row + ? Date.parse(finished_at) - Date.parse(row.started_at) + : null; + await db("step_runs") + .where({ id }) + .update({ + status, + finished_at, + duration_ms, + output: serialize(output), + error: err + ? err instanceof Error + ? err.message + : String(err) + : null, + }); +} + +/** + * @param {Array<{ + * runId: string, + * stepId?: string | null, + * ts: string, + * level: number, + * msg?: string | null, + * payload?: unknown, + * }>} rows + */ +export async function insertLogs(rows) { + if (!rows.length) return; + await db("logs").insert( + rows.map((r) => ({ + run_id: r.runId, + step_id: r.stepId ?? null, + ts: r.ts, + level: r.level, + msg: r.msg ?? null, + payload: serialize(r.payload), + })), + ); +} + +/** + * @param {{ + * owner?: string, + * workflow?: string, + * status?: string, + * limit?: number, + * before?: string, + * }} [filters] + */ +export async function listRuns(filters = {}) { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + let q = db("workflow_runs").select("*").orderBy("started_at", "desc"); + if (filters.owner) q = q.where("owner", filters.owner); + if (filters.workflow) q = q.where("workflow", filters.workflow); + if (filters.status) q = q.where("status", filters.status); + if (filters.before) q = q.where("started_at", "<", filters.before); + const rows = await q.limit(limit); + return rows.map((row) => ({ + ...row, + input: deserialize(row.input), + output: deserialize(row.output), + })); +} + +/** + * @param {string} id + */ +export async function getRun(id) { + const run = await db("workflow_runs").where({ id }).first(); + if (!run) return null; + + const steps = await db("step_runs") + .where({ run_id: id }) + .orderBy("step_index", "asc"); + const logs = await db("logs").where({ run_id: id }).orderBy("ts", "asc").orderBy("id", "asc"); + + return { + ...run, + input: deserialize(run.input), + output: deserialize(run.output), + steps: steps.map((s) => ({ + ...s, + config: deserialize(s.config), + output: deserialize(s.output), + })), + logs: logs.map((l) => ({ + ...l, + payload: deserialize(l.payload), + })), + }; +} + +/** + * @param {number} days + * @returns {Promise} number of deleted runs + */ +export async function pruneOlderThan(days) { + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + return db("workflow_runs").where("started_at", "<", cutoff).del(); +} diff --git a/workflows/default/cron-example.yaml b/workflows/default/cron-example.yaml new file mode 100644 index 0000000..5d2e484 --- /dev/null +++ b/workflows/default/cron-example.yaml @@ -0,0 +1,10 @@ +name: cron example +description: | + this workflow triggered by cron +scripts: + - script: ntfy.js + config: + url: https://ntfy.sh/scrunner +triggers: + - type: cron + schedule: "*/20 * * * *" diff --git a/workflows/default/manual-trigger.yaml b/workflows/default/manual-trigger.yaml new file mode 100644 index 0000000..781cdbc --- /dev/null +++ b/workflows/default/manual-trigger.yaml @@ -0,0 +1,9 @@ +name: manual trigger +scripts: + - add-one.js + - add-ten.js +triggers: + - type: HTTP + method: POST + path: /mt + diff --git a/workflows/default/registers.yaml b/workflows/default/registers.yaml new file mode 100644 index 0000000..f1ed72a --- /dev/null +++ b/workflows/default/registers.yaml @@ -0,0 +1,5 @@ +scripts: + - time-to-ntfy-example.yaml + - test.yaml + - manual-trigger.yaml + - cron-example.yaml \ No newline at end of file diff --git a/workflows/default/test.yaml b/workflows/default/test.yaml new file mode 100644 index 0000000..b7b273e --- /dev/null +++ b/workflows/default/test.yaml @@ -0,0 +1,12 @@ +name: manual trigger +scripts: + - get-current-time.js + - script: jsonata.js + config: + expression: '{"data": {"message": datetime & " " & processId}}' + - ntfy.js +triggers: + - type: HTTP + method: POST + path: /mt + diff --git a/workflows/default/time-to-ntfy-example.yaml b/workflows/default/time-to-ntfy-example.yaml new file mode 100644 index 0000000..880e59a --- /dev/null +++ b/workflows/default/time-to-ntfy-example.yaml @@ -0,0 +1,12 @@ +name: time to ntfy example +description: | + this workflow will send a message to ntfy with the current time +scripts: + - get-current-time.js + - script: ntfy.js + config: + url: https://ntfy.sh/scrunner +triggers: + - type: HTTP + method: POST + path: /time-to-ntfy \ No newline at end of file