diff --git a/.gitignore b/.gitignore index 31b0f885..4b8c4954 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* -# Caches +# Caches .cache @@ -173,4 +173,7 @@ dist # Finder (MacOS) folder config .DS_Store -test \ No newline at end of file +**/test/ + +# Local Claude settings +.claude/settings.local.json diff --git a/biome.json b/biome.json index 1546ebb2..564d31e2 100644 --- a/biome.json +++ b/biome.json @@ -1,18 +1,81 @@ { - "$schema": "https://biomejs.dev/schemas/1.5.2/schema.json", - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, + "$schema": "https://biomejs.dev/schemas/2.2.6/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "includes": [ + "**", + "!node_modules", + "!.vercel", + "!dist" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "complexity": { + "noBannedTypes": "off", + "noStaticOnlyClass": "off" + }, + "correctness": { + "noUnusedVariables": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": [ + "**/*.svelte", + "**/*.astro", + "**/*.vue" + ], + "linter": { "rules": { - "style": { - "useTemplate": "off" - }, - "performance": { - "noDelete": "off" - }, - "recommended": true + "style": { + "useConst": "off", + "useImportType": "off" + }, + "correctness": { + "noUnusedVariables": "off", + "noUnusedImports": "off" + } + } + } + }, + { + "includes": [ + "**" + ], + "json": { + "parser": { + "allowComments": true } + } } -} + ] +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 1e783ceb..2ad5a689 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "tasuku": "^2.0.1", }, "devDependencies": { - "@biomejs/biome": "^2.0.0", + "@biomejs/biome": "^2.2.5", "@types/bun": "^1.2.5", "@types/minimist": "^1.2.5", "husky": "^9.1.7", @@ -20,23 +20,23 @@ }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.2.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.3", "@biomejs/cli-darwin-x64": "2.2.3", "@biomejs/cli-linux-arm64": "2.2.3", "@biomejs/cli-linux-arm64-musl": "2.2.3", "@biomejs/cli-linux-x64": "2.2.3", "@biomejs/cli-linux-x64-musl": "2.2.3", "@biomejs/cli-win32-arm64": "2.2.3", "@biomejs/cli-win32-x64": "2.2.3" }, "bin": { "biome": "bin/biome" } }, "sha512-9w0uMTvPrIdvUrxazZ42Ib7t8Y2yoGLKLdNne93RLICmaHw7mcLv4PPb5LvZLJF3141gQHiCColOh/v6VWlWmg=="], + "@biomejs/biome": ["@biomejs/biome@2.2.6", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.2.6.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.6", "@biomejs/cli-darwin-x64": "2.2.6", "@biomejs/cli-linux-arm64": "2.2.6", "@biomejs/cli-linux-arm64-musl": "2.2.6", "@biomejs/cli-linux-x64": "2.2.6", "@biomejs/cli-linux-x64-musl": "2.2.6", "@biomejs/cli-win32-arm64": "2.2.6", "@biomejs/cli-win32-x64": "2.2.6" }, "bin": { "biome": "bin/biome" } }, "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OrqQVBpadB5eqzinXN4+Q6honBz+tTlKVCsbEuEpljK8ASSItzIRZUA02mTikl3H/1nO2BMPFiJ0nkEZNy3B1w=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.6.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-OCdBpb1TmyfsTgBAM1kPMXyYKTohQ48WpiN9tkt9xvU6gKVKHY4oVwteBebiOqyfyzCNaSiuKIPjmHjUZ2ZNMg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.6.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-g/Uta2DqYpECxG+vUmTAmUKlVhnGEcY7DXWgKP8ruLRa8Si1QHsWknPY3B/wCo0KgYiFIOAZ9hjsHfNb9L85+g=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.6.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-q3w9jJ6JFPZPeqyvwwPeaiS/6NEszZ+pXKF+IczNo8Xj6fsii45a4gEEicKyKIytalV+s829ACZujQlXAiVLBQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.6.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-LEtyYL1fJsvw35CxrbQ0gZoxOG3oZsAjzfRdvRBRHxOpQ91Q5doRVjvWW/wepgSdgk5hlaNzfeqpyGmfSD0Eyw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.6.tgz", { "os": "linux", "cpu": "x64" }, "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-y76Dn4vkP1sMRGPFlNc+OTETBhGPJ90jY3il6jAfur8XWrYBQV3swZ1Jo0R2g+JpOeeoA0cOwM7mJG6svDz79w=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.6.tgz", { "os": "linux", "cpu": "x64" }, "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Ms9zFYzjcJK7LV+AOMYnjN3pV3xL8Prxf9aWdDVL74onLn5kcvZ1ZMQswE5XHtnd/r/0bnUd928Rpbs14BzVmA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.6.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-gvCpewE7mBwBIpqk1YrUqNR4mCiyJm6UI3YWQQXkedSSEwzRdodRpaKhbdbHw1/hmTWOVXQ+Eih5Qctf4TCVOQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "https://registry.npmmirror.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.6.tgz", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], diff --git a/package.json b/package.json index 3b718b76..de01ec92 100644 --- a/package.json +++ b/package.json @@ -1,50 +1,53 @@ { - "name": "create-elysiajs", - "type": "commonjs", - "version": "1.6.0", - "description": "Scaffolding your Elysia project with the environment with easy!", - "author": "kravetsone", - "repository": { - "type": "git", - "url": "git+https://github.com/kravetsone/create-elysiajs.git" - }, - "readme": "https://github.com/kravetsone/create-elysiajs", - "keywords": [ - "elysia", - "elysiajs", - "scaffolding", - "create", - "template", - "lint", - "eslint", - "biome", - "orm", - "prisma", - "drizzle", - "husky" - ], - "files": ["dist"], - "bin": { - "create-elysiajs": "dist/index.js" - }, - "scripts": { - "watch": "bunx pkgroll -w", - "lint": "bunx @biomejs/biome check src", - "lint:fix": "bun lint --apply", - "prepublishOnly": "bunx pkgroll" - }, - "devDependencies": { - "@biomejs/biome": "^2.0.0", - "@types/bun": "^1.2.5", - "@types/minimist": "^1.2.5", - "husky": "^9.1.7", - "pkgroll": "^2.11.2", - "ts-dedent": "^2.2.0", - "typescript": "^5.8.2" - }, - "dependencies": { - "enquirer": "^2.4.1", - "minimist": "^1.2.8", - "tasuku": "^2.0.1" - } + "name": "create-elysiajs", + "type": "commonjs", + "version": "1.6.0", + "description": "Scaffolding your Elysia project with the environment with easy!", + "author": "kravetsone", + "repository": { + "type": "git", + "url": "git+https://github.com/kravetsone/create-elysiajs.git" + }, + "readme": "https://github.com/kravetsone/create-elysiajs", + "keywords": [ + "elysia", + "elysiajs", + "scaffolding", + "create", + "template", + "lint", + "eslint", + "biome", + "orm", + "prisma", + "drizzle", + "husky" + ], + "files": [ + "dist" + ], + "bin": { + "create-elysiajs": "dist/index.js" + }, + "scripts": { + "watch": "bunx pkgroll -w", + "lint": "bunx @biomejs/biome check src", + "lint:fix": "bunx @biomejs/biome check --write", + "prepublishOnly": "bunx pkgroll", + "test": "bun run ./dist/index.js --pm bun test" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.5", + "@types/bun": "^1.2.5", + "@types/minimist": "^1.2.5", + "husky": "^9.1.7", + "pkgroll": "^2.11.2", + "ts-dedent": "^2.2.0", + "typescript": "^5.8.2" + }, + "dependencies": { + "enquirer": "^2.4.1", + "minimist": "^1.2.8", + "tasuku": "^2.0.1" + } } diff --git a/renovate.json b/renovate.json index 1f3a9862..8df2426c 100644 --- a/renovate.json +++ b/renovate.json @@ -1,20 +1,20 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "automergeSchedule": [ - "before 4am" - ], - "automerge": true, - "extends": ["config:recommended", ":dependencyDashboard"], - "lockFileMaintenance": { - "enabled": true, - "automerge": true - }, - "bumpVersion": "patch", - "labels": ["bump"], - "customManagers": [{ - "fileMatch": ["deps.ts$"], - "datasourceTemplate": "npm", - "matchStrings": ["\t\"?(?.*?)\"?: \"\\^?(?.*?)\""], - "autoReplaceStringTemplate": "\t\"{{{depName}}}\": \"^{{{newValue}}}\"" - }] - } \ No newline at end of file + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "automergeSchedule": ["before 4am"], + "automerge": true, + "extends": ["config:recommended", ":dependencyDashboard"], + "lockFileMaintenance": { + "enabled": true, + "automerge": true + }, + "bumpVersion": "patch", + "labels": ["bump"], + "customManagers": [ + { + "fileMatch": ["deps.ts$"], + "datasourceTemplate": "npm", + "matchStrings": ["\t\"?(?.*?)\"?: \"\\^?(?.*?)\""], + "autoReplaceStringTemplate": "\t\"{{{depName}}}\": \"^{{{newValue}}}\"" + } + ] +} diff --git a/src/deps.share.ts b/src/deps.share.ts new file mode 100644 index 00000000..6e4bdcf1 --- /dev/null +++ b/src/deps.share.ts @@ -0,0 +1,8 @@ +export const ShareDeps = { + elysia: "catalog:", + typescript: "catalog:", + "@types/bun": "catalog:", + "@biomejs/biome": "catalog:", + eslint: "catalog:", + rimraf: "catalog:", +}; diff --git a/src/deps.ts b/src/deps.ts index d4d41d29..c1ea000e 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -1,48 +1,42 @@ export const dependencies = { - "elysia": "^1.3.21", + elysia: "^1.3.21", - "typescript": "^5.9.2", + typescript: "^5.9.2", "@types/bun": "^1.2.21", "@biomejs/biome": "^2.2.3", - "eslint": "^9.35.0", + eslint: "^9.35.0", "eslint-plugin-drizzle": "^0.2.3", - "prisma": "^6.15.0", + prisma: "^6.15.0", "@prisma/client": "^6.15.0", "drizzle-orm": "^0.44.5", "drizzle-kit": "^0.31.4", - - "pg": "^8.16.3", + + pg: "^8.16.3", "@types/pg": "^8.15.5", - "postgres": "^3.4.7", + postgres: "^3.4.7", - "mysql2": "^3.14.4", + mysql2: "^3.14.4", husky: "^9.1.7", + "@elysiajs/static": "^1.4.0", "@elysiajs/bearer": "^1.3.0", - - "@elysiajs/cors": "^1.3.3", - - "@elysiajs/html": "^1.3.1", - "@kitajs/ts-html-plugin": "^4.1.2", "@elysiajs/jwt": "^1.3.3", "@elysiajs/server-timing": "^1.3.0", - "@elysiajs/static": "^1.3.0", - "@elysiajs/swagger": "^1.3.1", "elysia-autoload": "^1.7.0", @@ -55,7 +49,7 @@ export const dependencies = { "elysia-oauth2": "^2.1.0", - "arctic": "^3.7.0", + arctic: "^3.7.0", "env-var": "^7.5.0", @@ -63,17 +57,61 @@ export const dependencies = { jobify: "^0.1.6", - "ioredis": "^5.7.0", + ioredis: "^5.7.0", "@verrou/core": "^0.5.1", "@aws-sdk/client-s3": "^3.882.0", + "@aws-sdk/s3-request-presigner": "^3.882.0", + "@elysiajs/eden": "^1.3.3", "ioredis-mock": "^8.9.0", - + "@electric-sql/pglite": "^0.3.7", - "gramio": "^0.4.10", + gramio: "^0.4.10", + "@elysiajs/openapi": "^1.4.11", + "@elysiajs/cors": "^1.4.0", + "@elysiajs/html": "^1.4.0", + + // Frontend dependencies + vue: "^3.5.21", + + "@vueuse/core": "^13.9.0", + + "vue-router": "^4.5.1", + pinia: "^3.0.3", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "vue-tsc": "^3.0.6", + + // Vue UI & Styling + "vue-sonner": "^2.0.9", + tailwindcss: "^4.1.14", + "@tailwindcss/vite": "^4.1.14", + "@egoist/tailwindcss-icons": "^1.9.0", + + // Vue Build Tools + "unplugin-auto-import": "^20.1.0", + "unplugin-vue-components": "^29.0.0", + react: "^18.3.1", + "react-dom": "^18.3.1", + "@tanstack/react-router": "^1.81.0", + "@tanstack/react-query": "^5.56.2", + zustand: "^5.0.0", + "@vitejs/plugin-react": "^4.3.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "solid-js": "^1.9.3", + "@solidjs/router": "^0.14.10", + "vite-plugin-solid": "^2.10.2", + svelte: "^5.0.0", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@sveltejs/adapter-auto": "^3.2.5", + vite: "^7.1.10", + turbo: "^2.5.8", + rimraf: "^6.0.1", }; diff --git a/src/generators/monorepo.ts b/src/generators/monorepo.ts new file mode 100644 index 00000000..2320dd30 --- /dev/null +++ b/src/generators/monorepo.ts @@ -0,0 +1,338 @@ +import fs from "node:fs/promises"; +import { + getDBIndex, + getEnvFile, + getReadme, + getTSConfigBase, + getTurboJson, +} from "../templates"; +import { getCommonSchemas } from "../templates/backend/libs/common-schemas"; +import { getConnection } from "../templates/backend/libs/connection"; +import { getHealthyCheck } from "../templates/backend/libs/healthyCheck"; +import { getSchemaHelper } from "../templates/backend/libs/schemaHelper"; +import { getUserModuleIndex } from "../templates/backend/modules/user"; +import { getUsersController } from "../templates/backend/modules/user/users.controller"; +import { getUsersModel } from "../templates/backend/modules/user/users.model"; +import { getUsersService } from "../templates/backend/modules/user/users.service"; +import { getErrorIndex } from "../templates/backend/plugins/err"; +import { getErrorBase } from "../templates/backend/plugins/err/base"; +import { getDatabaseErrorMapper } from "../templates/backend/plugins/err/database-error-mapper"; +import { getErrorPlugin } from "../templates/backend/plugins/err/err.plugin"; +import { getErrorGuards } from "../templates/backend/plugins/err/guards"; +import { getHttpErrors } from "../templates/backend/plugins/err/http-error"; +import { getLoggerPlugin } from "../templates/backend/plugins/logger"; +import { getRes } from "../templates/backend/utils/Res"; +import { getBackendTypes } from "../templates/backend-types"; +import { getBotFile } from "../templates/bot"; +import { getBackendPackageJson } from "../templates/package/backend-package.json"; +import { getPackageJson } from "../templates/package/package.json"; +import { getAuthPlugin } from "../templates/services/auth"; +import { + getBackendTSConfig, + getElysiaTSConfig, + getFrontendTSConfig, + getNodeTSConfig, + getTypeScriptConfigPackageJson, +} from "../templates/tsconfig"; +import { getAppTSConfig } from "../templates/tsconfig/app"; +import { + getAppVue, + getEnvDTs, + getFrontendPackageJson, + getHomeVue, + getIndexHTML, + getViteConfig, + getVueMain, +} from "../templates/vue"; +import { getHandleApi } from "../templates/vue/handleApi"; +import { getMainCSS } from "../templates/vue/main.css"; +import { getRouter } from "../templates/vue/router"; +import { getUseApiWithToast } from "../templates/vue/useApiWithToast"; +import { getUseTreaty } from "../templates/vue/useTreaty"; +import type { PreferencesType } from "../utils"; +import { + generateBackendDirectories, + generateCommonBackendFiles, + generateDatabaseFiles, +} from "./utils/backend-helpers"; +import { + collectCommonBackendPreferences, + collectFrontendPreferences, + collectTelegramPreferences, +} from "./utils/preference-collectors"; + +export async function generateMonorepo( + projectDir: string, + preferences: PreferencesType, +) { + try { + // Generate monorepo structure + await generateMonorepoStructure(projectDir, preferences); + + // Generate backend application (reuse single app logic) + await generateBackendApp(projectDir, preferences); + + // Generate frontend application (if needed) + if (preferences.frontend !== "None") { + await generateFrontendApp(projectDir, preferences); + } + } catch (error) { + // Clean up partial state or re-throw with context + throw new Error( + `Failed to generate monorepo: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export async function collectMonorepoPreferences(preferences: PreferencesType) { + // Collect Telegram preference + await collectTelegramPreferences(preferences, { + initial: "yes", + message: "Do you want to include Telegram bot support?", + }); + + // Collect frontend preference (monorepo-specific) + await collectFrontendPreferences(preferences, { + choices: ["None", "Vue"], + message: "Select frontend framework: (currently only Vue supported)", + }); + + // Collect common backend preferences with monorepo-specific options + await collectCommonBackendPreferences(preferences, { + linterChoices: ["None", "Biome"], // Monorepo prefers Biome + ormChoices: ["None", "Drizzle"], // Monorepo focuses on Drizzle + includePrismaDatabases: false, // Monorepo doesn't include Prisma options + }); +} + +async function generateMonorepoStructure( + projectDir: string, + preferences: PreferencesType, +) { + // Root directory files + await fs.writeFile(`${projectDir}/package.json`, getPackageJson(preferences)); + await fs.writeFile(`${projectDir}/turbo.json`, getTurboJson()); + await fs.writeFile( + `${projectDir}/.gitignore`, + ["dist", "node_modules", ".env", ".env.production", ".turbo"].join("\n"), + ); + await fs.writeFile(`${projectDir}/README.md`, getReadme(preferences)); + // Create packages directory + await fs.mkdir(`${projectDir}/packages`); + + // Create tsconfig package + await fs.mkdir(`${projectDir}/packages/tsconfig`); + await fs.writeFile( + `${projectDir}/packages/tsconfig/package.json`, + getTypeScriptConfigPackageJson(preferences.projectName), + ); + await fs.writeFile( + `${projectDir}/packages/tsconfig/base.json`, + getTSConfigBase(), + ); + await fs.writeFile( + `${projectDir}/packages/tsconfig/elysia.json`, + getElysiaTSConfig(), + ); + await fs.writeFile( + `${projectDir}/packages/tsconfig/vue.json`, + getFrontendTSConfig("Vue"), + ); + await fs.writeFile( + `${projectDir}/packages/tsconfig/node.json`, + getNodeTSConfig(), + ); + + // Create apps directory + await fs.mkdir(`${projectDir}/apps`); +} + +async function generateBackendApp( + projectDir: string, + preferences: PreferencesType, +) { + const backendDir = `${projectDir}/apps/backend`; + + // Create backend application directory and files + await fs.mkdir(backendDir); + await fs.writeFile( + `${backendDir}/package.json`, + getBackendPackageJson(preferences), + ); + await fs.writeFile( + `${backendDir}/tsconfig.json`, + getBackendTSConfig(preferences.plugins), + ); + + // Generate backend directory structure + const { baseDir, srcDir } = await generateBackendDirectories( + projectDir, + true, + ); // isMonorepo + + // Use shared utilities to generate common backend files + await generateCommonBackendFiles( + projectDir, + preferences, + true, // isMonorepo + ); + + // Create user module example + await fs.writeFile(`${srcDir}/modules/user/index.ts`, getUserModuleIndex()); + await fs.writeFile(`${srcDir}/modules/user/users.model.ts`, getUsersModel()); + await fs.writeFile( + `${srcDir}/modules/user/users.service.ts`, + getUsersService(), + ); + await fs.writeFile( + `${srcDir}/modules/user/users.controller.ts`, + getUsersController(), + ); + + // Generate database related files + if (preferences.orm !== "None") { + await fs.writeFile(`${srcDir}/libs/index.ts`, getDBIndex(preferences)); + + // Generate libs auxiliary files + await fs.writeFile(`${srcDir}/libs/schemaHelper.ts`, getSchemaHelper()); + await fs.writeFile(`${srcDir}/libs/common-schemas.ts`, getCommonSchemas()); + await fs.writeFile(`${srcDir}/libs/connection.ts`, getConnection()); + await fs.writeFile(`${srcDir}/libs/healthyCheck.ts`, getHealthyCheck()); + + // Use shared utilities to generate database files + await generateDatabaseFiles(projectDir, preferences, true); // isMonorepo + } + + // plugins directory already created in generateBackendDirectories + + // Create logger plugin + await fs.writeFile(`${backendDir}/src/plugins/logger.ts`, getLoggerPlugin()); + + // Create error handling plugin directory and files + // plugins/err directory already created in generateBackendDirectories + await fs.writeFile(`${backendDir}/src/plugins/err/base.ts`, getErrorBase()); + await fs.writeFile( + `${backendDir}/src/plugins/err/http-error.ts`, + getHttpErrors(), + ); + await fs.writeFile( + `${backendDir}/src/plugins/err/guards.ts`, + getErrorGuards(), + ); + await fs.writeFile( + `${backendDir}/src/plugins/err/database-error-mapper.ts`, + getDatabaseErrorMapper(), + ); + await fs.writeFile(`${backendDir}/src/plugins/err/index.ts`, getErrorIndex()); + await fs.writeFile( + `${backendDir}/src/plugins/err/err.plugin.ts`, + getErrorPlugin(), + ); + + if (preferences.telegramRelated) { + await fs.writeFile( + `${backendDir}/src/plugins/auth.plugin.ts`, + getAuthPlugin(), + ); + } + + // utils and types directories already created in generateBackendDirectories + await fs.writeFile(`${backendDir}/src/utils/Res.ts`, getRes()); + + // Generate type definition files + await fs.writeFile( + `${backendDir}/src/types/index.ts`, + getBackendTypes(preferences), + ); + + // Generate bot file + if (preferences.telegramRelated) { + await fs.writeFile(`${backendDir}/src/bot.ts`, getBotFile()); + } +} + +async function generateFrontendApp( + projectDir: string, + preferences: PreferencesType, +) { + const frontendDir = `${projectDir}/apps/frontend`; + + await fs.mkdir(frontendDir); + + // Create frontend .env file + await fs.writeFile( + `${frontendDir}/.env`, + `VITE_API_URL=http://localhost:5000`, + ); + await fs.writeFile( + `${frontendDir}/.env.production`, + getEnvFile(preferences, true), + ); + + await fs.writeFile( + `${frontendDir}/package.json`, + getFrontendPackageJson(preferences), + ); + + if (preferences.frontend === "Vue") { + // First create necessary directory structure + await fs.mkdir(`${frontendDir}/src`); + await fs.mkdir(`${frontendDir}/src/pages`); + await fs.mkdir(`${frontendDir}/src/components`); + await fs.mkdir(`${frontendDir}/src/assets`); + await fs.mkdir(`${frontendDir}/src/assets/styles`); + await fs.mkdir(`${frontendDir}/src/utils`); + await fs.mkdir(`${frontendDir}/src/utils/api`); + await fs.mkdir(`${frontendDir}/src/router`); + await fs.mkdir(`${frontendDir}/public`); + + // Configuration files + await fs.writeFile( + `${frontendDir}/tsconfig.json`, + getFrontendTSConfig(preferences.frontend), + ); + await fs.writeFile(`${frontendDir}/tsconfig.node.json`, getNodeTSConfig()); + + await fs.writeFile(`${frontendDir}/tsconfig.app.json`, getAppTSConfig()); + await fs.writeFile( + `${frontendDir}/vite.config.ts`, + getViteConfig(preferences.frontend), + ); + await fs.writeFile( + `${frontendDir}/index.html`, + getIndexHTML(preferences.projectName), + ); + + // Main files + await fs.writeFile(`${frontendDir}/src/main.ts`, getVueMain()); + await fs.writeFile( + `${frontendDir}/src/App.vue`, + getAppVue(preferences.projectName), + ); + await fs.writeFile(`${frontendDir}/src/pages/Home.vue`, getHomeVue()); + await fs.writeFile(`${frontendDir}/src/env.d.ts`, getEnvDTs()); + + // Style files + await fs.writeFile( + `${frontendDir}/src/assets/styles/main.css`, + getMainCSS(), + ); + + // Router files + await fs.writeFile(`${frontendDir}/src/router/index.ts`, getRouter()); + + // API utility files + await fs.writeFile( + `${frontendDir}/src/utils/api/useTreaty.ts`, + getUseTreaty(), + ); + await fs.writeFile( + `${frontendDir}/src/utils/api/useApiWithToast.ts`, + getUseApiWithToast(), + ); + await fs.writeFile( + `${frontendDir}/src/utils/api/handleApi.ts`, + getHandleApi(), + ); + } +} diff --git a/src/generators/single-app.ts b/src/generators/single-app.ts new file mode 100644 index 00000000..f2604e10 --- /dev/null +++ b/src/generators/single-app.ts @@ -0,0 +1,208 @@ +import fs from "node:fs/promises"; +import dedent from "ts-dedent"; +import { + generateEslintConfig, + getConfigFile, + getDBIndex, + getDrizzleConfig, + getEnvFile, + getIndex, + getReadme, + getTSConfig, +} from "../templates"; +import { getBotFile } from "../templates/bot"; +import { + getDevelopmentDockerCompose, + getDockerCompose, + getDockerfile, +} from "../templates/docker"; +import { getElysiaIndex } from "../templates/elysia"; +import { getPackageJson } from "../templates/package/package.json"; +import { getAuthPlugin } from "../templates/services/auth"; +import { getJobifyFile } from "../templates/services/jobify"; +import { getLocksFile } from "../templates/services/locks"; +import { getPosthogIndex } from "../templates/services/posthog"; +import { getRedisFile } from "../templates/services/redis"; +import { getS3ServiceFile } from "../templates/services/s3"; +import { + getPreloadFile, + getTestSharedFile, + getTestsAPIFile, + getTestsIndex, +} from "../templates/tests"; +import { getVSCodeExtensions, getVSCodeSettings } from "../templates/vscode"; +import type { PreferencesType } from "../utils"; +import { + getDatabaseSchemaImport, + generateDatabaseFiles, + generateBackendDirectories, + generateCommonBackendFiles, +} from "./utils/backend-helpers"; +import { + collectTelegramPreferences, + collectCommonBackendPreferences, + collectOtherToolsPreferences, +} from "./utils/preference-collectors"; + +export async function generateSingleApp( + projectDir: string, + preferences: PreferencesType, +) { + try { + // Generate file structure + await generateFiles(projectDir, preferences); + } catch (error) { + throw new Error(`Failed to generate single app: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export async function collectPreferences(preferences: PreferencesType) { + // Collect Telegram preference with single-app default + await collectTelegramPreferences(preferences, { + initial: "no", + message: "Is your project related to Telegram (Did you want to validate init data and etc)?", + }); + + // Collect common backend preferences with single-app options + await collectCommonBackendPreferences(preferences, { + linterChoices: ["None", "ESLint", "Biome"], // Single app supports all linters + ormChoices: ["None", "Prisma", "Drizzle"], // Single app supports all ORMs + includePrismaDatabases: true, // Single app includes Prisma options + }); + + // Collect other tools preferences + await collectOtherToolsPreferences(preferences, { + includeS3: true, + includePosthog: true, + includeJobify: true, + includeHusky: true, + includeLocks: true, + includeRedis: true, + includeDocker: true, + includeVSCode: true, + }); +} + +async function generateFiles(projectDir: string, preferences: PreferencesType) { + if (preferences.plugins.includes("Static")) + await fs.mkdir(`${projectDir}/public`); + + if (preferences.linter === "ESLint") + await fs.writeFile( + `${projectDir}/eslint.config.mjs`, + generateEslintConfig(preferences), + ); + + await fs.writeFile(`${projectDir}/package.json`, getPackageJson(preferences)); + await fs.writeFile(`${projectDir}/tsconfig.json`, getTSConfig(preferences)); + await fs.writeFile(`${projectDir}/README.md`, getReadme(preferences)); + await fs.writeFile( + `${projectDir}/.gitignore`, + ["dist", "node_modules", ".env", ".env.production"].join("\n"), + ); + + // Use shared tools to generate common backend files and directories + await generateCommonBackendFiles(projectDir, preferences, false); // not isMonorepo + const { srcDir } = await generateBackendDirectories(projectDir, false); // not isMonorepo + + // Generate database related files + if (preferences.orm !== "None") { + await fs.writeFile( + `${srcDir}/db/index.ts`, + getDBIndex(preferences), + ); + // Use shared tools to generate database files + await generateDatabaseFiles(projectDir, preferences, false); // not isMonorepo + } + + await fs.mkdir(`${srcDir}/services`); + + if (preferences.others.includes("Posthog")) { + await fs.writeFile( + `${srcDir}/services/posthog.ts`, + getPosthogIndex(), + ); + } + + if (preferences.others.includes("Jobify")) { + await fs.writeFile(`${srcDir}/services/jobify.ts`, getJobifyFile()); + await fs.mkdir(`${srcDir}/jobs`); + } + + if (preferences.redis) { + await fs.writeFile(`${srcDir}/services/redis.ts`, getRedisFile()); + } + + if (preferences.locks) { + await fs.writeFile( + `${srcDir}/services/locks.ts`, + getLocksFile(preferences), + ); + } + + if (preferences.s3Client !== "None") { + await fs.writeFile( + `${srcDir}/services/s3.ts`, + getS3ServiceFile(preferences), + ); + } + + if (preferences.telegramRelated) { + await fs.writeFile( + `${srcDir}/services/auth.plugin.ts`, + getAuthPlugin(), + ); + } + + if (preferences.docker) { + await fs.writeFile(`${projectDir}/Dockerfile`, getDockerfile(preferences)); + await fs.writeFile( + `${projectDir}/docker-compose.dev.yml`, + getDevelopmentDockerCompose(preferences), + ); + await fs.writeFile( + `${projectDir}/docker-compose.yml`, + getDockerCompose(preferences), + ); + } + + if (preferences.vscode) { + await fs.mkdir(`${projectDir}/.vscode`); + await fs.writeFile( + `${projectDir}/.vscode/settings.json`, + getVSCodeSettings(preferences), + ); + await fs.writeFile( + `${projectDir}/.vscode/extensions.json`, + getVSCodeExtensions(preferences), + ); + } + + if (preferences.mockWithPGLite) { + await fs.mkdir(`${projectDir}/tests`); + await fs.writeFile( + `${projectDir}/tests/preload.ts`, + getPreloadFile(preferences), + ); + await fs.writeFile(`${projectDir}/tests/api.ts`, getTestsAPIFile()); + await fs.mkdir(`${projectDir}/tests/e2e`); + await fs.writeFile( + `${projectDir}/tests/e2e/index.test.ts`, + getTestsIndex(), + ); + + await fs.writeFile( + `${projectDir}/bunfig.toml`, + dedent /* toml */`[test] + preload = ["./tests/preload.ts"] + `, + ); + + if (preferences.telegramRelated) + await fs.writeFile(`${projectDir}/tests/shared.ts`, getTestSharedFile()); + } + + if (preferences.telegramRelated) { + await fs.writeFile(`${srcDir}/bot.ts`, getBotFile()); + } +} diff --git a/src/generators/utils/backend-helpers.ts b/src/generators/utils/backend-helpers.ts new file mode 100644 index 00000000..a1b171e5 --- /dev/null +++ b/src/generators/utils/backend-helpers.ts @@ -0,0 +1,148 @@ +import type { PreferencesType } from "../../utils"; + +/** + * Get database schema import statement based on database type + * Used for Drizzle ORM schema files + */ +export function getDatabaseSchemaImport(database: string): string { + switch (database) { + case "PostgreSQL": + return `// import { pgTable } from "drizzle-orm/pg-core"`; + case "MySQL": + return `// import { mysqlTable } from "drizzle-orm/mysql-core"`; + case "SQLite": + return `// import { sqliteTable } from "drizzle-orm/sqlite-core"`; + default: + return `// import { pgTable } from "drizzle-orm/pg-core"`; + } +} + +/** + * Get drivers map for Drizzle ORM based on database type and runtime + */ +export function getDrizzleDriversMap( + database: string, + runtime: "Bun" | "Node.js", +) { + const driversMap: Record = { + PostgreSQL: ( + [ + runtime === "Bun" ? "Bun.sql" : undefined, + "node-postgres", + "Postgres.JS", + ] as const + ).filter((x) => x !== undefined), + MySQL: ["MySQL 2"], + SQLite: ["Bun SQLite"], + }; + + return driversMap[database] || []; +} + +/** + * Generate database-related files for both monorepo and single-app + */ +export async function generateDatabaseFiles( + projectDir: string, + preferences: PreferencesType, + isMonorepo: boolean = false, +) { + const fs = await import("node:fs/promises"); + + // Directory structure differs between monorepo and single-app + const dbDir = isMonorepo + ? `${projectDir}/apps/backend/src/libs` + : `${projectDir}/src/db`; + const configDir = isMonorepo ? `${projectDir}/apps/backend` : projectDir; + + if (preferences.orm === "Drizzle") { + // Import here to avoid circular dependencies + const { getDrizzleConfig } = await import("../../templates"); + + await fs.writeFile( + `${configDir}/drizzle.config.ts`, + getDrizzleConfig(preferences), + ); + await fs.writeFile( + `${dbDir}/schema.ts`, + getDatabaseSchemaImport(preferences.database || "PostgreSQL"), + ); + if (preferences.database === "SQLite") { + const dbPath = isMonorepo + ? `${projectDir}/apps/backend/sqlite.db` + : `${projectDir}/sqlite.db`; + await fs.writeFile(dbPath, ""); + } + } +} + +/** + * Generate backend directory structure common to both monorepo and single-app + */ +export async function generateBackendDirectories( + projectDir: string, + isMonorepo: boolean = false, +) { + const fs = await import("node:fs/promises"); + + const baseDir = isMonorepo ? `${projectDir}/apps/backend` : projectDir; + const srcDir = `${baseDir}/src`; + + // Common directories + const directories = [ + `${srcDir}/configs`, + `${srcDir}/modules/user`, + `${srcDir}/plugins/err`, + `${srcDir}/utils`, + `${srcDir}/types`, + ]; + + if (isMonorepo) { + // Additional directories for monorepo + directories.push(`${srcDir}/libs`); + } else { + // Single-app specific directories + directories.push(`${srcDir}/routes`, `${srcDir}/services`); + } + + // Create all directories + for (const dir of directories) { + await fs.mkdir(dir, { recursive: true }); + } + + // Create public directory if Static plugin is used + // Note: This needs to be called after preferences are available + return { baseDir, srcDir }; +} + +/** + * Generate common backend files (config, index, server) + */ +export async function generateCommonBackendFiles( + projectDir: string, + preferences: PreferencesType, + isMonorepo: boolean = false, +) { + const fs = await import("node:fs/promises"); + const { getConfigFile, getIndex, getEnvFile } = await import( + "../../templates" + ); + const { getElysiaIndex } = await import("../../templates/elysia"); + + const baseDir = isMonorepo ? `${projectDir}/apps/backend` : projectDir; + const srcDir = `${baseDir}/src`; + + // Common files + await fs.writeFile(`${srcDir}/index.ts`, getIndex(preferences)); + await fs.writeFile(`${srcDir}/server.ts`, getElysiaIndex(preferences)); + await fs.writeFile(`${srcDir}/config.ts`, getConfigFile(preferences)); + + // Environment files + await fs.writeFile(`${baseDir}/.env`, getEnvFile(preferences)); + await fs.writeFile( + `${baseDir}/.env.production`, + getEnvFile(preferences, true), + ); + + return { baseDir, srcDir }; +} diff --git a/src/generators/utils/preference-collectors.ts b/src/generators/utils/preference-collectors.ts new file mode 100644 index 00000000..f432760b --- /dev/null +++ b/src/generators/utils/preference-collectors.ts @@ -0,0 +1,303 @@ +import type { PreferencesType } from "../../utils"; + +// Dynamic import wrapper for enquirer to avoid ESM/CommonJS issues +async function prompt(questions: any): Promise { + const { default: Enquirer } = await import("enquirer"); + const enquirer = new Enquirer(); + return enquirer.prompt(questions); +} + +/** + * Collect common backend preferences (linter, ORM, database, driver, plugins) + * This function extracts shared logic between monorepo and single-app generators + */ +export async function collectCommonBackendPreferences( + preferences: PreferencesType, + options: { + linterChoices?: ("None" | "ESLint" | "Biome")[]; + ormChoices?: ("None" | "Prisma" | "Drizzle")[]; + includePrismaDatabases?: boolean; + } = {}, +) { + const { + linterChoices = ["None", "ESLint", "Biome"], + ormChoices = ["None", "Prisma", "Drizzle"], + includePrismaDatabases = true, + } = options; + + // Collect linter preference + const { linter } = await prompt<{ linter: PreferencesType["linter"] }>({ + type: "select", + name: "linter", + message: "Select linters/formatters:", + choices: linterChoices, + }); + preferences.linter = linter; + + // Collect ORM preference + const { orm } = await prompt<{ orm: PreferencesType["orm"] }>({ + type: "select", + name: "orm", + message: "Select ORM/Query Builder:", + choices: ormChoices, + }); + preferences.orm = orm; + + // Collect database and driver preferences based on ORM + if (orm === "Prisma" && includePrismaDatabases) { + const { database } = await prompt<{ + database: PreferencesType["database"]; + }>({ + type: "select", + name: "database", + message: "Select DataBase for Prisma:", + choices: [ + "PostgreSQL", + "MySQL", + "MongoDB", + "SQLite", + "SQLServer", + "CockroachDB", + ], + }); + preferences.database = database; + } + + if (orm === "Drizzle") { + const { database } = await prompt<{ + database: "PostgreSQL" | "MySQL" | "SQLite"; + }>({ + type: "select", + name: "database", + message: "Select DataBase for Drizzle:", + choices: ["PostgreSQL", "MySQL", "SQLite"], + }); + + const driversMap: Record = { + PostgreSQL: ( + [ + preferences.runtime === "Bun" ? "Bun.sql" : undefined, + "node-postgres", + "Postgres.JS", + ] as const + ).filter((x) => x !== undefined), + MySQL: ["MySQL 2"], + SQLite: ["Bun SQLite"], + }; + + const { driver } = await prompt<{ driver: PreferencesType["driver"] }>({ + type: "select", + name: "driver", + message: `Select driver for ${database}:`, + choices: driversMap[database], + }); + preferences.database = database; + preferences.driver = driver; + + // Ask about PGLite for PostgreSQL + if (database === "PostgreSQL") { + const { mockWithPGLite } = await prompt<{ + mockWithPGLite: PreferencesType["mockWithPGLite"]; + }>({ + type: "toggle", + name: "mockWithPGLite", + initial: "yes", + message: + "Do you want to mock database in tests with PGLite (Postgres in WASM)?", + }); + preferences.mockWithPGLite = mockWithPGLite; + } + } + + // Collect Elysia plugins preference + const { plugins } = await prompt<{ + plugins: PreferencesType["plugins"]; + }>({ + type: "multiselect", + name: "plugins", + message: "Select Elysia plugins: (Space to select, Enter to continue)", + choices: [ + "CORS", + "Swagger", + "openAPI", + "JWT", + "Autoload", + "Oauth 2.0", + "HTML/JSX", + "Static", + "Bearer", + "Server Timing", + ] as PreferencesType["plugins"], + }); + preferences.plugins = plugins; +} + +/** + * Collect other tools preferences (S3, Posthog, Jobify, Husky, etc.) + */ +export async function collectOtherToolsPreferences( + preferences: PreferencesType, + options: { + includeS3?: boolean; + includePosthog?: boolean; + includeJobify?: boolean; + includeHusky?: boolean; + includeLocks?: boolean; + includeRedis?: boolean; + includeDocker?: boolean; + includeVSCode?: boolean; + } = {}, +) { + const { + includeS3 = true, + includePosthog = true, + includeJobify = true, + includeHusky = true, + includeLocks = true, + includeRedis = true, + includeDocker = true, + includeVSCode = true, + } = options; + + // Build choices for other tools + const otherChoices: string[] = []; + if (includeS3) otherChoices.push("S3"); + if (includePosthog) otherChoices.push("Posthog"); + if (includeJobify) otherChoices.push("Jobify"); + if (includeHusky) otherChoices.push("Husky"); + + if (otherChoices.length > 0) { + const { others } = await prompt<{ others: PreferencesType["others"] }>({ + type: "multiselect", + name: "others", + message: "Select others tools: (Space to select, Enter to continue)", + choices: otherChoices, + }); + preferences.others = others; + + // S3 client preference + if (includeS3 && others.includes("S3")) { + const { s3Client } = await prompt<{ + s3Client: PreferencesType["s3Client"]; + }>({ + type: "select", + name: "s3Client", + message: "Select S3 client:", + choices: ["Bun.S3Client", "@aws-sdk/client-s3"], + }); + preferences.s3Client = s3Client; + } + + // Git repository preference (if not using Husky) + if (includeHusky) { + if (!others.includes("Husky")) { + const { git } = await prompt<{ git: boolean }>({ + type: "toggle", + name: "git", + initial: "yes", + message: "Create an empty Git repository?", + }); + preferences.git = git; + } else { + preferences.git = true; + } + } + } + + // Locks preference + if (includeLocks) { + const { locks } = await prompt<{ locks: boolean }>({ + type: "toggle", + name: "locks", + initial: "yes", + message: "Do you want to use Locks to prevent race conditions?", + }); + preferences.locks = locks; + } + + // Redis preference + if (includeRedis) { + if (preferences.others.includes("Jobify")) { + preferences.redis = true; + } else { + const { redis } = await prompt<{ redis: boolean }>({ + type: "toggle", + name: "redis", + initial: "yes", + message: "Do you want to use Redis?", + }); + preferences.redis = redis; + } + } + + // Docker preference + if (includeDocker) { + const { docker } = await prompt<{ docker: boolean }>({ + type: "toggle", + name: "docker", + initial: "yes", + message: "Create Dockerfile + docker.compose.yml?", + }); + preferences.docker = docker; + } + + // VS Code preference + if (includeVSCode) { + const { vscode } = await prompt<{ vscode: boolean }>({ + type: "toggle", + name: "vscode", + initial: "yes", + message: + "Create .vscode folder with VSCode extensions recommendations and settings?", + }); + preferences.vscode = vscode; + } +} + +/** + * Collect Telegram-related preferences + */ +export async function collectTelegramPreferences( + preferences: PreferencesType, + options: { + initial?: "yes" | "no"; + message?: string; + } = {}, +) { + const { initial = "no", message } = options; + + const { telegramRelated } = await prompt<{ + telegramRelated: boolean; + }>({ + type: "toggle", + name: "telegramRelated", + initial, + message: + message || + "Is your project related to Telegram (Did you want to validate init data and etc)?", + }); + preferences.telegramRelated = telegramRelated; +} + +/** + * Collect frontend framework preference (for monorepo) + */ +export async function collectFrontendPreferences( + preferences: PreferencesType, + options: { + choices?: ("None" | "Vue" | "React" | "Solid" | "Svelte")[]; + message?: string; + } = {}, +) { + const { choices = ["None", "Vue"], message } = options; + + const { frontend } = await prompt<{ + frontend: PreferencesType["frontend"]; + }>({ + type: "select", + name: "frontend", + message: message || "Select frontend framework:", + choices, + }); + preferences.frontend = frontend; +} diff --git a/src/index.ts b/src/index.ts index 0848f1ab..077c5fd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,50 +1,28 @@ #!/usr/bin/env node import fs from "node:fs/promises"; import path from "node:path"; -import { prompt } from "enquirer"; import minimist from "minimist"; import task from "tasuku"; -import dedent from "ts-dedent"; import { - generateEslintConfig, - getConfigFile, - getDBIndex, - getDrizzleConfig, - getElysiaIndex, - getEnvFile, - getIndex, - getInstallCommands, - getPackageJson, - getReadme, - getTSConfig, -} from "./templates"; -import { getBotFile } from "./templates/bot"; + collectMonorepoPreferences, + generateMonorepo, +} from "./generators/monorepo"; +import { collectPreferences, generateSingleApp } from "./generators/single-app"; +import { getInstallCommands } from "./templates"; import { - getDevelopmentDockerCompose, - getDockerCompose, - getDockerfile, -} from "./templates/docker"; -import { getAuthPlugin } from "./templates/services/auth"; -import { getJobifyFile } from "./templates/services/jobify"; -import { getLocksFile } from "./templates/services/locks"; -import { getPosthogIndex } from "./templates/services/posthog"; -import { getRedisFile } from "./templates/services/redis"; -import { getS3ServiceFile } from "./templates/services/s3"; -import { - getPreloadFile, - getTestSharedFile, - getTestsAPIFile, - getTestsIndex, -} from "./templates/tests"; -import { getVSCodeExtensions, getVSCodeSettings } from "./templates/vscode"; -import { - Preferences, - type PreferencesType, createOrFindDir, detectPackageManager, exec, + Preferences, } from "./utils"; +// Dynamic import wrapper for enquirer to avoid ESM/CommonJS issues +async function prompt(questions: any): Promise { + const { default: Enquirer } = await import("enquirer"); + const enquirer = new Enquirer(); + return enquirer.prompt(questions); +} + const preferences = new Preferences(); const args = minimist(process.argv.slice(2)); @@ -89,387 +67,40 @@ createOrFindDir(projectDir) preferences.dir = dir; preferences.projectName = path.basename(projectDir); preferences.packageManager = packageManager; - preferences.isMonorepo = !!args.monorepo; preferences.runtime = packageManager === "bun" ? "Bun" : "Node.js"; - // biome-ignore lint/complexity/noExtraBooleanCast: + // biome-ignore lint/complexity/noExtraBooleanCast: < explicit conversion for clarity> preferences.noInstall = !Boolean(args.install ?? true); - const filesInTargetDirectory = await fs.readdir(projectDir); - if (filesInTargetDirectory.length) { - const { overwrite } = await prompt<{ overwrite: boolean }>({ - type: "toggle", - name: "overwrite", - initial: "yes", - message: `\n${filesInTargetDirectory.join( - "\n", - )}\n\nThe directory ${preferences.projectName} is not empty. Do you want to delete the files?`, - }); - if (!overwrite) { - console.log("Cancelled..."); - return process.exit(0); - } - - await fs.rm(projectDir, { recursive: true }); - await fs.mkdir(projectDir); - } - - if (!args.monorepo) { - const { telegramRelated } = await prompt<{ - telegramRelated: PreferencesType["telegramRelated"]; - }>({ - type: "toggle", - name: "telegramRelated", - initial: "no", - message: - "Is your project related to Telegram (Did you wants to validate init data and etc)?", - }); - preferences.telegramRelated = telegramRelated; - - const { linter } = await prompt<{ linter: PreferencesType["linter"] }>({ - type: "select", - name: "linter", - message: "Select linters/formatters:", - choices: ["None", "ESLint", "Biome"], - }); - preferences.linter = linter; - - const { orm } = await prompt<{ orm: PreferencesType["orm"] }>({ - type: "select", - name: "orm", - message: "Select ORM/Query Builder:", - choices: ["None", "Prisma", "Drizzle"], - }); - preferences.orm = orm; - if (orm === "Prisma") { - const { database } = await prompt<{ - database: PreferencesType["database"]; - }>({ - type: "select", - name: "database", - message: "Select DataBase for Prisma:", - choices: [ - "PostgreSQL", - "MySQL", - "MongoDB", - "SQLite", - "SQLServer", - "CockroachDB", - ], - }); - preferences.database = database; - } - if (orm === "Drizzle") { - const { database } = await prompt<{ - database: "PostgreSQL" | "MySQL" | "SQLite"; - }>({ - type: "select", - name: "database", - message: "Select DataBase for Drizzle:", - choices: ["PostgreSQL", "MySQL", "SQLite"], - }); - const driversMap: Record = - { - PostgreSQL: ( - [ - preferences.runtime === "Bun" ? "Bun.sql" : undefined, - "node-postgres", - "Postgres.JS", - ] as const - ).filter((x) => x !== undefined), - MySQL: ["MySQL 2"], - SQLite: ["Bun SQLite"], - }; - - const { driver } = await prompt<{ driver: PreferencesType["driver"] }>({ - type: "select", - name: "driver", - message: `Select driver for ${database}:`, - choices: driversMap[database], - }); - preferences.database = database; - preferences.driver = driver; + // Ask whether to create a monorepo project + const { isMonorepo } = await prompt<{ isMonorepo: boolean }>({ + type: "toggle", + name: "isMonorepo", + initial: "no", + message: "Do you want to create a monorepo project?", + }); + preferences.isMonorepo = isMonorepo; - if (database === "PostgreSQL") { - const { mockWithPGLite } = await prompt<{ - mockWithPGLite: PreferencesType["mockWithPGLite"]; - }>({ - type: "toggle", - name: "mockWithPGLite", - initial: "yes", - message: - "Do you want to mock database in tests with PGLite (Postgres in WASM)?", - }); - preferences.mockWithPGLite = mockWithPGLite; - } - } + // 先收集所有用户偏好(在 task 之外执行) + if (preferences.isMonorepo) { + preferences.frontendDir = `${projectDir}/apps/frontend`; + preferences.backendDir = `${projectDir}/apps/backend`; + await collectMonorepoPreferences(preferences); } else { - preferences.telegramRelated = true; + await collectPreferences(preferences); } - const { plugins } = await prompt<{ - plugins: PreferencesType["plugins"]; - }>({ - type: "multiselect", - name: "plugins", - message: "Select Elysia plugins: (Space to select, Enter to continue)", - choices: [ - "CORS", - "Swagger", - "JWT", - "Autoload", - "Oauth 2.0", - // "Logger", - "HTML/JSX", - "Static", - "Bearer", - "Server Timing", - ] as PreferencesType["plugins"], - }); - preferences.plugins = plugins; - if (!args.monorepo) { - const { others } = await prompt<{ others: PreferencesType["others"] }>({ - type: "multiselect", - name: "others", - message: "Select others tools: (Space to select, Enter to continue)", - choices: ["S3", "Posthog", "Jobify", "Husky"], - }); - preferences.others = others; - - if (others.includes("S3")) { - const { s3Client } = await prompt<{ - s3Client: PreferencesType["s3Client"]; - }>({ - type: "select", - name: "s3Client", - message: "Select S3 client:", - choices: ["Bun.S3Client", "@aws-sdk/client-s3"], - }); - preferences.s3Client = s3Client; - } - - if (!others.includes("Husky")) { - const { git } = await prompt<{ git: boolean }>({ - type: "toggle", - name: "git", - initial: "yes", - message: "Create an empty Git repository?", - }); - preferences.git = git; - } else preferences.git = true; - const { locks } = await prompt<{ locks: boolean }>({ - type: "toggle", - name: "locks", - initial: "yes", - message: "Do you want to use Locks to prevent race conditions?", - }); - - preferences.locks = locks; - - if (others.includes("Jobify")) { - preferences.redis = true; + // Then execute generation task (task only performs file generation, no interaction) + await task("Generating template...", async ({ setTitle }) => { + if (preferences.isMonorepo) { + setTitle("Setting up monorepo structure..."); + await generateMonorepo(projectDir, preferences); + setTitle("Monorepo template generation is complete!"); } else { - const { redis } = await prompt<{ redis: boolean }>({ - type: "toggle", - name: "redis", - initial: "yes", - message: "Do you want to use Redis?", - }); - - preferences.redis = redis; + setTitle("Setting up single application..."); + await generateSingleApp(projectDir, preferences); + setTitle("Single application template generation is complete!"); } - - const { docker } = await prompt<{ docker: boolean }>({ - type: "toggle", - name: "docker", - initial: "yes", - message: "Create Dockerfile + docker.compose.yml?", - }); - - preferences.docker = docker; - - const { vscode } = await prompt<{ vscode: boolean }>({ - type: "toggle", - name: "vscode", - initial: "yes", - message: - "Create .vscode folder with VSCode extensions recommendations and settings?", - }); - - preferences.vscode = vscode; - } - - await task("Generating a template...", async ({ setTitle }) => { - if (plugins.includes("Static")) await fs.mkdir(projectDir + "/public"); - - if (preferences.linter === "ESLint") - await fs.writeFile( - `${projectDir}/eslint.config.mjs`, - generateEslintConfig(preferences), - ); - - await fs.writeFile( - projectDir + "/package.json", - getPackageJson(preferences), - ); - await fs.writeFile( - projectDir + "/tsconfig.json", - getTSConfig(preferences), - ); - await fs.writeFile(projectDir + "/.env", getEnvFile(preferences)); - await fs.writeFile( - projectDir + "/.env.production", - getEnvFile(preferences, true), - ); - await fs.writeFile(projectDir + "/README.md", getReadme(preferences)); - await fs.writeFile( - projectDir + "/.gitignore", - ["dist", "node_modules", ".env", ".env.production"].join("\n"), - ); - await fs.mkdir(projectDir + "/src"); - await fs.writeFile( - projectDir + "/src/server.ts", - getElysiaIndex(preferences), - ); - await fs.writeFile(projectDir + "/src/index.ts", getIndex(preferences)); - await fs.writeFile( - `${projectDir}/src/config.ts`, - getConfigFile(preferences), - ); - - // if (plugins.includes("Autoload")) - await fs.mkdir(projectDir + "/src/routes"); - - if (preferences.orm !== "None") { - await fs.mkdir(projectDir + "/src/db"); - await fs.writeFile( - projectDir + "/src/db/index.ts", - getDBIndex(preferences), - ); - - if (preferences.orm === "Drizzle") { - await fs.writeFile( - projectDir + "/drizzle.config.ts", - getDrizzleConfig(preferences), - ); - await fs.writeFile( - projectDir + "/src/db/schema.ts", - preferences.database === "PostgreSQL" - ? `// import { pgTable } from "drizzle-orm/pg-core"` - : preferences.database === "MySQL" - ? `// import { mysqlTable } from "drizzle-orm/mysql-core"` - : `// import { sqliteTable } from "drizzle-orm/sqlite-core"`, - ); - if (preferences.database === "SQLite") - await fs.writeFile(projectDir + "/sqlite.db", ""); - } - } - - await fs.mkdir(projectDir + "/src/services"); - - if (preferences.others.includes("Posthog")) { - await fs.writeFile( - `${projectDir}/src/services/posthog.ts`, - getPosthogIndex(), - ); - } - - if (preferences.others.includes("Jobify")) { - await fs.writeFile( - `${projectDir}/src/services/jobify.ts`, - getJobifyFile(), - ); - await fs.mkdir(projectDir + "/src/jobs"); - } - - if (preferences.redis) { - await fs.writeFile( - `${projectDir}/src/services/redis.ts`, - getRedisFile(), - ); - } - - if (preferences.locks) { - await fs.writeFile( - `${projectDir}/src/services/locks.ts`, - getLocksFile(preferences), - ); - } - - if (preferences.s3Client !== "None") { - await fs.writeFile( - `${projectDir}/src/services/s3.ts`, - getS3ServiceFile(preferences), - ); - } - - if (preferences.telegramRelated) { - await fs.writeFile( - `${projectDir}/src/services/auth.plugin.ts`, - getAuthPlugin(), - ); - } - if (preferences.docker) { - await fs.writeFile( - `${projectDir}/Dockerfile`, - getDockerfile(preferences), - ); - await fs.writeFile( - `${projectDir}/docker-compose.dev.yml`, - getDevelopmentDockerCompose(preferences), - ); - await fs.writeFile( - `${projectDir}/docker-compose.yml`, - getDockerCompose(preferences), - ); - } - - if (preferences.vscode) { - await fs.mkdir(`${projectDir}/.vscode`); - await fs.writeFile( - `${projectDir}/.vscode/settings.json`, - getVSCodeSettings(preferences), - ); - await fs.writeFile( - `${projectDir}/.vscode/extensions.json`, - getVSCodeExtensions(preferences), - ); - } - - if (preferences.mockWithPGLite) { - await fs.mkdir(projectDir + "/tests"); - await fs.writeFile( - `${projectDir}/tests/preload.ts`, - getPreloadFile(preferences), - ); - await fs.writeFile( - `${projectDir}/tests/api.ts`, - getTestsAPIFile(preferences), - ); - await fs.mkdir(`${projectDir}/tests/e2e`); - await fs.writeFile( - `${projectDir}/tests/e2e/index.test.ts`, - getTestsIndex(preferences), - ); - - await fs.writeFile( - `${projectDir}/bunfig.toml`, - dedent /* toml */`[test] - preload = ["./tests/preload.ts"] - `, - ); - - if (preferences.telegramRelated) - await fs.writeFile( - `${projectDir}/tests/shared.ts`, - getTestSharedFile(), - ); - } - - if (preferences.telegramRelated && !preferences.isMonorepo) - await fs.writeFile(`${projectDir}/src/bot.ts`, getBotFile()); - - setTitle("Template generation is complete!"); }); const commands = getInstallCommands(preferences); diff --git a/src/templates/backend-types.ts b/src/templates/backend-types.ts new file mode 100644 index 00000000..33af87c7 --- /dev/null +++ b/src/templates/backend-types.ts @@ -0,0 +1,6 @@ +import type { PreferencesType } from "../utils"; + +export function getBackendTypes(preferences: PreferencesType) { + return ` +`; +} diff --git a/src/templates/backend/libs/common-schemas.ts b/src/templates/backend/libs/common-schemas.ts new file mode 100644 index 00000000..5c98c5b8 --- /dev/null +++ b/src/templates/backend/libs/common-schemas.ts @@ -0,0 +1,23 @@ +export function getCommonSchemas() { + return `import { t } from "elysia"; + +/** + * Common pagination query Schema + */ +export const PageQuerySchema = t.Object({ + page: t.Integer({ minimum: 1, default: 1 }), + limit: t.Integer({ minimum: 1, maximum: 100, default: 10 }), +}); + +/** + * Common pagination response Schema + */ +export const PageResponseSchema = t.Object({ + data: t.Array(t.Any()), + total: t.Integer(), + page: t.Integer(), + limit: t.Integer(), + totalPages: t.Integer(), +}); +`; +} \ No newline at end of file diff --git a/src/templates/backend/libs/connection.ts b/src/templates/backend/libs/connection.ts new file mode 100644 index 00000000..8cee43d7 --- /dev/null +++ b/src/templates/backend/libs/connection.ts @@ -0,0 +1,15 @@ +export function getConnection() { + return `import { drizzle } from "drizzle-orm/node-postgres"; +import * as schema from "./schema"; +import { config } from "../configs/config"; + +// You can specify any property from the node-postgres connection options +const db = drizzle({ + connection: { + connectionString: config.DATABASE_URL, + schema, +}); + +export { db }; +`; +} diff --git a/src/templates/backend/libs/healthyCheck.ts b/src/templates/backend/libs/healthyCheck.ts new file mode 100644 index 00000000..d9f50474 --- /dev/null +++ b/src/templates/backend/libs/healthyCheck.ts @@ -0,0 +1,49 @@ +export function getHealthyCheck() { + return `import { sql } from "drizzle-orm"; +import { db } from "./connection"; + +/** + * Health check function - verifies database connection is working properly + * @returns Health check result + */ +export async function performHealthCheck() { + try { + // Print database connection information + const url = process.env.DATABASE_URL; + if (url) { + try { + const u = new URL(url); + const safe = \`\${u.protocol}//\${u.hostname}\${u.port ? ":" : ""}\${u.port}\${u.pathname}\`; + console.log("Database connection target", safe); + } catch { + console.log("Database connection string configured"); + } + } + // Use drizzle's query method instead of execute(sql\`\`) + const result = await db.execute(sql\`SELECT 1 + 1 AS solution\`); + + console.log("Database connection successful"); + return { success: true, message: "Health check passed" }; + } catch (error) { + console.error("Database connection failed:", error); + return { + success: false, + message: \`Database connection failed: \${(error as Error).message}\`, + }; + } +} + +// Health check function before server startup +export async function startupHealthCheck() { + console.log("🔍 Performing startup health check..."); + const result = await performHealthCheck(); + + if (result.success) { + console.log("✅ Database health check passed"); + } else { + console.warn(\`⚠️ Database health check failed: \${result.message}\`); + console.warn("⚠️ Continuing server startup, but database features may not be available"); + } +} +`; +} diff --git a/src/templates/backend/libs/index.ts b/src/templates/backend/libs/index.ts new file mode 100644 index 00000000..be6504cc --- /dev/null +++ b/src/templates/backend/libs/index.ts @@ -0,0 +1,4 @@ +export * from "./common-schemas"; +export * from "./connection"; +export * from "./healthyCheck"; +export * from "./schemaHelper"; diff --git a/src/templates/backend/libs/schemaHelper.ts b/src/templates/backend/libs/schemaHelper.ts new file mode 100644 index 00000000..f661861a --- /dev/null +++ b/src/templates/backend/libs/schemaHelper.ts @@ -0,0 +1,15 @@ +export function getSchemaHelper() { + return `import { serial, timestamp } from "drizzle-orm/pg-core"; + +export const id = serial("id").primaryKey(); + +export const createdAt = timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(); + +export const updatedAt = timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()); +`; +} \ No newline at end of file diff --git a/src/templates/backend/modules/user/index.ts b/src/templates/backend/modules/user/index.ts new file mode 100644 index 00000000..4dd7dd04 --- /dev/null +++ b/src/templates/backend/modules/user/index.ts @@ -0,0 +1,7 @@ +export function getUserModuleIndex() { + return `// User module unified export +export { usersController } from "./users.controller"; + +export { UsersService } from "./users.service"; +`; +} diff --git a/src/templates/backend/modules/user/users.controller.ts b/src/templates/backend/modules/user/users.controller.ts new file mode 100644 index 00000000..fb746016 --- /dev/null +++ b/src/templates/backend/modules/user/users.controller.ts @@ -0,0 +1,21 @@ +export function getUsersController() { + return `import Elysia from "elysia"; +import { commonRes } from "../../utils/Res"; + +/** + * User management controller + * Handles user-related HTTP requests + */ +export const usersController = new Elysia({ + prefix: "/users", + tags: ["user management"], +}) + + .get("/ping", () => { + return commonRes("pong"); + }, { + detail: { + summary: "healthyCheck", } + }); +`; +} diff --git a/src/templates/backend/modules/user/users.model.ts b/src/templates/backend/modules/user/users.model.ts new file mode 100644 index 00000000..7a27ebc5 --- /dev/null +++ b/src/templates/backend/modules/user/users.model.ts @@ -0,0 +1,126 @@ +export function getUsersModel() { + return `import { relations } from "drizzle-orm"; +import { + index, + integer, + boolean as pgBoolean, + pgTable, + text, + varchar, +} from "drizzle-orm/pg-core"; +import { + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-typebox"; +import { t } from "elysia"; +import { createdAt, id, updatedAt } from "../../libs/schemaHelper"; +import { PageQuerySchema } from "../../libs/common-schemas"; + +/** + * 1. Drizzle table definition + * User table - stores basic user information + */ +export const usersTable = pgTable( + "users", + { + id, + username: varchar("username", { length: 50 }).notNull().unique(), + password: varchar("password", { length: 255 }).notNull(), + email: varchar("email", { length: 100 }).unique().notNull(), + role: varchar("role", { length: 50 }).notNull().default("user"), + nickname: varchar("nickname", { length: 50 }).notNull(), + avatar: varchar("avatar", { length: 255 }), + isActive: pgBoolean("isActive").notNull().default(true), + createdAt, + updatedAt, + }, + (users) => [index("user_id_idx").on(users.id)], +); + +/** + * Token table definition + */ +export const tokenTable = pgTable( + "tokens", + { + id, + ownerId: integer("owner_id") + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + accessToken: text("access_token").notNull(), + refreshToken: text("refresh_token").notNull(), + createdAt, + }, + (token) => [index("token_id_idx").on(token.id)], +); + +/** + * Relationship definition + */ +export const tokenRelations = relations(tokenTable, ({ one }) => ({ + owner: one(usersTable, { + fields: [tokenTable.ownerId], + references: [usersTable.id], + }), +})); + +/** + * User model namespace + * Contains all user-related schemas, type definitions and business logic + */ +export namespace UsersModel { + // User status enum + export enum UserStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING = "pending", + SUSPENDED = "suspended", + } + + // Basic TypeBox schemas (generated from Drizzle tables) + export const Insert = createInsertSchema(usersTable, {}); + export const Update = createUpdateSchema(usersTable); + export const Select = createSelectSchema(usersTable); + + // Business DTO schemas + export const Create = t.Omit(Insert, ["id", "createdAt", "updatedAt"]); + export const Patch = t.Omit(Update, ["id", "createdAt", "updatedAt", "password"]); + + // Query schemas + export const ListQuery = t.Composite([ + PageQuerySchema, + t.Object({ + username: t.String({ optional: true }), + email: t.String({ optional: true }), + isActive: t.Boolean({ optional: true }), + }), + ]); + + // Operation schemas + export const Login = t.Object({ + username: t.String({ minLength: 1 }), + password: t.String({ minLength: 1 }), + }); + + export const ChangePassword = t.Object({ + oldPassword: t.String({ minLength: 1 }), + newPassword: t.String({ minLength: 6 }), + }); + + // Frontend needed type (VO - View Object) + // WARNING: Always exclude password field when querying users for this VO + export const ViewObject = t.Object({ + id: t.Integer(), + username: t.String(), + email: t.String(), + role: t.String(), + nickname: t.String(), + avatar: t.String({ optional: true }), + isActive: t.Boolean(), + createdAt: t.Date(), + updatedAt: t.Date(), + });} + +`; +} diff --git a/src/templates/backend/modules/user/users.service.ts b/src/templates/backend/modules/user/users.service.ts new file mode 100644 index 00000000..63983820 --- /dev/null +++ b/src/templates/backend/modules/user/users.service.ts @@ -0,0 +1,8 @@ +export function getUsersService() { + + return `export abstract class UsersService { + async getById(id: number) { + throw new Error('getById not implemented'); + } +}`; +} diff --git a/src/templates/backend/plugins/err/base.ts b/src/templates/backend/plugins/err/base.ts new file mode 100644 index 00000000..62a29e7e --- /dev/null +++ b/src/templates/backend/plugins/err/base.ts @@ -0,0 +1,17 @@ +export function getErrorBase() { + return `// src/errors/base.ts +export class CustomError extends Error { + status: number; + originalError?: unknown; + + constructor(message: string, status: number = 500, originalError?: unknown) { + super(message); + this.status = status; + this.originalError = originalError; + + // Fix TypeScript prototype chain issue. + Object.setPrototypeOf(this, new.target.prototype); + } +} +`; +} \ No newline at end of file diff --git a/src/templates/backend/plugins/err/database-error-mapper.ts b/src/templates/backend/plugins/err/database-error-mapper.ts new file mode 100644 index 00000000..ebc4fe47 --- /dev/null +++ b/src/templates/backend/plugins/err/database-error-mapper.ts @@ -0,0 +1,64 @@ +export function getDatabaseErrorMapper() { + return `// src/errors/database-error-mapper.ts + +import { CustomError } from "./base"; +import { isDatabaseError } from "./guards"; +import { + DatabaseError, + DuplicateError, + ServiceUnavailableError, + ValidationError, +} from "./http-error"; + +/** + * Maps low-level database errors (like those thrown by Drizzle/PostgreSQL) to semantic custom errors + */ +export function mapDatabaseError( + error: any, +): DatabaseError | DuplicateError | ValidationError | ServiceUnavailableError { + const code = String(error?.code); + const detail = error?.detail || error?.message || "Database operation failed"; + + switch (code) { + case "23505": // unique_violation + return new DuplicateError(detail, error); + case "23503": // foreign_key_violation + return new ValidationError("Related data does not exist, please check data integrity", error); + case "23502": // not_null_violation + return new ValidationError("Required field cannot be empty", error); + case "23514": // check_violation + return new ValidationError("Data format is incorrect", error); + case "08006": // connection_failure + return new ServiceUnavailableError("Database connection failed, please try again later", error); + case "28P01": // invalid_password + return new DatabaseError("Database authentication failed", error); + case "40P01": // deadlock_detected + return new DatabaseError("Database deadlock, please retry", error); + case "57014": // query_canceled + return new DatabaseError("Database operation timed out", error); + default: + return new DatabaseError(detail, error); + } +} + +/** + * Convenience function for handling database errors + * If the error is already a custom error, throw it directly + * Otherwise, convert it using mapDatabaseError and throw + */ +export function handleDatabaseError(error: any): never { + // If already a custom error, throw it directly + if (error instanceof CustomError) { + throw error; + } + + // If it's a database error, map it + if (isDatabaseError(error)) { + throw mapDatabaseError(error); + } + + // Other unknown errors, wrap as DatabaseError + throw new DatabaseError("Database operation failed", error); +} +`; +} \ No newline at end of file diff --git a/src/templates/backend/plugins/err/err.plugin.ts b/src/templates/backend/plugins/err/err.plugin.ts new file mode 100644 index 00000000..53066373 --- /dev/null +++ b/src/templates/backend/plugins/err/err.plugin.ts @@ -0,0 +1,51 @@ +export function getErrorPlugin() { + return `// src/plugins/errorHandler.ts +import { Elysia } from "elysia"; +import * as HttpError from "./http-error"; +import * as Errors from "./index"; +export const errorHandler = new Elysia() + .error(HttpError) // Register all custom error classes + .onError(({ code, error, path }) => { + // 1. Registered custom errors (like ValidationError) + if (error instanceof Errors.CustomError) { + const err = error as Errors.CustomError; + if (process.env.NODE_ENV === "development") { + console.log( + \`[AppError \${err.status}] \${path}:\`, + err.message, + err.originalError, + ); + } + + return Response.json( + { + status: err.status, + message: err.message, + data: null, + // ⚠️ Don't expose originalError in production + // ...(process.env.NODE_ENV === 'development' && { debug: err.originalError }), + }, + { status: err.status }, + ); + } + + // 2. Database errors (unwrapped original errors) + if (Errors.isDatabaseError(error)) { + const mapped = Errors.mapDatabaseError(error); + console.log(\`[DBError \${error.code}] \${path}:\`, error); + return Response.json( + { status: mapped.status, message: mapped.message, data: null }, + { status: mapped.status }, + ); + } + + // 3. Other unknown errors (500) + console.log(\`[InternalServerError] \${path}:\`, error); + return Response.json( + { status: 500, message: "Internal server error", data: null }, + { status: 500 }, + ); + }) + .as("global"); +`; +} \ No newline at end of file diff --git a/src/templates/backend/plugins/err/guards.ts b/src/templates/backend/plugins/err/guards.ts new file mode 100644 index 00000000..ef025a0c --- /dev/null +++ b/src/templates/backend/plugins/err/guards.ts @@ -0,0 +1,26 @@ +export function getErrorGuards() { + return `// src/errors/guards.ts +import { CustomError } from "./base"; + +/** + * Check if it's a PostgreSQL/Drizzle style database error + */ +export function isDatabaseError( + error: unknown, +): error is { code: string; detail?: string } { + return ( + error != null && + typeof error === "object" && + "code" in error && + typeof (error as any).code === "string" + ); +} + +/** + * Check if it's a custom error (optional, usually using code in Errors is simpler) + */ +// export function isCustomError(error: unknown): error is CustomError { +// return error instanceof CustomError; +// } +`; +} \ No newline at end of file diff --git a/src/templates/backend/plugins/err/http-error.ts b/src/templates/backend/plugins/err/http-error.ts new file mode 100644 index 00000000..8a8e43d5 --- /dev/null +++ b/src/templates/backend/plugins/err/http-error.ts @@ -0,0 +1,58 @@ +export function getHttpErrors() { + return `// src/errors/http-errors.ts +import { CustomError } from "./base"; + +export class ValidationError extends CustomError { + constructor(message = "Data validation failed", originalError?: unknown) { + super(message, 400, originalError); + } +} + +export class NotFoundError extends CustomError { + constructor(message = "Record not found", originalError?: unknown) { + super(message, 404, originalError); + } +} + +export class AuthenticationError extends CustomError { + constructor(message = "Authentication failed", originalError?: unknown) { + super(message, 401, originalError); + } +} + +export class AuthorizationError extends CustomError { + constructor(message = "Insufficient permissions", originalError?: unknown) { + super(message, 403, originalError); + } +} + +export class DuplicateError extends CustomError { + constructor(message = "Data already exists", originalError?: unknown) { + super(message, 409, originalError); + } +} + +export class BusinessError extends CustomError { + constructor(message = "Business logic error", originalError?: unknown) { + super(message, 400, originalError); + } +} + +export class ServiceUnavailableError extends CustomError { + constructor(message = "Service temporarily unavailable", originalError?: unknown) { + super(message, 503, originalError); + } +} + +export class InternalServerError extends CustomError { + constructor(message = "Internal server error", originalError?: unknown) { + super(message, 500, originalError); + } +} + +export class DatabaseError extends CustomError { + constructor(message: string = "Database operation failed", originalError?: unknown) { + super(message, 500, originalError); + } +}`; +} \ No newline at end of file diff --git a/src/templates/backend/plugins/err/index.ts b/src/templates/backend/plugins/err/index.ts new file mode 100644 index 00000000..92973520 --- /dev/null +++ b/src/templates/backend/plugins/err/index.ts @@ -0,0 +1,19 @@ +export function getErrorIndex() { + return `// src/errors/index.ts +export { CustomError } from "./base"; +// Utility functions +export { handleDatabaseError, mapDatabaseError } from "./database-error-mapper"; +export { isDatabaseError } from "./guards"; +// Export all specific error classes (for Elysia.error()) +export { + AuthenticationError, + AuthorizationError, + BusinessError, + DatabaseError, + DuplicateError, + InternalServerError, + NotFoundError, + ServiceUnavailableError, + ValidationError, +} from "./http-error";`; +} diff --git a/src/templates/backend/plugins/logger.ts b/src/templates/backend/plugins/logger.ts new file mode 100644 index 00000000..4b2a80c0 --- /dev/null +++ b/src/templates/backend/plugins/logger.ts @@ -0,0 +1,15 @@ +export function getLoggerPlugin() { + return `// WARNING: This logger logs request bodies. Sanitize sensitive fields in production. +import { Elysia } from "elysia"; + +export const logPlugin = new Elysia() + .onTransform(function log({ body, params, path, request: { method } }) { + // TODO: Add sanitization for sensitive fields (passwords, tokens, PII) + console.log(\`\${method} \${path}\`, { + body, + params, + }); + }) + .as("global"); +`; +} \ No newline at end of file diff --git a/src/templates/backend/utils/Res.ts b/src/templates/backend/utils/Res.ts new file mode 100644 index 00000000..3ebe8df7 --- /dev/null +++ b/src/templates/backend/utils/Res.ts @@ -0,0 +1,104 @@ +export function getRes() { + return `// Elysia + Drizzle Unified Response Format Utility + +import { t } from "elysia"; + +/** + * Frontend response type definition + * Errors also use this type + */ +export type CommonRes = { + code: number; + message: string; + data: T; +}; + +/** + * Success response function + * Errors also use this response function + * @param data Response data + * @param code Response code + * @param message Response message + * @returns Unified response object + */ +export function commonRes( + data: T, + code = 200, + message = "Success", +): CommonRes { + return { + code, + message, + data, + }; +} + +/** + * Pagination query parameters Elysia type definition + */ +export const PaginationQuery = t.Object({ + page: t.Optional(t.Number({ minimum: 1 })), + limit: t.Optional(t.Number({ minimum: 1, maximum: 100 })), +}); + +export type PaginationQueryType = typeof PaginationQuery.static; +/** + * Pagination metadata Elysia type definition + */ +export const PageMeta = t.Object({ + total: t.Number(), + page: t.Number(), + limit: t.Number(), + totalPages: t.Number(), +}); +export type PageMeta = typeof PageMeta.static; +export interface PageData { + items: T[]; // Current page data + meta: PageMeta; +} + +/** + * Frontend pagination response type definition + */ +export type PageRes = { + code: number; + message: string; + data: PageData; +}; +// ==================== Response Functions ==================== + +/** + * Create paginated response following project standards + * Reuses pageRes function + * @param data Data array + * @param total Total count + * @param page Current page number + * @param limit Page size + * @param message Response message + * @returns Paginated response following project standards + */ +export function pageRes( + data: T[], + total: number, + page = 1, + limit = 10, + message = "Data retrieved successfully", +) { + if (limit <= 0) { + throw new Error("limit must be greater than 0"); + } + return commonRes( + { + items: data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }, + 200, + message, + ); +}`; +} diff --git a/src/templates/db.ts b/src/templates/db.ts index 66678f01..233cbb58 100644 --- a/src/templates/db.ts +++ b/src/templates/db.ts @@ -112,9 +112,7 @@ export function getDBIndex({ orm, driver, packageManager }: Preferences) { export function getDrizzleConfig({ database }: Preferences) { return [ `import type { Config } from "drizzle-kit"`, - `import env from "env-var"`, - "", - 'const DATABASE_URL = env.get("DATABASE_URL").required().asString()', + `import { config } from "./src/configs/config"`, "", "export default {", ` schema: "./src/db/schema.ts",`, @@ -122,7 +120,7 @@ export function getDrizzleConfig({ database }: Preferences) { ` dialect: "${database.toLowerCase()}",`, ` casing: "snake_case",`, " dbCredentials: {", - " url: DATABASE_URL", + " url: config.DATABASE_URL", " }", "} satisfies Config", ].join("\n"); diff --git a/src/templates/elysia.ts b/src/templates/elysia.ts index 4ee181a7..f2dda2e7 100644 --- a/src/templates/elysia.ts +++ b/src/templates/elysia.ts @@ -1,8 +1,6 @@ import type { Preferences } from "../utils"; export function getElysiaIndex({ - orm, - driver, plugins, telegramRelated, isMonorepo, diff --git a/src/templates/env.ts b/src/templates/env.ts index 6754d48b..cc690170 100644 --- a/src/templates/env.ts +++ b/src/templates/env.ts @@ -133,13 +133,13 @@ export function getConfigFile({ return dedent /* ts */` import env from "env-var"; - + export const config = { NODE_ENV: env .get("NODE_ENV") .default("development") .asEnum(["production", "test", "development"]), - + ${envs.join(",\n")} }`; diff --git a/src/templates/index.ts b/src/templates/index.ts index 91a8bab0..2e0936a2 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -1,14 +1,29 @@ import dedent from "ts-dedent"; import type { PreferencesType } from "../utils"; -export * from "./package.json"; -export * from "./elysia"; -export * from "./install"; +export * from "./backend/libs"; +// Backend modules, libs, plugins, utils +export * from "./backend/modules/user"; +export * from "./backend/plugins/err"; +export * from "./backend/plugins/logger"; +export * from "./backend/utils/Res"; +export * from "./backend-types"; +export * from "./bot"; export * from "./db"; -export * from "./tsconfig.json"; +export * from "./elysia"; export * from "./env"; -export * from "./readme.md"; export * from "./eslint"; +export * from "./install"; +export * from "./package/backend-package.json"; +export * from "./package/package.json"; +export * from "./readme.md"; +export * from "./services/auth"; +export * from "./tsconfig"; +export * from "./tsconfig.json"; +export * from "./turbo.json"; +export * from "./types"; +export * from "./vue"; +export * from "./vue/eden-client"; const dbExportedMap = { Prisma: "prisma", @@ -37,6 +52,8 @@ export function getIndex({ const startUpTasks: string[] = []; imports.push(`import { app } from "./server.ts"`); + imports.push(`import packageJson from "../package.json"`); + imports.push(`import { startupHealthCheck } from "./libs/healthyCheck";`); gracefulShutdownTasks.push("await app.stop()"); // TODO: GramIO integration @@ -54,10 +71,6 @@ export function getIndex({ console.log("🗄️ Database was connected!")`); } - startUpTasks.push(/*ts*/ ` - app.listen(config.PORT, () => console.log(\`🦊 Server started at \${app.server?.url.origin}\`)) - `); - if (telegramRelated && !isMonorepo) { imports.push(`import { bot } from "./bot.ts"`); startUpTasks.push(dedent /* tss */` @@ -73,7 +86,7 @@ export function getIndex({ return dedent /* sts */` ${imports.join("\n")} const signals = ["SIGINT", "SIGTERM"]; - + for (const signal of signals) { process.on(signal, async () => { console.log(\`Received \${signal}. Initiating graceful shutdown...\`); @@ -81,7 +94,7 @@ export function getIndex({ process.exit(0); }) } - + process.on("uncaughtException", (error) => { console.error(error); }) @@ -89,6 +102,26 @@ export function getIndex({ process.on("unhandledRejection", (error) => { console.error(error); }) - + + // Start HTTP server + const elysia = app.listen(config.PORT, () => { + console.log(\`🦊 Server started at \${app.server?.url.origin}\`); + }); + + // Health check (optional, async non-blocking) + startupHealthCheck().catch(console.error); + + // Startup logs + (() => { + if (config.NODE_ENV === "production") { + console.log(\`Current environment: Production\${config.APP_URL ? ': ' + config.APP_URL : ''}\`); + console.log("Version:", packageJson.version); + } else { + console.log("Current environment: Development"); + console.log("Version:", packageJson.version); + } + })(); + + export type EndApp = typeof elysia; ${startUpTasks.join("\n")}`; } diff --git a/src/templates/package.json.ts b/src/templates/package.json.ts deleted file mode 100644 index ab95ccfb..00000000 --- a/src/templates/package.json.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { dependencies } from "../deps"; -import { type Preferences, pmExecuteMap, pmRunMap } from "../utils"; - -export function getPackageJson({ - dir, - projectName, - linter, - packageManager, - orm, - driver, - others, - plugins, - isMonorepo, - locks, - redis, - mockWithPGLite, - telegramRelated, - s3Client, -}: Preferences) { - const sample = { - name: projectName, - type: "module", - scripts: { - dev: - packageManager === "bun" - ? "bun --watch src/index.ts" - : `${pmExecuteMap[packageManager]} tsx watch --env-file .env src/index.ts`, - start: - packageManager === "bun" - ? "NODE_ENV=production bun run ./src/index.ts" - : `NODE_ENV=production ${pmExecuteMap[packageManager]} tsx --env-file=.env --env-file=.env.production src/index.ts`, - } as Record, - dependencies: { - elysia: dependencies.elysia, - "env-var": dependencies["env-var"], - } as Record, - devDependencies: { - typescript: dependencies.typescript, - } as Record, - }; - - // if (packageManager === "bun") - sample.devDependencies["@types/bun"] = dependencies["@types/bun"]; - - if (linter === "Biome") { - sample.scripts.lint = `${pmExecuteMap[packageManager]} @biomejs/biome check src`; - sample.scripts["lint:fix"] = `${pmRunMap[packageManager]} lint --write`; - sample.devDependencies["@biomejs/biome"] = dependencies["@biomejs/biome"]; - } - if (linter === "ESLint") { - // \"src/**/*.ts\" - sample.scripts.lint = `${pmExecuteMap[packageManager]} eslint`; - // \"src/**/*.ts\" - sample.scripts["lint:fix"] = `${pmExecuteMap[packageManager]} eslint --fix`; - sample.devDependencies.eslint = dependencies.eslint; - sample.devDependencies["@antfu/eslint-config"] = - dependencies["@antfu/eslint-config"]; - if (orm === "Drizzle") - sample.devDependencies["eslint-plugin-drizzle"] = - dependencies["eslint-plugin-drizzle"]; - } - - if (orm === "Prisma") { - sample.devDependencies.prisma = dependencies.prisma; - sample.dependencies["@prisma/client"] = dependencies["@prisma/client"]; - } - if (orm === "Drizzle") { - sample.dependencies["drizzle-orm"] = dependencies["drizzle-orm"]; - sample.devDependencies["drizzle-kit"] = dependencies["drizzle-kit"]; - if (driver === "node-postgres") { - sample.dependencies.pg = dependencies.pg; - sample.devDependencies["@types/pg"] = dependencies["@types/pg"]; - } - if (driver === "Postgres.JS") { - sample.dependencies.postgres = dependencies.postgres; - } - if (driver === "MySQL 2") { - sample.dependencies.mysql2 = dependencies.mysql2; - } - sample.scripts.generate = `${pmExecuteMap[packageManager]} drizzle-kit generate`; - sample.scripts.push = `${pmExecuteMap[packageManager]} drizzle-kit push`; - sample.scripts.migrate = `${pmExecuteMap[packageManager]} drizzle-kit migrate`; - sample.scripts.studio = `${pmExecuteMap[packageManager]} drizzle-kit studio`; - } - - if (others.includes("Husky")) { - sample.devDependencies.husky = dependencies.husky; - sample.scripts.prepare = "husky"; - } - - if (plugins.includes("Bearer")) - sample.dependencies["@elysiajs/bearer"] = dependencies["@elysiajs/bearer"]; - if (plugins.includes("CORS")) - sample.dependencies["@elysiajs/cors"] = dependencies["@elysiajs/cors"]; - if (plugins.includes("HTML/JSX")) { - sample.dependencies["@elysiajs/html"] = dependencies["@elysiajs/html"]; - sample.dependencies["@kitajs/ts-html-plugin"] = - dependencies["@kitajs/ts-html-plugin"]; - } - if (plugins.includes("JWT")) - sample.dependencies["@elysiajs/jwt"] = dependencies["@elysiajs/jwt"]; - if (plugins.includes("Server Timing")) - sample.dependencies["@elysiajs/server-timing"] = - dependencies["@elysiajs/server-timing"]; - if (plugins.includes("Static")) - sample.dependencies["@elysiajs/static"] = dependencies["@elysiajs/static"]; - if (plugins.includes("Swagger")) - sample.dependencies["@elysiajs/swagger"] = - dependencies["@elysiajs/swagger"]; - if (plugins.includes("Autoload")) - sample.dependencies["elysia-autoload"] = dependencies["elysia-autoload"]; - if (plugins.includes("Logger")) - sample.dependencies["@bogeychan/elysia-logger"] = - dependencies["@bogeychan/elysia-logger"]; - - if (plugins.includes("Oauth 2.0")) { - sample.dependencies.arctic = dependencies.arctic; - sample.dependencies["elysia-oauth2"] = dependencies["elysia-oauth2"]; - } - - if (redis) { - sample.dependencies.ioredis = dependencies.ioredis; - if (mockWithPGLite) - sample.devDependencies["ioredis-mock"] = dependencies["ioredis-mock"]; - } - - if (others.includes("Jobify")) { - sample.dependencies.jobify = dependencies.jobify; - } - - if (others.includes("Posthog")) { - sample.dependencies["posthog-node"] = dependencies["posthog-node"]; - } - - if (locks) { - sample.dependencies["@verrou/core"] = dependencies["@verrou/core"]; - } - - if (isMonorepo) - sample.dependencies["@gramio/init-data"] = - dependencies["@gramio/init-data"]; - - if (others.includes("S3") && s3Client === "@aws-sdk/client-s3") { - sample.dependencies["@aws-sdk/client-s3"] = - dependencies["@aws-sdk/client-s3"]; - } - - if (mockWithPGLite) { - sample.devDependencies["@electric-sql/pglite"] = - dependencies["@electric-sql/pglite"]; - sample.devDependencies["@elysiajs/eden"] = dependencies["@elysiajs/eden"]; - } - - if (telegramRelated && !isMonorepo) { - sample.dependencies.gramio = dependencies.gramio; - sample.dependencies["@gramio/init-data"] = - dependencies["@gramio/init-data"]; - } - - return JSON.stringify(sample, null, 2); -} diff --git a/src/templates/package/backend-package.json.ts b/src/templates/package/backend-package.json.ts new file mode 100644 index 00000000..33175405 --- /dev/null +++ b/src/templates/package/backend-package.json.ts @@ -0,0 +1,68 @@ +import { dependencies } from "../../deps"; +import { ShareDeps } from "../../deps.share"; +import { getSingleAppDepsAndScripts, type Preferences } from "../../utils"; + +export function getBackendPackageJson({ + projectName, + packageManager, + orm, + driver, + others, + plugins, + redis, + mockWithPGLite, + telegramRelated, + s3Client, +}: Preferences) { + // Get plugin dependencies with proper type classification + const { + dependencies: pluginDeps, + devDependencies: pluginDevDeps, + scripts: pluginScripts, + } = getSingleAppDepsAndScripts(plugins, { + linter: "Biome", // Backend always uses Biome in monorepo + orm, + driver, + others, + redis, + mockWithPGLite, + telegramRelated, + s3Client, + packageManager, + }); + + const sample = { + name: `@${projectName}/backend`, + version: "0.0.1", + module: "index.ts", + type: "module", + private: true, + scripts: { + build: + "rimraf dist && NODE_ENV=production bun build --target bun --minify-whitespace --minify-syntax --outfile ./dist/index.js src/index.ts", + dev: "bun --watch src/index.ts", + clean: "rimraf dist node_modules", + "type-check": "bun --bun tsc --noEmit", + lint: "biome check", + "lint:fix": "biome check --write", + start: "bun --env-file=.env --env-file=.env.production ./dist/index.js", + ...pluginScripts, // Merge plugin-specific scripts + } as Record, + dependencies: { + elysia: ShareDeps.elysia, + "env-var": dependencies["env-var"], + ...pluginDeps, // Merge runtime dependencies from plugins + } as Record, + devDependencies: { + "@repo/tsconfig": "workspace:*", + "@types/bun": ShareDeps["@types/bun"], + typescript: ShareDeps.typescript, + rimraf: ShareDeps.rimraf, + "@biomejs/biome": dependencies["@biomejs/biome"], + "@elysiajs/eden": dependencies["@elysiajs/eden"], // Eden for frontend type support + ...pluginDevDeps, // Merge dev dependencies from plugins + } as Record, + }; + + return JSON.stringify(sample, null, 2); +} diff --git a/src/templates/package/package.json.ts b/src/templates/package/package.json.ts new file mode 100644 index 00000000..05397829 --- /dev/null +++ b/src/templates/package/package.json.ts @@ -0,0 +1,108 @@ +import { dependencies } from "../../deps"; +import { + getMonorepoRootDepsAndScripts, + getSingleAppDepsAndScripts, + type Preferences, + pmExecuteMap, +} from "../../utils"; + +export function getPackageJson({ + projectName, + linter, + packageManager, + orm, + driver, + others, + plugins, + isMonorepo, + redis, + mockWithPGLite, + telegramRelated, + s3Client, +}: Preferences) { + const deps: Record = {}; + const devDeps: Record = {}; + const scripts: Record = {}; + if (isMonorepo) { + const { devDependencies: extraDeps, scripts: extraScripts } = + getMonorepoRootDepsAndScripts({ + linter, + orm, + others, + packageManager, + mockWithPGLite, + }); + + const rootPackage = { + name: projectName, + private: true, + type: "module", + packageManager: `${packageManager}@latest`, + workspaces: ["packages/*", "apps/*"], + scripts: { + build: "turbo run build", + dev: "turbo run dev", + start: "turbo run start", + "check-types": "turbo run check-types", + clean: "turbo run clean", + preclean: "rimraf node_modules dist bun.lockb .turbo", + ...extraScripts, // Merge dynamic scripts! + }, + engines: { bun: ">=1.3.0" }, + dependencies: {}, // monorepo root generally has no runtime dependencies! + devDependencies: { + turbo: dependencies.turbo, + rimraf: dependencies.rimraf, + "@types/bun": dependencies["@types/bun"], + ...extraDeps, // Merge dynamic dependencies! + }, + }; + + return JSON.stringify(rootPackage, null, 2); + } + + // Single application configuration + const { + dependencies: pluginDeps, + devDependencies: pluginDevDeps, + scripts: pluginScripts, + } = getSingleAppDepsAndScripts(plugins, { + linter, + orm, + driver, + others, + redis, + mockWithPGLite, + telegramRelated, + s3Client, + packageManager, + }); + + const sample = { + name: projectName, + type: "module", + scripts: { + dev: + packageManager === "bun" + ? "bun --watch src/index.ts" + : `${pmExecuteMap[packageManager]} tsx watch --env-file .env src/index.ts`, + start: + packageManager === "bun" + ? "NODE_ENV=production bun run ./src/index.ts" + : `NODE_ENV=production ${pmExecuteMap[packageManager]} tsx --env-file=.env --env-file=.env.production src/index.ts`, + ...pluginScripts, // Merge plugin-specific scripts + } as Record, + dependencies: { + elysia: dependencies.elysia, + "env-var": dependencies["env-var"], + ...pluginDeps, // Merge runtime dependencies from plugins + } as Record, + devDependencies: { + typescript: dependencies.typescript, + "@types/bun": dependencies["@types/bun"], + ...pluginDevDeps, // Merge dev dependencies from plugins + } as Record, + }; + + return JSON.stringify(sample, null, 2); +} diff --git a/src/templates/package/typescript-config-package.json.ts b/src/templates/package/typescript-config-package.json.ts new file mode 100644 index 00000000..701cae02 --- /dev/null +++ b/src/templates/package/typescript-config-package.json.ts @@ -0,0 +1,24 @@ +export function getTypeScriptConfigPackageJson(projectName: string) { + return JSON.stringify( + { + name: `@${projectName}/tsconfig`, + version: "0.0.0", + private: true, + type: "module", + main: "index.js", + types: "index.d.ts", + files: ["*.json", "*.js", "*.d.ts"], + exports: { + "./base": "./base.json", + "./backend": "./backend.json", + "./frontend": "./frontend.json", + "./node": "./node.json", + "./next": "./next.json", + "./react": "./react.json", + "./vue": "./vue.json", + }, + }, + null, + 2, + ); +} diff --git a/src/templates/readme.md.ts b/src/templates/readme.md.ts index 600752e1..0fd421b0 100644 --- a/src/templates/readme.md.ts +++ b/src/templates/readme.md.ts @@ -31,6 +31,7 @@ const links: Record< "Oauth 2.0": "[Oauth 2.0](https://github.com/kravetsone/elysia-oauth2)", Logger: "[Logger](https://github.com/bogeychan/elysia-logger)", "HTML/JSX": "[HTML/JSX](https://elysiajs.com/plugins/html.html)", + openAPI: "[OpenAPI](https://elysiajs.com/plugins/openapi.html)", Static: "[Static](https://elysiajs.com/plugins/static.html)", Bearer: "[Bearer](https://elysiajs.com/plugins/bearer.html)", "Server Timing": diff --git a/src/templates/tests.ts b/src/templates/tests.ts index 7b122358..bada21f5 100644 --- a/src/templates/tests.ts +++ b/src/templates/tests.ts @@ -1,7 +1,6 @@ import dedent from "ts-dedent"; import type { Preferences } from "../utils"; -import { driverNames } from "./db"; -import { driverNamesToDrizzle } from "./db"; +import { driverNames, driverNamesToDrizzle } from "./db"; export function getPreloadFile({ redis, driver }: Preferences) { const imports: string[] = []; @@ -39,14 +38,14 @@ console.timeEnd("PGLite init"); `; } -export function getTestsAPIFile({ redis, driver }: Preferences) { +export function getTestsAPIFile() { return dedent /* ts */`import { treaty } from "@elysiajs/eden"; import { app } from "../src/server.ts"; - + export const api = treaty(app);`; } -export function getTestsIndex({ redis, driver }: Preferences) { +export function getTestsIndex() { return dedent /* ts */`import { describe, it, expect } from "bun:test"; import { api } from "../api.ts"; diff --git a/src/templates/tsconfig/app.ts b/src/templates/tsconfig/app.ts new file mode 100644 index 00000000..4afade80 --- /dev/null +++ b/src/templates/tsconfig/app.ts @@ -0,0 +1,28 @@ +export function getAppTSConfig() { + return JSON.stringify( + { + extends: "@repo/tsconfig/vue.json", + compilerOptions: { + baseUrl: ".", + paths: { + "@/*": ["./src/*"], + "@backend/*": ["../backend/src/*"], + }, + }, + include: [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.vue", + "../backend/src/index.ts", + "../backend/src/types/*.ts", + ], + references: [ + { + path: "../backend", + }, + ], + }, + null, + 2, + ); +} diff --git a/src/templates/tsconfig/backend.ts b/src/templates/tsconfig/backend.ts new file mode 100644 index 00000000..9adb089a --- /dev/null +++ b/src/templates/tsconfig/backend.ts @@ -0,0 +1,28 @@ +export function getBackendTSConfig(plugins: string[] = []) { + return JSON.stringify( + { + $schema: "https://json.schemastore.org/tsconfig", + extends: "@repo/tsconfig/elysia.json", + compilerOptions: { + incremental: true, + tsBuildInfoFile: "./dist/.tsbuildinfo", + baseUrl: ".", + paths: { + "@backend/*": ["src/*"], + }, + ...(plugins.includes("HTML/JSX") + ? { + jsx: "react", + jsxFactory: "Html.createElement", + jsxFragmentFactory: "Html.Fragment", + plugins: [{ name: "@kitajs/ts-html-plugin" }], + } + : {}), + }, + include: ["src"], + exclude: ["node_modules", "dist"], + }, + null, + 2, + ); +} diff --git a/src/templates/tsconfig/base.ts b/src/templates/tsconfig/base.ts new file mode 100644 index 00000000..2a851cd1 --- /dev/null +++ b/src/templates/tsconfig/base.ts @@ -0,0 +1,27 @@ +export function getTSConfigBase() { + return JSON.stringify( + { + $schema: "https://json.schemastore.org/tsconfig", + compilerOptions: { + declaration: true, + declarationMap: true, + esModuleInterop: true, + incremental: true, + isolatedModules: true, + lib: ["ES2022", "DOM", "DOM.Iterable"], + target: "ES2022", + module: "ESNext", + moduleResolution: "bundler", + moduleDetection: "force", + noUncheckedIndexedAccess: true, + resolveJsonModule: true, + skipLibCheck: true, + strict: true, + allowJs: true, + composite: true, + }, + }, + null, + 2, + ); +} diff --git a/src/templates/tsconfig/elysia.ts b/src/templates/tsconfig/elysia.ts new file mode 100644 index 00000000..dda420ac --- /dev/null +++ b/src/templates/tsconfig/elysia.ts @@ -0,0 +1,16 @@ +export function getElysiaTSConfig() { + return JSON.stringify( + { + $schema: "https://json.schemastore.org/tsconfig", + extends: "./base.json", + compilerOptions: { + noUncheckedIndexedAccess: true, + module: "ESNext", + moduleResolution: "bundler", + target: "ES2022", + }, + }, + null, + 2, + ); +} diff --git a/src/templates/tsconfig/frontend.ts b/src/templates/tsconfig/frontend.ts new file mode 100644 index 00000000..457da9dc --- /dev/null +++ b/src/templates/tsconfig/frontend.ts @@ -0,0 +1,46 @@ +export function getFrontendTSConfig(framework: "Vue" | "React") { + const configs = { + Vue: { + files: [], + references: [ + { + path: "./tsconfig.app.json", + }, + { + path: "./tsconfig.node.json", + }, + ], + }, + React: { + $schema: "https://json.schemastore.org/tsconfig", + extends: ["./base.json"], + compilerOptions: { + forceConsistentCasingInFileNames: true, + inlineSources: false, + noUnusedLocals: false, + noUnusedParameters: false, + preserveWatchOutput: true, + strictNullChecks: true, + jsx: "react-jsx", + lib: ["ES2022", "DOM", "DOM.Iterable"], + module: "ESNext", + moduleResolution: "bundler", + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + useDefineForClassFields: true, + skipLibCheck: true, + incremental: true, + }, + }, + }; + + return JSON.stringify( + configs[framework as keyof typeof configs] || configs.Vue, + null, + 2, + ); +} diff --git a/src/templates/tsconfig/index.ts b/src/templates/tsconfig/index.ts new file mode 100644 index 00000000..3a985e60 --- /dev/null +++ b/src/templates/tsconfig/index.ts @@ -0,0 +1,6 @@ +export * from "./backend"; +export * from "./base"; +export * from "./elysia"; +export * from "./frontend"; +export * from "./node"; +export * from "./package"; diff --git a/src/templates/tsconfig/node.ts b/src/templates/tsconfig/node.ts new file mode 100644 index 00000000..c071fdb1 --- /dev/null +++ b/src/templates/tsconfig/node.ts @@ -0,0 +1,28 @@ +export function getNodeTSConfig() { + return JSON.stringify( + { + compilerOptions: { + target: "ES2022", + lib: ["ES2022"], + module: "ESNext", + skipLibCheck: true, + /* Bundler mode */ + moduleResolution: "bundler", + allowImportingTsExtensions: true, + verbatimModuleSyntax: true, + moduleDetection: "force", + noEmit: true, + /* Linting */ + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + erasableSyntaxOnly: true, + noFallthroughCasesInSwitch: true, + noUncheckedSideEffectImports: true, + }, + include: ["vite.config.ts"], + }, + null, + 2, + ); +} diff --git a/src/templates/tsconfig/package.ts b/src/templates/tsconfig/package.ts new file mode 100644 index 00000000..f77cdd0b --- /dev/null +++ b/src/templates/tsconfig/package.ts @@ -0,0 +1,18 @@ +// tsconfig package +export function getTypeScriptConfigPackageJson(projectName: string) { + return JSON.stringify( + { + name: `@${projectName}/tsconfig`, + version: "0.0.0", + private: true, + publishConfig: { + access: "public", + }, + devDependencies: { + "@vue/tsconfig": "^0.5.1", + }, + }, + null, + 2, + ); +} diff --git a/src/templates/turbo.json.ts b/src/templates/turbo.json.ts new file mode 100644 index 00000000..4d60d78e --- /dev/null +++ b/src/templates/turbo.json.ts @@ -0,0 +1,43 @@ +export function getTurboJson() { + return `{ + "$schema": "https://turborepo.com/schema.json", + "ui": "tui", + "tasks": { + "build": { + "dependsOn": [ + "^build" + ], + "inputs": [ + "$TURBO_DEFAULT$", + ".env*" + ], + "outputs": [ + "dist/**", + "!.next/cache/**" + ] + }, + "lint": { + "dependsOn": [ + "^lint" + ] + }, + "lint:fix": { + "dependsOn": [ + "^lint:fix" + ] + }, + "check-types": { + "dependsOn": [ + "^check-types" + ] + }, + "clean": { + "cache": false + }, + "dev": { + "persistent": true, + "cache": false + } + } +}`; +} diff --git a/src/templates/types/index.ts b/src/templates/types/index.ts new file mode 100644 index 00000000..861eaca7 --- /dev/null +++ b/src/templates/types/index.ts @@ -0,0 +1,11 @@ +import type { PreferencesType } from "../../utils"; + +// Export all type-related template functions. +// Note: package.json.ts file doesn't exist yet, commenting out for now +// export * from "./package.json"; +export * from "./tsconfig.json"; + +export function getTypesIndex(preferences: PreferencesType) { + return ` +`; +} diff --git a/src/templates/types/tsconfig.json.ts b/src/templates/types/tsconfig.json.ts new file mode 100644 index 00000000..04d8e4b0 --- /dev/null +++ b/src/templates/types/tsconfig.json.ts @@ -0,0 +1,17 @@ +export function getTypesTSConfig() { + const tsconfig = { + extends: "../tsconfig/base.json", + compilerOptions: { + outDir: "./dist", + rootDir: "./", + declaration: true, + declarationMap: true, + composite: true, + incremental: true, + }, + include: ["./**/*.ts"], + exclude: ["node_modules", "dist"], + }; + + return JSON.stringify(tsconfig, null, 2); +} diff --git a/src/templates/vue/App.ts b/src/templates/vue/App.ts new file mode 100644 index 00000000..07f97b04 --- /dev/null +++ b/src/templates/vue/App.ts @@ -0,0 +1,22 @@ +export function getAppVue(projectName: string) { + return ` + + + +`; +} diff --git a/src/templates/vue/Home.ts b/src/templates/vue/Home.ts new file mode 100644 index 00000000..7fcbd41a --- /dev/null +++ b/src/templates/vue/Home.ts @@ -0,0 +1,215 @@ +export function getHomeVue() { + return ` + + + +`; +} diff --git a/src/templates/vue/eden-client.ts b/src/templates/vue/eden-client.ts new file mode 100644 index 00000000..6d4c5c3c --- /dev/null +++ b/src/templates/vue/eden-client.ts @@ -0,0 +1,181 @@ +export function getEdenClient(projectName: string) { + return `import { edenClient } from '@elysiajs/eden'; +import type { ApiResponseRoutes, ApiResponse, PaginatedResponse, User, Partner } from '@${projectName}/backend'; + +// Create type-safe API client +export const api = edenClient(import.meta.env.VITE_API_URL || 'http://localhost:3000'); + +// Convenient API call wrappers +export const apiClient = { + // Health check + async health() { + return await api.health.get(); + }, + + + async getUsers(page = 1, pageSize = 10) { + return await api.api.users.get({ + query: { page, pageSize } + }); + }, + + async getUser(id: string) { + return await api.api.users[':id'].get({ + params: { id } + }); + }, + + async createUser(userData: { email: string; name?: string }) { + return await api.api.users.post({ + body: userData + }); + }, + + async updateUser(id: string, userData: { email?: string; name?: string }) { + return await api.api.users[':id'].put({ + params: { id }, + body: userData + }); + }, + + async deleteUser(id: string) { + return await api.api.users[':id'].delete({ + params: { id } + }); + }, + + + async getPartners(page = 1, pageSize = 10, search?: string) { + return await api.api.partners.get({ + query: { page, pageSize, ...(search && { search }) } + }); + }, + + async getPartner(id: string) { + return await api.api.partners[':id'].get({ + params: { id } + }); + }, + + async createPartner(partnerData: { name: string; email?: string; phone?: string }) { + return await api.api.partners.post({ + body: partnerData + }); + }, + + async updatePartner(id: string, partnerData: { name?: string; email?: string; phone?: string }) { + return await api.api.partners[':id'].put({ + params: { id }, + body: partnerData + }); + }, + + async deletePartner(id: string) { + return await api.api.partners[':id'].delete({ + params: { id } + }); + } +}; + +export class ApiError extends Error { + constructor( + message: string, + public status?: number, + public data?: any + ) { + super(message); + this.name = 'ApiError'; + } +} + +export const handleApiResponse = async ( + apiCall: Promise | any> +): Promise => { + try { + const response = await apiCall; + + // Handle Eden response format + const data = response.data || response; + + if (data && typeof data === 'object' && 'success' in data) { + if (!data.success) { + throw new ApiError( + data.error || 'API request failed', + response.status || 500, + data + ); + } + return data.data as T; + } + + // Handle directly returned data + return data as T; + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + // Handle network errors, etc. + throw new ApiError( + error instanceof Error ? error.message : 'Unknown error', + 0, + error + ); + } +}; + +// Type-safe handling of paginated data +export const handlePaginatedResponse = async ( + apiCall: Promise | any> +): Promise<{ data: T[]; pagination: NonNullable['pagination']> }> => { + const response = await handleApiResponse>(apiCall); + return { + data: response.data || [], + pagination: response.pagination || { + page: 1, + pageSize: 10, + total: 0, + totalPages: 0 + } + }; +}; + +// Specific typed API call functions +export const userApi = { + getUsers: (page = 1, pageSize = 10) => + handlePaginatedResponse(apiClient.getUsers(page, pageSize)), + + getUser: (id: string) => + handleApiResponse(apiClient.getUser(id)), + + createUser: (userData: { email: string; name?: string }) => + handleApiResponse(apiClient.createUser(userData)), + + updateUser: (id: string, userData: { email?: string; name?: string }) => + handleApiResponse(apiClient.updateUser(id, userData)), + + deleteUser: (id: string) => + handleApiResponse(apiClient.deleteUser(id)) +}; + +export const partnerApi = { + getPartners: (page = 1, pageSize = 10, search?: string) => + handlePaginatedResponse(apiClient.getPartners(page, pageSize, search)), + + getPartner: (id: string) => + handleApiResponse(apiClient.getPartner(id)), + + createPartner: (partnerData: { name: string; email?: string; phone?: string }) => + handleApiResponse(apiClient.createPartner(partnerData)), + + updatePartner: (id: string, partnerData: { name?: string; email?: string; phone?: string }) => + handleApiResponse(apiClient.updatePartner(id, partnerData)), + + deletePartner: (id: string) => + handleApiResponse(apiClient.deletePartner(id)) +}; + +export const healthApi = { + check: () => handleApiResponse(apiClient.health()) +};`; +} diff --git a/src/templates/vue/env.ts b/src/templates/vue/env.ts new file mode 100644 index 00000000..ef0c8a8f --- /dev/null +++ b/src/templates/vue/env.ts @@ -0,0 +1,18 @@ +export function getEnvDTs() { + return `/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + // More environment variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}`; +} diff --git a/src/templates/vue/frontend-package.json.ts b/src/templates/vue/frontend-package.json.ts new file mode 100644 index 00000000..66d62e54 --- /dev/null +++ b/src/templates/vue/frontend-package.json.ts @@ -0,0 +1,110 @@ +import { dependencies } from "../../deps"; +import { ShareDeps } from "../../deps.share"; + +import type { Preferences } from "../../utils"; + +export function getFrontendPackageJson({ + projectName, + packageManager, + frontend, +}: Preferences) { + const sample = { + name: `@${projectName}/frontend`, + private: true, + version: "0.0.0", + type: "module", + packageManager: `${packageManager}@latest`, + scripts: { + dev: "vite", + build: "rimraf dist && vite build", + preview: "vite preview", + clean: "rimraf node_modules bun.lock dist", + "type-check": "vue-tsc --noEmit", + lint: "biome check", + "lint:fix": "biome check --write", + }, + dependencies: {} as Record, + devDependencies: { + "@types/bun": ShareDeps["@types/bun"], + typescript: ShareDeps.typescript, + rimraf: ShareDeps.rimraf, + vite: dependencies.vite, + } as Record, + bun: { + ignoredBuiltDependencies: ["esbuild"], + }, + browserslist: [ + "Android 4.4", + "iOS >= 9", + "Chrome >= 49", + "Firefox >= 31", + "Safari >= 9.1", + "Edge >= 13", + "Opera >= 36", + ], + }; + + // Add dependencies based on frontend framework + if (frontend === "Vue") { + sample.dependencies.vue = dependencies.vue; + sample.dependencies["@vueuse/core"] = dependencies["@vueuse/core"]; + sample.dependencies["vue-router"] = dependencies["vue-router"]; + sample.dependencies["vue-sonner"] = dependencies["vue-sonner"]; + sample.dependencies["@elysiajs/eden"] = dependencies["@elysiajs/eden"]; + sample.dependencies["@tailwindcss/vite"] = dependencies["@tailwindcss/vite"]; + sample.dependencies.tailwindcss = dependencies.tailwindcss; + + // Add dependency on backend package + sample.dependencies[`@${projectName}/backend`] = `workspace:*`; + + sample.devDependencies["@vitejs/plugin-vue"] = + dependencies["@vitejs/plugin-vue"]; + sample.devDependencies["@vue/tsconfig"] = dependencies["@vue/tsconfig"]; + sample.devDependencies["vue-tsc"] = dependencies["vue-tsc"]; + sample.devDependencies["@egoist/tailwindcss-icons"] = dependencies["@egoist/tailwindcss-icons"]; + sample.devDependencies["unplugin-auto-import"] = dependencies["unplugin-auto-import"]; + sample.devDependencies["unplugin-vue-components"] = dependencies["unplugin-vue-components"]; + sample.devDependencies["@repo/tsconfig"] = "workspace:*"; + } + + if (frontend === "React") { + sample.dependencies.react = dependencies.react; + sample.dependencies["react-dom"] = dependencies["react-dom"]; + sample.dependencies["@tanstack/react-router"] = + dependencies["@tanstack/react-router"]; + sample.dependencies["@tanstack/react-query"] = + dependencies["@tanstack/react-query"]; + sample.dependencies.zustand = dependencies.zustand; + sample.dependencies["@elysiajs/eden"] = dependencies["@elysiajs/eden"]; + + sample.devDependencies["@vitejs/plugin-react"] = + dependencies["@vitejs/plugin-react"]; + sample.devDependencies["@types/react"] = dependencies["@types/react"]; + sample.devDependencies["@types/react-dom"] = + dependencies["@types/react-dom"]; + } + + if (frontend === "Solid") { + sample.dependencies["solid-js"] = dependencies["solid-js"]; + sample.dependencies["@solidjs/router"] = dependencies["@solidjs/router"]; + sample.dependencies.zustand = dependencies.zustand; + sample.dependencies["@elysiajs/eden"] = dependencies["@elysiajs/eden"]; + + sample.devDependencies["vite-plugin-solid"] = + dependencies["vite-plugin-solid"]; + } + + if (frontend === "Svelte") { + sample.dependencies.svelte = dependencies.svelte; + sample.dependencies["@sveltejs/kit"] = dependencies["@sveltejs/kit"]; + sample.dependencies.zustand = dependencies.zustand; + sample.dependencies["@elysiajs/eden"] = dependencies["@elysiajs/eden"]; + + sample.devDependencies["@sveltejs/vite-plugin-svelte"] = + dependencies["@sveltejs/vite-plugin-svelte"]; + sample.devDependencies["@sveltejs/adapter-auto"] = + dependencies["@sveltejs/adapter-auto"]; + } + + return JSON.stringify(sample, null, 2); +} diff --git a/src/templates/vue/handleApi.ts b/src/templates/vue/handleApi.ts new file mode 100644 index 00000000..b6865d3c --- /dev/null +++ b/src/templates/vue/handleApi.ts @@ -0,0 +1,28 @@ +export function getHandleApi() { + return ` +import { useApiWithToast } from "./useApiWithToast"; + +/** + * Example API wrapper - customize the methods below for your API routes + */ +export const useApi = () => { + const api = useApiWithToast(); + return { + // Partners + partners: { + + list: (query: string) => + api.callPaginated(() => + api.client.api.partners.ping.get({ query: { str: query } }) + ), + }, + // Raw toast methods (if custom notifications are needed) + toast: { + error: api.showError, + success: api.showSuccess, + warning: api.showWarning, + }, + }; +}; +`; +} \ No newline at end of file diff --git a/src/templates/vue/index.html.ts b/src/templates/vue/index.html.ts new file mode 100644 index 00000000..49a110f5 --- /dev/null +++ b/src/templates/vue/index.html.ts @@ -0,0 +1,15 @@ +export function getIndexHTML(projectName: string, lang = 'en') { + return ` + + + + + + ${projectName} + + +
+ + +`; +} diff --git a/src/templates/vue/index.ts b/src/templates/vue/index.ts new file mode 100644 index 00000000..263bb3e6 --- /dev/null +++ b/src/templates/vue/index.ts @@ -0,0 +1,14 @@ +export { getAppVue } from "./App"; +export { getEdenClient } from "./eden-client"; +export { getEnvDTs } from "./env"; +export { getFrontendPackageJson } from "./frontend-package.json"; +export { getHomeVue } from "./Home"; +export { getHandleApi } from "./handleApi"; +export { getIndexHTML } from "./index.html"; +export { getMainCSS } from "./main.css"; +export { getRouter } from "./router"; +export { getTSConfigVue } from "./tsconfig-vue.json"; +export { getUseApiWithToast } from "./useApiWithToast"; +export { getUseTreaty } from "./useTreaty"; +export { getViteConfig } from "./vite"; +export { getVueMain } from "./vue-main"; diff --git a/src/templates/vue/main.css.ts b/src/templates/vue/main.css.ts new file mode 100644 index 00000000..33e05b85 --- /dev/null +++ b/src/templates/vue/main.css.ts @@ -0,0 +1,9 @@ +export function getMainCSS() { + return `@import "tailwindcss"; + +/* pass options to the plugin */ +@plugin '@egoist/tailwindcss-icons' { + scale: 1.5; +} +`; +} diff --git a/src/templates/vue/router.ts b/src/templates/vue/router.ts new file mode 100644 index 00000000..feb66f0b --- /dev/null +++ b/src/templates/vue/router.ts @@ -0,0 +1,18 @@ +export function getRouter() { + return `import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: "/", + name: "Home", + component: () => import("../pages/Home.vue"), + }, + + ], +}) + +export default router +`; +} \ No newline at end of file diff --git a/src/templates/vue/tsconfig-vue.json.ts b/src/templates/vue/tsconfig-vue.json.ts new file mode 100644 index 00000000..22c7424f --- /dev/null +++ b/src/templates/vue/tsconfig-vue.json.ts @@ -0,0 +1,23 @@ +export function getTSConfigVue() { + return `{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "jsxImportSource": "vue", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "types": ["vite/client"] + }, + "include": [ + "env.d.ts", + "src/**/*", + "src/**/*.vue" + ], + "exclude": ["src/**/__tests__/*"] +}`; +} diff --git a/src/templates/vue/useApiWithToast.ts b/src/templates/vue/useApiWithToast.ts new file mode 100644 index 00000000..8a8d3968 --- /dev/null +++ b/src/templates/vue/useApiWithToast.ts @@ -0,0 +1,138 @@ +export function getUseApiWithToast() { + return ` +import { toast } from "vue-sonner"; +import client from "./useTreaty"; + +export type Err = + { + message?: string; + data?: null | Record | string; + status: number; + }; +/** + * Simple API Hook with Toast notifications - maintains type inference + */ +export function useApiWithToast() { + + // Error toast notification + const showError = (message: string) => { + toast.error(message) + }; + + // Warning toast notification + const showWarning = (message: string) => { + toast.warning(message) + }; + + // Get user-friendly error messages + const getErrorMessage = (error: any): string => { + if (error?.status === 400) return "Invalid request parameters"; + if (error?.status === 401) return "Login expired, please login again"; + if (error?.status === 403) return "Insufficient permissions"; + if (error?.status === 404) return "Requested resource not found"; + if (error?.status === 500) return "Internal server error, please try again later"; + return error?.message || "Operation failed, please try again later"; + }; + + // Basic API call - maintains original type inference + const call = async ( + apiCall: () => Promise<{ data: Data; error: Err }>, + ): Promise => { + try { + const { data, error } = await apiCall(); + if (error) { + showError(getErrorMessage(error)); + return null; + } + return data; + } catch (error: any) { + showError(getErrorMessage(error)); + return null; + } + }; + + // API call with default value + const callWithDefault = async ( + apiCall: () => Promise<{ data: Data; error: Err }>, + defaultValue: Data, + ): Promise => { + const result = await call(apiCall); + return result !== null ? result : defaultValue; + }; + + // Paginated API call + const callPaginated = async ( + apiCall: () => Promise<{ data: Data; error: Err }>, + defaultValue?: any, + ): Promise => { + const defaultPaginatedValue = defaultValue || { + items: [], + meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, + }; + const result = await callWithDefault(apiCall, defaultPaginatedValue); + return result; + }; + + // Success notification + const showSuccess = (message: string) => { + toast.success(message) + }; + + // Create operation (with success notification) + const create = async ( + apiCall: () => Promise<{ data: Data; error: Err }>, + successMessage: string = "Created successfully", + ): Promise => { + const result = await call(apiCall); + if (result !== null) { + showSuccess(successMessage); + } + return result; + }; + + // Update operation (with success notification) + const update = async ( + apiCall: () => Promise<{ data: Data; error: Err }>, + successMessage: string = "Updated successfully", + ): Promise => { + const result = await call(apiCall); + if (result !== null) { + showSuccess(successMessage); + } + return result; + }; + + // Delete operation (with success notification) + const remove = async ( + apiCall: () => Promise<{ data: Data; error: Err }>, + successMessage: string = "Deleted successfully", + ): Promise => { + const result = await call(apiCall); + if (result !== null) { + showSuccess(successMessage); + } + return result; + }; + + return { + // Basic calls + call, + callWithDefault, + callPaginated, + + // CRUD operations + create, + update, + remove, + + // Toast methods + showError, + showWarning, + showSuccess, + + // Direct client access + client, + }; +} +`; +} \ No newline at end of file diff --git a/src/templates/vue/useTreaty.ts b/src/templates/vue/useTreaty.ts new file mode 100644 index 00000000..fb0ebadc --- /dev/null +++ b/src/templates/vue/useTreaty.ts @@ -0,0 +1,11 @@ +export function getUseTreaty() { + return `import { EndApp } from "@backend/index"; +import { treaty } from "@elysiajs/eden"; + // Create Eden Treaty client +export const client = treaty( + import.meta.env.VITE_API_URL || "http://localhost:9002", +); + +export default client; +`; +} \ No newline at end of file diff --git a/src/templates/vue/vite.ts b/src/templates/vue/vite.ts new file mode 100644 index 00000000..5ca6d67b --- /dev/null +++ b/src/templates/vue/vite.ts @@ -0,0 +1,79 @@ +export function getViteConfig(frontend: string) { + if (frontend === "Vue") { + return `import tailwindcss from "@tailwindcss/vite"; +import vue from "@vitejs/plugin-vue"; +import { resolve } from "node:path"; +import { defineConfig } from "vite"; +import AutoImport from "unplugin-auto-import/vite"; +import Components from "unplugin-vue-components/vite"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + tailwindcss(), + AutoImport({ + imports: ["vue", "vue-router", "@vueuse/core"], + dts: true, // Generate type declaration files + }), + // Auto-import components + Components({ + resolvers: [], + dts: true, // Generate component type declaration files + dirs: ["src/components"], // Directory for auto-imported components + extensions: ["vue"], // Component file extensions + deep: true, // Deep search subdirectories + include: [/\\.vue$/, /\\.vue\\?vue/], // Included file types + }), + ], + server: { + port: 5000, + fs: { + allow: ['../backend/src'], + }, + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + "@backend": resolve(__dirname, "../backend/src"), + }, + }, + build: { + outDir: "dist", + sourcemap: true, + }, +});`; + } + + if (frontend === "React") { + return `import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:3000", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, + build: { + outDir: "dist", + sourcemap: true, + }, +});`; + } + + // Default Vue configuration + return getViteConfig("Vue"); +} diff --git a/src/templates/vue/vue-main.ts b/src/templates/vue/vue-main.ts new file mode 100644 index 00000000..3b276c8c --- /dev/null +++ b/src/templates/vue/vue-main.ts @@ -0,0 +1,14 @@ +export function getVueMain() { + return `import { createApp } from 'vue'; +import "./assets/styles/main.css"; + +import App from './App.vue'; +import router from './router'; + +const app = createApp(App) + +// app.use(createPinia()) +app.use(router) + +app.mount('#app')`; +} diff --git a/src/utils.ts b/src/utils.ts index ffcac677..f496b3a4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import child_process from "node:child_process"; import { randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import { promisify } from "node:util"; +import { dependencies } from "./deps"; export type PackageManager = "bun" | "npm" | "yarn" | "pnpm"; @@ -27,6 +28,43 @@ export async function createOrFindDir(path: string) { await fs.stat(path).catch(async () => fs.mkdir(path)); } +export type PluginName = + | "Bearer" + | "CORS" + | "HTML/JSX" + | "JWT" + | "Server Timing" + | "Static" + | "Swagger" + | "Autoload" + | "openAPI" + | "Logger" + | "Oauth 2.0"; + +export type PluginDependency = { + name: string; + type: "runtime" | "dev"; +}; + +export const pluginDepsMap: Record = { + Bearer: [{ name: "@elysiajs/bearer", type: "runtime" }], + CORS: [{ name: "@elysiajs/cors", type: "runtime" }], + "HTML/JSX": [ + { name: "@elysiajs/html", type: "runtime" }, + { name: "@kitajs/ts-html-plugin", type: "dev" }, + ], + JWT: [{ name: "@elysiajs/jwt", type: "runtime" }], + "Server Timing": [{ name: "@elysiajs/server-timing", type: "runtime" }], + Static: [{ name: "@elysiajs/static", type: "runtime" }], + Swagger: [{ name: "@elysiajs/swagger", type: "runtime" }], + Autoload: [{ name: "elysia-autoload", type: "runtime" }], + openAPI: [{ name: "@elysiajs/openapi", type: "runtime" }], + Logger: [{ name: "@bogeychan/elysia-logger", type: "runtime" }], + "Oauth 2.0": [ + { name: "arctic", type: "runtime" }, + { name: "elysia-oauth2", type: "runtime" }, + ], +} as const; export class Preferences { projectName = ""; dir = ""; @@ -50,21 +88,14 @@ export class Preferences { | "None" = "None"; git = true; others: ("S3" | "Husky" | "Posthog" | "Jobify")[] = []; - plugins: ( - | "JWT" - | "CORS" - | "Swagger" - | "Autoload" - | "Oauth 2.0" - | "Logger" - | "HTML/JSX" - | "Static" - | "Bearer" - | "Server Timing" - )[] = []; + plugins: PluginName[] = []; // integration with create-gramio isMonorepo = false; + frontend: "None" | "Vue" | "React" | "Solid" | "Svelte" = "None"; + frontendDir = ""; + backendDir = ""; + docker = false; vscode = false; redis = false; @@ -129,3 +160,175 @@ export const pmInstallFrozenLockfileProduction: Record = yarn: "yarn install --frozen-lockfile --production", pnpm: "pnpm install --frozen-lockfile --prod", }; + +export function getMonorepoRootDepsAndScripts({ + linter, + orm, + others, + packageManager, + mockWithPGLite, +}: Pick< + Preferences, + "linter" | "orm" | "others" | "packageManager" | "mockWithPGLite" +>) { + const devDependencies: Record = {}; + const scripts: Record = {}; + + // handle Linter + if (linter === "Biome") { + devDependencies["@biomejs/biome"] = dependencies["@biomejs/biome"]; + scripts.lint = `${pmExecuteMap[packageManager]} @biomejs/biome check .`; + scripts["lint:fix"] = `${pmRunMap[packageManager]} lint --write`; + } + if (linter === "ESLint") { + devDependencies.eslint = dependencies.eslint; + devDependencies["@antfu/eslint-config"] = + dependencies["@antfu/eslint-config"]; + scripts.lint = `${pmExecuteMap[packageManager]} eslint .`; + scripts["lint:fix"] = `${pmExecuteMap[packageManager]} eslint --fix .`; + } + + // handle ORM + if (orm === "Drizzle") { + devDependencies["drizzle-kit"] = dependencies["drizzle-kit"]; + scripts["db:generate"] = + `${pmExecuteMap[packageManager]} drizzle-kit generate`; + scripts["db:push"] = `${pmExecuteMap[packageManager]} drizzle-kit push`; + scripts["db:studio"] = `${pmExecuteMap[packageManager]} drizzle-kit studio`; + } + if (orm === "Prisma") { + devDependencies.prisma = dependencies.prisma; + scripts["db:generate"] = `${pmExecuteMap[packageManager]} prisma generate`; + scripts["db:push"] = `${pmExecuteMap[packageManager]} prisma db push`; + scripts["db:studio"] = `${pmExecuteMap[packageManager]} prisma studio`; + } + + // handle other tools + if (others.includes("Husky")) { + devDependencies.husky = dependencies.husky; + scripts.prepare = "husky"; + } + + if (mockWithPGLite) { + devDependencies["@electric-sql/pglite"] = + dependencies["@electric-sql/pglite"]; + } + + return { devDependencies, scripts }; +} + +export function getSingleAppDepsAndScripts( + plugins: PluginName[], + { + linter, + orm, + driver, + others, + redis, + mockWithPGLite, + telegramRelated, + s3Client, + packageManager, + }: { + linter: "ESLint" | "Biome" | "None"; + orm: "Prisma" | "Drizzle" | "None"; + driver: string; + others: string[]; + redis?: boolean; + mockWithPGLite?: boolean; + telegramRelated?: boolean; + s3Client?: string; + packageManager: PackageManager; + }, +) { + const runtimeDeps: Record = {}; + const devDeps: Record = {}; + const scripts: Record = {}; + + // Handle plugin dependencies with type classification + for (const plugin of plugins) { + const deps = pluginDepsMap[plugin]; + if (deps) { + for (const { name, type } of deps) { + const version = dependencies[name as keyof typeof dependencies]; + if (!version) continue; // Safety fallback + + if (type === "runtime") { + runtimeDeps[name] = version; + } else { + devDeps[name] = version; + } + } + } + } + + // --- Linter --- + if (linter === "Biome") { + scripts.lint = `${pmExecuteMap[packageManager]} @biomejs/biome check src`; + scripts["lint:fix"] = `${pmRunMap[packageManager]} lint --write`; + devDeps["@biomejs/biome"] = dependencies["@biomejs/biome"]; + } else if (linter === "ESLint") { + scripts.lint = `${pmExecuteMap[packageManager]} eslint`; + scripts["lint:fix"] = `${pmExecuteMap[packageManager]} eslint --fix`; + devDeps.eslint = dependencies.eslint; + devDeps["@antfu/eslint-config"] = dependencies["@antfu/eslint-config"]; + if (orm === "Drizzle") + devDeps["eslint-plugin-drizzle"] = dependencies["eslint-plugin-drizzle"]; + } + + // --- ORM --- + if (orm === "Prisma") { + devDeps.prisma = dependencies.prisma; + runtimeDeps["@prisma/client"] = dependencies["@prisma/client"]; + } else if (orm === "Drizzle") { + runtimeDeps["drizzle-orm"] = dependencies["drizzle-orm"]; + devDeps["drizzle-kit"] = dependencies["drizzle-kit"]; + if (driver === "node-postgres") { + runtimeDeps.pg = dependencies.pg; + devDeps["@types/pg"] = dependencies["@types/pg"]; + } else if (driver === "Postgres.JS") { + runtimeDeps.postgres = dependencies.postgres; + } else if (driver === "MySQL 2") { + runtimeDeps.mysql2 = dependencies.mysql2; + } + scripts.generate = `${pmExecuteMap[packageManager]} drizzle-kit generate`; + scripts.push = `${pmExecuteMap[packageManager]} drizzle-kit push`; + scripts.migrate = `${pmExecuteMap[packageManager]} drizzle-kit migrate`; + scripts.studio = `${pmExecuteMap[packageManager]} drizzle-kit studio`; + } + + // --- Others --- + if (others.includes("Husky")) { + devDeps.husky = dependencies.husky; + scripts.prepare = "husky"; + } + if (others.includes("Jobify")) { + runtimeDeps.jobify = dependencies.jobify; + } + if (others.includes("Posthog")) { + runtimeDeps["posthog-node"] = dependencies["posthog-node"]; + } + if (others.includes("S3") && s3Client === "@aws-sdk/client-s3") { + runtimeDeps["@aws-sdk/client-s3"] = dependencies["@aws-sdk/client-s3"]; + } + + // --- Redis --- + if (redis) { + runtimeDeps.ioredis = dependencies.ioredis; + if (mockWithPGLite) devDeps["ioredis-mock"] = dependencies["ioredis-mock"]; + } + + // --- Telegram --- + if (telegramRelated) { + runtimeDeps.gramio = dependencies.gramio; + runtimeDeps["@gramio/init-data"] = dependencies["@gramio/init-data"]; + } + + // --- PGLite --- + if (mockWithPGLite) { + devDeps["@electric-sql/pglite"] = dependencies["@electric-sql/pglite"]; + devDeps["@elysiajs/eden"] = dependencies["@elysiajs/eden"]; + } + + return { dependencies: runtimeDeps, devDependencies: devDeps, scripts }; +} diff --git a/tsconfig.json b/tsconfig.json index ec76b48d..97007edd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,15 @@ { - "compilerOptions": { - "lib": ["ESNext"], - "module": "CommonJS", - "target": "esnext", - "declaration": false, - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "rootDir": "./src", - "outDir": "./dist" - }, - "include": ["src"] -} \ No newline at end of file + "compilerOptions": { + "lib": ["ESNext"], + "module": "CommonJS", + "target": "esnext", + "declaration": false, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +}