Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
033f6f8
docs: add implementation plan for linter separation and ESLint support
plvo Feb 12, 2026
0263dc8
feat(types): add linter to MetaProject and ProjectContext
plvo Feb 12, 2026
ac5d903
feat(meta): add linter category with biome and eslint, remove biome f…
plvo Feb 12, 2026
b41cce1
feat(handlebars): add linter case to has helper
plvo Feb 12, 2026
48b00e2
feat(resolver): add stack suffix support for project addon templates
plvo Feb 12, 2026
89951a3
feat(pkg-gen): add linter handling for app, root, and package generation
plvo Feb 12, 2026
5ba4c3f
feat(cli): add linter prompt handling
plvo Feb 12, 2026
291304a
feat(flags): add --linter flag
plvo Feb 12, 2026
bee6ae9
feat(summary): display linter in CLI command and project structure
plvo Feb 12, 2026
bd7195d
refactor(templates): move biome from tooling to linter category
plvo Feb 12, 2026
10b179e
feat(templates): add ESLint shared config package for turborepo
plvo Feb 12, 2026
56c158f
feat(templates): add per-app ESLint configs for all stacks
plvo Feb 12, 2026
b0fa5ca
docs: update CLAUDE.md for linter category and ESLint support
plvo Feb 12, 2026
4e9108d
refactor(summary): derive config labels from META instead of hardcoding
plvo Feb 12, 2026
3c03c84
fix(tests): update tests for biome moving from tooling to linter cate…
plvo Feb 12, 2026
f6fc94c
test: add ESLint linter unit and integration tests
plvo Feb 12, 2026
7f10a20
fix(meta): pin ESLint dependency versions
plvo Feb 12, 2026
2460ad6
fix(meta): update ESLint deps to latest major versions
plvo Feb 12, 2026
7bb4134
feat(pkg-gen): add packageManager field to generated package.json
plvo Feb 12, 2026
d6ac651
fix(pkg-gen): preserve turbo lint script when adding linter scripts
plvo Feb 12, 2026
59c56eb
fix(pkg-gen): revert lint script protection, biome runs from monorepo…
plvo Feb 12, 2026
2a6b576
refactor(handlebars): make `has` helper dynamic over project categories
plvo Feb 12, 2026
77db834
docs: add linter section with ESLint page, update Biome docs
plvo Feb 12, 2026
dc6765f
docs(readme): update flags to match current CLI interface
plvo Feb 12, 2026
2095531
docs: use official taglines for linter descriptions
plvo Feb 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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):**
Expand All @@ -355,17 +358,18 @@ 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):**
```bash
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
Expand All @@ -381,14 +385,17 @@ bunx create-faster myapp \
- `--orm <name>`: ORM provider (requires database)
- Options: `prisma`, `drizzle`

- `--linter <name>`: Linter
- Options: `biome`, `eslint`

- `--tooling <name>`: Add tooling (repeatable)
- Options: `husky` (requires git)

- `--git`: Initialize git repository

- `--pm <manager>`: Package manager
- Options: `bun`, `npm`, `pnpm`

- `--extras <items>`: Comma-separated extras
- Options: `biome`, `husky` (husky requires git)

### Auto-Generated Command

After project creation, a copy-paste ready command is displayed:
Expand All @@ -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
```
Expand Down
41 changes: 38 additions & 3 deletions apps/cli/src/__meta__.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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: '^9',
'@eslint/js': '^9',
'typescript-eslint': '^8',
globals: '^16',
'eslint-plugin-react': '^7',
'eslint-plugin-react-hooks': '^5',
'@next/eslint-plugin-next': '^15',
},
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',
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ')}`);
Expand All @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion apps/cli/src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface ParsedFlags {
app?: string[];
database?: string;
orm?: string;
linter?: string;
tooling?: string[];
git?: boolean;
pm?: string;
Expand All @@ -24,6 +25,7 @@ export function parseFlags(): Partial<TemplateContext> {

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(', ');

Expand All @@ -38,6 +40,7 @@ export function parseFlags(): Partial<TemplateContext> {
.option('--app <name:stack:libraries>', 'Add app (repeatable)', collect, [])
.option('--database <name>', `Database provider (${dbNames})`)
.option('--orm <name>', `ORM provider (${ormNames})`)
.option('--linter <name>', `Linter (${linterNames})`)
.option('--tooling <name>', 'Add tooling (repeatable)', collect, [])
.option('--git', 'Initialize git repository')
.option('--no-git', 'Skip git initialization')
Expand All @@ -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}
`,
)
Expand All @@ -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: [] };
}
Expand All @@ -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]) {
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/lib/handlebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export function registerHandlebarsHelpers(): void {
return this.project?.database === value;
case 'orm':
return this.project?.orm === value;
case 'linter':
return this.project?.linter === value;
case 'tooling':
return Array.isArray(this.project?.tooling) && this.project.tooling.includes(value);
case 'stack':
Expand Down
41 changes: 41 additions & 0 deletions apps/cli/src/lib/package-json-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,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,
Expand Down Expand Up @@ -255,6 +274,19 @@ 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',
Expand Down Expand Up @@ -293,6 +325,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];
Expand Down
44 changes: 44 additions & 0 deletions apps/cli/src/lib/template-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
return frontmatter.path ?? relativePath;
}

const scope = frontmatter.mono?.scope ?? addon.mono?.scope ?? 'root';

Check failure on line 54 in apps/cli/src/lib/template-resolver.ts

View workflow job for this annotation

GitHub Actions / 🧪 Test & Quality

TypeError: undefined is not an object (evaluating 'addon.mono')

at resolveProjectAddonDestination (/home/runner/work/create-faster/create-faster/apps/cli/src/lib/template-resolver.ts:54:44) at <anonymous> (/home/runner/work/create-faster/create-faster/apps/cli/tests/unit/lib/template-resolver.test.ts:95:20)
const filePath = frontmatter.mono?.path ?? relativePath;

switch (scope) {
Expand Down Expand Up @@ -144,6 +144,9 @@
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;

Expand All @@ -155,6 +158,39 @@
return templates;
}

function resolveStackSpecificAddonTemplates(
category: ProjectCategoryName,
addonName: string,
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[] = [];

for (const file of files) {
const source = join(addonDir, file);

const { stackName: fileSuffix, cleanFilename } = parseStackSuffix(file, VALID_STACKS);
if (!fileSuffix) continue;
if (fileSuffix !== stackName) continue;

const { only } = readFrontmatter(source);
if (shouldSkipTemplate(only, ctx)) continue;

const transformedPath = transformFilename(cleanFilename);
const isTurborepo = ctx.repo === 'turborepo';
const destination = isTurborepo ? `apps/${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);
Expand Down Expand Up @@ -188,6 +224,14 @@
if (ctx.project.orm) {
templates.push(...resolveTemplatesForProjectAddon('orm', ctx.project.orm, ctx));
}
if (ctx.project.linter) {
templates.push(...resolveTemplatesForProjectAddon('linter', ctx.project.linter, ctx));
for (const app of ctx.apps) {
templates.push(
...resolveStackSpecificAddonTemplates('linter', ctx.project.linter, app.appName, app.stackName, ctx),
);
}
}
for (const tooling of ctx.project.tooling) {
templates.push(...resolveTemplatesForProjectAddon('tooling', tooling, ctx));
}
Expand Down
Loading
Loading