diff --git a/README.md b/README.md index 34d5be6..f0f5658 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,11 @@ The setup wizard will guide you through project creation and next steps. Make su During setup, choose a profile: -- **Recommended**: Full app features + Docker deployment assets + CD workflow. -- **Platform-First**: Full app features, no deployment assets. -- **Custom**: Pick app features and deployment options independently. +- **Recommended**: Core app with everything included. +- **Platform-Agnostic**: Core app without dockerfiles. +- **Modular**: Core app with features of your choice. + +If you choose **Modular**, the wizard opens feature customization before scaffolding. **What's running after setup:** diff --git a/apps/api/test/helpers/test-db.ts b/apps/api/test/helpers/test-db.ts index 26f5ab8..fa0bf0b 100644 --- a/apps/api/test/helpers/test-db.ts +++ b/apps/api/test/helpers/test-db.ts @@ -1,7 +1,6 @@ -import 'dotenv-flow/config'; - import { PrismaPg } from '@prisma/adapter-pg'; import { execSync } from 'child_process'; +import dotenvFlow from 'dotenv-flow'; import { Pool } from 'pg'; import { PrismaClient } from '@/generated/client/client.js'; @@ -9,6 +8,8 @@ import { PrismaClient } from '@/generated/client/client.js'; let prisma: PrismaClient | null = null; let pool: Pool | null = null; +dotenvFlow.config({ silent: true }); + /** * Get test database URL * Uses DATABASE_URL from env but appends _test suffix to database name diff --git a/create-blitzpack/README.md b/create-blitzpack/README.md index 7990b92..5bd00b5 100644 --- a/create-blitzpack/README.md +++ b/create-blitzpack/README.md @@ -31,9 +31,11 @@ pnpm create blitzpack [project-name] [options] The scaffold wizard supports three profiles: -- **Recommended**: All app features plus Docker deployment assets and CD workflow. -- **Platform-First**: All app features, without deployment assets. -- **Custom**: Pick app features and deployment options independently. +- **Recommended**: Core app with everything included. +- **Platform-Agnostic**: Core app without dockerfiles. +- **Modular**: Core app with features of your choice. + +Each profile is a setup path. The **Modular** path opens full feature customization before files are created. ## Requirements diff --git a/create-blitzpack/package.json b/create-blitzpack/package.json index 725385b..bd22c1e 100644 --- a/create-blitzpack/package.json +++ b/create-blitzpack/package.json @@ -1,6 +1,6 @@ { "name": "create-blitzpack", - "version": "0.1.18", + "version": "0.1.20", "description": "Create a new Blitzpack project - full-stack TypeScript monorepo with Next.js and Fastify", "type": "module", "bin": { @@ -16,18 +16,17 @@ "clean": "rm -rf dist" }, "dependencies": { + "@clack/prompts": "^1.0.0", "chalk": "^5.6.2", "commander": "^13.1.0", "fs-extra": "^11.3.3", "giget": "^2.0.0", "ora": "^8.2.0", - "prompts": "^2.4.2", "validate-npm-package-name": "^6.0.2" }, "devDependencies": { "@types/fs-extra": "^11.0.4", "@types/node": "^22.19.3", - "@types/prompts": "^2.4.9", "@types/validate-npm-package-name": "^4.0.2", "tsup": "^8.5.1", "typescript": "^5.9.3" diff --git a/create-blitzpack/src/checks.ts b/create-blitzpack/src/checks.ts index 5210924..5e9d39f 100644 --- a/create-blitzpack/src/checks.ts +++ b/create-blitzpack/src/checks.ts @@ -1,10 +1,15 @@ import chalk from 'chalk'; import { execSync } from 'child_process'; +import ora from 'ora'; + +import { isDockerInstalled } from './docker.js'; +import { isGitInstalled } from './git.js'; interface CheckResult { passed: boolean; name: string; - message?: string; + required: boolean; + message: string; } function checkNodeVersion(): CheckResult { @@ -16,18 +21,22 @@ function checkNodeVersion(): CheckResult { return { passed: true, name: 'Node.js', + required: true, + message: nodeVersion, }; } return { passed: false, name: 'Node.js', + required: true, message: `Node.js >= 20.0.0 required (found ${nodeVersion})`, }; } catch { return { passed: false, name: 'Node.js', + required: true, message: 'Failed to check Node.js version', }; } @@ -35,48 +44,101 @@ function checkNodeVersion(): CheckResult { function checkPnpmInstalled(): CheckResult { try { - execSync('pnpm --version', { stdio: 'ignore' }); + const version = execSync('pnpm --version', { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); return { passed: true, name: 'pnpm', + required: true, + message: `v${version}`, }; } catch { return { passed: false, name: 'pnpm', + required: true, message: 'pnpm not found. Install: npm install -g pnpm', }; } } +function checkGit(): CheckResult { + const installed = isGitInstalled(); + return { + passed: installed, + name: 'git', + required: false, + message: installed + ? 'available (repository initialization supported)' + : 'not found (git init step will be skipped)', + }; +} + +function checkDocker(): CheckResult { + const installed = isDockerInstalled(); + return { + passed: installed, + name: 'Docker', + required: false, + message: installed + ? 'available (automatic local DB setup supported)' + : 'not found (start PostgreSQL separately)', + }; +} + export async function runPreflightChecks(): Promise { console.log(); - console.log(chalk.bold(' Checking requirements...')); + console.log(chalk.bold(' System readiness')); + console.log(chalk.dim(' Validating required and optional local tooling...')); console.log(); - const checks: CheckResult[] = [checkNodeVersion(), checkPnpmInstalled()]; + const checks: CheckResult[] = [ + checkNodeVersion(), + checkPnpmInstalled(), + checkGit(), + checkDocker(), + ]; - let hasErrors = false; + const requiredFailures: CheckResult[] = []; + const optionalWarnings: CheckResult[] = []; for (const check of checks) { + const spinner = ora(`Checking ${check.name}...`).start(); + if (check.passed) { - console.log(chalk.green(' ✔'), check.name); + spinner.succeed(chalk.bold(check.name)); } else { - hasErrors = true; - console.log(chalk.red(' ✖'), check.name); - if (check.message) { - console.log(chalk.dim(` ${check.message}`)); + if (check.required) { + requiredFailures.push(check); + spinner.fail(chalk.bold(check.name)); + } else { + optionalWarnings.push(check); + spinner.warn(chalk.bold(check.name)); } + console.log(chalk.dim(` ${check.message}`)); } } console.log(); - if (hasErrors) { + if (optionalWarnings.length > 0) { + console.log(chalk.yellow(' Optional tools missing:')); + for (const warning of optionalWarnings) { + console.log(chalk.dim(` • ${warning.name}: ${warning.message}`)); + } + console.log(); + } + + if (requiredFailures.length > 0) { console.log( chalk.red(' ✖'), - 'Requirements not met. Please fix the errors above.' + 'Required dependencies are missing. Fix the items below and try again:' ); + for (const failure of requiredFailures) { + console.log(chalk.dim(` • ${failure.name}: ${failure.message}`)); + } console.log(); return false; } diff --git a/create-blitzpack/src/commands/create.ts b/create-blitzpack/src/commands/create.ts index 0f4d078..cc00404 100644 --- a/create-blitzpack/src/commands/create.ts +++ b/create-blitzpack/src/commands/create.ts @@ -1,9 +1,9 @@ +import { confirm, isCancel } from '@clack/prompts'; import chalk from 'chalk'; import { spawn } from 'child_process'; import fs from 'fs-extra'; -import ora from 'ora'; +import ora, { type Ora } from 'ora'; import path from 'path'; -import prompts from 'prompts'; import { runPreflightChecks } from '../checks.js'; import type { FeatureOptions } from '../constants.js'; @@ -41,12 +41,60 @@ function runInstall(cwd: string): Promise { }); } +function installGitHooks(cwd: string): Promise { + return new Promise((resolve) => { + const isWindows = process.platform === 'win32'; + const child = spawn(isWindows ? 'pnpm.cmd' : 'pnpm', ['exec', 'husky'], { + cwd, + stdio: 'ignore', + }); + child.on('close', (code) => resolve(code === 0)); + child.on('error', () => resolve(false)); + }); +} + interface CreateFlags { skipGit?: boolean; skipInstall?: boolean; dryRun?: boolean; } +function renderProgressBar(current: number, total: number): string { + const width = 28; + const clampedTotal = Math.max(total, 1); + const ratio = Math.min(Math.max(current / clampedTotal, 0), 1); + const filled = Math.round(width * ratio); + const empty = width - filled; + const filledBar = chalk.cyan('█'.repeat(filled)); + const emptyBar = chalk.dim('░'.repeat(empty)); + const percentage = `${Math.round(ratio * 100)}`.padStart(3, ' '); + return `[${filledBar}${emptyBar}] ${percentage}%`; +} + +function renderStepTrack(step: number, total: number): string { + const segments: string[] = []; + + for (let index = 1; index <= total; index++) { + if (index < step) { + segments.push(chalk.green('●')); + } else if (index === step) { + segments.push(chalk.cyan('◆')); + } else { + segments.push(chalk.dim('◇')); + } + } + + return segments.join(chalk.dim('──')); +} + +function printStepHeader(step: number, total: number, title: string): void { + const completed = step - 1; + console.log(); + console.log(chalk.cyan(` Step ${step}/${total}`), chalk.bold(title)); + console.log(` ${renderProgressBar(completed, total)}`); + console.log(` ${renderStepTrack(step, total)}`); +} + function printDryRun(options: { projectName: string; projectSlug: string; @@ -92,12 +140,12 @@ function printDryRun(options: { console.log(` ${chalk.dim('•')} Download template from GitHub`); console.log(` ${chalk.dim('•')} Transform package.json files`); console.log(` ${chalk.dim('•')} Create .env.local files`); - if (!options.skipGit) { - console.log(` ${chalk.dim('•')} Initialize git repository`); - } if (!options.skipInstall) { console.log(` ${chalk.dim('•')} Install dependencies (pnpm install)`); } + if (!options.skipGit) { + console.log(` ${chalk.dim('•')} Initialize git repository`); + } console.log(); } @@ -139,14 +187,12 @@ export async function create( const files = await fs.readdir(targetDir); if (files.length > 0) { if (options.useCurrentDir) { - const { confirm } = await prompts({ - type: 'confirm', - name: 'confirm', - message: `Current directory is not empty. Continue?`, - initial: false, + const shouldContinue = await confirm({ + message: 'Current directory is not empty. Continue?', + initialValue: false, }); - if (!confirm) { + if (isCancel(shouldContinue) || !shouldContinue) { return; } } else { @@ -156,13 +202,25 @@ export async function create( } } - const spinner = ora(); + const shouldRunSetup = await promptAutomaticSetup(); + const totalSteps = + 2 + + (options.skipGit ? 0 : 1) + + (options.skipInstall ? 0 : 1) + + (shouldRunSetup ? 1 : 0); + let currentStep = 0; + let spinner: Ora | undefined; + let installSucceeded = false; try { - spinner.start('Downloading template from GitHub...'); + currentStep += 1; + printStepHeader(currentStep, totalSteps, 'Scaffold template'); + spinner = ora('Downloading template from GitHub...').start(); await downloadAndPrepareTemplate(targetDir, spinner, options.features); - spinner.start('Configuring project...'); + currentStep += 1; + printStepHeader(currentStep, totalSteps, 'Configure project files'); + spinner.start('Applying template transforms...'); await transformFiles( targetDir, { @@ -175,20 +233,12 @@ export async function create( await copyEnvFiles(targetDir); spinner.succeed('Configured project'); - if (!options.skipGit && isGitInstalled()) { - spinner.start('Initializing git repository...'); - const gitSuccess = initGit(targetDir); - if (gitSuccess) { - spinner.succeed('Initialized git repository'); - } else { - spinner.warn('Failed to initialize git repository'); - } - } - if (!options.skipInstall) { + currentStep += 1; + printStepHeader(currentStep, totalSteps, 'Install dependencies'); spinner.start('Installing dependencies...'); - const success = await runInstall(targetDir); - if (success) { + installSucceeded = await runInstall(targetDir); + if (installSucceeded) { spinner.succeed('Installed dependencies'); } else { spinner.warn( @@ -197,12 +247,39 @@ export async function create( } } - // Prompt for automatic setup + if (!options.skipGit && isGitInstalled()) { + currentStep += 1; + printStepHeader(currentStep, totalSteps, 'Initialize git repository'); + spinner.start('Initializing git repository...'); + const gitSuccess = initGit(targetDir); + if (gitSuccess) { + if (installSucceeded) { + spinner.start('Installing git hooks...'); + const hooksSuccess = await installGitHooks(targetDir); + if (hooksSuccess) { + spinner.succeed('Initialized git repository'); + } else { + spinner.warn( + 'Initialized git repository, but failed to install git hooks' + ); + } + } else { + spinner.succeed('Initialized git repository'); + } + } else { + spinner.warn('Failed to initialize git repository'); + } + } else if (!options.skipGit) { + currentStep += 1; + printStepHeader(currentStep, totalSteps, 'Initialize git repository'); + spinner.warn('Skipped git initialization (git not installed)'); + } + let ranAutomaticSetup = false; - const shouldRunSetup = await promptAutomaticSetup(); if (shouldRunSetup) { - console.log(); + currentStep += 1; + printStepHeader(currentStep, totalSteps, 'Run local database setup'); spinner.start('Starting PostgreSQL database...'); const dockerSuccess = runDockerCompose(targetDir); if (dockerSuccess) { @@ -231,7 +308,7 @@ export async function create( ranAutomaticSetup ); } catch (error) { - spinner.fail(); + spinner?.fail(); printError( error instanceof Error ? error.message : 'Unknown error occurred' ); diff --git a/create-blitzpack/src/constants.ts b/create-blitzpack/src/constants.ts index b8ea632..fa3ca2e 100644 --- a/create-blitzpack/src/constants.ts +++ b/create-blitzpack/src/constants.ts @@ -117,3 +117,51 @@ export interface FeatureOptions { dockerDeploy: boolean; ciCd: boolean; } + +export type ProjectProfileKey = 'recommended' | 'platformAgnostic' | 'modular'; + +export interface ProjectProfile { + key: ProjectProfileKey; + name: string; + description: string; + defaultFeatures: FeatureOptions; +} + +export const PROJECT_PROFILES: ProjectProfile[] = [ + { + key: 'recommended', + name: 'Recommended', + description: 'Core app with everything included', + defaultFeatures: { + testing: true, + admin: true, + uploads: true, + dockerDeploy: true, + ciCd: true, + }, + }, + { + key: 'platformAgnostic', + name: 'Platform-Agnostic', + description: 'Core app without dockerfiles', + defaultFeatures: { + testing: true, + admin: true, + uploads: true, + dockerDeploy: false, + ciCd: false, + }, + }, + { + key: 'modular', + name: 'Modular', + description: 'Core app with features of your choice', + defaultFeatures: { + testing: false, + admin: false, + uploads: false, + dockerDeploy: false, + ciCd: false, + }, + }, +]; diff --git a/create-blitzpack/src/prompts.ts b/create-blitzpack/src/prompts.ts index 0c5184a..b21188b 100644 --- a/create-blitzpack/src/prompts.ts +++ b/create-blitzpack/src/prompts.ts @@ -1,11 +1,22 @@ +import { + cancel, + confirm, + isCancel, + multiselect, + note, + select, + text, +} from '@clack/prompts'; import chalk from 'chalk'; -import prompts from 'prompts'; import { APP_FEATURES, DEFAULT_DESCRIPTION, DEPLOYMENT_FEATURES, + type FeatureKey, type FeatureOptions, + PROJECT_PROFILES, + type ProjectProfileKey, } from './constants.js'; import { isDockerRunning } from './docker.js'; import { getCurrentDirName, toSlug, validateProjectName } from './utils.js'; @@ -20,56 +31,123 @@ export interface ProjectOptions { features: FeatureOptions; } -export async function getProjectOptions( - providedName?: string, - flags: { skipGit?: boolean; skipInstall?: boolean } = {} -): Promise { - const questions: prompts.PromptObject[] = []; - - if (!providedName) { - questions.push({ - type: 'text', - name: 'projectName', - message: 'Project name:', - initial: 'my-app', - validate: (value: string) => { - const result = validateProjectName(value); - if (!result.valid) { - return result.problems?.[0] || 'Invalid project name'; - } - return true; - }, - }); - } +interface WizardState { + projectNameInput: string; + projectDescription: string; + profileKey: ProjectProfileKey; + selectedFeatures: FeatureKey[]; +} + +type WizardStage = 'details' | 'profile' | 'features' | 'review'; - questions.push({ - type: 'text', - name: 'projectDescription', - message: 'Project description:', - initial: DEFAULT_DESCRIPTION, - }); - - let cancelled = false; - const response = await prompts(questions, { - onCancel: () => { - cancelled = true; - }, - }); - - if (cancelled) { +const FEATURE_LABELS: Record = { + testing: 'Testing', + admin: 'Admin Dashboard', + uploads: 'File Uploads', + dockerDeploy: 'Docker deploy assets', + ciCd: 'CD workflow', +}; + +const FEATURE_HINTS: Record = { + testing: 'Vitest, integration tests, and test helpers', + admin: 'Admin routes, dashboard views, and management hooks', + uploads: 'Upload APIs, storage service, and UI upload components', + dockerDeploy: 'API/Web Dockerfiles and production Docker Compose', + ciCd: 'GitHub Actions workflow for image build and publish', +}; + +function handleCancelledPrompt(value: T | symbol): T | null { + if (isCancel(value)) { + cancel('Setup cancelled.'); return null; } + return value as T; +} - const projectName = providedName || response.projectName; - const validation = validateProjectName(projectName); +function getEnabledFeatureKeys(features: FeatureOptions): FeatureKey[] { + return (Object.keys(features) as FeatureKey[]).filter((key) => features[key]); +} - if (!validation.valid) { - console.log(`Invalid project name: ${validation.problems?.[0]}`); - return null; +function deriveProfileFeatureSelection( + profileKey: ProjectProfileKey +): FeatureKey[] { + const profile = + PROJECT_PROFILES.find((item) => item.key === profileKey) ?? + PROJECT_PROFILES[0]; + return getEnabledFeatureKeys(profile.defaultFeatures); +} + +function resolveFeatureSelection(selected: FeatureKey[]): FeatureKey[] { + const unique = Array.from(new Set(selected)); + const hasCiCd = unique.includes('ciCd'); + const hasDockerDeploy = unique.includes('dockerDeploy'); + + if (hasCiCd && !hasDockerDeploy) { + return [...unique.filter((key) => key !== 'dockerDeploy'), 'dockerDeploy']; } - const features = await promptFeatureSelection(); - if (!features) { + return unique; +} + +function buildFeatureOptions(selectedFeatures: FeatureKey[]): FeatureOptions { + const normalized = resolveFeatureSelection(selectedFeatures); + + return { + testing: normalized.includes('testing'), + admin: normalized.includes('admin'), + uploads: normalized.includes('uploads'), + dockerDeploy: normalized.includes('dockerDeploy'), + ciCd: normalized.includes('ciCd'), + }; +} + +function printWizardStep(step: number, total: number, title: string): void { + console.log(); + console.log(chalk.bold(` Step ${step}/${total}`), chalk.dim(title)); + console.log(); +} + +function getStepTotal(profileKey: ProjectProfileKey): number { + return profileKey === 'modular' ? 4 : 2; +} + +function printMultiselectControls(): void { + console.log( + chalk.dim(' Controls: ↑/↓ navigate • Space toggle • Enter confirm') + ); + console.log(); +} + +function printConfigurationSummary( + profileName: string, + features: FeatureOptions +): void { + const lines = [ + `${chalk.dim('Profile')}: ${profileName}`, + `${chalk.dim('Testing')}: ${features.testing ? 'yes' : 'no'}`, + `${chalk.dim('Admin dashboard')}: ${features.admin ? 'yes' : 'no'}`, + `${chalk.dim('File uploads')}: ${features.uploads ? 'yes' : 'no'}`, + `${chalk.dim('Docker deploy assets')}: ${features.dockerDeploy ? 'yes' : 'no'}`, + `${chalk.dim('CD workflow')}: ${features.ciCd ? 'yes' : 'no'}`, + ]; + + note(lines.join('\n'), chalk.cyan('Configuration summary')); +} + +function finalizeOptions( + state: WizardState, + providedName: string | undefined, + flags: { skipGit?: boolean; skipInstall?: boolean } +): ProjectOptions | null { + const projectName = providedName || state.projectNameInput; + const validation = validateProjectName(projectName); + + if (!validation.valid) { + console.log(); + console.log( + chalk.red(' ✖'), + validation.problems?.[0] ?? 'Invalid project name' + ); return null; } @@ -79,149 +157,252 @@ export async function getProjectOptions( return { projectName: actualProjectName, projectSlug: toSlug(actualProjectName), - projectDescription: response.projectDescription || DEFAULT_DESCRIPTION, + projectDescription: state.projectDescription || DEFAULT_DESCRIPTION, skipGit: flags.skipGit || false, skipInstall: flags.skipInstall || false, useCurrentDir, - features, + features: buildFeatureOptions(state.selectedFeatures), }; } -async function promptFeatureSelection(): Promise { - let cancelled = false; - - const { setupType } = await prompts( - { - type: 'select', - name: 'setupType', - message: 'Project profile:', - choices: [ - { - title: 'Recommended', - description: 'all app features + Docker deploy assets + CD workflow', - value: 'recommended', - }, - { - title: 'Platform-First', - description: 'all app features, no deployment assets', - value: 'platform', - }, - { - title: 'Custom', - description: 'choose app and deployment features', - value: 'customize', - }, - ], - initial: 0, - hint: '- Use arrow-keys, Enter to submit', - }, - { - onCancel: () => { - cancelled = true; - }, +export async function getProjectOptions( + providedName?: string, + flags: { skipGit?: boolean; skipInstall?: boolean } = {} +): Promise { + const initialProjectName = providedName || 'my-app'; + const initialProfileKey: ProjectProfileKey = 'recommended'; + + const state: WizardState = { + projectNameInput: initialProjectName, + projectDescription: DEFAULT_DESCRIPTION, + profileKey: initialProfileKey, + selectedFeatures: deriveProfileFeatureSelection(initialProfileKey), + }; + + let stage: WizardStage = 'details'; + + while (true) { + if (stage === 'details') { + printWizardStep(1, getStepTotal(state.profileKey), 'Project details'); + + if (!providedName) { + const projectNameInput = handleCancelledPrompt( + await text({ + message: chalk.cyan('Project name'), + initialValue: state.projectNameInput, + validate: (value) => { + if (typeof value !== 'string') { + return 'Project name is required'; + } + const result = validateProjectName(value); + return result.valid + ? undefined + : (result.problems?.[0] ?? 'Invalid project name'); + }, + }) + ); + if (projectNameInput === null) { + return null; + } + state.projectNameInput = projectNameInput; + } else { + console.log(chalk.dim(' Project name:'), chalk.white(providedName)); + } + + const descriptionInput = handleCancelledPrompt( + await text({ + message: chalk.cyan('Project description'), + initialValue: state.projectDescription, + }) + ); + if (descriptionInput === null) { + return null; + } + state.projectDescription = descriptionInput; + stage = 'profile'; + continue; } - ); - if (cancelled) { - return null; - } + if (stage === 'profile') { + printWizardStep( + 2, + getStepTotal(state.profileKey), + 'Choose a setup preset' + ); - if (setupType === 'recommended') { - return { - testing: true, - admin: true, - uploads: true, - dockerDeploy: true, - ciCd: true, - }; - } + const profileAction = handleCancelledPrompt( + await select({ + message: chalk.cyan('Setup preset'), + options: [ + ...PROJECT_PROFILES.map((profile) => ({ + value: profile.key, + label: profile.name, + hint: profile.description, + })), + { + value: '__back__', + label: 'Back', + hint: 'Return to project details', + }, + ], + initialValue: state.profileKey, + }) + ); + if (profileAction === null) { + return null; + } - if (setupType === 'platform') { - return { - testing: true, - admin: true, - uploads: true, - dockerDeploy: false, - ciCd: false, - }; - } + if (profileAction === '__back__') { + stage = 'details'; + continue; + } - const appFeatureChoices = APP_FEATURES.map((feature) => ({ - title: feature.name, - description: feature.description, - value: feature.key, - selected: true, - })); - - const { selectedAppFeatures } = await prompts( - { - type: 'multiselect', - name: 'selectedAppFeatures', - message: 'Select app features:', - choices: appFeatureChoices, - hint: '- Space to toggle, Enter to confirm', - instructions: false, - }, - { - onCancel: () => { - cancelled = true; - }, + state.profileKey = profileAction as ProjectProfileKey; + state.selectedFeatures = deriveProfileFeatureSelection(state.profileKey); + + if (state.profileKey !== 'modular') { + const result = finalizeOptions(state, providedName, flags); + if (!result) { + stage = 'details'; + continue; + } + return result; + } + + stage = 'features'; + continue; } - ); - if (cancelled) { - return null; - } + if (stage === 'features') { + const isModular = state.profileKey === 'modular'; + printWizardStep( + 3, + 4, + isModular ? 'Select features' : 'Deployment options' + ); - const deploymentFeatureChoices = DEPLOYMENT_FEATURES.map((feature) => ({ - title: feature.name, - description: feature.description, - value: feature.key, - selected: false, - })); - - const { selectedDeploymentFeatures } = await prompts( - { - type: 'multiselect', - name: 'selectedDeploymentFeatures', - message: 'Select deployment options (optional):', - choices: deploymentFeatureChoices, - hint: '- Space to toggle, Enter to confirm', - instructions: false, - }, - { - onCancel: () => { - cancelled = true; - }, + if (isModular) { + printMultiselectControls(); + + const selectedFeatures = handleCancelledPrompt( + await multiselect({ + message: chalk.cyan('Select features'), + options: [...APP_FEATURES, ...DEPLOYMENT_FEATURES].map( + (feature) => ({ + value: feature.key, + label: FEATURE_LABELS[feature.key], + hint: FEATURE_HINTS[feature.key], + }) + ), + initialValues: state.selectedFeatures, + required: false, + }) + ); + if (selectedFeatures === null) { + return null; + } + + state.selectedFeatures = resolveFeatureSelection( + selectedFeatures as FeatureKey[] + ); + + if ( + state.selectedFeatures.includes('ciCd') && + !selectedFeatures.includes('dockerDeploy') + ) { + console.log(); + console.log( + chalk.dim( + ' ℹ CD workflow requires Docker deployment assets, enabling both.' + ) + ); + } + } + + const nextAction = handleCancelledPrompt( + await select({ + message: chalk.cyan('Continue'), + options: [ + { + value: 'review', + label: 'Review configuration', + }, + { + value: 'profile', + label: 'Back', + hint: 'Return to preset selection', + }, + ], + initialValue: 'review', + }) + ); + if (nextAction === null) { + return null; + } + + stage = nextAction as WizardStage; + continue; } - ); - if (cancelled) { - return null; - } + const profile = + PROJECT_PROFILES.find((item) => item.key === state.profileKey) ?? + PROJECT_PROFILES[0]; + const options = finalizeOptions(state, providedName, flags); - const selectedApp = selectedAppFeatures || []; - const selectedDeployment = selectedDeploymentFeatures || []; - const includesCiCd = selectedDeployment.includes('ciCd'); - const includesDockerDeploy = - selectedDeployment.includes('dockerDeploy') || includesCiCd; + if (!options) { + stage = 'details'; + continue; + } + const features = options.features; - if (includesCiCd && !selectedDeployment.includes('dockerDeploy')) { - console.log(); - console.log( - chalk.dim( - ' ℹ CD workflow requires Docker deployment assets, enabling both.' - ) + const reviewStep = state.profileKey === 'modular' ? 4 : 3; + printWizardStep( + reviewStep, + getStepTotal(state.profileKey), + 'Review and confirm' ); - } + printConfigurationSummary(profile.name, features); - return { - testing: selectedApp.includes('testing'), - admin: selectedApp.includes('admin'), - uploads: selectedApp.includes('uploads'), - dockerDeploy: includesDockerDeploy, - ciCd: includesCiCd, - }; + const reviewOptions = [ + { + value: 'create', + label: 'Create project', + }, + { + value: 'profile', + label: 'Edit preset', + }, + { + value: 'details', + label: 'Edit project details', + }, + ]; + + if (state.profileKey === 'modular') { + reviewOptions.splice(1, 0, { + value: 'features', + label: 'Edit features', + }); + } + + const reviewAction = handleCancelledPrompt( + await select({ + message: chalk.cyan('Ready to scaffold?'), + options: reviewOptions, + initialValue: 'create', + }) + ); + if (reviewAction === null) { + return null; + } + + if (reviewAction !== 'create') { + stage = reviewAction as WizardStage; + continue; + } + + return options; + } } export async function promptAutomaticSetup(): Promise { @@ -241,13 +422,16 @@ export async function promptAutomaticSetup(): Promise { } console.log(); - const { runSetup } = await prompts({ - type: 'confirm', - name: 'runSetup', - message: - 'Run local setup now? (start PostgreSQL with Docker + run migrations)', - initial: true, - }); - - return runSetup || false; + const runSetup = handleCancelledPrompt( + await confirm({ + message: 'Run local setup now? (Docker PostgreSQL + database migrations)', + initialValue: true, + }) + ); + + if (runSetup === null) { + return false; + } + + return runSetup; } diff --git a/create-blitzpack/src/template.ts b/create-blitzpack/src/template.ts index f42ed78..bddc735 100644 --- a/create-blitzpack/src/template.ts +++ b/create-blitzpack/src/template.ts @@ -15,6 +15,8 @@ const POST_DOWNLOAD_EXCLUDES = [ 'create-blitzpack', 'apps/marketing', 'CONTRIBUTING.md', + 'docs/create-blitzpack-scaffolding-maintenance-plan.md', + 'pnpm-lock.yaml', ]; function getFeatureExclusions(features: FeatureOptions): string[] { diff --git a/create-blitzpack/src/transform.ts b/create-blitzpack/src/transform.ts index 7264fe7..7fc0c77 100644 --- a/create-blitzpack/src/transform.ts +++ b/create-blitzpack/src/transform.ts @@ -181,7 +181,8 @@ ${vars.projectDescription} \`\`\`bash pnpm install -pnpm init:project +docker compose up -d +pnpm db:migrate pnpm dev \`\`\` diff --git a/package.json b/package.json index 7ab8161..cda4271 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "type": "module", "description": "Production-ready Full Stack TypeScript monorepo with Next.js and Fastify", "scripts": { - "init:project": "node scripts/setup.js", "dev": "turbo run dev", "dev:tui": "turbo run dev --ui=tui", "dev:debug": "turbo run dev --verbose", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cadecb5..4f5be69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,6 +418,9 @@ importers: create-blitzpack: dependencies: + '@clack/prompts': + specifier: ^1.0.0 + version: 1.0.0 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -433,9 +436,6 @@ importers: ora: specifier: ^8.2.0 version: 8.2.0 - prompts: - specifier: ^2.4.2 - version: 2.4.2 validate-npm-package-name: specifier: ^6.0.2 version: 6.0.2 @@ -446,9 +446,6 @@ importers: '@types/node': specifier: ^22.19.3 version: 22.19.3 - '@types/prompts': - specifier: ^2.4.9 - version: 2.4.9 '@types/validate-npm-package-name': specifier: ^4.0.2 version: 4.0.2 @@ -1135,6 +1132,18 @@ packages: integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==, } + '@clack/core@1.0.0': + resolution: + { + integrity: sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==, + } + + '@clack/prompts@1.0.0': + resolution: + { + integrity: sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==, + } + '@csstools/color-helpers@5.1.0': resolution: { @@ -4586,12 +4595,6 @@ packages: integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==, } - '@types/prompts@2.4.9': - resolution: - { - integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==, - } - '@types/react-dom@19.2.3': resolution: { @@ -6938,6 +6941,7 @@ packages: { integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==, } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.1.0: @@ -6946,6 +6950,7 @@ packages: integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==, } engines: { node: 20 || >=22 } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -11427,6 +11432,17 @@ snapshots: '@chevrotain/utils@10.5.0': {} + '@clack/core@1.0.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@1.0.0': + dependencies: + '@clack/core': 1.0.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -13559,11 +13575,6 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 - '@types/prompts@2.4.9': - dependencies: - '@types/node': 22.19.3 - kleur: 3.0.3 - '@types/react-dom@19.2.3(@types/react@19.2.6)': dependencies: '@types/react': 19.2.6 @@ -14689,7 +14700,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -14730,7 +14741,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -14752,7 +14763,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9