generatesaas 3.24.0 → 3.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +160 -144
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{Command as Ip}from"commander";import*as vs from"@clack/prompts";import{existsSync as Ul,readdirSync as Fl,rmSync as Vl}from"fs";import{join as Bl,resolve as Kl}from"path";import{Option as X}from"commander";import*as S from"@clack/prompts";import*as kt from"@clack/prompts";import Zt from"picocolors";function Vr(e){let t=e?` GenerateSaaS v${e} `:" GenerateSaaS ";kt.intro(Zt.bgYellow(Zt.black(t)))}function Br(){kt.outro(Zt.yellow("Happy building!"))}import*as f from"@clack/prompts";import y from"picocolors";var lt={fullstack:{label:"Fullstack",hint:"Frontend hosts the API - works on serverless or long-running"},separate:{label:"Separate",hint:"Standalone Hono backend - long-running runtimes only (Docker, Render, Fly.io, Railway, Coolify, Dokploy)"}},Et={stripe:{label:"Stripe"},polar:{label:"Polar"},none:{label:"None",hint:"disable payments"}},St={smtp:{label:"SMTP",hint:"Mailpit for local dev"},ses:{label:"Amazon SES"},resend:{label:"Resend"}},Pt={sentry:{label:"Sentry",hint:"web, server and desktop - release health, tracing, alerting"},posthog:{label:"PostHog",hint:"web and server, plus LLM analytics - no Electron SDK, so a desktop build reports nothing"}},Kr={postgres:{label:"PostgreSQL",hint:"port 5432",port:5432},redis:{label:"Redis",hint:"port 6379",port:6379},inngest:{label:"Inngest",hint:"port 8288",port:8288},mailpit:{label:"Mailpit",hint:"port 1025",port:1025}};var _e={USD:{symbol:"$",name:"US Dollar",place:"left",space:!1},EUR:{symbol:"\u20AC",name:"Euro",place:"right",space:!1},GBP:{symbol:"\xA3",name:"British Pound",place:"left",space:!1},CAD:{symbol:"CA$",name:"Canadian Dollar",place:"left",space:!1},AUD:{symbol:"A$",name:"Australian Dollar",place:"left",space:!1},BRL:{symbol:"R$",name:"Brazilian Real",place:"left",space:!1},JPY:{symbol:"\xA5",name:"Japanese Yen",place:"left",space:!1}};var te={node:{label:"Node.js / Docker",hint:"long-running runtime - Render, Fly.io, Railway, Coolify, Dokploy, VPS",edgeRuntime:!1},vercel:{label:"Vercel",hint:"serverless functions",edgeRuntime:!0}},Oe={postgres:{label:"PostgreSQL (self-hosted)",hint:"local Docker, drizzle-orm/node-postgres",managed:!1,envVars:[{key:"DATABASE_URL",defaultValue:"postgres://postgres:postgres@localhost:5432/saas"}]},neon:{label:"Neon",hint:"serverless Postgres",managed:!0,envVars:[{key:"DATABASE_URL",comment:"# TODO: Add your Neon connection string"}]},supabase:{label:"Supabase",hint:"managed Postgres",managed:!0,envVars:[{key:"DATABASE_URL",comment:"# TODO: Add your Supabase connection string"}]}},xe={redis:{label:"Redis (self-hosted)",hint:"local Docker, ioredis",managed:!1,envVars:[{key:"REDIS_URL",defaultValue:"redis://localhost:6379"}]},upstash:{label:"Upstash",hint:"serverless Redis",managed:!0,envVars:[{key:"UPSTASH_REDIS_REST_URL",comment:"# TODO: Add your Upstash REST URL"},{key:"UPSTASH_REDIS_REST_TOKEN",comment:"# TODO: Add your Upstash REST token"}]}},qe=[{target:"vercel",provider:"redis",reason:"Vercel serverless cannot maintain persistent Redis connections. Consider Upstash."},{target:"vercel",provider:"postgres",reason:"Vercel serverless cannot pool connections to a self-hosted Postgres. Use Neon or Supabase - both are managed, connection-pooled Postgres."}],Je=[{architecture:"separate",target:"vercel",reason:"Standalone backends need a long-running runtime. Vercel's per-function TypeScript check conflicts with pnpm's isolated install. Use --architecture fullstack for serverless, or deploy the standalone backend to a long-running runtime (Render, Fly.io, Railway, Coolify, Dokploy, or your own VPS via Docker)."}],le={serverless:{label:"Serverless",hint:"Vercel + Neon + Upstash",values:{deploymentTarget:"vercel",databaseProvider:"neon",cacheProvider:"upstash"}},selfhost:{label:"Self-host",hint:"Docker or Node + Postgres + Redis",values:{deploymentTarget:"node",databaseProvider:"postgres",cacheProvider:"redis"}}},De=Object.keys(le);function Qt(e){let t=[];for(let r of qe)if(e.deploymentTarget===r.target&&(e.databaseProvider===r.provider||e.cacheProvider===r.provider)){let n=e.databaseProvider===r.provider?"databaseProvider":"cacheProvider";t.push(`\`${n}: "${r.provider}"\` with \`deploymentTarget: "${r.target}"\` - ${r.reason}`)}for(let r of Je)e.deploymentTarget===r.target&&e.architecture===r.architecture&&t.push(`\`architecture: "${r.architecture}"\` with \`deploymentTarget: "${r.target}"\` - ${r.reason}`);return t}var Gr=["Local file storage (sharp, geoip-lite)","SMTP email (use Resend or SES instead)","Content API git integration"];function Xe(e){let t=[];return e.databaseProvider==="postgres"&&t.push("postgres"),e.cacheProvider==="redis"&&t.push("redis"),e.emailProvider==="smtp"&&t.push("mailpit"),t.push("inngest"),t}var Ze=["fullstack","separate"],Qe=["stripe","polar","none"],et=["smtp","ses","resend"];var Hr=["claude-code","cursor","codex","gemini-cli","windsurf"],pe=["USD","EUR","GBP","CAD","AUD","BRL","JPY"],de=["node","vercel"],ue=["postgres","neon","supabase"],me=["redis","upstash"],F=["sentry","posthog"],Ne=["revenuecat","none"];function re(e){return e.paymentProvider!==void 0&&e.paymentProvider!=="none"}var er=[{key:"architecture",label:"Architecture",hint:"Fullstack (frontend hosts the API) or a standalone Hono backend.",category:"Infrastructure",kind:"enum",default:"fullstack",choices:Ze,adoptable:!1,impact:"structural",shapesTree:!0},{key:"deploymentTarget",label:"Deployment target",hint:"Node.js / Docker or Vercel serverless.",category:"Infrastructure",kind:"enum",default:"node",choices:de,adoptable:!1,impact:"config",shapesTree:!0},{key:"databaseProvider",label:"Database provider",hint:"Self-hosted PostgreSQL, Neon, or Supabase.",category:"Infrastructure",kind:"enum",default:"postgres",choices:ue,adoptable:!1,impact:"config",shapesTree:!0},{key:"cacheProvider",label:"Cache provider",hint:"Self-hosted Redis or Upstash.",category:"Infrastructure",kind:"enum",default:"redis",choices:me,adoptable:!1,impact:"config",shapesTree:!0},{key:"paymentProvider",label:"Payment provider",hint:"Stripe, Polar, or none.",category:"Features",kind:"enum",default:"none",choices:Qe,adoptable:!1,impact:"structural",shapesTree:!0},{key:"defaultCurrency",label:"Default currency",hint:"Billing and pricing currency.",category:"Features",kind:"enum",default:"USD",choices:pe,adoptable:!1,impact:"config",shapesTree:!1},{key:"emailProvider",label:"Email provider",hint:"SMTP, Amazon SES, or Resend.",category:"Features",kind:"enum",default:"smtp",choices:et,adoptable:!1,impact:"config",shapesTree:!1},{key:"observability",label:"Observability",hint:"Which services receive your app's errors, and on PostHog its LLM analytics. Pick any combination - anything you do not pick costs nothing, since its SDK and its adapter are never generated.",category:"Features",kind:"multiselect",default:[],choices:F,adoptable:!0,impact:"config",shapesTree:!0},{key:"multiTenancy",label:"Multi-tenancy (organizations)",hint:"Adds organizations - teams, members, and shared resources.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!1},{key:"projects",label:"Projects",hint:"Adds projects - a switcher that scopes a user's work inside their account or organization.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"config",shapesTree:!1},{key:"docs",label:"Docs app",hint:"Adds the self-hosted Fumadocs documentation site (apps/docs).",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"desktop",label:"Desktop app",hint:"Adds the cross-platform desktop app (apps/desktop, built with Electron).",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"mobile",label:"Mobile app",hint:"Adds the cross-platform mobile app (apps/mobile, built with Expo).",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"mobilePurchases",label:"Mobile store purchases",hint:"In-app purchases in the mobile app, through RevenueCat. Store purchases sit ON TOP of your web payment provider - a subscriber who buys in the app is the same account, billed by Apple or Google instead of by you. Off keeps the app on web billing, where the plan card says the subscription is managed on your website.",category:"Features",kind:"enum",default:()=>"none",choices:Ne,adoptable:!0,impact:"structural",shapesTree:!0,requires:e=>e.mobile===!0&&re(e),requiresLabel:"requires the mobile app and a web payment provider - store purchases are additive to the web lane, never a replacement"},{key:"ai",label:"AI features",hint:"Adds the AI surface (chat, models, automations, integrations). A desktop project ALWAYS ships the app's local AI stack: this option sets config.desktop.agents.enabled (the runtime gate on those screens). Off keeps config.ai.enabled false so the AI nav stays hidden - the AI packages still ship, because packages/translate depends on @repo/ai for the i18n pipeline every project uses.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"webAi",label:"AI in the web app",hint:"Whether the WEB app shows the AI surfaces - chat, automations, model and integration settings, and their nav entries. Off is the app-first shape: your desktop or mobile app is where your AI product lives and the website keeps account, billing and licensing only. The AI SERVICE is untouched either way (config.ai.enabled, the /api/ai routes and the desktop agents all stay exactly as your AI option set them), so both apps keep talking to the same backend. Only meaningful with AI features and at least one app - the desktop app or the mobile app.",category:"Features",kind:"boolean",default:!0,adoptable:!0,impact:"config",shapesTree:!1,requires:e=>e.ai===!0&&(e.desktop===!0||e.mobile===!0),requiresLabel:"requires AI features and an app to host them (the desktop app or the mobile app)"},{key:"credits",label:"Metered credits",hint:"Adds metered usage credits on top of subscription plans.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"config",shapesTree:!1,requires:e=>re(e),requiresLabel:"requires a payment provider"},{key:"revenueSharing",label:"Revenue sharing",hint:"Opt-in MRR leaderboard with dofollow backlinks.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"config",shapesTree:!1}];function ks(e,t){return e[t]!==void 0}function Yr(e){return er.filter(t=>t.adoptable&&!ks(e,t.key)&&(t.requires?t.requires(e):!0)).map(t=>({key:t.key,label:t.label,hint:t.hint,kind:t.kind,default:typeof t.default=="function"?t.default(e):t.default,...t.choices?{choices:t.choices}:{},impact:t.impact,...t.requiresLabel?{requiresLabel:t.requiresLabel}:{}}))}function Es(e,t){switch(e.kind){case"boolean":return typeof t=="boolean";case"enum":return typeof t=="string"&&(e.choices??[]).includes(t);case"multiselect":return Array.isArray(t)&&t.every(r=>typeof r=="string"&&(e.choices??[]).includes(r))}}function Ss(e){let t=(e.choices??[]).map(r=>JSON.stringify(r)).join(", ");switch(e.kind){case"boolean":return"true or false";case"enum":return`one of ${t}`;case"multiselect":return`an array of ${t}`}}function Wr(e){let t=[];for(let r of er){let n=e[r.key];n!==void 0&&(Es(r,n)||t.push(`\`${r.key}\` is ${JSON.stringify(n)}, but it must be ${Ss(r)}.`))}return t}function zr(e){let t={};for(let r of er)if(typeof r.default=="function"){if(r.requires&&!r.requires(e))continue;t[r.key]=r.default(e)}else t[r.key]=Array.isArray(r.default)?[...r.default]:r.default;return{...t,...e}}function tr(e,t){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||t}function At(e){return e.split("-").map(t=>t.charAt(0).toUpperCase()+t.slice(1)).join(" ")}function It(e){return/^[a-z][a-z0-9-]*$/.test(e)}function N(e){return e instanceof Error?e.message:String(e)}function pt(e){if(e.mobilePurchases!=="revenuecat")return e;if(e.mobile===!1)throw new Error("--mobile-purchases revenuecat requires the mobile app; remove --no-mobile.");if(e.paymentProvider==="none")throw new Error("--mobile-purchases revenuecat requires a payment provider (got --payment none): store purchases are additive to your web billing, never a replacement.");return e.mobile===void 0&&(e.mobile=!0),e}function dt(e){if(e.webAi!==!1)return e;if(e.ai===!1)throw new Error("--no-web-ai requires --ai (the desktop and mobile apps are where AI lives when the web surface is off).");if(e.desktop===!1&&e.mobile===!1)throw new Error("--no-web-ai requires --desktop or --mobile - with the web AI surface off, the desktop and mobile apps are the only places your AI features exist.");return e}function qr(e){if(e.webAi!==!1)return[];let t=[];return e.desktop!==!0&&e.mobile!==!0&&t.push("neither `desktop` nor `mobile` is true, so this project has no desktop or mobile app - and with the web AI surface off, those apps are the only places its AI features would exist."),t}function Jr(e){if(e.mobilePurchases!=="revenuecat")return[];let t=[];return e.mobile!==!0&&t.push("`mobile` is not true, so this project has no app to sell in - the store lane's paywall, purchase and restore surfaces all live in `apps/mobile`."),re(e)||t.push('`paymentProvider` is absent or `"none"`, and store purchases are additive to your web billing - there is nothing here for them to sit on top of.'),t}function Xr(e){let t=Ps(e,e.desktopAi);return delete e.desktopAi,t}function Ps(e,t){return e.desktop!==!0?{kind:"none"}:t===!0&&e.ai!==!0?{kind:"legacy-desktop-ai"}:t===!1&&e.ai===!0?{kind:"desktop-gains-ai"}:{kind:"none"}}function Zr(e){let t={};if(e.name!==void 0){if(!It(e.name))throw new Error(`Invalid project name "${e.name}". Use lowercase letters, numbers, and hyphens only. Must start with a letter.`);t.projectName=e.name}if(e.appName!==void 0){if(!e.appName.trim())throw new Error("App name cannot be empty.");t.appName=e.appName}if(e.location!==void 0?t.projectDir=e.location==="."?process.cwd():e.location:t.projectName!==void 0&&(t.projectDir=`./${t.projectName}`),e.architecture!==void 0&&(t.architecture=e.architecture),e.payment!==void 0&&(t.paymentProvider=e.payment),e.email!==void 0&&(t.emailProvider=e.email),e.org!==void 0&&(t.multiTenancy=e.org),e.projects!==void 0&&(t.projects=e.projects),e.docs!==void 0&&(t.docs=e.docs),e.desktop!==void 0&&(t.desktop=e.desktop),e.mobile!==void 0&&(t.mobile=e.mobile),e.mobilePurchases!==void 0){if(!Ne.includes(e.mobilePurchases))throw new Error(`Invalid mobile purchases "${e.mobilePurchases}". Valid values: ${Ne.join(", ")}`);t.mobilePurchases=e.mobilePurchases}if(e.ai!==void 0&&(t.ai=e.ai),e.webAi!==void 0&&(t.webAi=e.webAi),pt(t),dt(t),e.revenueSharing!==void 0&&(t.revenueSharing=e.revenueSharing),e.credits!==void 0){if(e.credits===!0&&e.payment==="none")throw new Error("--credits requires a payment provider (got --payment none).");t.credits=e.credits}if(e.currency!==void 0){if(!pe.includes(e.currency))throw new Error(`Invalid currency "${e.currency}". Valid values: ${pe.join(", ")}`);t.defaultCurrency=e.currency}if(e.deploy!==void 0){if(!de.includes(e.deploy))throw new Error(`Invalid deployment target "${e.deploy}". Valid values: ${de.join(", ")}`);t.deploymentTarget=e.deploy}if(e.database!==void 0){if(!ue.includes(e.database))throw new Error(`Invalid database provider "${e.database}". Valid values: ${ue.join(", ")}`);t.databaseProvider=e.database}if(e.cache!==void 0){if(!me.includes(e.cache))throw new Error(`Invalid cache provider "${e.cache}". Valid values: ${me.join(", ")}`);t.cacheProvider=e.cache}if(e.observability!==void 0){let r=e.observability.split(",").map(i=>i.trim()).filter(Boolean),n=r.filter(i=>!F.includes(i));if(n.length>0)throw new Error(`Invalid observability provider ${JSON.stringify(n.join(", "))}. Valid values: ${F.join(", ")}`);t.observability=r}if(e.preset!==void 0){if(!De.includes(e.preset))throw new Error(`Invalid preset "${e.preset}". Valid values: ${De.join(", ")}`);let r=le[e.preset].values;t.deploymentTarget??=r.deploymentTarget,t.databaseProvider??=r.databaseProvider,t.cacheProvider??=r.cacheProvider}if(e.demo===!0&&(t.demo=!0),e.baseUrl!==void 0){let r=e.baseUrl.trim();if(r==="")throw new Error("--base-url cannot be empty. Provide an absolute URL like https://example.com.");let n;try{n=new URL(r)}catch{throw new Error(`Invalid --base-url "${e.baseUrl}". Must be an absolute URL like https://example.com.`)}if(n.protocol!=="http:"&&n.protocol!=="https:")throw new Error(`Invalid --base-url "${e.baseUrl}". Must use http or https.`);t.baseUrl=`${n.protocol}//${n.host}`}return t}var tt={projectName:"my-saas",architecture:"fullstack",paymentProvider:"stripe",emailProvider:"smtp",multiTenancy:!1,projects:!1,docs:!1,desktop:!1,mobile:!1,mobilePurchases:"none",ai:!1,webAi:!0,revenueSharing:!1,credits:!0,defaultCurrency:"USD",deploymentTarget:"node",databaseProvider:"postgres",cacheProvider:"redis",observability:[]};function Qr(e){let t=e.projectName??tt.projectName,r=e.projectDir??`./${t}`,n=e.appName??At(t),i=e.deploymentTarget??tt.deploymentTarget,o=te[i]?.edgeRuntime??!1,s=e.databaseProvider??(o?"neon":tt.databaseProvider),a=e.cacheProvider??(o?"upstash":tt.cacheProvider),d=e.emailProvider??(o?"resend":tt.emailProvider),h={...tt,...e,projectName:t,appName:n,projectDir:r,deploymentTarget:i,databaseProvider:s,cacheProvider:a,emailProvider:d};h.paymentProvider==="none"&&(h.credits=!1),(!h.mobile||h.paymentProvider==="none")&&(h.mobilePurchases="none"),pt(h);for(let g of qe){if(h.deploymentTarget!==g.target)continue;let w=h.databaseProvider===g.provider?"database":"cache";if(h.databaseProvider===g.provider||h.cacheProvider===g.provider)throw new Error(`Incompatible: --deploy ${g.target} + --${w} ${g.provider}. ${g.reason}`)}for(let g of Je)if(h.architecture===g.architecture&&h.deploymentTarget===g.target)throw new Error(`Incompatible: --architecture ${g.architecture} + --deploy ${g.target}. ${g.reason}`);return dt(h),h}function I(e){f.isCancel(e)&&(f.cancel("Setup cancelled."),process.exit(0))}function rr(e){let t=[];return e.databaseProvider==="neon"&&t.push({key:"DATABASE_URL",message:"Neon connection string (optional):",placeholder:"postgres://...",secret:!0}),e.databaseProvider==="supabase"&&t.push({key:"DATABASE_URL",message:"Supabase connection string (optional):",placeholder:"postgres://...",secret:!0}),e.cacheProvider==="upstash"&&(t.push({key:"UPSTASH_REDIS_REST_URL",message:"Upstash REST URL (optional):",placeholder:"https://...",secret:!1}),t.push({key:"UPSTASH_REDIS_REST_TOKEN",message:"Upstash REST token (optional):",secret:!0})),e.paymentProvider==="stripe"&&(t.push({key:"STRIPE_SECRET_KEY",message:"Stripe secret key (optional):",placeholder:"sk_test_...",secret:!0}),t.push({key:"STRIPE_WEBHOOK_SECRET",message:"Stripe webhook secret (optional):",placeholder:"whsec_...",secret:!0})),e.paymentProvider==="polar"&&(t.push({key:"POLAR_ACCESS_TOKEN",message:"Polar access token (optional):",secret:!0}),t.push({key:"POLAR_WEBHOOK_SECRET",message:"Polar webhook secret (optional):",secret:!0})),e.emailProvider==="resend"&&t.push({key:"RESEND_API_KEY",message:"Resend API key (optional):",placeholder:"re_...",secret:!0}),e.emailProvider==="ses"&&(t.push({key:"AMAZON_SES_REGION",message:"Amazon SES region (optional):",placeholder:"us-east-1",secret:!1}),t.push({key:"AMAZON_SES_KEY",message:"Amazon SES access key (optional):",secret:!0}),t.push({key:"AMAZON_SES_SECRET",message:"Amazon SES secret (optional):",secret:!0})),e.demo&&t.push({key:"TURNSTILE_SECRET_KEY",message:"Cloudflare Turnstile secret key (optional):",secret:!0}),t}async function en(e,t){let r=!1;e&&pt(e),e&&dt(e),f.log.info(y.bold("Project"));let n=e?.projectName??await(async()=>{r=!0;let c=await f.text({message:"Project name:",placeholder:"my-saas",validate:u=>{if(!u?.trim())return"Project name is required.";if(!It(u))return"Use lowercase letters, numbers, and hyphens only. Must start with a letter."}});return I(c),c})(),i=e?.appName??await(async()=>{r=!0;let c=await f.text({message:"App name:",initialValue:At(n),validate:u=>{if(!u?.trim())return"App name is required."}});return I(c),c})(),o=e?.projectDir??await(async()=>{r=!0;let c=await f.text({message:"Project location:",initialValue:`./${n}`});return I(c),c==="."?process.cwd():c})();f.log.info(y.bold("Infrastructure"));let s={};if(e?.deploymentTarget===void 0&&e?.databaseProvider===void 0&&e?.cacheProvider===void 0){r=!0;let c=await f.select({message:"Infrastructure:",options:[...De.map(u=>({value:u,label:le[u].label,hint:le[u].hint})),{value:"custom",label:"Customize",hint:"choose each piece yourself"}]});if(I(c),c!=="custom"){let u=le[c].values;s.deploymentTarget=u.deploymentTarget,s.databaseProvider=u.databaseProvider,s.cacheProvider=u.cacheProvider,f.log.info(`${le[c].label}: ${le[c].hint}.`)}}let a=e?.deploymentTarget??s.deploymentTarget??"node";if(e?.deploymentTarget===void 0&&s.deploymentTarget===void 0){r=!0;let c=await f.select({message:"Deployment target:",options:de.map(u=>({value:u,label:te[u].label,hint:te[u].hint}))});I(c),a=c}let d=e?.architecture?Je.find(c=>c.architecture===e.architecture&&c.target===a):void 0;if(d)throw new Error(`Incompatible: --architecture ${d.architecture} + --deploy ${d.target}. ${d.reason}`);let h=new Set(Je.filter(c=>c.target===a).map(c=>c.architecture)),g=Ze.filter(c=>!h.has(c)),w=e?.architecture??await(async()=>{if(g.length===1){let u=g[0];return f.log.info(`Auto-selected ${lt[u].label} architecture (only compatible option for ${te[a].label}).`),u}r=!0;let c=await f.select({message:"Architecture:",options:g.map(u=>({value:u,label:lt[u].label,hint:lt[u].hint}))});return I(c),c})(),v=e?.databaseProvider??s.databaseProvider??await(async()=>{r=!0;let c=ue.filter(R=>!qe.some(Ee=>Ee.target===a&&Ee.provider===R));if(c.length===1){let R=c[0];return f.log.info(`Auto-selected ${Oe[R].label} (only compatible option for ${te[a].label}).`),R}let u=await f.select({message:"Database provider:",options:c.map(R=>({value:R,label:Oe[R].label,hint:Oe[R].hint}))});return I(u),u})(),P=e?.cacheProvider??s.cacheProvider??await(async()=>{r=!0;let c=me.filter(R=>!qe.some(Ee=>Ee.target===a&&Ee.provider===R));if(c.length===1){let R=c[0];return f.log.info(`Auto-selected ${xe[R].label} (only compatible option for ${te[a].label}).`),R}let u=await f.select({message:"Cache provider:",options:c.map(R=>({value:R,label:xe[R].label,hint:xe[R].hint}))});return I(u),u})();if(te[a]?.edgeRuntime){let c=Gr.map(u=>` - ${u}`).join(`
|
|
3
|
-
`);f.note(c,"Unavailable on edge runtime")}f.log.info(y.bold("Features"));let m=e?.paymentProvider??await(async()=>{r=!0;let c=await f.select({message:"Payment provider:",options:
|
|
4
|
-
`),u=[` Deploy target: ${y.cyan(te[a]?.label??"Node.js / Docker")}`,` Database: ${y.cyan(
|
|
5
|
-
`),R=[m!=="none"?` Payment: ${y.cyan(
|
|
6
|
-
`),
|
|
7
|
-
`);
|
|
8
|
-
`),"Summary");let
|
|
9
|
-
`,{mode:384})}async function
|
|
10
|
-
`,scripts:{},references:{}}}}:await(await
|
|
11
|
-
`)}var
|
|
12
|
-
`))}}var
|
|
13
|
-
`)}function
|
|
14
|
-
- **Payments:** ${
|
|
2
|
+
import{Command as Rp}from"commander";import*as ks from"@clack/prompts";import{existsSync as Vl,readdirSync as Bl,rmSync as Kl}from"fs";import{join as Gl,resolve as Hl}from"path";import{Option as X}from"commander";import*as S from"@clack/prompts";import*as St from"@clack/prompts";import Qt from"picocolors";function Br(e){let t=e?` GenerateSaaS v${e} `:" GenerateSaaS ";St.intro(Qt.bgYellow(Qt.black(t)))}function Kr(){St.outro(Qt.yellow("Happy building!"))}import*as f from"@clack/prompts";import y from"picocolors";var ut={fullstack:{label:"Fullstack",hint:"Frontend hosts the API - works on serverless or long-running"},separate:{label:"Separate",hint:"Standalone Hono backend - long-running runtimes only (Docker, Render, Fly.io, Railway, Coolify, Dokploy)"}},Pt={stripe:{label:"Stripe"},polar:{label:"Polar"},none:{label:"None",hint:"disable payments"}},At={smtp:{label:"SMTP",hint:"Mailpit for local dev"},ses:{label:"Amazon SES"},resend:{label:"Resend"}},It={sentry:{label:"Sentry",hint:"web, server and desktop - release health, tracing, alerting"},posthog:{label:"PostHog",hint:"web and server, plus LLM analytics - no Electron SDK, so a desktop build reports nothing"}},Gr={postgres:{label:"PostgreSQL",hint:"port 5432",port:5432},redis:{label:"Redis",hint:"port 6379",port:6379},inngest:{label:"Inngest",hint:"port 8288",port:8288},mailpit:{label:"Mailpit",hint:"port 1025",port:1025}};var Oe={USD:{symbol:"$",name:"US Dollar",place:"left",space:!1},EUR:{symbol:"\u20AC",name:"Euro",place:"right",space:!1},GBP:{symbol:"\xA3",name:"British Pound",place:"left",space:!1},CAD:{symbol:"CA$",name:"Canadian Dollar",place:"left",space:!1},AUD:{symbol:"A$",name:"Australian Dollar",place:"left",space:!1},BRL:{symbol:"R$",name:"Brazilian Real",place:"left",space:!1},JPY:{symbol:"\xA5",name:"Japanese Yen",place:"left",space:!1}};var te={node:{label:"Node.js / Docker",hint:"long-running runtime - Render, Fly.io, Railway, Coolify, Dokploy, VPS",edgeRuntime:!1},vercel:{label:"Vercel",hint:"serverless functions",edgeRuntime:!0}},xe={postgres:{label:"PostgreSQL (self-hosted)",hint:"local Docker, drizzle-orm/node-postgres",managed:!1,envVars:[{key:"DATABASE_URL",defaultValue:"postgres://postgres:postgres@localhost:5432/saas"}]},neon:{label:"Neon",hint:"serverless Postgres",managed:!0,envVars:[{key:"DATABASE_URL",comment:"# TODO: Add your Neon connection string"}]},supabase:{label:"Supabase",hint:"managed Postgres",managed:!0,envVars:[{key:"DATABASE_URL",comment:"# TODO: Add your Supabase connection string"}]}},De={redis:{label:"Redis (self-hosted)",hint:"local Docker, ioredis",managed:!1,envVars:[{key:"REDIS_URL",defaultValue:"redis://localhost:6379"}]},upstash:{label:"Upstash",hint:"serverless Redis",managed:!0,envVars:[{key:"UPSTASH_REDIS_REST_URL",comment:"# TODO: Add your Upstash REST URL"},{key:"UPSTASH_REDIS_REST_TOKEN",comment:"# TODO: Add your Upstash REST token"}]}},Xe=[{target:"vercel",provider:"redis",reason:"Vercel serverless cannot maintain persistent Redis connections. Consider Upstash."},{target:"vercel",provider:"postgres",reason:"Vercel serverless cannot pool connections to a self-hosted Postgres. Use Neon or Supabase - both are managed, connection-pooled Postgres."}],Ze=[{architecture:"separate",target:"vercel",reason:"Standalone backends need a long-running runtime. Vercel's per-function TypeScript check conflicts with pnpm's isolated install. Use --architecture fullstack for serverless, or deploy the standalone backend to a long-running runtime (Render, Fly.io, Railway, Coolify, Dokploy, or your own VPS via Docker)."}],le={serverless:{label:"Serverless",hint:"Vercel + Neon + Upstash",values:{deploymentTarget:"vercel",databaseProvider:"neon",cacheProvider:"upstash"}},selfhost:{label:"Self-host",hint:"Docker or Node + Postgres + Redis",values:{deploymentTarget:"node",databaseProvider:"postgres",cacheProvider:"redis"}}},Ne=Object.keys(le);function er(e){let t=[];for(let r of Xe)if(e.deploymentTarget===r.target&&(e.databaseProvider===r.provider||e.cacheProvider===r.provider)){let n=e.databaseProvider===r.provider?"databaseProvider":"cacheProvider";t.push(`\`${n}: "${r.provider}"\` with \`deploymentTarget: "${r.target}"\` - ${r.reason}`)}for(let r of Ze)e.deploymentTarget===r.target&&e.architecture===r.architecture&&t.push(`\`architecture: "${r.architecture}"\` with \`deploymentTarget: "${r.target}"\` - ${r.reason}`);return t}var Hr=["Local file storage (sharp, geoip-lite)","SMTP email (use Resend or SES instead)","Content API git integration"];function Qe(e){let t=[];return e.databaseProvider==="postgres"&&t.push("postgres"),e.cacheProvider==="redis"&&t.push("redis"),e.emailProvider==="smtp"&&t.push("mailpit"),t.push("inngest"),t}var et=["fullstack","separate"],tt=["stripe","polar","none"],rt=["smtp","ses","resend"];var Yr=["claude-code","cursor","codex","gemini-cli","windsurf"],pe=["USD","EUR","GBP","CAD","AUD","BRL","JPY"],de=["node","vercel"],ue=["postgres","neon","supabase"],me=["redis","upstash"],F=["sentry","posthog"],Ce=["revenuecat","none"];function re(e){return e.paymentProvider!==void 0&&e.paymentProvider!=="none"}var tr=[{key:"architecture",label:"Architecture",hint:"Fullstack (frontend hosts the API) or a standalone Hono backend.",category:"Infrastructure",kind:"enum",default:"fullstack",choices:et,adoptable:!1,impact:"structural",shapesTree:!0},{key:"deploymentTarget",label:"Deployment target",hint:"Node.js / Docker or Vercel serverless.",category:"Infrastructure",kind:"enum",default:"node",choices:de,adoptable:!1,impact:"config",shapesTree:!0},{key:"databaseProvider",label:"Database provider",hint:"Self-hosted PostgreSQL, Neon, or Supabase.",category:"Infrastructure",kind:"enum",default:"postgres",choices:ue,adoptable:!1,impact:"config",shapesTree:!0},{key:"cacheProvider",label:"Cache provider",hint:"Self-hosted Redis or Upstash.",category:"Infrastructure",kind:"enum",default:"redis",choices:me,adoptable:!1,impact:"config",shapesTree:!0},{key:"paymentProvider",label:"Payment provider",hint:"Stripe, Polar, or none.",category:"Features",kind:"enum",default:"none",choices:tt,adoptable:!1,impact:"structural",shapesTree:!0},{key:"defaultCurrency",label:"Default currency",hint:"Billing and pricing currency.",category:"Features",kind:"enum",default:"USD",choices:pe,adoptable:!1,impact:"config",shapesTree:!1},{key:"emailProvider",label:"Email provider",hint:"SMTP, Amazon SES, or Resend.",category:"Features",kind:"enum",default:"smtp",choices:rt,adoptable:!1,impact:"config",shapesTree:!1},{key:"observability",label:"Observability",hint:"Which services receive your app's errors, and on PostHog its LLM analytics. Pick any combination - anything you do not pick costs nothing, since its SDK and its adapter are never generated.",category:"Features",kind:"multiselect",default:[],choices:F,adoptable:!0,impact:"config",shapesTree:!0},{key:"multiTenancy",label:"Multi-tenancy (organizations)",hint:"Adds organizations - teams, members, and shared resources.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!1},{key:"projects",label:"Projects",hint:"Adds projects - a switcher that scopes a user's work inside their account or organization.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"config",shapesTree:!1},{key:"docs",label:"Docs app",hint:"Adds the self-hosted Fumadocs documentation site (apps/docs).",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"desktop",label:"Desktop app",hint:"Adds the cross-platform desktop app (apps/desktop, built with Electron).",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"mobile",label:"Mobile app",hint:"Adds the cross-platform mobile app (apps/mobile, built with Expo).",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"mobilePurchases",label:"Mobile store purchases",hint:"In-app purchases in the mobile app, through RevenueCat. Store purchases sit ON TOP of your web payment provider - a subscriber who buys in the app is the same account, billed by Apple or Google instead of by you. Off keeps the app on web billing, where the plan card says the subscription is managed on your website.",category:"Features",kind:"enum",default:()=>"none",choices:Ce,adoptable:!0,impact:"structural",shapesTree:!0,requires:e=>e.mobile===!0&&re(e),requiresLabel:"requires the mobile app and a web payment provider - store purchases are additive to the web lane, never a replacement"},{key:"ai",label:"AI features",hint:"Adds the AI surface (chat, models, automations, integrations). A desktop project ALWAYS ships the app's local AI stack: this option sets config.desktop.agents.enabled (the runtime gate on those screens). Off keeps config.ai.enabled false so the AI nav stays hidden - the AI packages still ship, because packages/translate depends on @repo/ai for the i18n pipeline every project uses.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"structural",shapesTree:!0},{key:"webAi",label:"AI in the web app",hint:"Whether the WEB app shows the AI surfaces - chat, automations, model and integration settings, and their nav entries. Off is the app-first shape: your desktop or mobile app is where your AI product lives and the website keeps account, billing and licensing only. The AI SERVICE is untouched either way (config.ai.enabled, the /api/ai routes and the desktop agents all stay exactly as your AI option set them), so both apps keep talking to the same backend. Only meaningful with AI features and at least one app - the desktop app or the mobile app.",category:"Features",kind:"boolean",default:!0,adoptable:!0,impact:"config",shapesTree:!1,requires:e=>e.ai===!0&&(e.desktop===!0||e.mobile===!0),requiresLabel:"requires AI features and an app to host them (the desktop app or the mobile app)"},{key:"credits",label:"Metered credits",hint:"Adds metered usage credits on top of subscription plans.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"config",shapesTree:!1,requires:e=>re(e),requiresLabel:"requires a payment provider"},{key:"revenueSharing",label:"Revenue sharing",hint:"Opt-in MRR leaderboard with dofollow backlinks.",category:"Features",kind:"boolean",default:!1,adoptable:!0,impact:"config",shapesTree:!1}];function Ss(e,t){return e[t]!==void 0}function Wr(e){return tr.filter(t=>t.adoptable&&!Ss(e,t.key)&&(t.requires?t.requires(e):!0)).map(t=>({key:t.key,label:t.label,hint:t.hint,kind:t.kind,default:typeof t.default=="function"?t.default(e):t.default,...t.choices?{choices:t.choices}:{},impact:t.impact,...t.requiresLabel?{requiresLabel:t.requiresLabel}:{}}))}function Ps(e,t){switch(e.kind){case"boolean":return typeof t=="boolean";case"enum":return typeof t=="string"&&(e.choices??[]).includes(t);case"multiselect":return Array.isArray(t)&&t.every(r=>typeof r=="string"&&(e.choices??[]).includes(r))}}function As(e){let t=(e.choices??[]).map(r=>JSON.stringify(r)).join(", ");switch(e.kind){case"boolean":return"true or false";case"enum":return`one of ${t}`;case"multiselect":return`an array of ${t}`}}function zr(e){let t=[];for(let r of tr){let n=e[r.key];n!==void 0&&(Ps(r,n)||t.push(`\`${r.key}\` is ${JSON.stringify(n)}, but it must be ${As(r)}.`))}return t}function qr(e){let t={};for(let r of tr)if(typeof r.default=="function"){if(r.requires&&!r.requires(e))continue;t[r.key]=r.default(e)}else t[r.key]=Array.isArray(r.default)?[...r.default]:r.default;return{...t,...e}}function rr(e,t){return e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||t}function Tt(e){return e.split("-").map(t=>t.charAt(0).toUpperCase()+t.slice(1)).join(" ")}function Rt(e){return/^[a-z][a-z0-9-]*$/.test(e)}function N(e){return e instanceof Error?e.message:String(e)}function mt(e){if(e.mobilePurchases!=="revenuecat")return e;if(e.mobile===!1)throw new Error("--mobile-purchases revenuecat requires the mobile app; remove --no-mobile.");if(e.paymentProvider==="none")throw new Error("--mobile-purchases revenuecat requires a payment provider (got --payment none): store purchases are additive to your web billing, never a replacement.");return e.mobile===void 0&&(e.mobile=!0),e}function ht(e){if(e.webAi!==!1)return e;if(e.ai===!1)throw new Error("--no-web-ai requires --ai (the desktop and mobile apps are where AI lives when the web surface is off).");if(e.desktop===!1&&e.mobile===!1)throw new Error("--no-web-ai requires --desktop or --mobile - with the web AI surface off, the desktop and mobile apps are the only places your AI features exist.");return e}function Jr(e){if(e.webAi!==!1)return[];let t=[];return e.desktop!==!0&&e.mobile!==!0&&t.push("neither `desktop` nor `mobile` is true, so this project has no desktop or mobile app - and with the web AI surface off, those apps are the only places its AI features would exist."),t}function Xr(e){if(e.mobilePurchases!=="revenuecat")return[];let t=[];return e.mobile!==!0&&t.push("`mobile` is not true, so this project has no app to sell in - the store lane's paywall, purchase and restore surfaces all live in `apps/mobile`."),re(e)||t.push('`paymentProvider` is absent or `"none"`, and store purchases are additive to your web billing - there is nothing here for them to sit on top of.'),t}function Zr(e){let t=Is(e,e.desktopAi);return delete e.desktopAi,t}function Is(e,t){return e.desktop!==!0?{kind:"none"}:t===!0&&e.ai!==!0?{kind:"legacy-desktop-ai"}:t===!1&&e.ai===!0?{kind:"desktop-gains-ai"}:{kind:"none"}}function Qr(e){let t={};if(e.name!==void 0){if(!Rt(e.name))throw new Error(`Invalid project name "${e.name}". Use lowercase letters, numbers, and hyphens only. Must start with a letter.`);t.projectName=e.name}if(e.appName!==void 0){if(!e.appName.trim())throw new Error("App name cannot be empty.");t.appName=e.appName}if(e.location!==void 0?t.projectDir=e.location==="."?process.cwd():e.location:t.projectName!==void 0&&(t.projectDir=`./${t.projectName}`),e.architecture!==void 0&&(t.architecture=e.architecture),e.payment!==void 0&&(t.paymentProvider=e.payment),e.email!==void 0&&(t.emailProvider=e.email),e.org!==void 0&&(t.multiTenancy=e.org),e.projects!==void 0&&(t.projects=e.projects),e.docs!==void 0&&(t.docs=e.docs),e.desktop!==void 0&&(t.desktop=e.desktop),e.mobile!==void 0&&(t.mobile=e.mobile),e.mobilePurchases!==void 0){if(!Ce.includes(e.mobilePurchases))throw new Error(`Invalid mobile purchases "${e.mobilePurchases}". Valid values: ${Ce.join(", ")}`);t.mobilePurchases=e.mobilePurchases}if(e.ai!==void 0&&(t.ai=e.ai),e.webAi!==void 0&&(t.webAi=e.webAi),mt(t),ht(t),e.revenueSharing!==void 0&&(t.revenueSharing=e.revenueSharing),e.credits!==void 0){if(e.credits===!0&&e.payment==="none")throw new Error("--credits requires a payment provider (got --payment none).");t.credits=e.credits}if(e.currency!==void 0){if(!pe.includes(e.currency))throw new Error(`Invalid currency "${e.currency}". Valid values: ${pe.join(", ")}`);t.defaultCurrency=e.currency}if(e.deploy!==void 0){if(!de.includes(e.deploy))throw new Error(`Invalid deployment target "${e.deploy}". Valid values: ${de.join(", ")}`);t.deploymentTarget=e.deploy}if(e.database!==void 0){if(!ue.includes(e.database))throw new Error(`Invalid database provider "${e.database}". Valid values: ${ue.join(", ")}`);t.databaseProvider=e.database}if(e.cache!==void 0){if(!me.includes(e.cache))throw new Error(`Invalid cache provider "${e.cache}". Valid values: ${me.join(", ")}`);t.cacheProvider=e.cache}if(e.observability!==void 0){let r=e.observability.split(",").map(i=>i.trim()).filter(Boolean),n=r.filter(i=>!F.includes(i));if(n.length>0)throw new Error(`Invalid observability provider ${JSON.stringify(n.join(", "))}. Valid values: ${F.join(", ")}`);t.observability=r}if(e.preset!==void 0){if(!Ne.includes(e.preset))throw new Error(`Invalid preset "${e.preset}". Valid values: ${Ne.join(", ")}`);let r=le[e.preset].values;t.deploymentTarget??=r.deploymentTarget,t.databaseProvider??=r.databaseProvider,t.cacheProvider??=r.cacheProvider}if(e.demo===!0&&(t.demo=!0),e.baseUrl!==void 0){let r=e.baseUrl.trim();if(r==="")throw new Error("--base-url cannot be empty. Provide an absolute URL like https://example.com.");let n;try{n=new URL(r)}catch{throw new Error(`Invalid --base-url "${e.baseUrl}". Must be an absolute URL like https://example.com.`)}if(n.protocol!=="http:"&&n.protocol!=="https:")throw new Error(`Invalid --base-url "${e.baseUrl}". Must use http or https.`);t.baseUrl=`${n.protocol}//${n.host}`}return t}var nt={projectName:"my-saas",architecture:"fullstack",paymentProvider:"stripe",emailProvider:"smtp",multiTenancy:!1,projects:!1,docs:!1,desktop:!1,mobile:!1,mobilePurchases:"none",ai:!1,webAi:!0,revenueSharing:!1,credits:!0,defaultCurrency:"USD",deploymentTarget:"node",databaseProvider:"postgres",cacheProvider:"redis",observability:[]};function en(e){let t=e.projectName??nt.projectName,r=e.projectDir??`./${t}`,n=e.appName??Tt(t),i=e.deploymentTarget??nt.deploymentTarget,o=te[i]?.edgeRuntime??!1,s=e.databaseProvider??(o?"neon":nt.databaseProvider),a=e.cacheProvider??(o?"upstash":nt.cacheProvider),d=e.emailProvider??(o?"resend":nt.emailProvider),h={...nt,...e,projectName:t,appName:n,projectDir:r,deploymentTarget:i,databaseProvider:s,cacheProvider:a,emailProvider:d};h.paymentProvider==="none"&&(h.credits=!1),(!h.mobile||h.paymentProvider==="none")&&(h.mobilePurchases="none"),mt(h);for(let g of Xe){if(h.deploymentTarget!==g.target)continue;let w=h.databaseProvider===g.provider?"database":"cache";if(h.databaseProvider===g.provider||h.cacheProvider===g.provider)throw new Error(`Incompatible: --deploy ${g.target} + --${w} ${g.provider}. ${g.reason}`)}for(let g of Ze)if(h.architecture===g.architecture&&h.deploymentTarget===g.target)throw new Error(`Incompatible: --architecture ${g.architecture} + --deploy ${g.target}. ${g.reason}`);return ht(h),h}function I(e){f.isCancel(e)&&(f.cancel("Setup cancelled."),process.exit(0))}function nr(e){let t=[];return e.databaseProvider==="neon"&&t.push({key:"DATABASE_URL",message:"Neon connection string (optional):",placeholder:"postgres://...",secret:!0}),e.databaseProvider==="supabase"&&t.push({key:"DATABASE_URL",message:"Supabase connection string (optional):",placeholder:"postgres://...",secret:!0}),e.cacheProvider==="upstash"&&(t.push({key:"UPSTASH_REDIS_REST_URL",message:"Upstash REST URL (optional):",placeholder:"https://...",secret:!1}),t.push({key:"UPSTASH_REDIS_REST_TOKEN",message:"Upstash REST token (optional):",secret:!0})),e.paymentProvider==="stripe"&&(t.push({key:"STRIPE_SECRET_KEY",message:"Stripe secret key (optional):",placeholder:"sk_test_...",secret:!0}),t.push({key:"STRIPE_WEBHOOK_SECRET",message:"Stripe webhook secret (optional):",placeholder:"whsec_...",secret:!0})),e.paymentProvider==="polar"&&(t.push({key:"POLAR_ACCESS_TOKEN",message:"Polar access token (optional):",secret:!0}),t.push({key:"POLAR_WEBHOOK_SECRET",message:"Polar webhook secret (optional):",secret:!0})),e.emailProvider==="resend"&&t.push({key:"RESEND_API_KEY",message:"Resend API key (optional):",placeholder:"re_...",secret:!0}),e.emailProvider==="ses"&&(t.push({key:"AMAZON_SES_REGION",message:"Amazon SES region (optional):",placeholder:"us-east-1",secret:!1}),t.push({key:"AMAZON_SES_KEY",message:"Amazon SES access key (optional):",secret:!0}),t.push({key:"AMAZON_SES_SECRET",message:"Amazon SES secret (optional):",secret:!0})),e.demo&&t.push({key:"TURNSTILE_SECRET_KEY",message:"Cloudflare Turnstile secret key (optional):",secret:!0}),t}async function tn(e,t){let r=!1;e&&mt(e),e&&ht(e),f.log.info(y.bold("Project"));let n=e?.projectName??await(async()=>{r=!0;let c=await f.text({message:"Project name:",placeholder:"my-saas",validate:u=>{if(!u?.trim())return"Project name is required.";if(!Rt(u))return"Use lowercase letters, numbers, and hyphens only. Must start with a letter."}});return I(c),c})(),i=e?.appName??await(async()=>{r=!0;let c=await f.text({message:"App name:",initialValue:Tt(n),validate:u=>{if(!u?.trim())return"App name is required."}});return I(c),c})(),o=e?.projectDir??await(async()=>{r=!0;let c=await f.text({message:"Project location:",initialValue:`./${n}`});return I(c),c==="."?process.cwd():c})();f.log.info(y.bold("Infrastructure"));let s={};if(e?.deploymentTarget===void 0&&e?.databaseProvider===void 0&&e?.cacheProvider===void 0){r=!0;let c=await f.select({message:"Infrastructure:",options:[...Ne.map(u=>({value:u,label:le[u].label,hint:le[u].hint})),{value:"custom",label:"Customize",hint:"choose each piece yourself"}]});if(I(c),c!=="custom"){let u=le[c].values;s.deploymentTarget=u.deploymentTarget,s.databaseProvider=u.databaseProvider,s.cacheProvider=u.cacheProvider,f.log.info(`${le[c].label}: ${le[c].hint}.`)}}let a=e?.deploymentTarget??s.deploymentTarget??"node";if(e?.deploymentTarget===void 0&&s.deploymentTarget===void 0){r=!0;let c=await f.select({message:"Deployment target:",options:de.map(u=>({value:u,label:te[u].label,hint:te[u].hint}))});I(c),a=c}let d=e?.architecture?Ze.find(c=>c.architecture===e.architecture&&c.target===a):void 0;if(d)throw new Error(`Incompatible: --architecture ${d.architecture} + --deploy ${d.target}. ${d.reason}`);let h=new Set(Ze.filter(c=>c.target===a).map(c=>c.architecture)),g=et.filter(c=>!h.has(c)),w=e?.architecture??await(async()=>{if(g.length===1){let u=g[0];return f.log.info(`Auto-selected ${ut[u].label} architecture (only compatible option for ${te[a].label}).`),u}r=!0;let c=await f.select({message:"Architecture:",options:g.map(u=>({value:u,label:ut[u].label,hint:ut[u].hint}))});return I(c),c})(),v=e?.databaseProvider??s.databaseProvider??await(async()=>{r=!0;let c=ue.filter(R=>!Xe.some(Se=>Se.target===a&&Se.provider===R));if(c.length===1){let R=c[0];return f.log.info(`Auto-selected ${xe[R].label} (only compatible option for ${te[a].label}).`),R}let u=await f.select({message:"Database provider:",options:c.map(R=>({value:R,label:xe[R].label,hint:xe[R].hint}))});return I(u),u})(),P=e?.cacheProvider??s.cacheProvider??await(async()=>{r=!0;let c=me.filter(R=>!Xe.some(Se=>Se.target===a&&Se.provider===R));if(c.length===1){let R=c[0];return f.log.info(`Auto-selected ${De[R].label} (only compatible option for ${te[a].label}).`),R}let u=await f.select({message:"Cache provider:",options:c.map(R=>({value:R,label:De[R].label,hint:De[R].hint}))});return I(u),u})();if(te[a]?.edgeRuntime){let c=Hr.map(u=>` - ${u}`).join(`
|
|
3
|
+
`);f.note(c,"Unavailable on edge runtime")}f.log.info(y.bold("Features"));let m=e?.paymentProvider??await(async()=>{r=!0;let c=await f.select({message:"Payment provider:",options:tt.map(u=>({value:u,label:Pt[u].label,hint:Pt[u].hint}))});return I(c),c})(),D=e?.defaultCurrency??await(async()=>{if(m==="none")return"USD";r=!0;let c=await f.select({message:"Default currency:",options:pe.map(u=>({value:u,label:u,hint:Oe[u].name}))});return I(c),c})(),we=e?.emailProvider??await(async()=>{r=!0;let c=await f.select({message:"Email provider:",options:rt.map(u=>({value:u,label:At[u].label,hint:At[u].hint}))});return I(c),c})(),K=e?.observability??await(async()=>{r=!0;let c=await f.multiselect({message:"Observability (space to select, enter to confirm):",required:!1,initialValues:[],options:F.map(u=>({value:u,label:It[u].label,hint:It[u].hint}))});return I(c),c})(),ae=e?.multiTenancy??await(async()=>{r=!0;let c=await f.confirm({message:"Enable multi-tenancy (organizations)?",initialValue:!1});return I(c),c})(),Re=e?.projects??await(async()=>{r=!0;let c=await f.confirm({message:"Enable projects (a switcher that scopes a user's work)?",initialValue:!1});return I(c),c})(),_e=e?.docs??await(async()=>{r=!0;let c=await f.confirm({message:"Include docs app? (self-hosted Fumadocs documentation site)",initialValue:!1});return I(c),c})(),Je=t?.desktopAllowed!==!1,ce=e?.desktop??(Je?await(async()=>{r=!0;let c=await f.confirm({message:"Include the desktop app? (apps/desktop - cross-platform, built with Electron, device-auth). With AI on, it also runs a local AI stack.",initialValue:!1});return I(c),c})():(f.log.info(y.dim("Desktop app: available on the Pro plan and up - skipped.")),!1)),E=t?.mobileAllowed!==!1,_=e?.mobile??(E?await(async()=>{r=!0;let c=await f.confirm({message:"Include the mobile app? (apps/mobile - iOS and Android, built with Expo, with push notifications and universal links)",initialValue:!1});return I(c),c})():(f.log.info(y.dim("Mobile app: available on the Pro plan and up - skipped.")),!1)),Q=!_||m==="none"?"none":e?.mobilePurchases??await(async()=>{r=!0;let R=await f.select({message:"Mobile in-app purchases?",options:[{value:"revenuecat",label:"RevenueCat",hint:"store billing on top of your web provider - Apple and Google charge, your app grants"},{value:"none",label:"None",hint:"web billing only; the app shows the plan, never a paywall"}],initialValue:"none"});return I(R),R})();mt({mobile:_,mobilePurchases:Q,paymentProvider:m});let G=e?.ai??await(async()=>{r=!0;let c=await f.confirm({message:"Add AI features? (chat, models, automations, integrations)",initialValue:!1});return I(c),c})(),j=e?.webAi??((ce||_)&&G?await(async()=>{r=!0;let c=await f.confirm({message:"Show the AI surfaces in the web app? (No: app-first - your desktop or mobile app is where your AI product lives, and the web keeps account, billing, and licensing only)",initialValue:!0});return I(c),c})():!0);ht({webAi:j,ai:G,desktop:ce,mobile:_});let ee=e?.revenueSharing??await(async()=>{r=!0;let c=await f.confirm({message:"Enable revenue sharing? (opt-in MRR leaderboard with dofollow backlinks)",initialValue:!1});return I(c),c})(),H=m==="none"?!1:e?.credits??await(async()=>{r=!0;let c=await f.confirm({message:"Enable credits? (metered usage on top of subscription plans)",initialValue:!0});return I(c),c})(),ke=e?.demo,Ee=nr({databaseProvider:v,cacheProvider:P,paymentProvider:m,emailProvider:we,demo:ke}),A={};if(Ee.length>0&&r){f.log.info(y.bold("Credentials")+y.dim(" all optional - press Enter to skip, fill in .env later"));for(let c of Ee)if(r=!0,c.secret){let u=await f.password({message:c.message,mask:"*"});I(u),typeof u=="string"&&u.trim()&&(A[c.key]=u.trim())}else{let u=await f.text({message:c.message,placeholder:c.placeholder});I(u),typeof u=="string"&&u.trim()&&(A[c.key]=u.trim())}}if(r){let c=[` Name: ${y.cyan(n)}`,` App name: ${y.cyan(i)}`,` Location: ${y.cyan(o)}`,` Architecture: ${y.cyan(ut[w].label)}`].join(`
|
|
4
|
+
`),u=[` Deploy target: ${y.cyan(te[a]?.label??"Node.js / Docker")}`,` Database: ${y.cyan(xe[v].label)}`,` Cache: ${y.cyan(De[P].label)}`].filter(Boolean).join(`
|
|
5
|
+
`),R=[m!=="none"?` Payment: ${y.cyan(Pt[m].label)} (${D})`:` Payment: ${y.dim("none")}`,` Credits: ${H?y.cyan("Yes"):y.dim("No")}`,` AI features: ${G?y.cyan("Yes"):y.dim("No")}`,...j?[]:[` Web AI: ${y.dim("No")} (app-first)`],` Email: ${y.cyan(At[we].label)}`,` Observability: ${K.length===0?y.dim("none"):y.cyan(K.map(Zt=>It[Zt].label).join(", "))}`,` Multi-tenancy: ${ae?y.cyan("Yes"):y.dim("No")}`,` Projects: ${Re?y.cyan("Yes"):y.dim("No")}`,` Docs app: ${_e?y.cyan("Yes"):y.dim("No")}`,` Desktop app: ${ce?y.cyan("Yes"):y.dim("No")}`,` Mobile app: ${_?y.cyan("Yes"):y.dim("No")}`,...Q==="none"?[]:[` In-app purch.: ${y.cyan("RevenueCat")}`],` Rev. sharing: ${ee?y.cyan("Yes"):y.dim("No")}`].join(`
|
|
6
|
+
`),Se=[y.bold("Project"),c,"",y.bold("Infrastructure"),u,"",y.bold("Features"),R];if(Ee.length>0){let Zt=Ee.map(Vr=>{let Es=A[Vr.key]?y.green("provided"):y.dim("skipped");return` ${Vr.key}: ${Es}`}).join(`
|
|
7
|
+
`);Se.push("",y.bold("Credentials"),Zt)}f.note(Se.join(`
|
|
8
|
+
`),"Summary");let Fr=await f.confirm({message:"Proceed with these settings?"});(f.isCancel(Fr)||!Fr)&&(f.cancel("Setup cancelled."),process.exit(0))}return{projectName:n,appName:i,projectDir:o,architecture:w,deploymentTarget:a,databaseProvider:v,cacheProvider:P,paymentProvider:m,emailProvider:we,observability:K,multiTenancy:ae,projects:Re,docs:_e,desktop:ce,mobile:_,mobilePurchases:Q,ai:G,webAi:j,revenueSharing:ee,credits:H,defaultCurrency:D,...Object.keys(A).length>0?{credentials:A}:{},...e?.baseUrl!==void 0?{baseUrl:e.baseUrl}:{},...ke!==void 0?{demo:ke}:{}}}import{createReadStream as mn,existsSync as $s}from"fs";import{mkdir as js}from"fs/promises";import{join as Ms}from"path";import{Readable as Us}from"stream";import{pipeline as ur}from"stream/promises";import{extract as Fs}from"tar";import{join as Le}from"path";import{homedir as Ts}from"os";var ft=process.env.GENERATESAAS_API_URL??"https://cli.generatesaas.com",Y=".generatesaas",he=Le(Y,"manifest.json"),rn="nextjs",ir="nuxt",sr="2.4.1",nn=Le(Y,"hashes.json"),_t=Le(Y,"template-hashes.json"),Ot=Le(Y,"template"),sn=Le(Y,"staging"),on=Le(Y,"staging.json"),fe=Le(Ts(),".generatesaas");var $=class extends Error{constructor(r,n,i){super(n);this.status=r;this.body=i}status;body;name="ApiError"};function ge(e){return{apiKey:e,baseUrl:ft}}async function Pe(e,t,r){let n=`${e.baseUrl}${t}`,i=await fetch(n,{...r,headers:{...r?.headers,Authorization:`Bearer ${e.apiKey}`,"User-Agent":"generatesaas-cli/3.25.1"}});if(!i.ok){let o,s;try{s=await i.json(),o=s.error??`API ${i.status}: ${t}`}catch{o=`API ${i.status}: ${t}`}throw new $(i.status,o,s)}return i}import{existsSync as Rs,mkdirSync as _s,readFileSync as Os,writeFileSync as xs}from"fs";import{dirname as Ds}from"path";import*as Ae from"@clack/prompts";function gt(){if(!Rs(fe))return null;try{let e=JSON.parse(Os(fe,"utf-8"));return e.apiKey?e.apiKey:(e.token&&!e.apiKey&&Ae.log.warning(`Found old GitHub token in ${fe}. Run 'generatesaas init' to set up your API key.`),null)}catch{return null}}function $e(e){_s(Ds(fe),{recursive:!0}),xs(fe,`${JSON.stringify({apiKey:e},null," ")}
|
|
9
|
+
`,{mode:384})}async function it(e){if(e?.apiKey)return e.apiKey;let t=process.env.GENERATESAAS_API_KEY;if(t)return t;let r=gt();if(r)return r;if(!e?.prompt)throw new Error("API key not found. Set GENERATESAAS_API_KEY or run 'generatesaas init' to configure.");return yt()}async function yt(){let e=await Ae.text({message:"Enter your GenerateSaaS API key:",placeholder:"gs_live_...",validate:t=>{if(!t?.trim())return"API key is required."}});return Ae.isCancel(e)&&(Ae.cancel("Setup cancelled."),process.exit(0)),e.trim()}import{readFile as Ns}from"fs/promises";async function Ie(e){let t=process.env.GENERATESAAS_VERSION_INDEX;return t?JSON.parse(await Ns(t,"utf-8")):process.env.GENERATESAAS_OFFLINE_LICENSE==="1"?{latest:"0.0.0-ci",versions:[{version:"0.0.0-ci",date:new Date().toISOString(),breaking:!1}],entitlements:null}:await(await Pe(e,"/versions")).json()}async function an(e,t){if(process.env.GENERATESAAS_OFFLINE_LICENSE==="1")return null;try{return await(await Pe(e,`/changelog/${encodeURIComponent(t)}`)).text()}catch(r){if(r instanceof $&&r.status===404)return null;throw r}}async function cn(e,t){return process.env.GENERATESAAS_OFFLINE_LICENSE==="1"?{skills:{"generatesaas-update":{skillMd:`# Offline skill stub
|
|
10
|
+
`,scripts:{},references:{}}}}:await(await Pe(e,`/skills/${encodeURIComponent(t)}`)).json()}function xt(e){if(!(e instanceof $)||e.status!==403)return null;let t=e.body;return!t||t.code!=="update_window_expired"?null:{message:e.message,lastAllowedVersion:t.lastAllowedVersion??null}}var or="nextjs";function ln(e){return{frontend:or,architecture:e.architecture,deployTarget:e.deploymentTarget,database:e.databaseProvider,cache:e.cacheProvider,payment:e.paymentProvider,email:e.emailProvider,multiTenancy:e.multiTenancy,blog:!0,docs:e.docs,desktop:e.desktop,mobile:e.mobile,runner:!1,credits:e.credits,revenueSharing:e.revenueSharing,currency:e.defaultCurrency}}async function ar(e,t){return process.env.GENERATESAAS_OFFLINE_LICENSE==="1"?{token:"offline-test-token",licenseId:"offline-test-license-id",installId:t.installId}:await(await Pe(e,"/license/sign",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json()}async function pn(e,t){return process.env.GENERATESAAS_OFFLINE_LICENSE==="1"?{token:t.currentToken}:await(await Pe(e,"/license/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json()}async function cr(e,t){let r=await fetch(`${e}/license/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(!r.ok)throw new Error(`Verification service returned ${r.status}`);return await r.json()}async function dn(e,t,r){let n=await fetch(`${e}/license/inspect`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${t}`},body:JSON.stringify(r)});if(!n.ok){let i=await n.json().catch(()=>null);throw new Error(i?.error??`Inspect endpoint returned ${n.status}`)}return await n.json()}var lr=new Set([".git","node_modules",".pnpm-store",".env",".env.test",".turbo",".data","dist",".next",".next-e2e-web",".next-e2e-desktop",".svelte-kit",".wrangler",".devcontainer","playwright-report","test-results",".drive-out",".mastra"]),pr=new Set(["pnpm-lock.yaml"]);function st(e){if(dr(e))return!0;for(let t of e.split("/"))if(pr.has(t))return!0;return!1}var Cs=new Set(["data","mksaas","references","scripts",".cursor",".agents",".codex",".generatesaas",".vscode",".mcp.json","vitest.config.ts","README.md","TODO.md","OVERVIEW.md","HANDOFF.md"]),un=["scripts/docs-lint.ts"],Ls=["docs/superpowers","packages/cli","packages/cli-api","packages/database/tests/no-migration-history.test.ts","infra/docker-compose.yml",".claude/commands",".claude/skills/web-port",".claude/skills/web-port-workspace",".claude/settings.local.json",".claude/worktrees"];function dr(e){let t=e.split("/");for(let r of t)if(lr.has(r))return!0;if(un.includes(e)||un.some(r=>r.startsWith(`${e}/`)))return!1;if(Cs.has(t[0]))return!0;for(let r of Ls)if(e===r||e.startsWith(`${r}/`))return!0;return!1}async function Dt(e,t,r){await js(r,{recursive:!0});let n=process.env.GENERATESAAS_TEMPLATE_TARBALL_DIR;if(n){let a=Ms(n,`v${t}.tar.gz`);if(!$s(a))throw new Error(`GENERATESAAS_TEMPLATE_TARBALL_DIR is set but ${a} does not exist.`);await ur(mn(a),mr(r));return}let i=process.env.GENERATESAAS_TEMPLATE_TARBALL;if(i){await ur(mn(i),mr(r));return}let o=await Pe(e,`/template/${encodeURIComponent(t)}`);if(!o.body)throw new Error("Empty response body");let s=Us.fromWeb(o.body);await ur(s,mr(r))}function mr(e){return Fs({cwd:e,strip:1,filter:t=>{let r=t.replace(/^[^/]+\//,"");return r?!dr(r):!0},sync:!1})}import{readdir as Nt,readFile as ot,rm as fn,rmdir as Vs,writeFile as bt}from"fs/promises";import{join as V}from"path";async function gn(e){let t;try{t=await Nt(e,{withFileTypes:!0})}catch{return!1}let r=!1;for(let n of t){let i=V(e,n.name);if(n.isDirectory()){await gn(i)&&(r=!0);continue}if(n.name.startsWith("_")){r=!0;continue}await fn(i,{force:!0})}return r||await Vs(e).catch(()=>{}),r}async function yn(e){let t=V(e,"packages/content"),r=[];try{r=(await Nt(t,{withFileTypes:!0})).filter(s=>s.isDirectory()).map(s=>s.name)}catch{return}let n=new Set;for(let s of r){let a=V(t,s),d;try{d=(await Nt(a,{withFileTypes:!0})).filter(h=>h.isDirectory())}catch{continue}for(let h of d)n.add(h.name),await gn(V(a,h.name))}let i=await Hs(e),o=new Set([...n].filter(s=>i===void 0||i.has(s)));await Promise.all([...o].map(s=>fn(V(e,"apps/web/public/images",s),{recursive:!0,force:!0}))),await Ws(e,o)}var Bs="packages/config/src/index.ts",Ks=/\bcontent:\s*\{[\s\S]*?\bsections:\s*\[([\s\S]*?)\]/,Gs=/\bslug:\s*"([^"]+)"/g;async function Hs(e){let t;try{t=await ot(V(e,Bs),"utf-8")}catch{return}let r=Ks.exec(t)?.[1];if(r===void 0)return;let n=[...r.matchAll(Gs)].map(i=>i[1]).filter(i=>i!==void 0);return n.length>0?new Set(n):void 0}var Ys="packages/translate/.meta/mdx-translation-meta.json";async function Ws(e,t){if(t.size===0)return;let r=V(e,Ys),n;try{n=JSON.parse(await ot(r,"utf-8"))}catch{return}if(typeof n!="object"||n===null)return;let i=n;if(typeof i.locales!="object"||i.locales===null)return;let o={};for(let[s,a]of Object.entries(i.locales)){if(typeof a!="object"||a===null)continue;let d={};for(let[h,g]of Object.entries(a)){let w=h.split("/"),v=w.length>1?w[0]:void 0,P=w[w.length-1]?.startsWith("_")===!0;v!==void 0&&t.has(v)&&!P||(d[h]=g)}o[s]=d}await bt(r,`${JSON.stringify({...i,locales:o},null," ")}
|
|
11
|
+
`)}var zs="packages/config/src/blog.ts";async function bn(e){let t=V(e,zs),r=await ot(t,"utf-8"),n=hn(hn(r,"blogCategories",t),"blogAuthors",t);await bt(t,n)}function hn(e,t,r){let n=new RegExp(`(export const ${t}\\b[^=]*=\\s*)\\[[\\s\\S]*?\\];`);if(!n.test(e))throw new Error(`emptyBlogConfig: could not find the \`export const ${t} = [...]\` declaration in ${r}. The boilerplate blog config may have been renamed or restructured; update the CLI strip in cleanup.ts.`);return e.replace(n,"$1[];")}async function vn(e){let t=V(e,"packages/i18n/translations"),r;try{r=await Nt(t)}catch{return}for(let n of r){let i=V(t,n,"web.json"),o;try{o=await ot(i,"utf-8")}catch{continue}let s=JSON.parse(o);!s.content?.categories||s.content.categories.blog===void 0||(s.content.categories.blog={},await bt(i,`${JSON.stringify(s,null," ")}
|
|
12
|
+
`))}}var qs="packages/config/src/ai/tasks.ts",Js="The array carries a SINGLE INERT DEMO task: the CLI strips it for buyer builds (leaving an empty\n * `aiTasks`, which hides the Tasks tab) and keeps it for `--demo` builds.",Xs="The array starts EMPTY, which hides the Tasks tab until you declare your first task.";async function wn(e){let t=V(e,qs),r=await ot(t,"utf-8"),n=r.replace(/\n?[\t ]*\/\/ gsaas:demo-ai-task-start[\s\S]*?\/\/ gsaas:demo-ai-task-end/,"");if(n===r)throw new Error(`removeDemoAiTasks: could not find the \`gsaas:demo-ai-task\` markers in ${t}. The boilerplate AI tasks config may have been renamed or restructured; update the CLI strip in cleanup.ts.`);let i=n.replace(Js,Xs);if(i===n)throw new Error(`removeDemoAiTasks: the demo-task paragraph in ${t} did not match, so the shipped file would describe a demo task it no longer carries (boilerplate drift).`);await bt(t,i)}async function kn(e){let t=V(e,".claude","settings.json"),r;try{r=await ot(t,"utf8")}catch{return}let n=JSON.parse(r);delete n.alwaysThinkingEnabled,delete n.enableAllProjectMcpServers,n.env&&(delete n.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS,Object.keys(n.env).length===0&&delete n.env),n.permissions?.allow&&(n.permissions.allow=n.permissions.allow.filter(Zs)),await bt(t,`${JSON.stringify(n,null," ")}
|
|
13
|
+
`)}function Zs(e){return!(e.startsWith("mcp__")||e.includes("nuxt"))}import{readFileSync as co}from"fs";import{dirname as lo,join as Sn}from"path";import{fileURLToPath as po}from"url";import{existsSync as Qs}from"fs";import{join as En}from"path";function Ct(e,t,r){let n=En(e,...t);return Qs(n)?n:En(e,r)}import{mkdir as eo,writeFile as to,readdir as ro,rm as no,rmdir as io}from"fs/promises";import{dirname as Lt,join as so,relative as oo,sep as ao}from"path";function je(e){return e.split(ao).join("/")}async function $t(e){await eo(e,{recursive:!0})}async function l(e,t){await $t(Lt(e)),await to(e,t,"utf-8")}async function at(e,t){await no(e,{force:!0});let r=Lt(e);for(;r!==t&&r!==Lt(r);){try{await io(r)}catch{return}r=Lt(r)}}async function ye(e,t,r,n){let i=[],o=await ro(e,{withFileTypes:!0});for(let s of o){let a=so(e,s.name),d=je(oo(t,a));r(d)||(s.isDirectory()?i.push(...await ye(a,t,r,n)):s.isFile()&&(n===void 0||n(d))&&i.push(a))}return i}var uo={postgres:"Postgres",neon:"Neon (managed Postgres)",supabase:"Supabase (managed Postgres)"},mo={resend:"Resend",ses:"Amazon SES",smtp:"SMTP"},ho={redis:"Redis",upstash:"Upstash Redis"},fo={node:"Node.js / Docker",vercel:"Vercel"},hr="apps/web",go="Next.js 16",yo="`app/` routes, `components/`, `lib/`",bo={stripe:"Stripe",polar:"Polar"};function vo(e){let t=e.architecture==="fullstack",r=t?"(fullstack - Hono API mounted inside the app)":"(separate - standalone Hono backend)",n=t?`Mounted inside \`${hr}\`. A standalone \`apps/backend\` is also kept (inert) so you can switch to separate later.`:"Runs from `apps/backend`; the frontend reaches it over HTTP.",i=ho[e.cacheProvider],o=fo[e.deploymentTarget],s=e.paymentProvider==="none"?"":`
|
|
14
|
+
- **Payments:** ${bo[e.paymentProvider]}`,a=e.desktop?e.mobile?", the desktop app (built with Electron) under `docs/desktop/` and the mobile app (built with Expo) under `docs/mobile/`":", and the desktop app (built with Electron) under `docs/desktop/`":e.mobile?", and the mobile app (built with Expo) under `docs/mobile/`":"",d=e.desktop?" With the desktop app on, both shells render the same body, so one change lands in both.":"",h=`\`useTranslations()\` from \`next-intl\` in components; messages are loaded in \`${hr}/i18n/request.ts\`.`;return ko({archSuffix:r,apiLocation:n,databaseName:uo[e.databaseProvider],cacheName:i,paymentLine:s,emailName:mo[e.emailProvider],deployName:o,appDocsRef:a,sharedScreensBothShells:d,i18nRule:h,webAppDir:hr,webAppName:go,webAppLayout:yo})}function wo(){return Ct(lo(po(import.meta.url)),["generators","content"],"content")}function ko(e){let t=co(Sn(wo(),"AGENTS.template.md"),"utf-8"),r=new Map(Object.entries(e)),n=t.replace(/\{\{(\w+)\}\}/g,(o,s)=>{let a=r.get(s);if(a===void 0)throw new Error(`AGENTS.template.md uses unknown token {{${s}}}`);return a}),i=/\{\{[^}]*\}\}/.exec(n);if(i)throw new Error(`AGENTS.template.md carries an unrendered token: ${i[0]}`);return n}async function Pn(e){await l(Sn(e.projectDir,"AGENTS.md"),vo(e))}import{join as Eo}from"path";var An="apps/web",fr="Next.js 16",So={postgres:"Postgres (self-hosted)",neon:"Neon (managed Postgres)",supabase:"Supabase (managed Postgres)"};function Po(e){let t=e.appName.trim()||e.projectName,r=So[e.databaseProvider],n=e.architecture==="fullstack"?`${fr} app at \`${An}/\` with the Hono API mounted inside it. A standalone \`apps/backend/\` is also included (inert in this fullstack setup) so you can split the API into a separate service later without re-scaffolding.`:`${fr} app at \`${An}/\` and a separate Hono backend at \`apps/backend/\`.`,i=e.architecture==="fullstack"?`- App + API: http://localhost:3000
|
|
15
15
|
- Inngest dev server: http://127.0.0.1:8288`:`- App: http://localhost:3000
|
|
16
16
|
- API: http://localhost:3010
|
|
17
17
|
- Inngest dev server: http://127.0.0.1:8288`,o=e.ai?`pnpm mastra:studio # optional: inspect the AI assistant and its memory (localhost:4111)
|
|
@@ -22,7 +22,7 @@ ${n}
|
|
|
22
22
|
|
|
23
23
|
## Stack
|
|
24
24
|
|
|
25
|
-
- **Frontend:** ${
|
|
25
|
+
- **Frontend:** ${fr}
|
|
26
26
|
- **Backend:** Hono (TypeScript, RPC-typed)
|
|
27
27
|
- **Auth:** Better Auth
|
|
28
28
|
- **ORM / DB:** Drizzle + ${r}
|
|
@@ -74,27 +74,27 @@ pnpm dlx generatesaas eject
|
|
|
74
74
|
## Updates
|
|
75
75
|
|
|
76
76
|
To pull the latest boilerplate changes into this project, open it in your AI coding agent and ask: \`update my GenerateSaaS project\`.
|
|
77
|
-
`}async function
|
|
77
|
+
`}async function In(e){await l(Eo(e.projectDir,"README.md"),Po(e))}import{access as Ao}from"fs/promises";import{join as vt}from"path";async function Io(e,t,r){let n=vt(e,"apps/desktop/dev-app-update.yml");try{await Ao(n)}catch{throw new Error(`Cannot neutralize the desktop dev update feed: ${n} is missing (boilerplate drift).`)}await l(n,`provider: generic
|
|
78
78
|
url: ${t}
|
|
79
79
|
updaterCacheDirName: ${r}
|
|
80
|
-
`)}function
|
|
80
|
+
`)}function To(e){let t=e.split(".");return t.length>=3?t.slice(1).join("."):e}function Ro(e,t){let r=rr(e,"app");return{appId:`${t.split(".").reverse().join(".")}.${r.replace(/-/g,"")}`,productName:e,protocol:r}}function _o(e){let t=e.toLowerCase().replace(/[^a-z0-9_]/g,"");return/^[a-z]/.test(t)?t:`app${t}`}function Oo(e,t){let r=rr(e,"app");return{appName:e,slug:r,bundleId:[...t.split(".").reverse(),r.replace(/-/g,"")].map(_o).join("."),scheme:/^[a-z]/.test(r)?r:`app-${r}`}}function xo(e){let t=[" // PRESENCE IS THE SWITCH: reporting runs as soon as the credential below is non-empty. There"," // is no separate `enabled` flag to remember - pasting the key IS turning it on, and clearing it"," // is turning it off. These are PUBLISHABLE credentials: they only authorize SENDING events,"," // never reading them, which is why they live here rather than in env and why a packaged desktop"," // app can carry them. Server-side, SENTRY_DSN and POSTHOG_API_KEY override them."].join(`
|
|
81
81
|
`),r=[];return e.includes("sentry")&&r.push(' sentry: { dsn: "" }'),e.includes("posthog")&&r.push([" // llmContent sends your users' prompts and completions to PostHog. Every number (tokens,"," // cost, latency, errors) is captured either way - turn it on only if your privacy policy"," // covers sending their message text to a third party.",' posthog: { publicKey: "", host: "https://us.i.posthog.com", llmContent: false }'].join(`
|
|
82
82
|
`)),r.length===0?[" // Error and product reporting is OFF: no provider was selected at init, so neither adapter",' // nor SDK was generated. To turn one on, add `"sentry"`, `"posthog"` or both to the'," // `observability` array in `.generatesaas/manifest.json`, then run `generatesaas update` -"," // which re-shapes the project and installs what it needs. Note that update stages nothing"," // while you are already on the latest version, so take the upgrade first if it reports no"," // changes."," observability: {},"].join(`
|
|
83
83
|
`):`${t}
|
|
84
84
|
observability: {
|
|
85
85
|
${r.join(`,
|
|
86
86
|
`)}
|
|
87
|
-
},`}async function
|
|
87
|
+
},`}async function Tn(e){let t=e.appName.replace(/\\/g,"\\\\").replace(/"/g,'\\"'),r=e.paymentProvider!=="none",n=e.baseUrl??"http://localhost:3000",i=e.baseUrl?new URL(e.baseUrl).hostname:"example.com",o=To(i),s=e.demo?"false":"true",a=e.multiTenancy?`{
|
|
88
88
|
multiTenant: true,
|
|
89
89
|
organizationLimit: 5,
|
|
90
90
|
defaultOrganizationName: "Personal"
|
|
91
|
-
}`:"{ multiTenant: false }",d=e.projects?"{ enabled: true, seedDefault: true }":"{ enabled: false, seedDefault: true }",h=
|
|
91
|
+
}`:"{ multiTenant: false }",d=e.projects?"{ enabled: true, seedDefault: true }":"{ enabled: false, seedDefault: true }",h=xo(e.observability),g=e.demo?`{
|
|
92
92
|
enableInDev: true,
|
|
93
93
|
chatwoot: {
|
|
94
94
|
baseUrl: "https://support.alexchelan.com",
|
|
95
95
|
websiteToken: "1uWvJLHkhWLihkK2bxLaj2Fg"
|
|
96
96
|
}
|
|
97
|
-
}`:"{}",w=e.architecture==="fullstack"?"false":"true",v=
|
|
97
|
+
}`:"{}",w=e.architecture==="fullstack"?"false":"true",v=Ro(t,o),P=`https://cdn.${o}/desktop`,m=Oo(t,o),D=` // Store purchases (RevenueCat) for the mobile app - ADDITIVE to the web provider, never a
|
|
98
98
|
// replacement: store rules require digital goods to be sold through in-app purchase.
|
|
99
99
|
mobile: {
|
|
100
100
|
enabled: ${e.mobilePurchases==="revenuecat"},
|
|
@@ -103,7 +103,7 @@ ${r.join(`,
|
|
|
103
103
|
grantCreditsToFamilyShare: false,
|
|
104
104
|
externalPurchaseLinks: "off",
|
|
105
105
|
customerCenter: true
|
|
106
|
-
}`,
|
|
106
|
+
}`,we=`import type { AgentsConfig, AppConfig } from "@repo/config/types";
|
|
107
107
|
import { resolveAgentsConfig } from "./agents";
|
|
108
108
|
import { desktopConfig } from "./desktop.mjs";
|
|
109
109
|
import { mobileConfig } from "./mobile.mjs";
|
|
@@ -248,7 +248,7 @@ ${h}
|
|
|
248
248
|
}`:`{
|
|
249
249
|
base: "${e.defaultCurrency}",
|
|
250
250
|
list: [
|
|
251
|
-
{ symbol: "${
|
|
251
|
+
{ symbol: "${Oe[e.defaultCurrency].symbol}", name: "${Oe[e.defaultCurrency].name}", code: "${e.defaultCurrency}", place: "${Oe[e.defaultCurrency].place}", space: ${Oe[e.defaultCurrency].space} }
|
|
252
252
|
],
|
|
253
253
|
countryMap: {
|
|
254
254
|
default: "${e.defaultCurrency}"
|
|
@@ -462,7 +462,7 @@ export * from "./robots";
|
|
|
462
462
|
export * from "./roles";
|
|
463
463
|
export * from "./section-tabs";
|
|
464
464
|
export * from "./tenancy";
|
|
465
|
-
`,K=
|
|
465
|
+
`,K=vt(e.projectDir,"packages/config/src/index.ts");await l(K,we),await l(vt(e.projectDir,"packages/config/src/support.ts"),`import type { SupportConfig } from "@repo/config/types";
|
|
466
466
|
|
|
467
467
|
/**
|
|
468
468
|
* Live-chat (support widget) configuration, re-exported by the config index as
|
|
@@ -475,7 +475,7 @@ export * from "./tenancy";
|
|
|
475
475
|
* import here type-only.
|
|
476
476
|
*/
|
|
477
477
|
export const supportConfig: SupportConfig = ${g};
|
|
478
|
-
`),await l(
|
|
478
|
+
`),await l(vt(e.projectDir,"packages/config/src/desktop.mjs"),`// Plain-JS desktop app identity. NO env reads, so tooling that cannot import the
|
|
479
479
|
// TypeScript config index reads it directly: the Electron main + renderer bundles,
|
|
480
480
|
// the CSP policy builder, and the electron-builder packaging config. Edit these
|
|
481
481
|
// values once to rebrand - they are the same in dev and production, so they live in
|
|
@@ -526,7 +526,7 @@ export const desktopConfig = {
|
|
|
526
526
|
background: false
|
|
527
527
|
}
|
|
528
528
|
};
|
|
529
|
-
`),await l(
|
|
529
|
+
`),await l(vt(e.projectDir,"packages/config/src/mobile.mjs"),`// Plain-JS mobile app identity. NO env reads, so tooling that cannot import the
|
|
530
530
|
// TypeScript config index reads it directly: app.config.ts, the EAS build config,
|
|
531
531
|
// Metro, and the app bundle itself (\`+native-intent\` reads UNIVERSAL_LINK_PATHS).
|
|
532
532
|
// The CLI wrote these values from your app name + domain at init; edit them here
|
|
@@ -639,9 +639,9 @@ export const SETTINGS_PRIVACY_PATH = "/settings/privacy";
|
|
|
639
639
|
export const SETTINGS_APP_LOCK_PATH = "/settings/app-lock";
|
|
640
640
|
/** app/(app)/settings/about.tsx - version, legal links and support. */
|
|
641
641
|
export const SETTINGS_ABOUT_PATH = "/settings/about";
|
|
642
|
-
`),e.desktop&&await
|
|
643
|
-
`)}function
|
|
644
|
-
`)}async function
|
|
642
|
+
`),e.desktop&&await Io(e.projectDir,P,`${v.protocol}-updater`)}import{join as Do}from"path";function No(e,t){let r=[e==="stripe"?' stripePriceId: "",':' polarProductId: "",'];return t&&r.push(' appleProductId: "",',' googleProductId: "",'," storeAmounts: {},"),r.join(`
|
|
643
|
+
`)}function gr(e){let t=[];return e.withCredits&&(t.push(`credits: ${e.credits}`),e.recurring!==!1&&t.push("creditInterval: 30")),t.push(`apiRateLimit: { maxRequests: ${e.rateLimit} }`),e.maxConcurrentRuns!==void 0&&t.push(`maxConcurrentRuns: ${e.maxConcurrentRuns}`),e.automationMinutesPerMonth!==void 0&&t.push(`automationMinutesPerMonth: ${e.automationMinutesPerMonth}`),t.map(r=>` ${r}`).join(`,
|
|
644
|
+
`)}async function Rn(e){let t=Do(e.projectDir,"packages/config/src/pricing.ts"),r=e.defaultCurrency;if(e.paymentProvider==="none"){let P=`import type { PricingConfig } from "@repo/config/types";
|
|
645
645
|
|
|
646
646
|
export const pricingConfig: PricingConfig = {
|
|
647
647
|
defaultPlan: "free",
|
|
@@ -670,7 +670,7 @@ export const pricingConfig: PricingConfig = {
|
|
|
670
670
|
credits: { enabled: false },
|
|
671
671
|
products: { enabled: false, items: [] }
|
|
672
672
|
};
|
|
673
|
-
`;await l(t,P);return}let n=e.paymentProvider,i=
|
|
673
|
+
`;await l(t,P);return}let n=e.paymentProvider,i=No(n,e.mobilePurchases==="revenuecat"),o=e.credits,s=!!e.ai,a=gr({credits:5,rateLimit:100,withCredits:o,recurring:!1,...s&&{maxConcurrentRuns:2,automationMinutesPerMonth:60}}),d=gr({credits:10,rateLimit:1e3,withCredits:o,...s&&{maxConcurrentRuns:5,automationMinutesPerMonth:600}}),h=gr({credits:40,rateLimit:5e3,withCredits:o,...s&&{maxConcurrentRuns:10,automationMinutesPerMonth:3e3}}),g=r==="USD"?"":` // !! ${r} pricing WARNING: creditsPerUsd anchors credit cost to USD (AI providers bill in
|
|
674
674
|
// !! USD), but your plan prices and credit purchases are denominated in ${r}. Verify that the
|
|
675
675
|
// !! ${r} amounts above cover the USD cost per credit (~0.50 USD at creditsPerUsd 1 with the
|
|
676
676
|
// !! 100% markup) - and raise prices or creditsPerUsd if they do not.
|
|
@@ -788,12 +788,12 @@ ${w},
|
|
|
788
788
|
items: []
|
|
789
789
|
}
|
|
790
790
|
};
|
|
791
|
-
`;await l(t,v)}var Do={smtp:[{key:"SMTP_HOST",defaultValue:"localhost"},{key:"SMTP_PORT",defaultValue:"1025"},{key:"SMTP_USER"},{key:"SMTP_PASSWORD"}],ses:[{key:"AMAZON_SES_REGION",comment:"# TODO: Configure Amazon SES credentials (e.g. us-east-1)"},{key:"AMAZON_SES_KEY"},{key:"AMAZON_SES_SECRET"},{key:"AMAZON_SES_CONFIGURATION_SET",comment:"# Delivery events: configuration set -> SNS topic -> /webhooks/email/ses (see docs: Email)"},{key:"AMAZON_SES_SNS_TOPIC_ARN"}],resend:[{key:"RESEND_API_KEY",comment:"# TODO: Add your Resend API key"},{key:"RESEND_WEBHOOK_SECRET",comment:"# Delivery events: Resend webhook -> /webhooks/email/resend (see docs: Email)"}]},No={stripe:[{key:"STRIPE_SECRET_KEY",comment:"# TODO: Add your Stripe keys"},{key:"STRIPE_WEBHOOK_SECRET"},{key:"STRIPE_API_BASE",comment:"# Point the Stripe SDK at another host - a local mock, or a proxy of your own. Unset means api.stripe.com."},{key:"STRIPE_PORTAL_GENERATION",comment:"# A number your deploy sets higher on every release (build timestamp, pipeline run number), so a new release retires the Stripe portal configurations an older one created. Unset, none are retired."}],polar:[{key:"POLAR_ACCESS_TOKEN",comment:"# TODO: Add your Polar keys"},{key:"POLAR_WEBHOOK_SECRET"}]};function Lt(e,t){return t?e.map(r=>{let n=t[r.key];return n?{...r,defaultValue:n,comment:void 0}:r}):e}function yt(e,t){for(let r of e)r.comment&&t.push(r.comment),r.defaultValue!==void 0?t.push(`${r.key}=${r.defaultValue}`):t.push(`#${r.key}=`)}function gr(e){return Array.from(crypto.getRandomValues(new Uint8Array(e))).map(t=>t.toString(16).padStart(2,"0")).join("")}function yr(e){return e.architecture==="fullstack"?{apiUrl:"http://localhost:3000/api",baseUrl:"http://localhost:3000"}:{apiUrl:"http://localhost:3010",baseUrl:"http://localhost:3000"}}var Co="https://your-app.example.com/api";function $t(e,t,r,n){e.push(n==="example"?`${t}=`:`${t}=${r}`)}function Rn(e,t){let r=t==="example"?void 0:e.credentials,{apiUrl:n,baseUrl:i}=yr(e),o=[];e.architecture==="separate"?o.push("# API Configuration","# Standalone backend's own URL (the frontend reaches it via the public var above).",`API_URL=${n}`,`BASE_URL=${i}`):o.push("# App","# (API_URL is derived from the frontend's *_PUBLIC_API_URL by the runtime env schema.)",`BASE_URL=${i}`),o.push("","# Client IP (production)","# The backend rate-limits by client IP and stamps it on audit entries. YOU ALMOST","# CERTAINLY DO NOT NEED THIS: it detects Vercel, Fly.io, Render, Railway, Netlify and","# Coolify from their own env vars, and treats a private connection peer (Docker, compose,","# Kubernetes, Dokploy, an nginx/Traefik/Caddy on the same host) as proof that something is","# forwarding to it. A forwarded header is trusted only in those cases, never on a server","# exposed directly - there, a caller could have sent it themselves."),e.architecture==="fullstack"?o.push("# This build runs the API in the frontend's route handler, which has no connection","# address, so on a host the backend does not recognise there is nothing to fall back on","# and every caller shares ONE rate-limit bucket: the 10/min sign-in limit applies to","# your whole app rather than per person, and audit entries record no address."):o.push("# This build reads the connection peer, so a server exposed directly identifies its","# callers correctly with nothing set here."),o.push("# SET IT when nothing above can see your topology - the common case is a CDN in front of a","# directly exposed server, where connections arrive from a public CDN address:","# Cloudflare -> TRUSTED_PROXY=cf-connecting-ip","# another proxy in front -> TRUSTED_PROXY=1 (the number of proxies)","# Whatever you set wins over detection; `false` turns detection off and trusts nothing.","# TRUSTED_PROXY=1"),o.push("","# Auth rate limits","# Raises every Better Auth rate limit by a whole number (1-50). Unset = 1 = the","# shipped limits. No value disables them: 0, empty or unparseable fails at boot.","# You almost certainly want the default. The reason to raise it is an automated suite,","# which drives ONE address and so shares one bucket - and the e2e config already sets","# its own value, so it needs nothing here.","# AUTH_RATE_LIMIT_MULTIPLIER=1"),o.push("","# Database"),yt(Lt(Oe[e.databaseProvider].envVars,r),o),o.push("","# Cache"),yt(Lt(xe[e.cacheProvider].envVars,r),o),e.cacheProvider==="redis"&&o.push("# Optional prefix on every Redis key the app writes. Set it when several deployments share","# one Redis instance, or to keep a load test out of the live keyspace.","# Set it once, before first boot. Changing it on a running deployment orphans in-flight","# auto-top-up claims and distributed locks (autotopup:pending:*, withMutex) - drain or use","# a maintenance window first.","#REDIS_KEY_PREFIX="),o.push("","# Authentication"),t==="example"&&o.push("# Generate a strong secret, e.g. `openssl rand -hex 32`"),$t(o,"BETTER_AUTH_SECRET",crypto.randomUUID(),t),o.push("","# Content API (random secret; required when contentApi feature is enabled in @repo/config)"),$t(o,"CONTENT_API_KEY",gr(32),t),o.push("","# Job Queue - Inngest","INNGEST_APP_ID=api"),$t(o,"INNGEST_EVENT_KEY",gr(32),t),o.push("# REQUIRED in production: it is how the /inngest endpoint tells Inngest from anyone else who","# finds the URL. The app refuses to boot without it unless INNGEST_DEV is set (local dev server).","# Take the real value from the Inngest dashboard."),$t(o,"INNGEST_SIGNING_KEY",gr(32),t),o.push("# The local Inngest dev server (pnpm dev:inngest). In production on Inngest Cloud leave this UNSET:","# Cloud takes events and registration on two hosts, which no single URL can name. Set it only for a","# self-hosted Inngest, to its one host.","INNGEST_BASE_URL=http://127.0.0.1:8288"),e.architecture==="separate"&&(o.push("","# API Port (standalone backend)","API_PORT=3010"),o.push("","# CORS + cross-subdomain cookies (production only - leave unset for local dev)","# TRUSTED_ORIGINS=https://your-app.example.com","# AUTH_COOKIE_DOMAIN=.example.com"));let s=Do[e.emailProvider];if(s&&(o.push("","# Email"),yt(Lt(s,r),o)),e.paymentProvider!=="none"){let d=No[e.paymentProvider];d&&(o.push("","# Payment"),yt(Lt(d,r),o))}o.push("","# Storage - S3 Compatible (required for uploads: avatars, files)","# Works with AWS S3, Cloudflare R2, MinIO, and other S3-compatible services.","# Uploads fail until these are set - storage is enabled in @repo/config by default.","#STORAGE_REGION=us-east-1","#STORAGE_ENDPOINT=https://your-account.r2.cloudflarestorage.com","#STORAGE_ACCESS_KEY_ID=","#STORAGE_SECRET_ACCESS_KEY=","# Two buckets: one publicly readable (avatars, assets), one credentials-only (private","# uploads served through the app). The bucket boundary is the privacy guarantee.","#STORAGE_PUBLIC_BUCKET=","#STORAGE_PRIVATE_BUCKET=",'# Public base URL for the public bucket. Public file URLs are STORAGE_PUBLIC_URL + "/" + key.',"#STORAGE_PUBLIC_URL=https://cdn.example.com","","# Storage - Local Filesystem (optional overrides)","# Defaults work out of the box; override for Docker volume mounts.","#STORAGE_LOCAL_PATH=/data","#STORAGE_LOCAL_PUBLIC_URL=https://cdn.example.com/files"),o.push("","# Admin Notifications (optional)","# Real-time notifications when business events occur (signups, purchases, etc.).","# Each provider is auto-enabled when its env vars are set.","#DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...","#SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...","#TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz","#TELEGRAM_CHAT_ID=-1001234567890","#TELEGRAM_TOPIC_ID=123"),o.push("","# Social sign-in (optional)","# Enable the matching provider in @repo/config, then set its pair here.","#GOOGLE_CLIENT_ID=","#GOOGLE_CLIENT_SECRET=","#GITHUB_CLIENT_ID=","#GITHUB_CLIENT_SECRET=","# Apple: APPLE_TEAM_ID + APPLE_KEY_ID + APPLE_PRIVATE_KEY are the signing key. Add APPLE_CLIENT_ID",'# (a Service ID) for the web button, and list "apple" in config.auth.socialProviders.',"#APPLE_CLIENT_ID=","#APPLE_TEAM_ID=","#APPLE_KEY_ID=","#APPLE_PRIVATE_KEY="),o.push("","# SMS - Twilio (optional)","#TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","#TWILIO_AUTH_TOKEN=","#TWILIO_PHONE_NUMBER=+1234567890"),o.push("","# Newsletter (optional)","#RESEND_AUDIENCE_ID=","#LISTMONK_URL=https://listmonk.yourdomain.com","#LISTMONK_API_USER=admin","#LISTMONK_API_TOKEN=","#LISTMONK_LIST_ID=1"),o.push("","# Translations - OpenRouter (optional)","# Set to auto-translate en/* into your other locales with `pnpm translate`.","# The pre-commit hook runs it on commit; without a key it skips and untranslated","# locales fall back to the default locale. Get a key: https://openrouter.ai/keys","#OPENROUTER_API_KEY=");let a=[];return e.observability.includes("sentry")&&a.push({key:"SENTRY_DSN",comment:"# Overrides config.observability.sentry.dsn for the server only."}),e.observability.includes("posthog")&&a.push({key:"POSTHOG_API_KEY",comment:"# Overrides config.observability.posthog.publicKey for the server only (host stays in config)."}),a.length>0&&(o.push("","# Error and product reporting (optional)"),yt(a,o)),o.push("","# AI base (optional)","# Enable config.ai in packages/config. Built-in AI resolves through OpenRouter only,","# so OPENROUTER_API_KEY above is the only MODEL provider key the backend reads.","# BYOK is per-user and never an env var: users paste their own key in the app and it","# is stored encrypted in the database.","# Override the OpenRouter API base - a gateway or a proxy of your own. It must carry the","# /api/v1 suffix. Unset uses the hosted API.","#OPENROUTER_BASE_URL=https://openrouter.ai/api/v1","","# AI web tools (optional)","# Give the assistant its own web access, on chat (on by default), the desktop app-tools","# lane, automations and the MCP server. A key's presence is the whole switch: set one","# and the model gets its tools, set none and chat is unchanged. config.ai.webSearch:","# false switches every lane off.","# ANY one key alone covers both capabilities. With several set, auto-detection takes the","# CHEAPEST first - TinyFish, then Firecrawl, then Parallel - per capability; naming","# config.ai.webTools.searchEngine or .extractEngine wins over that order, and a named","# engine whose key is missing ships no tool at all.","# WHAT A CALL COSTS THE USER: the vendor price x credits.creditsPerUsd x the","# webSearch/webExtract markup (shipped at 100, so twice cost). Override the vendor price","# with config.ai.webTools.searchCostUsd / .extractCostUsd.","# TinyFish (the auto-detect default) - both tools at a $0 list price, so a call debits","# nothing, writes no ledger row and is never refused for an empty balance. Rate-limited","# instead (30 searches and 150 pages a minute per API key by default, shared by every","# user), so set a price on a busy app. Note its terms include a license to train on","# submitted content.","# https://agent.tinyfish.ai/","#TINYFISH_API_KEY=","# Firecrawl (the recommended PAID engine) - `web_search` AND `web_extract` (reads one","# page) from one signup, and the only vendor here whose terms carry no license over what","# you submit.","# https://firecrawl.dev/","#FIRECRAWL_API_KEY=","# Parallel (optional) - both tools, cheaper than Firecrawl on each, at high quality;","# extract is slower on a page it has not already indexed. Note its customer terms","# include a license to train on submitted queries.","# https://platform.parallel.ai/","#PARALLEL_API_KEY="),e.desktop&&o.push("","# Desktop app (optional)","# Base URL the marketing download page reads the release feed from.","# Unset uses config.desktop.autoUpdate.url.","#DESKTOP_FEED_BASE_URL="),e.mobile&&(o.push("","# Mobile app","# The bundle identifier the native Sign in with Apple token is issued to - it joins","# APPLE_CLIENT_ID in the audience the backend accepts. With the three Apple signing values","# and no APPLE_CLIENT_ID, it alone turns on native-only Sign in with Apple: the iOS app shows","# Apple beside Google (it reads that at runtime) and the web shows no Apple button.","#APPLE_APP_BUNDLE_IDENTIFIER=","# Comma-separated Android signing certificate SHA-256 fingerprints. They feed","# .well-known/assetlinks.json (which verifies your universal links) and the passkey","# Android origins.","#MOBILE_ANDROID_SHA256_FINGERPRINTS=","# Optional. An Expo access token turns on enhanced security for push sends.","#EXPO_ACCESS_TOKEN="),e.mobilePurchases==="revenuecat"&&o.push("","# Mobile store purchases - RevenueCat (server)","# The first two verify incoming webhooks; the third is read by the sync route and the","# nightly reconcile pass.","#REVENUECAT_WEBHOOK_SECRET=","#REVENUECAT_AUTH_HEADER=","#REVENUECAT_API_KEY=")),o.push("","# Build output (optional)","# Next's distDir for apps/web. Unset uses .next; the e2e harness sets its own so a test","# build never overwrites a dev server's.","#NEXT_DIST_DIR="),o.push(""),o.join(`
|
|
792
|
-
`)}var
|
|
793
|
-
`)}function
|
|
794
|
-
`)}async function
|
|
795
|
-
${
|
|
796
|
-
${
|
|
791
|
+
`;await l(t,v)}var Co={smtp:[{key:"SMTP_HOST",defaultValue:"localhost"},{key:"SMTP_PORT",defaultValue:"1025"},{key:"SMTP_USER"},{key:"SMTP_PASSWORD"}],ses:[{key:"AMAZON_SES_REGION",comment:"# TODO: Configure Amazon SES credentials (e.g. us-east-1)"},{key:"AMAZON_SES_KEY"},{key:"AMAZON_SES_SECRET"},{key:"AMAZON_SES_CONFIGURATION_SET",comment:"# Delivery events: configuration set -> SNS topic -> /webhooks/email/ses (see docs: Email)"},{key:"AMAZON_SES_SNS_TOPIC_ARN"}],resend:[{key:"RESEND_API_KEY",comment:"# TODO: Add your Resend API key"},{key:"RESEND_WEBHOOK_SECRET",comment:"# Delivery events: Resend webhook -> /webhooks/email/resend (see docs: Email)"}]},Lo={stripe:[{key:"STRIPE_SECRET_KEY",comment:"# TODO: Add your Stripe keys"},{key:"STRIPE_WEBHOOK_SECRET"},{key:"STRIPE_API_BASE",comment:"# Point the Stripe SDK at another host - a local mock, or a proxy of your own. Unset means api.stripe.com."},{key:"STRIPE_PORTAL_GENERATION",comment:"# A number your deploy sets higher on every release (build timestamp, pipeline run number), so a new release retires the Stripe portal configurations an older one created. Unset, none are retired."}],polar:[{key:"POLAR_ACCESS_TOKEN",comment:"# TODO: Add your Polar keys"},{key:"POLAR_WEBHOOK_SECRET"}]};function jt(e,t){return t?e.map(r=>{let n=t[r.key];return n?{...r,defaultValue:n,comment:void 0}:r}):e}function wt(e,t){for(let r of e)r.comment&&t.push(r.comment),r.defaultValue!==void 0?t.push(`${r.key}=${r.defaultValue}`):t.push(`#${r.key}=`)}function yr(e){return Array.from(crypto.getRandomValues(new Uint8Array(e))).map(t=>t.toString(16).padStart(2,"0")).join("")}function br(e){return e.architecture==="fullstack"?{apiUrl:"http://localhost:3000/api",baseUrl:"http://localhost:3000"}:{apiUrl:"http://localhost:3010",baseUrl:"http://localhost:3000"}}var $o="https://your-app.example.com/api";function Mt(e,t,r,n){e.push(n==="example"?`${t}=`:`${t}=${r}`)}function _n(e,t){let r=t==="example"?void 0:e.credentials,{apiUrl:n,baseUrl:i}=br(e),o=[];e.architecture==="separate"?o.push("# API Configuration","# Standalone backend's own URL (the frontend reaches it via the public var above).",`API_URL=${n}`,`BASE_URL=${i}`):o.push("# App","# (API_URL is derived from the frontend's *_PUBLIC_API_URL by the runtime env schema.)",`BASE_URL=${i}`),o.push("","# Client IP (production)","# The backend rate-limits by client IP and stamps it on audit entries. YOU ALMOST","# CERTAINLY DO NOT NEED THIS: it detects Vercel, Fly.io, Render, Railway, Netlify and","# Coolify from their own env vars, and treats a private connection peer (Docker, compose,","# Kubernetes, Dokploy, an nginx/Traefik/Caddy on the same host) as proof that something is","# forwarding to it. A forwarded header is trusted only in those cases, never on a server","# exposed directly - there, a caller could have sent it themselves."),e.architecture==="fullstack"?o.push("# This build runs the API in the frontend's route handler, which has no connection","# address, so on a host the backend does not recognise there is nothing to fall back on","# and every caller shares ONE rate-limit bucket: the 10/min sign-in limit applies to","# your whole app rather than per person, and audit entries record no address."):o.push("# This build reads the connection peer, so a server exposed directly identifies its","# callers correctly with nothing set here."),o.push("# SET IT when nothing above can see your topology - the common case is a CDN in front of a","# directly exposed server, where connections arrive from a public CDN address:","# Cloudflare -> TRUSTED_PROXY=cf-connecting-ip","# another proxy in front -> TRUSTED_PROXY=1 (the number of proxies)","# Whatever you set wins over detection; `false` turns detection off and trusts nothing.","# TRUSTED_PROXY=1"),o.push("","# Auth rate limits","# Raises every Better Auth rate limit by a whole number (1-50). Unset = 1 = the","# shipped limits. No value disables them: 0, empty or unparseable fails at boot.","# You almost certainly want the default. The reason to raise it is an automated suite,","# which drives ONE address and so shares one bucket - and the e2e config already sets","# its own value, so it needs nothing here.","# AUTH_RATE_LIMIT_MULTIPLIER=1"),o.push("","# Database"),wt(jt(xe[e.databaseProvider].envVars,r),o),o.push("","# Cache"),wt(jt(De[e.cacheProvider].envVars,r),o),e.cacheProvider==="redis"&&o.push("# Optional prefix on every Redis key the app writes. Set it when several deployments share","# one Redis instance, or to keep a load test out of the live keyspace.","# Set it once, before first boot. Changing it on a running deployment orphans in-flight","# auto-top-up claims and distributed locks (autotopup:pending:*, withMutex) - drain or use","# a maintenance window first.","#REDIS_KEY_PREFIX="),o.push("","# Authentication"),t==="example"&&o.push("# Generate a strong secret, e.g. `openssl rand -hex 32`"),Mt(o,"BETTER_AUTH_SECRET",crypto.randomUUID(),t),o.push("","# Content API (random secret; required when contentApi feature is enabled in @repo/config)"),Mt(o,"CONTENT_API_KEY",yr(32),t),o.push("","# Job Queue - Inngest","INNGEST_APP_ID=api"),Mt(o,"INNGEST_EVENT_KEY",yr(32),t),o.push("# REQUIRED in production: it is how the /inngest endpoint tells Inngest from anyone else who","# finds the URL. The app refuses to boot without it unless INNGEST_DEV is set (local dev server).","# Take the real value from the Inngest dashboard."),Mt(o,"INNGEST_SIGNING_KEY",yr(32),t),o.push("# The local Inngest dev server (pnpm dev:inngest). In production on Inngest Cloud leave this UNSET:","# Cloud takes events and registration on two hosts, which no single URL can name. Set it only for a","# self-hosted Inngest, to its one host.","INNGEST_BASE_URL=http://127.0.0.1:8288"),e.architecture==="separate"&&(o.push("","# API Port (standalone backend)","API_PORT=3010"),o.push("","# CORS + cross-subdomain cookies (production only - leave unset for local dev)","# TRUSTED_ORIGINS=https://your-app.example.com","# AUTH_COOKIE_DOMAIN=.example.com"));let s=Co[e.emailProvider];if(s&&(o.push("","# Email"),wt(jt(s,r),o)),e.paymentProvider!=="none"){let d=Lo[e.paymentProvider];d&&(o.push("","# Payment"),wt(jt(d,r),o))}o.push("","# Storage - S3 Compatible (required for uploads: avatars, files)","# Works with AWS S3, Cloudflare R2, MinIO, and other S3-compatible services.","# Uploads fail until these are set - storage is enabled in @repo/config by default.","#STORAGE_REGION=us-east-1","#STORAGE_ENDPOINT=https://your-account.r2.cloudflarestorage.com","#STORAGE_ACCESS_KEY_ID=","#STORAGE_SECRET_ACCESS_KEY=","# Two buckets: one publicly readable (avatars, assets), one credentials-only (private","# uploads served through the app). The bucket boundary is the privacy guarantee.","#STORAGE_PUBLIC_BUCKET=","#STORAGE_PRIVATE_BUCKET=",'# Public base URL for the public bucket. Public file URLs are STORAGE_PUBLIC_URL + "/" + key.',"#STORAGE_PUBLIC_URL=https://cdn.example.com","","# Storage - Local Filesystem (optional overrides)","# Defaults work out of the box; override for Docker volume mounts.","#STORAGE_LOCAL_PATH=/data","#STORAGE_LOCAL_PUBLIC_URL=https://cdn.example.com/files"),o.push("","# Admin Notifications (optional)","# Real-time notifications when business events occur (signups, purchases, etc.).","# Each provider is auto-enabled when its env vars are set.","#DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...","#SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...","#TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz","#TELEGRAM_CHAT_ID=-1001234567890","#TELEGRAM_TOPIC_ID=123"),o.push("","# Social sign-in (optional)","# Enable the matching provider in @repo/config, then set its pair here.","#GOOGLE_CLIENT_ID=","#GOOGLE_CLIENT_SECRET=","#GITHUB_CLIENT_ID=","#GITHUB_CLIENT_SECRET=","# Apple: APPLE_TEAM_ID + APPLE_KEY_ID + APPLE_PRIVATE_KEY are the signing key. Add APPLE_CLIENT_ID",'# (a Service ID) for the web button, and list "apple" in config.auth.socialProviders.',"#APPLE_CLIENT_ID=","#APPLE_TEAM_ID=","#APPLE_KEY_ID=","#APPLE_PRIVATE_KEY="),o.push("","# SMS - Twilio (optional)","#TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","#TWILIO_AUTH_TOKEN=","#TWILIO_PHONE_NUMBER=+1234567890"),o.push("","# Newsletter (optional)","#RESEND_AUDIENCE_ID=","#LISTMONK_URL=https://listmonk.yourdomain.com","#LISTMONK_API_USER=admin","#LISTMONK_API_TOKEN=","#LISTMONK_LIST_ID=1"),o.push("","# Translations - OpenRouter (optional)","# Set to auto-translate en/* into your other locales with `pnpm translate`.","# The pre-commit hook runs it on commit; without a key it skips and untranslated","# locales fall back to the default locale. Get a key: https://openrouter.ai/keys","#OPENROUTER_API_KEY=");let a=[];return e.observability.includes("sentry")&&a.push({key:"SENTRY_DSN",comment:"# Overrides config.observability.sentry.dsn for the server only."}),e.observability.includes("posthog")&&a.push({key:"POSTHOG_API_KEY",comment:"# Overrides config.observability.posthog.publicKey for the server only (host stays in config)."}),a.length>0&&(o.push("","# Error and product reporting (optional)"),wt(a,o)),o.push("","# AI base (optional)","# Enable config.ai in packages/config. Built-in AI resolves through OpenRouter only,","# so OPENROUTER_API_KEY above is the only MODEL provider key the backend reads.","# BYOK is per-user and never an env var: users paste their own key in the app and it","# is stored encrypted in the database.","# Override the OpenRouter API base - a gateway or a proxy of your own. It must carry the","# /api/v1 suffix. Unset uses the hosted API.","#OPENROUTER_BASE_URL=https://openrouter.ai/api/v1","","# AI web tools (optional)","# Give the assistant its own web access, on chat (on by default), the desktop app-tools","# lane, automations and the MCP server. A key's presence is the whole switch: set one","# and the model gets its tools, set none and chat is unchanged. config.ai.webSearch:","# false switches every lane off.","# ANY one key alone covers both capabilities. With several set, auto-detection takes the","# CHEAPEST first - TinyFish, then Firecrawl, then Parallel - per capability; naming","# config.ai.webTools.searchEngine or .extractEngine wins over that order, and a named","# engine whose key is missing ships no tool at all.","# WHAT A CALL COSTS THE USER: the vendor price x credits.creditsPerUsd x the","# webSearch/webExtract markup (shipped at 100, so twice cost). Override the vendor price","# with config.ai.webTools.searchCostUsd / .extractCostUsd.","# TinyFish (the auto-detect default) - both tools at a $0 list price, so a call debits","# nothing, writes no ledger row and is never refused for an empty balance. Rate-limited","# instead (30 searches and 150 pages a minute per API key by default, shared by every","# user), so set a price on a busy app. Note its terms include a license to train on","# submitted content.","# https://agent.tinyfish.ai/","#TINYFISH_API_KEY=","# Firecrawl (the recommended PAID engine) - `web_search` AND `web_extract` (reads one","# page) from one signup, and the only vendor here whose terms carry no license over what","# you submit.","# https://firecrawl.dev/","#FIRECRAWL_API_KEY=","# Parallel (optional) - both tools, cheaper than Firecrawl on each, at high quality;","# extract is slower on a page it has not already indexed. Note its customer terms","# include a license to train on submitted queries.","# https://platform.parallel.ai/","#PARALLEL_API_KEY="),e.desktop&&o.push("","# Desktop app (optional)","# Base URL the marketing download page reads the release feed from.","# Unset uses config.desktop.autoUpdate.url.","#DESKTOP_FEED_BASE_URL="),e.mobile&&(o.push("","# Mobile app","# The bundle identifier the native Sign in with Apple token is issued to - it joins","# APPLE_CLIENT_ID in the audience the backend accepts. With the three Apple signing values","# and no APPLE_CLIENT_ID, it alone turns on native-only Sign in with Apple: the iOS app shows","# Apple beside Google (it reads that at runtime) and the web shows no Apple button.","#APPLE_APP_BUNDLE_IDENTIFIER=","# Comma-separated Android signing certificate SHA-256 fingerprints. They feed","# .well-known/assetlinks.json (which verifies your universal links) and the passkey","# Android origins.","#MOBILE_ANDROID_SHA256_FINGERPRINTS=","# Optional. An Expo access token turns on enhanced security for push sends.","#EXPO_ACCESS_TOKEN="),e.mobilePurchases==="revenuecat"&&o.push("","# Mobile store purchases - RevenueCat (server)","# The first two verify incoming webhooks; the third is read by the sync route and the","# nightly reconcile pass.","#REVENUECAT_WEBHOOK_SECRET=","#REVENUECAT_AUTH_HEADER=","#REVENUECAT_API_KEY=")),o.push("","# Build output (optional)","# Next's distDir for apps/web. Unset uses .next; the e2e harness sets its own so a test","# build never overwrites a dev server's.","#NEXT_DIST_DIR="),o.push(""),o.join(`
|
|
792
|
+
`)}var On="NEXT_PUBLIC_API_URL";function jo(e){let{apiUrl:t}=br(e),r=["# API Configuration",`${On}=${t}`];return e.architecture==="separate"&&r.push("","# Production (uncomment and replace with your deployed hostnames):",`# ${On}=${$o}`),r.push(""),r.join(`
|
|
793
|
+
`)}function Mo(e){let{apiUrl:t,baseUrl:r}=br(e),n=["# Expo reads env from THIS directory, never the monorepo root.","# Every EXPO_PUBLIC_* value is inlined into the app bundle - never put a secret here.","# Android emulator: replace localhost with 10.0.2.2; a physical device needs your machine's LAN IP","# EXPO_PUBLIC_API_URL is the value to fill first - without it the app throws at launch and names it.",`EXPO_PUBLIC_API_URL=${t}`,`EXPO_PUBLIC_WEB_URL=${r}`,"","# Native Google sign-in (optional)","#EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=","#EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID="];e.mobilePurchases==="revenuecat"&&n.push("","# RevenueCat public SDK keys (optional until you create your products)","#EXPO_PUBLIC_REVENUECAT_IOS_KEY=","#EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=");let i=[];return e.observability.includes("sentry")&&i.push("#EXPO_PUBLIC_SENTRY_DSN="),e.observability.includes("posthog")&&i.push("#EXPO_PUBLIC_POSTHOG_KEY="),i.length>0&&n.push("","# Observability (optional)",...i),n.push(""),n.join(`
|
|
794
|
+
`)}async function xn(e){let t=jo(e);if(await l(`${e.projectDir}/.env`,`${t}
|
|
795
|
+
${_n(e,"env")}`),await l(`${e.projectDir}/.env.example`,`${t}
|
|
796
|
+
${_n(e,"example")}`),e.mobile){let r=Mo(e);await l(`${e.projectDir}/apps/mobile/.env`,r),await l(`${e.projectDir}/apps/mobile/.env.example`,r)}}import{join as Uo}from"path";async function Dn(e){let t=Qe(e),r=[],n=[];if(t.includes("postgres")&&(r.push(` postgres:
|
|
797
797
|
image: postgres:18-alpine
|
|
798
798
|
restart: unless-stopped
|
|
799
799
|
ports:
|
|
@@ -855,22 +855,22 @@ ${r.join(`
|
|
|
855
855
|
volumes:
|
|
856
856
|
${n.join(`
|
|
857
857
|
`)}
|
|
858
|
-
`),await l(
|
|
859
|
-
`)}import{readFile as
|
|
860
|
-
export const pool = db.$client;`,
|
|
858
|
+
`),await l(Uo(e.projectDir,"infra/docker-compose.yml"),o)}import{join as Fo}from"path";var kt="Next.js";function Vo(e){let t=[];if(t.push({type:"node-terminal",request:"launch",name:"Dev",command:"pnpm dev",cwd:"${workspaceFolder}",skipFiles:["<node_internals>/**"],env:{NODE_ENV:"development"}}),e.architecture==="separate"&&t.push({type:"node-terminal",request:"launch",name:"Backend",command:"pnpm dev",cwd:"${workspaceFolder}/apps/backend",skipFiles:["<node_internals>/**"],env:{NODE_ENV:"development"}}),t.push({type:"node-terminal",request:"launch",name:kt,command:"pnpm dev",cwd:"${workspaceFolder}/apps/web",skipFiles:["<node_internals>/**"],env:{NODE_ENV:"development"}}),e.docs&&t.push({type:"node-terminal",request:"launch",name:"Docs",command:"pnpm dev",cwd:"${workspaceFolder}/apps/docs",skipFiles:["<node_internals>/**"],env:{NODE_ENV:"development"}}),e.desktop){let r=e.architecture==="separate"?"http://localhost:3010":"http://localhost:3000/api";t.push({type:"node-terminal",request:"launch",name:"Desktop",command:"pnpm dev",cwd:"${workspaceFolder}/apps/desktop",skipFiles:["<node_internals>/**"],env:{NODE_ENV:"development",BASE_URL:"http://localhost:3000",PUBLIC_API_URL:r}})}if(t.push({type:"node-terminal",request:"launch",name:"Inngest",command:"pnpm dev:inngest",cwd:"${workspaceFolder}",skipFiles:["<node_internals>/**"],env:{NODE_ENV:"development"}}),t.push({type:"node-terminal",request:"launch",name:"Mail",command:"pnpm dev:mail",cwd:"${workspaceFolder}",skipFiles:["<node_internals>/**"],env:{NODE_ENV:"development"}}),e.paymentProvider==="stripe"){let r=e.architecture==="separate"?"localhost:3010/auth/stripe/webhook":"localhost:3000/api/auth/stripe/webhook";t.push({type:"node-terminal",request:"launch",name:"Stripe",command:`stripe listen --forward-to ${r}`,cwd:"${workspaceFolder}",skipFiles:["<node_internals>/**"]})}return t}function Bo(e){let t=[];return e.architecture==="separate"?t.push({name:`Dev (${kt} + Backend + Inngest)`,configurations:["Backend",kt,"Inngest"]}):t.push({name:`Dev (${kt} + Inngest)`,configurations:[kt,"Inngest"]}),t}async function Nn(e){let t={version:"0.2.0",configurations:Vo(e),compounds:Bo(e)};await l(Fo(e.projectDir,".vscode/launch.json"),`${JSON.stringify(t,null," ")}
|
|
859
|
+
`)}import{readFile as Ln}from"fs/promises";import{join as $n}from"path";var vr='import { drizzle } from "drizzle-orm/node-postgres";',wr=`export const db = drizzle(parsed.data.DATABASE_URL, { schema });
|
|
860
|
+
export const pool = db.$client;`,Ko={postgres:{importLine:vr,instantiation:wr},neon:{importLine:`import { Pool } from "@neondatabase/serverless";
|
|
861
861
|
import { drizzle } from "drizzle-orm/neon-serverless";`,instantiation:`const pool = new Pool({ connectionString: parsed.data.DATABASE_URL });
|
|
862
862
|
export const db = drizzle(pool, { schema });`},supabase:{importLine:`import { drizzle } from "drizzle-orm/postgres-js";
|
|
863
863
|
import postgres from "postgres";`,instantiation:`const client = postgres(parsed.data.DATABASE_URL);
|
|
864
|
-
export const db = drizzle(client, { schema });`}};async function
|
|
864
|
+
export const db = drizzle(client, { schema });`}};async function jn(e){let t=$n(e.projectDir,"packages/database/src/index.ts"),r=await Ln(t,"utf-8");if(!r.includes(vr)||!r.includes(wr))throw new Error("generateDbDriver: boilerplate packages/database/src/index.ts is missing the node-postgres driver anchors (the import line or the `db` instantiation block); the boilerplate drifted.");let n=Ko[e.databaseProvider],i=r.replace(vr,n.importLine).replace(wr,n.instantiation);await l(t,i),await Ho(e)}var Cn='import { Client } from "pg";',kr=` const client = new Client({ connectionString });
|
|
865
865
|
await client.connect();
|
|
866
866
|
return {
|
|
867
867
|
query: async (text) => ({ rows: (await client.query<LiveColumnRow>(text)).rows }),
|
|
868
868
|
end: () => client.end()
|
|
869
|
-
};`,
|
|
869
|
+
};`,Go={postgres:null,neon:{importLine:'import { Client } from "@neondatabase/serverless";',open:kr},supabase:{importLine:'import postgres from "postgres";',open:` const sql = postgres(connectionString, { prepare: false });
|
|
870
870
|
return {
|
|
871
871
|
query: async (text) => ({ rows: await sql.unsafe(text) }),
|
|
872
872
|
end: () => sql.end()
|
|
873
|
-
};`}};async function
|
|
873
|
+
};`}};async function Ho(e){let t=Go[e.databaseProvider];if(!t)return;let r=$n(e.projectDir,"packages/database/src/db-verify.ts"),n=await Ln(r,"utf-8");if(!n.includes(Cn)||!n.includes(kr))throw new Error("generateDbDriver: boilerplate packages/database/src/db-verify.ts is missing the node-postgres verifier anchors (the import line or the openVerifyClient body); the boilerplate drifted.");await l(r,n.replace(Cn,t.importLine).replace(kr,t.open))}import{join as Mn}from"path";async function Un(e){e.cacheProvider==="upstash"&&(await Yo(e),await Wo(e))}async function Yo(e){await l(Mn(e.projectDir,"packages/runtime/src/redis.ts"),`import { Redis } from "@upstash/redis";
|
|
874
874
|
import type { Store } from "hono-rate-limiter";
|
|
875
875
|
import { env } from "./env";
|
|
876
876
|
|
|
@@ -1082,7 +1082,7 @@ export function subscribeRedisChannel(
|
|
|
1082
1082
|
|
|
1083
1083
|
/** No persistent connection to close with Upstash REST. */
|
|
1084
1084
|
export async function closeRedis(): Promise<void> {}
|
|
1085
|
-
`)}async function
|
|
1085
|
+
`)}async function Wo(e){await l(Mn(e.projectDir,"packages/runtime/src/mutex.ts"),`import { Lock } from "@upstash/lock";
|
|
1086
1086
|
import { redis } from "./redis";
|
|
1087
1087
|
|
|
1088
1088
|
export class MutexTimeoutError extends Error {
|
|
@@ -1160,18 +1160,18 @@ export async function withMutex<T>(
|
|
|
1160
1160
|
});
|
|
1161
1161
|
}
|
|
1162
1162
|
}
|
|
1163
|
-
`)}import{readFile as
|
|
1164
|
-
UPSTASH_REDIS_REST_TOKEN: z.string(),`;async function
|
|
1163
|
+
`)}import{readFile as zo}from"fs/promises";import{join as qo}from"path";var Fn=" REDIS_URL: z.string(),",Jo=` UPSTASH_REDIS_REST_URL: z.string(),
|
|
1164
|
+
UPSTASH_REDIS_REST_TOKEN: z.string(),`;async function Vn(e){let t=qo(e.projectDir,"packages/runtime/src/env.ts"),r=await zo(t,"utf-8");if(!r.includes(Fn))throw new Error("generateEnvSchema: boilerplate packages/runtime/src/env.ts is missing the REDIS_URL cache binding anchor; the boilerplate drifted.");let n=e.cacheProvider==="upstash"?r.replace(Fn,Jo):r;await l(t,n)}import{readFile as Yn}from"fs/promises";import{join as M}from"path";import{readFile as Sr,rm as Qo}from"fs/promises";import{join as Fe}from"path";import{readFile as Er}from"fs/promises";function Xo(e){return new RegExp(`^[ \\t]*(?:\\/\\/|\\{\\/\\*|\\*) gsaas:${e}-start(?: \\*\\/\\})?[ \\t]*\\r?\\n[\\s\\S]*?^[ \\t]*(?:\\/\\/|\\{\\/\\*|\\*) gsaas:${e}-end(?: \\*\\/\\})?[ \\t]*\\r?\\n`,"gm")}function Me(e,t,r,n){let i=Xo(r);if(e.match(i)===null)throw new Error(`${n}: expected gsaas ${r} markers in ${t}, but found none (boilerplate drift).`);let o=e.replace(i,"");if(o.includes(`gsaas:${r}-start`)||o.includes(`gsaas:${r}-end`))throw new Error(`${n}: an unbalanced gsaas ${r} marker is left in ${t} (boilerplate drift).`);return o.replace(/\n{3,}/g,`
|
|
1165
1165
|
|
|
1166
|
-
`).replace(/,(\r?\n[ \t]*[\]})])/g,"$1")}async function T(e,t,r,n,i){let o=await
|
|
1167
|
-
`)}function
|
|
1166
|
+
`).replace(/,(\r?\n[ \t]*[\]})])/g,"$1")}async function T(e,t,r,n,i){let o=await Er(e,"utf-8"),s=o.indexOf(t);if(s===-1)throw new Error(`${i}: expected to find the ${n} seam, but it was missing (boilerplate drift).`);if(o.includes(t,s+t.length))throw new Error(`${i}: the ${n} seam appears more than once (boilerplate drift).`);await l(e,o.slice(0,s)+r+o.slice(s+t.length))}async function Ue(e,t,r,n){let i=/\n([\t ]+)"/.exec(await Er(e,"utf-8"))?.[1];if(i!==r){let o=s=>s===" "?"tab":`${s.length}-space`;throw new Error(`${n}: expected ${e} to be ${o(r)}-indented, but it is ${i===void 0?"not indented":o(i)}-indented (boilerplate drift). Re-serializing it with the wrong indent would reformat the whole file.`)}await l(e,`${JSON.stringify(t,null,r)}
|
|
1167
|
+
`)}function Zo(e){let t="",r=0;for(;r<e.length;){let n=e[r],i=e[r+1];if(n==="/"&&i==="/"){for(;r<e.length&&e[r]!==`
|
|
1168
1168
|
`;)r++;continue}if(n==="/"&&i==="*"){for(r+=2;r<e.length&&!(e[r]==="*"&&e[r+1]==="/");)e[r]===`
|
|
1169
1169
|
`&&(t+=`
|
|
1170
1170
|
`),r++;r+=2;continue}if(n==='"'||n==="'"||n==="`"){let o=n;for(r++;r<e.length&&e[r]!==o;)e[r]==="\\"?r++:e[r]===`
|
|
1171
1171
|
`&&(t+=`
|
|
1172
|
-
`),r++;r++;continue}t+=n,r++}return t}async function
|
|
1173
|
-
`,r=await
|
|
1174
|
-
...sentryExpoPlugin()`,
|
|
1172
|
+
`),r++;r++;continue}t+=n,r++}return t}async function Bn(e){let t=`import { config } from "@repo/config";
|
|
1173
|
+
`,r=await Er(e,"utf-8");if(!r.includes(t))return;let n=r.replace(t,""),i=Zo(n);/\bconfig\b/.test(i)||await l(e,n)}var Ve="stripMobileObservability",Kn=["apps/mobile/lib/observability.ts","apps/mobile/types/env.d.ts","apps/mobile/tests/settings-privacy-screen.test.tsx"],Gn="apps/mobile/tests/observability.test.ts",ea="mobile-obs-any",ta=["apps/mobile/tests/settings-privacy-screen.test.tsx"],Ut={sentry:["@sentry/react-native","@sentry/cli"],posthog:["posthog-react-native"]},ra=`,
|
|
1174
|
+
...sentryExpoPlugin()`,na=["apps/mobile/app.config.ts"],ia="|@sentry/react-native",sa=` * DUAL-PROVIDER, the way \`packages/observability\` is. This repo carries the Sentry and the PostHog
|
|
1175
1175
|
* halves at once so dev and the test suite exercise both. A generated project carries only the
|
|
1176
1176
|
* providers its \`observability\` manifest option names: the CLI removes the other one's marked region
|
|
1177
1177
|
* from this file, its SDK from \`apps/mobile/package.json\` and, for Sentry, its Expo config plugin
|
|
@@ -1182,7 +1182,7 @@ export async function withMutex<T>(
|
|
|
1182
1182
|
* cross-provider \`{@link}\` and every paragraph about one provider's credentials sits INSIDE that
|
|
1183
1183
|
* provider's own region, and what is left between them is provider-neutral - the {@link PROVIDERS}
|
|
1184
1184
|
* list and the two exported entry points that walk it, which is what a project generated with
|
|
1185
|
-
* \`observability: []\` keeps and starts nothing with.`,
|
|
1185
|
+
* \`observability: []\` keeps and starts nothing with.`,oa='import { readPrivacySettings, type PrivacySettings } from "@/lib/privacy";',aa='import type { PrivacySettings } from "@/lib/privacy";';function ca(e){let t=e.map(n=>n==="sentry"?"Sentry":"PostHog").join(" and ");return`${e.length===0?` * Your project was generated with observability OFF, so this app received no reporting adapter
|
|
1186
1186
|
* and no reporting SDK: {@link PROVIDERS} is empty, and both functions below are no-ops that load
|
|
1187
1187
|
* nothing and send nothing.`:` * This app reports to ${t}. No other provider's start, SDK or Expo config plugin was
|
|
1188
1188
|
* generated, so there is nothing here for Metro to fold away and nothing for pnpm to install.`}
|
|
@@ -1190,9 +1190,9 @@ export async function withMutex<T>(
|
|
|
1190
1190
|
* To change providers, edit \`observability\` in .generatesaas/manifest.json and run
|
|
1191
1191
|
* \`generatesaas update\`: re-shaping brings that provider's half of this file, its SDK and its env
|
|
1192
1192
|
* key back. Pasting the other provider's credential into \`config.observability\` on its own cannot -
|
|
1193
|
-
* the start it would need is not here.`}async function
|
|
1194
|
-
`)}function W(e,t){for(let r of t)delete e.dependencies?.[r],delete e.devDependencies?.[r]}function
|
|
1195
|
-
`))}}import{readFile as
|
|
1193
|
+
* the start it would need is not here.`}async function Hn(e){if(!e.mobile)return;let t=e.projectDir,r=["sentry","posthog"].filter(o=>!e.observability.includes(o));e.observability.length===0&&await Qo(Fe(t,Gn),{force:!0});let n=e.observability.length===0?Kn:[...Kn,Gn];for(let o of r)for(let s of n){let a=Fe(t,s);await l(a,Me(await Sr(a,"utf-8"),s,`mobile-obs-${o}`,Ve))}if(e.observability.length===0)for(let o of ta){let s=Fe(t,o);await l(s,Me(await Sr(s,"utf-8"),o,ea,Ve))}if(r.includes("sentry")){for(let o of na){let s=Fe(t,o);await l(s,Me(await Sr(s,"utf-8"),o,"mobile-obs-sentry",Ve))}await T(Fe(t,"apps/mobile/app.config.ts"),ra,"","app.config.ts Sentry Expo plugin",Ve),await T(Fe(t,"apps/mobile/jest.config.js"),ia,"","jest.config.js Sentry transform allowance",Ve)}let i=Fe(t,"apps/mobile/lib/observability.ts");await T(i,sa,ca(e.observability),"lib/observability.ts dual-provider note",Ve),e.observability.length===0&&(await T(i,oa,aa,"lib/observability.ts privacy import",Ve),await Bn(i))}var ct={"@upstash/redis":"^1.37.0","@upstash/lock":"^0.2.1","@neondatabase/serverless":"^1.0.1",postgres:"^3.4.7"};async function ne(e){let t=await Yn(e,"utf-8");return JSON.parse(t)}async function ie(e,t){let r=/\n([\t ]+)"/.exec(await Yn(e,"utf-8"))?.[1]??" ";await l(e,`${JSON.stringify(t,null,r)}
|
|
1194
|
+
`)}function W(e,t){for(let r of t)delete e.dependencies?.[r],delete e.devDependencies?.[r]}function la(e){e.dependencies&&Object.keys(e.dependencies).length===0&&delete e.dependencies,e.devDependencies&&Object.keys(e.devDependencies).length===0&&delete e.devDependencies}function Be(e,t,r,n=!1){let i=n?"devDependencies":"dependencies";e[i]||(e[i]={}),e[i][t]=r}async function Wn(e){await da(e),await ua(e),await ma(e),await ya(e),await ba(e),await Ea(e),await pa(e)}async function pa(e){if(!e.mobile){let t=M(e.projectDir,"packages/auth/package.json"),r=await ne(t);W(r,["@better-auth/expo"]),await ie(t,r);let n=M(e.projectDir,"tooling/styles/package.json"),i=await ne(n);W(i,["tsx"]),la(i),await ie(n,i)}if(e.mobile&&e.mobilePurchases!=="revenuecat"){let t=M(e.projectDir,"apps/mobile/package.json"),r=await ne(t);W(r,["react-native-purchases","react-native-purchases-ui"]),await ie(t,r)}}async function da(e){let t=M(e.projectDir,"packages/api/package.json"),r=await ne(t);W(r,["sharp","@types/sharp"]),e.cacheProvider==="upstash"&&(W(r,["ioredis"]),Be(r,"@upstash/redis",ct["@upstash/redis"],!0),Be(r,"@upstash/lock",ct["@upstash/lock"],!0)),await ie(t,r)}async function ua(e){let t=M(e.projectDir,"apps/web/package.json"),r=await ne(t);W(r,["@upstash/ratelimit","@upstash/redis"]),await ie(t,r)}async function ma(e){let t=M(e.projectDir,"packages/runtime/package.json"),r=await ne(t);e.cacheProvider==="upstash"&&(W(r,["ioredis","rate-limit-redis","redis-semaphore"]),Be(r,"@upstash/redis",ct["@upstash/redis"]),Be(r,"@upstash/lock",ct["@upstash/lock"])),await ie(t,r)}var Pr={sentry:["@sentry/node","@sentry/browser","@sentry/electron"],posthog:["posthog-node","posthog-js"]},ha=["@sentry/electron"],fa=[...Pr.sentry,...Pr.posthog],ga=[...Ut.sentry,...Ut.posthog];async function ya(e){let t=M(e.projectDir,"packages/observability/package.json"),r=await ne(t),n=new Set(e.observability.flatMap(a=>Pr[a]));if(!e.desktop)for(let a of ha)n.delete(a);if(W(r,fa.filter(a=>!n.has(a))),await ie(t,r),!e.mobile)return;let i=M(e.projectDir,"apps/mobile/package.json"),o=await ne(i),s=new Set(e.observability.flatMap(a=>Ut[a]));W(o,ga.filter(a=>!s.has(a))),await ie(i,o)}async function ba(e){let t=M(e.projectDir,"packages/database/package.json"),r=await ne(t);e.databaseProvider==="neon"?(W(r,["pg","@types/pg"]),Be(r,"@neondatabase/serverless",ct["@neondatabase/serverless"])):e.databaseProvider==="supabase"&&(W(r,["pg","@types/pg"]),Be(r,"postgres",ct.postgres)),await ie(t,r)}var va=["app/.well-known/oauth-authorization-server/[[...path]]/route.ts","app/.well-known/oauth-protected-resource/[[...path]]/route.ts"];async function wa(e){let t=await ne(e),r=t.dependencies?.["@repo/api"];r&&(delete t.dependencies?.["@repo/api"],Be(t,"@repo/api",r,!0),await ie(e,t))}var ka=["instrumentation.ts","tests/instrumentation.test.ts"];async function Ea(e){if(e.architecture!=="separate")return;let t=M(e.projectDir,"apps/web");await at(M(t,"app/api/[[...rest]]/route.ts"),t);for(let r of va)await at(M(t,r),t);for(let r of ka)await at(M(t,r),t);await wa(M(t,"package.json"))}import{readFile as Sa}from"fs/promises";import{join as Pa}from"path";var Aa=["base.json","node.json","next.json"],zn="GenerateSaaS ";async function qn(e){if(!e.demo)for(let t of Aa){let r=Pa(e.projectDir,"tooling/typescript",t),n;try{n=await Sa(r,"utf-8")}catch{continue}let i=JSON.parse(n);typeof i.display!="string"||!i.display.startsWith(zn)||(i.display=i.display.slice(zn.length),await l(r,`${JSON.stringify(i,null," ")}
|
|
1195
|
+
`))}}import{readFile as Zn,rm as Ia}from"fs/promises";import{join as Et}from"path";async function Qn(e){e.cacheProvider==="upstash"&&(await Promise.all([Jn(Et(e.projectDir,"packages/runtime/tests/setup.ts")),Jn(Et(e.projectDir,"packages/mail/tests/setup.ts")),Ra(e.projectDir),Ia(Et(e.projectDir,"packages/runtime/tests/redis.test.ts"),{force:!0})]),await Ta(e.projectDir))}async function Ta(e){await l(Et(e,"packages/runtime/tests/redis.test.ts"),`import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1196
1196
|
|
|
1197
1197
|
/**
|
|
1198
1198
|
* Regression guards for the Upstash-backed cache layer in src/redis.ts.
|
|
@@ -1325,25 +1325,41 @@ describe("pub/sub", () => {
|
|
|
1325
1325
|
expect(() => stop()).not.toThrow();
|
|
1326
1326
|
});
|
|
1327
1327
|
});
|
|
1328
|
-
`)}async function
|
|
1328
|
+
`)}async function Jn(e){let t;try{t=await Zn(e,"utf-8")}catch{throw new Error(`pruneCacheTests: expected ${e} for the Upstash cache swap (boilerplate drift).`)}let r=t.replace(/\tREDIS_URL:\s*"[^"]*",?\n/,` UPSTASH_REDIS_REST_URL: "https://test.upstash.io",
|
|
1329
1329
|
UPSTASH_REDIS_REST_TOKEN: "test-token",
|
|
1330
|
-
`);if(r===t){if(t.includes("UPSTASH_REDIS_REST_URL"))return;throw new Error(`pruneCacheTests: found no REDIS_URL default to swap in ${e} (boilerplate drift).`)}await l(e,r)}async function
|
|
1330
|
+
`);if(r===t){if(t.includes("UPSTASH_REDIS_REST_URL"))return;throw new Error(`pruneCacheTests: found no REDIS_URL default to swap in ${e} (boilerplate drift).`)}await l(e,r)}var be="ioredisCounters",Xn="COUNT_UNDER_LIMIT_SCRIPT";async function Ra(e){let t=Et(e,"packages/api/tests/setup.ts"),r;try{r=await Zn(t,"utf-8")}catch{throw new Error(`pruneCacheTests: expected ${t} for the Upstash cache swap (boilerplate drift).`)}let n=r;n=n.replace(/\tREDIS_URL:\s*"[^"]*",?\n/g,` UPSTASH_REDIS_REST_URL: "https://test.upstash.io",
|
|
1331
1331
|
UPSTASH_REDIS_REST_TOKEN: "test-token",
|
|
1332
1332
|
`),n=n.replace(/vi\.mock\("ioredis"[\s\S]*?\n\}\);\n\n?/,""),n=n.replace(/vi\.mock\("rate-limit-redis"[\s\S]*?\n\}\);\n\n?/,""),n=n.replace(/\t\t\tREDIS_URL:\s*"[^"]*",?\n/,` UPSTASH_REDIS_REST_URL: "https://test.upstash.io",
|
|
1333
1333
|
UPSTASH_REDIS_REST_TOKEN: "test-token",
|
|
1334
|
-
`)
|
|
1334
|
+
`);let i=`vi.mock("@upstash/redis", () => {
|
|
1335
1335
|
class Redis {
|
|
1336
1336
|
get = vi.fn().mockResolvedValue(null);
|
|
1337
1337
|
mget = vi.fn().mockResolvedValue([]);
|
|
1338
1338
|
set = vi.fn().mockResolvedValue("OK");
|
|
1339
|
-
del = vi.fn().
|
|
1340
|
-
incr = vi.fn(
|
|
1341
|
-
|
|
1339
|
+
del = vi.fn(async (key: string) => (${be}.delete(key) ? 1 : 0));
|
|
1340
|
+
incr = vi.fn(async (key: string) => {
|
|
1341
|
+
const next = (${be}.get(key) ?? 0) + 1;
|
|
1342
|
+
${be}.set(key, next);
|
|
1343
|
+
return next;
|
|
1344
|
+
});
|
|
1345
|
+
decr = vi.fn(async (key: string) => {
|
|
1346
|
+
const next = (${be}.get(key) ?? 0) - 1;
|
|
1347
|
+
${be}.set(key, next);
|
|
1348
|
+
return next;
|
|
1349
|
+
});
|
|
1342
1350
|
expire = vi.fn().mockResolvedValue(1);
|
|
1343
1351
|
pexpire = vi.fn().mockResolvedValue(1);
|
|
1344
1352
|
pttl = vi.fn().mockResolvedValue(-1);
|
|
1345
1353
|
scan = vi.fn().mockResolvedValue(["0", []]);
|
|
1346
|
-
eval = vi.fn(
|
|
1354
|
+
eval = vi.fn(async (script: string, keys: string[], args: (string | number)[]) => {
|
|
1355
|
+
if (script !== ${Xn}) return null;
|
|
1356
|
+
const key = String(keys[0]);
|
|
1357
|
+
const limit = Number(args[0]);
|
|
1358
|
+
const next = (${be}.get(key) ?? 0) + 1;
|
|
1359
|
+
if (next > limit) return -1;
|
|
1360
|
+
${be}.set(key, next);
|
|
1361
|
+
return next;
|
|
1362
|
+
});
|
|
1347
1363
|
}
|
|
1348
1364
|
return { Redis };
|
|
1349
1365
|
});
|
|
@@ -1356,32 +1372,32 @@ vi.mock("@upstash/lock", () => {
|
|
|
1356
1372
|
return { Lock };
|
|
1357
1373
|
});
|
|
1358
1374
|
|
|
1359
|
-
$1`),n===r){if(r.includes("UPSTASH_REDIS_REST_URL"))return;throw new Error(`pruneCacheTests: nothing to rewrite in ${t} for the Upstash swap (boilerplate drift).`)}for(let o of['vi.mock("ioredis"','vi.mock("rate-limit-redis"',"REDIS_URL:"])if(n.includes(o))throw new Error(`pruneCacheTests: ${t} still contains ${o} after the Upstash swap (boilerplate drift).`);await l(t,n)}import{readFile as
|
|
1360
|
-
`),
|
|
1375
|
+
`;if(n=n.replace(/(^vi\.mock\("@repo\/runtime")/m,`${i}$1`),n===r){if(r.includes("UPSTASH_REDIS_REST_URL"))return;throw new Error(`pruneCacheTests: nothing to rewrite in ${t} for the Upstash swap (boilerplate drift).`)}for(let o of['vi.mock("ioredis"','vi.mock("rate-limit-redis"',"REDIS_URL:"])if(n.includes(o))throw new Error(`pruneCacheTests: ${t} still contains ${o} after the Upstash swap (boilerplate drift).`);for(let o of[`const ${be} = new Map`,`${Xn},`,`${be}.clear()`])if(!n.includes(o))throw new Error(`pruneCacheTests: ${t} no longer contains ${o}, which the Upstash mock relies on (boilerplate drift).`);await l(t,n)}import{readFile as _a}from"fs/promises";var Oa=/^[ \t]*(?:\/\/|\{\/\*|\*) gsaas:[a-z0-9-]+-(?:start|end)(?: \*\/\})?[ \t]*\r?\n/gm,xa=new Set([".ts",".tsx",".vue",".mjs",".js",".jsx",".md",".mdx"]),ei=32;function Da(e){let t=e.lastIndexOf(".");return t>=0&&xa.has(e.slice(t))}async function Na(e){let t=await _a(e,"utf-8");if(!t.includes("gsaas:"))return;let r=t.replace(Oa,"");r!==t&&await l(e,r)}async function ti(e){let t=await ye(e.projectDir,e.projectDir,r=>r==="node_modules"||r.startsWith("node_modules/")||r===".git"||r.startsWith(".git/"),Da);for(let r=0;r<t.length;r+=ei)await Promise.all(t.slice(r,r+ei).map(Na))}import{readdir as Vt,readFile as se,rm as k}from"fs/promises";import{join as p}from"path";var Ca=new Set(["ci.yml","desktop-release.yml","mobile-release.yml"]),La=["apps/web/app/.well-known/apple-app-site-association/route.ts","apps/web/app/.well-known/assetlinks.json/route.ts","apps/web/tests/routes/apple-app-site-association.test.ts","apps/web/tests/routes/assetlinks.test.ts"],$a=["packages/auth/src/config.ts","packages/api/src/routes/internal/index.ts","packages/api/src/index.ts"],ja=["packages/api/src/routes/internal/index.ts","packages/api/src/routes/inngest.ts","packages/notifications/src/index.ts","packages/config/src/cache.ts"],Ma=["packages/notifications/src/push","packages/notifications/tests/push","packages/api/src/routes/internal/push.ts","packages/api/tests/routes/push.test.ts","packages/api/src/functions/notifications/push-dispatch.ts","packages/api/src/functions/notifications/push-receipts.ts","packages/api/src/functions/notifications/push-sink.ts","packages/api/tests/functions/push-dispatch.test.ts","packages/api/tests/functions/push-receipts.test.ts","packages/api/tests/functions/push-sink.test.ts","packages/api/tests/unit/push-store.test.ts","packages/api/tests/routes/push-registry.test.ts","packages/config/tests/push-cache-keys.test.ts"],Ua=["packages/payments/src/store","packages/payments/tests/store","packages/payments/tests/helpers/clock.ts","packages/payments/tests/lib/store-claim-ids.test.ts","packages/api/src/routes/internal/webhooks/revenuecat.ts","packages/api/tests/routes/revenuecat-webhook.test.ts","packages/api/tests/routes/revenuecat-webhook-limiter.test.ts","packages/api/tests/routes/revenuecat-interleavings.test.ts","packages/api/src/routes/internal/billing-mobile.ts","packages/api/tests/routes/billing-mobile-sync.test.ts","packages/api/tests/routes/billing-mobile-mount.test.ts","packages/api/src/functions/billing/revenuecat-reconcile.ts","packages/api/src/functions/billing/revenuecat-reconcile-entity.ts","packages/api/tests/functions/revenuecat-reconcile.test.ts"],Fa=["packages/api/src/routes/internal/index.ts","packages/api/src/routes/inngest.ts","packages/api/src/index.ts","packages/payments/src/index.ts","packages/config/src/cache.ts"],Va=["apps/mobile/lib/purchases","apps/mobile/app/(app)/settings/billing/paywall.tsx","apps/mobile/e2e/flows/billing-paywall.yaml","apps/mobile/e2e/flows/billing-purchase.yaml","apps/mobile/e2e/flows/billing-restore.yaml"],Ba=[{dir:"apps/mobile/components/billing",prefixes:["paywall","credit-packs","store-"]},{dir:"apps/mobile/tests",prefixes:["purchases-"]}],Ka=["apps/mobile/app.config.ts","apps/mobile/app/_layout.tsx","apps/mobile/app/delete-account.tsx","apps/mobile/app/(app)/settings/billing/index.tsx","apps/mobile/components/home/plan-cards.tsx","apps/mobile/lib/api-client.ts","apps/mobile/lib/auth/sign-out.ts","apps/mobile/types/env.d.ts","apps/mobile/tests/api-client.test.ts","apps/mobile/tests/feature-seams.test.ts","apps/mobile/tests/root-layout.test.tsx","apps/mobile/tests/route-manifest.test.ts","apps/mobile/tests/sign-out.test.ts"],Ga=[","," {",' file: "billing-paywall.yaml",',' tags: ["purchases"],',' title: "The paywall renders sandbox offerings"'," },"," {",' file: "billing-purchase.yaml",',' tags: ["purchases"],',' title: "A sandbox purchase updates the plan card through the sync route"'," },",' { file: "billing-restore.yaml", tags: ["purchases"], title: "Restore purchases" }',""].join(`
|
|
1376
|
+
`),Ha=[["## Billing (store purchases)","","#### Paywall (`billing-paywall.yaml`)","","| `testID` | Element | Flow |","| --- | --- | --- |","| `settings-paywall-screen` | The paywall route's screen root | `billing-paywall.yaml` |","| `billing-paywall-loading` | The offering read's skeleton | `billing-paywall.yaml` |","| `billing-paywall-status-error` | The offering read failed | `billing-paywall.yaml` |","| `billing-paywall-status-retry` | Retry the offering read | `billing-paywall.yaml` |","| `billing-paywall-list` | The list of purchasable packages | `billing-paywall.yaml` |","| `billing-paywall-row-<productId>` | One package row, keyed by store product id | `billing-paywall.yaml` |","| `billing-paywall-row-<productId>-action` | That row's buy control | `billing-purchase.yaml` |","| `billing-paywall-restore` | Restore purchases, from the paywall | `billing-restore.yaml` |","",""].join(`
|
|
1361
1377
|
`),["#### Purchase and restore controls (`billing-purchase.yaml`, `billing-restore.yaml`)","","| `testID` | Element | Flow |","| --- | --- | --- |",'| `billing-upgrade` | The plan card\'s "See plans" link into the paywall | `billing-purchase.yaml` |',"| `billing-restore` | Restore purchases, on the billing page | `billing-restore.yaml` |",'| `billing-restore-lead` | The "Already subscribed?" lead beside it | `billing-restore.yaml` |',"| `billing-manage` | Manage subscription (Customer Center or the store page) | `billing-purchase.yaml` |","| `billing-external-link` | The external purchase link, when the disclosure sheet ships | `billing-purchase.yaml` |","| `billing-external-link-disclosure` | That link's disclosure sheet | `billing-purchase.yaml` |","",""].join(`
|
|
1362
1378
|
`),["#### Credit packs (`billing-purchase.yaml`)","","| `testID` | Element | Flow |","| --- | --- | --- |","| `billing-credit-packs` | The credit packs card | `billing-purchase.yaml` |","| `billing-credit-packs-list` | The pack list | `billing-purchase.yaml` |","| `billing-credit-packs-row-<storeProductId>` | One pack row | `billing-purchase.yaml` |","| `billing-credit-packs-row-<storeProductId>-action` | That pack's buy control | `billing-purchase.yaml` |","| `billing-credit-packs-empty` | No pack is available on this plan | `billing-purchase.yaml` |","| `billing-credit-packs-unavailable` | The store listed none of the configured ids | `billing-purchase.yaml` |","",""].join(`
|
|
1363
|
-
`)],
|
|
1364
|
-
`)}async function
|
|
1365
|
-
`))}async function
|
|
1366
|
-
`)}async function
|
|
1367
|
-
`,"","index.mdx desktop app card",r),await z(p(t,"docs/(platform)/ai.mdx"),"ai.mdx automation lanes row + section","desktop-lanes-docs",r)),i||(await z(n,"configuration.mdx desktop-AI paragraph","desktop-ai-docs",r),await T(p(t,"docs/(platform)/navigation.mdx"),"gates the desktop app's Chat and Automations, see [desktop AI](/desktop/ai) |","gates the desktop app's Chat and Automations |","navigation.mdx sidebar-flag agents-row link",r))}async function
|
|
1379
|
+
`)],Ya=["apps/web/app/[locale]/(marketing)/download","apps/web/tests/routes/download-page.test.tsx"],Wa=["cli","cli:clean","demo:bench","playground:regen","playground:test","playground:test:repo","playground:test:audit","playground:test:units","playground:test:e2e","playground:test:build","playground:test:mobile","check-types:scripts","ai:parity"];async function ri(e){let t=p(e.projectDir,".github/workflows"),r=await Vt(t).catch(()=>[]);for(let n of r)Ca.has(n)||await k(p(t,n),{recursive:!0,force:!0})}async function za(e,t){let r=p(e,"package.json"),n=JSON.parse(await se(r,"utf-8"));if(!n.scripts)return;let i=!1;for(let s of t)s in n.scripts&&(delete n.scripts[s],i=!0);if(!i)return;let o=/\n([\t ]+)"/.exec(await se(r,"utf-8"))?.[1]??" ";await l(r,`${JSON.stringify(n,null,o)}
|
|
1380
|
+
`)}async function ni(e){await za(e.projectDir,[...Wa])}async function ii(e){let t=p(e.projectDir,"package.json"),r=await se(t,"utf-8"),n=JSON.parse(r);!n.scripts||!("dev"in n.scripts)||(n.scripts.dev=e.architecture==="separate"?"turbo run dev --continue":"turbo run dev --continue --filter=!backend",await l(t,`${JSON.stringify(n,null," ")}
|
|
1381
|
+
`))}async function si(e){let t=p(e.projectDir,"package.json"),r=JSON.parse(await se(t,"utf-8")),n=r.scripts?.test;if(n!=="turbo run test --concurrency=4 && vitest run")throw new Error(`rewriteRootTestScript: expected the root test script to be "turbo run test --concurrency=4 && vitest run", got ${JSON.stringify(n)} (boilerplate drift).`);r.scripts.test="turbo run test --concurrency=4",await l(t,`${JSON.stringify(r,null," ")}
|
|
1382
|
+
`)}async function oi(e){e.docs||await k(p(e.projectDir,"apps/docs"),{recursive:!0})}async function ai(e){let t=p(e.projectDir,"docs/(platform)/meta.json"),r=JSON.parse(await se(t,"utf-8"));if(r.title!=="GenerateSaaS")throw new Error(`stripUnusedDocsSilo: expected the feature-page group to be titled "GenerateSaaS", found ${JSON.stringify(r.title)} (boilerplate drift).`);r.title=e.appName,await Ue(t,r," ","stripUnusedDocsSilo"),e.desktop||await k(p(e.projectDir,"docs/desktop"),{recursive:!0,force:!0}),e.mobile||await k(p(e.projectDir,"docs/mobile"),{recursive:!0,force:!0}),await qa(e),await Za(e)}async function z(e,t,r,n){await l(e,Me(await se(e,"utf-8"),t,r,n))}async function qa(e){let t=e.projectDir,r="stripUnusedDocsSilo",n=p(t,"docs/(platform)/configuration.mdx"),i=e.desktop;e.desktop||(await T(n,"| Desktop app + device sign-in - see [desktop app](/desktop) |","| Desktop app + device sign-in |","configuration.mdx desktop config-table link",r),await z(n,"configuration.mdx desktop TRUSTED_ORIGINS callout","desktop-origins-docs",r),await T(p(t,"docs/cli/init.mdx"),"the [desktop app](/desktop), AI features","the desktop app, AI features","cli init.mdx desktop app link",r),await z(p(t,"docs/(platform)/payments/organizations.mdx"),"payments organizations.mdx desktop tenancy callout","desktop-tenancy-docs",r),await z(p(t,"docs/(platform)/projects.mdx"),"projects.mdx desktop scoping callout","desktop-projects-docs",r),await z(p(t,"docs/(platform)/client-architecture.mdx"),"client-architecture.mdx desktop card","desktop-architecture-docs",r),await z(p(t,"docs/(platform)/screens.mdx"),"screens.mdx desktop halves","screens-desktop-docs",r),await z(p(t,"docs/(platform)/testing/e2e.mdx"),"testing/e2e.mdx desktop e2e callout","desktop-testing-docs",r),await T(p(t,"docs/(platform)/index.mdx"),` <Card href="/desktop" title="Desktop app" description="Cross-platform desktop app with device sign-in and AI agents." />
|
|
1383
|
+
`,"","index.mdx desktop app card",r),await z(p(t,"docs/(platform)/ai.mdx"),"ai.mdx automation lanes row + section","desktop-lanes-docs",r)),i||(await z(n,"configuration.mdx desktop-AI paragraph","desktop-ai-docs",r),await T(p(t,"docs/(platform)/navigation.mdx"),"gates the desktop app's Chat and Automations, see [desktop AI](/desktop/ai) |","gates the desktop app's Chat and Automations |","navigation.mdx sidebar-flag agents-row link",r))}async function ci(e){if(e.desktop)return;await k(p(e.projectDir,"apps/desktop"),{recursive:!0});let t=p(e.projectDir,"packages/i18n/translations");for(let i of await Vt(t,{withFileTypes:!0}))i.isDirectory()&&await k(p(t,i.name,"desktop.json"),{force:!0});await k(p(e.projectDir,".github/workflows/desktop-release.yml"));for(let i of Ya)await k(p(e.projectDir,i),{recursive:!0});await k(p(e.projectDir,"packages/utils/src/desktop-download.ts")),await k(p(e.projectDir,"packages/utils/tests/desktop-download.test.ts"));let r=p(e.projectDir,"packages/utils/package.json"),n=JSON.parse(await se(r,"utf-8"));if(!n.exports?.["./desktop-download"])throw new Error("stripDesktopApp: expected packages/utils/package.json to carry the ./desktop-download export");delete n.exports["./desktop-download"],await Ue(r,n," ","stripDesktopApp")}function Ja(e){return e.desktop}async function li(e){Ja(e)||await k(p(e.projectDir,"packages/agent-core"),{recursive:!0})}async function Xa(e,t,r,n){let i=await Vt(e);for(let o of t){let s=i.filter(a=>a.startsWith(o));if(s.length===0)throw new Error(`${n}: expected ${r} to hold at least one ${o}* entry`);for(let a of s)await k(p(e,a),{recursive:!0})}}async function Ft(e,t,r,n){await l(e,Me(await se(e,"utf-8"),t,r,n))}async function Za(e){let t=e.projectDir,r="stripUnusedDocsSilo",n=e.mobile&&e.mobilePurchases==="revenuecat";e.mobile||(await T(p(t,"docs/(platform)/index.mdx"),` <Card href="/mobile" title="Mobile app" description="Cross-platform iOS and Android app with push, universal links, and store billing." />
|
|
1368
1384
|
`,"","index.mdx mobile app card",r),await T(p(t,"docs/(platform)/configuration.mdx")," - see [mobile app](/mobile)","","configuration.mdx mobile config-table link",r),await T(p(t,"docs/(platform)/client-architecture.mdx"),` <Card href="/mobile" title="Mobile app">The Expo app - the third surface this client core serves.</Card>
|
|
1369
1385
|
`,"","client-architecture.mdx mobile app card",r),await T(p(t,"docs/cli/init.mdx"),"The [mobile app](/mobile) (`apps/mobile`, built with Expo)","The mobile app (`apps/mobile`, built with Expo)","cli init.mdx mobile app link",r),await T(p(t,"docs/(platform)/notifications.mdx"),"- **On a phone too.** With the [mobile app](/mobile) shipped and `config.mobile.push.enabled` on, the same notification is delivered to the user's registered devices as a push - see [mobile notifications](/mobile/notifications).\n\n","","notifications.mdx mobile push bullet",r),await T(p(t,"docs/(platform)/authentication/social-oauth.mdx")," - the native flow adds `APPLE_APP_BUNDLE_IDENTIFIER`; without `APPLE_CLIENT_ID` it runs native-only (no web button), see [mobile auth](/mobile/auth) |"," |","social-oauth.mdx Apple native-flow tail",r),await T(p(t,"docs/(platform)/authentication/two-factor-and-passkeys.mdx"),"- **On the [mobile app](/mobile/auth)**, passkeys use the platform's own biometrics and are claimed by the same `.well-known` association files. The app's biometric **App lock** is a separate device gate, never a second factor.\n\n","","two-factor-and-passkeys.mdx mobile passkeys bullet",r),await T(p(t,"docs/(platform)/project-structure.mdx"),"the web, desktop and mobile apps","the web and desktop apps","project-structure.mdx surface list",r),await z(p(t,"docs/(platform)/testing/e2e.mdx"),"testing e2e.mdx mobile Maestro section","mobile-testing-docs",r),await z(p(t,"docs/(platform)/environment-variables/index.mdx"),"environment-variables mobile section","mobile-env-docs",r)),n||(await T(p(t,"docs/(platform)/payments/meta.json"),', "mobile-billing"',"","payments meta.json mobile-billing entry",r),await T(p(t,"docs/(platform)/payments/index.mdx"),` <Card href="/payments/mobile-billing" title="Mobile store billing" description="In-app purchases through RevenueCat, additive to your web provider." />
|
|
1370
1386
|
`,"","payments index.mdx mobile store billing card",r),await k(p(t,"docs/(platform)/payments/mobile-billing.mdx"))),e.mobile&&!n&&(await T(p(t,"docs/mobile/meta.json"),', "billing"',"","mobile meta.json billing entry",r),await T(p(t,"docs/mobile/index.mdx"),` <Card href="/mobile/billing" title="Billing" description="Store purchases through RevenueCat, and the web-billing shape." />
|
|
1371
|
-
`,"","mobile index.mdx billing card",r),await z(p(t,"docs/mobile/index.mdx"),"mobile index.mdx store-purchases block","mobile-purchases-docs",r),await k(p(t,"docs/mobile/billing"),{recursive:!0,force:!0}))}async function
|
|
1372
|
-
`,"e2e tags.ts purchases inventory entries","stripMobilePurchases")}}import{readFile as
|
|
1387
|
+
`,"","mobile index.mdx billing card",r),await z(p(t,"docs/mobile/index.mdx"),"mobile index.mdx store-purchases block","mobile-purchases-docs",r),await k(p(t,"docs/mobile/billing"),{recursive:!0,force:!0}))}async function pi(e){if(e.mobile)return;await k(p(e.projectDir,"apps/mobile"),{recursive:!0});let t=p(e.projectDir,"packages/i18n/translations");for(let i of await Vt(t,{withFileTypes:!0}))i.isDirectory()&&await k(p(t,i.name,"mobile.json"),{force:!0});await k(p(e.projectDir,"packages/i18n/tests/mobile-notification-labels.test.ts")),await k(p(e.projectDir,"packages/i18n/tests/mobile-brand-literals.test.ts")),await k(p(e.projectDir,"packages/i18n/tests/mobile-shared-merge.test.ts")),await k(p(e.projectDir,"packages/auth/tests/expo-plugin.test.ts")),await k(p(e.projectDir,"packages/auth/tests/expo-authorization-allowlist.test.ts")),await k(p(e.projectDir,"packages/auth/src/expo-authorization-allowlist.ts")),await k(p(e.projectDir,".github/workflows/mobile-release.yml")),await k(p(e.projectDir,"tooling/styles/scripts/build-mobile-css.ts"));let r=p(e.projectDir,"tooling/styles/package.json"),n=JSON.parse(await se(r,"utf-8"));if(!n.scripts?.["mobile-css"])throw new Error("stripMobileApp: expected tooling/styles/package.json to carry the mobile-css script");delete n.scripts["mobile-css"],Object.keys(n.scripts).length===0&&delete n.scripts,await Ue(r,n," ","stripMobileApp");for(let i of La)await at(p(e.projectDir,i),p(e.projectDir,"apps/web"));await k(p(e.projectDir,"packages/api/src/routes/internal/well-known.ts")),await k(p(e.projectDir,"packages/api/tests/routes/well-known-mobile.test.ts")),await k(p(e.projectDir,"packages/api/src/routes/internal/mobile-auth.ts")),await k(p(e.projectDir,"packages/api/tests/routes/mobile-auth.test.ts")),await k(p(e.projectDir,"apps/web/public/images/stores"),{recursive:!0});for(let i of $a)await Ft(p(e.projectDir,i),i,"mobile-auth","stripMobileApp");for(let i of ja)await Ft(p(e.projectDir,i),i,"mobile-push","stripMobileApp");for(let i of Ma)await k(p(e.projectDir,i),{recursive:!0})}async function di(e){if(e.mobile&&e.mobilePurchases==="revenuecat")return;for(let n of Ua)await k(p(e.projectDir,n),{recursive:!0});let t=p(e.projectDir,"packages/payments/package.json"),r=JSON.parse(await se(t,"utf-8"));if(!r.sideEffects?.includes("./src/store/revenuecat/index.ts"))throw new Error("stripMobilePurchases: expected packages/payments/package.json to list the store lane in sideEffects");r.sideEffects=r.sideEffects.filter(n=>n!=="./src/store/revenuecat/index.ts"),await Ue(t,r," ","stripMobilePurchases");for(let n of Fa)await Ft(p(e.projectDir,n),n,"mobile-purchases","stripMobilePurchases");if(e.mobile){for(let n of Va)await k(p(e.projectDir,n),{recursive:!0});for(let{dir:n,prefixes:i}of Ba)await Xa(p(e.projectDir,n),i,n,"stripMobilePurchases");for(let n of Ka)await Ft(p(e.projectDir,n),n,"mobile-purchases","stripMobilePurchases");for(let n of Ha)await T(p(e.projectDir,"apps/mobile/e2e/README.md"),n,"","e2e README purchases row block","stripMobilePurchases");await T(p(e.projectDir,"apps/mobile/e2e/tags.ts"),Ga,`
|
|
1388
|
+
`,"e2e tags.ts purchases inventory entries","stripMobilePurchases")}}import{readFile as Qa}from"fs/promises";import{join as ec}from"path";var ui=`on:
|
|
1373
1389
|
workflow_run:
|
|
1374
1390
|
workflows: ["Tag Release"]
|
|
1375
1391
|
branches: [main]
|
|
1376
1392
|
types: [completed]
|
|
1377
|
-
workflow_dispatch:`,
|
|
1378
|
-
workflow_dispatch:`;async function
|
|
1393
|
+
workflow_dispatch:`,tc=`on:
|
|
1394
|
+
workflow_dispatch:`;async function mi(e){if(!e.desktop)return;let t=ec(e.projectDir,".github/workflows/desktop-release.yml"),r=await Qa(t,"utf8");if(!r.includes(ui))throw new Error(`Cannot configure the desktop release trigger: the expected workflow_run block was not found in ${t}. The boilerplate workflow may have drifted.`);await l(t,r.replace(ui,tc))}import{readFile as rc}from"fs/promises";import{join as nc}from"path";var hi=`on:
|
|
1379
1395
|
workflow_run:
|
|
1380
1396
|
workflows: ["Tag Release"]
|
|
1381
1397
|
branches: [main]
|
|
1382
1398
|
types: [completed]
|
|
1383
|
-
workflow_dispatch:`,
|
|
1384
|
-
workflow_dispatch:`;async function
|
|
1399
|
+
workflow_dispatch:`,ic=`on:
|
|
1400
|
+
workflow_dispatch:`;async function fi(e){if(!e.mobile)return;let t=nc(e.projectDir,".github/workflows/mobile-release.yml"),r=await rc(t,"utf8");if(!r.includes(hi))throw new Error(`Cannot configure the mobile release trigger: the expected workflow_run block was not found in ${t}. The boilerplate workflow may have drifted.`);await l(t,r.replace(hi,ic))}import{join as sc}from"path";async function bi(e){let t=oc(e);await l(sc(e.projectDir,".github/workflows/ci.yml"),t)}function gi(e){return e.databaseProvider!=="neon"&&e.cacheProvider!=="upstash"}function yi(e,t,r){return` - uses: actions/upload-artifact@v4
|
|
1385
1401
|
if: \${{ ${r} }}
|
|
1386
1402
|
with:
|
|
1387
1403
|
name: playwright-report-${e}
|
|
@@ -1390,7 +1406,7 @@ $1`),n===r){if(r.includes("UPSTASH_REDIS_REST_URL"))return;throw new Error(`prun
|
|
|
1390
1406
|
${t}/test-results
|
|
1391
1407
|
if-no-files-found: ignore
|
|
1392
1408
|
retention-days: 7
|
|
1393
|
-
`}function
|
|
1409
|
+
`}function oc(e){let t=e.cacheProvider==="upstash",r=t?"":` redis:
|
|
1394
1410
|
image: redis:8-alpine
|
|
1395
1411
|
ports:
|
|
1396
1412
|
- 6379:6379
|
|
@@ -1434,7 +1450,7 @@ ${r} mailpit:
|
|
|
1434
1450
|
# suite's port is derived per checkout - a baked URL would name a port nothing serves.
|
|
1435
1451
|
- name: Start Inngest
|
|
1436
1452
|
run: docker run -d --name inngest --network host inngest/inngest:v1.17.4 inngest dev
|
|
1437
|
-
`,d=
|
|
1453
|
+
`,d=gi(e)?`
|
|
1438
1454
|
# The end-to-end suite, on a manual "Run workflow" dispatch only - it builds the app in production
|
|
1439
1455
|
# mode and drives it with Playwright, too slow to pay for on every PR. It needs no keys and no
|
|
1440
1456
|
# internet: the harness preflights the services below, builds and starts the app itself, provisions
|
|
@@ -1469,7 +1485,7 @@ ${a}
|
|
|
1469
1485
|
env:
|
|
1470
1486
|
NODE_OPTIONS: --max-old-space-size=6144
|
|
1471
1487
|
|
|
1472
|
-
${
|
|
1488
|
+
${yi("web","apps/web","!cancelled()")}`:"\n # No end-to-end job: this project's stack cannot run one in CI without credentials.\n # - a `neon` database talks over a serverless websocket that only reaches a managed endpoint,\n # so a Postgres service container is unreachable from the app;\n # - an `upstash` cache is a hosted REST endpoint, and this file carries no secrets.\n # The suite itself works locally: `pnpm infra` for a project on self-hosted Postgres and Redis,\n # then `pnpm e2e:ci`. To run it here, add a job with those services plus your own credentials as\n # repository secrets, or point a second CI environment at a disposable managed instance.\n",g=e.desktop&&gi(e)?`
|
|
1473
1489
|
# The DESKTOP end-to-end suite, on a manual "Run workflow" dispatch only: it builds the Electron
|
|
1474
1490
|
# bundles AND a Next production build, which is too slow to pay for on every push. It has its own
|
|
1475
1491
|
# port and its own build directory, so it can never collide with the web job above.
|
|
@@ -1503,7 +1519,7 @@ ${a}
|
|
|
1503
1519
|
env:
|
|
1504
1520
|
NODE_OPTIONS: --max-old-space-size=6144
|
|
1505
1521
|
|
|
1506
|
-
${
|
|
1522
|
+
${yi("desktop","apps/desktop","failure()")}`:"",w=e.databaseProvider==="neon"?`
|
|
1507
1523
|
# Required for neon: the api suite's embedded Postgres is TCP-only and the neon
|
|
1508
1524
|
# websocket driver cannot reach it. Add this repository secret (any Postgres this
|
|
1509
1525
|
# suite may CREATE DATABASE on - a Neon branch works).
|
|
@@ -1564,22 +1580,22 @@ ${n} env:
|
|
|
1564
1580
|
- run: pnpm check-types
|
|
1565
1581
|
|
|
1566
1582
|
- run: pnpm test
|
|
1567
|
-
${d}${g}`}import{readFile as
|
|
1568
|
-
`))}async function
|
|
1569
|
-
`))}async function
|
|
1570
|
-
// ${
|
|
1583
|
+
${d}${g}`}import{readFile as vi}from"fs/promises";import{existsSync as ac}from"fs";import{join as Ar}from"path";var wi="@repo/database";function cc(e){return e?"pnpm -F @repo/database reset && pnpm -F @repo/database run deploy -- --push":"pnpm -F @repo/database run deploy"}function lc(e){switch(e){case"fullstack":return"web";case"separate":return"backend";default:{let t=e;throw new Error(`schemaOwnerApp: unhandled architecture "${String(t)}"`)}}}async function pc(e,t,r){let n=await vi(e,"utf-8"),i=JSON.parse(n),o=i.scripts?.[t];if(!o)throw new Error(`Cannot prepend to missing script "${t}" in ${e}`);o.includes(wi)||(i.scripts={...i.scripts,[t]:`${r} && ${o}`},await l(e,`${JSON.stringify(i,null," ")}
|
|
1584
|
+
`))}async function dc(e,t){let r=ac(e)?JSON.parse(await vi(e,"utf-8")):{$schema:"https://openapi.vercel.sh/vercel.json"},n=r.buildCommand?.trim()||"pnpm build";n.includes(wi)||(r.buildCommand=`${t} && ${n}`,await l(e,`${JSON.stringify(r,null," ")}
|
|
1585
|
+
`))}async function ki(e){let t=cc(e.demo===!0),r=lc(e.architecture),n=Ar(e.projectDir,"apps",r);switch(e.deploymentTarget){case"node":await pc(Ar(n,"package.json"),"start",t);return;case"vercel":await dc(Ar(n,"vercel.json"),t);return;default:{let i=e.deploymentTarget;throw new Error(`generateDeployScripts: unhandled deployment target "${String(i)}"`)}}}import{readFile as uc}from"fs/promises";import{existsSync as mc}from"fs";import{join as Bt}from"path";var Ei=["stripe","polar"],hc=[...Ei,"none"];async function Si(e){let t=Bt(e.projectDir,"packages/payments/src"),r=e.paymentProvider,i=`// Active payment provider. To switch, change the path below to
|
|
1586
|
+
// ${hc.filter(o=>o!==r).map(o=>`"./${o}/index"`).join(" or ")} (other folders are kept in place).
|
|
1571
1587
|
export { ops } from "./${r}/index";
|
|
1572
|
-
`;await l(
|
|
1588
|
+
`;await l(Bt(t,"providers/index.ts"),i),await l(Bt(t,"index.ts"),await fc(t,r))}async function fc(e,t){let r=Bt(e,"index.ts");if(!mc(r))throw new Error(`generatePaymentBarrel: expected ${r} to exist - did packages/payments move?`);let n=await uc(r,"utf-8"),i=Ei.filter(s=>s!==t);return n.split(`
|
|
1573
1589
|
`).filter(s=>!i.some(a=>s.includes(`./providers/${a}/`))).join(`
|
|
1574
|
-
`)}import{access as
|
|
1590
|
+
`)}import{access as gc,readFile as Ht,rm as Kt}from"fs/promises";import{join as q}from"path";var Ri=["sentry","posthog"],Ir={sentry:"src/providers/sentry",posthog:"src/providers/posthog"},Gt={sentry:["tests/sentry-load-guard.test.ts"],posthog:["tests/posthog-load-guard.test.ts","tests/llm-analytics.test.ts"]},yc=["tests/resolution.test.ts","tests/export-isolation.test.ts"],bc=["src/providers/sentry/desktop-main.ts","src/providers/sentry/desktop-renderer.ts","src/resolve-desktop-main.ts","src/resolve-desktop-renderer.ts"],vc=["./desktop-main","./desktop-renderer"],wc=[`vi.mock("@sentry/electron/main", sdk.factories["@sentry/electron/main"]);
|
|
1575
1591
|
`,`vi.mock("@sentry/electron/renderer", sdk.factories["@sentry/electron/renderer"]);
|
|
1576
|
-
`],
|
|
1592
|
+
`],kc=[' describeGate("desktop main", "electronMain", async () => {',' describeGate("desktop renderer", "electronRenderer", async () => {',' describe("the desktop main init options", () => {',' describe("the desktop renderer init", () => {'],Pi=`
|
|
1577
1593
|
});
|
|
1578
|
-
`,
|
|
1579
|
-
`).find(a=>a.trim().length>0&&!a.startsWith(`${
|
|
1580
|
-
`&&(s+=1),e.slice(0,n)+e.slice(s)}function
|
|
1594
|
+
`,Ec=" ";async function _i(e){let t=q(e.projectDir,"packages/observability");await Ai(t,"packages/observability");let r=e.observability,n=r.filter(s=>!F.includes(s));if(n.length>0)throw new Error(`observability names ${JSON.stringify(n)}, which is not a reporting provider. Every entry must be one of: ${F.join(", ")}. Shaping stops here so the project is not left half-stripped.`);for(let s of r)await Ai(q(t,Ir[s]),`packages/observability/${Ir[s]}`);let i=e.desktop?[...Ti,...hl]:Ti,o=new Map;for(let s of i)o.set(s.file,await Ht(q(t,s.file),"utf-8"));for(let s of Dc(r,e.desktop))await Nc(t,s);for(let s of Ri)if(!r.includes(s)){await Kt(q(t,Ir[s]),{recursive:!0,force:!0});for(let a of Gt[s])await Kt(q(t,a),{force:!0})}for(let s of yc)await Kt(q(t,s),{force:!0});e.desktop||await Sc(t,r);for(let s of i){let a=o.get(s.file);a!==void 0&&await l(q(t,s.file),Ic(a,s,r))}}async function Ai(e,t){try{await gc(e)}catch{throw new Error(`generateObservabilityProvider: expected ${t} to exist - did the observability package move?`)}}async function Sc(e,t){for(let i of bc)await Kt(q(e,i),{force:!0});t.includes("sentry")&&await Pc(e);let r=q(e,"package.json"),n=JSON.parse(await Ht(r,"utf-8"));for(let i of vc){if(!n.exports?.[i])throw new Error(`generateObservabilityProvider: expected packages/observability/package.json to carry the ${i} export`);delete n.exports[i]}await Ue(r,n," ","generateObservabilityProvider")}async function Pc(e){let t=q(e,Gt.sentry[0]),r=await Ht(t,"utf-8");r=Tr(r,wc.map(n=>({find:n,replace:""})),Gt.sentry[0]);for(let n of kc)r=Ac(r,n,Gt.sentry[0]);await l(t,r)}function Ac(e,t,r){let n=e.indexOf(t),i=n===-1?-1:e.indexOf(Pi,n);if(n===-1||i===-1)throw new Error(`generateObservabilityProvider: ${r} no longer contains the block this strip removes - re-point the anchor. Missing: ${t.trim()}`);if(e.includes(t,n+t.length))throw new Error(`generateObservabilityProvider: ${r} now opens more than one block with the same line, so this strip cannot tell which one to remove - re-point the anchor. Block: ${t.trim()}`);let o=e.slice(n+t.length,i).split(`
|
|
1595
|
+
`).find(a=>a.trim().length>0&&!a.startsWith(`${Ec} `));if(o!==void 0)throw new Error(`generateObservabilityProvider: ${r} no longer closes the block this strip removes at its own indent - re-point the anchor. Block: ${t.trim()}; first line inside it that is not part of its body: ${o.trim()}`);let s=i+Pi.length;return e[s]===`
|
|
1596
|
+
`&&(s+=1),e.slice(0,n)+e.slice(s)}function Ic(e,t,r){let n=Ri.filter(s=>!r.includes(s)),i=e,o=[];for(let s of n){let a=Tc(i,s);i=a.source,o.push(...a.bindings)}t.dualMode!==!1&&(i=Tr(i,[{find:_c,replace:Oc(r)}],t.file)),i=Tr(i,t.edits(r),t.file);for(let s of n)if(i.includes(`./providers/${s}`))throw new Error(`generateObservabilityProvider: ${t.file} still reaches providers/${s} after the strip - the boilerplate grew an import shape the drop does not know.`);for(let s of o)if(new RegExp(String.raw`\b${s}\b`).test(i))throw new Error(`generateObservabilityProvider: ${t.file} still names ${s}, whose import the strip removed - the boilerplate grew a use the pinned rewrites do not cover.`);return i}function Tr(e,t,r){let n=e;for(let i of t){let o=n.indexOf(i.find);if(o===-1)throw new Error(`generateObservabilityProvider: ${r} no longer contains the text this strip rewrites - re-point the anchor. Missing: ${i.find.split(`
|
|
1581
1597
|
`)[0]}`);if(i.all!==!0&&n.includes(i.find,o+i.find.length))throw new Error(`generateObservabilityProvider: ${r} now contains the text this strip rewrites more than once, so rewriting the first would leave the rest in the buyer's copy - make the anchor unique or mark the edit \`all\`. Ambiguous: ${i.find.split(`
|
|
1582
|
-
`)[0]}`);n=i.all===!0?n.replaceAll(i.find,i.replace):n.replace(i.find,i.replace)}return n}function
|
|
1598
|
+
`)[0]}`);n=i.all===!0?n.replaceAll(i.find,i.replace):n.replace(i.find,i.replace)}return n}function Tc(e,t){let r=new RegExp(String.raw`^import[^;]*?from "\./providers/${t}/[^"]*";\n`,"gm"),n=[];return{source:e.replace(r,o=>(n.push(...Rc(o)),"")),bindings:n}}function Rc(e){let t=/^import\s+(?:type\s+)?(\S(?:[\s\S]*?\S)?)\s+from\s/.exec(e)?.[1]??"",r=[],n=/\{([\s\S]*?)\}/.exec(t);n?.[1]&&r.push(...n[1].split(",").map(s=>s.trim().replace(/^type\s+/,"")).map(s=>s.split(/\s+as\s+/).at(-1)??"").filter(s=>s.length>0));let i=t.replace(/\{[\s\S]*?\}/,"").replace(/,/g," ").trim(),o=/^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(i);return o?.[1]?r.push(o[1]):/^[A-Z_$][\w$]*$/i.test(i)&&r.push(i),r}var _c=" * DUAL-MODE, exactly as `packages/payments/src/providers/index.ts` does it. This repo resolves the\n * provider at RUNTIME so dev and the test suite exercise every one of them. A generated project has\n * exactly one provider, so the CLI overwrites this file with a direct re-export of it and the rest\n * become unreachable and are pruned; the generator is the observability twin of\n * `packages/cli/src/generators/payment-barrel.ts` and lands with the manifest option.";function Oc(e){let t=e.map(n=>n==="sentry"?"Sentry":"PostHog").join(" and ");return`${e.length===0?` * Your project was generated with observability OFF, so no provider adapter and no reporting
|
|
1583
1599
|
* SDK were shipped: this file is the whole layer, and every call is a no-op that loads nothing.`:` * Your project reports to ${t}. No other provider's adapter or SDK was generated, so
|
|
1584
1600
|
* there is nothing here for a bundler to fold away and nothing for pnpm to install.`}
|
|
1585
1601
|
*
|
|
@@ -1587,28 +1603,28 @@ export { ops } from "./${r}/index";
|
|
|
1587
1603
|
* run \`generatesaas update\`: re-shaping brings that provider's adapter and its dependency back. The
|
|
1588
1604
|
* update stages nothing while you are already on the latest version, so the switch lands with the
|
|
1589
1605
|
* next release. Pasting the other provider's credential into \`config.observability\` on its own
|
|
1590
|
-
* cannot: the adapter it would need is not here.`}var
|
|
1606
|
+
* cannot: the adapter it would need is not here.`}var Ii={source:"src/providers/sentry/browser.ts",name:"SentryBrowserInitOptions",fields:["environment: string;"]},xc={source:"src/providers/sentry/desktop-main.ts",name:"SentryDesktopMainInitOptions",fields:["environment: string;","appIsReady: boolean;"]};function Dc(e,t){return e.includes("sentry")?[]:t?[Ii,xc]:[Ii]}async function Nc(e,t){let r=await Ht(q(e,t.source),"utf-8"),n=`export interface ${t.name} {
|
|
1591
1607
|
`,i=r.indexOf(n),o=r.indexOf(`
|
|
1592
1608
|
}
|
|
1593
1609
|
`,i);if(i===-1||o===-1)throw new Error(`generateObservabilityProvider: ${t.source} no longer exports ${t.name} - the stand-in this strip emits for a project without Sentry has nothing left to track.`);let s=r.slice(i+n.length,o).split(`
|
|
1594
|
-
`).map(a=>a.trim()).filter(a=>/^[A-Z_$][\w$]*\??: .+;$/i.test(a));if(s.join(" ")!==t.fields.join(" "))throw new Error(`generateObservabilityProvider: ${t.name} now declares [${s.join(" ")}] but the stand-in emitted for a project without Sentry declares [${t.fields.join(" ")}] - update the stand-in so switching providers on an update cannot break a buyer's call site.`)}var
|
|
1595
|
-
|
|
1610
|
+
`).map(a=>a.trim()).filter(a=>/^[A-Z_$][\w$]*\??: .+;$/i.test(a));if(s.join(" ")!==t.fields.join(" "))throw new Error(`generateObservabilityProvider: ${t.name} now declares [${s.join(" ")}] but the stand-in emitted for a project without Sentry declares [${t.fields.join(" ")}] - update the stand-in so switching providers on an update cannot break a buyer's call site.`)}var Cc=" into every browser bundle. `tests/export-isolation.test.ts` walks the real module graph to keep\n * that from ever landing quietly.",Lc=` if (resolveSentryDsn(observability, process.env.SENTRY_DSN)) ops.push(sentryServerOps);
|
|
1611
|
+
`,$c=` if (resolvePosthogCredentials(observability, process.env.POSTHOG_API_KEY)) {
|
|
1596
1612
|
ops.push(posthogServerOps);
|
|
1597
1613
|
}
|
|
1598
|
-
`,
|
|
1614
|
+
`,jc=` if (resolveSentryDsn(config.observability, process.env.SENTRY_DSN)) {
|
|
1599
1615
|
await initSentryServer();
|
|
1600
1616
|
}
|
|
1601
|
-
|
|
1602
|
-
`,
|
|
1603
|
-
`,
|
|
1604
|
-
`,
|
|
1617
|
+
`,Mc=` if (observability?.sentry?.dsn) ops.push(sentryBrowserOps);
|
|
1618
|
+
`,Uc=` if (observability?.posthog?.publicKey) ops.push(posthogBrowserOps);
|
|
1619
|
+
`,Fc=` if (config.observability?.sentry?.dsn) await initSentryBrowser(options);
|
|
1620
|
+
`,Vc=` if (observability?.posthog?.publicKey && !observability?.sentry?.dsn) {
|
|
1605
1621
|
announceMissingDesktopSdk("posthog");
|
|
1606
1622
|
}
|
|
1607
|
-
`,
|
|
1623
|
+
`,Bc=` // PostHog ships no desktop SDK, so a project configured ONLY for PostHog reports nothing here -
|
|
1608
1624
|
// announced rather than silent, because a desktop build that quietly drops every error looks
|
|
1609
1625
|
// identical to one with reporting off.
|
|
1610
|
-
`,
|
|
1611
|
-
`,
|
|
1626
|
+
`,Kc=`import { createProviderGapAnnouncer } from "./announce";
|
|
1627
|
+
`,Gc=`/**
|
|
1612
1628
|
* Say once that the selected provider cannot report from the desktop main process.
|
|
1613
1629
|
*
|
|
1614
1630
|
* A WARNING and never an error: PostHog ships no Electron SDK, and a buyer running PostHog on the
|
|
@@ -1626,7 +1642,7 @@ const announceMissingDesktopSdk = createProviderGapAnnouncer({
|
|
|
1626
1642
|
remedy: "Select sentry to report errors from the desktop app."
|
|
1627
1643
|
});
|
|
1628
1644
|
|
|
1629
|
-
`,
|
|
1645
|
+
`,Hc=`/**
|
|
1630
1646
|
* Say once that the selected provider cannot report from the desktop renderer.
|
|
1631
1647
|
*
|
|
1632
1648
|
* A WARNING and never an error: PostHog ships no Electron SDK, and a buyer running PostHog on the
|
|
@@ -1640,89 +1656,89 @@ const announceMissingDesktopSdk = createProviderGapAnnouncer({
|
|
|
1640
1656
|
remedy: "Select sentry to report errors from the desktop app."
|
|
1641
1657
|
});
|
|
1642
1658
|
|
|
1643
|
-
`,
|
|
1644
|
-
`,
|
|
1645
|
-
`,
|
|
1646
|
-
`,
|
|
1659
|
+
`,Yc=` const observability = config.observability;
|
|
1660
|
+
`,Wc=`import { config } from "@repo/config";
|
|
1661
|
+
`,zc=` // No error-reporting adapter was generated for this project, so there is nothing to arm.
|
|
1662
|
+
`,qc="export type BrowserReportingOptions = SentryBrowserInitOptions;",Jc=`export interface BrowserReportingOptions {
|
|
1647
1663
|
/** The deployment environment reports are tagged with. */
|
|
1648
1664
|
environment: string;
|
|
1649
|
-
}`,
|
|
1650
|
-
`,
|
|
1651
|
-
`,
|
|
1665
|
+
}`,Xc=` return observability?.sentry?.dsn ? [sentryDesktopMainOps] : [];
|
|
1666
|
+
`,Zc=` if (observability?.sentry?.dsn) await initSentryDesktopMain(options);
|
|
1667
|
+
`,Qc="export type DesktopMainReportingOptions = SentryDesktopMainInitOptions;",el=`export interface DesktopMainReportingOptions {
|
|
1652
1668
|
/** The deployment environment reports are tagged with. */
|
|
1653
1669
|
environment: string;
|
|
1654
1670
|
/** Whether Electron's \`app\` has emitted \`ready\` yet. */
|
|
1655
1671
|
appIsReady: boolean;
|
|
1656
|
-
}`,
|
|
1657
|
-
`,
|
|
1658
|
-
`;function
|
|
1659
|
-
`},{find:
|
|
1660
|
-
`},{find:
|
|
1672
|
+
}`,tl=` return observability?.sentry?.dsn ? [sentryDesktopRendererOps] : [];
|
|
1673
|
+
`,rl=` if (observability?.sentry?.dsn) await initSentryDesktopRenderer();
|
|
1674
|
+
`;function Oi(e,t){let r=[];return e.includes("sentry")||r.push({find:t.sentry,replace:""}),e.includes("posthog")||r.push({find:t.posthog,replace:""}),r}function nl(e){let t=[{find:Cc,replace:" into every browser bundle."},...Oi(e,{sentry:Lc,posthog:$c})];return e.includes("sentry")||t.push({find:jc,replace:""}),t}function il(e){let t=[...Oi(e,{sentry:Mc,posthog:Uc})];return e.includes("sentry")||t.push({find:Fc,replace:""},sl),t}var sl={find:qc,replace:Jc};function ol(e){let t=[...xi(e,Gc)];return e.includes("sentry")||t.push({find:Xc,replace:` return [];
|
|
1675
|
+
`},{find:Zc,replace:Ni(e)},{find:Qc,replace:el}),t.push(...Di(e)),t}function al(e){let t=[...xi(e,Hc)];return e.includes("sentry")||t.push({find:tl,replace:` return [];
|
|
1676
|
+
`},{find:rl,replace:Ni(e)}),t.push(...Di(e)),t}function xi(e,t){return e.includes("posthog")?[]:[{find:Bc,replace:""},{find:Vc,replace:"",all:!0},{find:t,replace:""},{find:Kc,replace:""}]}function Di(e){return e.length>0?[]:[{find:Yc,replace:"",all:!0},{find:Wc,replace:""}]}function Ni(e){return e.length>0?"":zc}var cl=` if (!posthogConfigured()) return;
|
|
1661
1677
|
try {
|
|
1662
1678
|
const { captureAiGeneration: capture } = await import("./providers/posthog/llm");
|
|
1663
1679
|
await capture(event);
|
|
1664
1680
|
} catch (reportingError) {
|
|
1665
1681
|
logReportingFailure("[observability] failed to report an AI generation", reportingError);
|
|
1666
1682
|
}
|
|
1667
|
-
`,
|
|
1668
|
-
`,
|
|
1669
|
-
`,
|
|
1683
|
+
`,ll=` // No PostHog adapter was generated for this project, so there is nothing to report to.
|
|
1684
|
+
`,pl=` // No PostHog adapter was generated for this project, so there is nothing queued to drain.
|
|
1685
|
+
`,dl=`/** Whether PostHog is configured on this server, without touching the PostHog folder. */
|
|
1670
1686
|
function posthogConfigured(): boolean {
|
|
1671
1687
|
return Boolean(process.env.POSTHOG_API_KEY || config.observability?.posthog?.publicKey);
|
|
1672
1688
|
}
|
|
1673
1689
|
|
|
1674
|
-
`,
|
|
1690
|
+
`,ul=` if (!posthogConfigured()) return;
|
|
1675
1691
|
try {
|
|
1676
1692
|
const { flushAiGenerations: flush } = await import("./providers/posthog/llm");
|
|
1677
1693
|
await flush();
|
|
1678
1694
|
} catch (reportingError) {
|
|
1679
1695
|
logReportingFailure("[observability] failed to flush queued AI generations", reportingError);
|
|
1680
1696
|
}
|
|
1681
|
-
`;function
|
|
1697
|
+
`;function ml(e){return e.includes("posthog")?[]:[{find:cl,replace:ll},{find:ul,replace:pl},{find:dl,replace:""},{find:`import { logReportingFailure } from "./logger";
|
|
1682
1698
|
`,replace:""},{find:`import { config } from "@repo/config";
|
|
1683
|
-
`,replace:""},{find:"export async function captureAiGeneration(event: AiGenerationEvent): Promise<void> {",replace:"export async function captureAiGeneration(_event: AiGenerationEvent): Promise<void> {"},{find:" * @param event - The call to record.",replace:" * @param _event - Ignored: no PostHog adapter was generated for this project."}]}var
|
|
1684
|
-
`),await l(
|
|
1685
|
-
`)}import{relative as
|
|
1699
|
+
`,replace:""},{find:"export async function captureAiGeneration(event: AiGenerationEvent): Promise<void> {",replace:"export async function captureAiGeneration(_event: AiGenerationEvent): Promise<void> {"},{find:" * @param event - The call to record.",replace:" * @param _event - Ignored: no PostHog adapter was generated for this project."}]}var Ti=[{file:"src/resolve-server.ts",edits:nl},{file:"src/resolve-browser.ts",edits:il},{file:"src/llm-analytics.ts",edits:ml,dualMode:!1}],hl=[{file:"src/resolve-desktop-main.ts",edits:ol},{file:"src/resolve-desktop-renderer.ts",edits:al}];import{readdir as fl,readFile as gl}from"fs/promises";import{join as Ci}from"path";async function Li(e){if(e.demo)return;let t=e.appName.trim()||e.projectName,r=JSON.stringify(t).slice(1,-1),n=Ci(e.projectDir,"packages/i18n/translations"),i;try{i=await fl(n)}catch{return}for(let o of i){let s=Ci(n,o,"web.json"),a;try{a=await gl(s,"utf-8")}catch{continue}let d=a.replaceAll("GenerateSaaS",()=>r);d!==a&&await l(s,d)}}async function Yt(e){let t=e.projectDir;e.demo||(await yn(t),await bn(t),await vn(t),await wn(t)),await kn(t),await Pn(e),await In(e),await Tn(e),e.demo||await Rn(e),await xn(e),await Dn(e),await Nn(e),await jn(e),await Un(e),await Vn(e),await Wn(e),await qn(e),await Qn(e),await ri(e),await bi(e),await ni(e),await ii(e),await si(e),await oi(e),await ai(e),await ci(e),await li(e),await mi(e),await pi(e),await di(e),await fi(e),await ki(e),await Si(e),await _i(e),await Hn(e),await Li(e),await ti(e)}import{basename as ji,join as Mi,relative as bl}from"path";import{createHash as $i}from"crypto";import{readFile as yl}from"fs/promises";async function Wt(e){let t=await yl(e);return $i("sha256").update(t).digest("hex")}function Rr(e){return $i("sha256").update(e).digest("hex")}var vl=new Set(["data",Y]);function wl(e){let t=e.split("/");for(let r of t)if(lr.has(r)||pr.has(r)||vl.has(r)||r.startsWith(".env")&&!r.includes("example"))return!0;return!1}function Ui(e,t){return{projectName:e.projectName??ji(t),appName:e.appName??e.projectName??ji(t),projectDir:t,architecture:e.architecture??"fullstack",paymentProvider:e.paymentProvider??"none",emailProvider:e.emailProvider??"smtp",multiTenancy:e.multiTenancy??!1,projects:e.projects??!1,docs:e.docs??!1,desktop:e.desktop??!1,mobile:e.mobile??!1,mobilePurchases:e.mobilePurchases??"none",ai:e.ai??!1,webAi:e.webAi??!0,revenueSharing:e.revenueSharing??!1,credits:e.credits??!1,defaultCurrency:e.defaultCurrency??"USD",deploymentTarget:e.deploymentTarget??"node",databaseProvider:e.databaseProvider??"postgres",cacheProvider:e.cacheProvider??"redis",observability:e.observability??[],version:e.version,baseUrl:e.baseUrl,credentials:{},demo:!1}}function kl(e,t){return{version:e.version,initialVersion:e.version,appName:e.appName,projectName:e.projectName,frontend:rn,architecture:e.architecture,paymentProvider:e.paymentProvider,emailProvider:e.emailProvider,multiTenancy:e.multiTenancy,projects:e.projects,docs:e.docs,desktop:e.desktop,mobile:e.mobile,...e.mobile&&re(e)?{mobilePurchases:e.mobilePurchases}:{},ai:e.ai,webAi:e.webAi,credits:e.credits,revenueSharing:e.revenueSharing,defaultCurrency:e.defaultCurrency,deploymentTarget:e.deploymentTarget,databaseProvider:e.databaseProvider,cacheProvider:e.cacheProvider,observability:e.observability,...e.baseUrl?{baseUrl:e.baseUrl}:{},...t&&{licenseToken:t.token,licenseKeyHash:t.keyHash,installId:t.installId}}}async function Fi(e,t){let n=(await ye(e.projectDir,e.projectDir,wl)).sort(),i=await Promise.all(n.map(async a=>[je(bl(e.projectDir,a)),await Wt(a)])),o=Object.fromEntries(i),s=kl(e,t);await l(Mi(e.projectDir,he),`${JSON.stringify(s,null," ")}
|
|
1700
|
+
`),await l(Mi(e.projectDir,nn),`${JSON.stringify(o,null," ")}
|
|
1701
|
+
`)}import{relative as El}from"path";async function lt(e){let r=(await ye(e,e,st)).sort(),n=await Promise.all(r.map(async i=>[je(El(e,i)),await Wt(i)]));return Object.fromEntries(n)}import{copyFile as Sl,mkdir as Pl,rm as Al}from"fs/promises";import{dirname as Il,join as Vi,relative as Tl}from"path";import{existsSync as Rl}from"fs";async function _r(e,t){Rl(t)&&await Al(t,{recursive:!0,force:!0});let n=(await ye(e,e,st)).map(i=>({file:i,dest:Vi(t,je(Tl(e,i)))}));await Promise.all([...new Set(n.map(({dest:i})=>Il(i)))].map(i=>Pl(i,{recursive:!0}))),await Promise.all(n.map(({file:i,dest:o})=>Sl(i,o)))}async function Bi(e,t){await _r(e,Vi(t,Ot))}import{readdir as _l,readFile as Gi}from"fs/promises";import{existsSync as Ol}from"fs";import{dirname as xl,join as oe,resolve as Or,sep as xr}from"path";import{fileURLToPath as Dl}from"url";var Nl={"claude-code":".claude/skills",cursor:".cursor/skills",codex:".agents/skills","gemini-cli":".gemini/skills",windsurf:".windsurf/skills"},Ke=Object.values(Nl),Dr=["generatesaas-update","generatesaas-translate"],Cl=xl(Dl(import.meta.url));function Ll(){return Ct(Cl,["skill","content"],"content")}async function Nr(e,t){for(let r of Ke){let n=oe(e,r);for(let[i,o]of Object.entries(t)){let s=Or(n,i);if(!s.startsWith(n+xr))continue;let a=oe(s,"scripts"),d=oe(s,"references");await $t(a),await $t(d),await l(oe(s,"SKILL.md"),o.skillMd.replaceAll("__SKILL_ROOT__",r));for(let[g,w]of Object.entries(o.scripts)){let v=Or(a,g);v.startsWith(a+xr)&&await l(v,w)}let h=Object.entries(o.references);h.length===0&&await l(oe(d,".gitkeep"),"");for(let[g,w]of h){let v=Or(d,g);v.startsWith(d+xr)&&await l(v,w)}}}}async function Ki(e){if(!Ol(e))return{};let t={};for(let r of await _l(e))r!==".gitkeep"&&(t[r]=await Gi(oe(e,r),"utf-8"));return t}async function Hi(e){let t=Ll(),r={};for(let n of Dr){let i=oe(t,n);r[n]={skillMd:await Gi(oe(i,"SKILL.md"),"utf-8"),scripts:await Ki(oe(i,"scripts")),references:await Ki(oe(i,"references"))}}await Nr(e,r)}import{execFile as $l,execFileSync as jl}from"child_process";import{access as Yi,readFile as Ml}from"fs/promises";import{join as Cr}from"path";import*as C from"@clack/prompts";function He(e){try{let t=process.platform==="win32"?"where":"which";return jl(t,[e],{stdio:"ignore"}),!0}catch{return!1}}function Ge(e,t,r,n=3e5){return new Promise((i,o)=>{$l(e,t,{cwd:r,timeout:n,maxBuffer:50*1024*1024},(s,a,d)=>{if(s){let h=String(a||"").trim(),w=[String(d||"").trim(),h].filter(Boolean).join(`
|
|
1686
1702
|
`);o(new Error(w?`${s.message}
|
|
1687
|
-
${w}`:s.message))}else i()})})}async function
|
|
1688
|
-
`),J.yellow("Start Development"));let i=[];i.push(`App ${J.cyan("http://localhost:3000")}`),e.architecture==="separate"&&i.push(`API ${J.cyan("http://localhost:3010")}`),r.includes("mailpit")&&i.push(`Mailpit ${J.cyan("http://localhost:8025")}`),r.includes("inngest")&&i.push(`Inngest ${J.cyan("http://localhost:8288")}`),
|
|
1689
|
-
`),J.yellow("Dev Tools"));let o=[],s=
|
|
1690
|
-
`),J.yellow("Deployment"))}function
|
|
1691
|
-
`),await Fi(m,m)}await Ki(m),await Mi(D,P),n.stop("Project files generated.")}catch(E){n.stop("Generation failed."),S.cancel(N(E)),process.exit(1)}!await Hi(m)&&D.demo===!0&&(S.cancel("Lockfile regeneration failed on a demo build - refusing to ship a stale lockfile."),process.exit(1));let K=await Yi(m),ae=D.demo!==!0&&e.dbMigration!==!1,Te=!1;ae&&K&&(await Wi(m),Te=!0),await zi(m),K&&await qi(m);let Re=Ke("docker"),ce=rr(D).map(E=>E.key).filter(E=>!D.credentials?.[E]);Ji(D,{pnpmInstalled:K,dockerAvailable:Re,skippedCredentials:ce,baselineMigrationMissing:ae&&!Te}),Br(),S.log.info(Gl.dim(`Done in ${((performance.now()-t)/1e3).toFixed(1)}s`))}import{existsSync as qt}from"fs";import{readFile as ss}from"fs/promises";import{join as He,resolve as Zl}from"path";import*as b from"@clack/prompts";import L from"picocolors";import{mkdtemp as zl,rm as ql}from"fs/promises";import{tmpdir as Jl}from"os";import{join as Xl}from"path";async function Cr(e,t,r,n){let i=await zl(Xl(Jl(),"generatesaas-stage-"));try{await Ot(e,t,i),await Ht({...r,projectDir:i}),await Rr(i,n)}finally{await ql(i,{recursive:!0,force:!0})}}function es(e){return e?"ON":"OFF"}var ct="packages/config/src/index.ts";function ts(e){let t=e.databaseProvider!==void 0&&e.databaseProvider!=="postgres",r=e.cacheProvider!==void 0&&e.cacheProvider!=="redis";return[...e.dockerServices??[]].filter(n=>!(n==="postgres"&&t)&&!(n==="redis"&&r)).sort()}function rs(e){return[...Xe({databaseProvider:e.databaseProvider??"postgres",cacheProvider:e.cacheProvider??"redis",emailProvider:e.emailProvider??"smtp"})].sort()}var Lr=[{key:"errorTracking",severity:"warn",normalize:e=>{e.errorTracking===void 0||e.observability!==void 0||(e.observability=e.errorTracking==="none"?[]:[e.errorTracking])},preserved:()=>!0,describe:()=>"Retired option `errorTracking`: it is now `observability`, an array, because a project may run more than one provider at once. Your recorded choice was migrated across automatically."},{key:"companion",severity:"warn",normalize:e=>{e.companion===void 0||e.runner!==void 0||(e.runner=e.companion)},preserved:()=>!0,describe:()=>"Retired option `companion`: it was renamed to `runner`, and that lane has since been removed. Your recorded choice was migrated across automatically so the removal is reported under one name."},{key:"runner",severity:"warn",preserved:e=>e.runner!==!0,describe:()=>"Retired option `runner`: your project recorded the runner ON, and the paired-device lane is GONE - not moved to config, removed. This update deletes the pairing endpoint, the dispatch transport, the relay, the `/runner/*` routes and the Runners page from your project, so machines your users had paired are no longer reachable from it. Chat and automations keep running on your app's own AI backend (`config.ai`); nothing else about them changes."},{key:"companionSource",severity:"warn",preserved:()=>!1,describe:e=>`Retired option \`companionSource\`: your project recorded a \`${String(e.companionSource)}\` daemon source. The paired-device lane that daemon served has been removed from your project, so there is no daemon left to source.`},{key:"companionName",severity:"warn",preserved:()=>!1,describe:e=>`Retired option \`companionName\`: your project recorded a branded daemon called ${e.companionName}. The paired-device lane is gone, so your project no longer ships an install flow to brand. An existing install on a user's machine keeps running; it has nothing left to pair with here.`},{key:"companionRepoUrl",severity:"warn",preserved:()=>!1,describe:e=>`Retired option \`companionRepoUrl\`: your project pointed its install flow at ${e.companionRepoUrl}. The paired-device lane is gone, so there is no install flow left to point anywhere.`},{key:"companionLocalPath",severity:"warn",preserved:e=>(e.companionLocalPath??"").trim()==="",describe:e=>`Retired option \`companionLocalPath\`: your project recorded a local daemon checkout at ${e.companionLocalPath}. The option is gone, and so is the paired-device lane behind it - nothing in your project is built from a local daemon checkout any more.`},{key:"desktopAutoRelease",severity:"warn",applies:e=>e.desktop===!0,preserved:e=>e.desktopAutoRelease!==!0,describe:()=>"Retired option `desktopAutoRelease`: your project recorded automatic desktop releases, but the release trigger is no longer an option - cutting a release publishes signed installers from your repository and spends your Actions minutes, so it is always deliberate now. The staged `.github/workflows/desktop-release.yml` runs on `workflow_dispatch` only (the Actions tab). Add a `workflow_run` block to it if you want the CI-chained trigger back."},{key:"billingScope",severity:"ask",applies:e=>e.multiTenancy===!0,preserved:e=>e.billingScope!=="organization",describe:()=>"Retired option `billingScope`: your project recorded organization-scoped billing, and organization billing is GONE - not moved to config, removed. Money now lives only on a USER: an organization has no balance, no plan and no payment-provider customer of its own, and a workspace is funded by that organization's OWNER, whose personal balance every credit spent in it is drawn from. Payments already in flight are safe - a webhook arriving with old organization metadata is credited to that organization's owner rather than dropped - but a STORED balance is not: the `credits` and `plan` columns an organization holds them in are going away, and this update does NOT migrate them for you. MOVE every paying organization's credits and plan onto its owner BEFORE you take new checkouts, or that balance goes with the columns. How should this update proceed?"},{key:"socialProviders",severity:"warn",preserved:e=>(e.socialProviders??[]).length===0,describe:e=>`Retired option \`socialProviders\`: your project recorded ${(e.socialProviders??[]).join(", ")}, but it is now a plain config value that starts empty. Add those IDs back to \`config.auth.socialProviders\` in ${ct} or their sign-in buttons stop rendering.`},{key:"mcpServer",severity:"warn",preserved:e=>e.mcpServer!==!0,describe:()=>`Retired option \`mcpServer\`: your project recorded the outward MCP server ON, but its code now ships to every project behind a config gate generated OFF. Set \`config.mcpServer.enabled\` to \`true\` in ${ct} to keep your /mcp endpoint and its settings tab.`},{key:"emailTracking",severity:"ask",preserved:e=>e.emailTracking!==!1,describe:()=>`Retired option \`emailTracking\`: your project recorded email open/click tracking OFF, but it is now a plain config value generated ON. Applying this update as-is starts tracking opens and clicks for every user you mail. Put it back by setting \`config.email.tracking.enabled\` to \`false\` in ${ct}. How should this update proceed?`},{key:"aiBuiltin",severity:"warn",normalize:e=>{e.aiBuiltin===void 0&&e.aiByok!==void 0&&(e.aiBuiltin=e.aiByok===!1)},applies:e=>e.ai===!0,preserved:e=>e.aiBuiltin===re(e),describe:e=>`Retired option \`aiBuiltin\`: your project recorded built-in AI models ${es(e.aiBuiltin)}, but it is now a plain config value generated ${es(re(e))} for a project ${re(e)?"with":"without"} a payment provider. Set \`config.ai.builtin\` to \`${String(e.aiBuiltin===!0)}\` in ${ct} to keep your choice.`},{key:"aiByok",severity:"warn",applies:e=>e.ai===!0,preserved:e=>e.aiByok!==!1,describe:()=>`Retired option \`aiByok\`: your project recorded bring-your-own-key AI OFF, but it is now a plain config value generated ON. Set \`config.ai.byok\` to \`false\` in ${ct} to keep it off.`},{key:"dockerServices",severity:"warn",preserved:e=>ts(e).join(",")===rs(e).join(","),describe:e=>`Retired option \`dockerServices\`: your project recorded ${ts(e).join(", ")||"no services"}, but the local Docker services are now DERIVED from your stack - postgres when you self-host Postgres, redis when you self-host Redis, mailpit on SMTP, inngest always - so an inconsistent selection is no longer possible. Your regenerated \`infra/docker-compose.yml\` will contain ${rs(e).join(", ")}. Add any extra service back by hand; the file is yours to edit.`},{key:"aiTools",severity:"warn",preserved:e=>(e.aiTools??[]).length===Hr.length,describe:e=>`Retired option \`aiTools\`: your project recorded ${(e.aiTools??[]).join(", ")||"no AI tools"}, but the \`generatesaas-update\` skill bundle now ships to every AI-tool root (${Ve.join(", ")}) so it is there whichever assistant you switch to. Delete the roots you do not use - they are plain text files with no runtime path.`},{key:"blog",severity:"warn",preserved:e=>e.blog!==!1,describe:()=>`Retired option \`blog\`: your project recorded the blog OFF, but content is no longer an option. This update adds the \`/blog\`, \`/alternatives\` and \`/compare\` route code to your project; every one of them 404s until you add a file under \`packages/content/{locale}/{section}/\`, and none of them appears in your navbar, footer, sitemap or llms.txt while it is empty. Delete an entry from \`config.content.sections\` in ${ct} to drop a section for good.`}],hf=Lr.map(e=>e.key);function ns(e){for(let t of Lr)t.normalize?.(e)}function is(e){let t=[];for(let r of Lr){if(r.normalize?.(e),e[r.key]===void 0)continue;(!r.applies||r.applies(e))&&!r.preserved(e)&&t.push({key:r.key,message:r.describe(e),severity:r.severity}),delete e[r.key]}return t}function os(e){e.command("update").description("Update AI skill files and stage template updates").allowExcessArguments(!0).option("--cwd <path>","project directory (default: current directory)").option("--answer <id=choice>","answer a question that would otherwise prompt (repeatable), e.g. --answer desktop-migration=keep-ai",Ql,[]).action(async(t,r)=>{r.args.length>0&&b.log.warn("`auto` is now the default and the argument is ignored.");let n=ep(t.answer),i=Zl(t.cwd??process.cwd()),o=He(i,he),s;try{s=JSON.parse(await ss(o,"utf-8"))}catch{b.cancel(".generatesaas/manifest.json not found. Run this from a GenerateSaaS project."),process.exit(1)}s.frontend===nr&&(b.cancel(`This project was generated on the Nuxt frontend (\`.generatesaas/manifest.json\` records \`frontend: "${nr}"\`).
|
|
1703
|
+
${w}`:s.message))}else i()})})}async function Wi(e){if(!He("pnpm"))return C.log.warn("pnpm not found. Skipping lockfile regeneration."),!1;try{return await Ge("pnpm",["install","--lockfile-only","--no-frozen-lockfile"],e),!0}catch(t){let r=t instanceof Error?t.message:String(t);return C.log.warn(`Lockfile regeneration failed: ${r}`),C.log.warn("Deploys using --frozen-lockfile may fail."),!1}}async function zi(e){if(!He("pnpm"))return C.log.warn("pnpm not found. Skipping dependency installation."),C.log.info("Install pnpm: https://pnpm.io/installation"),!1;let t=C.spinner();t.start("Installing dependencies (this may take a minute)...");try{return await Ge("pnpm",["install"],e),t.stop("Dependencies installed."),!0}catch(r){t.stop("Dependency installation failed.");let n=r instanceof Error?r.message:String(r);return C.log.warn(`pnpm install failed: ${n}`),C.log.warn("You can run it manually later."),!1}}async function qi(e){if(!He("pnpm"))return!1;let t=C.spinner();t.start("Generating baseline database migration...");try{return await Ge("pnpm",["-F","@repo/database","generate"],e),t.stop("Baseline migration generated."),!0}catch(r){t.stop("Baseline migration generation failed.");let n=r instanceof Error?r.message:String(r);return C.log.warn(`Could not generate baseline migration: ${n}`),C.log.warn("Run 'pnpm -F @repo/database generate' before your first deploy."),!1}}async function Ji(e){try{return await Yi(Cr(e,".git")),C.log.info("Git repository already exists, skipping init."),!0}catch{}if(!He("git"))return C.log.warn("git not found. Skipping repository initialization."),!1;let t=C.spinner();t.start("Initializing git repository...");try{return await Ge("git",["init"],e),await Ge("git",["add","-A"],e),await Ge("git",["commit","--no-verify","-m","Initial commit from GenerateSaaS"],e),t.stop("Git repository initialized."),!0}catch{return t.stop("Git initialization failed."),C.log.warn("You can run git init manually later."),!1}}async function Xi(e){if(!He("pnpm"))return!1;try{await Yi(Cr(e,".git"))}catch{return!1}try{let t=JSON.parse(await Ml(Cr(e,"package.json"),"utf-8")),r=!!t.devDependencies?.["simple-git-hooks"],n=!!t["simple-git-hooks"];if(!r||!n)return!1}catch{return!1}try{return await Ge("pnpm",["exec","simple-git-hooks"],e),!0}catch{return C.log.warn("Could not install git hooks. Run 'pnpm exec simple-git-hooks' manually."),!1}}import*as Ye from"@clack/prompts";import J from"picocolors";function Zi(e,t){let r=Qe(e);t.dockerAvailable||Ye.log.warn("Docker not found. Install Docker to run local services: https://docs.docker.com/get-docker/"),t.baselineMigrationMissing&&Ye.log.warn("No database migration was generated (the install did not complete). Run `pnpm install` then `pnpm --filter @repo/database generate` and commit the result BEFORE deploying - otherwise your first deploy pushes the schema untracked.");let n=[];n.push(`cd ${e.projectDir}`),t.pnpmInstalled||n.push("pnpm install"),n.push(`pnpm infra ${J.dim(`# ${r.map(a=>Gr[a].label).join(", ")}`)}`),n.push(`pnpm dev ${J.dim("# http://localhost:3000")}`),t.skippedCredentials.length>0&&(n.push(""),n.push(J.dim("Fill in remaining TODO values in .env"))),Ye.note(n.join(`
|
|
1704
|
+
`),J.yellow("Start Development"));let i=[];i.push(`App ${J.cyan("http://localhost:3000")}`),e.architecture==="separate"&&i.push(`API ${J.cyan("http://localhost:3010")}`),r.includes("mailpit")&&i.push(`Mailpit ${J.cyan("http://localhost:8025")}`),r.includes("inngest")&&i.push(`Inngest ${J.cyan("http://localhost:8288")}`),Ye.note(i.join(`
|
|
1705
|
+
`),J.yellow("Dev Tools"));let o=[],s=Ul(e);s.length>0&&o.push(`Set in production: ${J.dim(s.join(", "))}`),o.push("pnpm db:setup # Apply the database schema"),o.push(Fl(e)),Ye.note(o.join(`
|
|
1706
|
+
`),J.yellow("Deployment"))}function Ul(e){let t=["DATABASE_URL","BETTER_AUTH_SECRET","INNGEST_APP_ID","INNGEST_EVENT_KEY","INNGEST_SIGNING_KEY"];return e.cacheProvider==="upstash"?t.push("UPSTASH_REDIS_REST_URL","UPSTASH_REDIS_REST_TOKEN"):t.push("REDIS_URL"),e.paymentProvider==="stripe"?t.push("STRIPE_SECRET_KEY","STRIPE_WEBHOOK_SECRET"):e.paymentProvider==="polar"&&t.push("POLAR_ACCESS_TOKEN","POLAR_WEBHOOK_SECRET"),e.emailProvider==="ses"?t.push("AMAZON_SES_REGION","AMAZON_SES_KEY","AMAZON_SES_SECRET"):e.emailProvider==="resend"?t.push("RESEND_API_KEY"):t.push("SMTP_HOST","SMTP_PORT"),t}function Fl(e){switch(e.deploymentTarget){case"node":return"Deploy with Docker or your preferred Node.js host";case"vercel":return"vercel deploy # Deploy to Vercel"}}function pt(e){let r=(e.startsWith("v")?e.slice(1):e).match(/^(\d+)\.(\d+)\.(\d+)$/);return r?[Number(r[1]),Number(r[2]),Number(r[3])]:null}function Te(e,t){let r=pt(e),n=pt(t);if(!r||!n)return 0;for(let i=0;i<3;i++)if(r[i]!==n[i])return r[i]-n[i];return 0}function zt(e,t){return e.versions.find(r=>r.version===t)?.cli}function Qi(e){return process.env.GENERATESAAS_TEMPLATE_TARBALL||process.env.GENERATESAAS_TEMPLATE_TARBALL_DIR||process.env.GENERATESAAS_OFFLINE_LICENSE==="1"?!0:pt(e)===null}function qt(e,t,r="3.25.1"){if(Qi(r))return{ok:!0};let n=zt(e,e.latest);return!n||pt(n)===null?{ok:!0}:Te(r,n)>=0?{ok:!0}:{ok:!1,message:`Your installed generatesaas CLI (v${r}) is too old for template v${e.latest}, which needs CLI v${n} or newer. Run npx generatesaas@latest ${t} so npm fetches the current CLI. If the release happened minutes ago, npm may still be propagating it - retry shortly.`}}function es(e,t,r="3.25.1"){if(Qi(r))return{ok:!0};let n=zt(e,t);return!n||pt(n)===null?{ok:!0}:Te(r,n)===0?{ok:!0}:{ok:!1,message:`Template v${t} must be scaffolded by CLI v${n} exactly (the CLI whose generators produced that release). Run npx generatesaas@${n} init.`}}import Yl from"picocolors";var Wl="a10a6fb9d7cadde32e37dad52059d17b5d2b916b08c76d8fbcc99982e9a3d87f";async function zl(e){let t=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(`generatesaas-demo:${e}`)),r=[...new Uint8Array(t).subarray(0,16)].map(n=>n.toString(16).padStart(2,"0")).join("");return`${r.slice(0,8)}-${r.slice(8,12)}-${r.slice(12,16)}-${r.slice(16,20)}-${r.slice(20,32)}`}function ts(e){e.command("init").description("Scaffold a new GenerateSaaS project").argument("[apiKey]","license key (same as --api-key)").option("-n, --name <name>","project name (lowercase, hyphens, starts with letter)").option("--app-name <name>","display name for the app").option("-l, --location <path>","project directory (default: ./{name})").addOption(new X("--architecture <type>","fullstack or separate").choices([...et])).addOption(new X("--payment <provider>","payment provider").choices([...tt])).addOption(new X("--email <provider>","email provider").choices([...rt])).option("--org","enable multi-tenancy (organizations)").option("--no-org","disable multi-tenancy").option("--projects","enable projects (a switcher that scopes a user's work)").option("--no-projects","disable projects").option("--docs","include the docs app (apps/docs, Fumadocs)").option("--no-docs","exclude the docs app").option("--desktop","include the desktop app (apps/desktop, built with Electron)").option("--no-desktop","exclude the desktop app").option("--mobile","include the mobile app (apps/mobile, built with Expo)").option("--no-mobile","exclude the mobile app").addOption(new X("--mobile-purchases <lane>","in-app purchases in the mobile app: revenuecat adds the store billing lane on top of your web payment provider, none keeps web billing only").choices([...Ce])).option("--ai","include the AI features (chat, models, automations, integrations)").option("--no-ai","exclude the AI features").option("--web-ai","show the AI features in the web app (the default)").option("--no-web-ai","desktop-first: keep the AI features out of the web app, which is reduced to account, login and billing - the desktop or mobile app is the AI product. Requires --desktop or --mobile, plus --ai").option("--revenue-sharing","enable revenue sharing").option("--no-revenue-sharing","disable revenue sharing").option("--credits","enable credits system").option("--no-credits","disable credits system (subscription-only)").addOption(new X("--currency <code>","default currency for billing").choices([...pe])).addOption(new X("--preset <preset>","infrastructure preset: serverless (Vercel + Neon + Upstash) or selfhost (Docker or Node + Postgres + Redis); --deploy/--database/--cache override it piecewise").choices([...Ne])).addOption(new X("--deploy <target>","deployment target").choices([...de])).addOption(new X("--database <provider>","database provider").choices([...ue])).addOption(new X("--cache <provider>","cache provider").choices([...me])).option("--observability <providers>",`comma-separated observability providers (${F.join(", ")}); anything unselected ships no SDK`).option("--api-key <key>","API key (skips interactive prompt)").option("--base-url <url>","public base URL (e.g. https://example.com) - bakes into canonical/og/sitemap").option("-y, --yes","accept defaults for unspecified options (non-interactive)").addOption(new X("--demo","first-party demo build: keep sample content, mark site non-indexable - requires CI API key").hideHelp()).addOption(new X("--no-db-migration","skip generating the baseline DB migration (internal: demos/CI/playground)").hideHelp()).action(async(t,r)=>{await ql(t?{...r,apiKey:t}:r)})}async function ql(e){let t=performance.now();Br("3.25.1");let r;try{r=Qr(e)}catch(E){S.cancel(N(E)),process.exit(1)}let n=S.spinner(),i;try{i=await it({apiKey:e.apiKey,prompt:!e.yes})}catch(E){S.cancel(N(E)),process.exit(1)}e.demo&&Rr(i)!==Wl&&(S.cancel("--demo is restricted to first-party demo deployments."),process.exit(1));let o=ge(i),s,a=async()=>{s=await Ie(o);let E=qt(s,"init");if(!E.ok)throw new Error(E.message);return{latestVersion:s.latest,selectedVersion:s.latest,desktopAllowed:s.entitlements?.desktopAllowed??!0,mobileAllowed:s.entitlements?.mobileAllowed??!0}};n.start("Verifying access...");let d,h,g=!0,w=!0;try{({latestVersion:d,selectedVersion:h,desktopAllowed:g,mobileAllowed:w}=await a()),n.stop("Access verified."),$e(i)}catch(E){if(n.stop("Access verification failed."),E instanceof $&&E.status===401){e.yes&&(S.cancel("Invalid API key. Cannot prompt in non-interactive mode."),process.exit(1)),S.log.warning("Invalid API key."),i=await yt(),o=ge(i),n.start("Verifying access...");try{({latestVersion:d,selectedVersion:h,desktopAllowed:g,mobileAllowed:w}=await a()),n.stop("Access verified."),$e(i)}catch(_){n.stop("Access verification failed."),S.cancel(_ instanceof $&&_.status===401?"Invalid API key.":N(_)),process.exit(1)}}else S.cancel(N(E)),process.exit(1)}S.log.success(`Latest version: ${d}`),r.desktop===!0&&!g&&(S.cancel("The desktop app is available on the Pro plan and up. Upgrade your plan at generatesaas.com."),process.exit(1)),r.mobile===!0&&!w&&(S.cancel("The mobile app is available on the Pro plan and up. Upgrade your plan at generatesaas.com."),process.exit(1));let v;e.yes?v=en({...r,...g?{}:{desktop:!1},...w?{}:{mobile:!1}}):v=await tn(r,{desktopAllowed:g,mobileAllowed:w});let P;n.start("Activating license...");try{let E=e.demo&&v.baseUrl?await zl(v.baseUrl):crypto.randomUUID(),_=()=>({frontend:or,version:h,installId:E,projectName:v.projectName,options:ln(v)}),Q;try{Q=await ar(o,_())}catch(G){let j=xt(G);if(!j?.lastAllowedVersion||!s)throw G;n.stop("License activation failed.");let ee=es(s,j.lastAllowedVersion);if(ee.ok||(S.cancel(`${j.message} ${ee.message}`),process.exit(1)),e.yes){let ke=zt(s,j.lastAllowedVersion);S.cancel(`${j.message} Re-run ${ke?`npx generatesaas@${ke} init`:"init"} without --yes to continue with v${j.lastAllowedVersion} (the last version your license covers).`),process.exit(1)}let H=await S.confirm({message:`Your update window has ended. Continue with v${j.lastAllowedVersion} (the last version your license covers)?`});(S.isCancel(H)||!H)&&(S.cancel("Setup cancelled."),process.exit(0)),h=j.lastAllowedVersion,n.start(`Activating license for v${h}...`),Q=await ar(o,_())}P={token:Q.token,keyHash:Rr(i),installId:Q.installId??E},n.stop("License activated.")}catch(E){n.stop("License activation failed."),S.cancel(N(E)),process.exit(1)}let m=Hl(v.projectDir);if(Vl(m)&&Bl(m).length>0)if(e.yes)S.log.info(`Directory ${m} is not empty. Merging (keeping existing files, overwriting conflicts).`);else{let _=await S.select({message:`Directory ${m} is not empty.`,options:[{value:"merge",label:"Merge",hint:"keep existing files, overwrite conflicts"},{value:"overwrite",label:"Overwrite",hint:"delete everything and start fresh"},{value:"cancel",label:"Cancel"}]});(S.isCancel(_)||_==="cancel")&&(S.cancel("Setup cancelled."),process.exit(0)),_==="overwrite"&&Kl(m,{recursive:!0,force:!0})}let D={...v,projectDir:m,version:h,...e.demo?{docs:!1}:{}};n.start("Downloading template...");try{await Dt(o,h,m),n.stop("Template downloaded.")}catch(E){n.stop("Download failed."),S.cancel(N(E)),process.exit(1)}n.start("Generating project files...");try{if(await Yt(D),!e.demo){let E=await lt(m);await l(Gl(m,_t),`${JSON.stringify(E,null," ")}
|
|
1707
|
+
`),await Bi(m,m)}await Hi(m),await Fi(D,P),n.stop("Project files generated.")}catch(E){n.stop("Generation failed."),S.cancel(N(E)),process.exit(1)}!await Wi(m)&&D.demo===!0&&(S.cancel("Lockfile regeneration failed on a demo build - refusing to ship a stale lockfile."),process.exit(1));let K=await zi(m),ae=D.demo!==!0&&e.dbMigration!==!1,Re=!1;ae&&K&&(await qi(m),Re=!0),await Ji(m),K&&await Xi(m);let _e=He("docker"),ce=nr(D).map(E=>E.key).filter(E=>!D.credentials?.[E]);Zi(D,{pnpmInstalled:K,dockerAvailable:_e,skippedCredentials:ce,baselineMigrationMissing:ae&&!Re}),Kr(),S.log.info(Yl.dim(`Done in ${((performance.now()-t)/1e3).toFixed(1)}s`))}import{existsSync as Jt}from"fs";import{readFile as as}from"fs/promises";import{join as We,resolve as ep}from"path";import*as b from"@clack/prompts";import L from"picocolors";import{mkdtemp as Jl,rm as Xl}from"fs/promises";import{tmpdir as Zl}from"os";import{join as Ql}from"path";async function Lr(e,t,r,n){let i=await Jl(Ql(Zl(),"generatesaas-stage-"));try{await Dt(e,t,i),await Yt({...r,projectDir:i}),await _r(i,n)}finally{await Xl(i,{recursive:!0,force:!0})}}function rs(e){return e?"ON":"OFF"}var dt="packages/config/src/index.ts";function ns(e){let t=e.databaseProvider!==void 0&&e.databaseProvider!=="postgres",r=e.cacheProvider!==void 0&&e.cacheProvider!=="redis";return[...e.dockerServices??[]].filter(n=>!(n==="postgres"&&t)&&!(n==="redis"&&r)).sort()}function is(e){return[...Qe({databaseProvider:e.databaseProvider??"postgres",cacheProvider:e.cacheProvider??"redis",emailProvider:e.emailProvider??"smtp"})].sort()}var $r=[{key:"errorTracking",severity:"warn",normalize:e=>{e.errorTracking===void 0||e.observability!==void 0||(e.observability=e.errorTracking==="none"?[]:[e.errorTracking])},preserved:()=>!0,describe:()=>"Retired option `errorTracking`: it is now `observability`, an array, because a project may run more than one provider at once. Your recorded choice was migrated across automatically."},{key:"companion",severity:"warn",normalize:e=>{e.companion===void 0||e.runner!==void 0||(e.runner=e.companion)},preserved:()=>!0,describe:()=>"Retired option `companion`: it was renamed to `runner`, and that lane has since been removed. Your recorded choice was migrated across automatically so the removal is reported under one name."},{key:"runner",severity:"warn",preserved:e=>e.runner!==!0,describe:()=>"Retired option `runner`: your project recorded the runner ON, and the paired-device lane is GONE - not moved to config, removed. This update deletes the pairing endpoint, the dispatch transport, the relay, the `/runner/*` routes and the Runners page from your project, so machines your users had paired are no longer reachable from it. Chat and automations keep running on your app's own AI backend (`config.ai`); nothing else about them changes."},{key:"companionSource",severity:"warn",preserved:()=>!1,describe:e=>`Retired option \`companionSource\`: your project recorded a \`${String(e.companionSource)}\` daemon source. The paired-device lane that daemon served has been removed from your project, so there is no daemon left to source.`},{key:"companionName",severity:"warn",preserved:()=>!1,describe:e=>`Retired option \`companionName\`: your project recorded a branded daemon called ${e.companionName}. The paired-device lane is gone, so your project no longer ships an install flow to brand. An existing install on a user's machine keeps running; it has nothing left to pair with here.`},{key:"companionRepoUrl",severity:"warn",preserved:()=>!1,describe:e=>`Retired option \`companionRepoUrl\`: your project pointed its install flow at ${e.companionRepoUrl}. The paired-device lane is gone, so there is no install flow left to point anywhere.`},{key:"companionLocalPath",severity:"warn",preserved:e=>(e.companionLocalPath??"").trim()==="",describe:e=>`Retired option \`companionLocalPath\`: your project recorded a local daemon checkout at ${e.companionLocalPath}. The option is gone, and so is the paired-device lane behind it - nothing in your project is built from a local daemon checkout any more.`},{key:"desktopAutoRelease",severity:"warn",applies:e=>e.desktop===!0,preserved:e=>e.desktopAutoRelease!==!0,describe:()=>"Retired option `desktopAutoRelease`: your project recorded automatic desktop releases, but the release trigger is no longer an option - cutting a release publishes signed installers from your repository and spends your Actions minutes, so it is always deliberate now. The staged `.github/workflows/desktop-release.yml` runs on `workflow_dispatch` only (the Actions tab). Add a `workflow_run` block to it if you want the CI-chained trigger back."},{key:"billingScope",severity:"ask",applies:e=>e.multiTenancy===!0,preserved:e=>e.billingScope!=="organization",describe:()=>"Retired option `billingScope`: your project recorded organization-scoped billing, and organization billing is GONE - not moved to config, removed. Money now lives only on a USER: an organization has no balance, no plan and no payment-provider customer of its own, and a workspace is funded by that organization's OWNER, whose personal balance every credit spent in it is drawn from. Payments already in flight are safe - a webhook arriving with old organization metadata is credited to that organization's owner rather than dropped - but a STORED balance is not: the `credits` and `plan` columns an organization holds them in are going away, and this update does NOT migrate them for you. MOVE every paying organization's credits and plan onto its owner BEFORE you take new checkouts, or that balance goes with the columns. How should this update proceed?"},{key:"socialProviders",severity:"warn",preserved:e=>(e.socialProviders??[]).length===0,describe:e=>`Retired option \`socialProviders\`: your project recorded ${(e.socialProviders??[]).join(", ")}, but it is now a plain config value that starts empty. Add those IDs back to \`config.auth.socialProviders\` in ${dt} or their sign-in buttons stop rendering.`},{key:"mcpServer",severity:"warn",preserved:e=>e.mcpServer!==!0,describe:()=>`Retired option \`mcpServer\`: your project recorded the outward MCP server ON, but its code now ships to every project behind a config gate generated OFF. Set \`config.mcpServer.enabled\` to \`true\` in ${dt} to keep your /mcp endpoint and its settings tab.`},{key:"emailTracking",severity:"ask",preserved:e=>e.emailTracking!==!1,describe:()=>`Retired option \`emailTracking\`: your project recorded email open/click tracking OFF, but it is now a plain config value generated ON. Applying this update as-is starts tracking opens and clicks for every user you mail. Put it back by setting \`config.email.tracking.enabled\` to \`false\` in ${dt}. How should this update proceed?`},{key:"aiBuiltin",severity:"warn",normalize:e=>{e.aiBuiltin===void 0&&e.aiByok!==void 0&&(e.aiBuiltin=e.aiByok===!1)},applies:e=>e.ai===!0,preserved:e=>e.aiBuiltin===re(e),describe:e=>`Retired option \`aiBuiltin\`: your project recorded built-in AI models ${rs(e.aiBuiltin)}, but it is now a plain config value generated ${rs(re(e))} for a project ${re(e)?"with":"without"} a payment provider. Set \`config.ai.builtin\` to \`${String(e.aiBuiltin===!0)}\` in ${dt} to keep your choice.`},{key:"aiByok",severity:"warn",applies:e=>e.ai===!0,preserved:e=>e.aiByok!==!1,describe:()=>`Retired option \`aiByok\`: your project recorded bring-your-own-key AI OFF, but it is now a plain config value generated ON. Set \`config.ai.byok\` to \`false\` in ${dt} to keep it off.`},{key:"dockerServices",severity:"warn",preserved:e=>ns(e).join(",")===is(e).join(","),describe:e=>`Retired option \`dockerServices\`: your project recorded ${ns(e).join(", ")||"no services"}, but the local Docker services are now DERIVED from your stack - postgres when you self-host Postgres, redis when you self-host Redis, mailpit on SMTP, inngest always - so an inconsistent selection is no longer possible. Your regenerated \`infra/docker-compose.yml\` will contain ${is(e).join(", ")}. Add any extra service back by hand; the file is yours to edit.`},{key:"aiTools",severity:"warn",preserved:e=>(e.aiTools??[]).length===Yr.length,describe:e=>`Retired option \`aiTools\`: your project recorded ${(e.aiTools??[]).join(", ")||"no AI tools"}, but the \`generatesaas-update\` skill bundle now ships to every AI-tool root (${Ke.join(", ")}) so it is there whichever assistant you switch to. Delete the roots you do not use - they are plain text files with no runtime path.`},{key:"blog",severity:"warn",preserved:e=>e.blog!==!1,describe:()=>`Retired option \`blog\`: your project recorded the blog OFF, but content is no longer an option. This update adds the \`/blog\`, \`/alternatives\` and \`/compare\` route code to your project; every one of them 404s until you add a file under \`packages/content/{locale}/{section}/\`, and none of them appears in your navbar, footer, sitemap or llms.txt while it is empty. Delete an entry from \`config.content.sections\` in ${dt} to drop a section for good.`}],gf=$r.map(e=>e.key);function ss(e){for(let t of $r)t.normalize?.(e)}function os(e){let t=[];for(let r of $r){if(r.normalize?.(e),e[r.key]===void 0)continue;(!r.applies||r.applies(e))&&!r.preserved(e)&&t.push({key:r.key,message:r.describe(e),severity:r.severity}),delete e[r.key]}return t}function cs(e){e.command("update").description("Update AI skill files and stage template updates").allowExcessArguments(!0).option("--cwd <path>","project directory (default: current directory)").option("--answer <id=choice>","answer a question that would otherwise prompt (repeatable), e.g. --answer desktop-migration=keep-ai",tp,[]).action(async(t,r)=>{r.args.length>0&&b.log.warn("`auto` is now the default and the argument is ignored.");let n=rp(t.answer),i=ep(t.cwd??process.cwd()),o=We(i,he),s;try{s=JSON.parse(await as(o,"utf-8"))}catch{b.cancel(".generatesaas/manifest.json not found. Run this from a GenerateSaaS project."),process.exit(1)}s.frontend===ir&&(b.cancel(`This project was generated on the Nuxt frontend (\`.generatesaas/manifest.json\` records \`frontend: "${ir}"\`).
|
|
1692
1708
|
|
|
1693
|
-
v${
|
|
1709
|
+
v${sr} is the last release that ships a Nuxt frontend, and this CLI cannot update or migrate a Nuxt project to Next.js.
|
|
1694
1710
|
|
|
1695
|
-
Your project keeps working exactly as it is - stay on v${
|
|
1711
|
+
Your project keeps working exactly as it is - stay on v${sr}, or start a fresh project on Next.js with ${L.cyan("generatesaas init")} when you are ready to move.`),process.exit(1));let a=zr(s);a.length>0&&(b.cancel(`.generatesaas/manifest.json carries a value this CLI cannot shape a project from:
|
|
1696
1712
|
|
|
1697
1713
|
${a.map(m=>` - ${m}`).join(`
|
|
1698
1714
|
`)}
|
|
1699
1715
|
|
|
1700
|
-
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1)),
|
|
1716
|
+
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1)),ss(s);let d=er(s);d.length>0&&(b.cancel(`.generatesaas/manifest.json records a combination this project cannot run:
|
|
1701
1717
|
|
|
1702
1718
|
${d.map(m=>` - ${m}`).join(`
|
|
1703
1719
|
`)}
|
|
1704
1720
|
|
|
1705
|
-
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let h=
|
|
1721
|
+
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let h=Jr(s);h.length>0&&(b.cancel(`.generatesaas/manifest.json turns the web AI surface off (\`webAi: false\`) in a project that cannot run without it:
|
|
1706
1722
|
|
|
1707
1723
|
${h.map(m=>` - ${m}`).join(`
|
|
1708
1724
|
`)}
|
|
1709
1725
|
|
|
1710
|
-
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let g=
|
|
1726
|
+
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let g=Xr(s);g.length>0&&(b.cancel(`.generatesaas/manifest.json turns on mobile store purchases (\`mobilePurchases: "revenuecat"\`) in a project that cannot run them:
|
|
1711
1727
|
|
|
1712
1728
|
${g.map(m=>` - ${m}`).join(`
|
|
1713
1729
|
`)}
|
|
1714
1730
|
|
|
1715
|
-
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let w;try{w=await
|
|
1731
|
+
Correct it there, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let w;try{w=await it()}catch(m){b.cancel(N(m)),process.exit(1)}let v=ge(w),P=b.spinner();try{P.start("Verifying access...");let m;try{m=await Ie(v)}catch(A){throw A instanceof $&&A.status===401?new Error("Your saved API key was rejected. Run `generatesaas auth` to update it, or set GENERATESAAS_API_KEY."):A}let D=qt(m,"update");if(!D.ok)throw new Error(D.message);P.stop("Access verified."),$e(w),P.start("Fetching latest skill files...");let we=await cn(v,m.latest);await Nr(i,we.skills);let K=Ke;if(P.stop("Skills updated."),b.log.success(`Skill files installed to ${L.cyan(K.length.toString())} locations.`),s.version===m.latest){b.log.info(`Already on the latest version (${s.version}).`);return}Te(s.version,m.latest)>0&&(b.cancel(`This project is on ${s.version}, which is newer than the latest release (${m.latest}). Updating would stage the older template over it. Check that your API key points at the right account, or correct \`version\` in ${he}.`),process.exit(1));let ae=m.entitlements;ae&&(s.desktop&&!ae.desktopAllowed&&(b.cancel("This project includes the desktop app, which is available on the Pro plan and up. Upgrade your plan at generatesaas.com to keep updating it."),process.exit(1)),s.mobile&&ae.mobileAllowed===!1&&(b.cancel("This project includes the mobile app, which is available on the Pro plan and up. Upgrade your plan at generatesaas.com to keep updating it."),process.exit(1)));let Re=JSON.stringify(s);await np(s,Zr(s),n),await ip(os(s),n);let _e=Wr(s),Je=qr(s),ce=er(Je);ce.length>0&&(b.cancel(`.generatesaas/manifest.json is missing a provider key, and its default would create a combination this project cannot run:
|
|
1716
1732
|
|
|
1717
1733
|
${ce.map(A=>` - ${A}`).join(`
|
|
1718
1734
|
`)}
|
|
1719
1735
|
|
|
1720
|
-
Add the key to .generatesaas/manifest.json with a value your deployment target supports, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let E=JSON.stringify(
|
|
1721
|
-
`),E=!1,b.log.success("License refreshed.")}catch(A){let c=
|
|
1722
|
-
`);let _=
|
|
1723
|
-
`)}if(P.stop("Baseline template stored."),!
|
|
1724
|
-
`),P.stop("Baseline hashes computed.")}await l(
|
|
1725
|
-
`),b.log.info(`Update staged: ${L.cyan(s.version)} \u2192 ${L.cyan(m.latest)}`),b.log.info(`Ask your AI coding assistant to ${L.cyan("'update my GenerateSaaS project'")}.`),s.desktop===!0&&
|
|
1736
|
+
Add the key to .generatesaas/manifest.json with a value your deployment target supports, then run ${L.cyan("generatesaas update")} again.`),process.exit(1));let E=JSON.stringify(Je)!==Re;if(s=Je,s.licenseToken)try{let A=await pn(v,{currentToken:s.licenseToken,newVersion:m.latest});s.licenseToken=A.token,A.licenseKeyHash&&(s.licenseKeyHash=A.licenseKeyHash),await l(o,`${JSON.stringify(s,null," ")}
|
|
1737
|
+
`),E=!1,b.log.success("License refreshed.")}catch(A){let c=xt(A);c&&(b.cancel(c.message),process.exit(1)),b.log.warn("License refresh skipped.")}E&&await l(o,`${JSON.stringify(s,null," ")}
|
|
1738
|
+
`);let _=Ui(s,i),Q=We(i,sn);P.start(`Staging v${m.latest} (shaped for your config)...`),await Lr(v,m.latest,_,Q),P.stop("Template staged.");let{text:G,title:j}=await ap(v,m,s.version);G&&b.note(G,j);let ee=We(i,_t),H=We(i,Ot),ke=!Jt(H),Ee=!Jt(ee);if(ke){if(P.start("Building baseline template (one-time migration)..."),await Lr(v,s.version,_,H),Ee){let A=await lt(H);await l(ee,`${JSON.stringify(A,null," ")}
|
|
1739
|
+
`)}if(P.stop("Baseline template stored."),!Ee){let A=await cp(ee,H);A>0&&b.log.warn(`Rebuilt baseline differs from the original for ${A} file(s) (the CLI's shaping evolved since this project was scaffolded). Classification still follows the committed template-hashes.json; upstream diffs for those files may include unrelated noise.`)}}else if(Ee){P.start("Computing baseline template hashes...");let A=await lt(H);await l(ee,`${JSON.stringify(A,null," ")}
|
|
1740
|
+
`),P.stop("Baseline hashes computed.")}await l(We(i,on),`${JSON.stringify({currentVersion:s.version,targetVersion:m.latest,changelog:G,stagedAt:new Date().toISOString(),..._e.length>0?{newOptions:_e}:{}},null," ")}
|
|
1741
|
+
`),b.log.info(`Update staged: ${L.cyan(s.version)} \u2192 ${L.cyan(m.latest)}`),b.log.info(`Ask your AI coding assistant to ${L.cyan("'update my GenerateSaaS project'")}.`),s.desktop===!0&&Jt(We(i,"apps/tauri"))&&b.log.warn(`Desktop: the app moved to an Electron shell (${L.cyan("apps/desktop")}). Your project still has the old Tauri shell (${L.cyan("apps/tauri")}) - remove it after verifying the Electron app builds and runs. See ${L.cyan("/desktop/updates")}.`),s.desktop===!0&&Jt(We(i,"apps/electron"))&&b.log.warn(`Desktop: the app now lives at ${L.cyan("apps/desktop")}. Your project still has the old ${L.cyan("apps/electron")} directory - move your local changes across, remove it, then run ${L.cyan("pnpm install")}. See ${L.cyan("/desktop/updates")}.`)}catch(m){P.stop("Failed."),b.cancel(`Update failed: ${N(m)}`),process.exit(1)}})}var ls="desktop-migration";function tp(e,t){return t.push(e),t}function rp(e){let t=new Set([ls,...Object.keys(ds).map(n=>`retired:${n}`)]),r=new Map;for(let n of e){let i=n.indexOf("=");(i<=0||i===n.length-1)&&(b.cancel(`\`--answer ${n}\` is malformed. Use \`--answer <id>=<choice>\`.`),process.exit(1));let o=n.slice(0,i);t.has(o)||(b.cancel(`\`--answer ${n}\` names a question this update does not ask. Valid ids: ${[...t].join(", ")}.`),process.exit(1)),r.set(o,n.slice(i+1))}return r}async function ps(e,t){let r=t.get(e.id);if(r!==void 0){let n=e.choices.find(i=>i.id===r);return n||(b.cancel(`\`--answer ${e.id}=${r}\` is not a valid choice. Valid choices: ${e.choices.map(i=>i.id).join(", ")}.`),process.exit(1)),n}if(process.stdin.isTTY){let n=await b.select({message:e.message,options:e.choices.map(o=>({value:o.id,label:o.label,...o.hint?{hint:o.hint}:{}}))});b.isCancel(n)&&(b.cancel("Update cancelled. Nothing was changed or staged."),process.exit(1));let i=e.choices.find(o=>o.id===n);return i||(b.cancel("Update cancelled. Nothing was changed or staged."),process.exit(1)),i}b.cancel(`${e.message}
|
|
1726
1742
|
|
|
1727
1743
|
This update is running without an interactive terminal, so it cannot ask you directly. Answer the question \`${e.id}\` and re-run, for example:
|
|
1728
1744
|
|
|
@@ -1730,24 +1746,24 @@ This update is running without an interactive terminal, so it cannot ask you dir
|
|
|
1730
1746
|
|
|
1731
1747
|
Choices:
|
|
1732
1748
|
${e.choices.map(n=>` - ${n.id}: ${n.label}${n.hint?` (${n.hint})`:""}`).join(`
|
|
1733
|
-
`)}`),process.exit(1)}async function
|
|
1749
|
+
`)}`),process.exit(1)}async function np(e,t,r){if(t.kind==="none")return;let n=op[t.kind];(await ps({id:ls,message:n.message,choices:n.choices},r)).apply(e)}var ds={billingScope:[{value:"stage",label:"Stage it - I will move each organization's balance to its owner myself",hint:"move every paying organization's credits and plan onto its owner before you take new checkouts - the columns holding them are going away, and a stored balance is not migrated for you"},{value:"stop",label:"Stop, and stage nothing",hint:"migrate those balances in your database first, then re-run update - this is buyer DATA, not a config line, so nothing the update stages can put it back"}],emailTracking:[{value:"stage",label:"Stage it - I will turn tracking back off myself",hint:"set `config.email.tracking.enabled` to false in packages/config/src/index.ts before your next send"},{value:"stop",label:"Stop, and stage nothing",hint:"edit packages/config/src/index.ts first, then re-run update - a file you have edited is walked as a 3-way merge instead of being replaced"}]};async function ip(e,t){let r=[];for(let n of e){let i=n.severity==="ask"?ds[n.key]:void 0;if(!i){b.log.warn(n.message);continue}r.push({notice:n,answers:i})}for(let{notice:n,answers:i}of r)(await ps({id:`retired:${n.key}`,message:n.message,choices:i.map(s=>({id:s.value,label:s.label,hint:s.hint}))},t)).id==="stop"&&(b.cancel("Update cancelled. Nothing was changed or staged."),process.exit(1))}function sp(e){e.desktop=!1}var op={"legacy-desktop-ai":{message:"Your desktop app runs the local AI stack through the retired desktop-AI option. Desktop AI now follows the AI option. Keep it?",choices:[{id:"keep-ai",label:"Turn AI on, keep the desktop AI stack",hint:"also makes your website's AI surface available - keep it hidden with config.ai.enabled: false",apply:e=>{e.ai=!0}},{id:"drop-ai",label:"Turn desktop AI off",hint:"your desktop app becomes a client shell: no AI, no Terminal",apply:e=>{e.ai=!1}}]},"desktop-gains-ai":{message:"Your desktop app has no AI today, but your project has the AI option on. With the desktop-AI option retired, AI on a desktop project now ships the app's local AI stack. Keep it?",choices:[{id:"keep-ai",label:"Keep AI - the desktop app gains the AI stack",hint:"adds the local AI stack and the Terminal to your desktop app",apply:()=>{}},{id:"drop-ai",label:"Drop AI",hint:"your website's AI surface hides",apply:e=>{e.ai=!1}},{id:"drop-desktop",label:"Drop the desktop app",hint:"your website keeps its AI surface exactly as it is today",apply:sp}]}};async function ap(e,t,r){let n=t.latest,i=t.versions.filter(d=>Te(d.version,r)>0&&Te(d.version,n)<=0).sort((d,h)=>Te(d.version,h.version)),o=async d=>{try{return await an(e,d)}catch{return null}};if(i.length<=1)return{text:await o(n),title:`Changelog v${n}`};let s=await Promise.all(i.map(d=>o(d.version)));return{text:i.map((d,h)=>{let g=d.date?` (${d.date.slice(0,10)})`:"",w=s[h]??"_No changelog available for this release._";return`# v${d.version}${g}
|
|
1734
1750
|
|
|
1735
1751
|
${w}`}).join(`
|
|
1736
1752
|
|
|
1737
|
-
`),title:`Changelog v${r} \u2192 v${n}`}}async function
|
|
1738
|
-
`);B.note(o,
|
|
1739
|
-
`),O.yellow("License Details"))}function
|
|
1740
|
-
`).map(s=>s.trim()).filter(s=>s&&!s.startsWith("#"));i.length===0&&(x.cancel("No URLs found in file."),process.exit(1));let o=0;for(let s of i)await
|
|
1753
|
+
`),title:`Changelog v${r} \u2192 v${n}`}}async function cp(e,t){let r=JSON.parse(await as(e,"utf-8")),n=await lt(t),i=0;for(let[o,s]of Object.entries(r))st(o)||n[o]!==s&&i++;for(let o of Object.keys(n))o in r||i++;return i}import*as B from"@clack/prompts";import ve from"picocolors";import{readFile as lp}from"fs/promises";import{join as pp,resolve as dp}from"path";function us(e){e.command("status").description("Show project status and check for updates").option("--cwd <path>","project directory (default: current directory)").action(async t=>{let r=dp(t.cwd??process.cwd()),n=pp(r,he),i;try{i=JSON.parse(await lp(n,"utf-8"))}catch{B.cancel(".generatesaas/manifest.json not found. Run this from a GenerateSaaS project."),process.exit(1)}let o=[`Version: ${ve.cyan(i.version)}`,i.deploymentTarget?`Deploy target: ${ve.cyan(i.deploymentTarget)}`:null,i.databaseProvider?`Database: ${ve.cyan(i.databaseProvider)}`:null,i.cacheProvider?`Cache: ${ve.cyan(i.cacheProvider)}`:null].filter(Boolean).join(`
|
|
1754
|
+
`);B.note(o,ve.bold("Project Status"));let s=B.spinner();s.start("Checking for updates...");try{let a=await it(),d=ge(a),g=(await Ie(d)).latest;i.version===g?(s.stop("Up to date."),B.log.success(`Already on the latest version (${ve.green(g)})`)):(s.stop("Update available."),B.log.warning(`Update available: ${ve.yellow(i.version)} \u2192 ${ve.green(g)}`),B.log.info(`Open this project in your AI coding agent and ask it to ${ve.cyan("update my GenerateSaaS project")} - it fetches and applies the update for you.`))}catch(a){s.stop("Check failed."),a instanceof $&&a.status===401?B.log.warning("Invalid API key. Run `generatesaas auth` to update it, or set GENERATESAAS_API_KEY."):B.log.warning(`Could not check for updates: ${N(a)}`)}})}import{Buffer as up}from"buffer";import{readFile as mp}from"fs/promises";import*as x from"@clack/prompts";import O from"picocolors";function hp(){return process.env.GENERATESAAS_API_KEY??gt()}function fp(e){return{verdict:e.verdict,plan:e.license?.plan??e.domainInstalls.find(t=>t.ownerPlan)?.ownerPlan??null,mismatchDomain:e.install?.domain??null,ejectedAt:e.install?.ejectedAt??e.domainInstalls.find(t=>t.ejectedAt)?.ejectedAt??null}}function gp(e){return{verdict:e.verdict??"unknown",ejectedAt:e.ejectedAt??null}}function yp(e){switch(e.verdict){case"licensed":return x.log.success(`${O.green("LICENSED")} - resolves to an account with an active${e.plan?` ${e.plan}`:""} license.`),!0;case"ejected":return x.log.success(`${O.green("EJECTED")} - a licensed buyer opted this install out of telemetry${e.ejectedAt?` on ${e.ejectedAt.slice(0,10)}`:""}. The site is legitimate.`),!0;case"revoked":return x.log.error(`${O.red("REVOKED")} - the owning account no longer holds a plan (refund or chargeback). This deployment is no longer licensed.`),!1;case"token_domain_mismatch":return x.log.error(`${O.red("LEAKED TOKEN")} - this license belongs to a different deployment${e.mismatchDomain?` (${O.cyan(e.mismatchDomain)})`:""}, not this site. The token was copied from a licensed project.`),!1;case"no_license_history":return x.log.error(`${O.red("NO LICENSE HISTORY")} - no license has ever been associated with this site. If it runs GenerateSaaS, treat it as unlicensed.`),!1;default:return x.log.warn(`${O.yellow("UNKNOWN")} - could not cross-reference the records right now. Try again shortly.`),!1}}async function ms(e,t){let r=process.env.GENERATESAAS_API_URL??ft,n=hp();e.start("Cross-referencing license records...");try{let i=n?fp(await dn(r,n,{lkh:t.lkh,nid:t.nid,domain:t.domain})):gp(await cr(r,{token:t.token,domain:t.domain}));return e.stop(`${O.green("Checked")} - records cross-referenced`),yp(i)}catch(i){return e.stop(`${O.yellow("Skipped")} - ${N(i)}`),null}}function bp(e){let t=e.split(".");if(t.length!==3||!t[1])throw new Error("Invalid JWT format");let r=up.from(t[1],"base64url").toString("utf-8");return JSON.parse(r)}function jr(e){return typeof e!="number"?"unknown":new Date(e*1e3).toISOString().split("T")[0]}function hs(e){x.note([`License ID: ${O.cyan(String(e.lid??"unknown"))}`,`Version: ${O.cyan(String(e.ver??"unknown"))}`,`Init version: ${String(e.iver??"unknown")}`,`Frontend: ${String(e.fe??"unknown")}`,`Created: ${jr(e.pat)}`,`Last updated: ${jr(e.uat)}`,`Expires: ${jr(e.exp)}`,`Install ID: ${String(e.nid??"unknown")}`].join(`
|
|
1755
|
+
`),O.yellow("License Details"))}function vp(e){let r=(/^https?:\/\//i.test(e)?e:`https://${e}`).replace(/\/+$/,"");if(r.endsWith("/api"))return[`${r}/license`];try{if(new URL(r).pathname!=="/")return[`${r}/license`]}catch{return[`${r}/license`]}return[`${r}/api/license`,`${r}/license`]}async function fs(e){let t=x.spinner(),r=null,n="no candidates";for(let s of vp(e)){t.start(`Checking ${s}...`);try{let a=await fetch(s);if(!a.ok){n=`${s} returned ${a.status}`,t.stop(`${O.yellow("Not here")} - ${n}`);continue}let d=(await a.text()).trim();if(!d||d.split(".").length!==3){n=`${s} did not return a JWT`,t.stop(`${O.yellow("Not here")} - ${n}`);continue}r=d,t.stop(`${O.green("Found")} - license endpoint responded`);break}catch(a){n=`${s}: ${N(a)}`,t.stop(`${O.yellow("Unreachable")} - ${n}`)}}if(r===null){x.log.warn(`No license endpoint found (last: ${n}). The site may be ejected, not a GenerateSaaS app, or serving its API elsewhere.`);let s=gs(e);return s?await ms(t,{domain:s})??!1:!1}let i;try{i=bp(r)}catch{return x.log.error("Could not decode JWT payload."),!1}t.start("Verifying signature...");try{let s=process.env.GENERATESAAS_API_URL??ft,a=await cr(s,{token:r});if(a.valid)t.stop(`${O.green("Valid")} - signature verified`);else return t.stop(`${O.red("Invalid")} - ${a.reason}`),!1}catch{return t.stop(`${O.yellow("Skipped")} - could not reach verification service`),x.log.warn("Signature not verified. Displaying unverified claims:"),hs(i),!1}return hs(i),await ms(t,{token:r,lkh:typeof i.lkh=="string"?i.lkh:void 0,nid:typeof i.nid=="string"?i.nid:void 0,domain:gs(e)})??!0}function gs(e){try{return new URL(/^https?:\/\//i.test(e)?e:`https://${e}`).hostname}catch{return}}function ys(e){e.command("verify").description("Verify a GenerateSaaS license on a deployed site").argument("[url]","URL of the site to verify (e.g. https://example.com or https://example.com/api)").option("--file <path>","file with URLs to check, one per line").action(async(t,r)=>{if(!t&&!r.file&&(x.cancel("Provide a URL or --file <path>."),process.exit(1)),r.file){let i=(await mp(r.file,"utf-8")).split(`
|
|
1756
|
+
`).map(s=>s.trim()).filter(s=>s&&!s.startsWith("#"));i.length===0&&(x.cancel("No URLs found in file."),process.exit(1));let o=0;for(let s of i)await fs(s)&&o++,x.log.info("");x.log.success(`${o}/${i.length} sites verified.`)}else await fs(t)||process.exit(1)})}import{existsSync as wp,rmSync as kp}from"fs";import*as Z from"@clack/prompts";function bs(e){e.command("auth").description("Set or update your GenerateSaaS API key").option("--clear","remove saved API key").action(async t=>{if(t.clear){wp(fe)?(kp(fe),Z.log.success("API key removed.")):Z.log.info("No API key configured.");return}let r=gt();r?Z.log.info(`Current API key: ****${r.slice(-4)}`):Z.log.info("No API key configured.");let n=await yt(),i=ge(n),o=Z.spinner();o.start("Verifying API key...");try{await Ie(i),o.stop("API key verified."),$e(n),Z.log.success("API key saved.")}catch(s){o.stop("Verification failed."),s instanceof $&&s.status===401?Z.cancel("Invalid API key."):Z.cancel(N(s)),process.exit(1)}})}import{existsSync as Xt,readFileSync as Ur,rmSync as Ep,writeFileSync as vs}from"fs";import{join as ze}from"path";import*as U from"@clack/prompts";var Sp=["packages/api/src/functions/maintenance/license-heartbeat.ts","packages/api/src/lib/cron-spread.ts","packages/api/src/lib/manifest.ts","packages/api/src/routes/internal/license.ts","packages/api/tests/functions/license-heartbeat.test.ts","packages/api/tests/lib/cron-spread.test.ts"],Pp=[{file:"packages/api/src/routes/inngest.ts",removals:[`import { licenseHeartbeatFunction } from "../functions/maintenance/license-heartbeat";
|
|
1741
1757
|
`,` licenseHeartbeatFunction,
|
|
1742
1758
|
`]},{file:"packages/api/src/routes/internal/index.ts",removals:[`import licenseRoutes from "./license";
|
|
1743
1759
|
`,` .route("/license", licenseRoutes)
|
|
1744
1760
|
`]},{file:"packages/api/src/routes/public/v1/health.ts",removals:[`import { manifest } from "../../../lib/manifest";
|
|
1745
|
-
`],replacements:[{from:'c.json({ status: "success" as const, version: manifest?.version })',to:'c.json({ status: "success" as const })'}]}];function
|
|
1761
|
+
`],replacements:[{from:'c.json({ status: "success" as const, version: manifest?.version })',to:'c.json({ status: "success" as const })'}]}];function Ap(){return Ke.flatMap(e=>Dr.map(t=>ze(e,t)))}function Mr(e){return Xt(e)?(Ep(e,{recursive:!0}),!0):!1}function Ip(e,t,r=[]){if(!Xt(e))return!1;let n=Ur(e,"utf-8"),i=n;for(let o of t)i=i.replace(o,"");for(let{from:o,to:s}of r)i=i.replace(o,s);return i===n?!1:(vs(e,i,"utf-8"),!0)}function Tp(e){let t=ze(e,".gitignore");if(!Xt(t))return!1;let r=Ur(t,"utf-8"),n=r.split(`
|
|
1746
1762
|
`).filter(i=>!i.includes(".generatesaas")).join(`
|
|
1747
|
-
`);return n===r?!1:(
|
|
1763
|
+
`);return n===r?!1:(vs(t,n,"utf-8"),!0)}function ws(e){e.command("eject").description("Remove all GenerateSaaS ties - manifest, license, heartbeat, skills").action(async()=>{let t=process.cwd(),r=ze(t,he),n;try{n=JSON.parse(Ur(r,"utf-8"))}catch{U.cancel("No GenerateSaaS project found in this directory."),process.exit(1)}let i=await U.text({message:'Type "eject" to confirm (this cannot be undone):',validate:a=>{if(a!=="eject")return'Type "eject" to confirm, or press Ctrl+C to cancel.'}});if(U.isCancel(i)&&(U.cancel("Eject cancelled."),process.exit(0)),n.licenseToken)try{await fetch("https://generatesaas.com/api/v1/heartbeat",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${n.licenseToken}`},body:JSON.stringify({event:"eject",version:n.version,frontend:n.frontend}),signal:AbortSignal.timeout(5e3)}),U.log.info("Recorded the opt-out with generatesaas.com (final event - nothing is sent after this).")}catch{U.log.warn("Could not reach generatesaas.com to record the opt-out. Ejecting anyway.")}let o=[],s=[];for(let a of Ap())Mr(ze(t,a))&&o.push(a);for(let a of Sp)Mr(ze(t,a))&&o.push(a);Mr(ze(t,Y))&&o.push(`${Y}/`);for(let a of Pp){let d=ze(t,a.file);Ip(d,a.removals,a.replacements)?s.push(a.file):Xt(d)&&U.log.warn(`Could not auto-modify ${a.file} - manually remove license/heartbeat references.`)}Tp(t)&&s.push(".gitignore");for(let a of o)U.log.info(`Deleted ${a}`);for(let a of s)U.log.info(`Modified ${a}`);U.log.success("Ejected successfully. This project is now fully standalone.")})}var qe=new Rp().name("generatesaas").description("CLI for scaffolding and managing GenerateSaaS projects").version("3.25.1").addHelpText("after",`
|
|
1748
1764
|
Examples:
|
|
1749
1765
|
$ generatesaas init Interactive setup
|
|
1750
1766
|
$ generatesaas init -n my-app -y Quick setup with defaults
|
|
1751
1767
|
$ generatesaas status Check for updates
|
|
1752
1768
|
$ generatesaas auth Set or update API key
|
|
1753
|
-
`);
|
|
1769
|
+
`);ts(qe);cs(qe);us(qe);ys(qe);bs(qe);ws(qe);qe.parseAsync().catch(e=>{ks.cancel("An unexpected error occurred."),console.error(e),process.exit(1)});
|