@shipindays/shipindays 0.1.12

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/index.js ADDED
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+
3
+ import * as p from "@clack/prompts";
4
+ import chalk from "chalk";
5
+ import fs from "fs-extra";
6
+ import path from "path";
7
+ import { execSync } from "child_process";
8
+ import { fileURLToPath } from "url";
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ const TEMPLATES_DIR = path.join(__dirname, "templates");
13
+ const BASE_DIR = path.join(TEMPLATES_DIR, "base");
14
+ const BLOCKS_DIR = path.join(TEMPLATES_DIR, "blocks");
15
+
16
+ function printBanner() {
17
+ console.log("\n");
18
+ console.log(chalk.green(" ⚡ shipindays"));
19
+ console.log(chalk.dim(" Ship your SaaS in days, not months."));
20
+ console.log(chalk.dim(" https://shipindays.nikhilsai.in\n"));
21
+ }
22
+
23
+ const AUTH_PROVIDERS = {
24
+ supabase: {
25
+ label: "Supabase Auth",
26
+ hint: "Magic link + OAuth — server.ts + client.ts included",
27
+ },
28
+ nextauth: {
29
+ label: "NextAuth v5",
30
+ hint: "Credentials + Google + GitHub — self-hostable",
31
+ },
32
+ };
33
+
34
+ const EMAIL_PROVIDERS = {
35
+ resend: {
36
+ label: "Resend",
37
+ hint: "resend.com — best DX, generous free tier",
38
+ },
39
+ nodemailer: {
40
+ label: "Nodemailer",
41
+ hint: "SMTP — works with Gmail, Outlook, any mail server",
42
+ },
43
+ };
44
+
45
+ const ENV_VARS = {
46
+ base: {
47
+ "# App": [
48
+ "NEXT_PUBLIC_APP_URL=http://localhost:3000",
49
+ ],
50
+ },
51
+ auth: {
52
+ supabase: {
53
+ "# Supabase (supabase.com → project → settings → API)": [
54
+ "NEXT_PUBLIC_SUPABASE_URL=",
55
+ "NEXT_PUBLIC_SUPABASE_ANON_KEY=",
56
+ "SUPABASE_SERVICE_ROLE_KEY=",
57
+ ],
58
+ },
59
+ nextauth: {
60
+ "# NextAuth": [
61
+ "AUTH_SECRET=",
62
+ "NEXTAUTH_URL=http://localhost:3000",
63
+ ],
64
+ "# OAuth — Google (console.cloud.google.com)": [
65
+ "AUTH_GOOGLE_ID=",
66
+ "AUTH_GOOGLE_SECRET=",
67
+ ],
68
+ "# OAuth — GitHub (github.com → Settings → Developer settings → OAuth Apps)": [
69
+ "AUTH_GITHUB_ID=",
70
+ "AUTH_GITHUB_SECRET=",
71
+ ],
72
+ },
73
+ },
74
+ email: {
75
+ resend: {
76
+ "# Resend (resend.com → API Keys)": [
77
+ "RESEND_API_KEY=",
78
+ ],
79
+ },
80
+ nodemailer: {
81
+ "# SMTP / Nodemailer": [
82
+ "SMTP_HOST=smtp.gmail.com",
83
+ "SMTP_PORT=587",
84
+ "SMTP_SECURE=false",
85
+ "SMTP_USER=",
86
+ "SMTP_PASS=",
87
+ "SMTP_FROM=you@yourdomain.com",
88
+ ],
89
+ },
90
+ },
91
+ };
92
+
93
+ // Recursively copy a directory, skipping unwanted folders.
94
+ // We do this manually instead of fs.copy(filter) because fs-extra's filter
95
+ // callback skips the root directory itself on some platforms when the
96
+ // target doesn't exist yet, resulting in nothing being copied.
97
+ async function copyDir(src, dest, skipNames = []) {
98
+ await fs.ensureDir(dest);
99
+ const entries = await fs.readdir(src, { withFileTypes: true });
100
+ for (const entry of entries) {
101
+ if (skipNames.includes(entry.name)) continue;
102
+ const srcPath = path.join(src, entry.name);
103
+ const destPath = path.join(dest, entry.name);
104
+ if (entry.isDirectory()) {
105
+ await copyDir(srcPath, destPath, skipNames);
106
+ } else {
107
+ await fs.copy(srcPath, destPath, { overwrite: true });
108
+ }
109
+ }
110
+ }
111
+
112
+ // Each block lives at templates/blocks/<feature>/<provider>/
113
+ // Its src/ folder is copied on top of the project's src/.
114
+ // To add a new provider: create the folder, match exported function names,
115
+ // add a package.json with extra deps, register it in the provider map above,
116
+ // and add env vars below.
117
+ async function injectBlock(feature, provider, targetPath) {
118
+ const blockRoot = path.join(BLOCKS_DIR, feature, provider);
119
+ const blockSrcDir = path.join(blockRoot, "src");
120
+
121
+ if (!await fs.pathExists(blockRoot)) {
122
+ throw new Error(
123
+ `Block not found: ${blockRoot}\n` +
124
+ `Make sure templates/blocks/${feature}/${provider}/ exists.`
125
+ );
126
+ }
127
+
128
+ if (!await fs.pathExists(blockSrcDir)) {
129
+ throw new Error(
130
+ `Block src/ folder missing: ${blockSrcDir}\n` +
131
+ `Structure: templates/blocks/${feature}/${provider}/src/...`
132
+ );
133
+ }
134
+
135
+ await copyDir(blockSrcDir, path.join(targetPath, "src"), ["node_modules", ".next"]);
136
+ }
137
+
138
+ // Reads the block's package.json and merges its deps into the project's package.json.
139
+ // The block's package.json is never copied as a file — only read for merging.
140
+ async function mergePackageJson(targetPath, feature, provider) {
141
+ const blockPkgPath = path.join(BLOCKS_DIR, feature, provider, "package.json");
142
+ const targetPkgPath = path.join(targetPath, "package.json");
143
+
144
+ if (!await fs.pathExists(blockPkgPath)) return;
145
+ if (!await fs.pathExists(targetPkgPath)) return;
146
+
147
+ const targetPkg = await fs.readJson(targetPkgPath);
148
+ const blockPkg = await fs.readJson(blockPkgPath);
149
+
150
+ targetPkg.dependencies = {
151
+ ...(targetPkg.dependencies ?? {}),
152
+ ...(blockPkg.dependencies ?? {}),
153
+ };
154
+ targetPkg.devDependencies = {
155
+ ...(targetPkg.devDependencies ?? {}),
156
+ ...(blockPkg.devDependencies ?? {}),
157
+ };
158
+
159
+ await fs.writeJson(targetPkgPath, targetPkg, { spaces: 2 });
160
+ }
161
+
162
+ function buildEnvExample(choices) {
163
+ let out = [
164
+ "# shipindays — environment variables",
165
+ "# 1. cp .env.example .env.local",
166
+ "# 2. Fill in every blank value before running npm run dev",
167
+ "",
168
+ ].join("\n");
169
+
170
+ for (const [comment, vars] of Object.entries(ENV_VARS.base)) {
171
+ out += `${comment}\n${vars.join("\n")}\n\n`;
172
+ }
173
+
174
+ for (const [feature, provider] of Object.entries(choices)) {
175
+ const providerVars = ENV_VARS[feature]?.[provider];
176
+ if (!providerVars) continue;
177
+ for (const [comment, vars] of Object.entries(providerVars)) {
178
+ out += `${comment}\n${vars.join("\n")}\n\n`;
179
+ }
180
+ }
181
+
182
+ return out.trimEnd() + "\n";
183
+ }
184
+
185
+ function buildGitignore() {
186
+ return [
187
+ "# dependencies",
188
+ "node_modules/",
189
+ ".pnp",
190
+ ".pnp.js",
191
+ "",
192
+ "# Next.js",
193
+ ".next/",
194
+ "out/",
195
+ "build/",
196
+ "",
197
+ "# env — never commit these",
198
+ ".env",
199
+ ".env.local",
200
+ ".env.*.local",
201
+ "",
202
+ "# misc",
203
+ ".DS_Store",
204
+ "*.pem",
205
+ "npm-debug.log*",
206
+ "yarn-debug.log*",
207
+ "yarn-error.log*",
208
+ "",
209
+ "# drizzle",
210
+ "drizzle/",
211
+ "",
212
+ "# Vercel",
213
+ ".vercel",
214
+ ].join("\n");
215
+ }
216
+
217
+ function isValidName(n) { return /^[a-zA-Z0-9-_]+$/.test(n); }
218
+
219
+ function toSlug(s) {
220
+ return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-_]/g, "");
221
+ }
222
+
223
+ function detectPM() {
224
+ const a = process.env.npm_config_user_agent ?? "";
225
+ return a.includes("pnpm") ? "pnpm" : a.includes("yarn") ? "yarn" : "npm";
226
+ }
227
+
228
+ function run(cmd, cwd) { execSync(cmd, { cwd, stdio: "inherit" }); }
229
+
230
+ function printNextSteps(projectDir, pm, choices) {
231
+ const isHere = projectDir === "." || projectDir === "./";
232
+ const runCmd = pm === "npm" ? "npm run" : pm;
233
+ let n = 1;
234
+ const s = (cmd) => console.log(chalk.dim(` ${n++}. ${cmd}`));
235
+
236
+ console.log("\n");
237
+ console.log(chalk.green(" ✓ Your SaaS is scaffolded.\n"));
238
+ console.log(chalk.dim(` Auth → ${choices.auth}`));
239
+ console.log(chalk.dim(` Email → ${choices.email}`));
240
+ console.log("");
241
+ if (!isHere) s(`cd ${projectDir.replace(/^\.\//, "")}`);
242
+ s("cp .env.example .env.local");
243
+ s(" → Fill in your keys (see comments in .env.local)");
244
+ s(`${runCmd} db:push`);
245
+ s(`${runCmd} dev`);
246
+ console.log("");
247
+ console.log(chalk.dim(" Please give a star to the repo! Thanks"));
248
+ console.log(chalk.dim(" GitHub → https://github.com/nikhilsaiankilla/shipindays"));
249
+ console.log(chalk.dim(" Twitter → https://x.com/itzznikhilsai"));
250
+ console.log("");
251
+ console.log(chalk.green(" Now go build what only you can build.\n"));
252
+ }
253
+
254
+ async function main() {
255
+ printBanner();
256
+ p.intro(chalk.bgGreen(chalk.black(" shipindays ")));
257
+
258
+ // 1. Project directory
259
+ let projectDir = process.argv[2];
260
+
261
+ if (!projectDir) {
262
+ const answer = await p.text({
263
+ message: "Where should we create your project?",
264
+ placeholder: "./my-saas",
265
+ defaultValue: "./my-saas",
266
+ validate(val) {
267
+ const clean = val.replace(/^\.\//, "");
268
+ if (clean !== "." && !isValidName(clean)) {
269
+ return "Letters, numbers, hyphens, underscores only.";
270
+ }
271
+ },
272
+ });
273
+ if (p.isCancel(answer)) { p.cancel("Cancelled."); process.exit(0); }
274
+ projectDir = answer;
275
+ }
276
+
277
+ const isCurrentDir = projectDir === "." || projectDir === "./";
278
+ const targetPath = isCurrentDir
279
+ ? process.cwd()
280
+ : path.resolve(process.cwd(), projectDir.replace(/^\.\//, ""));
281
+ const projectName = isCurrentDir
282
+ ? path.basename(process.cwd())
283
+ : toSlug(path.basename(projectDir.replace(/^\.\//, "")));
284
+
285
+ if (!isCurrentDir && await fs.pathExists(targetPath)) {
286
+ const files = (await fs.readdir(targetPath)).filter(f => f !== ".git");
287
+ if (files.length > 0) {
288
+ const ok = await p.confirm({
289
+ message: `${projectDir} is not empty. Overwrite?`,
290
+ initialValue: false,
291
+ });
292
+ if (p.isCancel(ok) || !ok) { p.cancel("Cancelled."); process.exit(0); }
293
+ }
294
+ }
295
+
296
+ // 2. Pick auth provider
297
+ const authProvider = await p.select({
298
+ message: "Auth provider",
299
+ options: Object.entries(AUTH_PROVIDERS).map(([value, { label, hint }]) => ({
300
+ value, label, hint,
301
+ })),
302
+ });
303
+ if (p.isCancel(authProvider)) { p.cancel("Cancelled."); process.exit(0); }
304
+
305
+ // 3. Pick email provider
306
+ const emailProvider = await p.select({
307
+ message: "Email provider",
308
+ options: Object.entries(EMAIL_PROVIDERS).map(([value, { label, hint }]) => ({
309
+ value, label, hint,
310
+ })),
311
+ });
312
+ if (p.isCancel(emailProvider)) { p.cancel("Cancelled."); process.exit(0); }
313
+
314
+ const choices = { auth: authProvider, email: emailProvider };
315
+
316
+ // 4. Git + install preferences
317
+ const initGit = await p.confirm({
318
+ message: "Initialize a git repository?",
319
+ initialValue: true,
320
+ });
321
+ if (p.isCancel(initGit)) { p.cancel("Cancelled."); process.exit(0); }
322
+
323
+ const pm = detectPM();
324
+ const install = await p.confirm({
325
+ message: `Install dependencies with ${pm}?`,
326
+ initialValue: true,
327
+ });
328
+ if (p.isCancel(install)) { p.cancel("Cancelled."); process.exit(0); }
329
+
330
+ const spin = p.spinner();
331
+
332
+ // 5. Copy base template
333
+ spin.start("Copying base template...");
334
+ if (!await fs.pathExists(BASE_DIR)) {
335
+ spin.stop(chalk.red(`Base template not found: ${BASE_DIR}`));
336
+ process.exit(1);
337
+ }
338
+ await copyDir(BASE_DIR, targetPath, ["node_modules", ".next", ".turbo"]);
339
+ spin.stop("Base template copied.");
340
+
341
+ // 6. Inject auth block
342
+ spin.start(`Injecting auth: ${choices.auth}...`);
343
+ await injectBlock("auth", choices.auth, targetPath);
344
+ await mergePackageJson(targetPath, "auth", choices.auth);
345
+ spin.stop(`Auth: ${choices.auth} ✓`);
346
+
347
+ // 7. Inject email block
348
+ spin.start(`Injecting email: ${choices.email}...`);
349
+ await injectBlock("email", choices.email, targetPath);
350
+ await mergePackageJson(targetPath, "email", choices.email);
351
+ spin.stop(`Email: ${choices.email} ✓`);
352
+
353
+ // 8. Write .env.example
354
+ spin.start("Writing .env.example...");
355
+ await fs.outputFile(path.join(targetPath, ".env.example"), buildEnvExample(choices));
356
+ spin.stop(".env.example written.");
357
+
358
+ // 9. Write .gitignore
359
+ await fs.outputFile(path.join(targetPath, ".gitignore"), buildGitignore());
360
+
361
+ // 10. Set project name in package.json
362
+ spin.start("Configuring package.json...");
363
+ const pkgPath = path.join(targetPath, "package.json");
364
+ if (await fs.pathExists(pkgPath)) {
365
+ const pkg = await fs.readJson(pkgPath);
366
+ pkg.name = projectName;
367
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
368
+ }
369
+ spin.stop("package.json configured.");
370
+
371
+ // 11. Git init
372
+ if (initGit) {
373
+ spin.start("Initialising git...");
374
+ try {
375
+ run("git init", targetPath);
376
+ run("git add -A", targetPath);
377
+ run(`git commit -m "chore: scaffold from shipindays"`, targetPath);
378
+ spin.stop("Git initialised.");
379
+ } catch {
380
+ spin.stop(chalk.yellow("Git skipped — run manually."));
381
+ }
382
+ }
383
+
384
+ // 12. Install dependencies
385
+ if (install) {
386
+ spin.start(`Installing with ${pm}...`);
387
+ try {
388
+ const cmd =
389
+ pm === "yarn" ? "yarn" :
390
+ pm === "pnpm" ? "pnpm install" :
391
+ "npm install";
392
+ run(cmd, targetPath);
393
+ spin.stop("Dependencies installed.");
394
+ } catch {
395
+ spin.stop(chalk.yellow(`Run '${pm} install' manually.`));
396
+ }
397
+ }
398
+
399
+ p.outro(chalk.green("Done!"));
400
+ printNextSteps(projectDir, pm, choices);
401
+ }
402
+
403
+ main().catch((err) => {
404
+ console.error(chalk.red("\n Fatal: " + err.message));
405
+ process.exit(1);
406
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@shipindays/shipindays",
3
+ "version": "0.1.12",
4
+ "description": "Open source CLI to scaffold a Next.js SaaS. Pick your auth, email, and payments wired up and ready to ship.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-shipindays-app": "./index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "templates"
12
+ ],
13
+ "keywords": [
14
+ "nextjs",
15
+ "saas",
16
+ "starter",
17
+ "boilerplate",
18
+ "supabase",
19
+ "drizzle",
20
+ "stripe",
21
+ "resend"
22
+ ],
23
+ "author": "Nikhil Sai <nikhilsaiankilla@gmail.com>",
24
+ "license": "MIT",
25
+ "homepage": "https://shipindays.nikhilsai.in",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/nikhilsaiankilla/shipindays"
29
+ },
30
+ "dependencies": {
31
+ "@clack/prompts": "^0.9.1",
32
+ "chalk": "^5.3.0",
33
+ "fs-extra": "^11.2.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ }
38
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,6 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/dev/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ /* config options here */
5
+ };
6
+
7
+ export default nextConfig;