diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 04a6dee..7bdd5d6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -31,13 +31,6 @@ jobs: working-directory: scripts run: npm test - # We deliberately do NOT run `npm install` on each template in CI: - # contributors install locally before opening a PR, package-lock.json - # makes the install reproducible, and the slow per-template install - # would dominate CI time as the registry grows. The static checks below - # (JSON parse, file existence, secret patterns, SQL parse, edge-function - # tsc) cover what install would catch except the rare "package was - # yanked from npm" case — we'll catch that via Dependabot/Snyk later. edge-function-tsc: name: tsc --noEmit on edge functions runs-on: ubuntu-latest @@ -92,3 +85,33 @@ jobs: exit 1 fi done + + publishable-builds: + name: Native template build and artifact admission + runs-on: ubuntu-latest + needs: [validate-registry] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Enable pinned package managers + run: corepack enable + - name: Build every native template with its controlled profile + run: | + { node -e "for (const item of require('./registry.json')) if (item.publishingCompatibility === 'native') console.log(item.slug + '\t' + item.buildProfile)"; printf 'react\tvite-npm-v1\ntodo\tnext-static-npm-v1\n'; } | + while IFS=$'\t' read -r slug profile; do + case "$profile" in + vite-npm-v1|next-static-npm-v1) + (cd "$slug" && npm ci --ignore-scripts && npm run build) + ;; + vite-pnpm-v1|next-static-pnpm-v1) + (cd "$slug" && pnpm install --frozen-lockfile --ignore-scripts && pnpm run build) + ;; + *) + echo "::error::$slug declares unknown controlled profile $profile" + exit 1 + ;; + esac + node scripts/verify-publishable-template.mjs "$slug" + done diff --git a/admin-dashboard/index.html b/admin-dashboard/index.html index d209b78..60d2647 100644 --- a/admin-dashboard/index.html +++ b/admin-dashboard/index.html @@ -8,6 +8,7 @@
+ diff --git a/admin-dashboard/src/lib/env.ts b/admin-dashboard/src/lib/env.ts index 6df6818..c447f44 100644 --- a/admin-dashboard/src/lib/env.ts +++ b/admin-dashboard/src/lib/env.ts @@ -5,7 +5,12 @@ const schema = z.object({ VITE_INSFORGE_ANON_KEY: z.string().min(1), }) -const parsed = schema.safeParse(import.meta.env) +const runtime = (window as Window & { __INSFORGE_RUNTIME_CONFIG__?: { apiBaseURL?: string; anonKey?: string } }).__INSFORGE_RUNTIME_CONFIG__ +const parsed = schema.safeParse({ + ...import.meta.env, + VITE_INSFORGE_URL: runtime?.apiBaseURL ?? import.meta.env.VITE_INSFORGE_URL, + VITE_INSFORGE_ANON_KEY: runtime?.anonKey ?? import.meta.env.VITE_INSFORGE_ANON_KEY, +}) if (!parsed.success) { // Surface a clear message in the browser console so missing config is obvious. diff --git a/insight-flow-agent-chat/index.html b/insight-flow-agent-chat/index.html index dcc86b7..142d841 100644 --- a/insight-flow-agent-chat/index.html +++ b/insight-flow-agent-chat/index.html @@ -12,6 +12,7 @@
+ diff --git a/insight-flow-agent-chat/src/lib/insforge.ts b/insight-flow-agent-chat/src/lib/insforge.ts index 226a81e..a684ef6 100644 --- a/insight-flow-agent-chat/src/lib/insforge.ts +++ b/insight-flow-agent-chat/src/lib/insforge.ts @@ -1,7 +1,8 @@ import { createClient } from '@insforge/sdk'; -const baseUrl = import.meta.env.VITE_INSFORGE_URL?.trim().replace(/\/$/, ''); -const anonKey = import.meta.env.VITE_INSFORGE_ANON_KEY?.trim(); +const runtime = (window as Window & { __INSFORGE_RUNTIME_CONFIG__?: { apiBaseURL?: string; anonKey?: string } }).__INSFORGE_RUNTIME_CONFIG__; +const baseUrl = (runtime?.apiBaseURL ?? import.meta.env.VITE_INSFORGE_URL)?.trim().replace(/\/$/, ''); +const anonKey = (runtime?.anonKey ?? import.meta.env.VITE_INSFORGE_ANON_KEY)?.trim(); export const connected = Boolean(baseUrl && anonKey); export const insforgeBaseUrl = baseUrl ?? ''; diff --git a/react/index.html b/react/index.html index b2bef0c..fba9ad0 100644 --- a/react/index.html +++ b/react/index.html @@ -19,6 +19,7 @@
+ diff --git a/react/src/lib/insforge.ts b/react/src/lib/insforge.ts index 25f5014..91c5375 100644 --- a/react/src/lib/insforge.ts +++ b/react/src/lib/insforge.ts @@ -3,8 +3,9 @@ import { createClient } from '@insforge/sdk'; let browserClient: ReturnType | null = null; export function getInsforgeConfig() { - const baseUrl = import.meta.env.VITE_INSFORGE_BASE_URL?.trim(); - const anonKey = import.meta.env.VITE_INSFORGE_ANON_KEY?.trim(); + const runtime = (window as Window & { __INSFORGE_RUNTIME_CONFIG__?: { apiBaseURL?: string; anonKey?: string } }).__INSFORGE_RUNTIME_CONFIG__; + const baseUrl = (runtime?.apiBaseURL ?? import.meta.env.VITE_INSFORGE_BASE_URL)?.trim(); + const anonKey = (runtime?.anonKey ?? import.meta.env.VITE_INSFORGE_ANON_KEY)?.trim(); return { baseUrl: baseUrl ?? '', diff --git a/registry.json b/registry.json index 6808785..6b44a73 100644 --- a/registry.json +++ b/registry.json @@ -5,6 +5,8 @@ "description": "Launch an online store in an afternoon. Product catalog, cart, wishlist, real Stripe Checkout with Apple Pay and Google Pay, a live order status timeline, and an admin fulfillment flow that marks orders shipped and delivered.", "category": "e-commerce", "framework": "nextjs", + "publishingCompatibility": "requires-node-runtime", + "publishingBlockers": ["Uses request-time cookies, Server Actions, and dynamic routes"], "features": ["Stripe Checkout", "Order Timeline", "Wishlist", "Admin Fulfillment", "shadcn/ui", "Auth"], "tags": ["e-commerce", "storefront", "stripe", "payments", "wishlist"], "cover": "assets/covers/e-commerce.png", @@ -18,6 +20,8 @@ "description": "Tell visitors what you're shipping before you've shipped it. Interactive product preview with tab-switching scenes, animated KPIs, pricing toggle, and a waitlist that writes straight to your database. Drop your copy in and launch.", "category": "marketing", "framework": "nextjs", + "publishingCompatibility": "requires-node-runtime", + "publishingBlockers": ["Uses request-time cookies, Server Actions, and Route Handlers"], "features": ["Waitlist Capture", "Pricing Page", "shadcn/ui", "Auth"], "tags": ["landing", "saas", "marketing", "waitlist", "shadcn"], "cover": "assets/covers/landing.png", @@ -31,6 +35,8 @@ "description": "Run a sales pipeline without paying Salesforce. Drag-and-drop kanban for leads, follow-up reminders, and a per-contact activity log — all multi-tenant from row one.", "category": "crm", "framework": "nextjs", + "publishingCompatibility": "requires-node-runtime", + "publishingBlockers": ["Uses server-only authentication, redirects, and API routes"], "features": ["dnd-kit", "Kanban Pipeline", "Auth"], "tags": ["crm", "sales", "pipeline", "leads"], "cover": "assets/covers/crm.png", @@ -44,6 +50,8 @@ "description": "Calendly meets Yelp: customers browse providers, book appointments around real availability, message about details, and leave reviews after. Two-sided RLS baked in.", "category": "marketplace", "framework": "nextjs", + "publishingCompatibility": "requires-node-runtime", + "publishingBlockers": ["Uses request-time cookies and Server Actions"], "features": ["Availability Scheduling", "Provider Reviews", "Auth"], "tags": ["booking", "appointments", "marketplace", "scheduling", "calendly"], "cover": "assets/covers/booking.png", @@ -57,6 +65,8 @@ "description": "Build a streaming AI chat app with persisted conversations and file attachments. AI and object storage are provided by the application's InsForge runtime, so generated projects only need the public InsForge endpoint and anon key.", "category": "AI", "framework": "nextjs", + "publishingCompatibility": "requires-node-runtime", + "publishingBlockers": ["Uses API routes and request-time authentication"], "features": [ "Streaming AI Chat", "Conversation History", @@ -87,6 +97,8 @@ "description": "An open-source thinking partner for your PDFs. Grounded in your own sources, with auto-generated mindmaps, spaced-repetition flashcards, two-host audio overviews, and RAG chat that highlights cited passages inside the source PDF. Self-hosted, your data, your model.", "category": "AI", "framework": "nextjs", + "publishingCompatibility": "requires-node-runtime", + "publishingBlockers": ["Uses middleware and API routes for RAG, auth, and document processing"], "features": ["RAG", "react-pdf", "OpenAI TTS", "Better Auth"], "tags": ["pdf", "study-tool", "notebooklm", "rag", "flashcards", "pgvector"], "cover": "assets/covers/ai-pdf-chatbot.png", @@ -100,6 +112,8 @@ "description": "Multi-user SaaS admin with workspace invites, sortable tables, real-time chat, charts, and a Composio-powered apps grid that pushes tasks to a chosen channel with two clicks. Vite + React + InsForge auth, database, RLS, storage, and realtime.", "category": "admin", "framework": "react", + "buildProfile": "vite-npm-v1", + "publishingCompatibility": "native", "features": ["TanStack Table", "Recharts", "Real-time Chat", "Auth", "Composio Integrations", "Outbound Actions"], "tags": ["admin", "dashboard", "shadcn", "tanstack-table", "workspace", "saas", "composio", "oauth"], "cover": "assets/covers/admin-dashboard.png", @@ -113,6 +127,8 @@ "description": "Your own Notion clone, no SaaS bill. Nested pages, BlockNote rich-text editor, image embeds, and shareable read-only links — perfect for internal wikis or docs sites.", "category": "productivity", "framework": "nextjs", + "publishingCompatibility": "requires-node-runtime", + "publishingBlockers": ["Uses request-time cookies, redirects, and Server Actions"], "features": ["BlockNote", "Nested Pages", "Public Share Links"], "tags": ["notion", "workspace", "docs", "wiki", "blocknote"], "cover": "assets/covers/workspace.png", @@ -126,6 +142,8 @@ "description": "ChatGPT 风格的 Insight Flow Agent 对话应用:登录用户在独立设置页保存 API Key 与 model/agent 参数,聊天页通过 InsForge Edge Function 流式呈现 OpenAI-compatible SSE。", "category": "AI", "framework": "react", + "buildProfile": "vite-npm-v1", + "publishingCompatibility": "native", "features": [ "ChatGPT-style UI", "Backend Agent Settings", @@ -155,6 +173,8 @@ "description": "面向可追溯网页调研的 AI Agent:通过浏览器访问多个来源,生成带引用的结论,并将每条判断关联到原始证据,便于复核与事实核查。", "category": "AI", "framework": "react", + "buildProfile": "vite-npm-v1", + "publishingCompatibility": "native", "features": [ "Browser-backed Research", "Evidence Ledger", @@ -183,6 +203,8 @@ "description": "持续监控重要网页:定期保存内容快照、对比前后变化,并由 AI 归纳变化内容、类型和优先级,减少无效提醒。", "category": "productivity", "framework": "react", + "buildProfile": "vite-npm-v1", + "publishingCompatibility": "native", "features": [ "Scheduled Page Checks", "Before and After Diffs", diff --git a/scripts/__tests__/validate-registry.test.mjs b/scripts/__tests__/validate-registry.test.mjs index 2ceadb4..b3804a3 100644 --- a/scripts/__tests__/validate-registry.test.mjs +++ b/scripts/__tests__/validate-registry.test.mjs @@ -15,6 +15,7 @@ function entry(slug, overrides = {}) { description: 'd', category: 'ai', framework: 'nextjs', + publishingCompatibility: 'unsupported', features: [], tags: [], cover: `assets/covers/${slug}.png`, diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs index b05bb21..e3286b4 100644 --- a/scripts/validate-registry.mjs +++ b/scripts/validate-registry.mjs @@ -19,6 +19,9 @@ const SCHEMA = { description: { type: 'string', minLength: 1 }, category: { type: 'string', minLength: 1 }, framework: { type: 'string', minLength: 1 }, + buildProfile: { enum: ['vite-npm-v1', 'vite-pnpm-v1', 'next-static-npm-v1', 'next-static-pnpm-v1'] }, + publishingCompatibility: { enum: ['native', 'conversion-required', 'requires-node-runtime', 'reference-only', 'unsupported'] }, + publishingBlockers: { type: 'array', uniqueItems: true, items: { type: 'string', minLength: 1 } }, features: { type: 'array', items: { type: 'string' } }, tags: { type: 'array', items: { type: 'string' } }, requiredCapabilities: { @@ -148,15 +151,31 @@ export async function validateTemplate(entry, repoRoot) { return { ok: false, errors }; } const pkgPath = join(subdir, 'package.json'); + let pkg; if (!existsSync(pkgPath)) { errors.push(`${entry.slug}/package.json: missing`); } else { try { - JSON.parse(readFileSync(pkgPath, 'utf8')); + pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch (e) { errors.push(`${entry.slug}/package.json: invalid JSON (${e.message})`); } } + if (!entry.publishingCompatibility) { + errors.push(`${entry.slug}: publishingCompatibility is required`); + } else if (entry.publishingCompatibility === 'native') { + if (!entry.buildProfile) errors.push(`${entry.slug}: native templates require a controlled buildProfile`); + const pnpm = entry.buildProfile?.includes('-pnpm-'); + const lockfile = pnpm ? 'pnpm-lock.yaml' : 'package-lock.json'; + if (!existsSync(join(subdir, lockfile))) errors.push(`${entry.slug}/${lockfile}: required by ${entry.buildProfile}`); + if (pnpm && !pkg?.packageManager?.startsWith('pnpm@')) errors.push(`${entry.slug}: packageManager must pin pnpm for a pnpm build profile`); + const allowedBuildScripts = entry.buildProfile?.startsWith('vite-') + ? ['vite build', 'tsc -b && vite build', 'vue-tsc -b && vite build'] + : ['next build']; + if (!allowedBuildScripts.includes(pkg?.scripts?.build)) errors.push(`${entry.slug}: build script is not allowed by ${entry.buildProfile}`); + } else if ((entry.publishingCompatibility === 'requires-node-runtime' || entry.publishingCompatibility === 'conversion-required') && !entry.publishingBlockers?.length) { + errors.push(`${entry.slug}: non-native templates must explain their publishing blockers`); + } if (!existsSync(join(subdir, 'LICENSE'))) { errors.push(`${entry.slug}/LICENSE: missing`); } diff --git a/scripts/verify-publishable-template.mjs b/scripts/verify-publishable-template.mjs new file mode 100644 index 0000000..82f8987 --- /dev/null +++ b/scripts/verify-publishable-template.mjs @@ -0,0 +1,43 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const slug = process.argv[2]; +const root = process.cwd(); +const registry = JSON.parse(readFileSync(join(root, 'registry.json'), 'utf8')); +const builtInProfiles = { react: 'vite-npm-v1', todo: 'next-static-npm-v1' }; +const entry = registry.find((item) => item.slug === slug) + ?? (builtInProfiles[slug] ? { slug, publishingCompatibility: 'native', buildProfile: builtInProfiles[slug] } : null); +if (!entry || entry.publishingCompatibility !== 'native') throw new Error(`${slug}: not a native template`); +const output = entry.buildProfile.startsWith('vite-') ? 'dist' : 'out'; +const outputRoot = join(root, slug, output); +if (!existsSync(join(outputRoot, 'index.html'))) throw new Error(`${slug}: ${output}/index.html missing`); +const html = readFileSync(join(outputRoot, 'index.html'), 'utf8'); +if (!html.includes('/.well-known/insforge-runtime-config.js')) throw new Error(`${slug}: runtime config script missing from production HTML`); +const isSPA = entry.buildProfile.startsWith('vite-'); +if (!isSPA && !existsSync(join(outputRoot, '404.html'))) throw new Error(`${slug}: static export must provide 404.html for non-SPA deep links`); + +const secretPatterns = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b/, + /\b(?:sk_live_|xox[abp]-)[A-Za-z0-9-]{16,}\b/, + /\bik_[A-Za-z0-9_-]{24,}\b/, +]; +let files = 0; +const visit = (directory) => { + for (const item of readdirSync(directory, { withFileTypes: true })) { + const file = join(directory, item.name); + if (item.isSymbolicLink()) throw new Error(`${slug}: production output contains symlink: ${file}`); + if (item.isDirectory()) visit(file); + else if (item.isFile()) { + files += 1; + const body = readFileSync(file); + if (body.length > 20 * 1024 * 1024) throw new Error(`${slug}: output file exceeds 20 MiB: ${file}`); + const text = body.toString('utf8'); + if (secretPatterns.some((pattern) => pattern.test(text))) throw new Error(`${slug}: production output contains credential material: ${file}`); + } + } +}; +visit(outputRoot); +if (!files || statSync(outputRoot).isSymbolicLink()) throw new Error(`${slug}: invalid production output`); +const deepLinkResult = isSPA ? 'index.html (SPA fallback)' : '404.html (ordinary static routing)'; +console.log(`${slug}: ${entry.buildProfile} admission passed (${files} files in ${output}; / => index.html; /admission-deep-link => ${deepLinkResult})`); diff --git a/todo/next.config.ts b/todo/next.config.ts index c8487dc..4e3a160 100644 --- a/todo/next.config.ts +++ b/todo/next.config.ts @@ -1,6 +1,8 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + output: "export", + images: { unoptimized: true }, webpack: (config, { dev }) => { if (dev) { config.cache = false; diff --git a/todo/src/app/insforge-client.ts b/todo/src/app/insforge-client.ts index ddfb8eb..8636a2d 100644 --- a/todo/src/app/insforge-client.ts +++ b/todo/src/app/insforge-client.ts @@ -1,6 +1,9 @@ import { createClient } from "@insforge/sdk"; +const runtime = typeof window === "undefined" ? undefined : + (window as Window & { __INSFORGE_RUNTIME_CONFIG__?: { apiBaseURL?: string; anonKey?: string } }).__INSFORGE_RUNTIME_CONFIG__; + export const insforge = createClient({ - baseUrl: process.env.NEXT_PUBLIC_INSFORGE_URL!, - anonKey: process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY!, + baseUrl: runtime?.apiBaseURL ?? process.env.NEXT_PUBLIC_INSFORGE_URL ?? "https://placeholder.invalid", + anonKey: runtime?.anonKey ?? process.env.NEXT_PUBLIC_INSFORGE_ANON_KEY ?? "anon_placeholder", }); diff --git a/todo/src/app/layout.tsx b/todo/src/app/layout.tsx index 902fadc..ce9d07e 100644 --- a/todo/src/app/layout.tsx +++ b/todo/src/app/layout.tsx @@ -13,6 +13,7 @@ export default function RootLayout({ }>) { return ( + diff --git a/web-research-agent/src/lib/insforge.ts b/web-research-agent/src/lib/insforge.ts index 3ea75aa..4849176 100644 --- a/web-research-agent/src/lib/insforge.ts +++ b/web-research-agent/src/lib/insforge.ts @@ -1,7 +1,8 @@ import { createClient } from "@insforge/sdk"; -const baseUrl = import.meta.env.VITE_INSFORGE_URL?.trim(); -const anonKey = import.meta.env.VITE_INSFORGE_ANON_KEY?.trim(); +const runtime = (window as Window & { __INSFORGE_RUNTIME_CONFIG__?: { apiBaseURL?: string; anonKey?: string } }).__INSFORGE_RUNTIME_CONFIG__; +const baseUrl = (runtime?.apiBaseURL ?? import.meta.env.VITE_INSFORGE_URL)?.trim(); +const anonKey = (runtime?.anonKey ?? import.meta.env.VITE_INSFORGE_ANON_KEY)?.trim(); export const connected = Boolean(baseUrl && anonKey); export const insforge = connected ? createClient({ baseUrl, anonKey }) : null; diff --git a/website-change-monitor/index.html b/website-change-monitor/index.html index 6b9c00c..2385a43 100644 --- a/website-change-monitor/index.html +++ b/website-change-monitor/index.html @@ -8,6 +8,7 @@
+ diff --git a/website-change-monitor/src/lib/insforge.ts b/website-change-monitor/src/lib/insforge.ts index 3ea75aa..4849176 100644 --- a/website-change-monitor/src/lib/insforge.ts +++ b/website-change-monitor/src/lib/insforge.ts @@ -1,7 +1,8 @@ import { createClient } from "@insforge/sdk"; -const baseUrl = import.meta.env.VITE_INSFORGE_URL?.trim(); -const anonKey = import.meta.env.VITE_INSFORGE_ANON_KEY?.trim(); +const runtime = (window as Window & { __INSFORGE_RUNTIME_CONFIG__?: { apiBaseURL?: string; anonKey?: string } }).__INSFORGE_RUNTIME_CONFIG__; +const baseUrl = (runtime?.apiBaseURL ?? import.meta.env.VITE_INSFORGE_URL)?.trim(); +const anonKey = (runtime?.anonKey ?? import.meta.env.VITE_INSFORGE_ANON_KEY)?.trim(); export const connected = Boolean(baseUrl && anonKey); export const insforge = connected ? createClient({ baseUrl, anonKey }) : null;