feat(profiles): add typed live step config profiles
Profiles store script + default config per owner. Workflow steps reference them with profile:, overlay keys win, and the UI marks overrides. Profile name is immutable after create so YAML refs stay stable. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("profiles", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("owner").notNullable();
|
||||
t.text("name").notNullable();
|
||||
t.text("script").notNullable();
|
||||
t.text("config").notNullable();
|
||||
t.text("description").notNullable().defaultTo("");
|
||||
t.text("created_at").notNullable();
|
||||
t.text("updated_at").notNullable();
|
||||
t.unique(["owner", "name"]);
|
||||
});
|
||||
|
||||
await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("profiles");
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
"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": "node test/workflow-history-smoke.js",
|
||||
"test:profiles": "node test/profiles-smoke.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1111.0",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shallow merge: step overlay keys replace profile defaults (including "").
|
||||
* Nested objects/arrays are replaced, not deep-merged.
|
||||
*
|
||||
* @param {unknown} profileConfig
|
||||
* @param {unknown} stepConfig
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function mergeProfileConfig(profileConfig, stepConfig) {
|
||||
const base =
|
||||
profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig)
|
||||
? { ...profileConfig }
|
||||
: {};
|
||||
if (stepConfig == null || typeof stepConfig !== "object" || Array.isArray(stepConfig)) {
|
||||
return base;
|
||||
}
|
||||
return { ...base, ...stepConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} config
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function configHasOverlay(config) {
|
||||
return (
|
||||
config != null &&
|
||||
typeof config === "object" &&
|
||||
!Array.isArray(config) &&
|
||||
Object.keys(config).length > 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import yaml from "yaml";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "./fs-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
const MAX_CONFIG_BYTES = 64 * 1024;
|
||||
const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function httpError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertProfileName(name) {
|
||||
if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) {
|
||||
throw httpError("invalid profile name");
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} script
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertProfileScript(script) {
|
||||
if (typeof script !== "string" || script.trim().length === 0) {
|
||||
throw httpError("script is required");
|
||||
}
|
||||
const trimmed = script.trim();
|
||||
if (trimmed.length > 256) {
|
||||
throw httpError("script name is too long");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} description
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertProfileDescription(description) {
|
||||
if (description == null) return "";
|
||||
if (typeof description !== "string") {
|
||||
throw httpError("description must be a string");
|
||||
}
|
||||
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} config
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeProfileConfig(config) {
|
||||
if (config == null) return "{}";
|
||||
if (typeof config !== "object" || Array.isArray(config)) {
|
||||
throw httpError("config must be an object");
|
||||
}
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(config);
|
||||
} catch {
|
||||
throw httpError("config must be JSON-serializable");
|
||||
}
|
||||
if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) {
|
||||
throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} stored
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function decodeProfileConfig(stored) {
|
||||
if (stored == null || stored === "") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`corrupt profile config: ${JSON.stringify(stored).slice(0, 80)}`);
|
||||
}
|
||||
throw new Error("corrupt profile config: not an object");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} row
|
||||
*/
|
||||
function publicProfile(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
script: row.script,
|
||||
config: decodeProfileConfig(String(row.config ?? "{}")),
|
||||
description: row.description == null ? "" : String(row.description),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listProfiles(filters = {}) {
|
||||
let q = db("profiles")
|
||||
.select("id", "owner", "name", "script", "config", "description", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
const rows = await q;
|
||||
return rows.map((row) => publicProfile(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getProfileById(id) {
|
||||
const row = await db("profiles").where({ id }).first();
|
||||
return row ? publicProfile(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function getProfilePlain(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const profileName = assertProfileName(name);
|
||||
const row = await db("profiles").where({ owner: ownerName, name: profileName }).first();
|
||||
return row ? publicProfile(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* owner: string,
|
||||
* name: string,
|
||||
* script: unknown,
|
||||
* config?: unknown,
|
||||
* description?: unknown,
|
||||
* }} opts
|
||||
*/
|
||||
export async function upsertProfile({ owner, name, script, config, description }) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const profileName = assertProfileName(name);
|
||||
const scriptName = assertProfileScript(script);
|
||||
const encoded = encodeProfileConfig(config ?? {});
|
||||
const desc = assertProfileDescription(description);
|
||||
const now = nowIso();
|
||||
const existing = await db("profiles").where({ owner: ownerName, name: profileName }).first();
|
||||
|
||||
if (existing) {
|
||||
await db("profiles")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
script: scriptName,
|
||||
config: encoded,
|
||||
description: desc,
|
||||
updated_at: now,
|
||||
});
|
||||
return getProfileById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("profiles").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: profileName,
|
||||
script: scriptName,
|
||||
config: encoded,
|
||||
description: desc,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getProfileById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteProfile(id) {
|
||||
const n = await db("profiles").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflows (same owner) whose YAML steps reference this profile name.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {{ file: string, name: string, steps: number }[]}
|
||||
*/
|
||||
export function listProfileUsages(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const profileName = assertProfileName(name);
|
||||
/** @type {{ file: string, name: string, steps: number }[]} */
|
||||
const usages = [];
|
||||
for (const file of listOwnerYamlFiles(ownerName)) {
|
||||
const content = readWorkflowYaml(ownerName, file);
|
||||
if (content == null) continue;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = yaml.parse(content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
||||
const scripts = parsed.scripts;
|
||||
if (!Array.isArray(scripts)) continue;
|
||||
let steps = 0;
|
||||
for (const step of scripts) {
|
||||
if (step != null && typeof step === "object" && !Array.isArray(step) && step.profile === profileName) {
|
||||
steps += 1;
|
||||
}
|
||||
}
|
||||
if (steps > 0) {
|
||||
usages.push({
|
||||
file,
|
||||
name: typeof parsed.name === "string" && parsed.name ? parsed.name : file,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
}
|
||||
return usages;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
sendSuccessPage,
|
||||
} from "./http-trigger-auth.js";
|
||||
import { resolveConfigRefs } from "./config-refs.js";
|
||||
import { mergeProfileConfig } from "./profile-config.js";
|
||||
import { getProfilePlain } from "./profiles-store.js";
|
||||
import {
|
||||
buildFailureAlertData,
|
||||
resolveFailureTriggerConfig,
|
||||
@@ -593,8 +595,21 @@ export function createRegistry(server, opts = {}) {
|
||||
owner,
|
||||
depth,
|
||||
) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const unresolvedConfig = parsed.config;
|
||||
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
let unresolvedConfig = parsed.config;
|
||||
if (parsed.kind === "script" && parsed.profile) {
|
||||
const profile = await getProfilePlain(owner, parsed.profile);
|
||||
if (!profile) {
|
||||
throw new Error(`profile "${parsed.profile}" not found`);
|
||||
}
|
||||
if (parsed.script && parsed.script !== profile.script) {
|
||||
throw new Error(
|
||||
`step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`,
|
||||
);
|
||||
}
|
||||
script = profile.script;
|
||||
unresolvedConfig = mergeProfileConfig(profile.config, parsed.config);
|
||||
}
|
||||
const incomingContext = normalizeContext(ctx.context);
|
||||
const step = await store.startStep({
|
||||
runId,
|
||||
@@ -904,7 +919,10 @@ export function createRegistry(server, opts = {}) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
if (parsed.kind === "script") refs.add(parsed.script);
|
||||
if (parsed.kind === "script") {
|
||||
if (parsed.script) refs.add(parsed.script);
|
||||
if (parsed.profile) refs.add(`profile:${parsed.profile}`);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid steps
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
assertProfileName,
|
||||
deleteProfile,
|
||||
getProfileById,
|
||||
getProfilePlain,
|
||||
listProfileUsages,
|
||||
listProfiles,
|
||||
upsertProfile,
|
||||
} from "../../profiles-store.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function profilesPlugin(fastify) {
|
||||
fastify.get("/profiles", async (req, reply) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
try {
|
||||
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
|
||||
const profiles = await listProfiles({ owner });
|
||||
const withUsage = profiles.map((profile) => ({
|
||||
...profile,
|
||||
usageCount: listProfileUsages(profile.owner, profile.name).length,
|
||||
}));
|
||||
return { profiles: withUsage };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/profiles/:id/usage", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await getProfileById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "profile not found" });
|
||||
}
|
||||
return { usages: listProfileUsages(existing.owner, existing.name) };
|
||||
});
|
||||
|
||||
fastify.put("/profiles", async (req, reply) => {
|
||||
const body = /** @type {{
|
||||
owner?: string,
|
||||
name?: string,
|
||||
script?: unknown,
|
||||
config?: unknown,
|
||||
description?: unknown,
|
||||
}} */ (req.body ?? {});
|
||||
try {
|
||||
fsStore.assertOwner(String(body.owner ?? ""));
|
||||
assertProfileName(String(body.name ?? ""));
|
||||
const profile = await upsertProfile({
|
||||
owner: String(body.owner),
|
||||
name: String(body.name),
|
||||
script: body.script,
|
||||
config: body.config,
|
||||
description: body.description,
|
||||
});
|
||||
return reply.send({ profile });
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/profiles/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const q = /** @type {{ force?: string }} */ (req.query ?? {});
|
||||
const existing = await getProfileById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "profile not found" });
|
||||
}
|
||||
const usages = listProfileUsages(existing.owner, existing.name);
|
||||
const force = q.force === "1" || q.force === "true";
|
||||
if (usages.length > 0 && !force) {
|
||||
return reply.code(409).send({
|
||||
error: "profile is used by workflows",
|
||||
usages,
|
||||
});
|
||||
}
|
||||
await deleteProfile(id);
|
||||
return { ok: true, forced: force && usages.length > 0, usages };
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
collectWorkflowWarnings,
|
||||
parseWorkflowDocument,
|
||||
} from "../../workflow-validate-warnings.js";
|
||||
import { getProfilePlain } from "../../profiles-store.js";
|
||||
import { resolveScriptRef } from "../../plugin-store.js";
|
||||
import {
|
||||
recordRevision,
|
||||
listRevisions,
|
||||
@@ -80,7 +82,9 @@ function scriptNames(workflow) {
|
||||
for (const raw of workflow.scripts ?? []) {
|
||||
try {
|
||||
const parsed = parseScriptStep(raw);
|
||||
names.push(parsed.kind === "set" ? "set" : parsed.script);
|
||||
if (parsed.kind === "set") names.push("set");
|
||||
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
|
||||
else names.push(parsed.script);
|
||||
} catch {
|
||||
names.push(null);
|
||||
}
|
||||
@@ -88,6 +92,59 @@ function scriptNames(workflow) {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function collectProfileWarnings(parsed, owner) {
|
||||
/** @type {Array<{ code: string, message: string, path?: string }>} */
|
||||
const warnings = [];
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
|
||||
return warnings;
|
||||
}
|
||||
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
|
||||
const profileName = raw.profile;
|
||||
if (typeof profileName !== "string" || !profileName) continue;
|
||||
const pathKey = `scripts[${i}]`;
|
||||
let profile;
|
||||
try {
|
||||
profile = await getProfilePlain(owner, profileName);
|
||||
} catch {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" is not a valid name`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!profile) {
|
||||
warnings.push({
|
||||
code: "unknown_profile",
|
||||
message: `Profile "${profileName}" not found`,
|
||||
path: pathKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
|
||||
warnings.push({
|
||||
code: "profile_script_mismatch",
|
||||
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
|
||||
path: pathKey,
|
||||
});
|
||||
}
|
||||
const resolved = resolveScriptRef(profile.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
code: "unknown_script",
|
||||
message: resolved.error,
|
||||
path: `${pathKey}.profile`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
@@ -109,8 +166,11 @@ async function validateStrictWorkflow(parsed) {
|
||||
* }} opts
|
||||
*/
|
||||
async function saveWorkflowContent(opts) {
|
||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||
const saveAnyway = Boolean(opts.saveAnyway);
|
||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||
if (parsed) {
|
||||
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
|
||||
}
|
||||
const saveAnyway = Boolean(opts.saveAnyway);
|
||||
|
||||
if (!saveAnyway) {
|
||||
if (parseError) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||
import secretsPlugin from "./src/api/secrets.js";
|
||||
import kvPlugin from "./src/api/kv.js";
|
||||
import variablesPlugin from "./src/api/variables.js";
|
||||
import profilesPlugin from "./src/api/profiles.js";
|
||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
@@ -169,6 +170,7 @@ export async function startApp(opts = {}) {
|
||||
await api.register(usersPlugin);
|
||||
await api.register(secretsPlugin);
|
||||
await api.register(variablesPlugin);
|
||||
await api.register(profilesPlugin);
|
||||
await api.register(kvPlugin);
|
||||
await api.register(httpPagesPlugin);
|
||||
await api.register(httpAuthsPlugin);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { migrate, db } from "../db.js";
|
||||
import {
|
||||
assertProfileName,
|
||||
deleteProfile,
|
||||
encodeProfileConfig,
|
||||
getProfilePlain,
|
||||
listProfileUsages,
|
||||
upsertProfile,
|
||||
} from "../profiles-store.js";
|
||||
import { mergeProfileConfig } from "../profile-config.js";
|
||||
import { parseScriptStep } from "../workflow-parse.js";
|
||||
|
||||
await migrate();
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
async function assertThrows(fn, match) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (match && !message.includes(match)) {
|
||||
throw new Error(`threw "${message}", expected to include "${match}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected to throw (${match ?? "any error"})`);
|
||||
}
|
||||
|
||||
const merged = mergeProfileConfig(
|
||||
{ url: "https://n.example/ops", fingerprint: true },
|
||||
{ fingerprint: "comic-rss" },
|
||||
);
|
||||
assert(merged.url === "https://n.example/ops", "profile url kept");
|
||||
assert(merged.fingerprint === "comic-rss", "overlay wins");
|
||||
|
||||
const emptyOverlay = mergeProfileConfig({ url: "https://n.example/ops" }, {});
|
||||
assert(emptyOverlay.url === "https://n.example/ops", "empty overlay");
|
||||
|
||||
const emptyWins = mergeProfileConfig({ url: "https://n.example/ops" }, { url: "" });
|
||||
assert(emptyWins.url === "", "empty string overlay wins");
|
||||
|
||||
const profileOnly = parseScriptStep({
|
||||
profile: "ops-ntfy",
|
||||
config: { fingerprint: "x" },
|
||||
});
|
||||
assert(profileOnly.kind === "script", "profile step kind");
|
||||
assert(profileOnly.script === "", "script supplied by profile at runtime");
|
||||
assert(profileOnly.profile === "ops-ntfy", "profile name");
|
||||
|
||||
const both = parseScriptStep({
|
||||
script: "ntfy.js",
|
||||
profile: "ops-ntfy",
|
||||
});
|
||||
assert(both.script === "ntfy.js" && both.profile === "ops-ntfy", "script + profile");
|
||||
|
||||
await assertThrows(
|
||||
() => parseScriptStep({ profile: "ops", set: { expression: "1" } }),
|
||||
"profile and set",
|
||||
);
|
||||
|
||||
assert(assertProfileName("ops-ntfy") === "ops-ntfy", "valid name");
|
||||
await assertThrows(() => assertProfileName("ops ntfy"), "invalid profile name");
|
||||
await assertThrows(() => encodeProfileConfig([]), "config must be an object");
|
||||
|
||||
const owner = "default";
|
||||
const name = `profiles_smoke_${Date.now()}`;
|
||||
const created = await upsertProfile({
|
||||
owner,
|
||||
name,
|
||||
script: "ntfy.js",
|
||||
config: { url: "$VAR_ntfy_channel" },
|
||||
description: "smoke",
|
||||
});
|
||||
assert(created.name === name, "created");
|
||||
assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip");
|
||||
assert(created.script === "ntfy.js", "script locked on profile");
|
||||
|
||||
const fetched = await getProfilePlain(owner, name);
|
||||
assert(fetched?.id === created.id, "get by owner/name");
|
||||
|
||||
const updated = await upsertProfile({
|
||||
owner,
|
||||
name,
|
||||
script: "send-email.js",
|
||||
config: { service: "Gmail" },
|
||||
description: "now mail",
|
||||
});
|
||||
assert(updated.id === created.id, "upsert same row");
|
||||
assert(updated.script === "send-email.js", "script may change");
|
||||
|
||||
const usages = listProfileUsages(owner, name);
|
||||
assert(Array.isArray(usages) && usages.length === 0, "unused profile");
|
||||
|
||||
await deleteProfile(created.id);
|
||||
assert((await getProfilePlain(owner, name)) == null, "deleted");
|
||||
|
||||
await db.destroy();
|
||||
console.log("profiles-smoke: ok");
|
||||
@@ -6,20 +6,22 @@ export const SET_STEP_SCRIPT = "set";
|
||||
* @typedef {{ alias: string, from: string }} NeedEdge
|
||||
* @typedef {{
|
||||
* kind: "script",
|
||||
* script: string,
|
||||
* config: unknown | null,
|
||||
* expression?: undefined,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
* }} ParsedScriptStep
|
||||
* script: string,
|
||||
* profile: string | null,
|
||||
* config: unknown | null,
|
||||
* expression?: undefined,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
* }} ParsedScriptStep
|
||||
* @typedef {{
|
||||
* kind: "set",
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* config: { expression: string },
|
||||
* expression: string,
|
||||
* id: string | null,
|
||||
* script: typeof SET_STEP_SCRIPT,
|
||||
* profile: null,
|
||||
* config: { expression: string },
|
||||
* expression: string,
|
||||
* id: string | null,
|
||||
* needsKind: "none" | "list" | "map",
|
||||
* needs: NeedEdge[],
|
||||
* when: string | null,
|
||||
@@ -85,6 +87,7 @@ export function parseScriptStep(step) {
|
||||
return {
|
||||
kind: "script",
|
||||
script: step,
|
||||
profile: null,
|
||||
config: null,
|
||||
id: null,
|
||||
needsKind: "none",
|
||||
@@ -97,24 +100,33 @@ export function parseScriptStep(step) {
|
||||
}
|
||||
|
||||
const hasScript = step.script != null && step.script !== "";
|
||||
const hasProfile = step.profile != null && step.profile !== "";
|
||||
const hasSet = step.set != null;
|
||||
|
||||
if (hasScript && hasSet) {
|
||||
throw new Error("Step cannot have both script and set");
|
||||
}
|
||||
if (hasProfile && hasSet) {
|
||||
throw new Error("Step cannot have both profile and set");
|
||||
}
|
||||
|
||||
if (hasSet) {
|
||||
return parseSetStep(step);
|
||||
}
|
||||
|
||||
if (hasScript) {
|
||||
if (typeof step.script !== "string") {
|
||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
if (hasProfile && typeof step.profile !== "string") {
|
||||
throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`);
|
||||
}
|
||||
if (hasScript && typeof step.script !== "string") {
|
||||
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||
}
|
||||
|
||||
if (hasScript || hasProfile) {
|
||||
const { needsKind, needs } = parseNeeds(step.needs);
|
||||
return {
|
||||
kind: "script",
|
||||
script: step.script,
|
||||
script: hasScript ? step.script : "",
|
||||
profile: hasProfile ? step.profile : null,
|
||||
config: step.config ?? null,
|
||||
id: parseOptionalId(step.id),
|
||||
needsKind,
|
||||
@@ -265,6 +277,7 @@ function parseSetStep(step) {
|
||||
return {
|
||||
kind: "set",
|
||||
script: SET_STEP_SCRIPT,
|
||||
profile: null,
|
||||
config: { expression },
|
||||
expression,
|
||||
id: parseOptionalId(step.id),
|
||||
|
||||
@@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) {
|
||||
try {
|
||||
const step = parseScriptStep(raw);
|
||||
if (step.kind === "set") continue;
|
||||
if (step.profile && !step.script) continue;
|
||||
const resolved = resolveScriptRef(step.script);
|
||||
if (resolved.error) {
|
||||
warnings.push({
|
||||
|
||||
Reference in New Issue
Block a user