create-payload-stack 0.1.1 → 0.4.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/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/index.js +593 -133
- package/package.json +10 -10
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Presecan / Fortbit d.o.o. and Payload Solutions contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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 -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
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { parseArgs } from "util";
|
|
5
4
|
import { createRequire } from "module";
|
|
6
|
-
import
|
|
5
|
+
import pc3 from "picocolors";
|
|
7
6
|
|
|
8
|
-
// src/
|
|
9
|
-
import
|
|
10
|
-
import { execa as execa2 } from "execa";
|
|
11
|
-
import { existsSync as existsSync3 } from "fs";
|
|
12
|
-
import path3 from "path";
|
|
7
|
+
// src/args.ts
|
|
8
|
+
import { parseArgs } from "util";
|
|
13
9
|
import pc from "picocolors";
|
|
14
10
|
|
|
15
|
-
// src/configure.ts
|
|
16
|
-
import { readFile, writeFile } from "fs/promises";
|
|
17
|
-
import path from "path";
|
|
18
|
-
|
|
19
11
|
// src/databases.ts
|
|
20
12
|
var DB_CHOICES = {
|
|
21
13
|
postgres: {
|
|
@@ -76,10 +68,437 @@ function defaultConnectionString(db, projectSlug) {
|
|
|
76
68
|
return `${db.connectionPrefix}${projectSlug}${db.connectionSuffix}`;
|
|
77
69
|
}
|
|
78
70
|
|
|
71
|
+
// src/storage.ts
|
|
72
|
+
var LOCAL_STORAGE_CONFIG = `// Local disk (./media): fine for development, lost on redeploy on Vercel and other ephemeral hosts.
|
|
73
|
+
// Move uploads to Vercel Blob, S3, R2, Azure, GCS or Uploadthing: https://payload.solutions/docs/payload-stack/storage`;
|
|
74
|
+
var STORAGE_CHOICES = {
|
|
75
|
+
"vercel-blob": {
|
|
76
|
+
key: "vercel-blob",
|
|
77
|
+
label: "Vercel Blob",
|
|
78
|
+
hint: "needs BLOB_READ_WRITE_TOKEN",
|
|
79
|
+
packageName: "@payloadcms/storage-vercel-blob",
|
|
80
|
+
importName: "vercelBlobStorage",
|
|
81
|
+
usesRequireEnv: false,
|
|
82
|
+
envVars: ["BLOB_READ_WRITE_TOKEN"],
|
|
83
|
+
config: `// Vercel Blob. Without BLOB_READ_WRITE_TOKEN uploads stay on local disk (./media).
|
|
84
|
+
if (env.BLOB_READ_WRITE_TOKEN) {
|
|
85
|
+
plugins.push(
|
|
86
|
+
vercelBlobStorage({
|
|
87
|
+
collections: { media: true },
|
|
88
|
+
token: env.BLOB_READ_WRITE_TOKEN,
|
|
89
|
+
// Vercel functions accept request bodies up to 4.5 MB. For larger files upload from the
|
|
90
|
+
// browser instead: set clientUploads: true and run pnpm generate:importmap.
|
|
91
|
+
// clientUploads: true,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
}`
|
|
95
|
+
},
|
|
96
|
+
s3: {
|
|
97
|
+
key: "s3",
|
|
98
|
+
label: "AWS S3",
|
|
99
|
+
hint: "or S3-compatible: MinIO, DigitalOcean Spaces, Backblaze B2",
|
|
100
|
+
packageName: "@payloadcms/storage-s3",
|
|
101
|
+
importName: "s3Storage",
|
|
102
|
+
usesRequireEnv: false,
|
|
103
|
+
envVars: ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"],
|
|
104
|
+
config: `// AWS S3, or any S3-compatible service. Without S3_BUCKET uploads stay on local disk (./media).
|
|
105
|
+
if (env.S3_BUCKET) {
|
|
106
|
+
plugins.push(
|
|
107
|
+
s3Storage({
|
|
108
|
+
collections: { media: true },
|
|
109
|
+
bucket: env.S3_BUCKET,
|
|
110
|
+
config: {
|
|
111
|
+
region: env.S3_REGION,
|
|
112
|
+
// Leave the keys unset to use the AWS default credential chain (IAM roles, profiles).
|
|
113
|
+
credentials:
|
|
114
|
+
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
|
115
|
+
? { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY }
|
|
116
|
+
: undefined,
|
|
117
|
+
// MinIO, DigitalOcean Spaces, Backblaze B2 and other S3-compatible services: set S3_ENDPOINT.
|
|
118
|
+
endpoint: env.S3_ENDPOINT,
|
|
119
|
+
forcePathStyle: Boolean(env.S3_ENDPOINT),
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
}`
|
|
124
|
+
},
|
|
125
|
+
r2: {
|
|
126
|
+
key: "r2",
|
|
127
|
+
label: "Cloudflare R2",
|
|
128
|
+
hint: "through the S3 API; needs R2_BUCKET, R2_ENDPOINT and keys",
|
|
129
|
+
packageName: "@payloadcms/storage-s3",
|
|
130
|
+
importName: "s3Storage",
|
|
131
|
+
usesRequireEnv: true,
|
|
132
|
+
envVars: ["R2_BUCKET", "R2_ENDPOINT", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"],
|
|
133
|
+
config: `// Cloudflare R2 through its S3-compatible API. Without R2_BUCKET uploads stay on local disk (./media).
|
|
134
|
+
if (env.R2_BUCKET) {
|
|
135
|
+
plugins.push(
|
|
136
|
+
s3Storage({
|
|
137
|
+
collections: {
|
|
138
|
+
media: env.R2_PUBLIC_URL
|
|
139
|
+
? {
|
|
140
|
+
// Serve files from the bucket's public URL or custom domain instead of through Payload.
|
|
141
|
+
disablePayloadAccessControl: true,
|
|
142
|
+
generateFileURL: ({ filename, prefix }) =>
|
|
143
|
+
\`\${env.R2_PUBLIC_URL}/\${prefix ? \`\${prefix}/\${filename}\` : filename}\`,
|
|
144
|
+
}
|
|
145
|
+
: true,
|
|
146
|
+
},
|
|
147
|
+
bucket: env.R2_BUCKET,
|
|
148
|
+
config: {
|
|
149
|
+
credentials: {
|
|
150
|
+
accessKeyId: requireEnv('R2_ACCESS_KEY_ID', 'for Cloudflare R2'),
|
|
151
|
+
secretAccessKey: requireEnv('R2_SECRET_ACCESS_KEY', 'for Cloudflare R2'),
|
|
152
|
+
},
|
|
153
|
+
// R2 requires region "auto" and path-style addressing.
|
|
154
|
+
region: 'auto',
|
|
155
|
+
endpoint: requireEnv('R2_ENDPOINT', 'for Cloudflare R2'),
|
|
156
|
+
forcePathStyle: true,
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
}`
|
|
161
|
+
},
|
|
162
|
+
azure: {
|
|
163
|
+
key: "azure",
|
|
164
|
+
label: "Azure Blob Storage",
|
|
165
|
+
hint: "needs AZURE_STORAGE_CONNECTION_STRING, container name and base URL",
|
|
166
|
+
packageName: "@payloadcms/storage-azure",
|
|
167
|
+
importName: "azureStorage",
|
|
168
|
+
usesRequireEnv: true,
|
|
169
|
+
envVars: ["AZURE_STORAGE_CONNECTION_STRING", "AZURE_STORAGE_CONTAINER_NAME", "AZURE_STORAGE_ACCOUNT_BASEURL"],
|
|
170
|
+
config: `// Azure Blob Storage. Without AZURE_STORAGE_CONNECTION_STRING uploads stay on local disk (./media).
|
|
171
|
+
if (env.AZURE_STORAGE_CONNECTION_STRING) {
|
|
172
|
+
plugins.push(
|
|
173
|
+
azureStorage({
|
|
174
|
+
collections: { media: true },
|
|
175
|
+
connectionString: env.AZURE_STORAGE_CONNECTION_STRING,
|
|
176
|
+
containerName: requireEnv('AZURE_STORAGE_CONTAINER_NAME', 'for Azure Blob Storage'),
|
|
177
|
+
baseURL: requireEnv('AZURE_STORAGE_ACCOUNT_BASEURL', 'for Azure Blob Storage'),
|
|
178
|
+
allowContainerCreate: env.AZURE_STORAGE_ALLOW_CONTAINER_CREATE === 'true',
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
}`
|
|
182
|
+
},
|
|
183
|
+
gcs: {
|
|
184
|
+
key: "gcs",
|
|
185
|
+
label: "Google Cloud Storage",
|
|
186
|
+
hint: "needs GCS_BUCKET and service-account credentials",
|
|
187
|
+
packageName: "@payloadcms/storage-gcs",
|
|
188
|
+
importName: "gcsStorage",
|
|
189
|
+
usesRequireEnv: false,
|
|
190
|
+
envVars: ["GCS_BUCKET", "GCS_PROJECT_ID", "GOOGLE_APPLICATION_CREDENTIALS or GCS_SERVICE_ACCOUNT_KEY"],
|
|
191
|
+
config: `// Google Cloud Storage. Without GCS_BUCKET uploads stay on local disk (./media).
|
|
192
|
+
if (env.GCS_BUCKET) {
|
|
193
|
+
plugins.push(
|
|
194
|
+
gcsStorage({
|
|
195
|
+
collections: { media: true },
|
|
196
|
+
bucket: env.GCS_BUCKET,
|
|
197
|
+
options: {
|
|
198
|
+
projectId: env.GCS_PROJECT_ID,
|
|
199
|
+
// Locally, point GOOGLE_APPLICATION_CREDENTIALS at a service-account key file. On hosts
|
|
200
|
+
// without a filesystem, paste the key's JSON into GCS_SERVICE_ACCOUNT_KEY instead.
|
|
201
|
+
credentials: env.GCS_SERVICE_ACCOUNT_KEY ? JSON.parse(env.GCS_SERVICE_ACCOUNT_KEY) : undefined,
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
}`
|
|
206
|
+
},
|
|
207
|
+
uploadthing: {
|
|
208
|
+
key: "uploadthing",
|
|
209
|
+
label: "Uploadthing",
|
|
210
|
+
hint: "needs UPLOADTHING_TOKEN",
|
|
211
|
+
packageName: "@payloadcms/storage-uploadthing",
|
|
212
|
+
importName: "uploadthingStorage",
|
|
213
|
+
usesRequireEnv: false,
|
|
214
|
+
envVars: ["UPLOADTHING_TOKEN"],
|
|
215
|
+
config: `// Uploadthing. Without UPLOADTHING_TOKEN uploads stay on local disk (./media).
|
|
216
|
+
if (env.UPLOADTHING_TOKEN) {
|
|
217
|
+
plugins.push(
|
|
218
|
+
uploadthingStorage({
|
|
219
|
+
collections: { media: true },
|
|
220
|
+
options: {
|
|
221
|
+
token: env.UPLOADTHING_TOKEN,
|
|
222
|
+
acl: 'public-read',
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
}`
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
var STORAGE_ADAPTER_KEYS = Object.keys(STORAGE_CHOICES);
|
|
230
|
+
var STORAGE_KEYS = ["none", ...STORAGE_ADAPTER_KEYS];
|
|
231
|
+
var STORAGE_PACKAGES = [...new Set(Object.values(STORAGE_CHOICES).map((c) => c.packageName))];
|
|
232
|
+
|
|
233
|
+
// src/args.ts
|
|
234
|
+
function helpText(version2) {
|
|
235
|
+
return `
|
|
236
|
+
${pc.bold("create-payload-stack")} ${pc.dim(`v${version2}`)}
|
|
237
|
+
Scaffold a SaaS on Payload CMS: Better Auth, organizations, Stripe and a shadcn dashboard.
|
|
238
|
+
|
|
239
|
+
${pc.bold("Usage")}
|
|
240
|
+
$ npx create-payload-stack@latest [name] [options]
|
|
241
|
+
|
|
242
|
+
${pc.bold("Options")}
|
|
243
|
+
-n, --name <name> Project name (also the directory)
|
|
244
|
+
-d, --db <db> ${DB_KEYS.join(" | ")}
|
|
245
|
+
--db-connection-string Connection string written to .env
|
|
246
|
+
--auth <list> Comma-separated: email-password, magic-link, passkey
|
|
247
|
+
--social <list> Comma-separated: google, github
|
|
248
|
+
--organizations Enable organizations (teams)
|
|
249
|
+
--no-organizations Disable organizations
|
|
250
|
+
--billing <mode> organization | user | none
|
|
251
|
+
--storage <adapter> ${STORAGE_KEYS.join(" | ")} (none = local disk)
|
|
252
|
+
--emails Edit transactional email copy in the Payload admin
|
|
253
|
+
--no-emails Keep emails as React Email components in code
|
|
254
|
+
--consent Cookie banner and audited legal pages (Payload Consent)
|
|
255
|
+
--no-consent Plain legal pages, no banner
|
|
256
|
+
--use-pnpm | --use-npm | --use-yarn | --use-bun
|
|
257
|
+
--no-install Skip dependency installation
|
|
258
|
+
--no-git Skip git init
|
|
259
|
+
-y, --defaults Accept defaults for every unanswered prompt
|
|
260
|
+
--branch <ref> Git branch or tag of the template repo (default: main)
|
|
261
|
+
--local-template <path> Scaffold from a local checkout of templates/payload-stack
|
|
262
|
+
--dry-run Print the resolved options and exit
|
|
263
|
+
-h, --help Show this help
|
|
264
|
+
-v, --version Show the version
|
|
265
|
+
|
|
266
|
+
${pc.bold("Examples")}
|
|
267
|
+
$ npx create-payload-stack@latest ridgeline
|
|
268
|
+
$ npx create-payload-stack@latest ridgeline -d sqlite --auth email-password,passkey --billing none -y
|
|
269
|
+
$ npx create-payload-stack@latest ridgeline --storage vercel-blob -y
|
|
270
|
+
|
|
271
|
+
Docs: https://payload.solutions/docs/payload-stack
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
function parse(argv) {
|
|
275
|
+
const { values, positionals } = parseArgs({
|
|
276
|
+
args: argv,
|
|
277
|
+
allowPositionals: true,
|
|
278
|
+
allowNegative: true,
|
|
279
|
+
options: {
|
|
280
|
+
name: { type: "string", short: "n" },
|
|
281
|
+
db: { type: "string", short: "d" },
|
|
282
|
+
"db-connection-string": { type: "string" },
|
|
283
|
+
auth: { type: "string" },
|
|
284
|
+
social: { type: "string" },
|
|
285
|
+
organizations: { type: "boolean" },
|
|
286
|
+
billing: { type: "string" },
|
|
287
|
+
storage: { type: "string" },
|
|
288
|
+
emails: { type: "boolean" },
|
|
289
|
+
consent: { type: "boolean" },
|
|
290
|
+
"use-pnpm": { type: "boolean" },
|
|
291
|
+
"use-npm": { type: "boolean" },
|
|
292
|
+
"use-yarn": { type: "boolean" },
|
|
293
|
+
"use-bun": { type: "boolean" },
|
|
294
|
+
install: { type: "boolean", default: true },
|
|
295
|
+
git: { type: "boolean", default: true },
|
|
296
|
+
defaults: { type: "boolean", short: "y", default: false },
|
|
297
|
+
branch: { type: "string", default: "main" },
|
|
298
|
+
"local-template": { type: "string" },
|
|
299
|
+
"dry-run": { type: "boolean", default: false },
|
|
300
|
+
help: { type: "boolean", short: "h", default: false },
|
|
301
|
+
version: { type: "boolean", short: "v", default: false }
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
const packageManager = values["use-pnpm"] ? "pnpm" : values["use-npm"] ? "npm" : values["use-yarn"] ? "yarn" : values["use-bun"] ? "bun" : void 0;
|
|
305
|
+
return {
|
|
306
|
+
flags: {
|
|
307
|
+
name: values.name,
|
|
308
|
+
db: values.db,
|
|
309
|
+
connectionString: values["db-connection-string"],
|
|
310
|
+
auth: values.auth,
|
|
311
|
+
social: values.social,
|
|
312
|
+
organizations: values.organizations,
|
|
313
|
+
billing: values.billing,
|
|
314
|
+
storage: values.storage,
|
|
315
|
+
emails: values.emails,
|
|
316
|
+
consent: values.consent,
|
|
317
|
+
packageManager,
|
|
318
|
+
install: values.install ?? true,
|
|
319
|
+
git: values.git ?? true,
|
|
320
|
+
defaults: values.defaults ?? false,
|
|
321
|
+
branch: values.branch ?? "main",
|
|
322
|
+
localTemplate: values["local-template"],
|
|
323
|
+
dryRun: values["dry-run"] ?? false,
|
|
324
|
+
help: values.help ?? false,
|
|
325
|
+
version: values.version ?? false
|
|
326
|
+
},
|
|
327
|
+
positional: positionals[0]
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/cli.ts
|
|
332
|
+
import * as p from "@clack/prompts";
|
|
333
|
+
import { execa as execa2 } from "execa";
|
|
334
|
+
import { existsSync as existsSync5 } from "fs";
|
|
335
|
+
import path6 from "path";
|
|
336
|
+
import pc2 from "picocolors";
|
|
337
|
+
|
|
338
|
+
// src/configure.ts
|
|
339
|
+
import { readFile, writeFile } from "fs/promises";
|
|
340
|
+
import path4 from "path";
|
|
341
|
+
|
|
342
|
+
// src/consent.ts
|
|
343
|
+
import { cp, rm as rm2, rmdir } from "fs/promises";
|
|
344
|
+
import { existsSync } from "fs";
|
|
345
|
+
import path2 from "path";
|
|
346
|
+
|
|
347
|
+
// src/variants.ts
|
|
348
|
+
import { rm } from "fs/promises";
|
|
349
|
+
import path from "path";
|
|
350
|
+
var VARIANT_DIR = "variants";
|
|
351
|
+
function variantPath(step) {
|
|
352
|
+
return path.join(VARIANT_DIR, step);
|
|
353
|
+
}
|
|
354
|
+
async function removeVariants(directory) {
|
|
355
|
+
await rm(path.join(directory, VARIANT_DIR), { force: true, recursive: true });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/consent.ts
|
|
359
|
+
var CONSENT_PACKAGE = "@payload-solutions/plugin-consent";
|
|
360
|
+
var CONSENT_REACT_PACKAGE = "@payload-solutions/consent-react";
|
|
361
|
+
var CONSENT_VERSION = "0.1.0";
|
|
362
|
+
var CONSENT_PACKAGES = {
|
|
363
|
+
[CONSENT_PACKAGE]: CONSENT_VERSION,
|
|
364
|
+
[CONSENT_REACT_PACKAGE]: CONSENT_VERSION
|
|
365
|
+
};
|
|
366
|
+
var VARIANT_CONSENT = variantPath("consent-plugin");
|
|
367
|
+
var SEAM_FILES = [
|
|
368
|
+
"plugin.ts",
|
|
369
|
+
"collections.ts",
|
|
370
|
+
"seed.ts",
|
|
371
|
+
"consent-root.tsx",
|
|
372
|
+
"consent-settings-link.tsx",
|
|
373
|
+
"legal-page.tsx",
|
|
374
|
+
"legal-setup-notice.tsx"
|
|
375
|
+
];
|
|
376
|
+
var BANNER_FILES = ["consent-banner.tsx", "consent-preferences-dialog.tsx"];
|
|
377
|
+
var TEMPLATE_LEGAL_FILES = ["src/collections/LegalPages.ts", "src/seed/legal.ts", "tests/unit/seed-legal.spec.ts"];
|
|
378
|
+
function swapConsentPackages(packageJson, enabled) {
|
|
379
|
+
const deps = { ...packageJson.dependencies };
|
|
380
|
+
const devDeps = { ...packageJson.devDependencies };
|
|
381
|
+
for (const pkg of Object.keys(CONSENT_PACKAGES)) {
|
|
382
|
+
delete devDeps[pkg];
|
|
383
|
+
if (enabled) deps[pkg] = CONSENT_PACKAGES[pkg];
|
|
384
|
+
else delete deps[pkg];
|
|
385
|
+
}
|
|
386
|
+
return { ...packageJson, dependencies: sortKeys(deps), devDependencies: sortKeys(devDeps) };
|
|
387
|
+
}
|
|
388
|
+
function sortKeys(obj) {
|
|
389
|
+
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
390
|
+
}
|
|
391
|
+
async function applyConsentChoice(directory, enabled) {
|
|
392
|
+
if (!enabled) return;
|
|
393
|
+
const variant = path2.join(directory, VARIANT_CONSENT);
|
|
394
|
+
if (!existsSync(variant)) {
|
|
395
|
+
throw new Error(`The template is missing ${VARIANT_CONSENT}; it cannot scaffold with consent.`);
|
|
396
|
+
}
|
|
397
|
+
for (const name of [...SEAM_FILES, ...BANNER_FILES]) {
|
|
398
|
+
await cp(path2.join(variant, name), path2.join(directory, "src/consent", name), { recursive: true });
|
|
399
|
+
}
|
|
400
|
+
await cp(path2.join(variant, "tests/consent.spec.ts"), path2.join(directory, "tests/unit/consent.spec.ts"));
|
|
401
|
+
for (const name of TEMPLATE_LEGAL_FILES) {
|
|
402
|
+
await rm2(path2.join(directory, name), { force: true, recursive: true });
|
|
403
|
+
}
|
|
404
|
+
await rmdir(path2.join(directory, "src/seed")).catch(() => {
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// src/emails.ts
|
|
409
|
+
import { cp as cp2, rm as rm3 } from "fs/promises";
|
|
410
|
+
import { existsSync as existsSync2 } from "fs";
|
|
411
|
+
import path3 from "path";
|
|
412
|
+
var EMAILS_PACKAGE = "@payload-solutions/plugin-emails";
|
|
413
|
+
var EMAILS_VERSION = "0.1.0";
|
|
414
|
+
var REACT_EMAIL_COMPONENTS = ["@react-email/components", "^1.0.12"];
|
|
415
|
+
var REACT_EMAIL_ONLY_PACKAGES = {
|
|
416
|
+
"@react-email/render": "^2.1.0",
|
|
417
|
+
"react-email": "^6.9.3"
|
|
418
|
+
};
|
|
419
|
+
var EMAIL_DEV_SCRIPT = ["email:dev", "email dev --dir src/components/auth/email --port 3001"];
|
|
420
|
+
var VARIANT_EMAILS = variantPath("emails-plugin");
|
|
421
|
+
var IMPORT_MARKER = "// emails-plugin-import";
|
|
422
|
+
var CONFIG_START = "// emails-plugin-config-start";
|
|
423
|
+
var CONFIG_END = "// emails-plugin-config-end";
|
|
424
|
+
var IMPORTS = `${IMPORT_MARKER}
|
|
425
|
+
import { emailsPlugin } from '${EMAILS_PACKAGE}'
|
|
426
|
+
import { emailDefinitions } from '@/emails/definitions'
|
|
427
|
+
import { StackEmailTemplate } from '@/emails/template'
|
|
428
|
+
import { globalVariableManifest, globalVariables } from '@/emails/variables'`;
|
|
429
|
+
var REGISTRATION = `plugins.push(
|
|
430
|
+
emailsPlugin({
|
|
431
|
+
// The catalogue, filtered by the features in stack.config.ts (src/emails/definitions).
|
|
432
|
+
emails: emailDefinitions,
|
|
433
|
+
// Branding lives in code; editors own the words (src/emails/template.tsx).
|
|
434
|
+
templates: { default: StackEmailTemplate },
|
|
435
|
+
// {{site.name}}, {{support.email}}, {{url.dashboard}} \u2026 in every email (src/emails/variables.ts).
|
|
436
|
+
globalVariables,
|
|
437
|
+
globalVariableManifest,
|
|
438
|
+
// Every send recorded, for 90 days. Set enabled: false to keep no record at all.
|
|
439
|
+
log: { enabled: true, retentionDays: 90 },
|
|
440
|
+
settings: { mediaCollection: 'media' },
|
|
441
|
+
}),
|
|
442
|
+
)`;
|
|
443
|
+
function addEmailsPlugin(payloadConfigSource) {
|
|
444
|
+
if (!payloadConfigSource.includes(IMPORT_MARKER)) {
|
|
445
|
+
throw new Error(`payload.config.ts is missing the "${IMPORT_MARKER}" marker`);
|
|
446
|
+
}
|
|
447
|
+
const start = payloadConfigSource.indexOf(CONFIG_START);
|
|
448
|
+
const end = payloadConfigSource.indexOf(CONFIG_END);
|
|
449
|
+
if (start === -1 || end === -1 || end < start) {
|
|
450
|
+
throw new Error(`payload.config.ts is missing the emails plugin markers`);
|
|
451
|
+
}
|
|
452
|
+
const withImports = payloadConfigSource.replace(IMPORT_MARKER, IMPORTS);
|
|
453
|
+
const at = withImports.indexOf(CONFIG_START);
|
|
454
|
+
const to = withImports.indexOf(CONFIG_END);
|
|
455
|
+
return `${withImports.slice(0, at)}${CONFIG_START}
|
|
456
|
+
${REGISTRATION}
|
|
457
|
+
${withImports.slice(to)}`;
|
|
458
|
+
}
|
|
459
|
+
function swapEmailsPackages(packageJson, enabled) {
|
|
460
|
+
const deps = { ...packageJson.dependencies };
|
|
461
|
+
const devDeps = { ...packageJson.devDependencies };
|
|
462
|
+
const scripts = { ...packageJson.scripts };
|
|
463
|
+
delete devDeps[EMAILS_PACKAGE];
|
|
464
|
+
delete devDeps[REACT_EMAIL_COMPONENTS[0]];
|
|
465
|
+
if (enabled) {
|
|
466
|
+
deps[EMAILS_PACKAGE] = EMAILS_VERSION;
|
|
467
|
+
deps[REACT_EMAIL_COMPONENTS[0]] = REACT_EMAIL_COMPONENTS[1];
|
|
468
|
+
for (const pkg of Object.keys(REACT_EMAIL_ONLY_PACKAGES)) delete deps[pkg];
|
|
469
|
+
delete scripts[EMAIL_DEV_SCRIPT[0]];
|
|
470
|
+
} else {
|
|
471
|
+
delete deps[EMAILS_PACKAGE];
|
|
472
|
+
delete deps[REACT_EMAIL_COMPONENTS[0]];
|
|
473
|
+
Object.assign(deps, REACT_EMAIL_ONLY_PACKAGES);
|
|
474
|
+
scripts[EMAIL_DEV_SCRIPT[0]] = EMAIL_DEV_SCRIPT[1];
|
|
475
|
+
}
|
|
476
|
+
return { ...packageJson, dependencies: sortKeys2(deps), devDependencies: sortKeys2(devDeps), scripts: sortKeys2(scripts) };
|
|
477
|
+
}
|
|
478
|
+
function sortKeys2(obj) {
|
|
479
|
+
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
480
|
+
}
|
|
481
|
+
var REACT_EMAIL_FILES = ["src/emails/send.ts", "src/components/auth/email"];
|
|
482
|
+
async function applyEmailsChoice(directory, enabled) {
|
|
483
|
+
const variant = path3.join(directory, VARIANT_EMAILS);
|
|
484
|
+
if (enabled) {
|
|
485
|
+
if (!existsSync2(variant)) {
|
|
486
|
+
throw new Error(`The template is missing ${VARIANT_EMAILS}; it cannot scaffold with emails.`);
|
|
487
|
+
}
|
|
488
|
+
for (const name of ["index.ts", "hooks.ts", "template.tsx", "variables.ts", "definitions"]) {
|
|
489
|
+
await cp2(path3.join(variant, name), path3.join(directory, "src/emails", name), { recursive: true });
|
|
490
|
+
}
|
|
491
|
+
await cp2(path3.join(variant, "tests/emails.spec.ts"), path3.join(directory, "tests/unit/emails.spec.ts"));
|
|
492
|
+
for (const name of REACT_EMAIL_FILES) {
|
|
493
|
+
await rm3(path3.join(directory, name), { force: true, recursive: true });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
79
498
|
// src/utils.ts
|
|
80
499
|
import slugifyBase from "@sindresorhus/slugify";
|
|
81
500
|
import { randomBytes } from "crypto";
|
|
82
|
-
import { existsSync, readdirSync } from "fs";
|
|
501
|
+
import { existsSync as existsSync3, readdirSync } from "fs";
|
|
83
502
|
import { execa } from "execa";
|
|
84
503
|
function slugify(input) {
|
|
85
504
|
return slugifyBase(input.trim()).replace(/^-+|-+$/g, "") || "my-saas";
|
|
@@ -88,7 +507,7 @@ function generateSecret(bytes = 32) {
|
|
|
88
507
|
return randomBytes(bytes).toString("base64url");
|
|
89
508
|
}
|
|
90
509
|
function isDirectoryEmpty(dir) {
|
|
91
|
-
if (!
|
|
510
|
+
if (!existsSync3(dir)) return true;
|
|
92
511
|
const entries = readdirSync(dir).filter((e) => ![".git", ".DS_Store"].includes(e));
|
|
93
512
|
return entries.length === 0;
|
|
94
513
|
}
|
|
@@ -153,6 +572,18 @@ function renderStackConfig(o) {
|
|
|
153
572
|
},
|
|
154
573
|
],
|
|
155
574
|
},`;
|
|
575
|
+
const legalBlock = o.consent ? ` legal: {
|
|
576
|
+
company: '${o.name.replace(/'/g, "\\'")} Ltd',
|
|
577
|
+
jurisdiction: 'Ireland',
|
|
578
|
+
// The seeded legal pages are written from here. Fill these in before you publish them:
|
|
579
|
+
// \`npx payload-consent scan\` lists every value still left as a placeholder.
|
|
580
|
+
// https://payload.solutions/docs/plugins/payload-consent/audit
|
|
581
|
+
legalName: '${o.name.replace(/'/g, "\\'")} Ltd',
|
|
582
|
+
address: '[REGISTERED ADDRESS]',
|
|
583
|
+
},` : ` legal: {
|
|
584
|
+
company: '${o.name.replace(/'/g, "\\'")} Ltd',
|
|
585
|
+
jurisdiction: 'Ireland',
|
|
586
|
+
},`;
|
|
156
587
|
return `import { defineStack } from '@/lib/stack'
|
|
157
588
|
|
|
158
589
|
/**
|
|
@@ -190,38 +621,86 @@ export default defineStack({
|
|
|
190
621
|
|
|
191
622
|
${billingBlock}
|
|
192
623
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
624
|
+
observability: {
|
|
625
|
+
tracesSampleRate: 0.1,
|
|
626
|
+
sendPII: false,
|
|
196
627
|
},
|
|
197
628
|
|
|
629
|
+
${legalBlock}
|
|
630
|
+
|
|
198
631
|
nav: [${o.billing !== "none" ? "{ label: 'Pricing', href: '/pricing' }, " : ""}{ label: 'Docs', href: 'https://payload.solutions/docs/payload-stack' }],
|
|
199
632
|
|
|
200
633
|
social: {},
|
|
201
634
|
})
|
|
202
635
|
`;
|
|
203
636
|
}
|
|
204
|
-
var
|
|
205
|
-
var
|
|
206
|
-
var
|
|
637
|
+
var IMPORT_MARKER2 = "// database-adapter-import";
|
|
638
|
+
var CONFIG_START2 = "// database-adapter-config-start";
|
|
639
|
+
var CONFIG_END2 = "// database-adapter-config-end";
|
|
207
640
|
function swapDatabaseAdapter(payloadConfigSource, db) {
|
|
208
|
-
const importLine = `${
|
|
641
|
+
const importLine = `${IMPORT_MARKER2}
|
|
209
642
|
import { ${db.importName} } from '${db.packageName}'`;
|
|
210
643
|
const withImport = payloadConfigSource.replace(
|
|
211
|
-
new RegExp(`${escapeRegExp(
|
|
644
|
+
new RegExp(`${escapeRegExp(IMPORT_MARKER2)}\\nimport [^\\n]+\\n`),
|
|
212
645
|
`${importLine}
|
|
213
646
|
`
|
|
214
647
|
);
|
|
215
648
|
if (withImport === payloadConfigSource && !payloadConfigSource.includes(importLine)) {
|
|
216
|
-
throw new Error(`payload.config.ts is missing the "${
|
|
649
|
+
throw new Error(`payload.config.ts is missing the "${IMPORT_MARKER2}" marker`);
|
|
217
650
|
}
|
|
218
|
-
const start = withImport.indexOf(
|
|
219
|
-
const end = withImport.indexOf(
|
|
651
|
+
const start = withImport.indexOf(CONFIG_START2);
|
|
652
|
+
const end = withImport.indexOf(CONFIG_END2);
|
|
220
653
|
if (start === -1 || end === -1) throw new Error(`payload.config.ts is missing the database adapter markers`);
|
|
221
|
-
return `${withImport.slice(0, start)}${
|
|
654
|
+
return `${withImport.slice(0, start)}${CONFIG_START2}
|
|
222
655
|
${db.config}
|
|
223
656
|
${withImport.slice(end)}`;
|
|
224
657
|
}
|
|
658
|
+
var STORAGE_IMPORT_MARKER = "// storage-adapter-import";
|
|
659
|
+
var STORAGE_CONFIG_START = "// storage-adapter-config-start";
|
|
660
|
+
var STORAGE_CONFIG_END = "// storage-adapter-config-end";
|
|
661
|
+
var ENV_IMPORT = /^import \{ ([^}]*) \} from '@\/lib\/env'$/m;
|
|
662
|
+
function swapStorageAdapter(payloadConfigSource, storage) {
|
|
663
|
+
const importPattern = new RegExp(
|
|
664
|
+
`${escapeRegExp(STORAGE_IMPORT_MARKER)}
|
|
665
|
+
(?:import \\{ \\w+ \\} from '@payloadcms\\/storage-[a-z0-9-]+'
|
|
666
|
+
)?`
|
|
667
|
+
);
|
|
668
|
+
if (!importPattern.test(payloadConfigSource)) {
|
|
669
|
+
throw new Error(`payload.config.ts is missing the "${STORAGE_IMPORT_MARKER}" marker`);
|
|
670
|
+
}
|
|
671
|
+
const importLines = storage ? `${STORAGE_IMPORT_MARKER}
|
|
672
|
+
import { ${storage.importName} } from '${storage.packageName}'
|
|
673
|
+
` : `${STORAGE_IMPORT_MARKER}
|
|
674
|
+
`;
|
|
675
|
+
let out = payloadConfigSource.replace(importPattern, importLines);
|
|
676
|
+
const envImport = ENV_IMPORT.exec(out);
|
|
677
|
+
if (!envImport) throw new Error(`payload.config.ts is missing the "@/lib/env" import`);
|
|
678
|
+
const names = envImport[1].split(",").map((name) => name.trim()).filter((name) => name && name !== "requireEnv");
|
|
679
|
+
if (storage?.usesRequireEnv) {
|
|
680
|
+
const at = names.findIndex((name) => name > "requireEnv");
|
|
681
|
+
names.splice(at === -1 ? names.length : at, 0, "requireEnv");
|
|
682
|
+
}
|
|
683
|
+
out = out.replace(ENV_IMPORT, `import { ${names.join(", ")} } from '@/lib/env'`);
|
|
684
|
+
const start = out.indexOf(STORAGE_CONFIG_START);
|
|
685
|
+
const end = out.indexOf(STORAGE_CONFIG_END);
|
|
686
|
+
if (start === -1 || end === -1 || end < start) throw new Error(`payload.config.ts is missing the storage adapter markers`);
|
|
687
|
+
const body = storage ? storage.config : LOCAL_STORAGE_CONFIG;
|
|
688
|
+
return `${out.slice(0, start)}${STORAGE_CONFIG_START}
|
|
689
|
+
${body}
|
|
690
|
+
${out.slice(end)}`;
|
|
691
|
+
}
|
|
692
|
+
function swapStoragePackage(packageJson, storage) {
|
|
693
|
+
const deps = { ...packageJson.dependencies };
|
|
694
|
+
const payloadVersion = deps.payload ?? "latest";
|
|
695
|
+
for (const pkg of STORAGE_PACKAGES) {
|
|
696
|
+
if (pkg !== storage?.packageName) delete deps[pkg];
|
|
697
|
+
}
|
|
698
|
+
if (storage) deps[storage.packageName] = payloadVersion;
|
|
699
|
+
return { ...packageJson, dependencies: sortKeys3(deps) };
|
|
700
|
+
}
|
|
701
|
+
function storageChoice(key) {
|
|
702
|
+
return key === "none" ? null : STORAGE_CHOICES[key];
|
|
703
|
+
}
|
|
225
704
|
function swapDatabasePackage(packageJson, db) {
|
|
226
705
|
const deps = { ...packageJson.dependencies };
|
|
227
706
|
const payloadVersion = deps.payload ?? "latest";
|
|
@@ -229,7 +708,16 @@ function swapDatabasePackage(packageJson, db) {
|
|
|
229
708
|
if (other.packageName !== db.packageName) delete deps[other.packageName];
|
|
230
709
|
}
|
|
231
710
|
deps[db.packageName] = payloadVersion;
|
|
232
|
-
return { ...packageJson, dependencies:
|
|
711
|
+
return { ...packageJson, dependencies: sortKeys3(deps) };
|
|
712
|
+
}
|
|
713
|
+
function stripWorkspaceDependencies(packageJson) {
|
|
714
|
+
const next = { ...packageJson };
|
|
715
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
716
|
+
const deps = next[field];
|
|
717
|
+
if (!deps) continue;
|
|
718
|
+
next[field] = Object.fromEntries(Object.entries(deps).filter(([, version2]) => !version2.startsWith("workspace:")));
|
|
719
|
+
}
|
|
720
|
+
return next;
|
|
233
721
|
}
|
|
234
722
|
function renameProject(packageJson, slug) {
|
|
235
723
|
const next = { ...packageJson, name: slug, version: "0.1.0", private: true };
|
|
@@ -254,7 +742,7 @@ function renderEnv(example, o) {
|
|
|
254
742
|
});
|
|
255
743
|
return lines.join("\n");
|
|
256
744
|
}
|
|
257
|
-
function
|
|
745
|
+
function sortKeys3(obj) {
|
|
258
746
|
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
259
747
|
}
|
|
260
748
|
function escapeRegExp(s) {
|
|
@@ -262,13 +750,25 @@ function escapeRegExp(s) {
|
|
|
262
750
|
}
|
|
263
751
|
async function configureProject(o) {
|
|
264
752
|
const db = DB_CHOICES[o.db];
|
|
265
|
-
const read = (rel) => readFile(
|
|
266
|
-
const write = (rel, content) => writeFile(
|
|
753
|
+
const read = (rel) => readFile(path4.join(o.directory, rel), "utf8");
|
|
754
|
+
const write = (rel, content) => writeFile(path4.join(o.directory, rel), content);
|
|
267
755
|
await write("src/stack.config.ts", renderStackConfig(o));
|
|
756
|
+
const storage = storageChoice(o.storage);
|
|
268
757
|
const payloadConfig = await read("src/payload.config.ts");
|
|
269
|
-
|
|
758
|
+
const withAdapters = swapStorageAdapter(swapDatabaseAdapter(payloadConfig, db), storage);
|
|
759
|
+
await write("src/payload.config.ts", o.emails ? addEmailsPlugin(withAdapters) : withAdapters);
|
|
760
|
+
await applyEmailsChoice(o.directory, o.emails);
|
|
761
|
+
await applyConsentChoice(o.directory, o.consent);
|
|
762
|
+
await removeVariants(o.directory);
|
|
270
763
|
const pkg = JSON.parse(await read("package.json"));
|
|
271
|
-
|
|
764
|
+
const configured = swapConsentPackages(
|
|
765
|
+
swapEmailsPackages(swapStoragePackage(swapDatabasePackage(pkg, db), storage), o.emails),
|
|
766
|
+
o.consent
|
|
767
|
+
);
|
|
768
|
+
await write(
|
|
769
|
+
"package.json",
|
|
770
|
+
JSON.stringify(renameProject(stripWorkspaceDependencies(configured), o.slug), null, 2) + "\n"
|
|
771
|
+
);
|
|
272
772
|
const example = await read(".env.example");
|
|
273
773
|
await write(".env", renderEnv(example, { connectionString: o.connectionString }));
|
|
274
774
|
}
|
|
@@ -278,9 +778,9 @@ var AUTH_METHODS = ["email-password", "magic-link", "passkey"];
|
|
|
278
778
|
var SOCIAL_PROVIDERS = ["google", "github"];
|
|
279
779
|
|
|
280
780
|
// src/template.ts
|
|
281
|
-
import { cp, mkdir, rm } from "fs/promises";
|
|
282
|
-
import { existsSync as
|
|
283
|
-
import
|
|
781
|
+
import { cp as cp3, mkdir, rm as rm4 } from "fs/promises";
|
|
782
|
+
import { existsSync as existsSync4 } from "fs";
|
|
783
|
+
import path5 from "path";
|
|
284
784
|
import { Readable } from "stream";
|
|
285
785
|
import { pipeline } from "stream/promises";
|
|
286
786
|
import * as tar from "tar";
|
|
@@ -308,29 +808,30 @@ Check your network, or pass --local-template <path> to scaffold from a local che
|
|
|
308
808
|
}
|
|
309
809
|
})
|
|
310
810
|
);
|
|
311
|
-
if (!
|
|
811
|
+
if (!existsSync4(path5.join(dest, "package.json"))) {
|
|
312
812
|
throw new Error(`The downloaded archive did not contain ${TEMPLATE_PATH}/package.json (branch "${branch}").`);
|
|
313
813
|
}
|
|
314
814
|
}
|
|
315
815
|
async function copyLocalTemplate({ from, dest }) {
|
|
316
|
-
const source =
|
|
317
|
-
if (!
|
|
816
|
+
const source = path5.resolve(from);
|
|
817
|
+
if (!existsSync4(path5.join(source, "package.json"))) {
|
|
318
818
|
throw new Error(`--local-template must point at a directory containing package.json (got ${source})`);
|
|
319
819
|
}
|
|
320
820
|
await mkdir(dest, { recursive: true });
|
|
321
|
-
await
|
|
821
|
+
await cp3(source, dest, {
|
|
322
822
|
recursive: true,
|
|
323
823
|
filter: (src) => {
|
|
324
|
-
const rel =
|
|
325
|
-
const top = rel.split(
|
|
824
|
+
const rel = path5.relative(source, src);
|
|
825
|
+
const top = rel.split(path5.sep)[0];
|
|
326
826
|
return !["node_modules", ".next", ".turbo", ".env", "test-results", "playwright-report", "next-env.d.ts", "tsconfig.tsbuildinfo", "CLAUDE.md"].includes(top ?? "");
|
|
327
827
|
}
|
|
328
828
|
});
|
|
329
|
-
await
|
|
829
|
+
await rm4(path5.join(dest, "CLAUDE.md"), { force: true });
|
|
330
830
|
}
|
|
331
831
|
|
|
332
832
|
// src/cli.ts
|
|
333
833
|
var DOCS = "https://payload.solutions/docs/payload-stack";
|
|
834
|
+
var CONSENT_DOCS = "https://payload.solutions/docs/plugins/payload-consent/audit";
|
|
334
835
|
function bail(message = "Cancelled.") {
|
|
335
836
|
p.cancel(message);
|
|
336
837
|
process.exit(1);
|
|
@@ -347,9 +848,13 @@ function parseList(raw, allowed, flag) {
|
|
|
347
848
|
}
|
|
348
849
|
return items;
|
|
349
850
|
}
|
|
851
|
+
function listWords(items) {
|
|
852
|
+
if (items.length <= 1) return items.join("");
|
|
853
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
854
|
+
}
|
|
350
855
|
async function run(flags, positionalName) {
|
|
351
856
|
console.log();
|
|
352
|
-
p.intro(
|
|
857
|
+
p.intro(pc2.bgWhite(pc2.black(" create-payload-stack ")));
|
|
353
858
|
const providedName = flags.name ?? positionalName;
|
|
354
859
|
const name = flags.defaults && !providedName ? "my-saas" : providedName ?? guard(
|
|
355
860
|
await p.text({
|
|
@@ -360,7 +865,7 @@ async function run(flags, positionalName) {
|
|
|
360
865
|
})
|
|
361
866
|
);
|
|
362
867
|
const slug = slugify(name);
|
|
363
|
-
const directory =
|
|
868
|
+
const directory = path6.resolve(process.cwd(), slug);
|
|
364
869
|
if (!isDirectoryEmpty(directory)) {
|
|
365
870
|
bail(`Directory ./${slug} already exists and is not empty.`);
|
|
366
871
|
}
|
|
@@ -433,6 +938,34 @@ async function run(flags, positionalName) {
|
|
|
433
938
|
})
|
|
434
939
|
);
|
|
435
940
|
}
|
|
941
|
+
const storageFlag = flags.storage;
|
|
942
|
+
if (storageFlag && !STORAGE_KEYS.includes(storageFlag)) bail(`Unknown value "${storageFlag}" for --storage. Allowed: ${STORAGE_KEYS.join(", ")}`);
|
|
943
|
+
const storage = storageFlag ?? (flags.defaults ? "none" : guard(
|
|
944
|
+
await p.select({
|
|
945
|
+
message: "Media storage",
|
|
946
|
+
initialValue: "none",
|
|
947
|
+
options: [
|
|
948
|
+
{ value: "none", label: "Skip for now", hint: "uploads go to ./media on local disk; add an adapter any time" },
|
|
949
|
+
...STORAGE_ADAPTER_KEYS.map((key) => ({ value: key, label: STORAGE_CHOICES[key].label, hint: STORAGE_CHOICES[key].hint }))
|
|
950
|
+
]
|
|
951
|
+
})
|
|
952
|
+
));
|
|
953
|
+
const emails = flags.emails ?? (flags.defaults ? true : guard(
|
|
954
|
+
await p.confirm({
|
|
955
|
+
message: "Transactional emails",
|
|
956
|
+
initialValue: true,
|
|
957
|
+
active: "Editable in the admin (Payload Emails)",
|
|
958
|
+
inactive: "React Email components in code"
|
|
959
|
+
})
|
|
960
|
+
));
|
|
961
|
+
const consent = flags.consent ?? (flags.defaults ? true : guard(
|
|
962
|
+
await p.confirm({
|
|
963
|
+
message: "Cookie consent and legal pages",
|
|
964
|
+
initialValue: true,
|
|
965
|
+
active: "Banner, consent records and audited legal pages (Payload Consent)",
|
|
966
|
+
inactive: "Plain legal pages, no banner"
|
|
967
|
+
})
|
|
968
|
+
));
|
|
436
969
|
const packageManager = flags.packageManager ?? await detectPackageManager();
|
|
437
970
|
const options = {
|
|
438
971
|
name,
|
|
@@ -444,6 +977,9 @@ async function run(flags, positionalName) {
|
|
|
444
977
|
social,
|
|
445
978
|
organizations,
|
|
446
979
|
billing,
|
|
980
|
+
storage,
|
|
981
|
+
emails,
|
|
982
|
+
consent,
|
|
447
983
|
packageManager,
|
|
448
984
|
install: flags.install,
|
|
449
985
|
git: flags.git
|
|
@@ -475,11 +1011,11 @@ async function run(flags, positionalName) {
|
|
|
475
1011
|
s2.stop(`Installed dependencies with ${packageManager}`);
|
|
476
1012
|
} catch (error) {
|
|
477
1013
|
s2.stop("Dependency installation failed");
|
|
478
|
-
p.log.warn(`Run ${
|
|
1014
|
+
p.log.warn(`Run ${pc2.cyan(installCommand(packageManager))} inside ./${slug} to retry.
|
|
479
1015
|
${error instanceof Error ? error.message.split("\n")[0] : ""}`);
|
|
480
1016
|
}
|
|
481
1017
|
}
|
|
482
|
-
if (options.git && !
|
|
1018
|
+
if (options.git && !existsSync5(path6.join(directory, ".git"))) {
|
|
483
1019
|
try {
|
|
484
1020
|
await execa2("git", ["init", "-b", "main"], { cwd: directory });
|
|
485
1021
|
await execa2("git", ["add", "-A"], { cwd: directory });
|
|
@@ -494,109 +1030,33 @@ ${error instanceof Error ? error.message.split("\n")[0] : ""}`);
|
|
|
494
1030
|
...db === "sqlite" ? [] : ["# start your database, then check DATABASE_URL in .env"],
|
|
495
1031
|
...social.length ? [`# add ${social.map((sp) => `${sp.toUpperCase()}_CLIENT_ID / _SECRET`).join(" and ")} to .env`] : [],
|
|
496
1032
|
...billing !== "none" ? ["# add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and NEXT_PUBLIC_STRIPE_PRICE_* to .env when you are ready for billing"] : [],
|
|
1033
|
+
...storage !== "none" ? [`# add ${listWords(STORAGE_CHOICES[storage].envVars)} to .env to store uploads in ${STORAGE_CHOICES[storage].label} (local disk until then)`] : [],
|
|
497
1034
|
runCommand(packageManager, "dev"),
|
|
498
|
-
"open http://localhost:3000/admin"
|
|
1035
|
+
"open http://localhost:3000/admin",
|
|
1036
|
+
...emails ? ["# email copy is under Emails in the admin; the design is src/emails/template.tsx"] : [],
|
|
1037
|
+
...consent ? [
|
|
1038
|
+
"# the seeded legal pages are templates, not policy: the homepage shows the six steps in development",
|
|
1039
|
+
`# ${CONSENT_DOCS}`
|
|
1040
|
+
] : []
|
|
499
1041
|
];
|
|
500
1042
|
p.note(steps.join("\n"), "Done. Next steps:");
|
|
501
|
-
p.outro(`Docs: ${
|
|
1043
|
+
p.outro(`Docs: ${pc2.underline(DOCS)}`);
|
|
502
1044
|
}
|
|
503
1045
|
|
|
504
1046
|
// src/index.ts
|
|
505
1047
|
var require2 = createRequire(import.meta.url);
|
|
506
1048
|
var { version } = require2("../package.json");
|
|
507
|
-
var HELP = `
|
|
508
|
-
${pc2.bold("create-payload-stack")} ${pc2.dim(`v${version}`)}
|
|
509
|
-
Scaffold a SaaS on Payload CMS: Better Auth, organizations, Stripe and a shadcn dashboard.
|
|
510
|
-
|
|
511
|
-
${pc2.bold("Usage")}
|
|
512
|
-
$ npx create-payload-stack@latest [name] [options]
|
|
513
|
-
|
|
514
|
-
${pc2.bold("Options")}
|
|
515
|
-
-n, --name <name> Project name (also the directory)
|
|
516
|
-
-d, --db <db> ${DB_KEYS.join(" | ")}
|
|
517
|
-
--db-connection-string Connection string written to .env
|
|
518
|
-
--auth <list> Comma-separated: email-password, magic-link, passkey
|
|
519
|
-
--social <list> Comma-separated: google, github
|
|
520
|
-
--organizations Enable organizations (teams)
|
|
521
|
-
--no-organizations Disable organizations
|
|
522
|
-
--billing <mode> organization | user | none
|
|
523
|
-
--use-pnpm | --use-npm | --use-yarn | --use-bun
|
|
524
|
-
--no-install Skip dependency installation
|
|
525
|
-
--no-git Skip git init
|
|
526
|
-
-y, --defaults Accept defaults for every unanswered prompt
|
|
527
|
-
--branch <ref> Git branch or tag of the template repo (default: main)
|
|
528
|
-
--local-template <path> Scaffold from a local checkout of templates/payload-stack
|
|
529
|
-
--dry-run Print the resolved options and exit
|
|
530
|
-
-h, --help Show this help
|
|
531
|
-
-v, --version Show the version
|
|
532
|
-
|
|
533
|
-
${pc2.bold("Examples")}
|
|
534
|
-
$ npx create-payload-stack@latest ridgeline
|
|
535
|
-
$ npx create-payload-stack@latest ridgeline -d sqlite --auth email-password,passkey --billing none -y
|
|
536
|
-
|
|
537
|
-
Docs: https://payload.solutions/docs/payload-stack
|
|
538
|
-
`;
|
|
539
|
-
function parse(argv) {
|
|
540
|
-
const { values, positionals } = parseArgs({
|
|
541
|
-
args: argv,
|
|
542
|
-
allowPositionals: true,
|
|
543
|
-
allowNegative: true,
|
|
544
|
-
options: {
|
|
545
|
-
name: { type: "string", short: "n" },
|
|
546
|
-
db: { type: "string", short: "d" },
|
|
547
|
-
"db-connection-string": { type: "string" },
|
|
548
|
-
auth: { type: "string" },
|
|
549
|
-
social: { type: "string" },
|
|
550
|
-
organizations: { type: "boolean" },
|
|
551
|
-
billing: { type: "string" },
|
|
552
|
-
"use-pnpm": { type: "boolean" },
|
|
553
|
-
"use-npm": { type: "boolean" },
|
|
554
|
-
"use-yarn": { type: "boolean" },
|
|
555
|
-
"use-bun": { type: "boolean" },
|
|
556
|
-
install: { type: "boolean", default: true },
|
|
557
|
-
git: { type: "boolean", default: true },
|
|
558
|
-
defaults: { type: "boolean", short: "y", default: false },
|
|
559
|
-
branch: { type: "string", default: "main" },
|
|
560
|
-
"local-template": { type: "string" },
|
|
561
|
-
"dry-run": { type: "boolean", default: false },
|
|
562
|
-
help: { type: "boolean", short: "h", default: false },
|
|
563
|
-
version: { type: "boolean", short: "v", default: false }
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
const packageManager = values["use-pnpm"] ? "pnpm" : values["use-npm"] ? "npm" : values["use-yarn"] ? "yarn" : values["use-bun"] ? "bun" : void 0;
|
|
567
|
-
return {
|
|
568
|
-
flags: {
|
|
569
|
-
name: values.name,
|
|
570
|
-
db: values.db,
|
|
571
|
-
connectionString: values["db-connection-string"],
|
|
572
|
-
auth: values.auth,
|
|
573
|
-
social: values.social,
|
|
574
|
-
organizations: values.organizations,
|
|
575
|
-
billing: values.billing,
|
|
576
|
-
packageManager,
|
|
577
|
-
install: values.install ?? true,
|
|
578
|
-
git: values.git ?? true,
|
|
579
|
-
defaults: values.defaults ?? false,
|
|
580
|
-
branch: values.branch ?? "main",
|
|
581
|
-
localTemplate: values["local-template"],
|
|
582
|
-
dryRun: values["dry-run"] ?? false,
|
|
583
|
-
help: values.help ?? false,
|
|
584
|
-
version: values.version ?? false
|
|
585
|
-
},
|
|
586
|
-
positional: positionals[0]
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
1049
|
async function main() {
|
|
590
1050
|
let parsed;
|
|
591
1051
|
try {
|
|
592
1052
|
parsed = parse(process.argv.slice(2));
|
|
593
1053
|
} catch (error) {
|
|
594
|
-
console.error(
|
|
595
|
-
console.log(
|
|
1054
|
+
console.error(pc3.red(error instanceof Error ? error.message : String(error)));
|
|
1055
|
+
console.log(helpText(version));
|
|
596
1056
|
process.exit(1);
|
|
597
1057
|
}
|
|
598
1058
|
if (parsed.flags.help) {
|
|
599
|
-
console.log(
|
|
1059
|
+
console.log(helpText(version));
|
|
600
1060
|
return;
|
|
601
1061
|
}
|
|
602
1062
|
if (parsed.flags.version) {
|
|
@@ -606,6 +1066,6 @@ async function main() {
|
|
|
606
1066
|
await run(parsed.flags, parsed.positional);
|
|
607
1067
|
}
|
|
608
1068
|
main().catch((error) => {
|
|
609
|
-
console.error(
|
|
1069
|
+
console.error(pc3.red(error instanceof Error ? error.stack ?? error.message : String(error)));
|
|
610
1070
|
process.exit(1);
|
|
611
1071
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-payload-stack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -33,14 +33,6 @@
|
|
|
33
33
|
"engines": {
|
|
34
34
|
"node": ">=20.9.0"
|
|
35
35
|
},
|
|
36
|
-
"scripts": {
|
|
37
|
-
"build": "tsup",
|
|
38
|
-
"dev": "tsup --watch",
|
|
39
|
-
"typecheck": "tsc --noEmit",
|
|
40
|
-
"test": "vitest run",
|
|
41
|
-
"clean": "rm -rf dist .turbo",
|
|
42
|
-
"prepublishOnly": "pnpm build"
|
|
43
|
-
},
|
|
44
36
|
"dependencies": {
|
|
45
37
|
"@clack/prompts": "^1.7.0",
|
|
46
38
|
"@sindresorhus/slugify": "^3.0.1",
|
|
@@ -53,5 +45,13 @@
|
|
|
53
45
|
"tsup": "^8.5.1",
|
|
54
46
|
"typescript": "^5.9.3",
|
|
55
47
|
"vitest": "^4.0.18"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsup --watch",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "pnpm build && vitest run",
|
|
54
|
+
"clean": "rm -rf dist .turbo",
|
|
55
|
+
"test:unit": "vitest run --exclude test/scaffold.test.ts"
|
|
56
56
|
}
|
|
57
|
-
}
|
|
57
|
+
}
|