@amirulabu/create-recurring-rabbit-app 0.0.0-alpha
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/bin/index.js +2 -0
- package/dist/index.js +592 -0
- package/package.json +43 -0
- package/templates/default/.editorconfig +21 -0
- package/templates/default/.env.example +15 -0
- package/templates/default/.eslintrc.json +35 -0
- package/templates/default/.prettierrc.json +7 -0
- package/templates/default/README.md +346 -0
- package/templates/default/app.config.ts +20 -0
- package/templates/default/docs/adding-features.md +439 -0
- package/templates/default/docs/adr/001-use-sqlite-for-development-database.md +22 -0
- package/templates/default/docs/adr/002-use-tanstack-start-over-nextjs.md +22 -0
- package/templates/default/docs/adr/003-use-better-auth-over-nextauth.md +22 -0
- package/templates/default/docs/adr/004-use-drizzle-over-prisma.md +22 -0
- package/templates/default/docs/adr/005-use-trpc-for-api-layer.md +22 -0
- package/templates/default/docs/adr/006-use-tailwind-css-v4-with-shadcn-ui.md +22 -0
- package/templates/default/docs/architecture.md +241 -0
- package/templates/default/docs/database.md +376 -0
- package/templates/default/docs/deployment.md +435 -0
- package/templates/default/docs/troubleshooting.md +668 -0
- package/templates/default/drizzle/migrations/0001_initial_schema.sql +39 -0
- package/templates/default/drizzle/migrations/meta/0001_snapshot.json +225 -0
- package/templates/default/drizzle/migrations/meta/_journal.json +12 -0
- package/templates/default/drizzle.config.ts +10 -0
- package/templates/default/lighthouserc.json +78 -0
- package/templates/default/src/app/__root.tsx +32 -0
- package/templates/default/src/app/api/auth/$.ts +15 -0
- package/templates/default/src/app/api/trpc.server.ts +12 -0
- package/templates/default/src/app/auth/forgot-password.tsx +107 -0
- package/templates/default/src/app/auth/login.tsx +34 -0
- package/templates/default/src/app/auth/register.tsx +34 -0
- package/templates/default/src/app/auth/reset-password.tsx +171 -0
- package/templates/default/src/app/auth/verify-email.tsx +111 -0
- package/templates/default/src/app/dashboard/index.tsx +122 -0
- package/templates/default/src/app/dashboard/settings.tsx +161 -0
- package/templates/default/src/app/globals.css +55 -0
- package/templates/default/src/app/index.tsx +83 -0
- package/templates/default/src/components/features/auth/login-form.tsx +172 -0
- package/templates/default/src/components/features/auth/register-form.tsx +202 -0
- package/templates/default/src/components/layout/dashboard-layout.tsx +27 -0
- package/templates/default/src/components/layout/header.tsx +29 -0
- package/templates/default/src/components/layout/sidebar.tsx +38 -0
- package/templates/default/src/components/ui/button.tsx +57 -0
- package/templates/default/src/components/ui/card.tsx +79 -0
- package/templates/default/src/components/ui/input.tsx +24 -0
- package/templates/default/src/lib/api.ts +42 -0
- package/templates/default/src/lib/auth.ts +292 -0
- package/templates/default/src/lib/email.ts +221 -0
- package/templates/default/src/lib/env.ts +119 -0
- package/templates/default/src/lib/hydration-timing.ts +289 -0
- package/templates/default/src/lib/monitoring.ts +336 -0
- package/templates/default/src/lib/utils.ts +6 -0
- package/templates/default/src/server/api/root.ts +10 -0
- package/templates/default/src/server/api/routers/dashboard.ts +37 -0
- package/templates/default/src/server/api/routers/user.ts +31 -0
- package/templates/default/src/server/api/trpc.ts +132 -0
- package/templates/default/src/server/auth/config.ts +241 -0
- package/templates/default/src/server/db/index.ts +153 -0
- package/templates/default/src/server/db/migrate.ts +125 -0
- package/templates/default/src/server/db/schema.ts +170 -0
- package/templates/default/src/server/db/seed.ts +130 -0
- package/templates/default/src/types/global.d.ts +25 -0
- package/templates/default/tailwind.config.js +46 -0
- package/templates/default/tsconfig.json +36 -0
package/bin/index.js
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import path4 from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { existsSync, promises, mkdirSync, readFileSync } from 'fs';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import { execSync, spawn } from 'child_process';
|
|
9
|
+
import chalk2 from 'chalk';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
|
|
12
|
+
async function copyTemplateFiles(templateDir, targetDir) {
|
|
13
|
+
const copyRecursive = async (src, dest) => {
|
|
14
|
+
const stat = await fs.stat(src);
|
|
15
|
+
if (stat.isDirectory()) {
|
|
16
|
+
const entries = await fs.readdir(src);
|
|
17
|
+
await fs.mkdir(dest, { recursive: true });
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
await copyRecursive(path4.join(src, entry), path4.join(dest, entry));
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
await fs.mkdir(path4.dirname(dest), { recursive: true });
|
|
23
|
+
await fs.copyFile(src, dest);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
await copyRecursive(templateDir, targetDir);
|
|
27
|
+
}
|
|
28
|
+
async function copyDirectory(src, dest) {
|
|
29
|
+
await copyTemplateFiles(src, dest);
|
|
30
|
+
}
|
|
31
|
+
async function generatePackageJson(targetDir, config) {
|
|
32
|
+
const packageJsonPath = path4.join(targetDir, "package.json");
|
|
33
|
+
const packageJson = {
|
|
34
|
+
name: config.name,
|
|
35
|
+
version: config.version ?? "0.1.0",
|
|
36
|
+
description: config.description ?? "A micro-SaaS built with TanStack Start, tRPC, and Drizzle",
|
|
37
|
+
type: "module",
|
|
38
|
+
scripts: {
|
|
39
|
+
dev: "vinxi dev",
|
|
40
|
+
build: "vinxi build",
|
|
41
|
+
start: "vinxi start",
|
|
42
|
+
"db:generate": "drizzle-kit generate",
|
|
43
|
+
"db:migrate": "drizzle-kit migrate",
|
|
44
|
+
"db:studio": "drizzle-kit studio",
|
|
45
|
+
"db:seed": "tsx src/server/db/seed.ts",
|
|
46
|
+
typecheck: "tsc --noEmit",
|
|
47
|
+
lint: "eslint . --ext .ts,.tsx",
|
|
48
|
+
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
|
49
|
+
format: 'prettier --write "src/**/*.{ts,tsx,json,css}"',
|
|
50
|
+
clean: "rm -rf .vinxi dist data/*.db data/*.db-shm data/*.db-wal",
|
|
51
|
+
"build:analyze": "ANALYZE_BUNDLE=true vinxi build",
|
|
52
|
+
lighthouse: "lhci autorun",
|
|
53
|
+
...config.scripts
|
|
54
|
+
},
|
|
55
|
+
dependencies: {
|
|
56
|
+
"@paralleldrive/cuid2": "^2.2.0",
|
|
57
|
+
"@radix-ui/react-slot": "^1.0.2",
|
|
58
|
+
"@tanstack/react-query": "^5.0.0",
|
|
59
|
+
"@tanstack/react-router": "~1.120.0",
|
|
60
|
+
"@tanstack/react-query-devtools": "^5.0.0",
|
|
61
|
+
"@tanstack/start": "^1.120.0",
|
|
62
|
+
"@trpc/client": "^11.0.0",
|
|
63
|
+
"@trpc/react-query": "^11.0.0",
|
|
64
|
+
"@trpc/server": "^11.0.0",
|
|
65
|
+
"@t3-oss/env-core": "^0.10.0",
|
|
66
|
+
"better-auth": "^1.0.0",
|
|
67
|
+
"better-sqlite3": "^12.0.0",
|
|
68
|
+
"class-variance-authority": "^0.7.0",
|
|
69
|
+
clsx: "^2.1.0",
|
|
70
|
+
dotenv: "^16.4.0",
|
|
71
|
+
"drizzle-orm": "^0.41.0",
|
|
72
|
+
postgres: "^3.4.0",
|
|
73
|
+
react: "^18.2.0",
|
|
74
|
+
"react-dom": "^18.2.0",
|
|
75
|
+
resend: "^3.2.0",
|
|
76
|
+
"tailwind-merge": "^2.2.0",
|
|
77
|
+
"tailwindcss-animate": "^1.0.7",
|
|
78
|
+
zod: "^3.22.0",
|
|
79
|
+
...config.dependencies
|
|
80
|
+
},
|
|
81
|
+
devDependencies: {
|
|
82
|
+
"@lhci/cli": "^0.12.0",
|
|
83
|
+
"@types/better-sqlite3": "^7.6.9",
|
|
84
|
+
"@types/node": "^20.11.0",
|
|
85
|
+
"@types/react": "^18.2.0",
|
|
86
|
+
"@types/react-dom": "^18.2.0",
|
|
87
|
+
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
88
|
+
"@typescript-eslint/parser": "^6.0.0",
|
|
89
|
+
eslint: "^8.56.0",
|
|
90
|
+
"eslint-plugin-react": "^7.32.0",
|
|
91
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
92
|
+
"drizzle-kit": "^0.31.0",
|
|
93
|
+
postcss: "^8.4.0",
|
|
94
|
+
prettier: "^3.2.5",
|
|
95
|
+
"rollup-plugin-visualizer": "^5.12.0",
|
|
96
|
+
tailwindcss: "^3.4.0",
|
|
97
|
+
tsx: "^4.7.0",
|
|
98
|
+
typescript: "^5.3.3",
|
|
99
|
+
vinxi: "^0.4.0",
|
|
100
|
+
...config.devDependencies
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n");
|
|
104
|
+
}
|
|
105
|
+
async function detectPackageManager() {
|
|
106
|
+
const cwd = process.cwd();
|
|
107
|
+
const hasLockFile = async (file) => {
|
|
108
|
+
try {
|
|
109
|
+
await fs.access(file);
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const hasPackageLockJson = await hasLockFile(`${cwd}/package-lock.json`);
|
|
116
|
+
const hasPnpmLockYaml = await hasLockFile(`${cwd}/pnpm-lock.yaml`);
|
|
117
|
+
const hasYarnLock = await hasLockFile(`${cwd}/yarn.lock`);
|
|
118
|
+
if (hasPnpmLockYaml) return "pnpm";
|
|
119
|
+
if (hasYarnLock) return "yarn";
|
|
120
|
+
if (hasPackageLockJson) return "npm";
|
|
121
|
+
const userAgent = process.env.npm_config_user_agent;
|
|
122
|
+
if (userAgent?.startsWith("pnpm")) return "pnpm";
|
|
123
|
+
if (userAgent?.startsWith("yarn")) return "yarn";
|
|
124
|
+
if (userAgent?.startsWith("npm")) return "npm";
|
|
125
|
+
try {
|
|
126
|
+
execSync("pnpm --version", { stdio: "ignore" });
|
|
127
|
+
return "pnpm";
|
|
128
|
+
} catch {
|
|
129
|
+
try {
|
|
130
|
+
execSync("yarn --version", { stdio: "ignore" });
|
|
131
|
+
return "yarn";
|
|
132
|
+
} catch {
|
|
133
|
+
return "npm";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function getInstallCommand(packageManager) {
|
|
138
|
+
switch (packageManager) {
|
|
139
|
+
case "pnpm":
|
|
140
|
+
return "pnpm install";
|
|
141
|
+
case "yarn":
|
|
142
|
+
return "yarn";
|
|
143
|
+
case "npm":
|
|
144
|
+
default:
|
|
145
|
+
return "npm install";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function installDependencies(projectPath, packageManager) {
|
|
149
|
+
const installCmd = getInstallCommand(packageManager);
|
|
150
|
+
const spinner = ora(`Installing dependencies with ${packageManager}...`).start();
|
|
151
|
+
try {
|
|
152
|
+
execSync(installCmd, {
|
|
153
|
+
cwd: projectPath,
|
|
154
|
+
stdio: "inherit"
|
|
155
|
+
});
|
|
156
|
+
spinner.succeed(chalk2.green("\u2713 Dependencies installed"));
|
|
157
|
+
if (packageManager === "pnpm") {
|
|
158
|
+
const rebuildSpinner = ora("Building native modules...").start();
|
|
159
|
+
try {
|
|
160
|
+
execSync("pnpm rebuild better-sqlite3", {
|
|
161
|
+
cwd: projectPath,
|
|
162
|
+
stdio: "inherit"
|
|
163
|
+
});
|
|
164
|
+
rebuildSpinner.succeed(chalk2.green("\u2713 Native modules built"));
|
|
165
|
+
} catch {
|
|
166
|
+
rebuildSpinner.warn(
|
|
167
|
+
chalk2.yellow("\u26A0 Native module build failed, trying alternative method...")
|
|
168
|
+
);
|
|
169
|
+
try {
|
|
170
|
+
execSync("npx node-gyp rebuild --directory node_modules/better-sqlite3", {
|
|
171
|
+
cwd: projectPath,
|
|
172
|
+
stdio: "inherit"
|
|
173
|
+
});
|
|
174
|
+
rebuildSpinner.succeed(chalk2.green("\u2713 Native modules built"));
|
|
175
|
+
} catch {
|
|
176
|
+
rebuildSpinner.warn(
|
|
177
|
+
chalk2.yellow("\u26A0 Native module build failed, may affect database functionality")
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
spinner.fail(chalk2.red("\u2717 Dependency installation failed"));
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Failed to install dependencies. Please run '${installCmd}' manually in project directory.`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function validateNodeVersion() {
|
|
190
|
+
const nodeVersion = process.version.replace("v", "");
|
|
191
|
+
const versionParts = nodeVersion.split(".");
|
|
192
|
+
const majorVersion = parseInt(versionParts[0] ?? "0", 10);
|
|
193
|
+
const validVersions = [18, 20, 22];
|
|
194
|
+
if (!validVersions.includes(majorVersion)) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Node.js v${majorVersion} is not supported. Please use Node.js 18, 20, or 22. Current version: v${nodeVersion}`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function generateSecret(length = 32) {
|
|
201
|
+
return crypto.randomBytes(length).toString("base64");
|
|
202
|
+
}
|
|
203
|
+
async function generateEnvFile(targetDir, options = {}) {
|
|
204
|
+
const envPath = path4.join(targetDir, ".env.local");
|
|
205
|
+
const secret = generateSecret(32);
|
|
206
|
+
const content = [
|
|
207
|
+
`# Database`,
|
|
208
|
+
`DATABASE_URL="${options.databaseUrl ?? ""}"`,
|
|
209
|
+
``,
|
|
210
|
+
`# Auth`,
|
|
211
|
+
`BETTER_AUTH_SECRET="${secret}"`,
|
|
212
|
+
`BETTER_AUTH_URL="${options.betterAuthUrl ?? "http://localhost:3000"}"`,
|
|
213
|
+
``,
|
|
214
|
+
`# Email`,
|
|
215
|
+
`RESEND_API_KEY="${options.resendApiKey ?? "your-resend-api-key"}"`,
|
|
216
|
+
``,
|
|
217
|
+
`# Environment`,
|
|
218
|
+
`NODE_ENV="${options.nodeEnv ?? "development"}"`,
|
|
219
|
+
``,
|
|
220
|
+
`# Client (public)`,
|
|
221
|
+
`PUBLIC_APP_URL="${options.betterAuthUrl ?? "http://localhost:3000"}"`,
|
|
222
|
+
``
|
|
223
|
+
].join("\n");
|
|
224
|
+
await fs.writeFile(envPath, content);
|
|
225
|
+
}
|
|
226
|
+
function loadEnvFile(projectPath) {
|
|
227
|
+
const envPath = path4.join(projectPath, ".env.local");
|
|
228
|
+
const env = {};
|
|
229
|
+
try {
|
|
230
|
+
const content = readFileSync(envPath, "utf-8");
|
|
231
|
+
const lines = content.split("\n");
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
const trimmedLine = line.trim();
|
|
234
|
+
if (trimmedLine && !trimmedLine.startsWith("#")) {
|
|
235
|
+
const [key, ...valueParts] = trimmedLine.split("=");
|
|
236
|
+
if (key) {
|
|
237
|
+
const value = valueParts.join("=").replace(/^"|"$/g, "").replace(/^'|'$/g, "");
|
|
238
|
+
env[key] = value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.warn(`Warning: Could not load .env.local file: ${String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
return env;
|
|
246
|
+
}
|
|
247
|
+
async function initializeDatabase(projectPath) {
|
|
248
|
+
const spinner = ora("Initializing database...").start();
|
|
249
|
+
try {
|
|
250
|
+
const dataDir = path4.join(projectPath, "data");
|
|
251
|
+
if (!existsSync(dataDir)) {
|
|
252
|
+
mkdirSync(dataDir, { recursive: true });
|
|
253
|
+
spinner.text = "Data directory created";
|
|
254
|
+
}
|
|
255
|
+
const env = loadEnvFile(projectPath);
|
|
256
|
+
await runNpmScript(
|
|
257
|
+
projectPath,
|
|
258
|
+
"./node_modules/.bin/drizzle-kit push",
|
|
259
|
+
"Creating database schema...",
|
|
260
|
+
env
|
|
261
|
+
);
|
|
262
|
+
spinner.succeed("Database initialized successfully");
|
|
263
|
+
const seedSpinner = ora("Seeding database with sample data...").start();
|
|
264
|
+
await runSeedScript(projectPath, env);
|
|
265
|
+
seedSpinner.succeed("Database seeded successfully");
|
|
266
|
+
} catch (error) {
|
|
267
|
+
spinner.fail("Database initialization failed");
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function runNpmScript(projectPath, command, description, env) {
|
|
272
|
+
return new Promise((resolve, reject) => {
|
|
273
|
+
const child = spawn(command, [], {
|
|
274
|
+
cwd: projectPath,
|
|
275
|
+
stdio: "pipe",
|
|
276
|
+
shell: true,
|
|
277
|
+
env: { ...process.env, ...env }
|
|
278
|
+
});
|
|
279
|
+
let errorOutput = "";
|
|
280
|
+
child.stdout?.on("data", (data) => {
|
|
281
|
+
console.log(data.toString());
|
|
282
|
+
});
|
|
283
|
+
child.stderr?.on("data", (data) => {
|
|
284
|
+
errorOutput += data.toString();
|
|
285
|
+
});
|
|
286
|
+
child.on("close", (code) => {
|
|
287
|
+
if (code !== 0) {
|
|
288
|
+
reject(new Error(`${description} failed with exit code ${code}
|
|
289
|
+
${errorOutput}`));
|
|
290
|
+
} else {
|
|
291
|
+
resolve();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
child.on("error", (error) => {
|
|
295
|
+
reject(new Error(`${description} failed: ${error.message}`));
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
async function runSeedScript(projectPath, env) {
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
const tsxPath = path4.join(projectPath, "node_modules/.bin/tsx");
|
|
302
|
+
const seedScriptPath = path4.join(projectPath, "src/server/db/seed.ts");
|
|
303
|
+
const command = process.platform === "win32" ? "node" : tsxPath;
|
|
304
|
+
const args = process.platform === "win32" ? [tsxPath, seedScriptPath] : [seedScriptPath];
|
|
305
|
+
const child = spawn(command, args, {
|
|
306
|
+
cwd: projectPath,
|
|
307
|
+
stdio: "inherit",
|
|
308
|
+
shell: process.platform === "win32",
|
|
309
|
+
env: { ...process.env, ...env }
|
|
310
|
+
});
|
|
311
|
+
child.on("close", (code) => {
|
|
312
|
+
if (code !== 0) {
|
|
313
|
+
reject(new Error(`Seed script failed with exit code ${code}`));
|
|
314
|
+
} else {
|
|
315
|
+
resolve();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
child.on("error", (error) => {
|
|
319
|
+
reject(new Error(`Seed script failed: ${error.message}`));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
var CLIError = class extends Error {
|
|
324
|
+
constructor(message, context) {
|
|
325
|
+
super(message);
|
|
326
|
+
this.context = context;
|
|
327
|
+
this.name = "CLIError";
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
function formatError(error) {
|
|
331
|
+
if (error instanceof CLIError && error.context) {
|
|
332
|
+
return formatContextualError(error);
|
|
333
|
+
}
|
|
334
|
+
return error.message;
|
|
335
|
+
}
|
|
336
|
+
function formatContextualError(error) {
|
|
337
|
+
const { error: originalError, step } = error.context;
|
|
338
|
+
const errorMessage = originalError.message;
|
|
339
|
+
let formatted = chalk2.red(`Error: ${errorMessage}
|
|
340
|
+
`);
|
|
341
|
+
if (step) {
|
|
342
|
+
formatted += chalk2.yellow(`Step: ${step}
|
|
343
|
+
`);
|
|
344
|
+
}
|
|
345
|
+
formatted += getErrorSuggestion(originalError.message);
|
|
346
|
+
return formatted;
|
|
347
|
+
}
|
|
348
|
+
function getErrorSuggestion(errorMessage) {
|
|
349
|
+
const suggestions = {
|
|
350
|
+
"Directory already exists": chalk2.cyan(
|
|
351
|
+
"\u{1F4A1} Suggestion: Choose a different project name or remove the existing directory.\n"
|
|
352
|
+
),
|
|
353
|
+
"Failed to install dependencies": chalk2.cyan(
|
|
354
|
+
"\u{1F4A1} Suggestion: Try running the install command manually in the project directory.\n"
|
|
355
|
+
),
|
|
356
|
+
"Node.js version is not supported": chalk2.cyan(
|
|
357
|
+
"\u{1F4A1} Suggestion: Install Node.js 18, 20, or 22 from https://nodejs.org/\n"
|
|
358
|
+
),
|
|
359
|
+
"Failed to create project": chalk2.cyan(
|
|
360
|
+
"\u{1F4A1} Suggestion: Ensure you have write permissions in the target directory.\n"
|
|
361
|
+
),
|
|
362
|
+
"Invalid project name": chalk2.cyan(
|
|
363
|
+
"\u{1F4A1} Suggestion: Project name must be lowercase, alphanumeric, with optional hyphens, and cannot start/end with a hyphen.\n"
|
|
364
|
+
),
|
|
365
|
+
"Reserved project name": chalk2.cyan(
|
|
366
|
+
"\u{1F4A1} Suggestion: This name is reserved by npm. Choose a different project name.\n"
|
|
367
|
+
)
|
|
368
|
+
};
|
|
369
|
+
for (const [key, suggestion] of Object.entries(suggestions)) {
|
|
370
|
+
if (errorMessage.includes(key)) {
|
|
371
|
+
return suggestion;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return chalk2.cyan("\u{1F4A1} Suggestion: Check the error message above and try again.\n");
|
|
375
|
+
}
|
|
376
|
+
function getSuccessMessage(_projectName, _packageManager) {
|
|
377
|
+
return `
|
|
378
|
+
${chalk2.green("\u2713")} Project created successfully!
|
|
379
|
+
${chalk2.green("\u2713")} Dependencies installed
|
|
380
|
+
${chalk2.green("\u2713")} Database initialized
|
|
381
|
+
${chalk2.green("\u2713")} Environment configured
|
|
382
|
+
|
|
383
|
+
${chalk2.bold.green("\u{1F680} Your micro-SaaS is ready!")}
|
|
384
|
+
|
|
385
|
+
${chalk2.cyan("Local:")} ${chalk2.cyan("http://localhost:3000")}
|
|
386
|
+
${chalk2.cyan("Database:")} ${chalk2.cyan("./data/app.db")}
|
|
387
|
+
|
|
388
|
+
${chalk2.white("Next steps:")}
|
|
389
|
+
${chalk2.white(" 1. Sign up for an account to see the dashboard")}
|
|
390
|
+
${chalk2.white(" 2. Check out the tRPC routes in src/server/api/")}
|
|
391
|
+
${chalk2.white(" 3. Start building your features!")}
|
|
392
|
+
|
|
393
|
+
${chalk2.white("Documentation:")} ${chalk2.cyan("README.md")}
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
396
|
+
function getTroubleshootingTips() {
|
|
397
|
+
return `
|
|
398
|
+
${chalk2.bold.yellow("Common Issues & Solutions:")}
|
|
399
|
+
|
|
400
|
+
${chalk2.red("\u2717 Directory already exists")}
|
|
401
|
+
${chalk2.cyan("Solution:")} Choose a different name or remove the existing directory.
|
|
402
|
+
|
|
403
|
+
${chalk2.red("\u2717 Dependency installation failed")}
|
|
404
|
+
${chalk2.cyan("Solution:")} Run install manually: ${chalk2.white("npm install")} / ${chalk2.white("pnpm install")} / ${chalk2.white("yarn")}
|
|
405
|
+
|
|
406
|
+
${chalk2.red("\u2717 Node.js version not supported")}
|
|
407
|
+
${chalk2.cyan("Solution:")} Install Node.js 18+ from ${chalk2.white("https://nodejs.org/")}
|
|
408
|
+
|
|
409
|
+
${chalk2.red("\u2717 Permission denied")}
|
|
410
|
+
${chalk2.cyan("Solution:")} Ensure you have write permissions in the target directory.
|
|
411
|
+
|
|
412
|
+
For more help, visit: ${chalk2.white("https://github.com/your-repo/issues")}
|
|
413
|
+
|
|
414
|
+
`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/utils/validation.ts
|
|
418
|
+
var RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
419
|
+
"node_modules",
|
|
420
|
+
"favicon.ico",
|
|
421
|
+
".git",
|
|
422
|
+
".env",
|
|
423
|
+
".env.local",
|
|
424
|
+
".env.production",
|
|
425
|
+
"package.json",
|
|
426
|
+
"package-lock.json",
|
|
427
|
+
"pnpm-lock.yaml",
|
|
428
|
+
"yarn.lock",
|
|
429
|
+
"tsconfig.json"
|
|
430
|
+
]);
|
|
431
|
+
var NPM_RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
432
|
+
"http",
|
|
433
|
+
"https",
|
|
434
|
+
"ftp",
|
|
435
|
+
"file",
|
|
436
|
+
"module",
|
|
437
|
+
"node",
|
|
438
|
+
"npm",
|
|
439
|
+
"package",
|
|
440
|
+
"system",
|
|
441
|
+
"os",
|
|
442
|
+
"fs",
|
|
443
|
+
"path",
|
|
444
|
+
"net",
|
|
445
|
+
"tls",
|
|
446
|
+
"crypto",
|
|
447
|
+
"buffer",
|
|
448
|
+
"stream",
|
|
449
|
+
"events",
|
|
450
|
+
"util",
|
|
451
|
+
"url",
|
|
452
|
+
"querystring",
|
|
453
|
+
"zlib"
|
|
454
|
+
]);
|
|
455
|
+
function validateProjectName(name) {
|
|
456
|
+
if (!name || name.trim().length === 0) {
|
|
457
|
+
throw new CLIError("Invalid project name: name cannot be empty");
|
|
458
|
+
}
|
|
459
|
+
if (name !== name.trim()) {
|
|
460
|
+
throw new CLIError("Invalid project name: name cannot start or end with spaces");
|
|
461
|
+
}
|
|
462
|
+
if (name.length > 214) {
|
|
463
|
+
throw new CLIError("Invalid project name: name must be 214 characters or less");
|
|
464
|
+
}
|
|
465
|
+
const npmNamePattern = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
466
|
+
if (!npmNamePattern.test(name)) {
|
|
467
|
+
throw new CLIError(
|
|
468
|
+
"Invalid project name: must be lowercase alphanumeric with optional hyphens and underscores"
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (name.startsWith("-") || name.endsWith("-") || name.startsWith("_") || name.endsWith("_")) {
|
|
472
|
+
throw new CLIError("Invalid project name: cannot start or end with hyphen or underscore");
|
|
473
|
+
}
|
|
474
|
+
if (NPM_RESERVED_NAMES.has(name.toLowerCase())) {
|
|
475
|
+
throw new CLIError(`Invalid project name: "${name}" is a reserved npm name`);
|
|
476
|
+
}
|
|
477
|
+
if (RESERVED_NAMES.has(name)) {
|
|
478
|
+
throw new CLIError(`Invalid project name: "${name}" is a reserved name`);
|
|
479
|
+
}
|
|
480
|
+
const parts = name.split(path4.sep);
|
|
481
|
+
const baseName = parts[parts.length - 1];
|
|
482
|
+
if (baseName !== name) {
|
|
483
|
+
throw new CLIError("Invalid project name: cannot contain path separators");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async function cleanupProject(projectPath) {
|
|
487
|
+
try {
|
|
488
|
+
if (await promises.access(projectPath).then(() => true).catch(() => false)) {
|
|
489
|
+
await promises.rm(projectPath, { recursive: true, force: true });
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
console.warn(`Warning: Could not cleanup ${projectPath}: ${error.message}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/commands/create.ts
|
|
497
|
+
var __filename2 = fileURLToPath(import.meta.url);
|
|
498
|
+
var __dirname2 = path4.dirname(__filename2);
|
|
499
|
+
var TEMPLATE_DIR = path4.join(__dirname2, "../../templates/default");
|
|
500
|
+
async function scaffoldProject(projectName, targetPath) {
|
|
501
|
+
const spinner = ora("Creating project structure...").start();
|
|
502
|
+
let projectPath = "";
|
|
503
|
+
let projectCreated = false;
|
|
504
|
+
try {
|
|
505
|
+
validateProjectName(projectName);
|
|
506
|
+
projectPath = path4.join(targetPath, projectName);
|
|
507
|
+
if (existsSync(projectPath)) {
|
|
508
|
+
spinner.fail(`Directory ${projectName} already exists. Please choose a different name.`);
|
|
509
|
+
throw new Error(`Directory ${projectName} already exists`);
|
|
510
|
+
}
|
|
511
|
+
spinner.succeed("Project directory will be created");
|
|
512
|
+
spinner.start("Copying template files...");
|
|
513
|
+
await copyDirectory(TEMPLATE_DIR, projectPath);
|
|
514
|
+
projectCreated = true;
|
|
515
|
+
spinner.succeed("Template files copied");
|
|
516
|
+
spinner.start("Generating package.json...");
|
|
517
|
+
await generatePackageJson(projectPath, { name: projectName });
|
|
518
|
+
spinner.succeed("package.json generated");
|
|
519
|
+
spinner.start("Configuring environment...");
|
|
520
|
+
await generateEnvFile(projectPath, {
|
|
521
|
+
betterAuthUrl: "http://localhost:3000",
|
|
522
|
+
nodeEnv: "development"
|
|
523
|
+
});
|
|
524
|
+
spinner.succeed("Environment configured");
|
|
525
|
+
const packageManager = await detectPackageManager();
|
|
526
|
+
return { projectPath, packageManager };
|
|
527
|
+
} catch (error) {
|
|
528
|
+
spinner.fail("Failed to create project");
|
|
529
|
+
if (projectPath && projectCreated) {
|
|
530
|
+
await cleanupProject(projectPath);
|
|
531
|
+
}
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function installProjectDependencies(projectPath, packageManager) {
|
|
536
|
+
const spinner = ora("Installing dependencies...").start();
|
|
537
|
+
try {
|
|
538
|
+
validateNodeVersion();
|
|
539
|
+
installDependencies(projectPath, packageManager);
|
|
540
|
+
spinner.text = `Dependencies installed with ${packageManager}`;
|
|
541
|
+
spinner.succeed("All dependencies installed");
|
|
542
|
+
} catch (error) {
|
|
543
|
+
spinner.fail("Dependency installation failed");
|
|
544
|
+
await cleanupProject(projectPath);
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function initProjectDatabase(projectPath) {
|
|
549
|
+
try {
|
|
550
|
+
await initializeDatabase(projectPath);
|
|
551
|
+
return { success: true };
|
|
552
|
+
} catch (error) {
|
|
553
|
+
await cleanupProject(projectPath);
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function displaySuccess(projectName, packageManager) {
|
|
558
|
+
console.log(getSuccessMessage());
|
|
559
|
+
}
|
|
560
|
+
function displayError(error) {
|
|
561
|
+
console.error(formatError(error));
|
|
562
|
+
if (error instanceof CLIError) {
|
|
563
|
+
console.log(getTroubleshootingTips());
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/index.ts
|
|
568
|
+
program.name("create-recurring-rabbit-app").description(
|
|
569
|
+
"Scaffold a production-ready micro-SaaS with TanStack Start, tRPC, Drizzle, and Better-auth"
|
|
570
|
+
).version("0.0.0-alpha").argument("[app-name]", "Name of your application", "my-saas-app").action(async (appName) => {
|
|
571
|
+
let hasError = false;
|
|
572
|
+
try {
|
|
573
|
+
const targetPath = process.cwd();
|
|
574
|
+
const { projectPath, packageManager } = await scaffoldProject(appName, targetPath);
|
|
575
|
+
await installProjectDependencies(projectPath, packageManager);
|
|
576
|
+
await initProjectDatabase(projectPath);
|
|
577
|
+
displaySuccess(appName, packageManager);
|
|
578
|
+
} catch (error) {
|
|
579
|
+
hasError = true;
|
|
580
|
+
if (error instanceof CLIError) {
|
|
581
|
+
displayError(error);
|
|
582
|
+
} else {
|
|
583
|
+
displayError(error);
|
|
584
|
+
}
|
|
585
|
+
process.exit(1);
|
|
586
|
+
} finally {
|
|
587
|
+
if (hasError) {
|
|
588
|
+
console.log("\nProject cleanup complete. No files were left behind.");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@amirulabu/create-recurring-rabbit-app",
|
|
3
|
+
"version": "0.0.0-alpha",
|
|
4
|
+
"description": "CLI tool to scaffold micro-SaaS apps with TanStack Start, tRPC, Drizzle, and Better-auth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-recurring-rabbit-app": "./bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"dist",
|
|
15
|
+
"templates"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
20
|
+
"start": "node dist/index.js",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "eslint . --ext .ts,.tsx",
|
|
23
|
+
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
|
24
|
+
"format": "prettier --write \"src/**/*.{ts,tsx,json,md}\""
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"chalk": "^5.3.0",
|
|
28
|
+
"commander": "^12.0.0",
|
|
29
|
+
"ora": "^8.0.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@eslint/js": "^9.39.2",
|
|
33
|
+
"@types/node": "^20.11.0",
|
|
34
|
+
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
|
35
|
+
"@typescript-eslint/parser": "^8.53.1",
|
|
36
|
+
"eslint": "^9.39.2",
|
|
37
|
+
"tsup": "^8.0.1",
|
|
38
|
+
"tsx": "^4.7.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
root = true
|
|
2
|
+
|
|
3
|
+
[*]
|
|
4
|
+
charset = utf-8
|
|
5
|
+
end_of_line = lf
|
|
6
|
+
indent_style = space
|
|
7
|
+
indent_size = 2
|
|
8
|
+
insert_final_newline = true
|
|
9
|
+
trim_trailing_whitespace = true
|
|
10
|
+
|
|
11
|
+
[*.ts]
|
|
12
|
+
indent_size = 2
|
|
13
|
+
|
|
14
|
+
[*.tsx]
|
|
15
|
+
indent_size = 2
|
|
16
|
+
|
|
17
|
+
[*.json]
|
|
18
|
+
indent_size = 2
|
|
19
|
+
|
|
20
|
+
[*.md]
|
|
21
|
+
trim_trailing_whitespace = false
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Database
|
|
2
|
+
DATABASE_URL=""
|
|
3
|
+
|
|
4
|
+
# Auth
|
|
5
|
+
BETTER_AUTH_SECRET="your-secret-key-min-32-characters"
|
|
6
|
+
BETTER_AUTH_URL="http://localhost:3000"
|
|
7
|
+
|
|
8
|
+
# Email
|
|
9
|
+
RESEND_API_KEY="your-resend-api-key"
|
|
10
|
+
|
|
11
|
+
# Environment
|
|
12
|
+
NODE_ENV="development"
|
|
13
|
+
|
|
14
|
+
# Client (public)
|
|
15
|
+
PUBLIC_APP_URL="http://localhost:3000"
|