feat(scripts): add sidecar icons and tags in the admin UI

Serve optional PNG/JPG next to each script and show meta tags in lists, cards, and the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-15 21:21:24 +07:00
co-authored by Cursor
parent bccbf551e3
commit cae308359a
15 changed files with 256 additions and 47 deletions
+31
View File
@@ -55,11 +55,42 @@ export function writeScript(name, content) {
fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8");
}
/**
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
* @returns {{ filePath: string, contentType: string } | null}
*/
export function resolveScriptIcon(name) {
assertScriptName(name);
const base = name.slice(0, -3);
for (const { ext, contentType } of [
{ ext: "png", contentType: "image/png" },
{ ext: "jpg", contentType: "image/jpeg" },
]) {
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
return { filePath, contentType };
}
}
return null;
}
export function scriptHasIcon(name) {
return resolveScriptIcon(name) != null;
}
export function deleteScript(name) {
assertScriptName(name);
const filePath = path.join(SCRIPTS_DIR, name);
if (!fs.existsSync(filePath)) return false;
const icon = resolveScriptIcon(name);
fs.unlinkSync(filePath);
if (icon) {
try {
fs.unlinkSync(icon.filePath);
} catch {
// ignore missing icon
}
}
return true;
}
+1
View File
@@ -87,6 +87,7 @@ async function fetchHtml(ctx) {
fetchHtml.meta = {
description: "Fetch HTML and optionally select elements or transform with JSONata",
previewConfigKey: "url",
tags: ["HTTP"],
config: {
url: { type: "string", required: true, description: "Page URL" },
selector: { type: "string", required: false, description: "CSS selector" },
+1
View File
@@ -116,6 +116,7 @@ async function fetchHttp(ctx) {
fetchHttp.meta = {
description: "Fetch a URL and store the response body.",
previewConfigKey: "url",
tags: ["HTTP"],
config: {
url: { type: "string", required: true, description: "Request URL" },
method: {
@@ -69,6 +69,7 @@ async function fetchRssFeed(ctx) {
fetchRssFeed.meta = {
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
previewConfigKey: "url",
tags: ["RSS"],
config: {
url: { type: "string", required: false, description: "Feed URL (or pass data.url)" },
outputVar: {
@@ -29,6 +29,7 @@ triggerWorkflow.meta = {
description:
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.",
previewConfigKey: "name",
tags: ["trigger"],
config: {
name: {
type: "string",
+19 -2
View File
@@ -1,3 +1,4 @@
import fs from "fs";
import {
clearScriptCache,
inspectScriptSource,
@@ -21,11 +22,27 @@ export default function scriptsPluginFactory(registry) {
content == null
? { meta: null, metaError: "script not found" }
: inspectScriptSource(name, content);
return { name, ...inspected };
return { name, hasIcon: fsStore.scriptHasIcon(name), ...inspected };
});
return { scripts };
});
fastify.get("/scripts/:name/icon", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
fsStore.assertScriptName(name);
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
const icon = fsStore.resolveScriptIcon(name);
if (icon == null) return reply.code(404).send({ error: "icon not found" });
const body = fs.readFileSync(icon.filePath);
return reply
.type(icon.contentType)
.header("Cache-Control", "private, max-age=60")
.send(body);
});
fastify.get("/scripts/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params);
try {
@@ -35,7 +52,7 @@ export default function scriptsPluginFactory(registry) {
}
const content = fsStore.readScript(name);
if (content == null) return reply.code(404).send({ error: "script not found" });
return { name, content, ...inspectScriptSource(name, content) };
return { name, content, hasIcon: fsStore.scriptHasIcon(name), ...inspectScriptSource(name, content) };
});
fastify.put("/scripts/:name", async (req, reply) => {