Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ npx create-better-t-stack@latest
- Databases: SQLite, PostgreSQL, MySQL, MongoDB (or none)
- ORMs: Drizzle, Prisma, Mongoose (or none)
- Auth: Better-Auth (optional)
- Addons: Turborepo, PWA, Tauri, Biome, Husky, Starlight, Fumadocs, Ruler, Ultracite, Oxlint
- Addons: Turborepo, PWA, Tauri, Biome, Lefthook, Husky, Starlight, Fumadocs, Ruler, Ultracite, Oxlint
- Examples: Todo, AI
- DB Setup: Turso, Neon, Supabase, Prisma PostgreSQL, MongoDB Atlas, Cloudflare D1, Docker
- Web Deploy: Cloudflare Workers
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Follow the prompts to configure your project or use the `--yes` flag for default
| **Database Setup** | • Turso (SQLite)<br>• Cloudflare D1 (SQLite)<br>• Neon (PostgreSQL)<br>• Supabase (PostgreSQL)<br>• Prisma Postgres<br>• MongoDB Atlas<br>• None (manual setup) |
| **Authentication** | Better-Auth (email/password, with more options coming soon) |
| **Styling** | Tailwind CSS with shadcn/ui components |
| **Addons** | • PWA support<br>• Tauri (desktop applications)<br>• Starlight (documentation site)<br>• Biome (linting and formatting)<br>• Husky (Git hooks)<br>• Turborepo (optimized builds) |
| **Addons** | • PWA support<br>• Tauri (desktop applications)<br>• Starlight (documentation site)<br>• Biome (linting and formatting)<br>• Lefthook, Husky (Git hooks)<br>• Turborepo (optimized builds) |
| **Examples** | • Todo app<br>• AI Chat interface (using Vercel AI SDK) |
| **Developer Experience** | • Automatic Git initialization<br>• Package manager choice (npm, pnpm, bun)<br>• Automatic dependency installation |

Expand All @@ -58,7 +58,7 @@ Options:
--auth Include authentication
--no-auth Exclude authentication
--frontend <types...> Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, native-bare, native-uniwind, native-unistyles, none)
--addons <types...> Additional addons (pwa, tauri, starlight, biome, husky, turborepo, fumadocs, ultracite, oxlint, none)
--addons <types...> Additional addons (pwa, tauri, starlight, biome, lefthook,husky, turborepo, fumadocs, ultracite, oxlint, none)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix CLI docs formatting: lefthook,husky should be lefthook, husky.
Line 61 and Line 195 have missing spacing after the comma, which is easy to copy/paste wrong and looks inconsistent.

-  --addons <types...>             Additional addons (pwa, tauri, starlight, biome, lefthook,husky, turborepo, fumadocs, ultracite, oxlint, none)
+  --addons <types...>             Additional addons (pwa, tauri, starlight, biome, lefthook, husky, turborepo, fumadocs, ultracite, oxlint, none)

-- - **Addons 'none'**: Skips all addons (PWA, Tauri, Starlight, Biome, Lefthook,Husky, Turborepo).
+- **Addons 'none'**: Skips all addons (PWA, Tauri, Starlight, Biome, Lefthook, Husky, Turborepo).

Also applies to: 195-195

🤖 Prompt for AI Agents
In apps/cli/README.md around lines 61 and 195, the addon list contains
"lefthook,husky" without a space after the comma; update both occurrences to
"lefthook, husky" to ensure consistent formatting and correct copy/paste
behavior.

--examples <types...> Examples to include (todo, ai, none)
--git Initialize git repository
--no-git Skip git initialization
Expand Down Expand Up @@ -192,7 +192,7 @@ npx create-better-t-stack my-app --frontend none --backend hono --api trpc --dat
- **ORM 'none'**: Can be used when you want to handle database operations manually or use a different ORM.
- **Runtime 'none'**: Only available with Convex backend or when backend is 'none'.
- **Cloudflare Workers runtime**: Only compatible with Hono backend, Drizzle ORM (or no ORM), and SQLite database (with D1 setup). Not compatible with MongoDB.
- **Addons 'none'**: Skips all addons (PWA, Tauri, Starlight, Biome, Husky, Turborepo).
- **Addons 'none'**: Skips all addons (PWA, Tauri, Starlight, Biome, Lefthook,Husky, Turborepo).
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent spacing in addons list. There's a missing space in "Lefthook,Husky" but not in "Lefthook, Husky" on line 44. These should both be consistently formatted as "Lefthook, Husky".

Suggested change
- **Addons 'none'**: Skips all addons (PWA, Tauri, Starlight, Biome, Lefthook,Husky, Turborepo).
- **Addons 'none'**: Skips all addons (PWA, Tauri, Starlight, Biome, Lefthook, Husky, Turborepo).

Copilot uses AI. Check for mistakes.
- **Examples 'none'**: Skips all example implementations (todo, AI chat).
- **SvelteKit, Nuxt, and SolidJS** frontends are only compatible with oRPC API layer
- **PWA support** requires React with TanStack Router, React Router, or SolidJS
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const dependencyVersionMap = {
"@biomejs/biome": "^2.2.0",
oxlint: "^1.32.0",

lefthook: "^2.0.11",
husky: "^9.1.7",
"lint-staged": "^16.1.2",

Expand Down Expand Up @@ -178,6 +179,7 @@ export const ADDON_COMPATIBILITY = {
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid", "next"],
biome: [],
husky: [],
lefthook: [],
turborepo: [],
starlight: [],
ultracite: [],
Expand Down
35 changes: 31 additions & 4 deletions apps/cli/src/helpers/addons/addons-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,31 @@ ${pc.cyan("Docs:")} ${pc.underline("https://turborepo.com/docs")}
}
const hasUltracite = addons.includes("ultracite");
const hasBiome = addons.includes("biome");
const hasHusky = addons.includes("husky");
const gitHook = addons.includes("husky")
? "husky"
: addons.includes("lefthook")
? "lefthook"
: "";
Comment on lines +61 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce “only one git hooks manager”: current logic silently prefers Husky.
At Line 61–65, if a user selects both husky and lefthook, gitHook becomes "husky" without warning. If “only one may be selected” is a real requirement, this should be validated explicitly (ideally where addon compatibility is validated).

-  const gitHook = addons.includes("husky")
-    ? "husky"
-    : addons.includes("lefthook")
-      ? "lefthook"
-      : "";
+  const hasHusky = addons.includes("husky");
+  const hasLefthook = addons.includes("lefthook");
+  if (hasHusky && hasLefthook) {
+    throw new Error("Only one git hooks manager can be selected: husky or lefthook.");
+  }
+  const gitHook = hasHusky ? "husky" : hasLefthook ? "lefthook" : null;

(You’ll need to adjust the later if (gitHook) checks accordingly.)

Also applies to: 69-82

🤖 Prompt for AI Agents
In apps/cli/src/helpers/addons/addons-setup.ts around lines 61-65 (also
affecting logic at 69-82), the current selection logic silently prefers "husky"
when both "husky" and "lefthook" are present; enforce validation so only one
git-hook manager can be selected: detect if both are in the addons array and
throw or return a clear validation error, otherwise set gitHook to the chosen
value based on presence; update subsequent if (gitHook) checks to assume a
single valid value (or handle the error case) so the flow never silently prefers
one over the other.

const hasOxlint = addons.includes("oxlint");

if (hasUltracite) {
await setupUltracite(config, hasHusky);
await setupUltracite(config, gitHook);
} else {
if (hasBiome) {
await setupBiome(projectDir);
}
if (hasHusky) {
if (gitHook) {
let linter: "biome" | "oxlint" | undefined;
if (hasOxlint) {
linter = "oxlint";
} else if (hasBiome) {
linter = "biome";
}
await setupHusky(projectDir, linter);
if (gitHook === "husky") {
await setupHusky(projectDir, linter);
} else if (gitHook === "lefthook") {
await setupLefthook(projectDir);
Comment on lines +81 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Pass linter parameter to setupLefthook for pre-commit hook configuration.

When gitHook is "lefthook" and a linter (Biome or Oxlint) is configured, setupLefthook should receive the linter parameter to generate the appropriate lefthook.yml configuration. The PR objectives state: "pre-commit hooks are populated with... direct commands for lefthook (using stage_fixed: true)."

Currently, Line 84 calls setupLefthook(projectDir) without passing the linter, unlike Line 82 which correctly passes it to setupHusky.

Apply this diff:

      if (gitHook === "husky") {
        await setupHusky(projectDir, linter);
      } else if (gitHook === "lefthook") {
-        await setupLefthook(projectDir);
+        await setupLefthook(projectDir, linter);
      }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/cli/src/helpers/addons/addons-setup.ts around lines 81 to 84, the call
to setupLefthook omits the linter parameter, so lefthook configuration won't
include linter-specific pre-commit commands; update the call to pass the linter
(await setupLefthook(projectDir, linter)) matching how setupHusky is invoked so
lefthook.yml is generated with the correct linter hooks.

}
}
}

Expand Down Expand Up @@ -156,6 +164,25 @@ export async function setupHusky(projectDir: string, linter?: "biome" | "oxlint"
}
}

export async function setupLefthook(projectDir: string) {
await addPackageDependency({
devDependencies: ["lefthook"],
projectDir,
});

const packageJsonPath = path.join(projectDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);

packageJson.scripts = {
...packageJson.scripts,
prepare: "lefthook install",
};

await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
}
Comment on lines +167 to +184
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Complete setupLefthook implementation: generate lefthook.yml with linter configuration.

The function currently only installs the lefthook package and sets the prepare script. According to the PR objectives, when Biome or Oxlint are configured, lefthook should be set up with pre-commit hooks using stage_fixed: true to eliminate the need for lint-staged.

Update the function signature and add lefthook.yml generation:

-export async function setupLefthook(projectDir: string) {
+export async function setupLefthook(projectDir: string, linter?: "biome" | "oxlint") {
   await addPackageDependency({
     devDependencies: ["lefthook"],
     projectDir,
   });

   const packageJsonPath = path.join(projectDir, "package.json");
   if (await fs.pathExists(packageJsonPath)) {
     const packageJson = await fs.readJson(packageJsonPath);

     packageJson.scripts = {
       ...packageJson.scripts,
       prepare: "lefthook install",
     };

     await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
   }
+
+  // Generate lefthook.yml with pre-commit hooks if linter is configured
+  if (linter) {
+    const lefthookConfigPath = path.join(projectDir, "lefthook.yml");
+    let command = "";
+    
+    if (linter === "biome") {
+      command = "biome check --write .";
+    } else if (linter === "oxlint") {
+      command = "oxlint";
+    }
+
+    const lefthookConfig = `pre-commit:
+  commands:
+    lint:
+      glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
+      run: ${command}
+      stage_fixed: true
+`;
+
+    await fs.writeFile(lefthookConfigPath, lefthookConfig, "utf-8");
+  }
 }
🤖 Prompt for AI Agents
In apps/cli/src/helpers/addons/addons-setup.ts around lines 167–184,
setupLefthook currently only installs lefthook and adds a prepare script; update
the function signature to accept detection flags (e.g.,
setupLefthook(projectDir: string, options = { useBiome?: boolean, useOxlint?:
boolean })) and after installing lefthook generate a lefthook.yml file in
projectDir. Construct a lefthook.yml with a top-level "pre-commit" hook that
contains "stage_fixed: true" and a sequence of commands conditioned on options:
when useBiome is true include the appropriate biome lint/format command (invoked
via npx or the package bin), when useOxlint is true include the oxlint command;
if both are enabled run both in order. Serialize the YAML (use js-yaml or build
a small YAML string) and write it to projectDir/lefthook.yml, ensuring the file
is created atomically and existing lefthook.yml is overwritten only when
contents change. Ensure the prepare script remains and no other behavior is
altered.


async function setupPwa(projectDir: string, frontends: Frontend[]) {
const isCompatibleFrontend = frontends.some((f) =>
["react-router", "tanstack-router", "solid"].includes(f),
Expand Down
18 changes: 7 additions & 11 deletions apps/cli/src/helpers/addons/ultracite-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { autocompleteMultiselect, group, log, multiselect, spinner } from "@clac
import { execa } from "execa";
import pc from "picocolors";
import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";

import { exitCancelled } from "../../utils/errors";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import { setupBiome } from "./addons-setup";
Expand Down Expand Up @@ -130,7 +130,7 @@ function getFrameworksFromFrontend(frontend: string[]): string[] {
return Array.from(frameworks);
}

export async function setupUltracite(config: ProjectConfig, hasHusky: boolean) {
export async function setupUltracite(config: ProjectConfig, gitHook: string) {
const { packageManager, projectDir, frontend } = config;

try {
Expand Down Expand Up @@ -197,8 +197,11 @@ export async function setupUltracite(config: ProjectConfig, hasHusky: boolean) {
ultraciteArgs.push("--hooks", ...hooks);
}

if (hasHusky) {
ultraciteArgs.push("--integrations", "husky", "lint-staged");
if (gitHook) {
ultraciteArgs.push("--integrations", `${gitHook}`);
if (gitHook === "husky") {
ultraciteArgs.push("lint-staged");
}
}

const ultraciteArgsString = ultraciteArgs.join(" ");
Expand All @@ -215,13 +218,6 @@ export async function setupUltracite(config: ProjectConfig, hasHusky: boolean) {
shell: true,
});

if (hasHusky) {
await addPackageDependency({
devDependencies: ["husky", "lint-staged"],
projectDir,
});
}

s.stop("Ultracite setup successfully!");
} catch (error) {
log.error(pc.red("Failed to set up Ultracite"));
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/helpers/core/create-readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ function generateFeaturesList(
addonsList.push("- **Tauri** - Build native desktop applications");
} else if (addon === "biome") {
addonsList.push("- **Biome** - Linting and formatting");
} else if (addon === "lefthook") {
addonsList.push("- **Lefthook** - Fast and powerful Git hooks manager");
} else if (addon === "husky") {
addonsList.push("- **Husky** - Git hooks for code quality");
} else if (addon === "starlight") {
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/helpers/core/post-installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export async function displayPostInstallInstructions(
const runCmd =
packageManager === "npm" ? "npm run" : packageManager === "pnpm" ? "pnpm run" : "bun run";
const cdCmd = `cd ${relativePath}`;
const hasHuskyOrBiome = addons?.includes("husky") || addons?.includes("biome");
const hasGitHooksOrBiome =
addons?.includes("husky") || addons?.includes("biome") || addons?.includes("lefthook");

const databaseInstructions =
!isConvex && database !== "none"
Expand All @@ -42,7 +43,7 @@ export async function displayPostInstallInstructions(
: "";

const tauriInstructions = addons?.includes("tauri") ? getTauriInstructions(runCmd) : "";
const lintingInstructions = hasHuskyOrBiome ? getLintingInstructions(runCmd) : "";
const lintingInstructions = hasGitHooksOrBiome ? getLintingInstructions(runCmd) : "";
const nativeInstructions =
(frontend?.includes("native-bare") ||
frontend?.includes("native-uniwind") ||
Expand Down
24 changes: 21 additions & 3 deletions apps/cli/src/prompts/addons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ function getAddonDisplay(addon: Addons): { label: string; hint: string } {
label = "Ruler";
hint = "Centralize your AI rules";
break;
case "lefthook":
label = "Lefthook";
hint = "Fast and powerful Git hooks manager";
break;
case "husky":
label = "Husky";
hint = "Modern native Git hooks made easy";
Expand All @@ -66,11 +70,17 @@ function getAddonDisplay(addon: Addons): { label: string; hint: string } {
const ADDON_GROUPS = {
Documentation: ["starlight", "fumadocs"],
Linting: ["biome", "oxlint", "ultracite"],
Other: ["ruler", "turborepo", "pwa", "tauri", "husky"],
Other: ["ruler", "turborepo", "pwa", "tauri", "lefthook", "husky"],
};

export async function getAddonsChoice(addons?: Addons[], frontends?: Frontend[], auth?: Auth) {
if (addons !== undefined) return addons;
export async function getAddonsChoice(
initialAddons?: Addons[],
frontends?: Frontend[],
auth?: Auth,
) {
if (initialAddons !== undefined) return initialAddons;

const existingAddons: Addons[] = [];

const allAddons = AddonsSchema.options.filter((addon) => addon !== "none");
const groupedOptions: Record<string, AddonOption[]> = {
Expand All @@ -85,6 +95,14 @@ export async function getAddonsChoice(addons?: Addons[], frontends?: Frontend[],
const { isCompatible } = validateAddonCompatibility(addon, frontendsArray, auth);
if (!isCompatible) continue;

// Skip husky if lefthook is already selected, and vice versa
if (
(addon === "husky" && existingAddons.includes("lefthook")) ||
(addon === "lefthook" && existingAddons.includes("husky"))
) {
continue;
}
Comment on lines +98 to +104
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove ineffective skip logic: existingAddons is never populated.

The existingAddons array is initialized as empty at Line 83 and never populated, so the skip logic at Lines 98-104 will never execute. Mutual exclusion is already correctly handled by getCompatibleAddons filtering (via validateAddonCompatibility), making this code redundant and potentially confusing.

Remove the ineffective skip logic:

-    // Skip husky if lefthook is already selected, and vice versa
-    if (
-      (addon === "husky" && existingAddons.includes("lefthook")) ||
-      (addon === "lefthook" && existingAddons.includes("husky"))
-    ) {
-      continue;
-    }
-

Also remove the now-unused existingAddons declaration at Line 83:

   if (initialAddons !== undefined) return initialAddons;

-  const existingAddons: Addons[] = [];
-
   const allAddons = AddonsSchema.options.filter((addon) => addon !== "none");

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/cli/src/prompts/addons.ts around lines 83 and 98–104, the existingAddons
array is declared at line 83 but never populated, so the conditional that skips
husky/lefthook at lines 98–104 is ineffective and redundant (compatibility is
already enforced by getCompatibleAddons/validateAddonCompatibility). Remove the
unused existingAddons declaration at line 83 and delete the entire if-block
spanning lines 98–104 to clean up dead code and avoid confusion.


const { label, hint } = getAddonDisplay(addon);
const option = { value: addon, label, hint };

Expand Down
8 changes: 8 additions & 0 deletions apps/cli/src/utils/compatibility-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,14 @@ export function getCompatibleAddons(

if (addon === "none") return false;

// Skip husky if lefthook is already selected, and vice versa
if (
(addon === "husky" && existingAddons.includes("lefthook")) ||
(addon === "lefthook" && existingAddons.includes("husky"))
) {
return false;
}

const { isCompatible } = validateAddonCompatibility(addon, frontend, auth);
return isCompatible;
});
Expand Down
14 changes: 14 additions & 0 deletions apps/cli/templates/addons/lefthook/lefthook.yml.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
pre-commit:
commands:
{{#if (includes addons "biome")}}
biome-check:
run: biome check --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true
{{else if (includes addons "oxlint")}}
oxlint-fix:
run: oxlint --fix {staged_files}
stage_fixed: true
{{else}}
info:
run: echo "Go to https://lefthook.dev/ for more information on configuring hooks!"
{{/if}}
2 changes: 1 addition & 1 deletion apps/cli/templates/addons/ruler/.ruler/bts.md.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ add
Available addons you can add:
- **Documentation**: Starlight, Fumadocs
- **Linting**: Biome, Oxlint, Ultracite
- **Other**: Ruler, Turborepo, PWA, Tauri, Husky
- **Other**: Ruler, Turborepo, PWA, Tauri, Lefthook, Husky

You can also add web deployment configurations like Cloudflare Workers support.

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/test/addons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test

describe("Addon Configurations", () => {
describe("Universal Addons (no frontend restrictions)", () => {
const universalAddons = ["biome", "husky", "turborepo", "oxlint"];
const universalAddons = ["biome", "lefthook", "husky", "turborepo", "oxlint"];

for (const addon of universalAddons) {
it(`should work with ${addon} addon on any frontend`, async () => {
Expand Down
1 change: 1 addition & 0 deletions apps/web/content/docs/cli/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ Additional features to include:
- `starlight`: Starlight documentation site
- `fumadocs`: Fumadocs documentation site
- `biome`: Biome linting and formatting
- `lefthook`: Git hooks with Lefthook
- `husky`: Git hooks with Husky
- `turborepo`: Turborepo monorepo setup
- `ultracite`: Ultracite configuration
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ See the full list in the [CLI Reference](/docs/cli). Key flags:
- `--orm`: drizzle, prisma, mongoose, none
- `--api`: trpc, orpc, none
- `--auth`: better-auth, clerk, none
- `--addons`: turborepo, pwa, tauri, biome, husky, starlight, fumadocs, ultracite, oxlint, ruler, none
- `--addons`: turborepo, pwa, tauri, biome, lefthook,husky, starlight, fumadocs, ultracite, oxlint, ruler, none
- `--examples`: todo, ai, none

## Next Steps
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/project-structure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ apps/docs/
"backend": "<none|hono|express|fastify|elysia|next|convex>",
"runtime": "<bun|node|workers>",
"frontend": ["<next|tanstack-router|react-router|tanstack-start|nuxt|svelte|solid>"] ,
"addons": ["<turborepo|biome|husky|pwa|starlight>"] ,
"addons": ["<turborepo|biome|lefthook|husky|pwa|starlight>"] ,
"examples": ["<ai|todo|none>"] ,
"auth": <"better-auth"|"clerk"|"none">,
"packageManager": "<bun|pnpm|npm>",
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/app/(home)/new/_components/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,22 @@ export const analyzeStackCompatibility = (stack: StackState): CompatibilityResul
});
}

// Handle husky/lefthook mutual exclusion
const hasHusky = nextStack.addons.includes("husky");
const hasLefthook = nextStack.addons.includes("lefthook");

if (hasHusky && hasLefthook) {
// Remove lefthook if both are selected (prioritize husky)
nextStack.addons = nextStack.addons.filter((addon) => addon !== "lefthook");
notes.addons.notes.push("Lefthook is not compatible with Husky. It will be removed.");
notes.addons.hasIssue = true;
changes.push({
category: "addons",
message: "Lefthook removed (not compatible with Husky)",
});
changed = true;
}
Comment on lines +905 to +919
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Husky/lefthook check is unreachable for some backend types.

This mutual exclusion block is nested inside the else branch (line 283) that only runs when backend is not convex/none/self-*. Git hook managers are backend-agnostic, so this check should execute regardless of backend type.

Consider moving this block outside the backend-specific conditionals (e.g., after line 63 or before the final return) to ensure the conflict is always resolved.

+  // Handle husky/lefthook mutual exclusion (backend-agnostic)
+  const hasHusky = nextStack.addons.includes("husky");
+  const hasLefthook = nextStack.addons.includes("lefthook");
+
+  if (hasHusky && hasLefthook) {
+    nextStack.addons = nextStack.addons.filter((addon) => addon !== "lefthook");
+    notes.addons.notes.push("Lefthook is not compatible with Husky. It will be removed.");
+    notes.addons.hasIssue = true;
+    changes.push({
+      category: "addons",
+      message: "Lefthook removed (not compatible with Husky)",
+    });
+    changed = true;
+  }
+
   const isConvex = nextStack.backend === "convex";

Then remove the duplicate block from lines 905-919.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/src/app/(home)/new/_components/utils.ts around lines 905 to 919, the
Husky/Lefthook mutual-exclusion check currently lives inside a backend-specific
else branch so it never runs for convex/none/self-* backends; move this entire
block so it executes unconditionally (e.g., place it after the backend-specific
conditionals finish or just before the function's final return) so hook-manager
conflicts are always resolved, and then remove the duplicate block at lines
905-919 to avoid repetition.


const originalAddonsLength = nextStack.addons.length;
if (incompatibleAddons.length > 0) {
nextStack.addons = nextStack.addons.filter((addon) => !incompatibleAddons.includes(addon));
Expand Down Expand Up @@ -1547,6 +1563,18 @@ export const getDisabledReason = (
}
}

if (category === "addons" && optionId === "husky") {
if (finalStack.addons.includes("lefthook")) {
return "Husky is not compatible with Lefthook. Deselect Lefthook first.";
}
}

if (category === "addons" && optionId === "lefthook") {
if (finalStack.addons.includes("husky")) {
return "Lefthook is not compatible with Husky. Deselect Husky first.";
}
}
Comment on lines +1566 to +1576
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Disabled reason check is ineffective due to checking post-adjustment state.

These checks use finalStack.addons but finalStack is computed after analyzeStackCompatibility auto-resolves the conflict (lines 905-919 remove lefthook when both are present). By the time this check runs, the conflicting addon has already been removed, so finalStack.addons.includes(...) always returns false.

To properly disable the option in the UI, check currentStack.addons instead:

   if (category === "addons" && optionId === "husky") {
-    if (finalStack.addons.includes("lefthook")) {
+    if (currentStack.addons.includes("lefthook")) {
       return "Husky is not compatible with Lefthook. Deselect Lefthook first.";
     }
   }

   if (category === "addons" && optionId === "lefthook") {
-    if (finalStack.addons.includes("husky")) {
+    if (currentStack.addons.includes("husky")) {
       return "Lefthook is not compatible with Husky. Deselect Husky first.";
     }
   }

Alternatively, move these checks earlier in the function (before line 1140) alongside the other early-return checks for clearer intent and better efficiency.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (category === "addons" && optionId === "husky") {
if (finalStack.addons.includes("lefthook")) {
return "Husky is not compatible with Lefthook. Deselect Lefthook first.";
}
}
if (category === "addons" && optionId === "lefthook") {
if (finalStack.addons.includes("husky")) {
return "Lefthook is not compatible with Husky. Deselect Husky first.";
}
}
if (category === "addons" && optionId === "husky") {
if (currentStack.addons.includes("lefthook")) {
return "Husky is not compatible with Lefthook. Deselect Lefthook first.";
}
}
if (category === "addons" && optionId === "lefthook") {
if (currentStack.addons.includes("husky")) {
return "Lefthook is not compatible with Husky. Deselect Husky first.";
}
}
🤖 Prompt for AI Agents
In apps/web/src/app/(home)/new/_components/utils.ts around lines 1566 to 1576,
the disabled-reason checks for the "husky" and "lefthook" addons incorrectly
inspect finalStack.addons (which is computed after analyzeStackCompatibility
auto-resolves conflicts), so the conflicting addon will already be removed and
the checks always return false; change these checks to use currentStack.addons
(or move them earlier in the function before analyzeStackCompatibility runs,
near the other early-return checks) so the UI sees the pre-adjustment state and
can correctly disable the conflicting option.


if (category === "examples" && optionId === "todo") {
if (finalStack.database === "none" && finalStack.backend !== "convex") {
return "Todo example requires a database. Select a database first (or use Convex backend which includes its own database).";
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lib/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,14 @@ export const TECH_OPTIONS: Record<
color: "from-green-500 to-green-700",
default: false,
},
{
id: "lefthook",
name: "Lefthook",
description: "Fast and powerful Git hooks manager",
icon: `${ICON_BASE_URL}/lefthook.svg`,
color: "from-red-500 to-red-700",
default: false,
},
{
id: "husky",
name: "Husky",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/stack-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function generateStackCommand(stack: StackState) {
"tauri",
"starlight",
"biome",
"lefthook",
"husky",
"turborepo",
"ultracite",
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const AddonsSchema = z
"tauri",
"starlight",
"biome",
"lefthook",
"husky",
"ruler",
"turborepo",
Expand Down