create-payload-stack 0.1.0 → 0.3.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 +351 -102
- package/package.json +26 -12
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
|
+
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.
|
|
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 -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,6 +68,269 @@ 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
|
+
--use-pnpm | --use-npm | --use-yarn | --use-bun
|
|
253
|
+
--no-install Skip dependency installation
|
|
254
|
+
--no-git Skip git init
|
|
255
|
+
-y, --defaults Accept defaults for every unanswered prompt
|
|
256
|
+
--branch <ref> Git branch or tag of the template repo (default: main)
|
|
257
|
+
--local-template <path> Scaffold from a local checkout of templates/payload-stack
|
|
258
|
+
--dry-run Print the resolved options and exit
|
|
259
|
+
-h, --help Show this help
|
|
260
|
+
-v, --version Show the version
|
|
261
|
+
|
|
262
|
+
${pc.bold("Examples")}
|
|
263
|
+
$ npx create-payload-stack@latest ridgeline
|
|
264
|
+
$ npx create-payload-stack@latest ridgeline -d sqlite --auth email-password,passkey --billing none -y
|
|
265
|
+
$ npx create-payload-stack@latest ridgeline --storage vercel-blob -y
|
|
266
|
+
|
|
267
|
+
Docs: https://payload.solutions/docs/payload-stack
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
function parse(argv) {
|
|
271
|
+
const { values, positionals } = parseArgs({
|
|
272
|
+
args: argv,
|
|
273
|
+
allowPositionals: true,
|
|
274
|
+
allowNegative: true,
|
|
275
|
+
options: {
|
|
276
|
+
name: { type: "string", short: "n" },
|
|
277
|
+
db: { type: "string", short: "d" },
|
|
278
|
+
"db-connection-string": { type: "string" },
|
|
279
|
+
auth: { type: "string" },
|
|
280
|
+
social: { type: "string" },
|
|
281
|
+
organizations: { type: "boolean" },
|
|
282
|
+
billing: { type: "string" },
|
|
283
|
+
storage: { type: "string" },
|
|
284
|
+
"use-pnpm": { type: "boolean" },
|
|
285
|
+
"use-npm": { type: "boolean" },
|
|
286
|
+
"use-yarn": { type: "boolean" },
|
|
287
|
+
"use-bun": { type: "boolean" },
|
|
288
|
+
install: { type: "boolean", default: true },
|
|
289
|
+
git: { type: "boolean", default: true },
|
|
290
|
+
defaults: { type: "boolean", short: "y", default: false },
|
|
291
|
+
branch: { type: "string", default: "main" },
|
|
292
|
+
"local-template": { type: "string" },
|
|
293
|
+
"dry-run": { type: "boolean", default: false },
|
|
294
|
+
help: { type: "boolean", short: "h", default: false },
|
|
295
|
+
version: { type: "boolean", short: "v", default: false }
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
const packageManager = values["use-pnpm"] ? "pnpm" : values["use-npm"] ? "npm" : values["use-yarn"] ? "yarn" : values["use-bun"] ? "bun" : void 0;
|
|
299
|
+
return {
|
|
300
|
+
flags: {
|
|
301
|
+
name: values.name,
|
|
302
|
+
db: values.db,
|
|
303
|
+
connectionString: values["db-connection-string"],
|
|
304
|
+
auth: values.auth,
|
|
305
|
+
social: values.social,
|
|
306
|
+
organizations: values.organizations,
|
|
307
|
+
billing: values.billing,
|
|
308
|
+
storage: values.storage,
|
|
309
|
+
packageManager,
|
|
310
|
+
install: values.install ?? true,
|
|
311
|
+
git: values.git ?? true,
|
|
312
|
+
defaults: values.defaults ?? false,
|
|
313
|
+
branch: values.branch ?? "main",
|
|
314
|
+
localTemplate: values["local-template"],
|
|
315
|
+
dryRun: values["dry-run"] ?? false,
|
|
316
|
+
help: values.help ?? false,
|
|
317
|
+
version: values.version ?? false
|
|
318
|
+
},
|
|
319
|
+
positional: positionals[0]
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/cli.ts
|
|
324
|
+
import * as p from "@clack/prompts";
|
|
325
|
+
import { execa as execa2 } from "execa";
|
|
326
|
+
import { existsSync as existsSync3 } from "fs";
|
|
327
|
+
import path3 from "path";
|
|
328
|
+
import pc2 from "picocolors";
|
|
329
|
+
|
|
330
|
+
// src/configure.ts
|
|
331
|
+
import { readFile, writeFile } from "fs/promises";
|
|
332
|
+
import path from "path";
|
|
333
|
+
|
|
79
334
|
// src/utils.ts
|
|
80
335
|
import slugifyBase from "@sindresorhus/slugify";
|
|
81
336
|
import { randomBytes } from "crypto";
|
|
@@ -161,6 +416,9 @@ function renderStackConfig(o) {
|
|
|
161
416
|
* Pricing page, checkout, entitlements, sign-in screens, navigation, legal pages and emails all
|
|
162
417
|
* read from here. Secrets never go in this file: they stay in .env and are read in src/lib/env.ts.
|
|
163
418
|
* Stripe price ids are public identifiers, so they may come from NEXT_PUBLIC_* variables.
|
|
419
|
+
*
|
|
420
|
+
* The object is typed (StackInput in src/lib/stack.ts): your editor suggests every key and value,
|
|
421
|
+
* pnpm typecheck fails on typos or unknown keys, and the schema rejects them again at boot.
|
|
164
422
|
*/
|
|
165
423
|
export default defineStack({
|
|
166
424
|
name: '${o.name.replace(/'/g, "\\'")}',
|
|
@@ -187,6 +445,11 @@ export default defineStack({
|
|
|
187
445
|
|
|
188
446
|
${billingBlock}
|
|
189
447
|
|
|
448
|
+
observability: {
|
|
449
|
+
tracesSampleRate: 0.1,
|
|
450
|
+
sendPII: false,
|
|
451
|
+
},
|
|
452
|
+
|
|
190
453
|
legal: {
|
|
191
454
|
company: '${o.name.replace(/'/g, "\\'")} Ltd',
|
|
192
455
|
jurisdiction: 'Ireland',
|
|
@@ -219,6 +482,52 @@ import { ${db.importName} } from '${db.packageName}'`;
|
|
|
219
482
|
${db.config}
|
|
220
483
|
${withImport.slice(end)}`;
|
|
221
484
|
}
|
|
485
|
+
var STORAGE_IMPORT_MARKER = "// storage-adapter-import";
|
|
486
|
+
var STORAGE_CONFIG_START = "// storage-adapter-config-start";
|
|
487
|
+
var STORAGE_CONFIG_END = "// storage-adapter-config-end";
|
|
488
|
+
var ENV_IMPORT = /^import \{ ([^}]*) \} from '@\/lib\/env'$/m;
|
|
489
|
+
function swapStorageAdapter(payloadConfigSource, storage) {
|
|
490
|
+
const importPattern = new RegExp(
|
|
491
|
+
`${escapeRegExp(STORAGE_IMPORT_MARKER)}
|
|
492
|
+
(?:import \\{ \\w+ \\} from '@payloadcms\\/storage-[a-z0-9-]+'
|
|
493
|
+
)?`
|
|
494
|
+
);
|
|
495
|
+
if (!importPattern.test(payloadConfigSource)) {
|
|
496
|
+
throw new Error(`payload.config.ts is missing the "${STORAGE_IMPORT_MARKER}" marker`);
|
|
497
|
+
}
|
|
498
|
+
const importLines = storage ? `${STORAGE_IMPORT_MARKER}
|
|
499
|
+
import { ${storage.importName} } from '${storage.packageName}'
|
|
500
|
+
` : `${STORAGE_IMPORT_MARKER}
|
|
501
|
+
`;
|
|
502
|
+
let out = payloadConfigSource.replace(importPattern, importLines);
|
|
503
|
+
const envImport = ENV_IMPORT.exec(out);
|
|
504
|
+
if (!envImport) throw new Error(`payload.config.ts is missing the "@/lib/env" import`);
|
|
505
|
+
const names = envImport[1].split(",").map((name) => name.trim()).filter((name) => name && name !== "requireEnv");
|
|
506
|
+
if (storage?.usesRequireEnv) {
|
|
507
|
+
const at = names.findIndex((name) => name > "requireEnv");
|
|
508
|
+
names.splice(at === -1 ? names.length : at, 0, "requireEnv");
|
|
509
|
+
}
|
|
510
|
+
out = out.replace(ENV_IMPORT, `import { ${names.join(", ")} } from '@/lib/env'`);
|
|
511
|
+
const start = out.indexOf(STORAGE_CONFIG_START);
|
|
512
|
+
const end = out.indexOf(STORAGE_CONFIG_END);
|
|
513
|
+
if (start === -1 || end === -1 || end < start) throw new Error(`payload.config.ts is missing the storage adapter markers`);
|
|
514
|
+
const body = storage ? storage.config : LOCAL_STORAGE_CONFIG;
|
|
515
|
+
return `${out.slice(0, start)}${STORAGE_CONFIG_START}
|
|
516
|
+
${body}
|
|
517
|
+
${out.slice(end)}`;
|
|
518
|
+
}
|
|
519
|
+
function swapStoragePackage(packageJson, storage) {
|
|
520
|
+
const deps = { ...packageJson.dependencies };
|
|
521
|
+
const payloadVersion = deps.payload ?? "latest";
|
|
522
|
+
for (const pkg of STORAGE_PACKAGES) {
|
|
523
|
+
if (pkg !== storage?.packageName) delete deps[pkg];
|
|
524
|
+
}
|
|
525
|
+
if (storage) deps[storage.packageName] = payloadVersion;
|
|
526
|
+
return { ...packageJson, dependencies: sortKeys(deps) };
|
|
527
|
+
}
|
|
528
|
+
function storageChoice(key) {
|
|
529
|
+
return key === "none" ? null : STORAGE_CHOICES[key];
|
|
530
|
+
}
|
|
222
531
|
function swapDatabasePackage(packageJson, db) {
|
|
223
532
|
const deps = { ...packageJson.dependencies };
|
|
224
533
|
const payloadVersion = deps.payload ?? "latest";
|
|
@@ -262,10 +571,14 @@ async function configureProject(o) {
|
|
|
262
571
|
const read = (rel) => readFile(path.join(o.directory, rel), "utf8");
|
|
263
572
|
const write = (rel, content) => writeFile(path.join(o.directory, rel), content);
|
|
264
573
|
await write("src/stack.config.ts", renderStackConfig(o));
|
|
574
|
+
const storage = storageChoice(o.storage);
|
|
265
575
|
const payloadConfig = await read("src/payload.config.ts");
|
|
266
|
-
await write("src/payload.config.ts", swapDatabaseAdapter(payloadConfig, db));
|
|
576
|
+
await write("src/payload.config.ts", swapStorageAdapter(swapDatabaseAdapter(payloadConfig, db), storage));
|
|
267
577
|
const pkg = JSON.parse(await read("package.json"));
|
|
268
|
-
await write(
|
|
578
|
+
await write(
|
|
579
|
+
"package.json",
|
|
580
|
+
JSON.stringify(renameProject(swapStoragePackage(swapDatabasePackage(pkg, db), storage), o.slug), null, 2) + "\n"
|
|
581
|
+
);
|
|
269
582
|
const example = await read(".env.example");
|
|
270
583
|
await write(".env", renderEnv(example, { connectionString: o.connectionString }));
|
|
271
584
|
}
|
|
@@ -344,9 +657,13 @@ function parseList(raw, allowed, flag) {
|
|
|
344
657
|
}
|
|
345
658
|
return items;
|
|
346
659
|
}
|
|
660
|
+
function listWords(items) {
|
|
661
|
+
if (items.length <= 1) return items.join("");
|
|
662
|
+
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
|
|
663
|
+
}
|
|
347
664
|
async function run(flags, positionalName) {
|
|
348
665
|
console.log();
|
|
349
|
-
p.intro(
|
|
666
|
+
p.intro(pc2.bgWhite(pc2.black(" create-payload-stack ")));
|
|
350
667
|
const providedName = flags.name ?? positionalName;
|
|
351
668
|
const name = flags.defaults && !providedName ? "my-saas" : providedName ?? guard(
|
|
352
669
|
await p.text({
|
|
@@ -430,6 +747,18 @@ async function run(flags, positionalName) {
|
|
|
430
747
|
})
|
|
431
748
|
);
|
|
432
749
|
}
|
|
750
|
+
const storageFlag = flags.storage;
|
|
751
|
+
if (storageFlag && !STORAGE_KEYS.includes(storageFlag)) bail(`Unknown value "${storageFlag}" for --storage. Allowed: ${STORAGE_KEYS.join(", ")}`);
|
|
752
|
+
const storage = storageFlag ?? (flags.defaults ? "none" : guard(
|
|
753
|
+
await p.select({
|
|
754
|
+
message: "Media storage",
|
|
755
|
+
initialValue: "none",
|
|
756
|
+
options: [
|
|
757
|
+
{ value: "none", label: "Skip for now", hint: "uploads go to ./media on local disk; add an adapter any time" },
|
|
758
|
+
...STORAGE_ADAPTER_KEYS.map((key) => ({ value: key, label: STORAGE_CHOICES[key].label, hint: STORAGE_CHOICES[key].hint }))
|
|
759
|
+
]
|
|
760
|
+
})
|
|
761
|
+
));
|
|
433
762
|
const packageManager = flags.packageManager ?? await detectPackageManager();
|
|
434
763
|
const options = {
|
|
435
764
|
name,
|
|
@@ -441,6 +770,7 @@ async function run(flags, positionalName) {
|
|
|
441
770
|
social,
|
|
442
771
|
organizations,
|
|
443
772
|
billing,
|
|
773
|
+
storage,
|
|
444
774
|
packageManager,
|
|
445
775
|
install: flags.install,
|
|
446
776
|
git: flags.git
|
|
@@ -472,7 +802,7 @@ async function run(flags, positionalName) {
|
|
|
472
802
|
s2.stop(`Installed dependencies with ${packageManager}`);
|
|
473
803
|
} catch (error) {
|
|
474
804
|
s2.stop("Dependency installation failed");
|
|
475
|
-
p.log.warn(`Run ${
|
|
805
|
+
p.log.warn(`Run ${pc2.cyan(installCommand(packageManager))} inside ./${slug} to retry.
|
|
476
806
|
${error instanceof Error ? error.message.split("\n")[0] : ""}`);
|
|
477
807
|
}
|
|
478
808
|
}
|
|
@@ -491,109 +821,28 @@ ${error instanceof Error ? error.message.split("\n")[0] : ""}`);
|
|
|
491
821
|
...db === "sqlite" ? [] : ["# start your database, then check DATABASE_URL in .env"],
|
|
492
822
|
...social.length ? [`# add ${social.map((sp) => `${sp.toUpperCase()}_CLIENT_ID / _SECRET`).join(" and ")} to .env`] : [],
|
|
493
823
|
...billing !== "none" ? ["# add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and NEXT_PUBLIC_STRIPE_PRICE_* to .env when you are ready for billing"] : [],
|
|
824
|
+
...storage !== "none" ? [`# add ${listWords(STORAGE_CHOICES[storage].envVars)} to .env to store uploads in ${STORAGE_CHOICES[storage].label} (local disk until then)`] : [],
|
|
494
825
|
runCommand(packageManager, "dev"),
|
|
495
826
|
"open http://localhost:3000/admin"
|
|
496
827
|
];
|
|
497
828
|
p.note(steps.join("\n"), "Done. Next steps:");
|
|
498
|
-
p.outro(`Docs: ${
|
|
829
|
+
p.outro(`Docs: ${pc2.underline(DOCS)}`);
|
|
499
830
|
}
|
|
500
831
|
|
|
501
832
|
// src/index.ts
|
|
502
833
|
var require2 = createRequire(import.meta.url);
|
|
503
834
|
var { version } = require2("../package.json");
|
|
504
|
-
var HELP = `
|
|
505
|
-
${pc2.bold("create-payload-stack")} ${pc2.dim(`v${version}`)}
|
|
506
|
-
Scaffold a SaaS on Payload CMS: Better Auth, organizations, Stripe and a shadcn dashboard.
|
|
507
|
-
|
|
508
|
-
${pc2.bold("Usage")}
|
|
509
|
-
$ npx create-payload-stack@latest [name] [options]
|
|
510
|
-
|
|
511
|
-
${pc2.bold("Options")}
|
|
512
|
-
-n, --name <name> Project name (also the directory)
|
|
513
|
-
-d, --db <db> ${DB_KEYS.join(" | ")}
|
|
514
|
-
--db-connection-string Connection string written to .env
|
|
515
|
-
--auth <list> Comma-separated: email-password, magic-link, passkey
|
|
516
|
-
--social <list> Comma-separated: google, github
|
|
517
|
-
--organizations Enable organizations (teams)
|
|
518
|
-
--no-organizations Disable organizations
|
|
519
|
-
--billing <mode> organization | user | none
|
|
520
|
-
--use-pnpm | --use-npm | --use-yarn | --use-bun
|
|
521
|
-
--no-install Skip dependency installation
|
|
522
|
-
--no-git Skip git init
|
|
523
|
-
-y, --defaults Accept defaults for every unanswered prompt
|
|
524
|
-
--branch <ref> Git branch or tag of the template repo (default: main)
|
|
525
|
-
--local-template <path> Scaffold from a local checkout of templates/payload-stack
|
|
526
|
-
--dry-run Print the resolved options and exit
|
|
527
|
-
-h, --help Show this help
|
|
528
|
-
-v, --version Show the version
|
|
529
|
-
|
|
530
|
-
${pc2.bold("Examples")}
|
|
531
|
-
$ npx create-payload-stack@latest ridgeline
|
|
532
|
-
$ npx create-payload-stack@latest ridgeline -d sqlite --auth email-password,passkey --billing none -y
|
|
533
|
-
|
|
534
|
-
Docs: https://payload.solutions/docs/payload-stack
|
|
535
|
-
`;
|
|
536
|
-
function parse(argv) {
|
|
537
|
-
const { values, positionals } = parseArgs({
|
|
538
|
-
args: argv,
|
|
539
|
-
allowPositionals: true,
|
|
540
|
-
allowNegative: true,
|
|
541
|
-
options: {
|
|
542
|
-
name: { type: "string", short: "n" },
|
|
543
|
-
db: { type: "string", short: "d" },
|
|
544
|
-
"db-connection-string": { type: "string" },
|
|
545
|
-
auth: { type: "string" },
|
|
546
|
-
social: { type: "string" },
|
|
547
|
-
organizations: { type: "boolean" },
|
|
548
|
-
billing: { type: "string" },
|
|
549
|
-
"use-pnpm": { type: "boolean" },
|
|
550
|
-
"use-npm": { type: "boolean" },
|
|
551
|
-
"use-yarn": { type: "boolean" },
|
|
552
|
-
"use-bun": { type: "boolean" },
|
|
553
|
-
install: { type: "boolean", default: true },
|
|
554
|
-
git: { type: "boolean", default: true },
|
|
555
|
-
defaults: { type: "boolean", short: "y", default: false },
|
|
556
|
-
branch: { type: "string", default: "main" },
|
|
557
|
-
"local-template": { type: "string" },
|
|
558
|
-
"dry-run": { type: "boolean", default: false },
|
|
559
|
-
help: { type: "boolean", short: "h", default: false },
|
|
560
|
-
version: { type: "boolean", short: "v", default: false }
|
|
561
|
-
}
|
|
562
|
-
});
|
|
563
|
-
const packageManager = values["use-pnpm"] ? "pnpm" : values["use-npm"] ? "npm" : values["use-yarn"] ? "yarn" : values["use-bun"] ? "bun" : void 0;
|
|
564
|
-
return {
|
|
565
|
-
flags: {
|
|
566
|
-
name: values.name,
|
|
567
|
-
db: values.db,
|
|
568
|
-
connectionString: values["db-connection-string"],
|
|
569
|
-
auth: values.auth,
|
|
570
|
-
social: values.social,
|
|
571
|
-
organizations: values.organizations,
|
|
572
|
-
billing: values.billing,
|
|
573
|
-
packageManager,
|
|
574
|
-
install: values.install ?? true,
|
|
575
|
-
git: values.git ?? true,
|
|
576
|
-
defaults: values.defaults ?? false,
|
|
577
|
-
branch: values.branch ?? "main",
|
|
578
|
-
localTemplate: values["local-template"],
|
|
579
|
-
dryRun: values["dry-run"] ?? false,
|
|
580
|
-
help: values.help ?? false,
|
|
581
|
-
version: values.version ?? false
|
|
582
|
-
},
|
|
583
|
-
positional: positionals[0]
|
|
584
|
-
};
|
|
585
|
-
}
|
|
586
835
|
async function main() {
|
|
587
836
|
let parsed;
|
|
588
837
|
try {
|
|
589
838
|
parsed = parse(process.argv.slice(2));
|
|
590
839
|
} catch (error) {
|
|
591
|
-
console.error(
|
|
592
|
-
console.log(
|
|
840
|
+
console.error(pc3.red(error instanceof Error ? error.message : String(error)));
|
|
841
|
+
console.log(helpText(version));
|
|
593
842
|
process.exit(1);
|
|
594
843
|
}
|
|
595
844
|
if (parsed.flags.help) {
|
|
596
|
-
console.log(
|
|
845
|
+
console.log(helpText(version));
|
|
597
846
|
return;
|
|
598
847
|
}
|
|
599
848
|
if (parsed.flags.version) {
|
|
@@ -603,6 +852,6 @@ async function main() {
|
|
|
603
852
|
await run(parsed.flags, parsed.positional);
|
|
604
853
|
}
|
|
605
854
|
main().catch((error) => {
|
|
606
|
-
console.error(
|
|
855
|
+
console.error(pc3.red(error instanceof Error ? error.stack ?? error.message : String(error)));
|
|
607
856
|
process.exit(1);
|
|
608
857
|
});
|
package/package.json
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-payload-stack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Scaffold a SaaS on Payload CMS: Better Auth, organizations, Stripe subscriptions and a shadcn dashboard, in one command.",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"payload",
|
|
7
|
+
"payloadcms",
|
|
8
|
+
"saas",
|
|
9
|
+
"boilerplate",
|
|
10
|
+
"starter",
|
|
11
|
+
"better-auth",
|
|
12
|
+
"stripe",
|
|
13
|
+
"shadcn",
|
|
14
|
+
"nextjs",
|
|
15
|
+
"create"
|
|
16
|
+
],
|
|
6
17
|
"license": "MIT",
|
|
7
18
|
"homepage": "https://www.payloadstack.com",
|
|
8
19
|
"repository": {
|
|
@@ -15,18 +26,13 @@
|
|
|
15
26
|
"bin": {
|
|
16
27
|
"create-payload-stack": "./dist/index.js"
|
|
17
28
|
},
|
|
18
|
-
"files": [
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
19
33
|
"engines": {
|
|
20
34
|
"node": ">=20.9.0"
|
|
21
35
|
},
|
|
22
|
-
"scripts": {
|
|
23
|
-
"build": "tsup",
|
|
24
|
-
"dev": "tsup --watch",
|
|
25
|
-
"typecheck": "tsc --noEmit",
|
|
26
|
-
"test": "vitest run",
|
|
27
|
-
"clean": "rm -rf dist .turbo",
|
|
28
|
-
"prepublishOnly": "pnpm build"
|
|
29
|
-
},
|
|
30
36
|
"dependencies": {
|
|
31
37
|
"@clack/prompts": "^1.7.0",
|
|
32
38
|
"@sindresorhus/slugify": "^3.0.1",
|
|
@@ -39,5 +45,13 @@
|
|
|
39
45
|
"tsup": "^8.5.1",
|
|
40
46
|
"typescript": "^5.9.3",
|
|
41
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"
|
|
42
56
|
}
|
|
43
|
-
}
|
|
57
|
+
}
|