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.
- package/README.md +2 -2
- package/dist/index.js +409 -42
- 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
|
-
|
|
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
|
|
327
|
-
import
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
465
|
-
var
|
|
466
|
-
var
|
|
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 = `${
|
|
749
|
+
const importLine = `${IMPORT_MARKER2}
|
|
469
750
|
import { ${db.importName} } from '${db.packageName}'`;
|
|
470
751
|
const withImport = payloadConfigSource.replace(
|
|
471
|
-
new RegExp(`${escapeRegExp(
|
|
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 "${
|
|
757
|
+
throw new Error(`payload.config.ts is missing the "${IMPORT_MARKER2}" marker`);
|
|
477
758
|
}
|
|
478
|
-
const start = withImport.indexOf(
|
|
479
|
-
const end = withImport.indexOf(
|
|
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)}${
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
572
|
-
const write = (rel, content) => writeFile(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
593
|
-
import
|
|
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 (!
|
|
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 =
|
|
627
|
-
if (!
|
|
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
|
|
947
|
+
await cp4(source, dest, {
|
|
632
948
|
recursive: true,
|
|
633
949
|
filter: (src) => {
|
|
634
|
-
const rel =
|
|
635
|
-
const top = rel.split(
|
|
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
|
|
955
|
+
await rm4(path6.join(dest, "CLAUDE.md"), { force: true });
|
|
640
956
|
}
|
|
641
957
|
|
|
642
958
|
// src/cli.ts
|
|
643
|
-
var
|
|
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 =
|
|
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 && !
|
|
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(
|
|
1196
|
+
p.outro(`Docs: ${pc2.underline(DOCS2)}`);
|
|
830
1197
|
}
|
|
831
1198
|
|
|
832
1199
|
// src/index.ts
|
package/package.json
CHANGED