Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 75 additions & 16 deletions apps/automation/scripts/deploy-workflows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const N8N_URL = (process.env.N8N_URL || 'http://localhost:5678').replace(/\/$/,
const API_KEY = process.env.N8N_API_KEY;
const STRAPI_BASE_URL = (process.env.STRAPI_BASE_URL || '').replace(/\/$/, '');
const NAMESPACE = process.env.N8N_WEBHOOK_NAMESPACE || 'strapi';
// The environment tag identifies this deploy's workflow set on the shared instance.
// Matching is scoped to it so staging and production sets (same names, different tags)
// never collide: a production deploy only ever updates production-tagged workflows.
const ENV_TAG = NAMESPACE === 'strapi' ? 'production' : NAMESPACE;

if (!API_KEY) {
console.error('Error: N8N_API_KEY is not set (target instance key).');
Expand Down Expand Up @@ -139,6 +143,33 @@ async function listWorkflows() {
return all;
}

// Resolve tag names to ids on the target instance, creating any that are missing.
async function resolveTags(names) {
const all = [];
let cursor;
do {
const url = new URL(`${N8N_URL}/api/v1/tags`);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const r = await fetch(url, { headers });
if (!r.ok) throw new Error(`list tags failed: ${r.status} ${await r.text()}`);
const b = await r.json();
all.push(...(b.data ?? []));
cursor = b.nextCursor;
} while (cursor);
const byName = new Map(all.map((t) => [t.name, t.id]));
const out = new Map();
for (const n of names) {
if (byName.has(n)) out.set(n, byName.get(n));
else {
const res = await api('POST', '/api/v1/tags', { name: n });
out.set(n, res.id);
console.log(` created tag ${n}`);
}
}
return out;
}

function loadLocal() {
const out = [];
for (const entry of readdirSync(WORKFLOWS_DIR, { withFileTypes: true })) {
Expand Down Expand Up @@ -175,18 +206,42 @@ async function main() {
for (const n of wf.nodes ?? []) if (byName.has(n.name)) n.credentials = byName.get(n.name);
}

// Pass 1: create/update by name (preserving existing credential bindings).
const existing = new Map((await listWorkflows()).map((w) => [w.name, w.id]));
const ids = new Map();
// Create/update each workflow, re-linking by-id references to the target's ids
// BEFORE the PUT. n8n refuses to publish an active workflow whose executeWorkflow
// target isn't published, so the reference must already be the target's id at write
// time. Push referenced sub-workflows first (error-handler, render-email) so their
// ids are known; seed `ids` from existing so re-deploys resolve every ref up front.
// Scope existing-workflow matching to THIS environment's tag, so duplicate sets on
// one instance (same names, different env tag) stay independent. On a first deploy to
// a new environment this is empty -> every workflow is created fresh; on a re-deploy it
// matches only this env's set -> updates in place and leaves the other set untouched.
const remote = await listWorkflows();
const inEnv = remote.filter((w) => (w.tags ?? []).some((t) => t.name === ENV_TAG));
console.log(
` found ${inEnv.length} existing '${ENV_TAG}'-tagged workflow(s) on target ` +
`(${remote.length} total on instance)\n`,
);
const existing = new Map(inEnv.map((w) => [w.name, w.id]));
const ids = new Map(existing);
const byName = new Map(local.map((w) => [w.name, w]));
let created = 0, updated = 0, failed = 0;

const order = [];
for (const nm of [ERROR_HANDLER_NAME, RENDER_EMAIL_NAME]) {
if (byName.has(nm)) order.push(byName.get(nm));
}
for (const wf of local) {
if (wf.name !== ERROR_HANDLER_NAME && wf.name !== RENDER_EMAIL_NAME) order.push(wf);
}

for (const wf of order) {
try {
relink(wf, ids.get(RENDER_EMAIL_NAME), ids.get(ERROR_HANDLER_NAME));
if (existing.has(wf.name)) {
const id = existing.get(wf.name);
const id = ids.get(wf.name);
const current = await api('GET', `/api/v1/workflows/${id}`);
preserveCreds(wf, current);
await api('PUT', `/api/v1/workflows/${id}`, sanitize(wf));
ids.set(wf.name, id);
updated++; console.log(` updated ${wf.name}`);
} else {
const res = await api('POST', '/api/v1/workflows', sanitize(wf));
Expand All @@ -198,23 +253,27 @@ async function main() {
}
}

// Pass 2: re-link by-id references to the target instance's ids.
const renderId = ids.get(RENDER_EMAIL_NAME);
const errorId = ids.get(ERROR_HANDLER_NAME);
let relinked = 0;
for (const wf of local) {
if (!ids.has(wf.name)) continue;
if (relink(wf, renderId, errorId)) {
// Pass 3: tag every deployed workflow with community-hub + the environment tag.
let tagged = 0;
try {
const tagIds = await resolveTags(['community-hub', ENV_TAG]);
const body = [{ id: tagIds.get('community-hub') }, { id: tagIds.get(ENV_TAG) }];
for (const wf of order) { // only the workflows we deployed, never pre-existing ones
const id = ids.get(wf.name);
if (!id) continue;
try {
await api('PUT', `/api/v1/workflows/${ids.get(wf.name)}`, sanitize(wf));
relinked++;
await api('PUT', `/api/v1/workflows/${id}/tags`, body);
tagged++;
} catch (e) {
console.error(` relink failed ${wf.name}: ${e.message}`);
console.error(` tag failed ${wf.name}: ${e.message}`);
}
}
console.log(` tagged ${tagged} workflows: community-hub + ${ENV_TAG}`);
} catch (e) {
console.error(` tagging skipped: ${e.message}`);
}

console.log(`\nSummary: ${created} created, ${updated} updated, ${relinked} re-linked, ${failed} failed.`);
console.log(`\nSummary: ${created} created, ${updated} updated, ${tagged} tagged, ${failed} failed.`);
console.log('Imported INACTIVE. Next: bind credentials in the n8n UI (see README), then activate.');
if (failed > 0) process.exit(1);
}
Expand Down
13 changes: 11 additions & 2 deletions apps/automation/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,17 @@ pnpm --filter automation run workflows:deploy

It (1) rewrites every HTTP node's base URL from the committed `http://localhost:1337`
placeholder to `STRAPI_BASE_URL`, (2) rewrites webhook paths to
`<namespace>/<event>`, and (3) re-points `executeWorkflow → render-email` and
`Settings → Error Workflow → error-handler` to the target instance's ids.
`<namespace>/<event>`, (3) re-points `executeWorkflow → render-email` and
`Settings → Error Workflow → error-handler` to the target instance's ids, and
(4) tags every workflow `community-hub` + the environment tag (`staging`, or
`production` when the namespace is `strapi`), creating the tags if missing.

> **Sets are keyed by the environment tag, not by name.** The staging and production
> sets carry identical workflow names, so the deploy decides "create vs update" by the
> **env tag** (`staging`/`production`), never by name. A production deploy only ever
> updates `production`-tagged workflows — it creates a fresh set on first run and leaves
> the staging set untouched. (The two coexist by tag + folder; n8n allows duplicate
> names.) This is why the tags matter operationally — don't strip them.

> **Plain `workflows:import`** is the no-substitution variant (local dev): matches by
> name, updates in place, but leaves the `localhost` base URL and `strapi/` paths and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
],
"parameters": {
"language": "javaScript",
"jsCode": "const item = $input.first().json;\n\nconst shell = `<!doctype html>\n<html>\n<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#212134;max-width:600px;margin:0 auto;padding:24px;\">\n <header style=\"border-bottom:1px solid #dcdce4;padding-bottom:16px;margin-bottom:24px;\">\n <strong style=\"color:#4945ff;\">Strapi Community</strong>\n </header>\n <main>${item.body}</main>\n <footer style=\"border-top:1px solid #dcdce4;margin-top:32px;padding-top:16px;font-size:12px;color:#666687;\">\n You're receiving this because you interacted with the Strapi community hub.\n </footer>\n</body>\n</html>`;\n\nreturn [{ json: { ...item, html_body: shell } }];"
"jsCode": "const item = $input.first().json;\n\n// Email body is Strapi richtext (markdown). Convert to HTML before wrapping.\n// HTML-escape first (the body contains interpolated reviewer feedback / decline reasons).\nfunction escapeHtml(s) {\n return String(s == null ? '' : s)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;');\n}\n\nfunction inline(s) {\n // s is already HTML-escaped; '>' is now '&gt;'\n return s\n .replace(/`([^`]+)`/g, '<code style=\"background:#f6f6f9;padding:1px 4px;border-radius:3px;\">$1</code>')\n .replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')\n .replace(/(^|[^*])\\*([^*\\n]+)\\*(?!\\*)/g, '$1<em>$2</em>')\n .replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href=\"$1\" style=\"color:#4945ff;\">$1</a>');\n}\n\nfunction markdownToHtml(md) {\n const esc = escapeHtml(String(md || '').trim());\n const blocks = esc.split(/\\n\\s*\\n/);\n return blocks.map((block) => {\n const lines = block.split('\\n');\n if (lines.length && lines.every((l) => /^\\s*&gt;\\s?/.test(l))) {\n const inner = lines.map((l) => l.replace(/^\\s*&gt;\\s?/, '')).join('<br>');\n return `<blockquote style=\"margin:0 0 16px;padding:8px 16px;border-left:3px solid #dcdce4;color:#666687;\">${inline(inner)}</blockquote>`;\n }\n const h = block.match(/^(#{1,3})\\s+([\\s\\S]+)$/);\n if (h && lines.length === 1) {\n const lvl = Math.min(h[1].length + 1, 4);\n return `<h${lvl} style=\"margin:0 0 12px;\">${inline(h[2])}</h${lvl}>`;\n }\n return `<p style=\"margin:0 0 16px;line-height:1.5;\">${inline(lines.join('<br>'))}</p>`;\n }).join('\\n');\n}\n\nconst bodyHtml = markdownToHtml(item.body);\n\nconst shell = `<!doctype html>\n<html>\n<body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#212134;max-width:600px;margin:0 auto;padding:24px;\">\n <header style=\"border-bottom:1px solid #dcdce4;padding-bottom:16px;margin-bottom:24px;\">\n <strong style=\"color:#4945ff;\">Strapi Community</strong>\n </header>\n <main>${bodyHtml}</main>\n <footer style=\"border-top:1px solid #dcdce4;margin-top:32px;padding-top:16px;font-size:12px;color:#666687;\">\n You're receiving this because you interacted with the Strapi community hub.\n </footer>\n</body>\n</html>`;\n\nreturn [{ json: { ...item, html_body: shell } }];"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/automation/workflows/security-scan/workflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@
],
"parameters": {
"language": "javaScript",
"jsCode": "try {\nconst merged = $input.first().json;\n\nfunction pick(stageName) {\n const stages = Array.isArray(merged.stage) ? merged.stage : [merged.stage];\n const results = Array.isArray(merged.result) ? merged.result : [merged.result];\n for (let i = 0; i < stages.length; i++) {\n if (stages[i] === stageName) return results[i];\n }\n return null;\n}\n\nconst depsR = pick('dependencies');\nconst aiR = pick('ai_analysis');\n\nfunction stagePassed(r) {\n if (!r) return null;\n if (r.skipped) return null;\n if (typeof r.passed === 'boolean') return r.passed;\n return null;\n}\n\nconst summary = {\n runAt: new Date().toISOString(),\n dependencies_passed: stagePassed(depsR),\n ai_risk_level: aiR?.parsed?.risk_level ?? null,\n ai_recommendation: aiR?.parsed?.recommendation ?? null,\n ai_files_scanned: aiR?.files_scanned ?? null,\n ai_files_available: aiR?.files_available ?? null,\n ai_scan_source: aiR?.scan_source ?? null,\n ai_bytes_scanned: aiR?.bytes_scanned ?? null,\n vulnerable_dep_count: depsR?.vulnerable_count ?? null,\n ai_concern_count: Array.isArray(aiR?.parsed?.concerns) ? aiR.parsed.concerns.length : null,\n registry: depsR?.registry ?? null,\n registry_available: depsR?.registry_available ?? null,\n not_implemented: depsR?.not_implemented ?? null,\n package_name: depsR?.package_name ?? null,\n version: depsR?.version ?? null,\n has_install_scripts: depsR?.has_install_scripts ?? null,\n install_scripts: depsR?.install_scripts ?? null,\n cross_check_mismatch_count: Array.isArray(depsR?.cross_check_mismatches) ? depsR.cross_check_mismatches.length : null,\n};\n\nreturn [{ json: { stage: 'summary', result: summary, status: 'completed' } }];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: summary-1\n return [{ json: { stage: \"summary\", result: { runAt: new Date().toISOString(), error: errMsg, passed: false }, status: \"failed\" } }];\n}"
"jsCode": "try {\nconst depsR = $('Format Package Scan Result').first().json.result ?? null;\nconst aiR = $('Format AI Result').first().json.result ?? null;\n\nfunction stagePassed(r) {\n if (!r) return null;\n if (r.skipped) return null;\n if (typeof r.passed === 'boolean') return r.passed;\n return null;\n}\n\nconst summary = {\n runAt: new Date().toISOString(),\n dependencies_passed: stagePassed(depsR),\n ai_risk_level: aiR?.parsed?.risk_level ?? null,\n ai_recommendation: aiR?.parsed?.recommendation ?? null,\n ai_files_scanned: aiR?.files_scanned ?? null,\n ai_files_available: aiR?.files_available ?? null,\n ai_scan_source: aiR?.scan_source ?? null,\n ai_bytes_scanned: aiR?.bytes_scanned ?? null,\n vulnerable_dep_count: depsR?.vulnerable_count ?? null,\n ai_concern_count: Array.isArray(aiR?.parsed?.concerns) ? aiR.parsed.concerns.length : null,\n registry: depsR?.registry ?? null,\n registry_available: depsR?.registry_available ?? null,\n not_implemented: depsR?.not_implemented ?? null,\n package_name: depsR?.package_name ?? null,\n version: depsR?.version ?? null,\n has_install_scripts: depsR?.has_install_scripts ?? null,\n install_scripts: depsR?.install_scripts ?? null,\n cross_check_mismatch_count: Array.isArray(depsR?.cross_check_mismatches) ? depsR.cross_check_mismatches.length : null,\n};\n\nreturn [{ json: { stage: 'summary', result: summary, status: 'completed' } }];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n return [{ json: { stage: \"summary\", result: { runAt: new Date().toISOString(), error: errMsg, passed: false }, status: \"failed\" } }];\n}"
},
"onError": "continueRegularOutput"
},
Expand Down
44 changes: 43 additions & 1 deletion apps/cms/src/components/sections/highlights.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@
"enum": [
"packages_highlighted",
"packages_newest",
"packages_selection",
"templates_highlighted",
"templates_newest",
"templates_selection",
"integrations_highlighted",
"integrations_newest",
"integrations_selection",
"recipes_highlighted",
"recipes_newest",
"showcases_highlighted",
Expand All @@ -37,14 +40,53 @@
"required": true,
"default": 2,
"min": 2,
"max": 6
"max": 6,
"conditions": {
"visible": {
"and": [
{ "!=": [{ "var": "query" }, "packages_selection"] },
{ "!=": [{ "var": "query" }, "templates_selection"] },
{ "!=": [{ "var": "query" }, "integrations_selection"] }
]
}
}
},
"grid": {
"type": "integer",
"required": true,
"default": 2,
"min": 2,
"max": 4
},
"packages": {
"type": "relation",
"relation": "oneToMany",
"target": "api::package.package",
"conditions": {
"visible": {
"==": [{ "var": "query" }, "packages_selection"]
}
}
},
"templates": {
"type": "relation",
"relation": "oneToMany",
"target": "api::template.template",
"conditions": {
"visible": {
"==": [{ "var": "query" }, "templates_selection"]
}
}
},
"integrations": {
"type": "relation",
"relation": "oneToMany",
"target": "api::integration.integration",
"conditions": {
"visible": {
"==": [{ "var": "query" }, "integrations_selection"]
}
}
}
},
"config": {}
Expand Down
4 changes: 4 additions & 0 deletions apps/cms/src/components/shared/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"featured": {
"type": "boolean",
"default": false
},
"recommended": {
"type": "boolean",
"default": false
}
},
"config": {}
Expand Down
10 changes: 10 additions & 0 deletions apps/cms/types/generated/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,22 @@ export interface SectionsHighlights extends Struct.ComponentSchema {
number
> &
Schema.Attribute.DefaultTo<2>;
integrations: Schema.Attribute.Relation<
'oneToMany',
'api::integration.integration'
>;
packages: Schema.Attribute.Relation<'oneToMany', 'api::package.package'>;
query: Schema.Attribute.Enumeration<
[
'packages_highlighted',
'packages_newest',
'packages_selection',
'templates_highlighted',
'templates_newest',
'templates_selection',
'integrations_highlighted',
'integrations_newest',
'integrations_selection',
'recipes_highlighted',
'recipes_newest',
'showcases_highlighted',
Expand All @@ -89,6 +97,7 @@ export interface SectionsHighlights extends Struct.ComponentSchema {
]
> &
Schema.Attribute.Required;
templates: Schema.Attribute.Relation<'oneToMany', 'api::template.template'>;
title: Schema.Attribute.String & Schema.Attribute.Required;
};
}
Expand Down Expand Up @@ -137,6 +146,7 @@ export interface SharedLabels extends Struct.ComponentSchema {
attributes: {
featured: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
official: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
recommended: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<false>;
};
}

Expand Down
18 changes: 10 additions & 8 deletions apps/web/src/components/content/avatar-pile/avatar-pile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { Data } from "@strapi/types";
import Image from "next/image";
import Link from "next/link";
import { cmsImageUrl } from "@/features/cms/lib/image-url";
import type { Owner } from "@/utils/types";

type Props = {
items: Data.ContentType<"plugin::better-auth.user">[];
items: Owner[];
size?: "S" | "L";
clickable?: boolean;
white?: boolean;
Expand All @@ -14,11 +15,7 @@ type Props = {
// 5 slots: 18 + 24 + 32 + 24 + 18 = 116%; 4 overlaps × 6% = 24% → net ≈ 92%.
const SLOT_WIDTHS = ["18%", "24%", "32%", "24%", "18%"];

const AvatarLarge = ({
items,
}: {
items: Data.ContentType<"plugin::better-auth.user">[];
}) => {
const AvatarLarge = ({ items }: { items: Owner[] }) => {
const slots = items.filter(Boolean).slice(0, 5);
const centerIdx = Math.floor((slots.length - 1) / 2);

Expand All @@ -29,7 +26,9 @@ const AvatarLarge = ({
const configIdx = 2 + distFromCenter;
const width = SLOT_WIDTHS[configIdx] ?? "18%";
const zIndex = 10 - Math.abs(distFromCenter);
const avatarUrl = m.image;
const imageUrl = "image" in m ? m.image : undefined;
const logoUrl = "logo" in m ? m.logo : undefined;
const avatarUrl = (imageUrl || logoUrl) as string;

return (
<div
Expand Down Expand Up @@ -76,7 +75,10 @@ const AvatarPile = ({ items, clickable, size = "S", white = false }: Props) => {
.filter(Boolean)
.slice(0, 5)
.map((m, i) => {
const avatarUrl = m.image;
const imageUrl = "image" in m ? m.image : undefined;
const logoUrl = "logo" in m ? m.logo : undefined;
const avatarUrl = (imageUrl || logoUrl) as string;

return avatarUrl ? (
<Wrapper
className="flex items-center"
Expand Down
Loading
Loading