saas-init 1.0.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 +98 -0
- package/dist/index.js +765 -0
- package/package.json +68 -0
- package/templates/auth/clerk/app/sign-in/[[...sign-in]]/page.tsx +9 -0
- package/templates/auth/clerk/app/sign-up/[[...sign-up]]/page.tsx +9 -0
- package/templates/auth/clerk/middleware.ts +12 -0
- package/templates/auth/nextauth/app/api/auth/route.ts +3 -0
- package/templates/auth/nextauth/auth.ts +12 -0
- package/templates/auth/supabase/middleware.ts +59 -0
- package/templates/auth/supabase/utils/supabase/client.ts +12 -0
- package/templates/auth/supabase/utils/supabase/server.ts +35 -0
- package/templates/base/app/globals.css +87 -0
- package/templates/base/app/layout.tsx +19 -0
- package/templates/base/app/page.tsx +7 -0
- package/templates/base/components.json +21 -0
- package/templates/base/lib/utils.ts +6 -0
- package/templates/base/next.config.ts +7 -0
- package/templates/base/package.json +23 -0
- package/templates/base/postcss.config.mjs +5 -0
- package/templates/base/tsconfig.json +27 -0
- package/templates/database/postgres/db/index.ts +12 -0
- package/templates/database/postgres/db/schema.ts +7 -0
- package/templates/database/postgres/drizzle.config.ts +10 -0
- package/templates/database/sqlite/db/index.ts +7 -0
- package/templates/database/sqlite/db/schema.ts +7 -0
- package/templates/database/sqlite/drizzle.config.ts +10 -0
- package/templates/database/supabase/utils/supabase/client.ts +12 -0
- package/templates/database/supabase/utils/supabase/db.ts +10 -0
- package/templates/docker/.dockerignore +5 -0
- package/templates/docker/Dockerfile +25 -0
- package/templates/docker/docker-compose.yml +22 -0
- package/templates/email/postmark/lib/email.ts +17 -0
- package/templates/email/resend/lib/email.ts +20 -0
- package/templates/github/.github/workflows/ci.yml +55 -0
- package/templates/landing/app/page.tsx +21 -0
- package/templates/landing/components/Footer.tsx +37 -0
- package/templates/landing/components/Hero.tsx +29 -0
- package/templates/landing/components/ProblemAgitate.tsx +34 -0
- package/templates/landing/components/SecondaryCTA.tsx +20 -0
- package/templates/landing/components/SocialProof.tsx +43 -0
- package/templates/landing/components/Transformation.tsx +48 -0
- package/templates/landing/components/ValueStack.tsx +54 -0
- package/templates/payments/lemonsqueezy/app/api/webhooks/lemonsqueezy/route.ts +58 -0
- package/templates/payments/lemonsqueezy/lib/lemonsqueezy.ts +13 -0
- package/templates/payments/stripe/app/api/webhooks/stripe/route.ts +46 -0
- package/templates/payments/stripe/lib/stripe.ts +10 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import { execSync } from "child_process";
|
|
8
|
+
import * as p7 from "@clack/prompts";
|
|
9
|
+
|
|
10
|
+
// src/types.ts
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
var projectConfigSchema = z.object({
|
|
13
|
+
name: z.string().min(1),
|
|
14
|
+
outDir: z.string().min(1),
|
|
15
|
+
auth: z.enum(["clerk", "nextauth", "supabase"]),
|
|
16
|
+
database: z.enum(["postgres", "sqlite", "supabase"]),
|
|
17
|
+
payments: z.enum(["stripe", "lemonsqueezy"]).nullable(),
|
|
18
|
+
email: z.enum(["resend", "postmark"]).nullable()
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// src/prompts/project.ts
|
|
22
|
+
import path from "path";
|
|
23
|
+
import * as p from "@clack/prompts";
|
|
24
|
+
function isValidNpmName(value) {
|
|
25
|
+
if (!value) return "Project name is required";
|
|
26
|
+
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(value) && !/^[a-z0-9]$/.test(value)) {
|
|
27
|
+
return "Name must be lowercase, contain only letters, digits, and hyphens";
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
async function promptProject() {
|
|
32
|
+
const name = await p.text({
|
|
33
|
+
message: "Project name",
|
|
34
|
+
placeholder: "my-app",
|
|
35
|
+
validate: (value) => {
|
|
36
|
+
const result = isValidNpmName(value);
|
|
37
|
+
if (result !== true) return result;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
if (p.isCancel(name)) {
|
|
41
|
+
p.cancel("Operation cancelled");
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
const outDir = await p.text({
|
|
45
|
+
message: "Output directory",
|
|
46
|
+
placeholder: `./${name}`,
|
|
47
|
+
defaultValue: `./${name}`,
|
|
48
|
+
validate: (value) => {
|
|
49
|
+
const resolved = path.resolve(value || `./${name}`);
|
|
50
|
+
const cwd = path.resolve(process.cwd());
|
|
51
|
+
if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
|
|
52
|
+
return "Output directory must be inside the current working directory";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (p.isCancel(outDir)) {
|
|
57
|
+
p.cancel("Operation cancelled");
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
return { name, outDir: path.resolve(outDir || `./${name}`) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/prompts/auth.ts
|
|
64
|
+
import * as p2 from "@clack/prompts";
|
|
65
|
+
async function promptAuth() {
|
|
66
|
+
const auth = await p2.select({
|
|
67
|
+
message: "Auth provider",
|
|
68
|
+
options: [
|
|
69
|
+
{ value: "clerk", label: "Clerk" },
|
|
70
|
+
{ value: "nextauth", label: "NextAuth" },
|
|
71
|
+
{ value: "supabase", label: "Supabase Auth" }
|
|
72
|
+
]
|
|
73
|
+
});
|
|
74
|
+
if (p2.isCancel(auth)) {
|
|
75
|
+
p2.cancel("Operation cancelled");
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
return { auth };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/prompts/database.ts
|
|
82
|
+
import * as p3 from "@clack/prompts";
|
|
83
|
+
async function promptDatabase() {
|
|
84
|
+
const database = await p3.select({
|
|
85
|
+
message: "Database",
|
|
86
|
+
options: [
|
|
87
|
+
{ value: "postgres", label: "Postgres" },
|
|
88
|
+
{ value: "sqlite", label: "SQLite" },
|
|
89
|
+
{ value: "supabase", label: "Supabase" }
|
|
90
|
+
]
|
|
91
|
+
});
|
|
92
|
+
if (p3.isCancel(database)) {
|
|
93
|
+
p3.cancel("Operation cancelled");
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
return { database };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/prompts/payments.ts
|
|
100
|
+
import * as p4 from "@clack/prompts";
|
|
101
|
+
async function promptPayments() {
|
|
102
|
+
const payments = await p4.select({
|
|
103
|
+
message: "Payments provider",
|
|
104
|
+
options: [
|
|
105
|
+
{ value: "stripe", label: "Stripe" },
|
|
106
|
+
{ value: "lemonsqueezy", label: "Lemon Squeezy" },
|
|
107
|
+
{ value: "none", label: "Skip" }
|
|
108
|
+
]
|
|
109
|
+
});
|
|
110
|
+
if (p4.isCancel(payments)) {
|
|
111
|
+
p4.cancel("Operation cancelled");
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
return { payments: payments === "none" ? null : payments };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/prompts/email.ts
|
|
118
|
+
import * as p5 from "@clack/prompts";
|
|
119
|
+
async function promptEmail() {
|
|
120
|
+
const email = await p5.select({
|
|
121
|
+
message: "Email provider",
|
|
122
|
+
options: [
|
|
123
|
+
{ value: "resend", label: "Resend" },
|
|
124
|
+
{ value: "postmark", label: "Postmark" },
|
|
125
|
+
{ value: "none", label: "Skip" }
|
|
126
|
+
]
|
|
127
|
+
});
|
|
128
|
+
if (p5.isCancel(email)) {
|
|
129
|
+
p5.cancel("Operation cancelled");
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
return { email: email === "none" ? null : email };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/prompts/summary.ts
|
|
136
|
+
import * as p6 from "@clack/prompts";
|
|
137
|
+
async function promptSummary(config) {
|
|
138
|
+
const lines = [
|
|
139
|
+
` name: ${config.name}`,
|
|
140
|
+
` outDir: ${config.outDir}`,
|
|
141
|
+
` auth: ${config.auth}`,
|
|
142
|
+
` database: ${config.database}`,
|
|
143
|
+
` payments: ${config.payments ?? "none"}`,
|
|
144
|
+
` email: ${config.email ?? "none"}`
|
|
145
|
+
];
|
|
146
|
+
p6.note(lines.join("\n"), "Project configuration");
|
|
147
|
+
const confirmed = await p6.confirm({ message: "Generate project?" });
|
|
148
|
+
if (p6.isCancel(confirmed) || confirmed === false) {
|
|
149
|
+
p6.cancel("Aborted");
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/generators/index.ts
|
|
155
|
+
import fs13 from "fs-extra";
|
|
156
|
+
|
|
157
|
+
// src/generators/base.ts
|
|
158
|
+
import path4 from "path";
|
|
159
|
+
import fs2 from "fs-extra";
|
|
160
|
+
|
|
161
|
+
// src/utils/files.ts
|
|
162
|
+
import fs from "fs-extra";
|
|
163
|
+
import path2 from "path";
|
|
164
|
+
|
|
165
|
+
// src/utils/template.ts
|
|
166
|
+
function replaceVars(content, vars) {
|
|
167
|
+
return content.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
168
|
+
return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] ?? match : match;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/utils/files.ts
|
|
173
|
+
async function writeTemplate(templatePath, destPath, vars) {
|
|
174
|
+
try {
|
|
175
|
+
const content = await fs.readFile(templatePath, "utf-8");
|
|
176
|
+
const substituted = replaceVars(content, vars);
|
|
177
|
+
await fs.ensureDir(path2.dirname(destPath));
|
|
178
|
+
await fs.writeFile(destPath, substituted, "utf-8");
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
throw new Error(`Failed to write template from ${templatePath} to ${destPath}: ${message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function appendEnv(destPath, vars) {
|
|
185
|
+
const envFile = path2.join(destPath, ".env.example");
|
|
186
|
+
let existing = "";
|
|
187
|
+
if (await fs.pathExists(envFile)) {
|
|
188
|
+
existing = await fs.readFile(envFile, "utf-8");
|
|
189
|
+
}
|
|
190
|
+
const existingKeys = new Set(
|
|
191
|
+
existing.split("\n").filter((line) => !line.trimStart().startsWith("#") && line.includes("=")).map((line) => line.substring(0, line.indexOf("=")).trim()).filter(Boolean)
|
|
192
|
+
);
|
|
193
|
+
const newLines = Object.entries(vars).filter(([key]) => !existingKeys.has(key)).map(([key, value]) => `${key}=${value}`);
|
|
194
|
+
if (newLines.length === 0) return;
|
|
195
|
+
const toAppend = existing.endsWith("\n") || existing === "" ? newLines.join("\n") + "\n" : "\n" + newLines.join("\n") + "\n";
|
|
196
|
+
await fs.appendFile(envFile, toAppend, "utf-8");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/utils/deps.ts
|
|
200
|
+
function mergeDeps(base, additions) {
|
|
201
|
+
return { ...base, ...additions };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/utils/paths.ts
|
|
205
|
+
import path3 from "path";
|
|
206
|
+
import { existsSync } from "fs";
|
|
207
|
+
import { fileURLToPath } from "url";
|
|
208
|
+
function findTemplatesRoot() {
|
|
209
|
+
let dir = path3.dirname(fileURLToPath(import.meta.url));
|
|
210
|
+
for (let i = 0; i < 10; i++) {
|
|
211
|
+
const candidate = path3.join(dir, "templates");
|
|
212
|
+
if (existsSync(candidate)) return candidate;
|
|
213
|
+
const parent = path3.dirname(dir);
|
|
214
|
+
if (parent === dir) break;
|
|
215
|
+
dir = parent;
|
|
216
|
+
}
|
|
217
|
+
throw new Error("Could not find templates directory");
|
|
218
|
+
}
|
|
219
|
+
var TEMPLATES_ROOT = findTemplatesRoot();
|
|
220
|
+
|
|
221
|
+
// src/generators/base.ts
|
|
222
|
+
var TEMPLATES_DIR = path4.join(TEMPLATES_ROOT, "base");
|
|
223
|
+
async function generate(config, outDir) {
|
|
224
|
+
const vars = { name: config.name };
|
|
225
|
+
const files = [
|
|
226
|
+
["app/layout.tsx", "app/layout.tsx"],
|
|
227
|
+
["app/page.tsx", "app/page.tsx"],
|
|
228
|
+
["app/globals.css", "app/globals.css"],
|
|
229
|
+
["next.config.ts", "next.config.ts"],
|
|
230
|
+
["tsconfig.json", "tsconfig.json"],
|
|
231
|
+
["package.json", "package.json"],
|
|
232
|
+
[".gitignore", ".gitignore"],
|
|
233
|
+
["postcss.config.mjs", "postcss.config.mjs"],
|
|
234
|
+
["lib/utils.ts", "lib/utils.ts"],
|
|
235
|
+
["components.json", "components.json"]
|
|
236
|
+
];
|
|
237
|
+
await Promise.all(
|
|
238
|
+
files.map(
|
|
239
|
+
([templateFile, destFile]) => writeTemplate(
|
|
240
|
+
path4.join(TEMPLATES_DIR, templateFile),
|
|
241
|
+
path4.join(outDir, destFile),
|
|
242
|
+
vars
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
);
|
|
246
|
+
const pkgPath = path4.join(outDir, "package.json");
|
|
247
|
+
const pkg = await fs2.readJson(pkgPath);
|
|
248
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
249
|
+
clsx: "^2.0.0",
|
|
250
|
+
"tailwind-merge": "^2.0.0"
|
|
251
|
+
});
|
|
252
|
+
pkg.devDependencies = mergeDeps(pkg.devDependencies ?? {}, {
|
|
253
|
+
tailwindcss: "^4.0.0",
|
|
254
|
+
"@tailwindcss/postcss": "^4.0.0"
|
|
255
|
+
});
|
|
256
|
+
await fs2.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/generators/auth/clerk.ts
|
|
260
|
+
import path5 from "path";
|
|
261
|
+
import fs3 from "fs-extra";
|
|
262
|
+
var TEMPLATES_DIR2 = path5.join(TEMPLATES_ROOT, "auth/clerk");
|
|
263
|
+
async function generate2(config, outDir) {
|
|
264
|
+
const files = [
|
|
265
|
+
["middleware.ts", "middleware.ts"],
|
|
266
|
+
["app/sign-in/[[...sign-in]]/page.tsx", "app/sign-in/[[...sign-in]]/page.tsx"],
|
|
267
|
+
["app/sign-up/[[...sign-up]]/page.tsx", "app/sign-up/[[...sign-up]]/page.tsx"]
|
|
268
|
+
];
|
|
269
|
+
await Promise.all(
|
|
270
|
+
files.map(
|
|
271
|
+
([templateFile, destFile]) => writeTemplate(
|
|
272
|
+
path5.join(TEMPLATES_DIR2, templateFile),
|
|
273
|
+
path5.join(outDir, destFile),
|
|
274
|
+
{}
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
);
|
|
278
|
+
const pkgPath = path5.join(outDir, "package.json");
|
|
279
|
+
const pkg = await fs3.readJson(pkgPath);
|
|
280
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
281
|
+
"@clerk/nextjs": "^6.0.0"
|
|
282
|
+
});
|
|
283
|
+
await fs3.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
284
|
+
await appendEnv(outDir, {
|
|
285
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_CHANGE_ME_clerk_publishable_key",
|
|
286
|
+
CLERK_SECRET_KEY: "sk_CHANGE_ME_clerk_secret_key"
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/generators/auth/nextauth.ts
|
|
291
|
+
import path6 from "path";
|
|
292
|
+
import fs4 from "fs-extra";
|
|
293
|
+
var TEMPLATES_DIR3 = path6.join(TEMPLATES_ROOT, "auth/nextauth");
|
|
294
|
+
async function generate3(config, outDir) {
|
|
295
|
+
const files = [
|
|
296
|
+
["app/api/auth/route.ts", "app/api/auth/[...nextauth]/route.ts"],
|
|
297
|
+
["auth.ts", "auth.ts"]
|
|
298
|
+
];
|
|
299
|
+
await Promise.all(
|
|
300
|
+
files.map(
|
|
301
|
+
([templateFile, destFile]) => writeTemplate(
|
|
302
|
+
path6.join(TEMPLATES_DIR3, templateFile),
|
|
303
|
+
path6.join(outDir, destFile),
|
|
304
|
+
{}
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
);
|
|
308
|
+
const pkgPath = path6.join(outDir, "package.json");
|
|
309
|
+
const pkg = await fs4.readJson(pkgPath);
|
|
310
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
311
|
+
"next-auth": "^5.0.0-beta.25"
|
|
312
|
+
});
|
|
313
|
+
await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
314
|
+
await appendEnv(outDir, {
|
|
315
|
+
AUTH_SECRET: "your_auth_secret_here",
|
|
316
|
+
AUTH_URL: "http://localhost:3000"
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/generators/auth/supabase-auth.ts
|
|
321
|
+
import path7 from "path";
|
|
322
|
+
import fs5 from "fs-extra";
|
|
323
|
+
var TEMPLATES_DIR4 = path7.join(TEMPLATES_ROOT, "auth/supabase");
|
|
324
|
+
async function generate4(config, outDir) {
|
|
325
|
+
const files = [
|
|
326
|
+
["utils/supabase/client.ts", "utils/supabase/client.ts"],
|
|
327
|
+
["utils/supabase/server.ts", "utils/supabase/server.ts"],
|
|
328
|
+
["middleware.ts", "middleware.ts"]
|
|
329
|
+
];
|
|
330
|
+
await Promise.all(
|
|
331
|
+
files.map(
|
|
332
|
+
([templateFile, destFile]) => writeTemplate(
|
|
333
|
+
path7.join(TEMPLATES_DIR4, templateFile),
|
|
334
|
+
path7.join(outDir, destFile),
|
|
335
|
+
{}
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
);
|
|
339
|
+
const pkgPath = path7.join(outDir, "package.json");
|
|
340
|
+
const pkg = await fs5.readJson(pkgPath);
|
|
341
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
342
|
+
"@supabase/ssr": "^0.5.0",
|
|
343
|
+
"@supabase/supabase-js": "^2.0.0"
|
|
344
|
+
});
|
|
345
|
+
await fs5.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
346
|
+
await appendEnv(outDir, {
|
|
347
|
+
NEXT_PUBLIC_SUPABASE_URL: "https://your-project.supabase.co",
|
|
348
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: "your_supabase_anon_key"
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/generators/database/postgres.ts
|
|
353
|
+
import path8 from "path";
|
|
354
|
+
import fs6 from "fs-extra";
|
|
355
|
+
var TEMPLATES_DIR5 = path8.join(TEMPLATES_ROOT, "database/postgres");
|
|
356
|
+
async function generate5(config, outDir) {
|
|
357
|
+
const files = [
|
|
358
|
+
["drizzle.config.ts", "drizzle.config.ts"],
|
|
359
|
+
["db/schema.ts", "db/schema.ts"],
|
|
360
|
+
["db/index.ts", "db/index.ts"]
|
|
361
|
+
];
|
|
362
|
+
await Promise.all(
|
|
363
|
+
files.map(
|
|
364
|
+
([templateFile, destFile]) => writeTemplate(
|
|
365
|
+
path8.join(TEMPLATES_DIR5, templateFile),
|
|
366
|
+
path8.join(outDir, destFile),
|
|
367
|
+
{}
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
);
|
|
371
|
+
const pkgPath = path8.join(outDir, "package.json");
|
|
372
|
+
const pkg = await fs6.readJson(pkgPath);
|
|
373
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
374
|
+
"drizzle-orm": "^0.30.0",
|
|
375
|
+
postgres: "^3.4.0"
|
|
376
|
+
});
|
|
377
|
+
pkg.devDependencies = mergeDeps(pkg.devDependencies ?? {}, {
|
|
378
|
+
"drizzle-kit": "^0.20.0"
|
|
379
|
+
});
|
|
380
|
+
await fs6.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
381
|
+
await appendEnv(outDir, {
|
|
382
|
+
DATABASE_URL: "postgresql://user:password@localhost:5432/mydb"
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/generators/database/sqlite.ts
|
|
387
|
+
import path9 from "path";
|
|
388
|
+
import fs7 from "fs-extra";
|
|
389
|
+
var TEMPLATES_DIR6 = path9.join(TEMPLATES_ROOT, "database/sqlite");
|
|
390
|
+
async function generate6(config, outDir) {
|
|
391
|
+
const files = [
|
|
392
|
+
["drizzle.config.ts", "drizzle.config.ts"],
|
|
393
|
+
["db/schema.ts", "db/schema.ts"],
|
|
394
|
+
["db/index.ts", "db/index.ts"]
|
|
395
|
+
];
|
|
396
|
+
await Promise.all(
|
|
397
|
+
files.map(
|
|
398
|
+
([templateFile, destFile]) => writeTemplate(
|
|
399
|
+
path9.join(TEMPLATES_DIR6, templateFile),
|
|
400
|
+
path9.join(outDir, destFile),
|
|
401
|
+
{}
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
);
|
|
405
|
+
const pkgPath = path9.join(outDir, "package.json");
|
|
406
|
+
const pkg = await fs7.readJson(pkgPath);
|
|
407
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
408
|
+
"drizzle-orm": "^0.30.0",
|
|
409
|
+
"better-sqlite3": "^9.0.0"
|
|
410
|
+
});
|
|
411
|
+
pkg.devDependencies = mergeDeps(pkg.devDependencies ?? {}, {
|
|
412
|
+
"drizzle-kit": "^0.20.0",
|
|
413
|
+
"@types/better-sqlite3": "^9.0.0"
|
|
414
|
+
});
|
|
415
|
+
await fs7.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/generators/database/supabase-db.ts
|
|
419
|
+
import path10 from "path";
|
|
420
|
+
import fs8 from "fs-extra";
|
|
421
|
+
var TEMPLATES_DIR7 = path10.join(TEMPLATES_ROOT, "database/supabase");
|
|
422
|
+
async function generate7(config, outDir) {
|
|
423
|
+
await writeTemplate(
|
|
424
|
+
path10.join(TEMPLATES_DIR7, "utils/supabase/db.ts"),
|
|
425
|
+
path10.join(outDir, "utils/supabase/db.ts"),
|
|
426
|
+
{}
|
|
427
|
+
);
|
|
428
|
+
if (config.auth !== "supabase") {
|
|
429
|
+
await writeTemplate(
|
|
430
|
+
path10.join(TEMPLATES_DIR7, "utils/supabase/client.ts"),
|
|
431
|
+
path10.join(outDir, "utils/supabase/client.ts"),
|
|
432
|
+
{}
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
const pkgPath = path10.join(outDir, "package.json");
|
|
436
|
+
const pkg = await fs8.readJson(pkgPath);
|
|
437
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
438
|
+
"@supabase/supabase-js": "^2.0.0"
|
|
439
|
+
});
|
|
440
|
+
await fs8.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
441
|
+
await appendEnv(outDir, {
|
|
442
|
+
NEXT_PUBLIC_SUPABASE_URL: "https://your-project.supabase.co",
|
|
443
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: "your_supabase_anon_key"
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/generators/payments/stripe.ts
|
|
448
|
+
import path11 from "path";
|
|
449
|
+
import fs9 from "fs-extra";
|
|
450
|
+
var TEMPLATES_DIR8 = path11.join(TEMPLATES_ROOT, "payments/stripe");
|
|
451
|
+
async function generate8(config, outDir) {
|
|
452
|
+
const files = [
|
|
453
|
+
["lib/stripe.ts", "lib/stripe.ts"],
|
|
454
|
+
["app/api/webhooks/stripe/route.ts", "app/api/webhooks/stripe/route.ts"]
|
|
455
|
+
];
|
|
456
|
+
await Promise.all(
|
|
457
|
+
files.map(
|
|
458
|
+
([templateFile, destFile]) => writeTemplate(
|
|
459
|
+
path11.join(TEMPLATES_DIR8, templateFile),
|
|
460
|
+
path11.join(outDir, destFile),
|
|
461
|
+
{}
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
);
|
|
465
|
+
const pkgPath = path11.join(outDir, "package.json");
|
|
466
|
+
const pkg = await fs9.readJson(pkgPath);
|
|
467
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
468
|
+
stripe: "^14.0.0"
|
|
469
|
+
});
|
|
470
|
+
await fs9.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
471
|
+
await appendEnv(outDir, {
|
|
472
|
+
STRIPE_SECRET_KEY: "sk_CHANGE_ME_stripe_secret_key",
|
|
473
|
+
STRIPE_WEBHOOK_SECRET: "whsec_CHANGE_ME_webhook_secret"
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/generators/payments/lemonsqueezy.ts
|
|
478
|
+
import path12 from "path";
|
|
479
|
+
import fs10 from "fs-extra";
|
|
480
|
+
var TEMPLATES_DIR9 = path12.join(TEMPLATES_ROOT, "payments/lemonsqueezy");
|
|
481
|
+
async function generate9(config, outDir) {
|
|
482
|
+
const files = [
|
|
483
|
+
["lib/lemonsqueezy.ts", "lib/lemonsqueezy.ts"],
|
|
484
|
+
["app/api/webhooks/lemonsqueezy/route.ts", "app/api/webhooks/lemonsqueezy/route.ts"]
|
|
485
|
+
];
|
|
486
|
+
await Promise.all(
|
|
487
|
+
files.map(
|
|
488
|
+
([templateFile, destFile]) => writeTemplate(
|
|
489
|
+
path12.join(TEMPLATES_DIR9, templateFile),
|
|
490
|
+
path12.join(outDir, destFile),
|
|
491
|
+
{}
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
const pkgPath = path12.join(outDir, "package.json");
|
|
496
|
+
const pkg = await fs10.readJson(pkgPath);
|
|
497
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
498
|
+
"@lemonsqueezy/lemonsqueezy.js": "^3.0.0"
|
|
499
|
+
});
|
|
500
|
+
await fs10.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
501
|
+
await appendEnv(outDir, {
|
|
502
|
+
LEMONSQUEEZY_API_KEY: "CHANGE_ME_lemonsqueezy_api_key",
|
|
503
|
+
LEMONSQUEEZY_WEBHOOK_SECRET: "CHANGE_ME_webhook_secret"
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/generators/email/resend.ts
|
|
508
|
+
import path13 from "path";
|
|
509
|
+
import fs11 from "fs-extra";
|
|
510
|
+
var TEMPLATES_DIR10 = path13.join(TEMPLATES_ROOT, "email/resend");
|
|
511
|
+
async function generate10(config, outDir) {
|
|
512
|
+
await writeTemplate(
|
|
513
|
+
path13.join(TEMPLATES_DIR10, "lib/email.ts"),
|
|
514
|
+
path13.join(outDir, "lib/email.ts"),
|
|
515
|
+
{}
|
|
516
|
+
);
|
|
517
|
+
const pkgPath = path13.join(outDir, "package.json");
|
|
518
|
+
const pkg = await fs11.readJson(pkgPath);
|
|
519
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
520
|
+
resend: "^4.0.0"
|
|
521
|
+
});
|
|
522
|
+
await fs11.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
523
|
+
await appendEnv(outDir, {
|
|
524
|
+
RESEND_API_KEY: "re_your_resend_api_key"
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/generators/email/postmark.ts
|
|
529
|
+
import path14 from "path";
|
|
530
|
+
import fs12 from "fs-extra";
|
|
531
|
+
var TEMPLATES_DIR11 = path14.join(TEMPLATES_ROOT, "email/postmark");
|
|
532
|
+
async function generate11(config, outDir) {
|
|
533
|
+
await writeTemplate(
|
|
534
|
+
path14.join(TEMPLATES_DIR11, "lib/email.ts"),
|
|
535
|
+
path14.join(outDir, "lib/email.ts"),
|
|
536
|
+
{}
|
|
537
|
+
);
|
|
538
|
+
const pkgPath = path14.join(outDir, "package.json");
|
|
539
|
+
const pkg = await fs12.readJson(pkgPath);
|
|
540
|
+
pkg.dependencies = mergeDeps(pkg.dependencies ?? {}, {
|
|
541
|
+
postmark: "^4.0.0"
|
|
542
|
+
});
|
|
543
|
+
await fs12.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
544
|
+
await appendEnv(outDir, {
|
|
545
|
+
POSTMARK_API_TOKEN: "your_postmark_server_api_token"
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/generators/landing.ts
|
|
550
|
+
import path15 from "path";
|
|
551
|
+
var TEMPLATES_DIR12 = path15.join(TEMPLATES_ROOT, "landing");
|
|
552
|
+
async function generate12(config, outDir) {
|
|
553
|
+
const vars = {
|
|
554
|
+
name: config.name,
|
|
555
|
+
tagline: "Your tagline here",
|
|
556
|
+
problemStatement: "The core problem you solve",
|
|
557
|
+
feature1: "Feature one",
|
|
558
|
+
feature2: "Feature two",
|
|
559
|
+
feature3: "Feature three",
|
|
560
|
+
price: "99",
|
|
561
|
+
problem1Title: "Wasted time on setup",
|
|
562
|
+
problem1Body: "Every new project means hours of boilerplate before you can build the actual thing.",
|
|
563
|
+
problem2Title: "Inconsistent foundations",
|
|
564
|
+
problem2Body: "Without a standard stack, every project diverges and maintenance gets harder.",
|
|
565
|
+
problem3Title: "Slow time to first user",
|
|
566
|
+
problem3Body: "The longer it takes to go live, the longer you wait for real feedback.",
|
|
567
|
+
testimonial1Quote: "This saved us weeks of setup time. We shipped our first paying customer in under 48 hours.",
|
|
568
|
+
testimonial1Name: "Alex Johnson",
|
|
569
|
+
testimonial1Role: "Founder, Acme Corp",
|
|
570
|
+
testimonial2Quote: "The best investment I made this year. Auth, payments, and emails all working out of the box.",
|
|
571
|
+
testimonial2Name: "Sarah Chen",
|
|
572
|
+
testimonial2Role: "CTO, Startup Inc",
|
|
573
|
+
testimonial3Quote: "I have tried every boilerplate out there. This one actually ships production-ready code.",
|
|
574
|
+
testimonial3Name: "Marcus Williams",
|
|
575
|
+
testimonial3Role: "Indie Hacker",
|
|
576
|
+
stage1Body: "You get your first win immediately. Setup takes minutes, results come the same day.",
|
|
577
|
+
stage2Body: "Each day builds on the last. Your results compound and momentum grows steadily.",
|
|
578
|
+
stage3Body: "You now have a clear competitive advantage. The gap between you and others widens.",
|
|
579
|
+
stage4Body: "You are operating at 10x your previous capacity. Results that used to take months happen in days."
|
|
580
|
+
};
|
|
581
|
+
const componentFiles = [
|
|
582
|
+
"components/Hero.tsx",
|
|
583
|
+
"components/ProblemAgitate.tsx",
|
|
584
|
+
"components/ValueStack.tsx",
|
|
585
|
+
"components/SocialProof.tsx",
|
|
586
|
+
"components/Transformation.tsx",
|
|
587
|
+
"components/SecondaryCTA.tsx",
|
|
588
|
+
"components/Footer.tsx"
|
|
589
|
+
];
|
|
590
|
+
await Promise.all(
|
|
591
|
+
componentFiles.map(
|
|
592
|
+
(file) => writeTemplate(
|
|
593
|
+
path15.join(TEMPLATES_DIR12, file),
|
|
594
|
+
path15.join(outDir, file),
|
|
595
|
+
vars
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
);
|
|
599
|
+
await writeTemplate(
|
|
600
|
+
path15.join(TEMPLATES_DIR12, "app/page.tsx"),
|
|
601
|
+
path15.join(outDir, "app/page.tsx"),
|
|
602
|
+
vars
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/generators/docker.ts
|
|
607
|
+
import path16 from "path";
|
|
608
|
+
var TEMPLATES_DIR13 = path16.join(TEMPLATES_ROOT, "docker");
|
|
609
|
+
async function generate13(config, outDir) {
|
|
610
|
+
const vars = { name: config.name };
|
|
611
|
+
await Promise.all([
|
|
612
|
+
writeTemplate(
|
|
613
|
+
path16.join(TEMPLATES_DIR13, "Dockerfile"),
|
|
614
|
+
path16.join(outDir, "Dockerfile"),
|
|
615
|
+
vars
|
|
616
|
+
),
|
|
617
|
+
writeTemplate(
|
|
618
|
+
path16.join(TEMPLATES_DIR13, ".dockerignore"),
|
|
619
|
+
path16.join(outDir, ".dockerignore"),
|
|
620
|
+
vars
|
|
621
|
+
),
|
|
622
|
+
writeTemplate(
|
|
623
|
+
path16.join(TEMPLATES_DIR13, "docker-compose.yml"),
|
|
624
|
+
path16.join(outDir, "docker-compose.yml"),
|
|
625
|
+
vars
|
|
626
|
+
)
|
|
627
|
+
]);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/generators/github.ts
|
|
631
|
+
import path17 from "path";
|
|
632
|
+
var TEMPLATES_DIR14 = path17.join(TEMPLATES_ROOT, "github");
|
|
633
|
+
async function generate14(config, outDir) {
|
|
634
|
+
const vars = { name: config.name };
|
|
635
|
+
await writeTemplate(
|
|
636
|
+
path17.join(TEMPLATES_DIR14, ".github", "workflows", "ci.yml"),
|
|
637
|
+
path17.join(outDir, ".github", "workflows", "ci.yml"),
|
|
638
|
+
vars
|
|
639
|
+
);
|
|
640
|
+
await appendEnv(outDir, {
|
|
641
|
+
VERCEL_TOKEN: "",
|
|
642
|
+
VERCEL_ORG_ID: "",
|
|
643
|
+
VERCEL_PROJECT_ID: ""
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/generators/index.ts
|
|
648
|
+
async function generate15(config) {
|
|
649
|
+
const { outDir } = config;
|
|
650
|
+
const dirExistedBefore = await fs13.pathExists(outDir);
|
|
651
|
+
try {
|
|
652
|
+
await fs13.ensureDir(outDir);
|
|
653
|
+
const testFile = ".writability-check";
|
|
654
|
+
await fs13.writeFile(`${outDir}/${testFile}`, "");
|
|
655
|
+
await fs13.remove(`${outDir}/${testFile}`);
|
|
656
|
+
} catch {
|
|
657
|
+
throw new Error(`Output directory "${outDir}" is not writable or cannot be created. Check permissions and try again.`);
|
|
658
|
+
}
|
|
659
|
+
const authGenerators = {
|
|
660
|
+
clerk: generate2,
|
|
661
|
+
nextauth: generate3,
|
|
662
|
+
supabase: generate4
|
|
663
|
+
};
|
|
664
|
+
const databaseGenerators = {
|
|
665
|
+
postgres: generate5,
|
|
666
|
+
sqlite: generate6,
|
|
667
|
+
supabase: generate7
|
|
668
|
+
};
|
|
669
|
+
const paymentsGenerators = {
|
|
670
|
+
stripe: generate8,
|
|
671
|
+
lemonsqueezy: generate9
|
|
672
|
+
};
|
|
673
|
+
const emailGenerators = {
|
|
674
|
+
resend: generate10,
|
|
675
|
+
postmark: generate11
|
|
676
|
+
};
|
|
677
|
+
try {
|
|
678
|
+
await generate(config, outDir);
|
|
679
|
+
await generate12(config, outDir);
|
|
680
|
+
const authGenerator = authGenerators[config.auth];
|
|
681
|
+
if (!authGenerator) {
|
|
682
|
+
throw new Error(`No generator found for auth provider: ${config.auth}`);
|
|
683
|
+
}
|
|
684
|
+
await authGenerator(config, outDir);
|
|
685
|
+
const databaseGenerator = databaseGenerators[config.database];
|
|
686
|
+
if (!databaseGenerator) {
|
|
687
|
+
throw new Error(`No generator found for database provider: ${config.database}`);
|
|
688
|
+
}
|
|
689
|
+
await databaseGenerator(config, outDir);
|
|
690
|
+
if (config.payments !== null) {
|
|
691
|
+
const paymentsGenerator = paymentsGenerators[config.payments];
|
|
692
|
+
if (!paymentsGenerator) {
|
|
693
|
+
throw new Error(`No generator found for payments provider: ${config.payments}`);
|
|
694
|
+
}
|
|
695
|
+
await paymentsGenerator(config, outDir);
|
|
696
|
+
}
|
|
697
|
+
if (config.email !== null) {
|
|
698
|
+
const emailGenerator = emailGenerators[config.email];
|
|
699
|
+
if (!emailGenerator) {
|
|
700
|
+
throw new Error(`No generator found for email provider: ${config.email}`);
|
|
701
|
+
}
|
|
702
|
+
await emailGenerator(config, outDir);
|
|
703
|
+
}
|
|
704
|
+
await generate13(config, outDir);
|
|
705
|
+
await generate14(config, outDir);
|
|
706
|
+
} catch (err) {
|
|
707
|
+
if (!dirExistedBefore) {
|
|
708
|
+
await fs13.remove(outDir);
|
|
709
|
+
} else {
|
|
710
|
+
const { log: log2 } = await import("@clack/prompts");
|
|
711
|
+
log2.warn(`Generation failed. The directory "${outDir}" may be in a partial state.`);
|
|
712
|
+
}
|
|
713
|
+
throw err;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/commands/init.ts
|
|
718
|
+
async function initCommand() {
|
|
719
|
+
p7.intro("saas-init");
|
|
720
|
+
const { name, outDir } = await promptProject();
|
|
721
|
+
const { auth } = await promptAuth();
|
|
722
|
+
const { database } = await promptDatabase();
|
|
723
|
+
const { payments } = await promptPayments();
|
|
724
|
+
const { email } = await promptEmail();
|
|
725
|
+
const rawConfig = { name, outDir, auth, database, payments, email };
|
|
726
|
+
const parsed = projectConfigSchema.safeParse(rawConfig);
|
|
727
|
+
if (!parsed.success) {
|
|
728
|
+
p7.cancel(`Invalid configuration: ${parsed.error.message}`);
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
const config = parsed.data;
|
|
732
|
+
await promptSummary(config);
|
|
733
|
+
const spinner2 = p7.spinner();
|
|
734
|
+
spinner2.start("Generating project files");
|
|
735
|
+
try {
|
|
736
|
+
await generate15(config);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
spinner2.stop("Generation failed");
|
|
739
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
740
|
+
p7.cancel(`Generation failed: ${message}`);
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
spinner2.stop("Files generated");
|
|
744
|
+
const install = await p7.confirm({ message: "Install dependencies now?" });
|
|
745
|
+
if (!p7.isCancel(install) && install) {
|
|
746
|
+
const installSpinner = p7.spinner();
|
|
747
|
+
installSpinner.start("Installing dependencies");
|
|
748
|
+
try {
|
|
749
|
+
execSync("pnpm install", { cwd: outDir, stdio: "inherit" });
|
|
750
|
+
installSpinner.stop("Dependencies installed");
|
|
751
|
+
} catch (error) {
|
|
752
|
+
installSpinner.stop("Failed to install dependencies");
|
|
753
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
754
|
+
p7.log.error(`pnpm install failed: ${message}`);
|
|
755
|
+
p7.log.warn("Run `pnpm install` manually in your project directory to install dependencies");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
p7.outro(`Done! Your project is ready at ${outDir}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/index.ts
|
|
762
|
+
var program = new Command();
|
|
763
|
+
program.name("saas-init").description("CLI scaffolding tool for production-ready SaaS projects").version("1.0.0");
|
|
764
|
+
program.command("init").description("Scaffold a new SaaS project").action(initCommand);
|
|
765
|
+
program.parse();
|