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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # saas-init
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/saas-init?color=blue)](https://www.npmjs.com/package/saas-init)
4
+ [![npm downloads](https://img.shields.io/npm/dm/saas-init)](https://www.npmjs.com/package/saas-init)
5
+ [![CI](https://github.com/oleg-koval/saas-init/actions/workflows/ci.yml/badge.svg)](https://github.com/oleg-koval/saas-init/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Node.js >= 18](https://img.shields.io/node/v/saas-init)](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 p7 from "@clack/prompts";
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/summary.ts
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
- 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");
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: log2 } = await import("@clack/prompts");
711
- log2.warn(`Generation failed. The directory "${outDir}" may be in a partial state.`);
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
- p7.intro("saas-init");
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
- p7.cancel(`Invalid configuration: ${parsed.error.message}`);
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 spinner2 = p7.spinner();
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
- p7.cancel(`Generation failed: ${message}`);
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 p7.confirm({ message: "Install dependencies now?" });
745
- if (!p7.isCancel(install) && install) {
746
- const installSpinner = p7.spinner();
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
- p7.log.error(`pnpm install failed: ${message}`);
755
- p7.log.warn("Run `pnpm install` manually in your project directory to install dependencies");
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
- p7.outro(`Done! Your project is ready at ${outDir}`);
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.10",
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/olegkoval/saas-init.git"
66
+ "url": "https://github.com/oleg-koval/saas-init.git"
67
67
  },
68
- "homepage": "https://github.com/olegkoval/saas-init#readme",
68
+ "homepage": "https://github.com/oleg-koval/saas-init#readme",
69
69
  "bugs": {
70
- "url": "https://github.com/olegkoval/saas-init/issues"
70
+ "url": "https://github.com/oleg-koval/saas-init/issues"
71
71
  },
72
72
  "engines": {
73
73
  "node": ">=18"
@@ -10,7 +10,7 @@
10
10
  "test": "echo \"No tests configured\" && exit 0"
11
11
  },
12
12
  "dependencies": {
13
- "next": "^16.2.0",
13
+ "next": "{{nextVersion}}",
14
14
  "react": "^19.0.0",
15
15
  "react-dom": "^19.0.0"
16
16
  },