create-payload-stack 0.3.0 → 0.5.0

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +409 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,11 +6,11 @@ Scaffold a SaaS on Payload CMS in one command.
6
6
  npx create-payload-stack@latest
7
7
  ```
8
8
 
9
- Seven prompts: project name, database (PostgreSQL, MongoDB, SQLite, Vercel Postgres), connection string, sign-in methods, organizations, billing, media storage (skip for local disk, or Vercel Blob, AWS S3, Cloudflare R2, Azure Blob Storage, Google Cloud Storage, Uploadthing). The CLI downloads [Payload Stack](https://www.payloadstack.com), writes your answers into `src/stack.config.ts`, swaps the Payload database adapter, wires the storage adapter into `payload.config.ts`, generates `.env` with fresh secrets, installs dependencies and initializes git.
9
+ Nine prompts: project name, database (PostgreSQL, MongoDB, SQLite, Vercel Postgres), connection string, sign-in methods, organizations, billing, media storage (skip for local disk, or Vercel Blob, AWS S3, Cloudflare R2, Azure Blob Storage, Google Cloud Storage, Uploadthing), transactional emails (edit copy in the Payload admin, or keep React Email components in code) and cookie consent (banner with audited legal pages, or plain legal pages). The CLI downloads [Payload Stack](https://www.payloadstack.com), writes your answers into `src/stack.config.ts`, swaps the Payload database adapter, wires the storage adapter into `payload.config.ts`, generates `.env` with fresh secrets, installs dependencies and initializes git.
10
10
 
11
11
  ```bash
12
12
  # non-interactive
13
- npx create-payload-stack@latest ridgeline -d postgres --auth email-password,magic-link,passkey --social github --billing organization --storage vercel-blob -y
13
+ npx create-payload-stack@latest ridgeline -d postgres --auth email-password,magic-link,passkey --social github --billing organization --storage vercel-blob --emails --consent -y
14
14
  ```
15
15
 
16
16
  Run `npx create-payload-stack@latest --help` for every flag. Documentation: https://payload.solutions/docs/payload-stack
package/dist/index.js CHANGED
@@ -68,6 +68,120 @@ function defaultConnectionString(db, projectSlug) {
68
68
  return `${db.connectionPrefix}${projectSlug}${db.connectionSuffix}`;
69
69
  }
70
70
 
71
+ // src/scheduler.ts
72
+ import { cp } from "fs/promises";
73
+ import { existsSync } from "fs";
74
+ import path2 from "path";
75
+
76
+ // src/variants.ts
77
+ import { rm } from "fs/promises";
78
+ import path from "path";
79
+ var VARIANT_DIR = "variants";
80
+ function variantPath(step) {
81
+ return path.join(VARIANT_DIR, step);
82
+ }
83
+ async function removeVariants(directory) {
84
+ await rm(path.join(directory, VARIANT_DIR), { force: true, recursive: true });
85
+ }
86
+
87
+ // src/scheduler.ts
88
+ var SCHEDULER_PACKAGE = "@payload-solutions/plugin-action-scheduler";
89
+ var SCHEDULER_VERSION = "0.2.0";
90
+ var VARIANT_SCHEDULER = variantPath("scheduler-plugin");
91
+ var SEAM_FILES = ["plugin.ts", "jobs.ts", "hooks.ts"];
92
+ var ACTION_DIR = "actions";
93
+ var RUNNER_KEYS = ["clock", "vercel", "server", "later"];
94
+ var DOCS = "https://payload.solutions/docs/payload-stack/scheduling";
95
+ var ALTERNATIVES = [
96
+ "# - Payload Clock: https://payloadclock.com (free; retries and alerts, works on any host)",
97
+ '# - Vercel cron: a "crons" entry in vercel.json hitting the same path',
98
+ "# - Long-lived server: RUN_JOBS_IN_PROCESS=true (never on serverless)",
99
+ "# - Any cron you already have: curl the endpoint with the CRON_SECRET bearer token",
100
+ "# - By hand: Scheduled Actions -> Run queue, in the admin",
101
+ `# ${DOCS}`
102
+ ];
103
+ var RUNNER_CHOICES = {
104
+ clock: {
105
+ label: "Payload Clock",
106
+ hint: "free hosted clock, retries and alerts, any host",
107
+ cronSecret: true,
108
+ inProcess: false,
109
+ next: () => [
110
+ "# scheduled actions need a clock. Sign up at https://payloadclock.com (free), point it at",
111
+ "# POST/GET /api/payload-jobs/run?allQueues=true with the CRON_SECRET in .env",
112
+ "# or use one of the others:",
113
+ ...ALTERNATIVES.slice(1)
114
+ ]
115
+ },
116
+ vercel: {
117
+ label: "Vercel cron",
118
+ hint: "vercel.json; once a minute on Pro, once a day on Hobby",
119
+ cronSecret: true,
120
+ inProcess: false,
121
+ next: () => [
122
+ "# vercel.json runs the queue every minute (Hobby plans get one run a day, which is coarse",
123
+ "# for a scheduler: https://payloadclock.com is free and runs every minute on any plan)",
124
+ "# add CRON_SECRET from .env to the Vercel project: Vercel sends it as the bearer token",
125
+ ...ALTERNATIVES
126
+ ]
127
+ },
128
+ server: {
129
+ label: "This server",
130
+ hint: "RUN_JOBS_IN_PROCESS=true; docker, Fly, Railway, a VM",
131
+ cronSecret: true,
132
+ inProcess: true,
133
+ next: () => [
134
+ "# RUN_JOBS_IN_PROCESS=true in .env runs the queue in this process, every minute.",
135
+ "# Never set it on Vercel or another serverless host, and run one instance with it.",
136
+ ...ALTERNATIVES
137
+ ]
138
+ },
139
+ later: {
140
+ label: "Decide later",
141
+ hint: "nothing runs the queue; the admin Run queue button still does",
142
+ cronSecret: false,
143
+ inProcess: false,
144
+ next: () => [
145
+ "# nothing runs the queue yet, so scheduled actions wait. Pick one when you deploy:",
146
+ ...ALTERNATIVES
147
+ ]
148
+ }
149
+ };
150
+ function swapSchedulerPackages(packageJson, enabled) {
151
+ const deps = { ...packageJson.dependencies };
152
+ const devDeps = { ...packageJson.devDependencies };
153
+ delete devDeps[SCHEDULER_PACKAGE];
154
+ if (enabled) deps[SCHEDULER_PACKAGE] = SCHEDULER_VERSION;
155
+ else delete deps[SCHEDULER_PACKAGE];
156
+ return { ...packageJson, dependencies: sortKeys(deps), devDependencies: sortKeys(devDeps) };
157
+ }
158
+ function sortKeys(obj) {
159
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
160
+ }
161
+ async function applySchedulerChoice(directory, enabled) {
162
+ if (!enabled) return;
163
+ const variant = path2.join(directory, VARIANT_SCHEDULER);
164
+ if (!existsSync(variant)) {
165
+ throw new Error(`The template is missing ${VARIANT_SCHEDULER}; it cannot scaffold with the scheduler.`);
166
+ }
167
+ for (const name of SEAM_FILES) {
168
+ await cp(path2.join(variant, name), path2.join(directory, "src/scheduler", name));
169
+ }
170
+ await cp(path2.join(variant, ACTION_DIR), path2.join(directory, "src/scheduler", ACTION_DIR), { recursive: true });
171
+ await cp(path2.join(variant, "tests/scheduler.spec.ts"), path2.join(directory, "tests/unit/scheduler.spec.ts"));
172
+ }
173
+ function renderVercelJson() {
174
+ return `${JSON.stringify(
175
+ {
176
+ $schema: "https://openapi.vercel.sh/vercel.json",
177
+ crons: [{ path: "/api/payload-jobs/run?allQueues=true", schedule: "* * * * *" }]
178
+ },
179
+ null,
180
+ 2
181
+ )}
182
+ `;
183
+ }
184
+
71
185
  // src/storage.ts
72
186
  var LOCAL_STORAGE_CONFIG = `// Local disk (./media): fine for development, lost on redeploy on Vercel and other ephemeral hosts.
73
187
  // Move uploads to Vercel Blob, S3, R2, Azure, GCS or Uploadthing: https://payload.solutions/docs/payload-stack/storage`;
@@ -249,6 +363,13 @@ function helpText(version2) {
249
363
  --no-organizations Disable organizations
250
364
  --billing <mode> organization | user | none
251
365
  --storage <adapter> ${STORAGE_KEYS.join(" | ")} (none = local disk)
366
+ --emails Edit transactional email copy in the Payload admin
367
+ --no-emails Keep emails as React Email components in code
368
+ --consent Cookie banner and audited legal pages (Payload Consent)
369
+ --no-consent Plain legal pages, no banner
370
+ --scheduler Scheduled and recurring actions (Payload Action Scheduler)
371
+ --no-scheduler No scheduler
372
+ --runner <kind> ${RUNNER_KEYS.join(" | ")} (what runs the queue; default: clock)
252
373
  --use-pnpm | --use-npm | --use-yarn | --use-bun
253
374
  --no-install Skip dependency installation
254
375
  --no-git Skip git init
@@ -281,6 +402,10 @@ function parse(argv) {
281
402
  organizations: { type: "boolean" },
282
403
  billing: { type: "string" },
283
404
  storage: { type: "string" },
405
+ emails: { type: "boolean" },
406
+ consent: { type: "boolean" },
407
+ scheduler: { type: "boolean" },
408
+ runner: { type: "string" },
284
409
  "use-pnpm": { type: "boolean" },
285
410
  "use-npm": { type: "boolean" },
286
411
  "use-yarn": { type: "boolean" },
@@ -306,6 +431,10 @@ function parse(argv) {
306
431
  organizations: values.organizations,
307
432
  billing: values.billing,
308
433
  storage: values.storage,
434
+ emails: values.emails,
435
+ consent: values.consent,
436
+ scheduler: values.scheduler,
437
+ runner: values.runner,
309
438
  packageManager,
310
439
  install: values.install ?? true,
311
440
  git: values.git ?? true,
@@ -323,18 +452,161 @@ function parse(argv) {
323
452
  // src/cli.ts
324
453
  import * as p from "@clack/prompts";
325
454
  import { execa as execa2 } from "execa";
326
- import { existsSync as existsSync3 } from "fs";
327
- import path3 from "path";
455
+ import { existsSync as existsSync6 } from "fs";
456
+ import path7 from "path";
328
457
  import pc2 from "picocolors";
329
458
 
330
459
  // src/configure.ts
331
460
  import { readFile, writeFile } from "fs/promises";
332
- import path from "path";
461
+ import path5 from "path";
462
+
463
+ // src/consent.ts
464
+ import { cp as cp2, rm as rm2, rmdir } from "fs/promises";
465
+ import { existsSync as existsSync2 } from "fs";
466
+ import path3 from "path";
467
+ var CONSENT_PACKAGE = "@payload-solutions/plugin-consent";
468
+ var CONSENT_REACT_PACKAGE = "@payload-solutions/consent-react";
469
+ var CONSENT_VERSION = "0.1.0";
470
+ var CONSENT_PACKAGES = {
471
+ [CONSENT_PACKAGE]: CONSENT_VERSION,
472
+ [CONSENT_REACT_PACKAGE]: CONSENT_VERSION
473
+ };
474
+ var VARIANT_CONSENT = variantPath("consent-plugin");
475
+ var SEAM_FILES2 = [
476
+ "plugin.ts",
477
+ "collections.ts",
478
+ "seed.ts",
479
+ "consent-root.tsx",
480
+ "consent-settings-link.tsx",
481
+ "legal-page.tsx",
482
+ "legal-setup-notice.tsx"
483
+ ];
484
+ var BANNER_FILES = ["consent-banner.tsx", "consent-preferences-dialog.tsx"];
485
+ var TEMPLATE_LEGAL_FILES = ["src/collections/LegalPages.ts", "src/seed/legal.ts", "tests/unit/seed-legal.spec.ts"];
486
+ function swapConsentPackages(packageJson, enabled) {
487
+ const deps = { ...packageJson.dependencies };
488
+ const devDeps = { ...packageJson.devDependencies };
489
+ for (const pkg of Object.keys(CONSENT_PACKAGES)) {
490
+ delete devDeps[pkg];
491
+ if (enabled) deps[pkg] = CONSENT_PACKAGES[pkg];
492
+ else delete deps[pkg];
493
+ }
494
+ return { ...packageJson, dependencies: sortKeys2(deps), devDependencies: sortKeys2(devDeps) };
495
+ }
496
+ function sortKeys2(obj) {
497
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
498
+ }
499
+ async function applyConsentChoice(directory, enabled) {
500
+ if (!enabled) return;
501
+ const variant = path3.join(directory, VARIANT_CONSENT);
502
+ if (!existsSync2(variant)) {
503
+ throw new Error(`The template is missing ${VARIANT_CONSENT}; it cannot scaffold with consent.`);
504
+ }
505
+ for (const name of [...SEAM_FILES2, ...BANNER_FILES]) {
506
+ await cp2(path3.join(variant, name), path3.join(directory, "src/consent", name), { recursive: true });
507
+ }
508
+ await cp2(path3.join(variant, "tests/consent.spec.ts"), path3.join(directory, "tests/unit/consent.spec.ts"));
509
+ for (const name of TEMPLATE_LEGAL_FILES) {
510
+ await rm2(path3.join(directory, name), { force: true, recursive: true });
511
+ }
512
+ await rmdir(path3.join(directory, "src/seed")).catch(() => {
513
+ });
514
+ }
515
+
516
+ // src/emails.ts
517
+ import { cp as cp3, rm as rm3 } from "fs/promises";
518
+ import { existsSync as existsSync3 } from "fs";
519
+ import path4 from "path";
520
+ var EMAILS_PACKAGE = "@payload-solutions/plugin-emails";
521
+ var EMAILS_VERSION = "0.1.0";
522
+ var REACT_EMAIL_COMPONENTS = ["@react-email/components", "^1.0.12"];
523
+ var REACT_EMAIL_ONLY_PACKAGES = {
524
+ "@react-email/render": "^2.1.0",
525
+ "react-email": "^6.9.3"
526
+ };
527
+ var EMAIL_DEV_SCRIPT = ["email:dev", "email dev --dir src/components/auth/email --port 3001"];
528
+ var VARIANT_EMAILS = variantPath("emails-plugin");
529
+ var IMPORT_MARKER = "// emails-plugin-import";
530
+ var CONFIG_START = "// emails-plugin-config-start";
531
+ var CONFIG_END = "// emails-plugin-config-end";
532
+ var IMPORTS = `${IMPORT_MARKER}
533
+ import { emailsPlugin } from '${EMAILS_PACKAGE}'
534
+ import { emailDefinitions } from '@/emails/definitions'
535
+ import { StackEmailTemplate } from '@/emails/template'
536
+ import { globalVariableManifest, globalVariables } from '@/emails/variables'`;
537
+ var REGISTRATION = `plugins.push(
538
+ emailsPlugin({
539
+ // The catalogue, filtered by the features in stack.config.ts (src/emails/definitions).
540
+ emails: emailDefinitions,
541
+ // Branding lives in code; editors own the words (src/emails/template.tsx).
542
+ templates: { default: StackEmailTemplate },
543
+ // {{site.name}}, {{support.email}}, {{url.dashboard}} \u2026 in every email (src/emails/variables.ts).
544
+ globalVariables,
545
+ globalVariableManifest,
546
+ // Every send recorded, for 90 days. Set enabled: false to keep no record at all.
547
+ log: { enabled: true, retentionDays: 90 },
548
+ settings: { mediaCollection: 'media' },
549
+ }),
550
+ )`;
551
+ function addEmailsPlugin(payloadConfigSource) {
552
+ if (!payloadConfigSource.includes(IMPORT_MARKER)) {
553
+ throw new Error(`payload.config.ts is missing the "${IMPORT_MARKER}" marker`);
554
+ }
555
+ const start = payloadConfigSource.indexOf(CONFIG_START);
556
+ const end = payloadConfigSource.indexOf(CONFIG_END);
557
+ if (start === -1 || end === -1 || end < start) {
558
+ throw new Error(`payload.config.ts is missing the emails plugin markers`);
559
+ }
560
+ const withImports = payloadConfigSource.replace(IMPORT_MARKER, IMPORTS);
561
+ const at = withImports.indexOf(CONFIG_START);
562
+ const to = withImports.indexOf(CONFIG_END);
563
+ return `${withImports.slice(0, at)}${CONFIG_START}
564
+ ${REGISTRATION}
565
+ ${withImports.slice(to)}`;
566
+ }
567
+ function swapEmailsPackages(packageJson, enabled) {
568
+ const deps = { ...packageJson.dependencies };
569
+ const devDeps = { ...packageJson.devDependencies };
570
+ const scripts = { ...packageJson.scripts };
571
+ delete devDeps[EMAILS_PACKAGE];
572
+ delete devDeps[REACT_EMAIL_COMPONENTS[0]];
573
+ if (enabled) {
574
+ deps[EMAILS_PACKAGE] = EMAILS_VERSION;
575
+ deps[REACT_EMAIL_COMPONENTS[0]] = REACT_EMAIL_COMPONENTS[1];
576
+ for (const pkg of Object.keys(REACT_EMAIL_ONLY_PACKAGES)) delete deps[pkg];
577
+ delete scripts[EMAIL_DEV_SCRIPT[0]];
578
+ } else {
579
+ delete deps[EMAILS_PACKAGE];
580
+ delete deps[REACT_EMAIL_COMPONENTS[0]];
581
+ Object.assign(deps, REACT_EMAIL_ONLY_PACKAGES);
582
+ scripts[EMAIL_DEV_SCRIPT[0]] = EMAIL_DEV_SCRIPT[1];
583
+ }
584
+ return { ...packageJson, dependencies: sortKeys3(deps), devDependencies: sortKeys3(devDeps), scripts: sortKeys3(scripts) };
585
+ }
586
+ function sortKeys3(obj) {
587
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
588
+ }
589
+ var REACT_EMAIL_FILES = ["src/emails/send.ts", "src/components/auth/email"];
590
+ async function applyEmailsChoice(directory, enabled) {
591
+ const variant = path4.join(directory, VARIANT_EMAILS);
592
+ if (enabled) {
593
+ if (!existsSync3(variant)) {
594
+ throw new Error(`The template is missing ${VARIANT_EMAILS}; it cannot scaffold with emails.`);
595
+ }
596
+ for (const name of ["index.ts", "hooks.ts", "template.tsx", "variables.ts", "definitions"]) {
597
+ await cp3(path4.join(variant, name), path4.join(directory, "src/emails", name), { recursive: true });
598
+ }
599
+ await cp3(path4.join(variant, "tests/emails.spec.ts"), path4.join(directory, "tests/unit/emails.spec.ts"));
600
+ for (const name of REACT_EMAIL_FILES) {
601
+ await rm3(path4.join(directory, name), { force: true, recursive: true });
602
+ }
603
+ }
604
+ }
333
605
 
334
606
  // src/utils.ts
335
607
  import slugifyBase from "@sindresorhus/slugify";
336
608
  import { randomBytes } from "crypto";
337
- import { existsSync, readdirSync } from "fs";
609
+ import { existsSync as existsSync4, readdirSync } from "fs";
338
610
  import { execa } from "execa";
339
611
  function slugify(input) {
340
612
  return slugifyBase(input.trim()).replace(/^-+|-+$/g, "") || "my-saas";
@@ -343,7 +615,7 @@ function generateSecret(bytes = 32) {
343
615
  return randomBytes(bytes).toString("base64url");
344
616
  }
345
617
  function isDirectoryEmpty(dir) {
346
- if (!existsSync(dir)) return true;
618
+ if (!existsSync4(dir)) return true;
347
619
  const entries = readdirSync(dir).filter((e) => ![".git", ".DS_Store"].includes(e));
348
620
  return entries.length === 0;
349
621
  }
@@ -408,6 +680,18 @@ function renderStackConfig(o) {
408
680
  },
409
681
  ],
410
682
  },`;
683
+ const legalBlock = o.consent ? ` legal: {
684
+ company: '${o.name.replace(/'/g, "\\'")} Ltd',
685
+ jurisdiction: 'Ireland',
686
+ // The seeded legal pages are written from here. Fill these in before you publish them:
687
+ // \`npx payload-consent scan\` lists every value still left as a placeholder.
688
+ // https://payload.solutions/docs/plugins/payload-consent/audit
689
+ legalName: '${o.name.replace(/'/g, "\\'")} Ltd',
690
+ address: '[REGISTERED ADDRESS]',
691
+ },` : ` legal: {
692
+ company: '${o.name.replace(/'/g, "\\'")} Ltd',
693
+ jurisdiction: 'Ireland',
694
+ },`;
411
695
  return `import { defineStack } from '@/lib/stack'
412
696
 
413
697
  /**
@@ -450,10 +734,7 @@ ${billingBlock}
450
734
  sendPII: false,
451
735
  },
452
736
 
453
- legal: {
454
- company: '${o.name.replace(/'/g, "\\'")} Ltd',
455
- jurisdiction: 'Ireland',
456
- },
737
+ ${legalBlock}
457
738
 
458
739
  nav: [${o.billing !== "none" ? "{ label: 'Pricing', href: '/pricing' }, " : ""}{ label: 'Docs', href: 'https://payload.solutions/docs/payload-stack' }],
459
740
 
@@ -461,24 +742,24 @@ ${billingBlock}
461
742
  })
462
743
  `;
463
744
  }
464
- var IMPORT_MARKER = "// database-adapter-import";
465
- var CONFIG_START = "// database-adapter-config-start";
466
- var CONFIG_END = "// database-adapter-config-end";
745
+ var IMPORT_MARKER2 = "// database-adapter-import";
746
+ var CONFIG_START2 = "// database-adapter-config-start";
747
+ var CONFIG_END2 = "// database-adapter-config-end";
467
748
  function swapDatabaseAdapter(payloadConfigSource, db) {
468
- const importLine = `${IMPORT_MARKER}
749
+ const importLine = `${IMPORT_MARKER2}
469
750
  import { ${db.importName} } from '${db.packageName}'`;
470
751
  const withImport = payloadConfigSource.replace(
471
- new RegExp(`${escapeRegExp(IMPORT_MARKER)}\\nimport [^\\n]+\\n`),
752
+ new RegExp(`${escapeRegExp(IMPORT_MARKER2)}\\nimport [^\\n]+\\n`),
472
753
  `${importLine}
473
754
  `
474
755
  );
475
756
  if (withImport === payloadConfigSource && !payloadConfigSource.includes(importLine)) {
476
- throw new Error(`payload.config.ts is missing the "${IMPORT_MARKER}" marker`);
757
+ throw new Error(`payload.config.ts is missing the "${IMPORT_MARKER2}" marker`);
477
758
  }
478
- const start = withImport.indexOf(CONFIG_START);
479
- const end = withImport.indexOf(CONFIG_END);
759
+ const start = withImport.indexOf(CONFIG_START2);
760
+ const end = withImport.indexOf(CONFIG_END2);
480
761
  if (start === -1 || end === -1) throw new Error(`payload.config.ts is missing the database adapter markers`);
481
- return `${withImport.slice(0, start)}${CONFIG_START}
762
+ return `${withImport.slice(0, start)}${CONFIG_START2}
482
763
  ${db.config}
483
764
  ${withImport.slice(end)}`;
484
765
  }
@@ -523,7 +804,7 @@ function swapStoragePackage(packageJson, storage) {
523
804
  if (pkg !== storage?.packageName) delete deps[pkg];
524
805
  }
525
806
  if (storage) deps[storage.packageName] = payloadVersion;
526
- return { ...packageJson, dependencies: sortKeys(deps) };
807
+ return { ...packageJson, dependencies: sortKeys4(deps) };
527
808
  }
528
809
  function storageChoice(key) {
529
810
  return key === "none" ? null : STORAGE_CHOICES[key];
@@ -535,7 +816,16 @@ function swapDatabasePackage(packageJson, db) {
535
816
  if (other.packageName !== db.packageName) delete deps[other.packageName];
536
817
  }
537
818
  deps[db.packageName] = payloadVersion;
538
- return { ...packageJson, dependencies: sortKeys(deps) };
819
+ return { ...packageJson, dependencies: sortKeys4(deps) };
820
+ }
821
+ function stripWorkspaceDependencies(packageJson) {
822
+ const next = { ...packageJson };
823
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
824
+ const deps = next[field];
825
+ if (!deps) continue;
826
+ next[field] = Object.fromEntries(Object.entries(deps).filter(([, version2]) => !version2.startsWith("workspace:")));
827
+ }
828
+ return next;
539
829
  }
540
830
  function renameProject(packageJson, slug) {
541
831
  const next = { ...packageJson, name: slug, version: "0.1.0", private: true };
@@ -549,7 +839,10 @@ function renderEnv(example, o) {
549
839
  DATABASE_URL: o.connectionString,
550
840
  PAYLOAD_SECRET: generateSecret(24),
551
841
  BETTER_AUTH_SECRET: generateSecret(32),
552
- NEXT_PUBLIC_APP_URL: o.appUrl ?? "http://localhost:3000"
842
+ NEXT_PUBLIC_APP_URL: o.appUrl ?? "http://localhost:3000",
843
+ // A clock needs a secret to get past jobs.access.run; without one that endpoint is admin-only.
844
+ ...o.cronSecret ? { CRON_SECRET: generateSecret(32) } : {},
845
+ ...o.inProcessRunner ? { RUN_JOBS_IN_PROCESS: "true" } : {}
553
846
  };
554
847
  const lines = example.split("\n").map((line) => {
555
848
  const m = /^#?\s?([A-Z0-9_]+)=(.*)$/.exec(line);
@@ -560,7 +853,7 @@ function renderEnv(example, o) {
560
853
  });
561
854
  return lines.join("\n");
562
855
  }
563
- function sortKeys(obj) {
856
+ function sortKeys4(obj) {
564
857
  return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
565
858
  }
566
859
  function escapeRegExp(s) {
@@ -568,19 +861,42 @@ function escapeRegExp(s) {
568
861
  }
569
862
  async function configureProject(o) {
570
863
  const db = DB_CHOICES[o.db];
571
- const read = (rel) => readFile(path.join(o.directory, rel), "utf8");
572
- const write = (rel, content) => writeFile(path.join(o.directory, rel), content);
864
+ const read = (rel) => readFile(path5.join(o.directory, rel), "utf8");
865
+ const write = (rel, content) => writeFile(path5.join(o.directory, rel), content);
573
866
  await write("src/stack.config.ts", renderStackConfig(o));
574
867
  const storage = storageChoice(o.storage);
575
868
  const payloadConfig = await read("src/payload.config.ts");
576
- await write("src/payload.config.ts", swapStorageAdapter(swapDatabaseAdapter(payloadConfig, db), storage));
869
+ const withAdapters = swapStorageAdapter(swapDatabaseAdapter(payloadConfig, db), storage);
870
+ await write("src/payload.config.ts", o.emails ? addEmailsPlugin(withAdapters) : withAdapters);
871
+ await applyEmailsChoice(o.directory, o.emails);
872
+ await applyConsentChoice(o.directory, o.consent);
873
+ await applySchedulerChoice(o.directory, o.scheduler);
874
+ await removeVariants(o.directory);
875
+ if (o.scheduler && o.runner === "vercel") {
876
+ await write("vercel.json", renderVercelJson());
877
+ }
577
878
  const pkg = JSON.parse(await read("package.json"));
879
+ const configured = swapSchedulerPackages(
880
+ swapConsentPackages(
881
+ swapEmailsPackages(swapStoragePackage(swapDatabasePackage(pkg, db), storage), o.emails),
882
+ o.consent
883
+ ),
884
+ o.scheduler
885
+ );
578
886
  await write(
579
887
  "package.json",
580
- JSON.stringify(renameProject(swapStoragePackage(swapDatabasePackage(pkg, db), storage), o.slug), null, 2) + "\n"
888
+ JSON.stringify(renameProject(stripWorkspaceDependencies(configured), o.slug), null, 2) + "\n"
581
889
  );
890
+ const runner = RUNNER_CHOICES[o.runner];
582
891
  const example = await read(".env.example");
583
- await write(".env", renderEnv(example, { connectionString: o.connectionString }));
892
+ await write(
893
+ ".env",
894
+ renderEnv(example, {
895
+ connectionString: o.connectionString,
896
+ cronSecret: o.scheduler && runner.cronSecret,
897
+ inProcessRunner: o.scheduler && runner.inProcess
898
+ })
899
+ );
584
900
  }
585
901
 
586
902
  // src/options.ts
@@ -588,9 +904,9 @@ var AUTH_METHODS = ["email-password", "magic-link", "passkey"];
588
904
  var SOCIAL_PROVIDERS = ["google", "github"];
589
905
 
590
906
  // src/template.ts
591
- import { cp, mkdir, rm } from "fs/promises";
592
- import { existsSync as existsSync2 } from "fs";
593
- import path2 from "path";
907
+ import { cp as cp4, mkdir, rm as rm4 } from "fs/promises";
908
+ import { existsSync as existsSync5 } from "fs";
909
+ import path6 from "path";
594
910
  import { Readable } from "stream";
595
911
  import { pipeline } from "stream/promises";
596
912
  import * as tar from "tar";
@@ -618,29 +934,30 @@ Check your network, or pass --local-template <path> to scaffold from a local che
618
934
  }
619
935
  })
620
936
  );
621
- if (!existsSync2(path2.join(dest, "package.json"))) {
937
+ if (!existsSync5(path6.join(dest, "package.json"))) {
622
938
  throw new Error(`The downloaded archive did not contain ${TEMPLATE_PATH}/package.json (branch "${branch}").`);
623
939
  }
624
940
  }
625
941
  async function copyLocalTemplate({ from, dest }) {
626
- const source = path2.resolve(from);
627
- if (!existsSync2(path2.join(source, "package.json"))) {
942
+ const source = path6.resolve(from);
943
+ if (!existsSync5(path6.join(source, "package.json"))) {
628
944
  throw new Error(`--local-template must point at a directory containing package.json (got ${source})`);
629
945
  }
630
946
  await mkdir(dest, { recursive: true });
631
- await cp(source, dest, {
947
+ await cp4(source, dest, {
632
948
  recursive: true,
633
949
  filter: (src) => {
634
- const rel = path2.relative(source, src);
635
- const top = rel.split(path2.sep)[0];
950
+ const rel = path6.relative(source, src);
951
+ const top = rel.split(path6.sep)[0];
636
952
  return !["node_modules", ".next", ".turbo", ".env", "test-results", "playwright-report", "next-env.d.ts", "tsconfig.tsbuildinfo", "CLAUDE.md"].includes(top ?? "");
637
953
  }
638
954
  });
639
- await rm(path2.join(dest, "CLAUDE.md"), { force: true });
955
+ await rm4(path6.join(dest, "CLAUDE.md"), { force: true });
640
956
  }
641
957
 
642
958
  // src/cli.ts
643
- var DOCS = "https://payload.solutions/docs/payload-stack";
959
+ var DOCS2 = "https://payload.solutions/docs/payload-stack";
960
+ var CONSENT_DOCS = "https://payload.solutions/docs/plugins/payload-consent/audit";
644
961
  function bail(message = "Cancelled.") {
645
962
  p.cancel(message);
646
963
  process.exit(1);
@@ -674,7 +991,7 @@ async function run(flags, positionalName) {
674
991
  })
675
992
  );
676
993
  const slug = slugify(name);
677
- const directory = path3.resolve(process.cwd(), slug);
994
+ const directory = path7.resolve(process.cwd(), slug);
678
995
  if (!isDirectoryEmpty(directory)) {
679
996
  bail(`Directory ./${slug} already exists and is not empty.`);
680
997
  }
@@ -759,6 +1076,46 @@ async function run(flags, positionalName) {
759
1076
  ]
760
1077
  })
761
1078
  ));
1079
+ const emails = flags.emails ?? (flags.defaults ? true : guard(
1080
+ await p.confirm({
1081
+ message: "Transactional emails",
1082
+ initialValue: true,
1083
+ active: "Editable in the admin (Payload Emails)",
1084
+ inactive: "React Email components in code"
1085
+ })
1086
+ ));
1087
+ const consent = flags.consent ?? (flags.defaults ? true : guard(
1088
+ await p.confirm({
1089
+ message: "Cookie consent and legal pages",
1090
+ initialValue: true,
1091
+ active: "Banner, consent records and audited legal pages (Payload Consent)",
1092
+ inactive: "Plain legal pages, no banner"
1093
+ })
1094
+ ));
1095
+ const scheduler = flags.scheduler ?? (flags.defaults ? true : guard(
1096
+ await p.confirm({
1097
+ message: "Scheduled and recurring actions",
1098
+ initialValue: true,
1099
+ active: "Trial reminders, dunning, sweeps, and a ledger in the admin (Payload Action Scheduler)",
1100
+ inactive: "No scheduler"
1101
+ })
1102
+ ));
1103
+ const runnerFlag = flags.runner;
1104
+ if (runnerFlag && !RUNNER_KEYS.includes(runnerFlag)) {
1105
+ bail(`Unknown value "${flags.runner}" for --runner. Allowed: ${RUNNER_KEYS.join(", ")}`);
1106
+ }
1107
+ if (runnerFlag && !scheduler) bail("--runner needs --scheduler. Nothing runs a queue that does not exist.");
1108
+ const runner = !scheduler ? "later" : runnerFlag ?? (flags.defaults ? "clock" : guard(
1109
+ await p.select({
1110
+ message: "What runs the queue",
1111
+ initialValue: "clock",
1112
+ options: RUNNER_KEYS.map((key) => ({
1113
+ value: key,
1114
+ label: RUNNER_CHOICES[key].label,
1115
+ hint: RUNNER_CHOICES[key].hint
1116
+ }))
1117
+ })
1118
+ ));
762
1119
  const packageManager = flags.packageManager ?? await detectPackageManager();
763
1120
  const options = {
764
1121
  name,
@@ -771,6 +1128,10 @@ async function run(flags, positionalName) {
771
1128
  organizations,
772
1129
  billing,
773
1130
  storage,
1131
+ emails,
1132
+ consent,
1133
+ scheduler,
1134
+ runner,
774
1135
  packageManager,
775
1136
  install: flags.install,
776
1137
  git: flags.git
@@ -806,7 +1167,7 @@ async function run(flags, positionalName) {
806
1167
  ${error instanceof Error ? error.message.split("\n")[0] : ""}`);
807
1168
  }
808
1169
  }
809
- if (options.git && !existsSync3(path3.join(directory, ".git"))) {
1170
+ if (options.git && !existsSync6(path7.join(directory, ".git"))) {
810
1171
  try {
811
1172
  await execa2("git", ["init", "-b", "main"], { cwd: directory });
812
1173
  await execa2("git", ["add", "-A"], { cwd: directory });
@@ -823,10 +1184,16 @@ ${error instanceof Error ? error.message.split("\n")[0] : ""}`);
823
1184
  ...billing !== "none" ? ["# add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and NEXT_PUBLIC_STRIPE_PRICE_* to .env when you are ready for billing"] : [],
824
1185
  ...storage !== "none" ? [`# add ${listWords(STORAGE_CHOICES[storage].envVars)} to .env to store uploads in ${STORAGE_CHOICES[storage].label} (local disk until then)`] : [],
825
1186
  runCommand(packageManager, "dev"),
826
- "open http://localhost:3000/admin"
1187
+ "open http://localhost:3000/admin",
1188
+ ...emails ? ["# email copy is under Emails in the admin; the design is src/emails/template.tsx"] : [],
1189
+ ...consent ? [
1190
+ "# the seeded legal pages are templates, not policy: the homepage shows the six steps in development",
1191
+ `# ${CONSENT_DOCS}`
1192
+ ] : [],
1193
+ ...scheduler ? ["# scheduled actions are under System -> Scheduled Actions in the admin", ...RUNNER_CHOICES[runner].next()] : []
827
1194
  ];
828
1195
  p.note(steps.join("\n"), "Done. Next steps:");
829
- p.outro(`Docs: ${pc2.underline(DOCS)}`);
1196
+ p.outro(`Docs: ${pc2.underline(DOCS2)}`);
830
1197
  }
831
1198
 
832
1199
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-payload-stack",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Scaffold a SaaS on Payload CMS: Better Auth, organizations, Stripe subscriptions and a shadcn dashboard, in one command.",
5
5
  "keywords": [
6
6
  "payload",