From 322f72e4b3fd03d1009eec23edd0ed7b1654d1a8 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:33:21 +0000 Subject: [PATCH 1/8] docs: add LICENSE, SECURITY, CONTRIBUTING, CODEOWNERS, and templates Public-repo essentials: MIT LICENSE; SECURITY.md (private disclosure + no-secrets policy); CONTRIBUTING.md (how to add a template, slug rule, no-secrets, lockfile); CODEOWNERS gating build scripts/workflows/config; PR + issue templates with a secrets/build-command checklist. --- .github/CODEOWNERS | 12 +++++++ .github/ISSUE_TEMPLATE/bug_report.md | 30 ++++++++++++++++ .github/ISSUE_TEMPLATE/new_template.md | 23 ++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 18 ++++++++++ CONTRIBUTING.md | 48 ++++++++++++++++++++++++++ LICENSE | 21 +++++++++++ SECURITY.md | 30 ++++++++++++++++ 7 files changed, 182 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/new_template.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9b6456f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Default owner for everything. +* @miguelrisero + +# Build system, dev server, CI, and hub config need maintainer review — +# these run code at build time and govern every template's behavior. +/build.mjs @miguelrisero +/build-node-functions.mjs @miguelrisero +/dev.mjs @miguelrisero +/dev-node-functions.mjs @miguelrisero +/vercel.json @miguelrisero +/externals.json @miguelrisero +/.github/ @miguelrisero diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9830c81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Something in the hub or build system isn't working +title: "[bug] " +labels: bug +--- + +**What happened** + + + +**Steps to reproduce** + +1. +2. +3. + +**Expected vs actual** + +**Environment** + +- Node version: +- OS: +- Template/project (if specific): + +**Build output / logs** + +``` + +``` diff --git a/.github/ISSUE_TEMPLATE/new_template.md b/.github/ISSUE_TEMPLATE/new_template.md new file mode 100644 index 0000000..0c62704 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_template.md @@ -0,0 +1,23 @@ +--- +name: New template proposal +about: Propose a new template to add under projects/ +title: "[template] " +labels: template +--- + +**Template name (slug)** + + + +**Type** + + + +**What it demonstrates** + + + +**Dependencies / build command** + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..059f974 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## What & why + + + +## Checklist + +- [ ] `npm run build` passes locally +- [ ] New template (if any) lives under `projects//` with a slug name + (`[a-z0-9-]`) and a committed lockfile +- [ ] **No secrets / keys / real customer or internal data / private hostnames** + — in files or history (this repo is public + indexable) +- [ ] Build command / install scripts are limited to what the template needs +- [ ] Docs updated if behavior changed (README / CLAUDE.md) + +## Notes for reviewers + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fcf018f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing + +Thanks for adding to **Runflow Templates**. Each folder under `projects/` is an +independent template that builds and deploys with the rest of the hub. + +## Add a template + +1. Create a folder under `projects//`. + - `` becomes the public URL path, so use a slug: **lowercase letters, + digits, and hyphens only** (`^[a-z0-9][a-z0-9-]*$`). The build skips + anything else. +2. Add your files. If it needs a build step, include a `package.json` (and a + committed lockfile — see "Reproducible installs" below). +3. Run `npm run build` from the repo root and confirm your template appears + under `.vercel/output/static//`. +4. Open a PR. CI runs `npm run build`; it must pass. + +The build auto-detects the type (`static`, `vite`, `next`, `nuxt`, +`nuxt-server`, `custom`, `node-functions`) — see the [README](README.md) for the +detection table and `template.config.json` options. + +## Ground rules + +- **No secrets, ever.** No API keys, tokens, real customer/internal data, + private hostnames, or internal screenshots — in files *or* commit history. + This repo is public and indexable. +- **Templates run code at build.** Your `package.json` scripts and any + `template.config.json` `buildCmd` execute in CI and on the deploy host. Keep + them to what your template needs; reviewers will scrutinize build commands. +- **Reproducible installs.** Commit a lockfile (`package-lock.json` / + `pnpm-lock.yaml` / `bun.lockb`) so builds are deterministic. +- **Keep it self-contained.** A template should build from its own folder with + no reach outside it. + +## Commit / PR norms + +- Small, focused commits with clear messages (Conventional Commits style: + `feat:`, `fix:`, `chore:`, `docs:`). +- Fill out the PR template checklist. +- Changes to `build*.mjs`, `dev*.mjs`, `.github/**`, `vercel.json`, or + `externals.json` require maintainer (CODEOWNERS) review. + +## Local development + +```bash +npm install # hub deps (esbuild) +npm run dev # build + serve at http://localhost:3000 +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e096bce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Runflow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bbb6e53 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Reporting a vulnerability + +Please **do not** open a public issue for security problems. Instead, use +GitHub's private vulnerability reporting (the **Security → Report a +vulnerability** tab on this repo) or email **security@runflow.io**. + +We aim to acknowledge reports within 3 business days. + +## Scope + +This repo is a **public template hosting hub**. The build orchestrator +(`build.mjs` / `build-node-functions.mjs`) and the CI pipeline are in scope. +Individual templates under `projects/` are examples — report issues in them too, +but treat their dependencies as third-party. + +## Important notes for contributors + +- **Templates execute code at build time.** Adding a folder under `projects/` + means its `package.json` install scripts and build command run in CI and on + the deploy host. Only the maintainers listed in `CODEOWNERS` can approve + changes to build scripts and workflows. +- **Never commit secrets.** Real credentials, API keys, private endpoints, or + internal hostnames must never appear in this public repo (including git + history). Use `.env` locally (it is gitignored) and your host's environment + settings in production. `CRON_SECRET` in `.env.example` is a placeholder. +- The auto-generated landing page and every hosted template are **public and + indexable**. Do not put anything behind a "password" in client-side code and + assume it is protected — it is not. Gate sensitive surfaces server-side. From b8c69756ea554edd7a1f98af0c799d5aadf61fce Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:33:21 +0000 Subject: [PATCH 2/8] ci: harden workflow and add Dependabot Least-privilege `permissions: contents: read`, concurrency cancellation, and SHA-pinned actions (checkout v6.0.3, setup-node v6.4.0) so tag re-points can't change CI. Dependabot covers github-actions + npm (root and projects/*). --- .github/dependabot.yml | 20 ++++++++++++++++++++ .github/workflows/ci.yml | 20 +++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0dd648 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + # Keep the SHA-pinned GitHub Actions current. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + commit-message: + prefix: ci + + # Hub dependencies (esbuild) + every template's package.json. + - package-ecosystem: npm + directories: + - "/" + - "/projects/*" + schedule: + interval: weekly + commit-message: + prefix: chore + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28e7e8b..826a718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,28 @@ on: branches: [main] pull_request: +# Least privilege: the build needs only to read the checkout. No write scopes, +# so a compromised step or a malicious fork-PR build can't push, comment, or +# touch other repo state with the GITHUB_TOKEN. +permissions: + contents: read + +# Cancel superseded runs on the same ref so spammy PRs can't pile up runners. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: name: Build & verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + # Actions pinned to full commit SHAs (not @v6 tags) so a tag re-point on + # the action's repo can't silently change what runs here. Dependabot + # (github-actions ecosystem) keeps these SHAs current. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.3 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm @@ -46,5 +60,5 @@ jobs: done fi - - name: Build all prototypes + - name: Build all templates run: npm run build From e9d76095bc39e83b2f4eb863cade45d353032990 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:35:31 +0000 Subject: [PATCH 3/8] fix: treat project folder names and template metadata as untrusted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate project folder names against a strict slug (^[a-z0-9][a-z0-9-]*$) at discovery and skip the rest — closes the command-injection vector where a folder name was interpolated into `sh -c` (`npx vite build --base //`). Extend the static-copy filter to drop secret-looking files (.env*, .npmrc, *.pem/*.key, ssh keys) so a template can't publish credentials. HTML-escape project name/title/type and section titles in the generated landing index, and allowlist link schemes (internal paths + http(s) only) so externals.json can't inject script/markup. --- build.mjs | 54 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/build.mjs b/build.mjs index 52868a0..00ed031 100644 --- a/build.mjs +++ b/build.mjs @@ -40,6 +40,38 @@ const PROJECTS_DIR = join(ROOT, "projects"); const DIST_DIR = join(ROOT, "dist"); const VERCEL_OUTPUT = join(ROOT, ".vercel", "output"); +// --- Untrusted-input guards ------------------------------------------------- +// A project folder name becomes a URL path AND is interpolated into shell build +// commands (`npx vite build --base // ...`) and the generated landing +// HTML. Treat it as untrusted: allow a strict slug only; anything else is +// skipped at discovery, which closes the command-injection vector. +const VALID_PROJECT_NAME = /^[a-z0-9][a-z0-9-]*$/; +const isValidProjectName = (name) => VALID_PROJECT_NAME.test(name); + +// HTML-escape text and double-quoted attribute values for the generated index. +// Project names/titles/types and externals.json come from repo content (a +// contributor PR), so never interpolate them raw — that would be stored XSS. +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// Allow only internal (root-relative) paths and http(s) URLs as link targets — +// blocks javascript:/data: scheme injection from externals.json. +function safeHref(href) { + const h = String(href ?? ""); + if (h.startsWith("/")) return h; + try { + const { protocol } = new URL(h); + if (protocol === "http:" || protocol === "https:") return h; + } catch { /* not a valid absolute URL */ } + return "#"; +} + async function detectProjectType(projectDir) { const configPath = join(projectDir, "template.config.json"); if (existsSync(configPath)) { @@ -99,9 +131,15 @@ async function buildProject(name, projectDir, config) { case "static": { await cp(projectDir, outputTarget, { recursive: true, + // Denylist build junk + anything that looks like a secret, so a template + // can't accidentally publish credentials into the public output. filter: (src) => { const base = src.split("/").pop(); - return !["node_modules", ".git", "template.config.json"].includes(base); + if (["node_modules", ".git", "template.config.json", ".npmrc", ".DS_Store"].includes(base)) return false; + if (base.startsWith(".env")) return false; // .env, .env.local, .env.* + if (/\.(pem|key|log)$/i.test(base)) return false; // keys, certs, logs + if (base === "id_rsa" || base === "id_ed25519") return false; + return true; }, }); break; @@ -225,18 +263,18 @@ async function buildLandingPage(projects, externals) { } const renderCard = (p) => ` - -
${p.name}${p.external ? ' ' : ""}
+
+
${escapeHtml(p.name)}${p.external ? ' ' : ""}
- ${p.type} - ${p.title ? `${p.title}` : ""} + ${escapeHtml(p.type)} + ${p.title ? `${escapeHtml(p.title)}` : ""}
`; const sectionBlocks = [...sectionMap.entries()] .map(([title, cards]) => `
-

${title}

+

${escapeHtml(title)}

${cards.map(renderCard).join("\n")}
@@ -536,6 +574,10 @@ async function main() { const projectDir = join(PROJECTS_DIR, name); const s = await stat(projectDir); if (!s.isDirectory()) continue; + if (!isValidProjectName(name)) { + console.warn(` [skip] "${name}" — folder names must match /^[a-z0-9][a-z0-9-]*$/ (lowercase, digits, hyphens). Skipping.`); + continue; + } const config = await detectProjectType(projectDir); projects.push({ name, config, dir: projectDir }); From f7ea3ef413702974b56811f10f9f1d4dd341b73d Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:41:00 +0000 Subject: [PATCH 4/8] fix: reproducible, fail-safe builds + testable config generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - detectInstallCmd uses `npm ci` for package-lock projects (was `npm install`, which floats deps) — matches build-node-functions.mjs. - run() gains a child 'error' handler + double-settle guard so a failed spawn rejects instead of hanging the build forever. - Bounded build concurrency (mapLimit) instead of unbounded Promise.allSettled. - Assembly, landing index, and config now use only successfully-built projects, so a failed project can't leak partial/empty output into the deployment. - Extract pure buildVercelConfig() (fail-fast on multi-segment dynamic [param] entries that would silently 404), and export pure helpers + guard main() so the orchestrator can be unit-tested. --- build.mjs | 136 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 88 insertions(+), 48 deletions(-) diff --git a/build.mjs b/build.mjs index 00ed031..e81d7e1 100644 --- a/build.mjs +++ b/build.mjs @@ -17,6 +17,8 @@ import { readdir, readFile, cp, rm, mkdir, stat, writeFile } from "node:fs/promi import { join, resolve } from "node:path"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; +import { availableParallelism } from "node:os"; +import { fileURLToPath } from "node:url"; import { emitNodeFunctionsProject, copyNodeFunctionsArtifacts } from "./build-node-functions.mjs"; function run(cmd, opts = {}) { @@ -26,15 +28,44 @@ function run(cmd, opts = {}) { stdio: ["ignore", "pipe", "pipe"], }); let stdout = "", stderr = ""; + // Guard against settling twice and handle spawn errors — without an `error` + // handler a failed spawn (e.g. missing `sh`, ENOMEM) leaves the promise + // pending forever and hangs the whole build. + let settled = false; + const settle = (fn, arg) => { if (!settled) { settled = true; fn(arg); } }; child.stdout.on("data", (d) => (stdout += d)); child.stderr.on("data", (d) => (stderr += d)); + child.on("error", (err) => settle(reject, new Error(`spawn failed for "${cmd}": ${err.message}`))); child.on("close", (code) => { - if (code !== 0) reject(new Error(`exit ${code}\n${stderr || stdout}`)); - else resolve({ stdout, stderr }); + if (code !== 0) settle(reject, new Error(`exit ${code}\n${stderr || stdout}`)); + else settle(resolve, { stdout, stderr }); }); }); } +// Bounded-concurrency map returning Promise.allSettled-shaped results, so a +// large hub doesn't fire one install/build per project all at once. +async function mapLimit(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const idx = next++; + try { + results[idx] = { status: "fulfilled", value: await fn(items[idx], idx) }; + } catch (reason) { + results[idx] = { status: "rejected", reason }; + } + } + }; + await Promise.all( + Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker) + ); + return results; +} + +const BUILD_CONCURRENCY = Math.max(1, availableParallelism() - 1); + const ROOT = resolve(import.meta.dirname); const PROJECTS_DIR = join(ROOT, "projects"); const DIST_DIR = join(ROOT, "dist"); @@ -108,7 +139,9 @@ async function detectProjectType(projectDir) { } function detectInstallCmd(projectDir) { - if (existsSync(join(projectDir, "package-lock.json"))) return "npm install"; + // Reproducible installs: when a lockfile exists, use the locked-install form so + // a build can't silently float to a fresh patch within the same semver range. + if (existsSync(join(projectDir, "package-lock.json"))) return "npm ci"; if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile"; if (existsSync(join(projectDir, "bun.lockb"))) return "bun install --frozen-lockfile"; return "npm install"; @@ -463,50 +496,49 @@ async function buildLandingPage(projects, externals) { await writeFile(join(staticDir, "index.html"), html); } -async function generateVercelConfig(projects, crons = []) { +// Pure: compute the Build Output API v3 config object from the project list. +// Kept side-effect-free so it can be unit-tested (see test/build.test.mjs). +function buildVercelConfig(projects, crons = []) { const serverProjects = projects.filter((p) => p.config.type === "nuxt-server"); const routes = []; - // Add routes for each server project + // API routes for each server project go to the Nitro __fallback function. for (const p of serverProjects) { - // API routes go to the Nitro __fallback function - routes.push({ - src: `/${p.name}/api/(.*)`, - dest: `/${p.name}/__fallback`, - }); + routes.push({ src: `/${p.name}/api/(.*)`, dest: `/${p.name}/__fallback` }); } - // Strip trailing slash from dynamic [param] paths in node-functions projects. - // `trailingSlash: true` 308-redirects /api/reports/UUID -> /api/reports/UUID/, - // and Vercel BOA's filesystem matcher won't pair the trailing-slash form with - // a `[param].func` entry. Rewrite the slash form back to no-slash so the - // dynamic match fires. + // node-functions dynamic routing. `trailingSlash: true` 308-redirects + // /api/x/UUID -> /api/x/UUID/, and Vercel BOA's filesystem matcher won't pair + // the slash form with a `[param].func` entry, so we route dynamic paths to the + // PARENT function and pass the captured segment via query string. const reEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const fnProjects = projects.filter((p) => p.config.type === "node-functions"); for (const p of fnProjects) { const entries = p.config.functions?.entries || []; const perEntry = p.config.functions?.perEntry || {}; for (const entry of entries) { - // Single-segment dynamic [id] entries: rewrite /// -> - // //?subId=. Vercel BOA's filesystem matcher doesn't - // auto-resolve `[param].func` dynamic routing — the .func directory name - // is treated literally — so we route dynamic paths to the PARENT function - // and pass the captured segment via query string. + // Single trailing dynamic segment: /// -> ?subId=. const m = entry.match(/^(.+?)\/\[([^\]]+)\]\.(?:mjs|js)$/); if (m) { const [, parentPath] = m; + // Fail fast on multi-segment dynamic paths — they'd silently emit a route + // to a non-existent function and 404 in prod. Use one trailing [id].mjs + // (parent reads ?subId=) or a catchAll entry instead. + if (parentPath.includes("[")) { + throw new Error( + `template.config.json (${p.name}): function entry "${entry}" has multiple dynamic [param] segments, which isn't supported. Use a single trailing [id].mjs or a catchAll entry.` + ); + } routes.push({ src: `^/${reEscape(p.name)}/${reEscape(parentPath)}/([^/]+)/?$`, dest: `/${p.name}/${parentPath}?subId=$1`, }); continue; } - // Catch-all opt-in via template.config.json: - // functions.perEntry["api/proxy.mjs"] = { catchAll: true } - // Routes /// to the function - // with the remainder in ?subPath=. Handy for API proxies that forward - // arbitrary upstream paths (e.g. /v1/resource/.../items). + // Catch-all opt-in: functions.perEntry["api/proxy.mjs"] = { catchAll: true } + // routes /// to the function with the remainder + // in ?subPath= (handy for API proxies forwarding arbitrary upstream paths). const cleanRel = entry.replace(/\.m?js$/, ""); if (perEntry[entry]?.catchAll) { routes.push({ @@ -517,25 +549,25 @@ async function generateVercelConfig(projects, crons = []) { } } - // Filesystem fallback (serves static files + node-functions automatically by path) + // Filesystem fallback (serves static files + node-functions automatically). routes.push({ handle: "filesystem" }); - // SPA fallback for server projects — Nitro serves the SPA shell + // SPA fallback for server projects — Nitro serves the SPA shell. for (const p of serverProjects) { - routes.push({ - src: `/${p.name}(?:/((?!api/).*))?`, - dest: `/${p.name}/__fallback`, - }); + routes.push({ src: `/${p.name}(?:/((?!api/).*))?`, dest: `/${p.name}/__fallback` }); } const config = { version: 3, cleanUrls: true, trailingSlash: true, routes }; if (Array.isArray(crons) && crons.length > 0) { config.crons = crons; } + return config; +} +async function generateVercelConfig(projects, crons = []) { await writeFile( join(VERCEL_OUTPUT, "config.json"), - JSON.stringify(config, null, 2) + JSON.stringify(buildVercelConfig(projects, crons), null, 2) ); } @@ -583,17 +615,15 @@ async function main() { projects.push({ name, config, dir: projectDir }); } - // Build all projects in parallel - console.log(`Found ${projects.length} project(s) — building in parallel:\n`); + // Build all projects with bounded concurrency + console.log(`Found ${projects.length} project(s) — building (up to ${BUILD_CONCURRENCY} at a time):\n`); const buildStart = Date.now(); - const results = await Promise.allSettled( - projects.map(async (p) => { - const t0 = Date.now(); - const { logs, crons } = await buildProject(p.name, p.dir, p.config); - return { ms: Date.now() - t0, logs, crons }; - }) - ); + const results = await mapLimit(projects, BUILD_CONCURRENCY, async (p) => { + const t0 = Date.now(); + const { logs, crons } = await buildProject(p.name, p.dir, p.config); + return { ms: Date.now() - t0, logs, crons }; + }); const allCrons = []; let failed = 0; @@ -629,10 +659,14 @@ async function main() { process.exitCode = 1; } + // Only assemble + index projects that actually built — never copy partial or + // empty output from a failed project into the deployment. + const built = projects.filter((_, i) => results[i].status === "fulfilled"); + // Assembly phase: populate .vercel/output/ console.log("Assembling Vercel Build Output API v3 structure...\n"); - for (const p of projects) { + for (const p of built) { const type = p.config.type || "static"; if (type === "nuxt-server") { @@ -689,7 +723,7 @@ async function main() { } // Generate landing page at .vercel/output/static/index.html - await buildLandingPage(projects, externals); + await buildLandingPage(built, externals); console.log("\nLanding page generated."); // Copy favicon if exists @@ -699,13 +733,19 @@ async function main() { } // Generate .vercel/output/config.json - await generateVercelConfig(projects, allCrons); + await generateVercelConfig(built, allCrons); console.log(`Vercel config generated${allCrons.length > 0 ? ` (${allCrons.length} cron(s))` : ""}.`); console.log(`\nBuild complete → .vercel/output/`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +// Exported for unit tests (test/build.test.mjs). +export { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, detectProjectType }; + +// Only run the build when executed directly (`node build.mjs`), not when imported. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} From 1680302f8622bfd14bf537e760c979c513da3f66 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:41:00 +0000 Subject: [PATCH 5/8] fix: dev server fails closed on CRON_SECRET in CI/prod, logs real serve path Refuse the 'local-dev-cron-secret' fallback when CI/VERCEL/NODE_ENV=production, and report the actual served directory (.vercel/output/static, dist fallback) instead of the stale 'Serving from: dist'. --- dev.mjs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/dev.mjs b/dev.mjs index da813ae..56b7c39 100644 --- a/dev.mjs +++ b/dev.mjs @@ -1,8 +1,8 @@ /** - * Local dev server for the prototypes hub + * Local dev server for Runflow Templates * - * Runs the build, then serves /dist on localhost:3000 - * with live reload on file changes. + * Runs the build, then serves .vercel/output/ (with a dist/ fallback) on + * localhost:3000, dispatching node-functions like the Vercel runtime does. */ import { createServer } from "node:http"; @@ -32,9 +32,14 @@ if (DEV_LOAD_DOTENV) { } // Apply the fallback whether or not .env existed — clean checkouts shouldn't // 401 on local cron probes just because they haven't created a .env yet. + // Never apply it in CI/production: those must fail closed on a real secret. if (!process.env.CRON_SECRET) { - process.env.CRON_SECRET = "local-dev-cron-secret"; - console.log("[dev] CRON_SECRET not set — using local fallback 'local-dev-cron-secret'"); + if (process.env.CI || process.env.VERCEL || process.env.NODE_ENV === "production") { + console.warn("[dev] CRON_SECRET not set — refusing the dev fallback in CI/production."); + } else { + process.env.CRON_SECRET = "local-dev-cron-secret"; + console.log("[dev] CRON_SECRET not set — using local fallback 'local-dev-cron-secret' (dev only)"); + } } } @@ -106,5 +111,5 @@ const server = createServer(async (req, res) => { server.listen(PORT, () => { console.log(`Dev server running at http://localhost:${PORT}`); - console.log(`Serving from: ${DIST}\n`); + console.log(`Serving ${join(VERCEL_OUTPUT, "static")} (fallback: ${DIST})\n`); }); From 4431a72408d78aab130b86c9f6b3559abe37466c Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:41:00 +0000 Subject: [PATCH 6/8] test: unit-test the build orchestrator and wire it into CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node --test coverage for escapeHtml, safeHref, slug validation, and buildVercelConfig (static, dynamic [id], catchAll, multi-segment reject, nuxt-server ordering, crons) + collectCronEntries. Adds `npm test` and a CI test step — exercises the routing/cron logic that the example projects don't. --- .github/workflows/ci.yml | 3 ++ package.json | 3 +- test/build.test.mjs | 87 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 test/build.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 826a718..3d55a37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,5 +60,8 @@ jobs: done fi + - name: Run tests + run: npm test + - name: Build all templates run: npm run build diff --git a/package.json b/package.json index c095278..5a4b7be 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "Runflow templates and demos — build many small projects and deploy them together", "scripts": { "build": "node build.mjs", - "dev": "node dev.mjs" + "dev": "node dev.mjs", + "test": "node --test" }, "devDependencies": { "esbuild": "^0.28.0" diff --git a/test/build.test.mjs b/test/build.test.mjs new file mode 100644 index 0000000..07d5cd6 --- /dev/null +++ b/test/build.test.mjs @@ -0,0 +1,87 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildVercelConfig, escapeHtml, safeHref, isValidProjectName } from "../build.mjs"; +import { collectCronEntries } from "../build-node-functions.mjs"; + +test("escapeHtml escapes HTML-significant characters", () => { + assert.equal(escapeHtml(` `; @@ -732,6 +814,29 @@ async function main() { await cp(faviconSrc, join(VERCEL_OUTPUT, "static", "favicon.ico")); } + // Selective noindex: templates that opt out via template.config.json + // `"noindex": true` get a robots meta injected and are dropped from the sitemap. + const noindexNames = new Set(built.filter((p) => p.config?.noindex).map((p) => p.name)); + for (const name of noindexNames) { + const indexPath = join(VERCEL_OUTPUT, "static", name, "index.html"); + if (!existsSync(indexPath)) continue; + const html = await readFile(indexPath, "utf-8"); + if (!/name=["']robots["']/i.test(html)) { + await writeFile(indexPath, html.replace(/]*)>/i, `\n `)); + console.log(` [${name}] Marked noindex`); + } + } + + // robots.txt (always) + sitemap.xml (when the site URL is known) + const siteUrl = getSiteUrl(); + await writeFile(join(VERCEL_OUTPUT, "static", "robots.txt"), buildRobotsTxt(siteUrl)); + if (siteUrl) { + await writeFile(join(VERCEL_OUTPUT, "static", "sitemap.xml"), buildSitemap(siteUrl, built, noindexNames)); + console.log(`robots.txt + sitemap.xml generated (${siteUrl}).`); + } else { + console.log("robots.txt generated — set SITE_URL for canonical URLs + sitemap.xml."); + } + // Generate .vercel/output/config.json await generateVercelConfig(built, allCrons); console.log(`Vercel config generated${allCrons.length > 0 ? ` (${allCrons.length} cron(s))` : ""}.`); @@ -740,7 +845,7 @@ async function main() { } // Exported for unit tests (test/build.test.mjs). -export { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, detectProjectType }; +export { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, detectProjectType, buildSitemap, buildRobotsTxt }; // Only run the build when executed directly (`node build.mjs`), not when imported. if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..053d053 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From 8ea523709b9c475b496db6bf89791daa7f7116a4 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 3 Jun 2026 09:45:33 +0000 Subject: [PATCH 8/8] test: cover sitemap/robots; docs: document SEO + SITE_URL Unit tests for buildSitemap (skips noindex) and buildRobotsTxt; README Deployment section documents SITE_URL, generated robots/sitemap, and the noindex opt-out. --- README.md | 6 +++++- test/build.test.mjs | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e283e2..0ceaae2 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,11 @@ Designed for **Vercel** (or any host that understands the Build Output API v3). - **Build command**: `npm run build` - **Output**: `.vercel/output/` (static assets + functions + `config.json`) - **Clean URLs**: `/example-next/about` resolves to `…/about.html` -- **Indexable**: no `noindex` headers — pages are public and crawlable +- **Indexable**: public and crawlable. Set `SITE_URL` (or rely on Vercel's + production URL) and the build emits `robots.txt` + `sitemap.xml` and adds + canonical/Open Graph tags. A template can opt out of indexing with + `"noindex": true` in its `template.config.json` (it's dropped from the sitemap + and gets a `robots` meta). ### Setup on Vercel diff --git a/test/build.test.mjs b/test/build.test.mjs index 07d5cd6..672fcb9 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildVercelConfig, escapeHtml, safeHref, isValidProjectName } from "../build.mjs"; +import { buildVercelConfig, escapeHtml, safeHref, isValidProjectName, buildSitemap, buildRobotsTxt } from "../build.mjs"; import { collectCronEntries } from "../build-node-functions.mjs"; test("escapeHtml escapes HTML-significant characters", () => { @@ -85,3 +85,19 @@ test("collectCronEntries prefixes project name and forces a trailing slash", () test("collectCronEntries throws when a cron is missing path or schedule", () => { assert.throws(() => collectCronEntries("p", { crons: [{ path: "/x" }] }), /schedule/); }); + +test("buildSitemap lists the landing + project roots and skips noindex projects", () => { + const xml = buildSitemap( + "https://x.test", + [{ name: "a", config: {} }, { name: "b", config: { noindex: true } }], + new Set(["b"]) + ); + assert.ok(xml.includes("https://x.test/")); + assert.ok(xml.includes("https://x.test/a/")); + assert.ok(!xml.includes("/b/")); +}); + +test("buildRobotsTxt allows all and includes Sitemap only when site URL is known", () => { + assert.ok(buildRobotsTxt("https://x.test").includes("Sitemap: https://x.test/sitemap.xml")); + assert.ok(!buildRobotsTxt("").includes("Sitemap:")); +});