diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 41c3b82..020fef6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -76,7 +76,7 @@ apps/cli/src/ Single source of truth for all stacks, libraries, and project addons: - `META.stacks`: App and server stacks (Next.js, Expo, Hono, TanStack Start) with `type: 'app' | 'server'` field - `META.libraries`: Per-app addons (shadcn, better-auth, etc.) with category/support/require/mono/packageJson/envs -- `META.project`: Project-level categories (database, orm, tooling) with prompt config and options +- `META.project`: Project-level categories (database, orm, linter, tooling) with prompt config and options - `META.repo`: Repository types (single, turborepo) - Addons declare `packageJson` for dependencies and `envs` for environment variables - Category-level `require` (e.g., orm requires database) @@ -93,7 +93,7 @@ Single source of truth for all stacks, libraries, and project addons: ### types/ctx.ts - `AppContext`: `{ appName, stackName, libraries }` -- `ProjectContext`: `{ database?, orm?, tooling[] }` +- `ProjectContext`: `{ database?, orm?, linter?, tooling[] }` - `TemplateContext`: Full context with projectName, repo, apps[], project, git, pm - `PackageManager`: `'bun' | 'npm' | 'pnpm' | undefined` @@ -149,7 +149,7 @@ Custom helpers: - Logical: `eq`, `ne`, `and`, `or` - Repo: `isMono()` - Check if turborepo - Libraries: `hasLibrary(name)` - Check if current app has library -- Project: `has(category, value)` - Check database/orm/tooling/stack +- Project: `has(category, value)` - Check database/orm/linter/tooling/stack - Context: `hasContext(key)` - Check if key exists in context - Utils: `appPort(name)` - Get port for app (3000 + index) @@ -181,7 +181,8 @@ Libraries are grouped by category in the interactive prompt (UI, Content, Auth, - **ORM**: Prisma, Drizzle (both with Better Auth integration) ### Dev Tools -- **Extras**: Biome (linter/formatter), Git, Husky (requires git) +- **Linter**: Biome, ESLint (single selection) +- **Extras**: Husky (requires git) - **Repo**: Single or Turborepo (auto-determined by app count) ## Current Status @@ -230,7 +231,8 @@ templates/ ├── stack/{framework}/ # Next.js, Expo, Hono, TanStack Start ├── libraries/{library}/ # Per-app library templates ├── project/orm/{provider}/ # Prisma, Drizzle -├── project/tooling/{tool}/ # Biome, Husky +├── project/linter/{linter}/ # Biome, ESLint +├── project/tooling/{tool}/ # Husky └── repo/{type}/ # Single, Turborepo configs ``` @@ -342,9 +344,10 @@ bunx create-faster myapp \ --app myapp:nextjs:shadcn,mdx \ --database postgres \ --orm drizzle \ + --linter biome \ + --tooling husky \ --git \ - --pm bun \ - --extras biome,husky + --pm bun ``` **Multi-app (Turborepo):** @@ -355,9 +358,10 @@ bunx create-faster mysaas \ --app api:hono \ --database postgres \ --orm drizzle \ + --linter eslint \ + --tooling husky \ --git \ - --pm bun \ - --extras biome,husky + --pm bun ``` **Mixed mode (partial flags):** @@ -365,7 +369,7 @@ bunx create-faster mysaas \ bunx create-faster myapp \ --app myapp:nextjs:shadcn \ --database postgres -# Will prompt for missing options (ORM, git, pm, extras) +# Will prompt for missing options (ORM, linter, git, pm, tooling) ``` ### Available Flags @@ -381,14 +385,17 @@ bunx create-faster myapp \ - `--orm `: ORM provider (requires database) - Options: `prisma`, `drizzle` +- `--linter `: Linter + - Options: `biome`, `eslint` + +- `--tooling `: Add tooling (repeatable) + - Options: `husky` (requires git) + - `--git`: Initialize git repository - `--pm `: Package manager - Options: `bun`, `npm`, `pnpm` -- `--extras `: Comma-separated extras - - Options: `biome`, `husky` (husky requires git) - ### Auto-Generated Command After project creation, a copy-paste ready command is displayed: @@ -401,9 +408,10 @@ After project creation, a copy-paste ready command is displayed: │ --app mobile:expo:nativewind \ │ --database postgres \ │ --orm drizzle \ +│ --linter biome \ +│ --tooling husky \ │ --git \ -│ --pm bun \ -│ --extras biome,husky +│ --pm bun │ └ ``` diff --git a/README.md b/README.md index bb81b96..adcc5a5 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,12 @@ Visit https://create.plvo.dev/docs for more details. ## Key Features -- **Multiple frameworks**: Next.js, Expo, Hono +- **Multiple frameworks**: Next.js, Expo, TanStack Start, Hono - **Automatic monorepo**: Turborepo configuration for 2+ apps - **Modular system**: 11+ optional modules (shadcn/ui, Better Auth, TanStack Query, MDX, PWA, etc.) - **Database support**: PostgreSQL, MySQL with Prisma or Drizzle ORM -- **Developer tools**: Biome formatter/linter, Husky git hooks +- **Linters**: Biome or ESLint with stack-specific configs +- **Developer tools**: Husky git hooks - **Dual modes**: Interactive prompts or CLI flags for automation - **Type-safe**: Full TypeScript support with strict configuration - **Auto-generated CLI commands**: Copy-paste ready command to recreate projects @@ -37,9 +38,10 @@ bunx create-faster myapp \ --app myapp:nextjs:shadcn,tanstack-query \ --database postgres \ --orm drizzle \ + --linter biome \ + --tooling husky \ --git \ - --pm bun \ - --extras biome,husky + --pm bun ``` ### Multi-App Monorepo @@ -53,9 +55,10 @@ bunx create-faster mysaas \ --app api:hono \ --database postgres \ --orm drizzle \ + --linter eslint \ + --tooling husky \ --git \ - --pm bun \ - --extras biome,husky + --pm bun ``` diff --git a/apps/cli/src/__meta__.ts b/apps/cli/src/__meta__.ts index 624f59b..02d414d 100644 --- a/apps/cli/src/__meta__.ts +++ b/apps/cli/src/__meta__.ts @@ -382,9 +382,9 @@ export const META: Meta = { }, }, }, - tooling: { - prompt: 'Add any extras?', - selection: 'multi', + linter: { + prompt: 'Choose a linter?', + selection: 'single', options: { biome: { label: 'Biome', @@ -397,9 +397,44 @@ export const META: Meta = { scripts: { format: 'biome format --write .', lint: 'biome lint', + check: 'biome check --fix .', + }, + }, + }, + eslint: { + label: 'ESLint', + hint: 'Most popular JavaScript linter', + mono: { scope: 'pkg', name: 'eslint-config' }, + packageJson: { + devDependencies: { + eslint: '^10.0.0', + '@eslint/js': '^10.0.1', + 'typescript-eslint': '^8.55.0', + globals: '^17.3.0', + 'eslint-plugin-react': '^7.37.5', + 'eslint-plugin-react-hooks': '^7.0.1', + '@next/eslint-plugin-next': '^16.1.6', + }, + exports: { + './base': './base.js', + './next': './next.js', + './react': './react.js', + './react-native': './react-native.js', + './server': './server.js', + }, + }, + appPackageJson: { + scripts: { + lint: 'eslint .', }, }, }, + }, + }, + tooling: { + prompt: 'Add any extras?', + selection: 'multi', + options: { husky: { label: 'Husky', hint: 'Git hooks', diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index cb8f711..0cb4bce 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -77,6 +77,7 @@ ${S_GRAY_BAR} ${color.italic(color.gray('Multiple apps = Turborepo monorepo'))} const parts: string[] = []; if (ctx.project.database) parts.push(`database: ${ctx.project.database}`); if (ctx.project.orm) parts.push(`orm: ${ctx.project.orm}`); + if (ctx.project.linter) parts.push(`linter: ${ctx.project.linter}`); if (ctx.project.tooling.length > 0) parts.push(`tooling: ${ctx.project.tooling.join(', ')}`); if (parts.length > 0) { log.info(`${color.green('✓')} Using project config: ${parts.join(', ')}`); @@ -97,6 +98,9 @@ ${S_GRAY_BAR} ${color.italic(color.gray('Multiple apps = Turborepo monorepo'))} case 'orm': ctx.project.orm = result as string | undefined; break; + case 'linter': + ctx.project.linter = result as string | undefined; + break; case 'tooling': ctx.project.tooling = (result as string[]) ?? []; break; diff --git a/apps/cli/src/flags.ts b/apps/cli/src/flags.ts index 363be4a..e2e5d36 100644 --- a/apps/cli/src/flags.ts +++ b/apps/cli/src/flags.ts @@ -13,6 +13,7 @@ interface ParsedFlags { app?: string[]; database?: string; orm?: string; + linter?: string; tooling?: string[]; git?: boolean; pm?: string; @@ -24,6 +25,7 @@ export function parseFlags(): Partial { const ormNames = Object.keys(META.project.orm.options).join(', '); const dbNames = Object.keys(META.project.database.options).join(', '); + const linterNames = Object.keys(META.project.linter.options).join(', '); const toolingNames = Object.keys(META.project.tooling.options).join(', '); const libraryNames = Object.keys(META.libraries).join(', '); @@ -38,6 +40,7 @@ export function parseFlags(): Partial { .option('--app ', 'Add app (repeatable)', collect, []) .option('--database ', `Database provider (${dbNames})`) .option('--orm ', `ORM provider (${ormNames})`) + .option('--linter ', `Linter (${linterNames})`) .option('--tooling ', 'Add tooling (repeatable)', collect, []) .option('--git', 'Initialize git repository') .option('--no-git', 'Skip git initialization') @@ -59,6 +62,7 @@ ${color.bold('Examples:')} ${color.gray('Available libraries:')} ${libraryNames} ${color.gray('Available ORMs:')} ${ormNames} ${color.gray('Available databases:')} ${dbNames} + ${color.gray('Available linters:')} ${linterNames} ${color.gray('Available tooling:')} ${toolingNames} `, ) @@ -84,7 +88,7 @@ ${color.bold('Examples:')} partial.apps = flags.app.map((appFlag) => parseAppFlag(appFlag)); } - const hasProjectFlags = flags.database || flags.orm || (flags.tooling && flags.tooling.length > 0); + const hasProjectFlags = flags.database || flags.orm || flags.linter || (flags.tooling && flags.tooling.length > 0); if (hasProjectFlags) { partial.project = { tooling: [] }; } @@ -108,6 +112,17 @@ ${color.bold('Examples:')} partial.project!.orm = flags.orm; } + if (flags.linter) { + if (!META.project.linter.options[flags.linter]) { + printError( + `Invalid linter '${flags.linter}'`, + `Available linters: ${Object.keys(META.project.linter.options).join(', ')}`, + ); + process.exit(1); + } + partial.project!.linter = flags.linter; + } + if (flags.tooling && flags.tooling.length > 0) { for (const toolingName of flags.tooling) { if (!META.project.tooling.options[toolingName]) { diff --git a/apps/cli/src/lib/handlebars.ts b/apps/cli/src/lib/handlebars.ts index c8213c4..b33a79b 100644 --- a/apps/cli/src/lib/handlebars.ts +++ b/apps/cli/src/lib/handlebars.ts @@ -1,5 +1,5 @@ import Handlebars from 'handlebars'; -import type { AppContext, EnrichedTemplateContext, TemplateContext } from '@/types/ctx'; +import type { AppContext, EnrichedTemplateContext, ProjectContext, TemplateContext } from '@/types/ctx'; export function registerHandlebarsHelpers(): void { Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); @@ -19,17 +19,19 @@ export function registerHandlebarsHelpers(): void { }); Handlebars.registerHelper('has', function (this: EnrichedTemplateContext, category: string, value: string) { - switch (category) { - case 'database': - return this.project?.database === value; - case 'orm': - return this.project?.orm === value; - case 'tooling': - return Array.isArray(this.project?.tooling) && this.project.tooling.includes(value); - case 'stack': - return Array.isArray(this.apps) && this.apps.some((app) => app.stackName === value); - default: - return false; + if (category === 'stack') { + return this.apps.some((app) => app.stackName === value); + } + + if (!(category in this.project)) { + return false; + } + + const categoryValue = this.project[category as keyof ProjectContext]; + if (Array.isArray(categoryValue)) { + return categoryValue.includes(value); + } else { + return categoryValue === value; } }); diff --git a/apps/cli/src/lib/package-json-generator.ts b/apps/cli/src/lib/package-json-generator.ts index 743021b..e483792 100644 --- a/apps/cli/src/lib/package-json-generator.ts +++ b/apps/cli/src/lib/package-json-generator.ts @@ -1,6 +1,7 @@ +import { execSync } from 'node:child_process'; import { META } from '@/__meta__'; import { isLibraryCompatible } from '@/lib/addon-utils'; -import type { AppContext, TemplateContext } from '@/types/ctx'; +import type { AppContext, PackageManager, TemplateContext } from '@/types/ctx'; import type { MetaAddon, PackageJsonConfig } from '@/types/meta'; export interface PackageJson { @@ -8,6 +9,7 @@ export interface PackageJson { version: string; private?: boolean; type?: string; + packageManager?: string; workspaces?: string[]; scripts?: Record; dependencies?: Record; @@ -165,6 +167,25 @@ export function generateAppPackageJson(app: AppContext, ctx: TemplateContext, ap } } + // Process linter addon + if (ctx.project.linter) { + const linterAddon = META.project.linter.options[ctx.project.linter]; + if (linterAddon) { + const packageName = getProjectAddonPackageName(linterAddon); + if (packageName && isTurborepo) { + merged.devDependencies = { + ...merged.devDependencies, + [`@repo/${packageName}`]: '*', + }; + if (linterAddon.appPackageJson) { + merged = mergePackageJsonConfigs(merged, linterAddon.appPackageJson); + } + } else if (!isTurborepo) { + merged = mergePackageJsonConfigs(merged, linterAddon.packageJson, linterAddon.appPackageJson); + } + } + } + if (isTurborepo) { merged.devDependencies = { ...merged.devDependencies, @@ -187,10 +208,13 @@ export function generateAppPackageJson(app: AppContext, ctx: TemplateContext, ap devDependencies = stripInternalDeps(devDependencies); } + const packageManager = !isTurborepo && ctx.pm ? getPackageManager(ctx.pm) : undefined; + const pkg: PackageJson = { name: isTurborepo ? app.appName : ctx.projectName, version: '0.1.0', private: true, + packageManager, scripts: sortObjectKeys(scripts), dependencies: sortObjectKeys(dependencies), devDependencies: sortObjectKeys(devDependencies), @@ -230,6 +254,11 @@ export function generatePackagePackageJson( }; } +export function getPackageManager(pm: NonNullable): string { + const pmVersion = execSync(`${pm} --version`, { stdio: 'pipe' }).toString().trim(); + return `${pm}@${pmVersion}`; +} + export function generateRootPackageJson(ctx: TemplateContext): GeneratedPackageJson { const scripts: Record = { dev: 'turbo dev', @@ -242,6 +271,8 @@ export function generateRootPackageJson(ctx: TemplateContext): GeneratedPackageJ turbo: '^2.4.0', }; + const packageManager: string = getPackageManager(ctx.pm ?? 'npm'); + // Add tooling to root package.json for (const toolingName of ctx.project.tooling) { const toolingAddon = META.project.tooling.options[toolingName]; @@ -255,10 +286,24 @@ export function generateRootPackageJson(ctx: TemplateContext): GeneratedPackageJ } } + // Add root-scoped linter to root package.json (biome) + if (ctx.project.linter) { + const linterAddon = META.project.linter.options[ctx.project.linter]; + if (linterAddon?.mono?.scope === 'root' && linterAddon.packageJson) { + if (linterAddon.packageJson.devDependencies) { + devDependencies = { ...devDependencies, ...linterAddon.packageJson.devDependencies }; + } + if (linterAddon.packageJson.scripts) { + Object.assign(scripts, linterAddon.packageJson.scripts); + } + } + } + const pkg: PackageJson = { name: ctx.projectName, version: '0.0.0', private: true, + packageManager, workspaces: ['apps/*', 'packages/*'], scripts: sortObjectKeys(scripts), devDependencies: sortObjectKeys(devDependencies), @@ -293,6 +338,15 @@ export function generateAllPackageJsons(ctx: TemplateContext): GeneratedPackageJ } } + // Collect linter package (eslint-config) + if (ctx.project.linter) { + const linterAddon = META.project.linter.options[ctx.project.linter]; + if (linterAddon?.mono?.scope === 'pkg') { + const pkgName = linterAddon.mono.name; + extractedPackages.set(pkgName, linterAddon.packageJson ?? {}); + } + } + // Collect ORM package with database deps merged if (ctx.project.orm) { const ormAddon = META.project.orm.options[ctx.project.orm]; diff --git a/apps/cli/src/lib/template-resolver.ts b/apps/cli/src/lib/template-resolver.ts index 91b30af..fd4bb25 100644 --- a/apps/cli/src/lib/template-resolver.ts +++ b/apps/cli/src/lib/template-resolver.ts @@ -144,6 +144,9 @@ function resolveTemplatesForProjectAddon( for (const file of files) { const source = join(addonDir, file); + const { stackName: fileSuffix } = parseStackSuffix(file, VALID_STACKS); + if (fileSuffix) continue; + const { frontmatter, only } = readFrontmatter(source); if (shouldSkipTemplate(only, ctx)) continue; @@ -155,6 +158,40 @@ function resolveTemplatesForProjectAddon( return templates; } +function resolveStackSpecificAddonTemplatesForApps( + category: ProjectCategoryName, + addonName: string, + apps: { appName: string; stackName: StackName }[], + ctx: TemplateContext, +): TemplateFile[] { + const addon = META.project[category]?.options[addonName]; + if (!addon) return []; + + const addonDir = join(TEMPLATES_DIR, 'project', category, addonName); + const files = scanDirectory(addonDir); + const templates: TemplateFile[] = []; + const isTurborepo = ctx.repo === 'turborepo'; + + for (const file of files) { + const { stackName: fileSuffix, cleanFilename } = parseStackSuffix(file, VALID_STACKS); + if (!fileSuffix) continue; + + const source = join(addonDir, file); + const { only } = readFrontmatter(source); + if (shouldSkipTemplate(only, ctx)) continue; + + const transformedPath = transformFilename(cleanFilename); + + for (const app of apps) { + if (app.stackName !== fileSuffix) continue; + const destination = isTurborepo ? `apps/${app.appName}/${transformedPath}` : transformedPath; + templates.push({ source, destination }); + } + } + + return templates; +} + function resolveTemplatesForRepo(ctx: TemplateContext): TemplateFile[] { const repoDir = join(TEMPLATES_DIR, 'repo', ctx.repo); const files = scanDirectory(repoDir); @@ -188,6 +225,10 @@ export function getAllTemplatesForContext(ctx: TemplateContext): TemplateFile[] if (ctx.project.orm) { templates.push(...resolveTemplatesForProjectAddon('orm', ctx.project.orm, ctx)); } + if (ctx.project.linter) { + templates.push(...resolveTemplatesForProjectAddon('linter', ctx.project.linter, ctx)); + templates.push(...resolveStackSpecificAddonTemplatesForApps('linter', ctx.project.linter, ctx.apps, ctx)); + } for (const tooling of ctx.project.tooling) { templates.push(...resolveTemplatesForProjectAddon('tooling', tooling, ctx)); } diff --git a/apps/cli/src/tui/summary.ts b/apps/cli/src/tui/summary.ts index 0d0e173..c5132e7 100644 --- a/apps/cli/src/tui/summary.ts +++ b/apps/cli/src/tui/summary.ts @@ -1,7 +1,7 @@ import { note, outro } from '@clack/prompts'; import color from 'picocolors'; import { META } from '@/__meta__'; -import type { TemplateContext } from '@/types/ctx'; +import type { ProjectContext, TemplateContext } from '@/types/ctx'; export function displayOutroCliCommand(ctx: TemplateContext, projectPath: string): void { let flagsCommand: string = `bunx create-faster ${ctx.projectName}`; @@ -11,16 +11,14 @@ export function displayOutroCliCommand(ctx: TemplateContext, projectPath: string flagsCommand += ` --app ${app.appName}:${app.stackName}${librariesStr}`; } - if (ctx.project.database) { - flagsCommand += ` --database ${ctx.project.database}`; - } - - if (ctx.project.orm) { - flagsCommand += ` --orm ${ctx.project.orm}`; - } - - for (const toolingName of ctx.project.tooling) { - flagsCommand += ` --tooling ${toolingName}`; + for (const projectKey of Object.keys(ctx.project) as (keyof ProjectContext)[]) { + if (Array.isArray(ctx.project[projectKey])) { + for (const value of ctx.project[projectKey]) { + flagsCommand += ` --${projectKey} ${value}`; + } + } else { + flagsCommand += ` --${projectKey} ${ctx.project[projectKey]}`; + } } if (ctx.git) { @@ -87,8 +85,12 @@ function buildProjectStructure(ctx: TemplateContext): string[] { const configs: string[] = []; if (isTurborepo) configs.push('Turborepo'); if (ctx.git) configs.push('Git'); - if (ctx.project.tooling.includes('biome')) configs.push('Biome'); - if (ctx.project.tooling.includes('husky')) configs.push('Husky'); + if (ctx.project.linter) { + configs.push(META.project.linter.options[ctx.project.linter]?.label ?? ctx.project.linter); + } + for (const name of ctx.project.tooling) { + configs.push(META.project.tooling.options[name]?.label ?? name); + } if (configs.length > 0) { lines.push(`└─ ⚙️ ${color.dim(configs.join(', '))}`); diff --git a/apps/cli/src/types/ctx.ts b/apps/cli/src/types/ctx.ts index d3aadaa..8f07d5c 100644 --- a/apps/cli/src/types/ctx.ts +++ b/apps/cli/src/types/ctx.ts @@ -9,6 +9,7 @@ export interface AppContext { export interface ProjectContext { database?: string; orm?: string; + linter?: string; tooling: string[]; } diff --git a/apps/cli/src/types/meta.ts b/apps/cli/src/types/meta.ts index e3dbd0a..4d4996f 100644 --- a/apps/cli/src/types/meta.ts +++ b/apps/cli/src/types/meta.ts @@ -65,6 +65,7 @@ export interface MetaRepoStack { export interface MetaProject { database: MetaProjectCategory; orm: MetaProjectCategory; + linter: MetaProjectCategory; tooling: MetaProjectCategory; } diff --git a/apps/cli/templates/project/tooling/biome/biome.json.hbs b/apps/cli/templates/project/linter/biome/biome.json.hbs similarity index 89% rename from apps/cli/templates/project/tooling/biome/biome.json.hbs rename to apps/cli/templates/project/linter/biome/biome.json.hbs index 7b2c007..9a2771e 100644 --- a/apps/cli/templates/project/tooling/biome/biome.json.hbs +++ b/apps/cli/templates/project/linter/biome/biome.json.hbs @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "root": true, "vcs": { "enabled": false, @@ -11,9 +11,6 @@ "ignoreUnknown": true, "includes": [ "**", - "!apps/app/src/app/init/**/*", - "!packages/ui/src/components/**/*", - "!packages/ui/src/styles/**/*", "!**/*.css", "!**/.next/**/*", "!**/node_modules/**/*", diff --git a/apps/cli/templates/project/linter/eslint/base.js.hbs b/apps/cli/templates/project/linter/eslint/base.js.hbs new file mode 100644 index 0000000..d2bcf34 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/base.js.hbs @@ -0,0 +1,16 @@ +--- +only: mono +mono: + scope: pkg + path: base.js +--- +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export const baseConfig = [ + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["dist/**", "node_modules/**"], + }, +]; diff --git a/apps/cli/templates/project/linter/eslint/eslint.config.mjs.expo.hbs b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.expo.hbs new file mode 100644 index 0000000..1a96034 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.expo.hbs @@ -0,0 +1,41 @@ +{{#if (isMono)}} +import { reactNativeConfig } from "@repo/eslint-config/react-native"; + +export default reactNativeConfig; +{{else}} +import { defineConfig, globalIgnores } from "eslint/config"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; + +export default defineConfig([ + js.configs.recommended, + ...tseslint.configs.recommended, + globalIgnores([".expo/**", "android/**", "ios/**", "node_modules/**"]), + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.browser, + ...globals.serviceworker, + }, + }, + }, + pluginReact.configs.flat["jsx-runtime"], + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + }, + }, +]); +{{/if}} diff --git a/apps/cli/templates/project/linter/eslint/eslint.config.mjs.hono.hbs b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.hono.hbs new file mode 100644 index 0000000..a0ca379 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.hono.hbs @@ -0,0 +1,23 @@ +{{#if (isMono)}} +import { serverConfig } from "@repo/eslint-config/server"; + +export default serverConfig; +{{else}} +import { defineConfig, globalIgnores } from "eslint/config"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import globals from "globals"; + +export default defineConfig([ + js.configs.recommended, + ...tseslint.configs.recommended, + globalIgnores(["dist/**", "node_modules/**"]), + { + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, +]); +{{/if}} diff --git a/apps/cli/templates/project/linter/eslint/eslint.config.mjs.nextjs.hbs b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.nextjs.hbs new file mode 100644 index 0000000..dfe1229 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.nextjs.hbs @@ -0,0 +1,51 @@ +{{#if (isMono)}} +import { nextConfig } from "@repo/eslint-config/next"; + +export default nextConfig; +{{else}} +import { defineConfig, globalIgnores } from "eslint/config"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginNext from "@next/eslint-plugin-next"; +import globals from "globals"; + +export default defineConfig([ + js.configs.recommended, + ...tseslint.configs.recommended, + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts", "node_modules/**"]), + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.browser, + ...globals.serviceworker, + }, + }, + }, + pluginReact.configs.flat["jsx-runtime"], + { + plugins: { + "@next/next": pluginNext, + }, + rules: { + ...pluginNext.configs.recommended.rules, + ...pluginNext.configs["core-web-vitals"].rules, + }, + }, + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + }, + }, +]); +{{/if}} diff --git a/apps/cli/templates/project/linter/eslint/eslint.config.mjs.tanstack-start.hbs b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.tanstack-start.hbs new file mode 100644 index 0000000..e265494 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/eslint.config.mjs.tanstack-start.hbs @@ -0,0 +1,40 @@ +{{#if (isMono)}} +import { reactConfig } from "@repo/eslint-config/react"; + +export default reactConfig; +{{else}} +import { defineConfig, globalIgnores } from "eslint/config"; +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; + +export default defineConfig([ + js.configs.recommended, + ...tseslint.configs.recommended, + globalIgnores(["dist/**", ".vinxi/**", ".output/**", "node_modules/**"]), + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.browser, + }, + }, + }, + pluginReact.configs.flat["jsx-runtime"], + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + }, + }, +]); +{{/if}} diff --git a/apps/cli/templates/project/linter/eslint/next.js.hbs b/apps/cli/templates/project/linter/eslint/next.js.hbs new file mode 100644 index 0000000..351c7eb --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/next.js.hbs @@ -0,0 +1,49 @@ +--- +only: mono +mono: + scope: pkg + path: next.js +--- +import { globalIgnores } from "eslint/config"; +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import pluginNext from "@next/eslint-plugin-next"; +import globals from "globals"; +import { baseConfig } from "./base.js"; + +export const nextConfig = [ + ...baseConfig, + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]), + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.browser, + ...globals.serviceworker, + }, + }, + }, + pluginReact.configs.flat["jsx-runtime"], + { + plugins: { + "@next/next": pluginNext, + }, + rules: { + ...pluginNext.configs.recommended.rules, + ...pluginNext.configs["core-web-vitals"].rules, + }, + }, + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + }, + }, +]; diff --git a/apps/cli/templates/project/linter/eslint/react-native.js.hbs b/apps/cli/templates/project/linter/eslint/react-native.js.hbs new file mode 100644 index 0000000..f6c0830 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/react-native.js.hbs @@ -0,0 +1,40 @@ +--- +only: mono +mono: + scope: pkg + path: react-native.js +--- +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import { baseConfig } from "./base.js"; + +export const reactNativeConfig = [ + ...baseConfig, + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.browser, + ...globals.serviceworker, + }, + }, + }, + pluginReact.configs.flat["jsx-runtime"], + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + }, + }, + { + ignores: [".expo/**", "android/**", "ios/**"], + }, +]; diff --git a/apps/cli/templates/project/linter/eslint/react.js.hbs b/apps/cli/templates/project/linter/eslint/react.js.hbs new file mode 100644 index 0000000..51883c1 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/react.js.hbs @@ -0,0 +1,36 @@ +--- +only: mono +mono: + scope: pkg + path: react.js +--- +import pluginReact from "eslint-plugin-react"; +import pluginReactHooks from "eslint-plugin-react-hooks"; +import globals from "globals"; +import { baseConfig } from "./base.js"; + +export const reactConfig = [ + ...baseConfig, + { + ...pluginReact.configs.flat.recommended, + languageOptions: { + ...pluginReact.configs.flat.recommended.languageOptions, + globals: { + ...globals.browser, + }, + }, + }, + pluginReact.configs.flat["jsx-runtime"], + { + plugins: { + "react-hooks": pluginReactHooks, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...pluginReactHooks.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + }, + }, +]; diff --git a/apps/cli/templates/project/linter/eslint/server.js.hbs b/apps/cli/templates/project/linter/eslint/server.js.hbs new file mode 100644 index 0000000..3e69e57 --- /dev/null +++ b/apps/cli/templates/project/linter/eslint/server.js.hbs @@ -0,0 +1,19 @@ +--- +only: mono +mono: + scope: pkg + path: server.js +--- +import globals from "globals"; +import { baseConfig } from "./base.js"; + +export const serverConfig = [ + ...baseConfig, + { + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, +]; diff --git a/apps/cli/templates/stack/nextjs/tsconfig.json.hbs b/apps/cli/templates/stack/nextjs/tsconfig.json.hbs index 733c318..a37b2ea 100644 --- a/apps/cli/templates/stack/nextjs/tsconfig.json.hbs +++ b/apps/cli/templates/stack/nextjs/tsconfig.json.hbs @@ -16,7 +16,7 @@ "resolveJsonModule": true, "isolatedModules": true, "verbatimModuleSyntax": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -34,6 +34,6 @@ "#/*": ["./*"] } }, - "include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "exclude": ["./node_modules"] } diff --git a/apps/cli/tests/integration/cli.test.ts b/apps/cli/tests/integration/cli.test.ts index b01f990..06a2aa1 100644 --- a/apps/cli/tests/integration/cli.test.ts +++ b/apps/cli/tests/integration/cli.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { join } from 'node:path'; -import { cleanupTempDir, createTempDir, fileExists, readJsonFile, runCli } from './helpers'; +import { cleanupTempDir, createTempDir, fileExists, readJsonFile, readTextFile, runCli } from './helpers'; describe('CLI Integration', () => { let tempDir: string; @@ -171,4 +171,128 @@ describe('CLI Integration', () => { expect(dbPkg.dependencies.pg).toBeDefined(); }); }); + + describe('ESLint linter', () => { + test('generates ESLint config for single repo', async () => { + const projectName = 'test-eslint-single'; + const projectPath = join(tempDir, projectName); + + const result = await runCli( + [projectName, '--app', `${projectName}:nextjs`, '--linter', 'eslint', '--no-git', '--no-install'], + tempDir, + ); + + expect(result.exitCode).toBe(0); + + expect(await fileExists(join(projectPath, 'eslint.config.mjs'))).toBe(true); + expect(await fileExists(join(projectPath, 'packages'))).toBe(false); + + const config = await readTextFile(join(projectPath, 'eslint.config.mjs')); + expect(config).toContain('defineConfig'); + expect(config).toContain('pluginNext'); + expect(config).not.toContain('@repo/eslint-config'); + + const pkg = await readJsonFile<{ + devDependencies: Record; + scripts: Record; + }>(join(projectPath, 'package.json')); + expect(pkg.devDependencies.eslint).toBeDefined(); + expect(pkg.devDependencies['@eslint/js']).toBeDefined(); + expect(pkg.devDependencies['typescript-eslint']).toBeDefined(); + expect(pkg.devDependencies['eslint-plugin-react']).toBeDefined(); + expect(pkg.devDependencies['@next/eslint-plugin-next']).toBeDefined(); + expect(pkg.scripts.lint).toBe('eslint .'); + }); + + test('generates ESLint shared config for turborepo', async () => { + const projectName = 'test-eslint-turbo'; + const projectPath = join(tempDir, projectName); + + const result = await runCli( + [projectName, '--app', 'web:nextjs', '--app', 'api:hono', '--linter', 'eslint', '--no-git', '--no-install'], + tempDir, + ); + + expect(result.exitCode).toBe(0); + + // Shared eslint-config package + expect(await fileExists(join(projectPath, 'packages/eslint-config/package.json'))).toBe(true); + expect(await fileExists(join(projectPath, 'packages/eslint-config/base.js'))).toBe(true); + expect(await fileExists(join(projectPath, 'packages/eslint-config/next.js'))).toBe(true); + expect(await fileExists(join(projectPath, 'packages/eslint-config/server.js'))).toBe(true); + expect(await fileExists(join(projectPath, 'packages/eslint-config/react.js'))).toBe(true); + expect(await fileExists(join(projectPath, 'packages/eslint-config/react-native.js'))).toBe(true); + + const eslintPkg = await readJsonFile<{ + name: string; + exports: Record; + devDependencies: Record; + }>(join(projectPath, 'packages/eslint-config/package.json')); + expect(eslintPkg.name).toBe('@repo/eslint-config'); + expect(eslintPkg.exports['./base']).toBe('./base.js'); + expect(eslintPkg.exports['./next']).toBe('./next.js'); + expect(eslintPkg.devDependencies.eslint).toBeDefined(); + + // Per-app thin configs + const webConfig = await readTextFile(join(projectPath, 'apps/web/eslint.config.mjs')); + expect(webConfig).toContain('nextConfig'); + expect(webConfig).toContain('@repo/eslint-config/next'); + + const apiConfig = await readTextFile(join(projectPath, 'apps/api/eslint.config.mjs')); + expect(apiConfig).toContain('serverConfig'); + expect(apiConfig).toContain('@repo/eslint-config/server'); + + // App package.jsons reference shared config + const webPkg = await readJsonFile<{ + devDependencies: Record; + scripts: Record; + }>(join(projectPath, 'apps/web/package.json')); + expect(webPkg.devDependencies['@repo/eslint-config']).toBe('*'); + expect(webPkg.scripts.lint).toBe('eslint .'); + + // Root does NOT have eslint deps + const rootPkg = await readJsonFile<{ + devDependencies: Record; + }>(join(projectPath, 'package.json')); + expect(rootPkg.devDependencies.eslint).toBeUndefined(); + }); + + test('generates correct ESLint config per stack in turborepo', async () => { + const projectName = 'test-eslint-stacks'; + const projectPath = join(tempDir, projectName); + + const result = await runCli( + [ + projectName, + '--app', + 'web:nextjs', + '--app', + 'mobile:expo', + '--app', + 'api:hono', + '--app', + 'start:tanstack-start', + '--linter', + 'eslint', + '--no-git', + '--no-install', + ], + tempDir, + ); + + expect(result.exitCode).toBe(0); + + const webConfig = await readTextFile(join(projectPath, 'apps/web/eslint.config.mjs')); + expect(webConfig).toContain('nextConfig'); + + const mobileConfig = await readTextFile(join(projectPath, 'apps/mobile/eslint.config.mjs')); + expect(mobileConfig).toContain('reactNativeConfig'); + + const apiConfig = await readTextFile(join(projectPath, 'apps/api/eslint.config.mjs')); + expect(apiConfig).toContain('serverConfig'); + + const startConfig = await readTextFile(join(projectPath, 'apps/start/eslint.config.mjs')); + expect(startConfig).toContain('reactConfig'); + }); + }); }); diff --git a/apps/cli/tests/unit/lib/addon-utils.test.ts b/apps/cli/tests/unit/lib/addon-utils.test.ts index 8ca1dd0..33c28d9 100644 --- a/apps/cli/tests/unit/lib/addon-utils.test.ts +++ b/apps/cli/tests/unit/lib/addon-utils.test.ts @@ -35,8 +35,8 @@ describe('getProjectAddon', () => { expect(addon?.label).toBe('Drizzle'); }); - test('gets addon from tooling category', () => { - const addon = getProjectAddon('tooling', 'biome'); + test('gets addon from linter category', () => { + const addon = getProjectAddon('linter', 'biome'); expect(addon?.label).toBe('Biome'); }); diff --git a/apps/cli/tests/unit/lib/env-generator.test.ts b/apps/cli/tests/unit/lib/env-generator.test.ts index 2d62d12..592c0b8 100644 --- a/apps/cli/tests/unit/lib/env-generator.test.ts +++ b/apps/cli/tests/unit/lib/env-generator.test.ts @@ -13,7 +13,8 @@ function makeContext(overrides: Partial = {}): TemplateContext project: { database: 'postgres', orm: 'drizzle', - tooling: ['biome'], + linter: 'biome', + tooling: [], }, git: true, pm: 'bun', @@ -108,7 +109,7 @@ describe('collectEnvFiles', () => { test('returns empty array when no addons have envs', () => { const ctx = makeContext({ - project: { tooling: ['biome'] }, + project: { linter: 'biome', tooling: [] }, apps: [{ appName: 'web', stackName: 'nextjs', libraries: [] }], }); const files = collectEnvFiles(ctx); diff --git a/apps/cli/tests/unit/lib/handlebars.test.ts b/apps/cli/tests/unit/lib/handlebars.test.ts index 368be61..67d3c46 100644 --- a/apps/cli/tests/unit/lib/handlebars.test.ts +++ b/apps/cli/tests/unit/lib/handlebars.test.ts @@ -115,10 +115,16 @@ describe('Handlebars helpers', () => { expect(template({ project: { orm: 'prisma', tooling: [] } })).toBe('no'); }); + test('checks linter value', () => { + const template = Handlebars.compile('{{#if (has "linter" "biome")}}yes{{else}}no{{/if}}'); + expect(template({ project: { linter: 'biome', tooling: [] } })).toBe('yes'); + expect(template({ project: { linter: 'eslint', tooling: [] } })).toBe('no'); + }); + test('checks tooling array', () => { - const template = Handlebars.compile('{{#if (has "tooling" "biome")}}yes{{else}}no{{/if}}'); - expect(template({ project: { tooling: ['biome', 'husky'] } })).toBe('yes'); - expect(template({ project: { tooling: ['husky'] } })).toBe('no'); + const template = Handlebars.compile('{{#if (has "tooling" "husky")}}yes{{else}}no{{/if}}'); + expect(template({ project: { tooling: ['husky'] } })).toBe('yes'); + expect(template({ project: { tooling: [] } })).toBe('no'); }); test('checks stack existence in apps', () => { diff --git a/apps/cli/tests/unit/lib/package-json-generator.test.ts b/apps/cli/tests/unit/lib/package-json-generator.test.ts index 1867250..48ef9fc 100644 --- a/apps/cli/tests/unit/lib/package-json-generator.test.ts +++ b/apps/cli/tests/unit/lib/package-json-generator.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { generateAllPackageJsons, generateAppPackageJson, mergePackageJsonConfigs } from '@/lib/package-json-generator'; +import { + generateAllPackageJsons, + generateAppPackageJson, + generateRootPackageJson, + getPackageManager, + mergePackageJsonConfigs, +} from '@/lib/package-json-generator'; import type { TemplateContext } from '@/types/ctx'; describe('mergePackageJsonConfigs', () => { @@ -60,7 +66,7 @@ describe('generateAppPackageJson (single repo)', () => { projectName: 'test-single', repo: 'single', apps: [{ appName: 'test-single', stackName: 'nextjs', libraries: ['shadcn'] }], - project: { database: 'postgres', orm: 'drizzle', tooling: ['biome'] }, + project: { database: 'postgres', orm: 'drizzle', linter: 'biome', tooling: [] }, git: true, }; @@ -83,7 +89,7 @@ describe('generateAppPackageJson (single repo)', () => { expect(result.content.scripts?.['db:generate']).toBeDefined(); }); - test('includes tooling extras', () => { + test('includes linter dependencies', () => { const result = generateAppPackageJson(ctx.apps[0], ctx, 0); expect(result.content.devDependencies?.['@biomejs/biome']).toBeDefined(); expect(result.content.scripts?.format).toBeDefined(); @@ -136,7 +142,7 @@ describe('internal @repo/* dependencies (turborepo)', () => { { appName: 'web', stackName: 'nextjs', libraries: ['shadcn', 'better-auth'] }, { appName: 'api', stackName: 'hono', libraries: [] }, ], - project: { database: 'postgres', orm: 'drizzle', tooling: ['biome'] }, + project: { database: 'postgres', orm: 'drizzle', linter: 'biome', tooling: [] }, git: true, }; @@ -201,7 +207,7 @@ describe('internal @repo/* dependencies (turborepo)', () => { projectName: 'test-single', repo: 'single', apps: [{ appName: 'test-single', stackName: 'nextjs', libraries: ['shadcn'] }], - project: { database: 'postgres', orm: 'drizzle', tooling: ['biome'] }, + project: { database: 'postgres', orm: 'drizzle', linter: 'biome', tooling: [] }, git: true, }; @@ -282,3 +288,224 @@ describe('appPackageJson for pkg-scoped libraries', () => { expect(repoRefs).toEqual([]); }); }); + +describe('ESLint linter (turborepo)', () => { + function findByPath(results: ReturnType, path: string) { + return results.find((r) => r.path === path); + } + + const ctx: TemplateContext = { + projectName: 'test-eslint', + repo: 'turborepo', + apps: [ + { appName: 'web', stackName: 'nextjs', libraries: [] }, + { appName: 'api', stackName: 'hono', libraries: [] }, + ], + project: { linter: 'eslint', tooling: [] }, + git: true, + }; + + test('generates eslint-config shared package', () => { + const results = generateAllPackageJsons(ctx); + const eslintPkg = findByPath(results, 'packages/eslint-config/package.json'); + + expect(eslintPkg).toBeDefined(); + expect(eslintPkg?.content.name).toBe('@repo/eslint-config'); + }); + + test('eslint-config package has all devDependencies', () => { + const results = generateAllPackageJsons(ctx); + const eslintPkg = findByPath(results, 'packages/eslint-config/package.json'); + + expect(eslintPkg?.content.devDependencies?.eslint).toBeDefined(); + expect(eslintPkg?.content.devDependencies?.['@eslint/js']).toBeDefined(); + expect(eslintPkg?.content.devDependencies?.['typescript-eslint']).toBeDefined(); + expect(eslintPkg?.content.devDependencies?.globals).toBeDefined(); + expect(eslintPkg?.content.devDependencies?.['eslint-plugin-react']).toBeDefined(); + }); + + test('eslint-config package has exports', () => { + const results = generateAllPackageJsons(ctx); + const eslintPkg = findByPath(results, 'packages/eslint-config/package.json'); + + expect(eslintPkg?.content.exports?.['./base']).toBe('./base.js'); + expect(eslintPkg?.content.exports?.['./next']).toBe('./next.js'); + expect(eslintPkg?.content.exports?.['./server']).toBe('./server.js'); + }); + + test('apps reference @repo/eslint-config', () => { + const results = generateAllPackageJsons(ctx); + const web = findByPath(results, 'apps/web/package.json'); + const api = findByPath(results, 'apps/api/package.json'); + + expect(web?.content.devDependencies?.['@repo/eslint-config']).toBe('*'); + expect(api?.content.devDependencies?.['@repo/eslint-config']).toBe('*'); + }); + + test('apps have lint script from appPackageJson', () => { + const results = generateAllPackageJsons(ctx); + const web = findByPath(results, 'apps/web/package.json'); + const api = findByPath(results, 'apps/api/package.json'); + + expect(web?.content.scripts?.lint).toBe('eslint .'); + expect(api?.content.scripts?.lint).toBe('eslint .'); + }); + + test('root package.json does NOT have eslint devDependencies', () => { + const results = generateAllPackageJsons(ctx); + const root = findByPath(results, 'package.json'); + + expect(root?.content.devDependencies?.eslint).toBeUndefined(); + expect(root?.content.devDependencies?.['@eslint/js']).toBeUndefined(); + }); +}); + +describe('ESLint linter (single repo)', () => { + const ctx: TemplateContext = { + projectName: 'test-eslint-single', + repo: 'single', + apps: [{ appName: 'test-eslint-single', stackName: 'nextjs', libraries: [] }], + project: { linter: 'eslint', tooling: [] }, + git: true, + }; + + test('includes all eslint deps directly in package.json', () => { + const result = generateAppPackageJson(ctx.apps[0], ctx, 0); + + expect(result.content.devDependencies?.eslint).toBeDefined(); + expect(result.content.devDependencies?.['@eslint/js']).toBeDefined(); + expect(result.content.devDependencies?.['typescript-eslint']).toBeDefined(); + expect(result.content.devDependencies?.globals).toBeDefined(); + expect(result.content.devDependencies?.['eslint-plugin-react']).toBeDefined(); + expect(result.content.devDependencies?.['eslint-plugin-react-hooks']).toBeDefined(); + expect(result.content.devDependencies?.['@next/eslint-plugin-next']).toBeDefined(); + }); + + test('includes lint script', () => { + const result = generateAppPackageJson(ctx.apps[0], ctx, 0); + expect(result.content.scripts?.lint).toBe('eslint .'); + }); + + test('does not have @repo/eslint-config reference', () => { + const result = generateAppPackageJson(ctx.apps[0], ctx, 0); + expect(result.content.devDependencies?.['@repo/eslint-config']).toBeUndefined(); + }); + + test('does not generate eslint-config package', () => { + const results = generateAllPackageJsons(ctx); + expect(results).toHaveLength(1); + expect(results[0].path).toBe('package.json'); + }); +}); + +describe('getPackageManager', () => { + test('returns bun@ format', () => { + const result = getPackageManager('bun'); + expect(result).toMatch(/^bun@\d+\.\d+\.\d+/); + }); + + test('returns npm@ format', () => { + const result = getPackageManager('npm'); + expect(result).toMatch(/^npm@\d+\.\d+\.\d+/); + }); +}); + +describe('generateRootPackageJson', () => { + const ctx: TemplateContext = { + projectName: 'test-root', + repo: 'turborepo', + apps: [{ appName: 'web', stackName: 'nextjs', libraries: [] }], + project: { tooling: [] }, + git: true, + pm: 'bun', + }; + + test('includes packageManager field', () => { + const result = generateRootPackageJson(ctx); + expect(result.content.packageManager).toBeDefined(); + expect(result.content.packageManager).toMatch(/^bun@\d+\.\d+\.\d+/); + }); + + test('defaults to npm when pm is undefined', () => { + const ctxNoPm: TemplateContext = { ...ctx, pm: undefined }; + const result = generateRootPackageJson(ctxNoPm); + expect(result.content.packageManager).toMatch(/^npm@\d+\.\d+\.\d+/); + }); + + test('includes turbo scripts', () => { + const result = generateRootPackageJson(ctx); + expect(result.content.scripts?.dev).toBe('turbo dev'); + expect(result.content.scripts?.build).toBe('turbo build'); + }); + + test('includes biome scripts and deps when linter is biome', () => { + const ctxBiome: TemplateContext = { + ...ctx, + project: { linter: 'biome', tooling: [] }, + }; + const result = generateRootPackageJson(ctxBiome); + expect(result.content.devDependencies?.['@biomejs/biome']).toBeDefined(); + expect(result.content.scripts?.format).toBeDefined(); + expect(result.content.scripts?.check).toBeDefined(); + }); + + test('biome overwrites turbo lint with biome lint at root', () => { + const ctxBiome: TemplateContext = { + ...ctx, + project: { linter: 'biome', tooling: [] }, + }; + const result = generateRootPackageJson(ctxBiome); + expect(result.content.scripts?.lint).toBe('biome lint'); + }); + + test('does NOT include eslint deps at root when linter is eslint', () => { + const ctxEslint: TemplateContext = { + ...ctx, + project: { linter: 'eslint', tooling: [] }, + }; + const result = generateRootPackageJson(ctxEslint); + expect(result.content.devDependencies?.eslint).toBeUndefined(); + expect(result.content.devDependencies?.['@eslint/js']).toBeUndefined(); + }); +}); + +describe('packageManager in single repo', () => { + test('includes packageManager when pm is set', () => { + const ctx: TemplateContext = { + projectName: 'test-single', + repo: 'single', + apps: [{ appName: 'test-single', stackName: 'nextjs', libraries: [] }], + project: { tooling: [] }, + git: true, + pm: 'bun', + }; + const result = generateAppPackageJson(ctx.apps[0], ctx, 0); + expect(result.content.packageManager).toMatch(/^bun@\d+\.\d+\.\d+/); + }); + + test('omits packageManager when pm is undefined', () => { + const ctx: TemplateContext = { + projectName: 'test-single', + repo: 'single', + apps: [{ appName: 'test-single', stackName: 'nextjs', libraries: [] }], + project: { tooling: [] }, + git: true, + pm: undefined, + }; + const result = generateAppPackageJson(ctx.apps[0], ctx, 0); + expect(result.content.packageManager).toBeUndefined(); + }); + + test('does not add packageManager to turborepo app package.json', () => { + const ctx: TemplateContext = { + projectName: 'test-turbo', + repo: 'turborepo', + apps: [{ appName: 'web', stackName: 'nextjs', libraries: [] }], + project: { tooling: [] }, + git: true, + pm: 'bun', + }; + const result = generateAppPackageJson(ctx.apps[0], ctx, 0); + expect(result.content.packageManager).toBeUndefined(); + }); +}); diff --git a/apps/cli/tests/unit/lib/template-resolver.test.ts b/apps/cli/tests/unit/lib/template-resolver.test.ts index 1f0e937..c11ee11 100644 --- a/apps/cli/tests/unit/lib/template-resolver.test.ts +++ b/apps/cli/tests/unit/lib/template-resolver.test.ts @@ -9,7 +9,7 @@ describe('resolveLibraryDestination', () => { projectName: 'test', repo: 'turborepo', apps: [{ appName: 'web', stackName: 'nextjs', libraries: ['shadcn'] }], - project: { database: 'postgres', orm: 'drizzle', tooling: ['biome'] }, + project: { database: 'postgres', orm: 'drizzle', linter: 'biome', tooling: [] }, git: true, }; @@ -17,7 +17,7 @@ describe('resolveLibraryDestination', () => { projectName: 'test', repo: 'single', apps: [{ appName: 'test', stackName: 'nextjs', libraries: ['shadcn'] }], - project: { database: 'postgres', orm: 'drizzle', tooling: ['biome'] }, + project: { database: 'postgres', orm: 'drizzle', linter: 'biome', tooling: [] }, git: true, }; @@ -90,8 +90,8 @@ describe('resolveProjectAddonDestination', () => { expect(result).toBe('src/lib/db/schema.ts'); }); - test('tooling addon goes to root', () => { - const addon = META.project.tooling.options.biome; + test('linter addon goes to root', () => { + const addon = META.project.linter.options.biome; const result = resolveProjectAddonDestination('biome.json', addon, turborepoCtx, {}); expect(result).toBe('biome.json'); }); @@ -102,4 +102,23 @@ describe('resolveProjectAddonDestination', () => { const result = resolveProjectAddonDestination('drizzle.config.ts', addon, turborepoCtx, fm); expect(result).toBe('drizzle.config.ts'); }); + + test('eslint addon goes to packages/eslint-config in turborepo', () => { + const addon = META.project.linter.options.eslint; + const result = resolveProjectAddonDestination('base.js', addon, turborepoCtx, {}); + expect(result).toBe('packages/eslint-config/base.js'); + }); + + test('eslint addon uses frontmatter mono path in turborepo', () => { + const addon = META.project.linter.options.eslint; + const fm: TemplateFrontmatter = { mono: { scope: 'pkg', path: 'next.js' } }; + const result = resolveProjectAddonDestination('next.js', addon, turborepoCtx, fm); + expect(result).toBe('packages/eslint-config/next.js'); + }); + + test('eslint addon uses file-based path in single repo', () => { + const addon = META.project.linter.options.eslint; + const result = resolveProjectAddonDestination('base.js', addon, singleCtx, {}); + expect(result).toBe('base.js'); + }); }); diff --git a/apps/cli/tests/unit/meta.test.ts b/apps/cli/tests/unit/meta.test.ts index 8c4609c..b7e0336 100644 --- a/apps/cli/tests/unit/meta.test.ts +++ b/apps/cli/tests/unit/meta.test.ts @@ -44,16 +44,61 @@ describe('META.project validation', () => { expect(META.project.orm.options.prisma).toBeDefined(); }); + test('linter category is single-select', () => { + expect(META.project.linter).toBeDefined(); + expect(META.project.linter.selection).toBe('single'); + expect(META.project.linter.options.biome).toBeDefined(); + expect(META.project.linter.options.eslint).toBeDefined(); + }); + + test('eslint has pkg-scoped mono with eslint-config name', () => { + const eslint = META.project.linter.options.eslint; + expect(eslint.mono?.scope).toBe('pkg'); + if (eslint.mono?.scope === 'pkg') { + expect(eslint.mono.name).toBe('eslint-config'); + } + }); + + test('eslint has all expected devDependencies', () => { + const eslint = META.project.linter.options.eslint; + const devDeps = eslint.packageJson?.devDependencies; + expect(devDeps?.eslint).toBeDefined(); + expect(devDeps?.['@eslint/js']).toBeDefined(); + expect(devDeps?.['typescript-eslint']).toBeDefined(); + expect(devDeps?.globals).toBeDefined(); + expect(devDeps?.['eslint-plugin-react']).toBeDefined(); + expect(devDeps?.['eslint-plugin-react-hooks']).toBeDefined(); + expect(devDeps?.['@next/eslint-plugin-next']).toBeDefined(); + }); + + test('eslint has shared config exports', () => { + const exports = META.project.linter.options.eslint.packageJson?.exports; + expect(exports?.['./base']).toBeDefined(); + expect(exports?.['./next']).toBeDefined(); + expect(exports?.['./react']).toBeDefined(); + expect(exports?.['./react-native']).toBeDefined(); + expect(exports?.['./server']).toBeDefined(); + }); + + test('eslint has appPackageJson with lint script', () => { + const eslint = META.project.linter.options.eslint; + expect(eslint.appPackageJson?.scripts?.lint).toBe('eslint .'); + }); + + test('biome has root-scoped mono', () => { + const biome = META.project.linter.options.biome; + expect(biome.mono?.scope).toBe('root'); + }); + test('tooling category is multi-select', () => { expect(META.project.tooling).toBeDefined(); expect(META.project.tooling.selection).toBe('multi'); - expect(META.project.tooling.options.biome).toBeDefined(); expect(META.project.tooling.options.husky).toBeDefined(); }); - test('project category order is database, orm, tooling', () => { + test('project category order is database, orm, linter, tooling', () => { const keys = Object.keys(META.project); - expect(keys).toEqual(['database', 'orm', 'tooling']); + expect(keys).toEqual(['database', 'orm', 'linter', 'tooling']); }); }); @@ -105,6 +150,6 @@ describe('META env vars', () => { test('addons without env vars have no envs field', () => { expect(META.libraries.shadcn.envs).toBeUndefined(); expect(META.libraries['tanstack-query'].envs).toBeUndefined(); - expect(META.project.tooling.options.biome.envs).toBeUndefined(); + expect(META.project.linter.options.biome.envs).toBeUndefined(); }); }); diff --git a/apps/cli/tests/unit/types/ctx.test.ts b/apps/cli/tests/unit/types/ctx.test.ts index 4f0c235..f99587f 100644 --- a/apps/cli/tests/unit/types/ctx.test.ts +++ b/apps/cli/tests/unit/types/ctx.test.ts @@ -17,11 +17,12 @@ describe('ProjectContext types', () => { const project: ProjectContext = { database: 'postgres', orm: 'drizzle', - tooling: ['biome', 'husky'], + linter: 'biome', + tooling: ['husky'], }; expect(project.database).toBe('postgres'); expect(project.orm).toBe('drizzle'); - expect(project.tooling).toContain('biome'); + expect(project.linter).toBe('biome'); }); test('ProjectContext database and orm are optional', () => { diff --git a/apps/www/content/docs/extras/biomejs.mdx b/apps/www/content/docs/linter/biomejs.mdx similarity index 86% rename from apps/www/content/docs/extras/biomejs.mdx rename to apps/www/content/docs/linter/biomejs.mdx index e7b689a..8492c1d 100644 --- a/apps/www/content/docs/extras/biomejs.mdx +++ b/apps/www/content/docs/linter/biomejs.mdx @@ -1,6 +1,6 @@ --- title: Biome -description: Biome is a fast, all-in-one toolchain for formatting, linting, and more - built in Rust for maximum performance. +description: One toolchain for your web project — format, lint, and more in a fraction of a second. --- @@ -12,17 +12,20 @@ description: Biome is a fast, all-in-one toolchain for formatting, linting, and **Scripts added to `package.json`:** - `format` — `biome format --write .` - `lint` — `biome lint` +- `check` — `biome check --fix .` File created: ``` biome.json # Complete Biome configuration ``` +**Turborepo mode:** `biome.json` is placed at the project root and applies to all apps. + ### biome.json ```json title="biome.json" { - "$schema": "https://biomejs.dev/schemas/2.2.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "root": true, "vcs": { "enabled": false, @@ -34,9 +37,6 @@ biome.json # Complete Biome configuration "ignoreUnknown": true, "includes": [ "**", - "!apps/app/src/app/init/**/*", - "!packages/ui/src/components/**/*", - "!packages/ui/src/styles/**/*", "!**/*.css", "!**/.next/**/*", "!**/node_modules/**/*", diff --git a/apps/www/content/docs/linter/eslint.mdx b/apps/www/content/docs/linter/eslint.mdx new file mode 100644 index 0000000..cbf6859 --- /dev/null +++ b/apps/www/content/docs/linter/eslint.mdx @@ -0,0 +1,62 @@ +--- +title: ESLint +description: The pluggable linting utility for JavaScript and JSX. +--- + + + +[→ ESLint Documentation](https://eslint.org) + +## What create-faster adds + +**Script added to each app's `package.json`:** +- `lint` — `eslint .` + +Each app gets a stack-specific `eslint.config.mjs` with the right plugins and ignores for its framework. + +### Stack configurations + +| Stack | Config preset | Plugins | +|-------|--------------|---------| +| Next.js | `next` | `eslint-plugin-react`, `eslint-plugin-react-hooks`, `@next/eslint-plugin-next` | +| Expo | `react-native` | `eslint-plugin-react`, `eslint-plugin-react-hooks` | +| TanStack Start | `react` | `eslint-plugin-react`, `eslint-plugin-react-hooks` | +| Hono | `server` | None (Node.js globals only) | + +All configs extend `@eslint/js` recommended + `typescript-eslint` recommended as a base. + +### Single repo + +In a single-repo project, `eslint.config.mjs` contains inline rules with all plugins configured directly: + +``` +eslint.config.mjs # Stack-specific flat config +``` + +### Turborepo + +In a turborepo, create-faster generates a shared `@repo/eslint-config` package with composable presets. Each app imports from it: + +``` +packages/eslint-config/ +├── package.json # Exports all presets +├── base.js # @eslint/js + typescript-eslint +├── next.js # base + React + Next.js rules +├── react.js # base + React rules +├── react-native.js # base + React + Expo ignores +└── server.js # base + Node.js globals + +apps/web/ +└── eslint.config.mjs # import { nextConfig } from "@repo/eslint-config/next" + +apps/api/ +└── eslint.config.mjs # import { serverConfig } from "@repo/eslint-config/server" +``` + +Each app's `eslint.config.mjs` is a one-liner that re-exports the shared preset: + +```js title="apps/web/eslint.config.mjs" +import { nextConfig } from "@repo/eslint-config/next"; + +export default nextConfig; +``` diff --git a/apps/www/content/docs/meta.json b/apps/www/content/docs/meta.json index b326b35..4d7ca5e 100644 --- a/apps/www/content/docs/meta.json +++ b/apps/www/content/docs/meta.json @@ -23,8 +23,10 @@ "---ORMS---", "orm/drizzle", "orm/prisma", + "---LINTERS---", + "linter/biomejs", + "linter/eslint", "---EXTRAS---", - "extras/biomejs", "extras/husky" ] } diff --git a/bun.lock b/bun.lock index e5c882c..2f7a9b0 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "apps/cli": { "name": "create-faster", - "version": "1.5.0", + "version": "1.6.0", "bin": { "create-faster": "./dist/index.js", },