From 4aa9e5b38bb0106746f5b5988481c7b72fbada7d Mon Sep 17 00:00:00 2001 From: muneebhashone Date: Sat, 1 Nov 2025 22:20:42 +0500 Subject: [PATCH] chore: update .gitignore to include .codacy and ensure proper file exclusions --- .github/workflows/publish-package.yml | 105 ++++ .gitignore | 4 +- packages/create-tbk-app/README.md | 32 +- packages/create-tbk-app/package.json | 3 +- packages/create-tbk-app/src/cli.ts | 574 ++++++++++++++++++--- packages/create-tbk-app/src/prompts.ts | 503 +++++++++--------- pnpm-lock.yaml | 152 +----- src/modules/blog/blog.controller.ts | 82 --- src/modules/blog/blog.dto.ts | 19 - src/modules/blog/blog.model.ts | 15 - src/modules/blog/blog.router.ts | 83 --- src/modules/blog/blog.schema.ts | 51 -- src/modules/blog/blog.services.ts | 78 --- src/modules/blog/factories/blog.factory.ts | 29 -- src/modules/blog/seeders/BlogSeeder.ts | 24 - src/routes/routes.ts | 2 - 16 files changed, 941 insertions(+), 815 deletions(-) create mode 100644 .github/workflows/publish-package.yml delete mode 100644 src/modules/blog/blog.controller.ts delete mode 100644 src/modules/blog/blog.dto.ts delete mode 100644 src/modules/blog/blog.model.ts delete mode 100644 src/modules/blog/blog.router.ts delete mode 100644 src/modules/blog/blog.schema.ts delete mode 100644 src/modules/blog/blog.services.ts delete mode 100644 src/modules/blog/factories/blog.factory.ts delete mode 100644 src/modules/blog/seeders/BlogSeeder.ts diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml new file mode 100644 index 0000000..8b912f3 --- /dev/null +++ b/.github/workflows/publish-package.yml @@ -0,0 +1,105 @@ +# GitHub Actions workflow for publishing create-tbk-app to npm +# +# SETUP INSTRUCTIONS: +# 1. Go to your repository Settings > Secrets and variables > Actions +# 2. Click "New repository secret" +# 3. Name: NPM_TOKEN +# 4. Value: Your npm access token (create at https://www.npmjs.com/settings/{username}/tokens) +# 5. Select "Automation" token type for CI/CD use +# 6. Save the secret +# +# For more info: https://docs.github.com/en/actions/security-guides/encrypted-secrets + +name: Publish create-tbk-app to npm + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.9.0 + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Bump version in package.json + id: bump-version + working-directory: packages/create-tbk-app + run: | + # Get current version + CURRENT_VERSION=$(node -p "require('./package.json').version") + echo "Current version: $CURRENT_VERSION" + + # Bump version based on input + npm version ${{ inputs.version_type }} --no-git-tag-version + + # Get new version + NEW_VERSION=$(node -p "require('./package.json').version") + echo "New version: $NEW_VERSION" + + # Set output for subsequent steps + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "tag=create-tbk-app-v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Commit version bump + run: | + git add packages/create-tbk-app/package.json + git commit -m "chore(create-tbk-app): bump version to ${{ steps.bump-version.outputs.version }}" + git push origin HEAD:${{ github.ref_name }} + + - name: Build package + working-directory: packages/create-tbk-app + run: pnpm build + + - name: Publish to npm + working-directory: packages/create-tbk-app + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc + pnpm publish --access public + rm .npmrc + + - name: Create git tag + run: | + git tag ${{ steps.bump-version.outputs.tag }} + git push origin ${{ steps.bump-version.outputs.tag }} + + - name: Summary + run: | + echo "βœ… Successfully published create-tbk-app@${{ steps.bump-version.outputs.version }} to npm" + echo "πŸ“¦ Tagged as ${{ steps.bump-version.outputs.tag }}" + diff --git a/.gitignore b/.gitignore index d9180de..63e52d8 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,6 @@ Api.ts #Ignore cursor AI rules .cursor/rules/codacy.mdc -/docs \ No newline at end of file +/docs + +.codacy \ No newline at end of file diff --git a/packages/create-tbk-app/README.md b/packages/create-tbk-app/README.md index c9f5b4e..e372d1d 100644 --- a/packages/create-tbk-app/README.md +++ b/packages/create-tbk-app/README.md @@ -5,13 +5,13 @@ CLI tool to scaffold TypeScript Backend Toolkit projects with customizable featu ## Quick Start ```bash -# Interactive mode (recommended) +# Guided prompts (recommended) npx create-tbk-app my-backend-api -# With preset -npx create-tbk-app my-api --preset=standard +# Skip prompts and use defaults from flags +npx create-tbk-app my-api -y --preset=standard -# Custom configuration via flags +# Provide defaults and confirm step-by-step npx create-tbk-app my-api --auth=jwt --cache=redis --email=resend ``` @@ -66,15 +66,22 @@ create-tbk-app [project-name] [options] Options: --preset Preset configuration (minimal, standard, full, custom) --auth Authentication (none, jwt, jwt-sessions) + --session-driver Session storage driver (mongo, redis) --cache Cache provider (none, memory, redis) --storage Storage provider (none, local, s3, r2) --email Email provider (none, resend, mailgun, smtp) - --queues Enable background jobs - --realtime Enable real-time features - --admin Include admin panel + --queues / --no-queues Toggle background jobs + --queue-dashboard Include queue monitoring dashboard (with queues) + --no-queue-dashboard Disable queue monitoring dashboard + --realtime / --no-realtime Toggle real-time features + --admin / --no-admin Toggle admin panel + --google-oauth Enable Google OAuth login (with auth) + --observability Observability level (basic, full) --pm Package manager (pnpm, npm, yarn) --skip-git Skip git initialization --skip-install Skip dependency installation + -y, --yes Skip prompts and accept defaults + --force Overwrite existing directory without prompting -h, --help Display help -V, --version Display version ``` @@ -93,6 +100,7 @@ You'll be prompted for: 3. Custom features (if preset is "custom") 4. Package manager preference 5. Git/install preferences +6. Final summary confirmation ### Non-Interactive Mode @@ -102,7 +110,7 @@ You'll be prompted for: npx create-tbk-app my-api --preset=minimal # Standard with npm -npx create-tbk-app my-api --preset=standard --pm=npm +npx create-tbk-app my-api --preset=standard --pm=npm --skip-install --skip-git # Full-featured npx create-tbk-app my-api --preset=full @@ -124,13 +132,19 @@ npx create-tbk-app my-api \ --storage=s3 \ --email=resend \ --realtime \ - --admin + --admin \ + --pm=pnpm \ + --skip-install \ + --skip-git ``` **Skip options:** ```bash # Don't install dependencies or init git npx create-tbk-app my-api --preset=standard --skip-install --skip-git + +# Force overwrite and skip prompts +npx create-tbk-app my-api -y --preset=standard --pm=pnpm --force ``` ## What Gets Generated diff --git a/packages/create-tbk-app/package.json b/packages/create-tbk-app/package.json index 3becbff..b064793 100644 --- a/packages/create-tbk-app/package.json +++ b/packages/create-tbk-app/package.json @@ -38,17 +38,16 @@ }, "license": "MIT", "dependencies": { + "@clack/prompts": "^0.7.0", "chalk": "^5.3.0", "commander": "^14.0.1", "fs-extra": "^11.2.0", "handlebars": "^4.7.8", - "inquirer": "^9.2.12", "ora": "^8.0.1", "validate-npm-package-name": "^5.0.0" }, "devDependencies": { "@types/fs-extra": "^11.0.4", - "@types/inquirer": "^9.0.7", "@types/node": "^18.11.18", "@types/validate-npm-package-name": "^4.0.2", "tsup": "^8.1.0", diff --git a/packages/create-tbk-app/src/cli.ts b/packages/create-tbk-app/src/cli.ts index 9b2c488..a882bb2 100644 --- a/packages/create-tbk-app/src/cli.ts +++ b/packages/create-tbk-app/src/cli.ts @@ -1,14 +1,60 @@ #!/usr/bin/env node import { Command } from 'commander'; +import fs from 'fs-extra'; import path from 'path'; import chalk from 'chalk'; -import { promptForProjectConfig } from './prompts.js'; +import { + cancel, + confirm, + select, + spinner, + text, + isCancel, +} from '@clack/prompts'; +import { + collectProjectConfig, + renderSummary, + type PromptDefaults, +} from './prompts.js'; import { generateProject } from './generators/project.generator.js'; -import { validateProjectName, checkDirectoryExists } from './utils/validation.js'; -import type { ProjectConfig } from './types/config.types.js'; +import { + validateProjectName, + checkDirectoryExists, +} from './utils/validation.js'; +import type { + AuthType, + CacheProvider, + EmailProvider, + ObservabilityLevel, + PackageManager, + PresetType, + ProjectConfig, + SessionDriver, + StorageProvider, +} from './types/config.types.js'; import { PRESETS } from './constants/presets.js'; +interface CliOptions { + preset?: string; + auth?: string; + sessionDriver?: string; + cache?: string; + storage?: string; + email?: string; + queues?: boolean; + queueDashboard?: boolean; + realtime?: boolean; + admin?: boolean; + googleOauth?: boolean; + observability?: string; + pm?: string; + skipGit?: boolean; + skipInstall?: boolean; + yes?: boolean; + force?: boolean; +} + const program = new Command(); program @@ -16,113 +62,513 @@ program .description('Scaffold a TypeScript Backend Toolkit project') .version('0.1.0') .argument('[project-name]', 'Name of the project') - .option('--preset ', 'Preset configuration (minimal, standard, full, custom)') + .option( + '--preset ', + 'Preset configuration (minimal, standard, full, custom)', + ) .option('--auth ', 'Authentication type (none, jwt, jwt-sessions)') + .option('--session-driver ', 'Session storage driver (mongo, redis)') .option('--cache ', 'Cache provider (none, memory, redis)') .option('--storage ', 'Storage provider (none, local, s3, r2)') .option('--email ', 'Email provider (none, resend, mailgun, smtp)') .option('--queues', 'Enable background jobs') + .option('--no-queues', 'Disable background jobs') + .option('--queue-dashboard', 'Include queue monitoring dashboard') + .option('--no-queue-dashboard', 'Disable queue monitoring dashboard') .option('--realtime', 'Enable real-time features') + .option('--no-realtime', 'Disable real-time features') .option('--admin', 'Include admin panel') + .option('--no-admin', 'Disable admin panel') + .option('--google-oauth', 'Enable Google OAuth login') + .option('--observability ', 'Observability level (basic, full)') .option('--pm ', 'Package manager (pnpm, npm, yarn)') .option('--skip-git', 'Skip git initialization') .option('--skip-install', 'Skip dependency installation') - .action(async (projectName: string | undefined, options: any) => { + .option('-y, --yes', 'Skip prompts and accept defaults') + .option('--force', 'Overwrite target directory without prompting') + .action(async (projectName: string | undefined, options: CliOptions) => { try { console.log(chalk.bold.cyan('\nπŸš€ create-tbk-app\n')); - // If flags are provided, use non-interactive mode - if (options.preset || options.auth || options.cache) { - await runNonInteractive(projectName, options); + const normalized = normalizeOptions(options); + const interactive = shouldRunInteractive(projectName, normalized); + + if (interactive) { + await runInteractiveFlow(projectName, normalized); } else { - // Interactive mode - await runInteractive(projectName); + await runNonInteractiveFlow(projectName, normalized); + } + } catch (error: unknown) { + if (isUserCancelled(error)) { + process.exit(0); } - } catch (error: any) { - console.error(chalk.red('\nβœ– Error:'), error.message); + + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red('\nβœ– Error:'), message); process.exit(1); } }); -async function runInteractive(projectName?: string) { - // Prompt user for configuration - const config = await promptForProjectConfig(projectName); +async function runInteractiveFlow( + projectName: string | undefined, + options: NormalizedOptions, +) { + const defaults = mapOptionsToDefaults(projectName, options); + const collected = await collectProjectConfig(projectName, defaults); + const validatedConfig = await validateAndAdjustConfig( + collected, + options, + true, + ); + await finalizeAndGenerate(validatedConfig, options, true); +} - // Validate project name - const validation = validateProjectName(config.projectName); - if (!validation.valid) { - throw new Error(`Invalid project name: ${validation.error}`); +async function runNonInteractiveFlow( + projectName: string | undefined, + options: NormalizedOptions, +) { + if (!projectName) { + throw new Error('Project name is required when skipping prompts.'); } - // Check if directory exists - const targetDir = path.resolve(process.cwd(), config.projectName); - const dirExists = await checkDirectoryExists(targetDir); + const config = buildConfigFromOptions(projectName, options); + const validatedConfig = await validateAndAdjustConfig(config, options, false); + await finalizeAndGenerate(validatedConfig, options, false); +} - if (dirExists) { - throw new Error( - `Directory "${config.projectName}" already exists and is not empty. Please choose a different name.`, - ); - } +interface NormalizedOptions { + preset?: PresetType; + auth?: AuthType; + sessionDriver?: SessionDriver; + cache?: CacheProvider; + storage?: StorageProvider; + email?: EmailProvider; + queues?: boolean; + queueDashboard?: boolean; + realtime?: boolean; + admin?: boolean; + googleOAuth?: boolean; + observability?: ObservabilityLevel; + packageManager?: PackageManager; + skipGit?: boolean; + skipInstall?: boolean; + yes: boolean; + force: boolean; +} + +const SAFE_NAME_REGEX = /^[a-z0-9-_]+$/; - // Generate project - await generateProject(targetDir, config); +function normalizeOptions(options: CliOptions): NormalizedOptions { + return { + preset: parseChoice( + options.preset, + ['minimal', 'standard', 'full', 'custom'], + 'preset', + ), + auth: parseChoice(options.auth, ['none', 'jwt', 'jwt-sessions'], 'auth'), + sessionDriver: parseChoice( + options.sessionDriver, + ['mongo', 'redis'], + 'session-driver', + ), + cache: parseChoice(options.cache, ['none', 'memory', 'redis'], 'cache'), + storage: parseChoice( + options.storage, + ['none', 'local', 's3', 'r2'], + 'storage', + ), + email: parseChoice( + options.email, + ['none', 'resend', 'mailgun', 'smtp'], + 'email', + ), + queues: getBooleanOption(options, 'queues'), + queueDashboard: getBooleanOption(options, 'queueDashboard'), + realtime: getBooleanOption(options, 'realtime'), + admin: getBooleanOption(options, 'admin'), + googleOAuth: getBooleanOption(options, 'googleOauth'), + observability: parseChoice( + options.observability, + ['basic', 'full'], + 'observability', + ), + packageManager: parseChoice(options.pm, ['pnpm', 'npm', 'yarn'], 'pm'), + skipGit: getBooleanOption(options, 'skipGit'), + skipInstall: getBooleanOption(options, 'skipInstall'), + yes: options.yes ?? false, + force: options.force ?? false, + }; } -async function runNonInteractive(projectName: string | undefined, options: any) { +function shouldRunInteractive( + projectName: string | undefined, + options: NormalizedOptions, +) { + if (options.yes) { + return false; + } + if (!projectName) { - throw new Error('Project name is required in non-interactive mode'); + return true; + } + + return !hasFullConfig(projectName, options); +} + +function hasFullConfig( + projectName: string | undefined, + options: NormalizedOptions, +) { + if (!projectName || !options.preset) { + return false; + } + + if (!hasBasicFlags(options)) { + return false; + } + + if (options.preset !== 'custom') { + return true; } - // Validate project name - const validation = validateProjectName(projectName); + return hasCustomFlags(options); +} + +function hasBasicFlags(options: NormalizedOptions) { + return [options.packageManager, options.skipGit, options.skipInstall].every( + (value) => value !== undefined, + ); +} + +function hasCustomFlags(options: NormalizedOptions) { + const choiceFields = [ + options.auth, + options.cache, + options.storage, + options.email, + options.observability, + ]; + if (choiceFields.some((value) => value === undefined)) { + return false; + } + + if (options.auth === 'jwt-sessions' && !options.sessionDriver) { + return false; + } + + const booleanFields = [options.queues, options.realtime, options.admin]; + if (booleanFields.some((value) => typeof value !== 'boolean')) { + return false; + } + + if (options.queues && typeof options.queueDashboard !== 'boolean') { + return false; + } + + return true; +} + +function mapOptionsToDefaults( + projectName: string | undefined, + options: NormalizedOptions, +): PromptDefaults { + return { + projectName, + preset: options.preset, + auth: options.auth, + sessionDriver: options.sessionDriver, + googleOAuth: options.googleOAuth, + cache: options.cache, + queues: options.queues, + queueDashboard: options.queueDashboard, + storage: options.storage, + email: options.email, + realtime: options.realtime, + admin: options.admin, + observability: options.observability, + packageManager: options.packageManager, + skipGit: options.skipGit, + skipInstall: options.skipInstall, + }; +} + +async function validateAndAdjustConfig( + config: ProjectConfig, + options: NormalizedOptions, + interactive: boolean, +): Promise { + const validation = validateProjectName(config.projectName); if (!validation.valid) { throw new Error(`Invalid project name: ${validation.error}`); } - // Build config from options - const config: ProjectConfig = buildConfigFromOptions(projectName, options); + return resolveProjectDirectory(config, options, interactive); +} + +async function resolveProjectDirectory( + config: ProjectConfig, + options: NormalizedOptions, + interactive: boolean, +): Promise { + let projectName = config.projectName; - // Check if directory exists - const targetDir = path.resolve(process.cwd(), projectName); - const dirExists = await checkDirectoryExists(targetDir); + while (true) { + const targetDir = getTargetDirectory(projectName); + const dirHasContent = await checkDirectoryExists(targetDir); - if (dirExists) { - throw new Error( - `Directory "${projectName}" already exists and is not empty. Please choose a different name.`, + if (!dirHasContent) { + if (options.force) { + await fs.ensureDir(targetDir); + } + + if (projectName !== config.projectName) { + return { ...config, projectName }; + } + + return config; + } + + if (options.force) { + await fs.emptyDir(targetDir); + return { ...config, projectName }; + } + + if (!interactive) { + throw new Error( + `Directory "${projectName}" already exists and is not empty. Use --force to overwrite or choose a different name.`, + ); + } + + const resolution = ensurePromptResult( + await select({ + message: `Directory "${projectName}" already exists and is not empty.`, + options: [ + { label: 'Overwrite existing directory', value: 'overwrite' }, + { label: 'Choose a different project name', value: 'rename' }, + { label: 'Cancel setup', value: 'cancel' }, + ], + }), + ); + + if (resolution === 'overwrite') { + await fs.emptyDir(targetDir); + return { ...config, projectName }; + } + + if (resolution === 'cancel') { + cancel('Setup cancelled.'); + throw new Error('User cancelled'); + } + + const nextName = ensurePromptResult( + await text({ + message: 'Enter a new project name', + initialValue: `${projectName}-1`, + validate: (input) => { + const result = validateProjectName(input.trim()); + if (!result.valid) { + return result.error || 'Invalid project name'; + } + if (!SAFE_NAME_REGEX.test(input.trim())) { + return 'Use lowercase letters, numbers, hyphens, or underscores only'; + } + return undefined; + }, + }), + ).trim(); + + projectName = nextName; + } +} + +async function finalizeAndGenerate( + config: ProjectConfig, + options: NormalizedOptions, + interactive: boolean, +) { + renderSummary(config); + + if (!options.yes && interactive) { + const proceed = ensurePromptResult( + await confirm({ + message: 'Create project with these settings?', + initialValue: true, + }), ); + + if (!proceed) { + cancel('Setup cancelled.'); + throw new Error('User cancelled'); + } } - // Generate project - await generateProject(targetDir, config); + const targetDir = getTargetDirectory(config.projectName); + const spin = spinner(); + + spin.start('Scaffolding project files...'); + + try { + await generateProject(targetDir, config); + spin.stop('Project created successfully βœ…'); + } catch (error) { + spin.stop('Failed to scaffold project'); + throw error; + } } -function buildConfigFromOptions(projectName: string, options: any): ProjectConfig { - // If preset is provided, start with preset config - let baseConfig: Partial = {}; +function buildConfigFromOptions( + projectName: string, + options: NormalizedOptions, +): ProjectConfig { + const preset = + options.preset && PRESETS[options.preset] ? options.preset : 'custom'; + const presetConfig = preset !== 'custom' ? PRESETS[preset].config : undefined; - if (options.preset && options.preset !== 'custom' && PRESETS[options.preset]) { - baseConfig = { ...PRESETS[options.preset].config }; + const auth = resolveOption( + options.auth, + presetConfig?.auth ?? 'none', + 'auth', + ); + + const sessionDriver = options.sessionDriver ?? presetConfig?.sessionDriver; + if (auth === 'jwt-sessions' && !sessionDriver) { + throw new Error('Session driver is required for auth type "jwt-sessions".'); } - // Override with CLI options + const cache = resolveOption( + options.cache, + presetConfig?.cache ?? 'none', + 'cache', + ); + const queues = resolveBooleanOption( + options.queues, + presetConfig?.queues ?? false, + ); + const queueDashboard = queues + ? resolveBooleanOption( + options.queueDashboard, + presetConfig?.queueDashboard ?? true, + ) + : false; + const storage = resolveOption( + options.storage, + presetConfig?.storage ?? 'none', + 'storage', + ); + const email = resolveOption( + options.email, + presetConfig?.email ?? 'none', + 'email', + ); + const realtime = resolveBooleanOption( + options.realtime, + presetConfig?.realtime ?? false, + ); + const admin = resolveBooleanOption( + options.admin, + presetConfig?.admin ?? false, + ); + const observability = resolveOption( + options.observability, + presetConfig?.observability ?? 'basic', + 'observability', + ); + return { projectName, - preset: options.preset || 'custom', - auth: options.auth || baseConfig.auth || 'none', - sessionDriver: baseConfig.sessionDriver, - cache: options.cache || baseConfig.cache || 'none', - queues: options.queues !== undefined ? options.queues : baseConfig.queues || false, - storage: options.storage || baseConfig.storage || 'none', - email: options.email || baseConfig.email || 'none', - realtime: options.realtime !== undefined ? options.realtime : baseConfig.realtime || false, - admin: options.admin !== undefined ? options.admin : baseConfig.admin || false, - queueDashboard: - baseConfig.queueDashboard !== undefined ? baseConfig.queueDashboard : false, - observability: baseConfig.observability || 'basic', - packageManager: options.pm || 'pnpm', - skipGit: options.skipGit || false, - skipInstall: options.skipInstall || false, + preset, + auth, + sessionDriver, + googleOAuth: resolveBooleanOption( + options.googleOAuth, + presetConfig?.googleOAuth ?? false, + ), + cache, + queues, + queueDashboard, + storage, + email, + realtime, + admin, + observability, + packageManager: options.packageManager ?? 'pnpm', + skipGit: resolveBooleanOption(options.skipGit, false), + skipInstall: resolveBooleanOption(options.skipInstall, false), }; } +function parseChoice( + value: string | undefined, + allowed: readonly T[], + flagName: string, +): T | undefined { + if (!value) { + return undefined; + } + + const normalized = value.toLowerCase() as T; + if (!allowed.includes(normalized)) { + throw new Error( + `Invalid value "${value}" for --${flagName}. Allowed values: ${allowed.join(', ')}`, + ); + } + + return normalized; +} + +function getBooleanOption(options: CliOptions, key: keyof CliOptions) { + const value = options[key]; + return typeof value === 'boolean' ? value : undefined; +} + +function resolveOption( + value: T | undefined, + fallback: T | undefined, + fieldName: string, +): T { + if (value !== undefined) { + return value; + } + + if (fallback !== undefined) { + return fallback; + } + + throw new Error(`Missing required option --${fieldName}.`); +} + +function resolveBooleanOption( + value: boolean | undefined, + fallback: boolean, +): boolean { + if (typeof value === 'boolean') { + return value; + } + + return fallback; +} + +function ensurePromptResult(result: T | symbol): T { + if (isCancel(result)) { + cancel('Setup cancelled.'); + throw new Error('User cancelled'); + } + + return result as T; +} + +function getTargetDirectory(projectName: string): string { + if (!SAFE_NAME_REGEX.test(projectName)) { + throw new Error( + 'Project name may only include lowercase letters, numbers, hyphens, and underscores.', + ); + } + + return path.resolve(process.cwd(), projectName); +} + +function isUserCancelled(error: unknown): boolean { + return error instanceof Error && error.message === 'User cancelled'; +} + program.parse(); diff --git a/packages/create-tbk-app/src/prompts.ts b/packages/create-tbk-app/src/prompts.ts index 554e724..3d6b7db 100644 --- a/packages/create-tbk-app/src/prompts.ts +++ b/packages/create-tbk-app/src/prompts.ts @@ -1,223 +1,235 @@ -import inquirer from 'inquirer'; +import { + confirm, + intro, + isCancel, + note, + select, + text, + cancel, +} from '@clack/prompts'; import type { - ProjectConfig, - PresetType, AuthType, CacheProvider, - StorageProvider, EmailProvider, - SessionDriver, + ObservabilityLevel, PackageManager, + PresetType, + ProjectConfig, + SessionDriver, + StorageProvider, } from './types/config.types.js'; import { PRESETS, getPresetChoices } from './constants/presets.js'; -export async function promptForProjectConfig(projectName?: string): Promise { - console.log('\nπŸš€ Welcome to create-tbk-app!\n'); +type BooleanLike = boolean | undefined; +type Choice = { value: T; label: string; hint?: string }; + +const projectNameRegex = /^[a-z0-9-_]+$/; + +export interface PromptDefaults { + projectName?: string; + preset?: PresetType; + auth?: AuthType; + sessionDriver?: SessionDriver; + googleOAuth?: BooleanLike; + cache?: CacheProvider; + queues?: BooleanLike; + queueDashboard?: BooleanLike; + storage?: StorageProvider; + email?: EmailProvider; + realtime?: BooleanLike; + admin?: BooleanLike; + observability?: ObservabilityLevel; + packageManager?: PackageManager; + skipGit?: BooleanLike; + skipInstall?: BooleanLike; +} - // Step 1: Project name - const { name } = await inquirer.prompt<{ name: string }>([ - { - type: 'input', - name: 'name', +export async function collectProjectConfig( + projectNameArg?: string, + defaults: PromptDefaults = {}, +): Promise { + intro('create-tbk-app'); + + const projectName = ensureNotCancelled( + await text({ message: 'What is your project named?', - default: projectName || 'my-backend', - validate: (input: string) => { + initialValue: projectNameArg || defaults.projectName || 'my-backend', + validate: (input) => { if (!input || input.trim().length === 0) { return 'Project name is required'; } - if (!/^[a-z0-9-_]+$/.test(input)) { - return 'Project name can only contain lowercase letters, numbers, hyphens, and underscores'; + if (!projectNameRegex.test(input)) { + return 'Use lowercase letters, numbers, hyphens, or underscores only'; } - return true; + return undefined; }, - }, - ]); - - // Step 2: Preset selection - const { preset } = await inquirer.prompt<{ preset: PresetType }>([ - { - type: 'list', - name: 'preset', - message: 'Which preset would you like to use?', - choices: getPresetChoices(), - }, - ]); - - // If not custom, use preset config - if (preset !== 'custom') { - const presetConfig = PRESETS[preset].config; - const { packageManager, skipGit, skipInstall } = await promptForBasicOptions(); - - return { - projectName: name, - preset, - ...presetConfig, - packageManager, - skipGit, - skipInstall, - } as any; + }), + ); + + const preset = await promptSelectValue({ + message: 'Which preset would you like to use?', + options: getPresetChoices().map((choice) => ({ + value: choice.value as PresetType, + label: choice.name, + })), + initialValue: defaults.preset, + }); + + let customConfig: Partial = {}; + + if (preset === 'custom') { + customConfig = await collectCustomConfig(defaults); + } else { + customConfig = { ...PRESETS[preset].config }; } - // Custom configuration - ask detailed questions - const customConfig = await promptForCustomConfig(); - const { packageManager, skipGit, skipInstall } = await promptForBasicOptions(); + const basicOptions = await collectBasicOptions(defaults); - return { - projectName: name, - preset: 'custom', - ...customConfig, - packageManager, - skipGit, - skipInstall, + const finalConfig: ProjectConfig = { + projectName, + preset, + auth: customConfig.auth!, + sessionDriver: customConfig.sessionDriver, + googleOAuth: customConfig.googleOAuth ?? false, + cache: customConfig.cache!, + queues: customConfig.queues ?? false, + queueDashboard: customConfig.queueDashboard ?? false, + storage: customConfig.storage!, + email: customConfig.email!, + realtime: customConfig.realtime ?? false, + admin: customConfig.admin ?? false, + observability: customConfig.observability!, + packageManager: basicOptions.packageManager, + skipGit: basicOptions.skipGit, + skipInstall: basicOptions.skipInstall, }; + + return finalConfig; } -async function promptForCustomConfig() { - console.log('\nπŸ“ Let\'s customize your backend...\n'); - - // Authentication - const { auth } = await inquirer.prompt<{ auth: AuthType }>([ - { - type: 'list', - name: 'auth', - message: 'Authentication system:', - choices: [ - { name: 'None - No authentication', value: 'none' }, - { name: 'JWT - Token-based auth', value: 'jwt' }, - { name: 'JWT + Sessions - Token + session management', value: 'jwt-sessions' }, - ], - }, - ]); +export function renderSummary(config: ProjectConfig) { + note( + 'Project configuration', + [ + `Name: ${config.projectName}`, + `Preset: ${config.preset}`, + `Auth: ${config.auth}${config.auth === 'jwt-sessions' && config.sessionDriver ? ` (${config.sessionDriver})` : ''}`, + `Google OAuth: ${config.googleOAuth ? 'yes' : 'no'}`, + `Cache: ${config.cache}`, + `Queues: ${config.queues ? 'enabled' : 'disabled'}`, + `Queue dashboard: ${config.queueDashboard ? 'yes' : 'no'}`, + `Storage: ${config.storage}`, + `Email: ${config.email}`, + `Realtime: ${config.realtime ? 'enabled' : 'disabled'}`, + `Admin: ${config.admin ? 'enabled' : 'disabled'}`, + `Observability: ${config.observability}`, + `Package manager: ${config.packageManager}`, + `Initialize git repo: ${config.skipGit ? 'no' : 'yes'}`, + `Install dependencies: ${config.skipInstall ? 'later' : 'now'}`, + ].join('\n'), + ); +} - let sessionDriver: SessionDriver | undefined; - if (auth === 'jwt-sessions') { - const result = await inquirer.prompt<{ sessionDriver: SessionDriver }>([ - { - type: 'list', - name: 'sessionDriver', - message: 'Session storage:', - choices: [ - { name: 'MongoDB - Store sessions in MongoDB', value: 'mongo' }, - { name: 'Redis - Store sessions in Redis (faster)', value: 'redis' }, - ], - }, - ]); - sessionDriver = result.sessionDriver; - } +async function collectCustomConfig( + defaults: PromptDefaults, +): Promise> { + note('Custom configuration', 'Let’s pick the features you need.'); - // Google OAuth - let googleOAuth = false; - if (auth !== 'none') { - const result = await inquirer.prompt<{ googleOAuth: boolean }>([ + const auth = await promptSelectValue({ + message: 'Authentication system', + options: [ + { label: 'None – No authentication', value: 'none' }, + { label: 'JWT – Token-based auth', value: 'jwt' }, { - type: 'confirm', - name: 'googleOAuth', - message: 'Enable Google OAuth login?', - default: false, + label: 'JWT + Sessions – Token + session management', + value: 'jwt-sessions', }, - ]); - googleOAuth = result.googleOAuth; - } + ], + initialValue: defaults.auth ?? 'none', + }); - // Caching - const { cache } = await inquirer.prompt<{ cache: CacheProvider }>([ - { - type: 'list', - name: 'cache', - message: 'Caching strategy:', - choices: [ - { name: 'None - No caching', value: 'none' }, - { name: 'Memory - In-memory cache (dev/testing)', value: 'memory' }, - { name: 'Redis - Redis cache (production)', value: 'redis' }, + let sessionDriver: SessionDriver | undefined = defaults.sessionDriver; + if (auth === 'jwt-sessions') { + sessionDriver = await promptSelectValue({ + message: 'Session storage', + options: [ + { label: 'MongoDB – Store sessions in MongoDB', value: 'mongo' }, + { label: 'Redis – Store sessions in Redis (faster)', value: 'redis' }, ], - }, - ]); - - // Background jobs - const { queues } = await inquirer.prompt<{ queues: boolean }>([ - { - type: 'confirm', - name: 'queues', - message: 'Enable background jobs? (BullMQ + Redis)', - default: false, - }, - ]); - - let queueDashboard = false; - if (queues) { - const result = await inquirer.prompt<{ queueDashboard: boolean }>([ - { - type: 'confirm', - name: 'queueDashboard', - message: 'Include queue monitoring dashboard?', - default: true, - }, - ]); - queueDashboard = result.queueDashboard; + initialValue: defaults.sessionDriver ?? 'redis', + }); } - // File storage - const { storage } = await inquirer.prompt<{ storage: StorageProvider }>([ - { - type: 'list', - name: 'storage', - message: 'File storage:', - choices: [ - { name: 'None - No file uploads', value: 'none' }, - { name: 'Local - Store files on disk', value: 'local' }, - { name: 'AWS S3 - Amazon S3', value: 's3' }, - { name: 'Cloudflare R2 - S3-compatible', value: 'r2' }, - ], - }, - ]); - - // Email - const { email } = await inquirer.prompt<{ email: EmailProvider }>([ - { - type: 'list', - name: 'email', - message: 'Email service:', - choices: [ - { name: 'None - No email sending', value: 'none' }, - { name: 'Resend - Modern email API', value: 'resend' }, - { name: 'Mailgun - Transactional email', value: 'mailgun' }, - { name: 'SMTP - Traditional SMTP', value: 'smtp' }, - ], - }, - ]); - - // Real-time - const { realtime } = await inquirer.prompt<{ realtime: boolean }>([ - { - type: 'confirm', - name: 'realtime', - message: 'Enable real-time features? (Socket.IO)', - default: false, - }, - ]); - - // Admin panel - const { admin } = await inquirer.prompt<{ admin: boolean }>([ - { - type: 'confirm', - name: 'admin', - message: 'Include admin panel? (Django-style auto-generated UI)', - default: false, - }, - ]); - - // Observability - const { observability } = await inquirer.prompt<{ observability: 'basic' | 'full' }>([ - { - type: 'list', - name: 'observability', - message: 'Observability level:', - choices: [ - { name: 'Basic - Logging only', value: 'basic' }, - { name: 'Full - Logging + Metrics + Health checks', value: 'full' }, - ], - }, - ]); + const googleOAuth = + auth !== 'none' + ? await promptConfirmValue( + 'Enable Google OAuth login?', + Boolean(defaults.googleOAuth), + ) + : false; + + const cache = await promptSelectValue({ + message: 'Caching strategy', + options: [ + { label: 'None – No caching', value: 'none' }, + { label: 'Memory – In-memory cache (dev/testing)', value: 'memory' }, + { label: 'Redis – Redis cache (production)', value: 'redis' }, + ], + initialValue: defaults.cache ?? 'none', + }); + + const queues = await promptConfirmValue( + 'Enable background jobs? (BullMQ + Redis)', + Boolean(defaults.queues), + ); + + const queueDashboard = queues + ? await promptConfirmValue( + 'Include queue monitoring dashboard?', + defaults.queueDashboard ?? true, + ) + : false; + + const storage = await promptSelectValue({ + message: 'File storage provider', + options: [ + { label: 'None – No file uploads', value: 'none' }, + { label: 'Local – Store files on disk', value: 'local' }, + { label: 'AWS S3 – Amazon S3', value: 's3' }, + { label: 'Cloudflare R2 – S3 compatible', value: 'r2' }, + ], + initialValue: defaults.storage ?? 'none', + }); + + const email = await promptSelectValue({ + message: 'Email service', + options: [ + { label: 'None – No email sending', value: 'none' }, + { label: 'Resend – Modern email API', value: 'resend' }, + { label: 'Mailgun – Transactional email', value: 'mailgun' }, + { label: 'SMTP – Traditional SMTP', value: 'smtp' }, + ], + initialValue: defaults.email ?? 'none', + }); + + const realtime = await promptConfirmValue( + 'Enable real-time features? (Socket.IO)', + Boolean(defaults.realtime), + ); + + const admin = await promptConfirmValue( + 'Include admin panel? (auto-generated UI)', + Boolean(defaults.admin), + ); + + const observability = await promptSelectValue({ + message: 'Observability level', + options: [ + { label: 'Basic – Logging only', value: 'basic' }, + { label: 'Full – Logging + metrics + health checks', value: 'full' }, + ], + initialValue: defaults.observability ?? 'full', + }); return { auth, @@ -231,49 +243,78 @@ async function promptForCustomConfig() { realtime, admin, observability, - }; + } satisfies Partial; } -async function promptForBasicOptions() { - const { packageManager } = await inquirer.prompt<{ packageManager: PackageManager }>([ - { - type: 'list', - name: 'packageManager', - message: 'Package manager:', - choices: [ - { name: 'pnpm (recommended)', value: 'pnpm' }, - { name: 'npm', value: 'npm' }, - { name: 'yarn', value: 'yarn' }, - ], - default: 'pnpm', - }, - ]); - - const { skipGit } = await inquirer.prompt<{ skipGit: boolean }>([ - { - type: 'confirm', - name: 'skipGit', - message: 'Initialize git repository?', - default: true, - // Invert the response since we ask "Initialize" but store "skip" - transformer: (value: boolean) => !value, - }, - ]); - - const { skipInstall } = await inquirer.prompt<{ skipInstall: boolean }>([ - { - type: 'confirm', - name: 'skipInstall', - message: 'Install dependencies now?', - default: true, - // Invert the response - transformer: (value: boolean) => !value, - }, - ]); +async function collectBasicOptions(defaults: PromptDefaults) { + const packageManager = await promptSelectValue({ + message: 'Package manager', + options: [ + { label: 'pnpm (recommended)', value: 'pnpm' }, + { label: 'npm', value: 'npm' }, + { label: 'yarn', value: 'yarn' }, + ], + initialValue: defaults.packageManager ?? 'pnpm', + }); + + const initializeGit = await promptConfirmValue( + 'Initialize a git repository?', + !(defaults.skipGit ?? false), + ); + + const installDepsNow = await promptConfirmValue( + 'Install dependencies now?', + !(defaults.skipInstall ?? false), + ); return { packageManager, - skipGit: !skipGit, // Invert for storage - skipInstall: !skipInstall, // Invert for storage - }; + skipGit: !initializeGit, + skipInstall: !installDepsNow, + } satisfies Pick; +} + +async function promptSelectValue({ + message, + options, + initialValue, +}: { + message: string; + options: Choice[]; + initialValue?: T; +}): Promise { + const selectPrompt = select as unknown as (opts: { + message: string; + options: Choice[]; + initialValue?: T; + }) => Promise; + + const result = await selectPrompt({ + message, + options, + initialValue, + }); + + return ensureNotCancelled(result); +} + +async function promptConfirmValue( + message: string, + initialValue: boolean, +): Promise { + const result = await confirm({ + message, + initialValue, + }); + + return ensureNotCancelled(result); +} + +function ensureNotCancelled(value: T | symbol): T { + if (isCancel(value)) { + cancel('Setup cancelled.'); + throw new Error('User cancelled'); + } + + return value as T; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5d83b1..27f93ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: packages/create-tbk-app: dependencies: + '@clack/prompts': + specifier: ^0.7.0 + version: 0.7.0 chalk: specifier: ^5.3.0 version: 5.6.2 @@ -255,9 +258,6 @@ importers: handlebars: specifier: ^4.7.8 version: 4.7.8 - inquirer: - specifier: ^9.2.12 - version: 9.3.8(@types/node@18.19.76) ora: specifier: ^8.0.1 version: 8.2.0 @@ -268,9 +268,6 @@ importers: '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 - '@types/inquirer': - specifier: ^9.0.7 - version: 9.0.9 '@types/node': specifier: ^18.11.18 version: 18.19.76 @@ -525,6 +522,14 @@ packages: '@bull-board/ui@5.23.0': resolution: {integrity: sha512-iI/Ssl8T5ZEn9s899Qz67m92M6RU8thf/aqD7cUHB2yHmkCjqbw7s7NaODTsyArAsnyu7DGJMWm7EhbfFXDNgQ==} + '@clack/core@0.3.5': + resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} + + '@clack/prompts@0.7.0': + resolution: {integrity: sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==} + bundledDependencies: + - is-unicode-supported + '@emnapi/runtime@1.3.1': resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} @@ -1253,19 +1258,6 @@ packages: cpu: [x64] os: [win32] - '@inquirer/external-editor@1.0.2': - resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.14': - resolution: {integrity: sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==} - engines: {node: '>=18'} - '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} @@ -1957,9 +1949,6 @@ packages: resolution: {integrity: sha512-vjpjevMaxtrtdrrV/TQNIFT7mKL8nvIKG7G/LjMDZdVvqRxRg5SNfGkeuSaowVc0rbK8xDA2d/Etunyb5GyzzA==} deprecated: This is a stub types definition for http-status-codes (https://github.com/prettymuchbryce/node-http-status). http-status-codes provides its own type definitions, so you don\'t need @types/http-status-codes installed! - '@types/inquirer@9.0.9': - resolution: {integrity: sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2017,9 +2006,6 @@ packages: '@types/swagger-ui-express@4.1.8': resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} - '@types/through@0.0.33': - resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} - '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -2170,10 +2156,6 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2369,9 +2351,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2392,10 +2371,6 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -3175,10 +3150,6 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: - resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3204,10 +3175,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inquirer@9.3.8: - resolution: {integrity: sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==} - engines: {node: '>=18'} - internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3679,10 +3646,6 @@ packages: msgpackr@1.11.2: resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} - mute-stream@1.0.0: - resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -4143,10 +4106,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4267,6 +4226,9 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4509,10 +4471,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -4657,10 +4615,6 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4711,10 +4665,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} @@ -5308,6 +5258,17 @@ snapshots: dependencies: '@bull-board/api': 5.23.0(@bull-board/ui@5.23.0) + '@clack/core@0.3.5': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.7.0': + dependencies: + '@clack/core': 0.3.5 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@emnapi/runtime@1.3.1': dependencies: tslib: 2.8.1 @@ -5718,15 +5679,6 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true - '@inquirer/external-editor@1.0.2(@types/node@18.19.76)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.0 - optionalDependencies: - '@types/node': 18.19.76 - - '@inquirer/figures@1.0.14': {} - '@ioredis/commands@1.2.0': {} '@isaacs/cliui@8.0.2': @@ -6468,11 +6420,6 @@ snapshots: dependencies: http-status-codes: 2.3.0 - '@types/inquirer@9.0.9': - dependencies: - '@types/through': 0.0.33 - rxjs: 7.8.2 - '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -6537,10 +6484,6 @@ snapshots: '@types/express': 4.17.21 '@types/serve-static': 1.15.7 - '@types/through@0.0.33': - dependencies: - '@types/node': 18.19.76 - '@types/validate-npm-package-name@4.0.2': {} '@types/validator@13.12.2': {} @@ -6736,10 +6679,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -6969,8 +6908,6 @@ snapshots: chalk@5.6.2: {} - chardet@2.1.1: {} - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -6987,8 +6924,6 @@ snapshots: cli-spinners@2.9.2: {} - cli-width@4.1.0: {} - client-only@0.0.1: {} cliui@8.0.1: @@ -7971,10 +7906,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.0: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} ignore@5.3.2: {} @@ -7995,23 +7926,6 @@ snapshots: ini@1.3.8: {} - inquirer@9.3.8(@types/node@18.19.76): - dependencies: - '@inquirer/external-editor': 1.0.2(@types/node@18.19.76) - '@inquirer/figures': 1.0.14 - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 1.0.0 - ora: 5.4.1 - run-async: 3.0.0 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - transitivePeerDependencies: - - '@types/node' - internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -8463,8 +8377,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - mute-stream@1.0.0: {} - mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -8986,8 +8898,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.8 fsevents: 2.3.3 - run-async@3.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -9162,6 +9072,8 @@ snapshots: is-arrayish: 0.3.2 optional: true + sisteransi@1.0.5: {} + slash@3.0.0: {} socket.io-adapter@2.5.5: @@ -9432,8 +9344,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.21.3: {} - type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -9603,12 +9513,6 @@ snapshots: wordwrap@1.0.0: {} - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9647,6 +9551,4 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: {} - zod@3.24.2: {} diff --git a/src/modules/blog/blog.controller.ts b/src/modules/blog/blog.controller.ts deleted file mode 100644 index c5581a2..0000000 --- a/src/modules/blog/blog.controller.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { Request } from 'express'; -import type { MongoIdSchemaType } from '@/common/common.schema'; -import type { ResponseExtended } from '@/types'; -import { successResponse } from '@/utils/response.utils'; -import type { - CreateBlogSchemaType, - GetBlogsSchemaType, - UpdateBlogSchemaType, - CreateBlogResponseSchema, - GetBlogsResponseSchema, - GetBlogByIdResponseSchema, - UpdateBlogResponseSchema, -} from './blog.schema'; -import { - createBlog, - deleteBlog, - getBlogById, - getBlogs, - updateBlog, -} from './blog.services'; - -// Using new res.created() helper -export const handleCreateBlog = async ( - req: Request, - res: ResponseExtended, -) => { - const blog = await createBlog(req.body); - return res.created?.({ - success: true, - message: 'Blog created successfully', - data: blog, - }); -}; - -// Using new res.ok() helper with paginated response -export const handleGetBlogs = async ( - req: Request, - res: ResponseExtended, -) => { - const { results, paginatorInfo } = await getBlogs(req.query); - return res.ok?.({ - success: true, - data: { - items: results, - paginator: paginatorInfo, - }, - }); -}; - -// Using new res.ok() helper -export const handleGetBlogById = async ( - req: Request, - res: ResponseExtended, -) => { - const blog = await getBlogById(req.params.id); - return res.ok?.({ - success: true, - data: blog, - }); -}; - -// Using new res.ok() helper -export const handleUpdateBlog = async ( - req: Request, - res: ResponseExtended, -) => { - const blog = await updateBlog(req.params.id, req.body); - return res.ok?.({ - success: true, - message: 'Blog updated successfully', - data: blog, - }); -}; - -// Keeping legacy pattern for comparison -export const handleDeleteBlog = async ( - req: Request, - res: ResponseExtended, -) => { - await deleteBlog({ id: req.params.id }); - return successResponse(res, 'Blog deleted successfully'); -}; diff --git a/src/modules/blog/blog.dto.ts b/src/modules/blog/blog.dto.ts deleted file mode 100644 index 2184b6d..0000000 --- a/src/modules/blog/blog.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; -import { definePaginatedResponse } from '../../common/common.utils'; - -export const blogOutSchema = z.object({ - name: z.string(), - description: z.string().optional(), - createdAt: z.date().optional(), - updatedAt: z.date().optional(), -}); - -export const blogSchema = blogOutSchema.extend({ - user: z.any(), -}); - -export const blogsPaginatedSchema = definePaginatedResponse(blogOutSchema); - -export type BlogModelType = z.infer; -export type BlogType = z.infer & { id: string; _id: string }; -export type BlogPaginatedType = z.infer; diff --git a/src/modules/blog/blog.model.ts b/src/modules/blog/blog.model.ts deleted file mode 100644 index e65fc00..0000000 --- a/src/modules/blog/blog.model.ts +++ /dev/null @@ -1,15 +0,0 @@ -import mongoose, { type Document, Schema } from 'mongoose'; -import type { BlogModelType, BlogType } from './blog.dto'; - -const BlogSchema: Schema = new Schema( - { - name: { type: String, required: true }, - description: { type: String }, - user: { type: Schema.Types.ObjectId, ref: 'User', required: true }, - }, - { timestamps: true }, -); - -export interface IBlogDocument extends Document, BlogModelType {} -const Blog = mongoose.model('Blog', BlogSchema); -export default Blog; diff --git a/src/modules/blog/blog.router.ts b/src/modules/blog/blog.router.ts deleted file mode 100644 index ffafb5c..0000000 --- a/src/modules/blog/blog.router.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mongoIdSchema } from '../../common/common.schema'; -import { canAccess } from '@/middlewares/can-access'; -import MagicRouter from '@/plugins/magic/router'; -import { - handleCreateBlog, - handleDeleteBlog, - handleGetBlogById, - handleGetBlogs, - handleUpdateBlog, -} from './blog.controller'; -import { - createBlogSchema, - getBlogsSchema, - updateBlogSchema, - createBlogResponseSchema, - getBlogsResponseSchema, - getBlogByIdResponseSchema, - updateBlogResponseSchema, -} from './blog.schema'; - -export const BLOG_ROUTER_ROOT = '/blogs'; - -const blogRouter = new MagicRouter(BLOG_ROUTER_ROOT); - -// List blogs with pagination (using new response system) -blogRouter.get( - '/', - { - requestType: { query: getBlogsSchema }, - responses: { - 200: getBlogsResponseSchema, - }, - }, - canAccess(), - handleGetBlogs, -); - -// Create blog (using new response system) -blogRouter.post( - '/', - { - requestType: { body: createBlogSchema }, - responses: { - 201: createBlogResponseSchema, - }, - }, - canAccess(), - handleCreateBlog, -); - -// Get blog by ID (using new response system) -blogRouter.get( - '/:id', - { - requestType: { params: mongoIdSchema }, - responses: { - 200: getBlogByIdResponseSchema, - }, - }, - canAccess(), - handleGetBlogById, -); - -// Update blog (using new response system) -blogRouter.patch( - '/:id', - { - requestType: { - params: mongoIdSchema, - body: updateBlogSchema, - }, - responses: { - 200: updateBlogResponseSchema, - }, - }, - canAccess(), - handleUpdateBlog, -); - -// Delete blog (keeping legacy pattern for comparison) -blogRouter.delete('/:id', {}, canAccess(), handleDeleteBlog); - -export default blogRouter.getRouter(); diff --git a/src/modules/blog/blog.schema.ts b/src/modules/blog/blog.schema.ts deleted file mode 100644 index 9b0b09e..0000000 --- a/src/modules/blog/blog.schema.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from 'zod'; -import { R } from '@/plugins/magic/response.builders'; -import { blogOutSchema } from './blog.dto'; - -export const createBlogSchema = z.object({ - name: z.string({ required_error: 'Name is required' }).min(1), - description: z.string().optional(), -}); - -export const updateBlogSchema = z.object({ - name: z.string().min(1).optional(), - description: z.string().optional(), -}); - -export const getBlogsSchema = z.object({ - searchString: z.string().optional(), - limitParam: z - .string() - .default('10') - .refine( - (value) => !Number.isNaN(Number(value)) && Number(value) >= 0, - 'Input must be positive integer', - ) - .transform(Number), - pageParam: z - .string() - .default('1') - .refine( - (value) => !Number.isNaN(Number(value)) && Number(value) >= 0, - 'Input must be positive integer', - ) - .transform(Number), -}); - -export type CreateBlogSchemaType = z.infer; -export type UpdateBlogSchemaType = z.infer; -export type GetBlogsSchemaType = z.infer; - -// Response schemas -export const createBlogResponseSchema = R.success(blogOutSchema); -export const getBlogsResponseSchema = R.paginated(blogOutSchema); -export const getBlogByIdResponseSchema = R.success(blogOutSchema); -export const updateBlogResponseSchema = R.success(blogOutSchema); - -// Response types -export type CreateBlogResponseSchema = z.infer; -export type GetBlogsResponseSchema = z.infer; -export type GetBlogByIdResponseSchema = z.infer< - typeof getBlogByIdResponseSchema ->; -export type UpdateBlogResponseSchema = z.infer; diff --git a/src/modules/blog/blog.services.ts b/src/modules/blog/blog.services.ts deleted file mode 100644 index aaea30e..0000000 --- a/src/modules/blog/blog.services.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { FilterQuery } from "mongoose"; -import type { MongoIdSchemaType } from "@/common/common.schema"; -import { getPaginator } from "@/utils/pagination.utils"; -import type { BlogType } from "./blog.dto"; -import Blog, { type IBlogDocument } from "./blog.model"; -import type { CreateBlogSchemaType, GetBlogsSchemaType, UpdateBlogSchemaType } from "./blog.schema"; - -export const createBlog = async ( - payload: CreateBlogSchemaType, -): Promise => { - const createdBlog = await Blog.create(payload); - return createdBlog.toObject(); -}; - -export const getBlogById = async (blogId: string): Promise => { - const blog = await Blog.findById(blogId); - - if (!blog) { - throw new Error("Blog not found"); - } - - return blog.toObject(); -}; - -export const updateBlog = async ( - blogId: string, - payload: UpdateBlogSchemaType, -): Promise => { - const blog = await Blog.findByIdAndUpdate( - blogId, - { $set: payload }, - { new: true }, - ); - - if (!blog) { - throw new Error("Blog not found"); - } - - return blog.toObject(); -}; - -export const deleteBlog = async (blogId: MongoIdSchemaType): Promise => { - const blog = await Blog.findByIdAndDelete(blogId.id); - - if (!blog) { - throw new Error("Blog not found"); - } -}; - -export const getBlogs = async ( - payload: GetBlogsSchemaType, -) => { - const conditions: FilterQuery = {}; - - if (payload.searchString) { - conditions.$or = [ - { name: { $regex: payload.searchString, $options: "i" } }, - { description: { $regex: payload.searchString, $options: "i" } }, - ]; - } - - const totalRecords = await Blog.countDocuments(conditions); - const paginatorInfo = getPaginator( - payload.limitParam, - payload.pageParam, - totalRecords, - ); - - const results = await Blog.find(conditions) - .limit(paginatorInfo.limit) - .skip(paginatorInfo.skip) - .exec(); - - return { - results, - paginatorInfo, - }; -}; diff --git a/src/modules/blog/factories/blog.factory.ts b/src/modules/blog/factories/blog.factory.ts deleted file mode 100644 index 3efd5c4..0000000 --- a/src/modules/blog/factories/blog.factory.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Types } from 'mongoose'; -import type { BlogModelType, BlogType } from '../blog.dto'; -import { createBlog } from '../blog.services'; - -type Overrides = Partial & Record; - -export const blogFactory = { - build(i = 1, overrides: Overrides = {}): BlogModelType { - return { - name: `Name ${i}`, - description: `Description ${i}`, - user: new Types.ObjectId() as any, - _id: new Types.ObjectId() as any - , ...overrides - } as unknown as BlogModelType; - }, - - async create(i = 1, overrides: Overrides = {}): Promise { - const payload = this.build(i, overrides); - // Prefer service function when available - return await createBlog(payload as any); - }, - - async createMany(count: number, overrides: Overrides = {}): Promise { - const out: BlogType[] = []; - for (let i = 1; i <= count; i += 1) out.push(await this.create(i, overrides)); - return out; - }, -}; \ No newline at end of file diff --git a/src/modules/blog/seeders/BlogSeeder.ts b/src/modules/blog/seeders/BlogSeeder.ts deleted file mode 100644 index cfa362e..0000000 --- a/src/modules/blog/seeders/BlogSeeder.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Seeder } from '@/seeders/types'; -import Blog from '../blog.model'; -import { blogFactory } from '../factories/blog.factory'; - -export const BlogSeeder: Seeder = { - name: 'BlogSeeder', - groups: ['base','dev','test'], - dependsOn: ["UserSeeder"], - collections: [Blog.collection.collectionName], - async run(ctx) { - if (ctx.env.group === 'dev' || ctx.env.group === 'test') { - const existing = await Blog.countDocuments({ name: { $regex: /^Name \d+$/ } }); - if (existing === 0) { - const docs = await blogFactory.createMany(5, { - user: (ctx.refs.has('user:seeded') ? ctx.refs.get('user:seeded')[0] : undefined) as any - }); - ctx.refs.set('blog:seeded', docs.map((d: any) => String(d._id))); - } else { - const ids = (await Blog.find({}).select('_id').lean()).map((d: any) => String(d._id)); - ctx.refs.set('blog:seeded', ids); - } - } - }, -}; \ No newline at end of file diff --git a/src/routes/routes.ts b/src/routes/routes.ts index bc7d0e1..c88d78e 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,6 +1,5 @@ import express from 'express'; import authRouter, { AUTH_ROUTER_ROOT } from '../modules/auth/auth.router'; -import blogRouter, { BLOG_ROUTER_ROOT } from '../modules/blog/blog.router'; import userRouter, { USER_ROUTER_ROOT } from '../modules/user/user.router'; import uploadRouter, { @@ -15,7 +14,6 @@ const router = express.Router(); router.use(HEALTH_ROUTER_ROOT, healthCheckRouter); router.use(USER_ROUTER_ROOT, userRouter); router.use(AUTH_ROUTER_ROOT, authRouter); -router.use(BLOG_ROUTER_ROOT, blogRouter); router.use(UPLOAD_ROUTER_ROOT, uploadRouter); export default router;