saas-init 1.0.10 → 1.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 +6 -0
- package/dist/index.js +171 -22
- package/package.json +4 -4
- package/templates/base/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# saas-init
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/saas-init)
|
|
4
|
+
[](https://www.npmjs.com/package/saas-init)
|
|
5
|
+
[](https://github.com/oleg-koval/saas-init/actions/workflows/ci.yml)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
3
9
|
CLI scaffolding tool that generates production-ready SaaS projects on top of Next.js.
|
|
4
10
|
|
|
5
11
|
## Install
|
package/dist/index.js
CHANGED
|
@@ -5,13 +5,14 @@ import { Command } from "commander";
|
|
|
5
5
|
|
|
6
6
|
// src/commands/init.ts
|
|
7
7
|
import { execSync } from "child_process";
|
|
8
|
-
import * as
|
|
8
|
+
import * as p9 from "@clack/prompts";
|
|
9
9
|
|
|
10
10
|
// src/types.ts
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
var projectConfigSchema = z.object({
|
|
13
13
|
name: z.string().min(1),
|
|
14
14
|
outDir: z.string().min(1),
|
|
15
|
+
nextVersion: z.enum(["15", "16"]),
|
|
15
16
|
auth: z.enum(["clerk", "nextauth", "supabase"]),
|
|
16
17
|
database: z.enum(["postgres", "sqlite", "supabase"]),
|
|
17
18
|
payments: z.enum(["stripe", "lemonsqueezy"]).nullable(),
|
|
@@ -132,8 +133,25 @@ async function promptEmail() {
|
|
|
132
133
|
return { email: email === "none" ? null : email };
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
// src/prompts/
|
|
136
|
+
// src/prompts/next-version.ts
|
|
136
137
|
import * as p6 from "@clack/prompts";
|
|
138
|
+
async function promptNextVersion() {
|
|
139
|
+
const nextVersion = await p6.select({
|
|
140
|
+
message: "Next.js version",
|
|
141
|
+
options: [
|
|
142
|
+
{ value: "16", label: "Next.js 16 (latest)", hint: "React 19, Turbopack stable" },
|
|
143
|
+
{ value: "15", label: "Next.js 15", hint: "React 18/19, stable" }
|
|
144
|
+
]
|
|
145
|
+
});
|
|
146
|
+
if (p6.isCancel(nextVersion)) {
|
|
147
|
+
p6.cancel("Operation cancelled");
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
return { nextVersion };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/prompts/summary.ts
|
|
154
|
+
import * as p7 from "@clack/prompts";
|
|
137
155
|
async function promptSummary(config) {
|
|
138
156
|
const lines = [
|
|
139
157
|
` name: ${config.name}`,
|
|
@@ -143,14 +161,130 @@ async function promptSummary(config) {
|
|
|
143
161
|
` payments: ${config.payments ?? "none"}`,
|
|
144
162
|
` email: ${config.email ?? "none"}`
|
|
145
163
|
];
|
|
146
|
-
|
|
147
|
-
const confirmed = await
|
|
148
|
-
if (
|
|
149
|
-
|
|
164
|
+
p7.note(lines.join("\n"), "Project configuration");
|
|
165
|
+
const confirmed = await p7.confirm({ message: "Generate project?" });
|
|
166
|
+
if (p7.isCancel(confirmed) || confirmed === false) {
|
|
167
|
+
p7.cancel("Aborted");
|
|
150
168
|
process.exit(0);
|
|
151
169
|
}
|
|
152
170
|
}
|
|
153
171
|
|
|
172
|
+
// src/prompts/env-vars.ts
|
|
173
|
+
import * as p8 from "@clack/prompts";
|
|
174
|
+
import { randomBytes } from "crypto";
|
|
175
|
+
function cancel9(value) {
|
|
176
|
+
p8.cancel("Operation cancelled");
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
function requireText(value) {
|
|
180
|
+
if (p8.isCancel(value)) cancel9(value);
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
async function ask(message, placeholder, defaultValue) {
|
|
184
|
+
const value = await p8.text({ message, placeholder, defaultValue });
|
|
185
|
+
return requireText(value);
|
|
186
|
+
}
|
|
187
|
+
async function promptSupabaseVars() {
|
|
188
|
+
p8.log.step("Supabase \u2014 supabase.com \u2192 your project \u2192 Settings \u2192 API");
|
|
189
|
+
const url = await ask(
|
|
190
|
+
"Project URL",
|
|
191
|
+
"https://xxxxxxxxxxxxxxxxxxxx.supabase.co"
|
|
192
|
+
);
|
|
193
|
+
const anonKey = await ask("Anon (public) key");
|
|
194
|
+
const serviceKey = await ask("Service role key (secret \u2014 keep private)");
|
|
195
|
+
return {
|
|
196
|
+
NEXT_PUBLIC_SUPABASE_URL: url,
|
|
197
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: anonKey,
|
|
198
|
+
SUPABASE_SERVICE_ROLE_KEY: serviceKey
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async function promptClerkVars() {
|
|
202
|
+
p8.log.step("Clerk \u2014 clerk.com \u2192 your app \u2192 API Keys");
|
|
203
|
+
const publishableKey = await ask("Publishable key", "pk_test_...");
|
|
204
|
+
const secretKey = await ask("Secret key", "sk_test_...");
|
|
205
|
+
return {
|
|
206
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: publishableKey,
|
|
207
|
+
CLERK_SECRET_KEY: secretKey
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function promptNextAuthVars() {
|
|
211
|
+
p8.log.step("NextAuth");
|
|
212
|
+
const secret = randomBytes(32).toString("hex");
|
|
213
|
+
p8.log.info(`Generated AUTH_SECRET: ${secret}`);
|
|
214
|
+
return {
|
|
215
|
+
AUTH_SECRET: secret,
|
|
216
|
+
AUTH_URL: "http://localhost:3000"
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function promptPostgresVars() {
|
|
220
|
+
p8.log.step("Postgres \u2014 enter your connection string");
|
|
221
|
+
const url = await ask(
|
|
222
|
+
"Database URL",
|
|
223
|
+
"postgresql://user:password@localhost:5432/mydb",
|
|
224
|
+
"postgresql://user:password@localhost:5432/mydb"
|
|
225
|
+
);
|
|
226
|
+
return { DATABASE_URL: url };
|
|
227
|
+
}
|
|
228
|
+
async function promptStripeVars() {
|
|
229
|
+
p8.log.step("Stripe \u2014 stripe.com \u2192 Developers \u2192 API keys");
|
|
230
|
+
const secretKey = await ask("Secret key", "sk_test_...");
|
|
231
|
+
p8.log.info("Webhook secret: add your endpoint after deploy \u2192 Developers \u2192 Webhooks \u2192 copy the signing secret");
|
|
232
|
+
const webhookSecret = await ask("Webhook secret (or leave blank to fill later)", "whsec_...", "");
|
|
233
|
+
return {
|
|
234
|
+
STRIPE_SECRET_KEY: secretKey,
|
|
235
|
+
STRIPE_WEBHOOK_SECRET: webhookSecret || "whsec_CHANGE_ME"
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
async function promptLemonSqueezyVars() {
|
|
239
|
+
p8.log.step("Lemon Squeezy \u2014 app.lemonsqueezy.com \u2192 Settings \u2192 API");
|
|
240
|
+
const apiKey = await ask("API key");
|
|
241
|
+
const webhookSecret = await ask("Webhook secret (or leave blank to fill later)", "", "");
|
|
242
|
+
return {
|
|
243
|
+
LEMONSQUEEZY_API_KEY: apiKey,
|
|
244
|
+
LEMONSQUEEZY_WEBHOOK_SECRET: webhookSecret || "CHANGE_ME"
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async function promptResendVars() {
|
|
248
|
+
p8.log.step("Resend \u2014 resend.com \u2192 API Keys \u2192 Create API key");
|
|
249
|
+
const apiKey = await ask("API key", "re_...");
|
|
250
|
+
return { RESEND_API_KEY: apiKey };
|
|
251
|
+
}
|
|
252
|
+
async function promptPostmarkVars() {
|
|
253
|
+
p8.log.step("Postmark \u2014 account.postmarkapp.com \u2192 Servers \u2192 your server \u2192 API Tokens");
|
|
254
|
+
const token = await ask("Server API token");
|
|
255
|
+
return { POSTMARK_API_TOKEN: token };
|
|
256
|
+
}
|
|
257
|
+
async function promptEnvVars(config) {
|
|
258
|
+
p8.log.message("");
|
|
259
|
+
p8.log.message("Now let's configure your services.");
|
|
260
|
+
p8.log.message("Follow the links below to find each value.");
|
|
261
|
+
p8.log.message("");
|
|
262
|
+
const vars = {};
|
|
263
|
+
if (config.auth === "supabase") {
|
|
264
|
+
Object.assign(vars, await promptSupabaseVars());
|
|
265
|
+
} else if (config.auth === "clerk") {
|
|
266
|
+
Object.assign(vars, await promptClerkVars());
|
|
267
|
+
} else if (config.auth === "nextauth") {
|
|
268
|
+
Object.assign(vars, await promptNextAuthVars());
|
|
269
|
+
}
|
|
270
|
+
if (config.database === "postgres") {
|
|
271
|
+
Object.assign(vars, await promptPostgresVars());
|
|
272
|
+
} else if (config.database === "supabase" && config.auth !== "supabase") {
|
|
273
|
+
Object.assign(vars, await promptSupabaseVars());
|
|
274
|
+
}
|
|
275
|
+
if (config.payments === "stripe") {
|
|
276
|
+
Object.assign(vars, await promptStripeVars());
|
|
277
|
+
} else if (config.payments === "lemonsqueezy") {
|
|
278
|
+
Object.assign(vars, await promptLemonSqueezyVars());
|
|
279
|
+
}
|
|
280
|
+
if (config.email === "resend") {
|
|
281
|
+
Object.assign(vars, await promptResendVars());
|
|
282
|
+
} else if (config.email === "postmark") {
|
|
283
|
+
Object.assign(vars, await promptPostmarkVars());
|
|
284
|
+
}
|
|
285
|
+
return vars;
|
|
286
|
+
}
|
|
287
|
+
|
|
154
288
|
// src/generators/index.ts
|
|
155
289
|
import fs13 from "fs-extra";
|
|
156
290
|
|
|
@@ -181,6 +315,12 @@ async function writeTemplate(templatePath, destPath, vars) {
|
|
|
181
315
|
throw new Error(`Failed to write template from ${templatePath} to ${destPath}: ${message}`);
|
|
182
316
|
}
|
|
183
317
|
}
|
|
318
|
+
async function writeEnvLocal(destPath, vars) {
|
|
319
|
+
const envFile = path2.join(destPath, ".env.local");
|
|
320
|
+
const lines = Object.entries(vars).map(([key, value]) => `${key}=${value}`);
|
|
321
|
+
await fs.ensureDir(destPath);
|
|
322
|
+
await fs.writeFile(envFile, lines.join("\n") + "\n", "utf-8");
|
|
323
|
+
}
|
|
184
324
|
async function appendEnv(destPath, vars) {
|
|
185
325
|
const envFile = path2.join(destPath, ".env.example");
|
|
186
326
|
let existing = "";
|
|
@@ -220,8 +360,12 @@ var TEMPLATES_ROOT = findTemplatesRoot();
|
|
|
220
360
|
|
|
221
361
|
// src/generators/base.ts
|
|
222
362
|
var TEMPLATES_DIR = path4.join(TEMPLATES_ROOT, "base");
|
|
363
|
+
var NEXT_VERSION_MAP = {
|
|
364
|
+
"15": "^15.3.0",
|
|
365
|
+
"16": "^16.2.0"
|
|
366
|
+
};
|
|
223
367
|
async function generate(config, outDir) {
|
|
224
|
-
const vars = { name: config.name };
|
|
368
|
+
const vars = { name: config.name, nextVersion: NEXT_VERSION_MAP[config.nextVersion] };
|
|
225
369
|
const files = [
|
|
226
370
|
["app/layout.tsx", "app/layout.tsx"],
|
|
227
371
|
["app/page.tsx", "app/page.tsx"],
|
|
@@ -345,7 +489,8 @@ async function generate4(config, outDir) {
|
|
|
345
489
|
await fs5.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
346
490
|
await appendEnv(outDir, {
|
|
347
491
|
NEXT_PUBLIC_SUPABASE_URL: "https://your-project.supabase.co",
|
|
348
|
-
NEXT_PUBLIC_SUPABASE_ANON_KEY: "your_supabase_anon_key"
|
|
492
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: "your_supabase_anon_key",
|
|
493
|
+
SUPABASE_SERVICE_ROLE_KEY: "your_supabase_service_role_key"
|
|
349
494
|
});
|
|
350
495
|
}
|
|
351
496
|
|
|
@@ -440,7 +585,8 @@ async function generate7(config, outDir) {
|
|
|
440
585
|
await fs8.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
441
586
|
await appendEnv(outDir, {
|
|
442
587
|
NEXT_PUBLIC_SUPABASE_URL: "https://your-project.supabase.co",
|
|
443
|
-
NEXT_PUBLIC_SUPABASE_ANON_KEY: "your_supabase_anon_key"
|
|
588
|
+
NEXT_PUBLIC_SUPABASE_ANON_KEY: "your_supabase_anon_key",
|
|
589
|
+
SUPABASE_SERVICE_ROLE_KEY: "your_supabase_service_role_key"
|
|
444
590
|
});
|
|
445
591
|
}
|
|
446
592
|
|
|
@@ -707,8 +853,8 @@ async function generate15(config) {
|
|
|
707
853
|
if (!dirExistedBefore) {
|
|
708
854
|
await fs13.remove(outDir);
|
|
709
855
|
} else {
|
|
710
|
-
const { log:
|
|
711
|
-
|
|
856
|
+
const { log: log3 } = await import("@clack/prompts");
|
|
857
|
+
log3.warn(`Generation failed. The directory "${outDir}" may be in a partial state.`);
|
|
712
858
|
}
|
|
713
859
|
throw err;
|
|
714
860
|
}
|
|
@@ -716,34 +862,37 @@ async function generate15(config) {
|
|
|
716
862
|
|
|
717
863
|
// src/commands/init.ts
|
|
718
864
|
async function initCommand() {
|
|
719
|
-
|
|
865
|
+
p9.intro("saas-init");
|
|
720
866
|
const { name, outDir } = await promptProject();
|
|
867
|
+
const { nextVersion } = await promptNextVersion();
|
|
721
868
|
const { auth } = await promptAuth();
|
|
722
869
|
const { database } = await promptDatabase();
|
|
723
870
|
const { payments } = await promptPayments();
|
|
724
871
|
const { email } = await promptEmail();
|
|
725
|
-
const rawConfig = { name, outDir, auth, database, payments, email };
|
|
872
|
+
const rawConfig = { name, outDir, nextVersion, auth, database, payments, email };
|
|
726
873
|
const parsed = projectConfigSchema.safeParse(rawConfig);
|
|
727
874
|
if (!parsed.success) {
|
|
728
|
-
|
|
875
|
+
p9.cancel(`Invalid configuration: ${parsed.error.message}`);
|
|
729
876
|
process.exit(1);
|
|
730
877
|
}
|
|
731
878
|
const config = parsed.data;
|
|
732
879
|
await promptSummary(config);
|
|
733
|
-
const
|
|
880
|
+
const envVars = await promptEnvVars(config);
|
|
881
|
+
const spinner2 = p9.spinner();
|
|
734
882
|
spinner2.start("Generating project files");
|
|
735
883
|
try {
|
|
736
884
|
await generate15(config);
|
|
737
885
|
} catch (error) {
|
|
738
886
|
spinner2.stop("Generation failed");
|
|
739
887
|
const message = error instanceof Error ? error.message : String(error);
|
|
740
|
-
|
|
888
|
+
p9.cancel(`Generation failed: ${message}`);
|
|
741
889
|
process.exit(1);
|
|
742
890
|
}
|
|
891
|
+
await writeEnvLocal(config.outDir, envVars);
|
|
743
892
|
spinner2.stop("Files generated");
|
|
744
|
-
const install = await
|
|
745
|
-
if (!
|
|
746
|
-
const installSpinner =
|
|
893
|
+
const install = await p9.confirm({ message: "Install dependencies now?" });
|
|
894
|
+
if (!p9.isCancel(install) && install) {
|
|
895
|
+
const installSpinner = p9.spinner();
|
|
747
896
|
installSpinner.start("Installing dependencies");
|
|
748
897
|
try {
|
|
749
898
|
execSync("pnpm install", { cwd: outDir, stdio: "inherit" });
|
|
@@ -751,11 +900,11 @@ async function initCommand() {
|
|
|
751
900
|
} catch (error) {
|
|
752
901
|
installSpinner.stop("Failed to install dependencies");
|
|
753
902
|
const message = error instanceof Error ? error.message : String(error);
|
|
754
|
-
|
|
755
|
-
|
|
903
|
+
p9.log.error(`pnpm install failed: ${message}`);
|
|
904
|
+
p9.log.warn("Run `pnpm install` manually in your project directory to install dependencies");
|
|
756
905
|
}
|
|
757
906
|
}
|
|
758
|
-
|
|
907
|
+
p9.outro(`Done! Your project is ready at ${outDir}`);
|
|
759
908
|
}
|
|
760
909
|
|
|
761
910
|
// src/index.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "saas-init",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "CLI scaffolding tool that generates production-ready SaaS projects with Next.js, auth, payments, database, and email — configured and ready to ship.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,11 +63,11 @@
|
|
|
63
63
|
"license": "MIT",
|
|
64
64
|
"repository": {
|
|
65
65
|
"type": "git",
|
|
66
|
-
"url": "https://github.com/
|
|
66
|
+
"url": "https://github.com/oleg-koval/saas-init.git"
|
|
67
67
|
},
|
|
68
|
-
"homepage": "https://github.com/
|
|
68
|
+
"homepage": "https://github.com/oleg-koval/saas-init#readme",
|
|
69
69
|
"bugs": {
|
|
70
|
-
"url": "https://github.com/
|
|
70
|
+
"url": "https://github.com/oleg-koval/saas-init/issues"
|
|
71
71
|
},
|
|
72
72
|
"engines": {
|
|
73
73
|
"node": ">=18"
|