create-payload-stack 0.1.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 +26 -0
- package/dist/index.js +608 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# create-payload-stack
|
|
2
|
+
|
|
3
|
+
Scaffold a SaaS on Payload CMS in one command.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx create-payload-stack@latest
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Six prompts: project name, database (PostgreSQL, MongoDB, SQLite, Vercel Postgres), connection string, sign-in methods, organizations, billing. The CLI downloads [Payload Stack](https://www.payloadstack.com), writes your answers into `src/stack.config.ts`, swaps the Payload database adapter, generates `.env` with fresh secrets, installs dependencies and initializes git.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# non-interactive
|
|
13
|
+
npx create-payload-stack@latest ridgeline -d postgres --auth email-password,magic-link,passkey --social github --billing organization -y
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Run `npx create-payload-stack@latest --help` for every flag. Documentation: https://payload.solutions/docs/payload-stack
|
|
17
|
+
|
|
18
|
+
## Development
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm --filter create-payload-stack build
|
|
22
|
+
node packages/create-payload-stack/dist/index.js my-app --local-template templates/payload-stack --no-install
|
|
23
|
+
pnpm --filter create-payload-stack test
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
MIT. A [payload.solutions](https://payload.solutions) project.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import pc2 from "picocolors";
|
|
7
|
+
|
|
8
|
+
// src/cli.ts
|
|
9
|
+
import * as p from "@clack/prompts";
|
|
10
|
+
import { execa as execa2 } from "execa";
|
|
11
|
+
import { existsSync as existsSync3 } from "fs";
|
|
12
|
+
import path3 from "path";
|
|
13
|
+
import pc from "picocolors";
|
|
14
|
+
|
|
15
|
+
// src/configure.ts
|
|
16
|
+
import { readFile, writeFile } from "fs/promises";
|
|
17
|
+
import path from "path";
|
|
18
|
+
|
|
19
|
+
// src/databases.ts
|
|
20
|
+
var DB_CHOICES = {
|
|
21
|
+
postgres: {
|
|
22
|
+
key: "postgres",
|
|
23
|
+
label: "PostgreSQL",
|
|
24
|
+
hint: "recommended",
|
|
25
|
+
packageName: "@payloadcms/db-postgres",
|
|
26
|
+
importName: "postgresAdapter",
|
|
27
|
+
connectionPrefix: "postgres://postgres:<password>@127.0.0.1:5432/",
|
|
28
|
+
connectionSuffix: "",
|
|
29
|
+
config: `db: postgresAdapter({
|
|
30
|
+
pool: {
|
|
31
|
+
connectionString: env.DATABASE_URL,
|
|
32
|
+
},
|
|
33
|
+
}),`
|
|
34
|
+
},
|
|
35
|
+
mongodb: {
|
|
36
|
+
key: "mongodb",
|
|
37
|
+
label: "MongoDB",
|
|
38
|
+
packageName: "@payloadcms/db-mongodb",
|
|
39
|
+
importName: "mongooseAdapter",
|
|
40
|
+
connectionPrefix: "mongodb://127.0.0.1/",
|
|
41
|
+
connectionSuffix: "",
|
|
42
|
+
config: `db: mongooseAdapter({
|
|
43
|
+
url: env.DATABASE_URL,
|
|
44
|
+
}),`
|
|
45
|
+
},
|
|
46
|
+
sqlite: {
|
|
47
|
+
key: "sqlite",
|
|
48
|
+
label: "SQLite",
|
|
49
|
+
hint: "zero setup, single file",
|
|
50
|
+
packageName: "@payloadcms/db-sqlite",
|
|
51
|
+
importName: "sqliteAdapter",
|
|
52
|
+
connectionPrefix: "file:./",
|
|
53
|
+
connectionSuffix: ".db",
|
|
54
|
+
config: `db: sqliteAdapter({
|
|
55
|
+
client: {
|
|
56
|
+
url: env.DATABASE_URL,
|
|
57
|
+
},
|
|
58
|
+
}),`
|
|
59
|
+
},
|
|
60
|
+
"vercel-postgres": {
|
|
61
|
+
key: "vercel-postgres",
|
|
62
|
+
label: "Vercel Postgres",
|
|
63
|
+
packageName: "@payloadcms/db-vercel-postgres",
|
|
64
|
+
importName: "vercelPostgresAdapter",
|
|
65
|
+
connectionPrefix: "postgres://postgres:<password>@127.0.0.1:5432/",
|
|
66
|
+
connectionSuffix: "",
|
|
67
|
+
config: `db: vercelPostgresAdapter({
|
|
68
|
+
pool: {
|
|
69
|
+
connectionString: env.DATABASE_URL,
|
|
70
|
+
},
|
|
71
|
+
}),`
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var DB_KEYS = Object.keys(DB_CHOICES);
|
|
75
|
+
function defaultConnectionString(db, projectSlug) {
|
|
76
|
+
return `${db.connectionPrefix}${projectSlug}${db.connectionSuffix}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/utils.ts
|
|
80
|
+
import slugifyBase from "@sindresorhus/slugify";
|
|
81
|
+
import { randomBytes } from "crypto";
|
|
82
|
+
import { existsSync, readdirSync } from "fs";
|
|
83
|
+
import { execa } from "execa";
|
|
84
|
+
function slugify(input) {
|
|
85
|
+
return slugifyBase(input.trim()).replace(/^-+|-+$/g, "") || "my-saas";
|
|
86
|
+
}
|
|
87
|
+
function generateSecret(bytes = 32) {
|
|
88
|
+
return randomBytes(bytes).toString("base64url");
|
|
89
|
+
}
|
|
90
|
+
function isDirectoryEmpty(dir) {
|
|
91
|
+
if (!existsSync(dir)) return true;
|
|
92
|
+
const entries = readdirSync(dir).filter((e) => ![".git", ".DS_Store"].includes(e));
|
|
93
|
+
return entries.length === 0;
|
|
94
|
+
}
|
|
95
|
+
async function commandExists(cmd) {
|
|
96
|
+
try {
|
|
97
|
+
await execa(process.platform === "win32" ? "where" : "which", [cmd]);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function detectPackageManager() {
|
|
104
|
+
const agent = process.env.npm_config_user_agent ?? "";
|
|
105
|
+
if (agent.startsWith("pnpm")) return "pnpm";
|
|
106
|
+
if (agent.startsWith("yarn")) return "yarn";
|
|
107
|
+
if (agent.startsWith("bun")) return "bun";
|
|
108
|
+
if (agent.startsWith("npm")) {
|
|
109
|
+
if (await commandExists("pnpm")) return "pnpm";
|
|
110
|
+
return "npm";
|
|
111
|
+
}
|
|
112
|
+
if (await commandExists("pnpm")) return "pnpm";
|
|
113
|
+
return "npm";
|
|
114
|
+
}
|
|
115
|
+
function installCommand(pm) {
|
|
116
|
+
return pm === "yarn" ? "yarn" : `${pm} install`;
|
|
117
|
+
}
|
|
118
|
+
function runCommand(pm, script) {
|
|
119
|
+
if (pm === "npm") return `npm run ${script}`;
|
|
120
|
+
return `${pm} ${script}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/configure.ts
|
|
124
|
+
function renderStackConfig(o) {
|
|
125
|
+
const list = (items) => `[${items.map((i) => `'${i}'`).join(", ")}]`;
|
|
126
|
+
const billingBlock = o.billing === "none" ? ` billing: { provider: 'none' },` : ` billing: {
|
|
127
|
+
provider: 'stripe',
|
|
128
|
+
attachedTo: '${o.billing}', // or '${o.billing === "organization" ? "user" : "organization"}'
|
|
129
|
+
plans: [
|
|
130
|
+
{
|
|
131
|
+
id: 'starter',
|
|
132
|
+
name: 'Starter',
|
|
133
|
+
description: 'For small teams getting started.',
|
|
134
|
+
prices: [
|
|
135
|
+
{ id: process.env.NEXT_PUBLIC_STRIPE_PRICE_STARTER_MONTHLY ?? 'price_starter_monthly', amount: 2900, currency: 'usd', interval: 'month' },
|
|
136
|
+
{ id: process.env.NEXT_PUBLIC_STRIPE_PRICE_STARTER_YEARLY ?? 'price_starter_yearly', amount: 29000, currency: 'usd', interval: 'year' },
|
|
137
|
+
],
|
|
138
|
+
features: ['Up to 3 members', '10 projects', 'Email support'],${o.billing === "organization" ? "\n seats: 3," : ""}
|
|
139
|
+
limits: { projects: 10 },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'team',
|
|
143
|
+
name: 'Team',
|
|
144
|
+
description: 'For growing teams that need room.',
|
|
145
|
+
prices: [
|
|
146
|
+
{ id: process.env.NEXT_PUBLIC_STRIPE_PRICE_TEAM_MONTHLY ?? 'price_team_monthly', amount: 9900, currency: 'usd', interval: 'month' },
|
|
147
|
+
{ id: process.env.NEXT_PUBLIC_STRIPE_PRICE_TEAM_YEARLY ?? 'price_team_yearly', amount: 99000, currency: 'usd', interval: 'year' },
|
|
148
|
+
],
|
|
149
|
+
features: ['Up to 25 members', 'Unlimited projects', 'Priority support', '14-day free trial'],
|
|
150
|
+
highlighted: true,${o.billing === "organization" ? "\n seats: 25," : ""}
|
|
151
|
+
trialDays: 14,
|
|
152
|
+
limits: { projects: -1 },
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},`;
|
|
156
|
+
return `import { defineStack } from '@/lib/stack'
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Your whole product, described in one file.
|
|
160
|
+
*
|
|
161
|
+
* Pricing page, checkout, entitlements, sign-in screens, navigation, legal pages and emails all
|
|
162
|
+
* read from here. Secrets never go in this file: they stay in .env and are read in src/lib/env.ts.
|
|
163
|
+
* Stripe price ids are public identifiers, so they may come from NEXT_PUBLIC_* variables.
|
|
164
|
+
*/
|
|
165
|
+
export default defineStack({
|
|
166
|
+
name: '${o.name.replace(/'/g, "\\'")}',
|
|
167
|
+
tagline: 'A new SaaS, built on Payload CMS.',
|
|
168
|
+
description:
|
|
169
|
+
'Authentication, organizations, subscriptions and an admin panel, wired together on Payload CMS and Next.js.',
|
|
170
|
+
url: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
|
171
|
+
support: { email: 'support@example.com' },
|
|
172
|
+
|
|
173
|
+
auth: {
|
|
174
|
+
methods: ${list(o.authMethods)},
|
|
175
|
+
social: ${list(o.social)},
|
|
176
|
+
twoFactor: ${o.authMethods.includes("email-password")},
|
|
177
|
+
requireEmailVerification: false,
|
|
178
|
+
allowSignUp: true,
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
organizations: {
|
|
182
|
+
enabled: ${o.organizations},
|
|
183
|
+
allowUserToCreate: true,
|
|
184
|
+
creatorRole: 'owner',
|
|
185
|
+
teams: false,
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
${billingBlock}
|
|
189
|
+
|
|
190
|
+
legal: {
|
|
191
|
+
company: '${o.name.replace(/'/g, "\\'")} Ltd',
|
|
192
|
+
jurisdiction: 'Ireland',
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
nav: [${o.billing !== "none" ? "{ label: 'Pricing', href: '/pricing' }, " : ""}{ label: 'Docs', href: 'https://payload.solutions/docs/payload-stack' }],
|
|
196
|
+
|
|
197
|
+
social: {},
|
|
198
|
+
})
|
|
199
|
+
`;
|
|
200
|
+
}
|
|
201
|
+
var IMPORT_MARKER = "// database-adapter-import";
|
|
202
|
+
var CONFIG_START = "// database-adapter-config-start";
|
|
203
|
+
var CONFIG_END = "// database-adapter-config-end";
|
|
204
|
+
function swapDatabaseAdapter(payloadConfigSource, db) {
|
|
205
|
+
const importLine = `${IMPORT_MARKER}
|
|
206
|
+
import { ${db.importName} } from '${db.packageName}'`;
|
|
207
|
+
const withImport = payloadConfigSource.replace(
|
|
208
|
+
new RegExp(`${escapeRegExp(IMPORT_MARKER)}\\nimport [^\\n]+\\n`),
|
|
209
|
+
`${importLine}
|
|
210
|
+
`
|
|
211
|
+
);
|
|
212
|
+
if (withImport === payloadConfigSource && !payloadConfigSource.includes(importLine)) {
|
|
213
|
+
throw new Error(`payload.config.ts is missing the "${IMPORT_MARKER}" marker`);
|
|
214
|
+
}
|
|
215
|
+
const start = withImport.indexOf(CONFIG_START);
|
|
216
|
+
const end = withImport.indexOf(CONFIG_END);
|
|
217
|
+
if (start === -1 || end === -1) throw new Error(`payload.config.ts is missing the database adapter markers`);
|
|
218
|
+
return `${withImport.slice(0, start)}${CONFIG_START}
|
|
219
|
+
${db.config}
|
|
220
|
+
${withImport.slice(end)}`;
|
|
221
|
+
}
|
|
222
|
+
function swapDatabasePackage(packageJson, db) {
|
|
223
|
+
const deps = { ...packageJson.dependencies };
|
|
224
|
+
const payloadVersion = deps.payload ?? "latest";
|
|
225
|
+
for (const other of Object.values(DB_CHOICES)) {
|
|
226
|
+
if (other.packageName !== db.packageName) delete deps[other.packageName];
|
|
227
|
+
}
|
|
228
|
+
deps[db.packageName] = payloadVersion;
|
|
229
|
+
return { ...packageJson, dependencies: sortKeys(deps) };
|
|
230
|
+
}
|
|
231
|
+
function renameProject(packageJson, slug) {
|
|
232
|
+
const next = { ...packageJson, name: slug, version: "0.1.0", private: true };
|
|
233
|
+
delete next.homepage;
|
|
234
|
+
delete next.repository;
|
|
235
|
+
delete next.description;
|
|
236
|
+
return next;
|
|
237
|
+
}
|
|
238
|
+
function renderEnv(example, o) {
|
|
239
|
+
const values = {
|
|
240
|
+
DATABASE_URL: o.connectionString,
|
|
241
|
+
PAYLOAD_SECRET: generateSecret(24),
|
|
242
|
+
BETTER_AUTH_SECRET: generateSecret(32),
|
|
243
|
+
NEXT_PUBLIC_APP_URL: o.appUrl ?? "http://localhost:3000"
|
|
244
|
+
};
|
|
245
|
+
const lines = example.split("\n").map((line) => {
|
|
246
|
+
const m = /^#?\s?([A-Z0-9_]+)=(.*)$/.exec(line);
|
|
247
|
+
if (!m) return line;
|
|
248
|
+
const key = m[1];
|
|
249
|
+
if (key in values) return `${key}=${values[key]}`;
|
|
250
|
+
return line;
|
|
251
|
+
});
|
|
252
|
+
return lines.join("\n");
|
|
253
|
+
}
|
|
254
|
+
function sortKeys(obj) {
|
|
255
|
+
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
256
|
+
}
|
|
257
|
+
function escapeRegExp(s) {
|
|
258
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
259
|
+
}
|
|
260
|
+
async function configureProject(o) {
|
|
261
|
+
const db = DB_CHOICES[o.db];
|
|
262
|
+
const read = (rel) => readFile(path.join(o.directory, rel), "utf8");
|
|
263
|
+
const write = (rel, content) => writeFile(path.join(o.directory, rel), content);
|
|
264
|
+
await write("src/stack.config.ts", renderStackConfig(o));
|
|
265
|
+
const payloadConfig = await read("src/payload.config.ts");
|
|
266
|
+
await write("src/payload.config.ts", swapDatabaseAdapter(payloadConfig, db));
|
|
267
|
+
const pkg = JSON.parse(await read("package.json"));
|
|
268
|
+
await write("package.json", JSON.stringify(renameProject(swapDatabasePackage(pkg, db), o.slug), null, 2) + "\n");
|
|
269
|
+
const example = await read(".env.example");
|
|
270
|
+
await write(".env", renderEnv(example, { connectionString: o.connectionString }));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/options.ts
|
|
274
|
+
var AUTH_METHODS = ["email-password", "magic-link", "passkey"];
|
|
275
|
+
var SOCIAL_PROVIDERS = ["google", "github"];
|
|
276
|
+
|
|
277
|
+
// src/template.ts
|
|
278
|
+
import { cp, mkdir, rm } from "fs/promises";
|
|
279
|
+
import { existsSync as existsSync2 } from "fs";
|
|
280
|
+
import path2 from "path";
|
|
281
|
+
import { Readable } from "stream";
|
|
282
|
+
import { pipeline } from "stream/promises";
|
|
283
|
+
import * as tar from "tar";
|
|
284
|
+
var REPO = "mpresecan/payload-solutions";
|
|
285
|
+
var TEMPLATE_PATH = "templates/payload-stack";
|
|
286
|
+
async function downloadTemplate({ dest, branch }) {
|
|
287
|
+
const url = `https://codeload.github.com/${REPO}/tar.gz/${encodeURIComponent(branch)}`;
|
|
288
|
+
const response = await fetch(url, { headers: { "User-Agent": "create-payload-stack" } });
|
|
289
|
+
if (!response.ok || !response.body) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Could not download the template (${response.status} ${response.statusText}) from ${url}.
|
|
292
|
+
Check your network, or pass --local-template <path> to scaffold from a local checkout.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
await mkdir(dest, { recursive: true });
|
|
296
|
+
const prefixDepth = 1 + TEMPLATE_PATH.split("/").length;
|
|
297
|
+
await pipeline(
|
|
298
|
+
Readable.fromWeb(response.body),
|
|
299
|
+
tar.x({
|
|
300
|
+
cwd: dest,
|
|
301
|
+
strip: prefixDepth,
|
|
302
|
+
filter: (entryPath) => {
|
|
303
|
+
const parts = entryPath.split("/");
|
|
304
|
+
return parts.slice(1, 1 + TEMPLATE_PATH.split("/").length).join("/") === TEMPLATE_PATH;
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
if (!existsSync2(path2.join(dest, "package.json"))) {
|
|
309
|
+
throw new Error(`The downloaded archive did not contain ${TEMPLATE_PATH}/package.json (branch "${branch}").`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function copyLocalTemplate({ from, dest }) {
|
|
313
|
+
const source = path2.resolve(from);
|
|
314
|
+
if (!existsSync2(path2.join(source, "package.json"))) {
|
|
315
|
+
throw new Error(`--local-template must point at a directory containing package.json (got ${source})`);
|
|
316
|
+
}
|
|
317
|
+
await mkdir(dest, { recursive: true });
|
|
318
|
+
await cp(source, dest, {
|
|
319
|
+
recursive: true,
|
|
320
|
+
filter: (src) => {
|
|
321
|
+
const rel = path2.relative(source, src);
|
|
322
|
+
const top = rel.split(path2.sep)[0];
|
|
323
|
+
return !["node_modules", ".next", ".turbo", ".env", "test-results", "playwright-report", "next-env.d.ts", "tsconfig.tsbuildinfo", "CLAUDE.md"].includes(top ?? "");
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
await rm(path2.join(dest, "CLAUDE.md"), { force: true });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/cli.ts
|
|
330
|
+
var DOCS = "https://payload.solutions/docs/payload-stack";
|
|
331
|
+
function bail(message = "Cancelled.") {
|
|
332
|
+
p.cancel(message);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
function guard(value) {
|
|
336
|
+
if (p.isCancel(value)) bail();
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
function parseList(raw, allowed, flag) {
|
|
340
|
+
if (raw === void 0) return void 0;
|
|
341
|
+
const items = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
342
|
+
for (const item of items) {
|
|
343
|
+
if (!allowed.includes(item)) bail(`Unknown value "${item}" for ${flag}. Allowed: ${allowed.join(", ")}`);
|
|
344
|
+
}
|
|
345
|
+
return items;
|
|
346
|
+
}
|
|
347
|
+
async function run(flags, positionalName) {
|
|
348
|
+
console.log();
|
|
349
|
+
p.intro(pc.bgWhite(pc.black(" create-payload-stack ")));
|
|
350
|
+
const providedName = flags.name ?? positionalName;
|
|
351
|
+
const name = flags.defaults && !providedName ? "my-saas" : providedName ?? guard(
|
|
352
|
+
await p.text({
|
|
353
|
+
message: "Project name",
|
|
354
|
+
placeholder: "my-saas",
|
|
355
|
+
defaultValue: "my-saas",
|
|
356
|
+
validate: (v) => v && v.trim().length < 2 ? "Use at least 2 characters" : void 0
|
|
357
|
+
})
|
|
358
|
+
);
|
|
359
|
+
const slug = slugify(name);
|
|
360
|
+
const directory = path3.resolve(process.cwd(), slug);
|
|
361
|
+
if (!isDirectoryEmpty(directory)) {
|
|
362
|
+
bail(`Directory ./${slug} already exists and is not empty.`);
|
|
363
|
+
}
|
|
364
|
+
const dbFlag = flags.db;
|
|
365
|
+
if (dbFlag && !DB_KEYS.includes(dbFlag)) bail(`Unknown database "${dbFlag}". Allowed: ${DB_KEYS.join(", ")}`);
|
|
366
|
+
const db = dbFlag ?? (flags.defaults ? "postgres" : guard(
|
|
367
|
+
await p.select({
|
|
368
|
+
message: "Database",
|
|
369
|
+
initialValue: "postgres",
|
|
370
|
+
options: DB_KEYS.map((key) => ({ value: key, label: DB_CHOICES[key].label, hint: DB_CHOICES[key].hint }))
|
|
371
|
+
})
|
|
372
|
+
));
|
|
373
|
+
const dbChoice = DB_CHOICES[db];
|
|
374
|
+
const suggested = defaultConnectionString(dbChoice, slug);
|
|
375
|
+
const connectionString = flags.connectionString ?? (flags.defaults ? suggested : guard(
|
|
376
|
+
await p.text({
|
|
377
|
+
message: "Connection string",
|
|
378
|
+
placeholder: suggested,
|
|
379
|
+
defaultValue: suggested,
|
|
380
|
+
initialValue: suggested
|
|
381
|
+
})
|
|
382
|
+
));
|
|
383
|
+
const authFlag = parseList(flags.auth, AUTH_METHODS, "--auth");
|
|
384
|
+
const socialFlag = parseList(flags.social, SOCIAL_PROVIDERS, "--social");
|
|
385
|
+
let authMethods;
|
|
386
|
+
let social;
|
|
387
|
+
if (authFlag || socialFlag || flags.defaults) {
|
|
388
|
+
authMethods = authFlag ?? ["email-password", "magic-link", "passkey"];
|
|
389
|
+
social = socialFlag ?? [];
|
|
390
|
+
} else {
|
|
391
|
+
const picked = guard(
|
|
392
|
+
await p.multiselect({
|
|
393
|
+
message: "Sign-in methods",
|
|
394
|
+
required: true,
|
|
395
|
+
initialValues: ["email-password", "magic-link", "passkey"],
|
|
396
|
+
options: [
|
|
397
|
+
{ value: "email-password", label: "Email + password" },
|
|
398
|
+
{ value: "magic-link", label: "Magic link" },
|
|
399
|
+
{ value: "passkey", label: "Passkeys" },
|
|
400
|
+
{ value: "google", label: "Google", hint: "needs GOOGLE_CLIENT_ID / _SECRET" },
|
|
401
|
+
{ value: "github", label: "GitHub", hint: "needs GITHUB_CLIENT_ID / _SECRET" }
|
|
402
|
+
]
|
|
403
|
+
})
|
|
404
|
+
);
|
|
405
|
+
authMethods = picked.filter((v) => AUTH_METHODS.includes(v));
|
|
406
|
+
social = picked.filter((v) => SOCIAL_PROVIDERS.includes(v));
|
|
407
|
+
if (authMethods.length === 0) {
|
|
408
|
+
p.log.warn("At least one non-social method is required for the Payload admin; adding email + password.");
|
|
409
|
+
authMethods = ["email-password"];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const organizations = flags.organizations ?? (flags.defaults ? true : guard(await p.confirm({ message: "Organizations (teams)", initialValue: true })));
|
|
413
|
+
let billing;
|
|
414
|
+
if (flags.billing !== void 0) {
|
|
415
|
+
if (!["organization", "user", "none"].includes(flags.billing)) bail(`Unknown value "${flags.billing}" for --billing. Allowed: organization, user, none`);
|
|
416
|
+
billing = flags.billing;
|
|
417
|
+
if (billing === "organization" && !organizations) bail("--billing organization requires organizations. Use --billing user or enable organizations.");
|
|
418
|
+
} else if (flags.defaults) {
|
|
419
|
+
billing = organizations ? "organization" : "user";
|
|
420
|
+
} else {
|
|
421
|
+
billing = guard(
|
|
422
|
+
await p.select({
|
|
423
|
+
message: "Billing",
|
|
424
|
+
initialValue: organizations ? "organization" : "user",
|
|
425
|
+
options: [
|
|
426
|
+
...organizations ? [{ value: "organization", label: "Stripe subscriptions, per organization" }] : [],
|
|
427
|
+
{ value: "user", label: "Stripe subscriptions, per user" },
|
|
428
|
+
{ value: "none", label: "No billing yet" }
|
|
429
|
+
]
|
|
430
|
+
})
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
const packageManager = flags.packageManager ?? await detectPackageManager();
|
|
434
|
+
const options = {
|
|
435
|
+
name,
|
|
436
|
+
slug,
|
|
437
|
+
directory,
|
|
438
|
+
db,
|
|
439
|
+
connectionString,
|
|
440
|
+
authMethods,
|
|
441
|
+
social,
|
|
442
|
+
organizations,
|
|
443
|
+
billing,
|
|
444
|
+
packageManager,
|
|
445
|
+
install: flags.install,
|
|
446
|
+
git: flags.git
|
|
447
|
+
};
|
|
448
|
+
if (flags.dryRun) {
|
|
449
|
+
p.note(JSON.stringify(options, null, 2), "Dry run: nothing written");
|
|
450
|
+
p.outro("Done.");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const s = p.spinner();
|
|
454
|
+
s.start(flags.localTemplate ? "Copying local template" : "Downloading template");
|
|
455
|
+
try {
|
|
456
|
+
if (flags.localTemplate) await copyLocalTemplate({ from: flags.localTemplate, dest: directory });
|
|
457
|
+
else await downloadTemplate({ dest: directory, branch: flags.branch });
|
|
458
|
+
await configureProject(options);
|
|
459
|
+
s.stop("Project files written");
|
|
460
|
+
} catch (error) {
|
|
461
|
+
s.stop("Failed");
|
|
462
|
+
bail(error instanceof Error ? error.message : String(error));
|
|
463
|
+
}
|
|
464
|
+
if (options.install) {
|
|
465
|
+
const s2 = p.spinner();
|
|
466
|
+
s2.start(`Installing dependencies with ${packageManager}`);
|
|
467
|
+
try {
|
|
468
|
+
await execa2(installCommand(packageManager).split(" ")[0], installCommand(packageManager).split(" ").slice(1), {
|
|
469
|
+
cwd: directory,
|
|
470
|
+
env: { ...process.env, CI: "true" }
|
|
471
|
+
});
|
|
472
|
+
s2.stop(`Installed dependencies with ${packageManager}`);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
s2.stop("Dependency installation failed");
|
|
475
|
+
p.log.warn(`Run ${pc.cyan(installCommand(packageManager))} inside ./${slug} to retry.
|
|
476
|
+
${error instanceof Error ? error.message.split("\n")[0] : ""}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (options.git && !existsSync3(path3.join(directory, ".git"))) {
|
|
480
|
+
try {
|
|
481
|
+
await execa2("git", ["init", "-b", "main"], { cwd: directory });
|
|
482
|
+
await execa2("git", ["add", "-A"], { cwd: directory });
|
|
483
|
+
await execa2("git", ["-c", "user.name=create-payload-stack", "-c", "user.email=cli@payloadstack.com", "commit", "-q", "-m", "feat: initial commit from create-payload-stack"], { cwd: directory });
|
|
484
|
+
} catch {
|
|
485
|
+
p.log.warn("Could not initialize a git repository. Run git init yourself.");
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const steps = [
|
|
489
|
+
`cd ${slug}`,
|
|
490
|
+
...options.install ? [] : [installCommand(packageManager)],
|
|
491
|
+
...db === "sqlite" ? [] : ["# start your database, then check DATABASE_URL in .env"],
|
|
492
|
+
...social.length ? [`# add ${social.map((sp) => `${sp.toUpperCase()}_CLIENT_ID / _SECRET`).join(" and ")} to .env`] : [],
|
|
493
|
+
...billing !== "none" ? ["# add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and NEXT_PUBLIC_STRIPE_PRICE_* to .env when you are ready for billing"] : [],
|
|
494
|
+
runCommand(packageManager, "dev"),
|
|
495
|
+
"open http://localhost:3000/admin"
|
|
496
|
+
];
|
|
497
|
+
p.note(steps.join("\n"), "Done. Next steps:");
|
|
498
|
+
p.outro(`Docs: ${pc.underline(DOCS)}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/index.ts
|
|
502
|
+
var require2 = createRequire(import.meta.url);
|
|
503
|
+
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
|
+
async function main() {
|
|
587
|
+
let parsed;
|
|
588
|
+
try {
|
|
589
|
+
parsed = parse(process.argv.slice(2));
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.error(pc2.red(error instanceof Error ? error.message : String(error)));
|
|
592
|
+
console.log(HELP);
|
|
593
|
+
process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
if (parsed.flags.help) {
|
|
596
|
+
console.log(HELP);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (parsed.flags.version) {
|
|
600
|
+
console.log(version);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
await run(parsed.flags, parsed.positional);
|
|
604
|
+
}
|
|
605
|
+
main().catch((error) => {
|
|
606
|
+
console.error(pc2.red(error instanceof Error ? error.stack ?? error.message : String(error)));
|
|
607
|
+
process.exit(1);
|
|
608
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-payload-stack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a SaaS on Payload CMS: Better Auth, organizations, Stripe subscriptions and a shadcn dashboard, in one command.",
|
|
5
|
+
"keywords": ["payload", "payloadcms", "saas", "boilerplate", "starter", "better-auth", "stripe", "shadcn", "nextjs", "create"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://www.payloadstack.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/mpresecan/payload-solutions.git",
|
|
11
|
+
"directory": "packages/create-payload-stack"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/mpresecan/payload-solutions/issues",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"create-payload-stack": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist", "README.md"],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.9.0"
|
|
21
|
+
},
|
|
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
|
+
"dependencies": {
|
|
31
|
+
"@clack/prompts": "^1.7.0",
|
|
32
|
+
"@sindresorhus/slugify": "^3.0.1",
|
|
33
|
+
"execa": "^10.0.1",
|
|
34
|
+
"picocolors": "^1.1.1",
|
|
35
|
+
"tar": "^7.5.22"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^24.0.0",
|
|
39
|
+
"tsup": "^8.5.1",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vitest": "^4.0.18"
|
|
42
|
+
}
|
|
43
|
+
}
|