Futher codebase breakdown, and CONTRIBUTING.md b394b26a
Pedro Santana · 2025-07-11 18:14 8 file(s) · +357 −284
CONTRIBUTING.md (added) +29 −0
1 +
# Contributing to create-bhvr
2 +
3 +
First off, thank you for considering contributing to `create-bhvr`.
4 +
5 +
## Code of Conduct
6 +
7 +
This project and everyone participating in it is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
8 +
9 +
## How Can I Contribute?
10 +
11 +
### Reporting Bugs
12 +
13 +
If you find a bug, please make sure the bug has not already been reported by searching on GitHub under [Issues](https://github.com/stevedylandev/bhvr/issues). If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/stevedylandev/bhvr/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring.
14 +
15 +
### Suggesting Enhancements
16 +
17 +
If you have an idea for an enhancement, please make sure the enhancement has not already been suggested by searching on GitHub under [Issues](https://github.com/stevedylandev/bhvr/issues). If you're unable to find an open issue addressing the suggestion, [open a new one](https://github.com/stevedylandev/bhvr/issues/new). Be sure to include a **title and clear description** of the enhancement you're suggesting.
18 +
19 +
### Submitting a Pull Request
20 +
21 +
1.  Fork the repository and create your branch from `main`.
22 +
2.  Run `bun install` to install the dependencies.
23 +
3.  Make your changes.
24 +
4.  Run `bun run build` to make sure your changes build correctly.
25 +
5.  Issue that pull request!
26 +
27 +
## License
28 +
29 +
By contributing, you agree that your contributions will be licensed under its MIT License.
src/lib/create-project.ts +27 −271
1 -
import path from "node:path";
2 -
import { consola } from "consola";
3 -
import degit from "degit";
4 -
import { execa } from "execa";
5 -
import fs from "fs-extra";
6 -
import ora from "ora";
7 -
import pc from "picocolors";
8 1
import type { ProjectOptions, ProjectResult } from "@/types";
9 -
import { DEFAULT_REPO } from "@/utils/constants";
10 -
import { TEMPLATES } from "@/utils/templates";
11 -
import { patchFilesForRPC } from "./patch-files-rpc";
12 -
import { setupBiome } from "./setup-biome";
13 -
import { tryCatch } from "@/utils/try-catch";
2 +
import { initializeGit } from "./initialize-git";
3 +
import { installDependencies } from "./install-dependencies";
4 +
import { promptForOptions } from "./prompt-for-options";
5 +
import { scaffoldTemplate } from "./scaffold-template";
14 6
15 7
export async function createProject(
16 8
  projectDirectory: string,
17 9
  options: ProjectOptions,
18 10
): Promise<ProjectResult | null> {
19 -
  let projectName = projectDirectory;
20 -
21 -
  if (!projectName && !options.yes) {
22 -
    const { data, error } = await tryCatch(
23 -
      consola.prompt(pc.yellow("What is the name of your project?"), {
24 -
        type: "text",
25 -
        default: "my-bhvr-app",
26 -
        placeholder: "my-bhvr-app",
27 -
        cancel: "reject",
28 -
      }),
29 -
    );
11 +
  const projectOptions = await promptForOptions({
12 +
    ...options,
13 +
    projectName: projectDirectory,
14 +
  });
30 15
31 -
    if (!data || error) {
32 -
      consola.error(pc.red("Project creation cancelled."));
33 -
      return null;
34 -
    }
35 -
36 -
    projectName = data;
16 +
  if (!projectOptions) {
17 +
    return null;
37 18
  }
38 19
39 -
  let templateChoice = options.template || "default";
20 +
  const scaffolded = await scaffoldTemplate(projectOptions);
40 21
41 -
  if (!options.yes && !options.branch) {
42 -
    const templateChoices = Object.keys(TEMPLATES).map((key) => ({
43 -
      label: `${key} (${TEMPLATES[key]?.description})`,
44 -
      value: key,
45 -
    }));
46 -
47 -
    const { data, error } = await tryCatch(
48 -
      consola.prompt(pc.yellow("Select a template:"), {
49 -
        type: "select",
50 -
        options: templateChoices,
51 -
        initial: "default",
52 -
        cancel: "reject",
53 -
      }),
54 -
    );
55 -
56 -
    if (!data || error) {
57 -
      consola.error("Project creation cancelled.");
58 -
      return null;
59 -
    }
60 -
61 -
    templateChoice = data;
22 +
  if (!scaffolded) {
23 +
    return null;
62 24
  }
63 25
64 -
  const projectPath = path.resolve(process.cwd(), projectName);
65 -
66 -
  if (fs.existsSync(projectPath)) {
67 -
    const files = fs.readdirSync(projectPath);
68 -
69 -
    if (files.length > 0 && !options.yes) {
70 -
      const { data: overwrite, error } = await tryCatch(
71 -
        consola.prompt(
72 -
          `The directory ${projectName} already exists and is not empty. Do you want to overwrite it?`,
73 -
          {
74 -
            type: "confirm",
75 -
            initial: false,
76 -
          },
77 -
        ),
78 -
      );
79 -
80 -
      if (!overwrite || error) {
81 -
        consola.error("Project creation cancelled.");
82 -
        return null;
83 -
      }
84 -
85 -
      await fs.emptyDir(projectPath);
86 -
    }
87 -
  }
88 -
89 -
  fs.ensureDirSync(projectPath);
90 -
91 -
  const repoPath = options.repo || DEFAULT_REPO;
92 -
  const templateConfig =
93 -
    TEMPLATES[templateChoice as keyof typeof TEMPLATES] || TEMPLATES.default;
94 -
  const branch = options.branch || (templateConfig?.branch ?? "main");
95 -
  const repoUrl = `${repoPath}#${branch}`;
96 -
  const spinner = ora("Downloading template...").start();
97 -
98 -
  try {
99 -
    const emitter = degit(repoUrl, {
100 -
      cache: false,
101 -
      force: true,
102 -
      verbose: false,
103 -
    });
104 -
105 -
    await emitter.clone(projectPath);
106 -
    spinner.succeed(
107 -
      `Template downloaded successfully (${templateChoice} template)`,
108 -
    );
109 -
110 -
    const pkgJsonPath = path.join(projectPath, "package.json");
111 -
    if (fs.existsSync(pkgJsonPath)) {
112 -
      const pkgJson = await fs.readJson(pkgJsonPath);
113 -
      pkgJson.name = projectName;
114 -
      await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
115 -
    }
116 -
117 -
    const gitDir = path.join(projectPath, ".git");
118 -
    if (fs.existsSync(gitDir)) {
119 -
      await fs.remove(gitDir);
120 -
      console.log(pc.blue("Removed .git directory"));
121 -
    }
122 -
123 -
    let useRpc = options.rpc;
124 -
125 -
    if (!options.yes && !options.rpc) {
126 -
      const { data: rpcResponse, error } = await tryCatch(
127 -
        consola.prompt("Use Hono RPC client for type-safe API communication?", {
128 -
          type: "confirm",
129 -
          initial: false,
130 -
        }),
131 -
      );
132 -
133 -
      if (!rpcResponse || error) {
134 -
        consola.error("Project creation cancelled.");
135 -
        return null;
136 -
      }
137 -
138 -
      useRpc = rpcResponse;
139 -
    }
140 -
141 -
    if (useRpc) {
142 -
      await patchFilesForRPC(projectPath, templateChoice);
143 -
    }
144 -
145 -
    let linter = options.linter;
146 -
147 -
    if (!options.yes && !options.linter) {
148 -
      const { data: linterResponse, error } = await tryCatch(
149 -
        consola.prompt("Select a linter:", {
150 -
          type: "select",
151 -
          options: [
152 -
            { label: "ESLint (default)", value: "eslint" },
153 -
            { label: "Biome", value: "biome" },
154 -
          ],
155 -
          initial: "eslint",
156 -
          cancel: "reject",
157 -
        }),
158 -
      );
159 -
160 -
      if (!linterResponse || error) {
161 -
        console.log(pc.yellow("Project creation cancelled."));
162 -
        return null;
163 -
      }
164 -
165 -
      linter = linterResponse as "eslint" | "biome";
166 -
    }
167 -
168 -
    if (linter === "biome") {
169 -
      await setupBiome(projectPath);
170 -
    }
26 +
  const gitInitialized = await initializeGit(
27 +
    projectOptions.projectName,
28 +
    projectOptions.yes,
29 +
  );
30 +
  const dependenciesInstalled = await installDependencies(
31 +
    projectOptions.projectName,
32 +
    projectOptions.yes,
33 +
  );
171 34
172 -
    let gitInitialized = false;
173 -
174 -
    if (!options.yes) {
175 -
      const { data: gitResponse, error } = await tryCatch(
176 -
        consola.prompt("Initialize a git repository?", {
177 -
          type: "confirm",
178 -
179 -
          initial: true,
180 -
          cancel: "reject",
181 -
        }),
182 -
      );
183 -
184 -
      if (error) {
185 -
        console.log(pc.yellow("Project creation cancelled."));
186 -
        return null;
187 -
      }
188 -
189 -
      if (gitResponse) {
190 -
        try {
191 -
          spinner.start("Initializing git repository...");
192 -
          await execa("git", ["init"], { cwd: projectPath });
193 -
          spinner.succeed("Git repository initialized");
194 -
          gitInitialized = true;
195 -
        } catch (err: unknown) {
196 -
          spinner.fail(
197 -
            "Failed to initialize git repository. Is git installed?",
198 -
          );
199 -
          if (err instanceof Error) {
200 -
            consola.error(pc.red("Git error:"), err.message);
201 -
          } else {
202 -
            consola.error(pc.red("Git error: Unknown error"));
203 -
          }
204 -
        }
205 -
      }
206 -
    } else {
207 -
      try {
208 -
        spinner.start("Initializing git repository...");
209 -
        await execa("git", ["init"], { cwd: projectPath });
210 -
        spinner.succeed("Git repository initialized");
211 -
        gitInitialized = true;
212 -
      } catch (_err) {
213 -
        spinner.fail("Failed to initialize git repository. Is git installed?");
214 -
      }
215 -
    }
216 -
217 -
    let dependenciesInstalled = false;
218 -
219 -
    if (!options.yes) {
220 -
      const { data: depsResponse, error } = await tryCatch(
221 -
        consola.prompt("Install dependencies?", {
222 -
          type: "confirm",
223 -
          initial: true,
224 -
          cancel: "reject",
225 -
        }),
226 -
      );
227 -
228 -
      if (error) {
229 -
        console.log(pc.yellow("Project creation cancelled."));
230 -
        return null;
231 -
      }
232 -
233 -
      if (depsResponse) {
234 -
        spinner.start("Installing dependencies...");
235 -
        try {
236 -
          await execa("bun", ["install"], { cwd: projectPath });
237 -
          spinner.succeed("Dependencies installed with bun");
238 -
          dependenciesInstalled = true;
239 -
        } catch (_bunErr) {
240 -
          try {
241 -
            spinner.text = "Installing dependencies with npm...";
242 -
            await execa("npm", ["install"], { cwd: projectPath });
243 -
            spinner.succeed("Dependencies installed with npm");
244 -
            dependenciesInstalled = true;
245 -
          } catch (_npmErr) {
246 -
            spinner.fail("Failed to install dependencies.");
247 -
            console.log(
248 -
              pc.yellow(
249 -
                "You can install them manually after navigating to the project directory.",
250 -
              ),
251 -
            );
252 -
          }
253 -
        }
254 -
      }
255 -
    } else {
256 -
      spinner.start("Installing dependencies...");
257 -
      try {
258 -
        await execa("bun", ["install"], { cwd: projectPath });
259 -
        spinner.succeed("Dependencies installed with bun");
260 -
        dependenciesInstalled = true;
261 -
      } catch (_bunErr) {
262 -
        try {
263 -
          spinner.text = "Installing dependencies with npm...";
264 -
          await execa("npm", ["install"], { cwd: projectPath });
265 -
          spinner.succeed("Dependencies installed with npm");
266 -
          dependenciesInstalled = true;
267 -
        } catch (_npmErr) {
268 -
          spinner.fail(
269 -
            "Failed to install dependencies. You can install them manually later.",
270 -
          );
271 -
        }
272 -
      }
273 -
    }
274 -
275 -
    return {
276 -
      projectName,
277 -
      gitInitialized,
278 -
      dependenciesInstalled,
279 -
      template: templateChoice,
280 -
    };
281 -
  } catch (err) {
282 -
    spinner.fail("Failed to download template");
283 -
    throw err;
284 -
  }
35 +
  return {
36 +
    projectName: projectOptions.projectName,
37 +
    gitInitialized,
38 +
    dependenciesInstalled,
39 +
    template: projectOptions.template,
40 +
  };
285 41
}
src/lib/index.ts +4 −0
1 1
export * from "./create-project";
2 2
export * from "./display-banner";
3 +
export * from "./initialize-git";
4 +
export * from "./install-dependencies";
3 5
export * from "./patch-files-rpc";
6 +
export * from "./prompt-for-options";
7 +
export * from "./scaffold-template";
4 8
export * from "./setup-biome";
src/lib/initialize-git.ts (added) +44 −0
1 +
import { consola } from "consola";
2 +
import { execa } from "execa";
3 +
import ora from "ora";
4 +
import pc from "picocolors";
5 +
import { tryCatch } from "@/utils/try-catch";
6 +
7 +
export async function initializeGit(
8 +
  projectPath: string,
9 +
  skipConfirmation?: boolean,
10 +
): Promise<boolean> {
11 +
  if (!skipConfirmation) {
12 +
    const { data: gitResponse, error } = await tryCatch(
13 +
      consola.prompt("Initialize a git repository?", {
14 +
        type: "confirm",
15 +
        initial: true,
16 +
        cancel: "reject",
17 +
      }),
18 +
    );
19 +
20 +
    if (error) {
21 +
      console.log(pc.yellow("Project creation cancelled."));
22 +
      return false;
23 +
    }
24 +
25 +
    if (!gitResponse) {
26 +
      return false;
27 +
    }
28 +
  }
29 +
30 +
  const spinner = ora("Initializing git repository...").start();
31 +
  try {
32 +
    await execa("git", ["init"], { cwd: projectPath });
33 +
    spinner.succeed("Git repository initialized");
34 +
    return true;
35 +
  } catch (err: unknown) {
36 +
    spinner.fail("Failed to initialize git repository. Is git installed?");
37 +
    if (err instanceof Error) {
38 +
      consola.error(pc.red("Git error:"), err.message);
39 +
    } else {
40 +
      consola.error(pc.red("Git error: Unknown error"));
41 +
    }
42 +
    return false;
43 +
  }
44 +
}
src/lib/install-dependencies.ts (added) +66 −0
1 +
import { consola } from "consola";
2 +
import { execa } from "execa";
3 +
import ora from "ora";
4 +
import pc from "picocolors";
5 +
import { tryCatch } from "@/utils/try-catch";
6 +
7 +
async function getPackageManager(): Promise<"bun" | "pnpm" | "npm"> {
8 +
  try {
9 +
    await execa("bun", ["--version"]);
10 +
    return "bun";
11 +
  } catch (e) {
12 +
    // bun is not installed
13 +
  }
14 +
15 +
  try {
16 +
    await execa("pnpm", ["--version"]);
17 +
    return "pnpm";
18 +
  } catch (e) {
19 +
    // pnpm is not installed
20 +
  }
21 +
22 +
  return "npm";
23 +
}
24 +
25 +
export async function installDependencies(
26 +
  projectPath: string,
27 +
  skipConfirmation?: boolean,
28 +
): Promise<boolean> {
29 +
  if (!skipConfirmation) {
30 +
    const { data: depsResponse, error } = await tryCatch(
31 +
      consola.prompt("Install dependencies?", {
32 +
        type: "confirm",
33 +
        initial: true,
34 +
        cancel: "reject",
35 +
      }),
36 +
    );
37 +
38 +
    if (error) {
39 +
      console.log(pc.yellow("Project creation cancelled."));
40 +
      return false;
41 +
    }
42 +
43 +
    if (!depsResponse) {
44 +
      return false;
45 +
    }
46 +
  }
47 +
48 +
  const packageManager = await getPackageManager();
49 +
  const spinner = ora(
50 +
    `Installing dependencies with ${packageManager}...`,
51 +
  ).start();
52 +
53 +
  try {
54 +
    await execa(packageManager, ["install"], { cwd: projectPath });
55 +
    spinner.succeed(`Dependencies installed with ${packageManager}`);
56 +
    return true;
57 +
  } catch (err) {
58 +
    spinner.fail("Failed to install dependencies.");
59 +
    console.log(
60 +
      pc.yellow(
61 +
        "You can install them manually after navigating to the project directory.",
62 +
      ),
63 +
    );
64 +
    return false;
65 +
  }
66 +
}
src/lib/prompt-for-options.ts (added) +103 −0
1 +
import { consola } from "consola";
2 +
import pc from "picocolors";
3 +
import type { ProjectOptions } from "@/types";
4 +
import { TEMPLATES } from "@/utils/templates";
5 +
import { tryCatch } from "@/utils/try-catch";
6 +
7 +
export async function promptForOptions(
8 +
  options: ProjectOptions,
9 +
): Promise<ProjectOptions | null> {
10 +
  let projectName = options.projectName;
11 +
12 +
  if (!projectName && !options.yes) {
13 +
    const { data, error } = await tryCatch(
14 +
      consola.prompt(pc.yellow("What is the name of your project?"), {
15 +
        type: "text",
16 +
        default: "my-bhvr-app",
17 +
        placeholder: "my-bhvr-app",
18 +
        cancel: "reject",
19 +
      }),
20 +
    );
21 +
22 +
    if (!data || error) {
23 +
      consola.error(pc.red("Project creation cancelled."));
24 +
      return null;
25 +
    }
26 +
27 +
    projectName = data;
28 +
  }
29 +
30 +
  let templateChoice = options.template || "default";
31 +
32 +
  if (!options.yes && !options.branch) {
33 +
    const templateChoices = Object.keys(TEMPLATES).map((key) => ({
34 +
      label: `${key} (${TEMPLATES[key]?.description})`,
35 +
      value: key,
36 +
    }));
37 +
38 +
    const { data, error } = await tryCatch(
39 +
      consola.prompt(pc.yellow("Select a template:"), {
40 +
        type: "select",
41 +
        options: templateChoices,
42 +
        initial: "default",
43 +
        cancel: "reject",
44 +
      }),
45 +
    );
46 +
47 +
    if (!data || error) {
48 +
      consola.error("Project creation cancelled.");
49 +
      return null;
50 +
    }
51 +
52 +
    templateChoice = data;
53 +
  }
54 +
55 +
  let useRpc = options.rpc;
56 +
57 +
  if (!options.yes && !options.rpc) {
58 +
    const { data: rpcResponse, error } = await tryCatch(
59 +
      consola.prompt("Use Hono RPC client for type-safe API communication?", {
60 +
        type: "confirm",
61 +
        initial: false,
62 +
      }),
63 +
    );
64 +
65 +
    if (error) {
66 +
      consola.error("Project creation cancelled.");
67 +
      return null;
68 +
    }
69 +
70 +
    useRpc = rpcResponse;
71 +
  }
72 +
73 +
  let linter = options.linter;
74 +
75 +
  if (!options.yes && !options.linter) {
76 +
    const { data: linterResponse, error } = await tryCatch(
77 +
      consola.prompt("Select a linter:", {
78 +
        type: "select",
79 +
        options: [
80 +
          { label: "ESLint (default)", value: "eslint" },
81 +
          { label: "Biome", value: "biome" },
82 +
        ],
83 +
        initial: "eslint",
84 +
        cancel: "reject",
85 +
      }),
86 +
    );
87 +
88 +
    if (error) {
89 +
      console.log(pc.yellow("Project creation cancelled."));
90 +
      return null;
91 +
    }
92 +
93 +
    linter = linterResponse as "eslint" | "biome";
94 +
  }
95 +
96 +
  return {
97 +
    ...options,
98 +
    projectName,
99 +
    template: templateChoice,
100 +
    rpc: useRpc,
101 +
    linter,
102 +
  };
103 +
}
src/lib/scaffold-template.ts (added) +70 −0
1 +
import path from "node:path";
2 +
import degit from "degit";
3 +
import fs from "fs-extra";
4 +
import ora from "ora";
5 +
import pc from "picocolors";
6 +
import type { ProjectOptions } from "@/types";
7 +
import { DEFAULT_REPO } from "@/utils/constants";
8 +
import { TEMPLATES } from "@/utils/templates";
9 +
import { patchFilesForRPC } from "./patch-files-rpc";
10 +
import { setupBiome } from "./setup-biome";
11 +
12 +
export async function scaffoldTemplate(
13 +
  options: ProjectOptions,
14 +
): Promise<boolean> {
15 +
  const { projectName, template, repo, branch, rpc, linter } = options;
16 +
  const projectPath = path.resolve(process.cwd(), projectName);
17 +
18 +
  if (fs.existsSync(projectPath)) {
19 +
    const files = fs.readdirSync(projectPath);
20 +
    if (files.length > 0) {
21 +
      await fs.emptyDir(projectPath);
22 +
    }
23 +
  }
24 +
25 +
  fs.ensureDirSync(projectPath);
26 +
27 +
  const repoPath = repo || DEFAULT_REPO;
28 +
  const templateConfig =
29 +
    TEMPLATES[template as keyof typeof TEMPLATES] || TEMPLATES.default;
30 +
  const repoBranch = branch || (templateConfig?.branch ?? "main");
31 +
  const repoUrl = `${repoPath}#${repoBranch}`;
32 +
  const spinner = ora("Downloading template...").start();
33 +
34 +
  try {
35 +
    const emitter = degit(repoUrl, {
36 +
      cache: false,
37 +
      force: true,
38 +
      verbose: false,
39 +
    });
40 +
41 +
    await emitter.clone(projectPath);
42 +
    spinner.succeed(`Template downloaded successfully (${template} template)`);
43 +
44 +
    const pkgJsonPath = path.join(projectPath, "package.json");
45 +
    if (fs.existsSync(pkgJsonPath)) {
46 +
      const pkgJson = await fs.readJson(pkgJsonPath);
47 +
      pkgJson.name = projectName;
48 +
      await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
49 +
    }
50 +
51 +
    const gitDir = path.join(projectPath, ".git");
52 +
    if (fs.existsSync(gitDir)) {
53 +
      await fs.remove(gitDir);
54 +
      console.log(pc.blue("Removed .git directory"));
55 +
    }
56 +
57 +
    if (rpc) {
58 +
      await patchFilesForRPC(projectPath, template);
59 +
    }
60 +
61 +
    if (linter === "biome") {
62 +
      await setupBiome(projectPath);
63 +
    }
64 +
65 +
    return true;
66 +
  } catch (err) {
67 +
    spinner.fail("Failed to download template");
68 +
    throw err;
69 +
  }
70 +
}
src/types.ts +14 −13
1 1
export interface TemplateInfo {
2 -
	branch: string;
3 -
	description: string;
2 +
  branch: string;
3 +
  description: string;
4 4
}
5 5
6 6
export type ProjectOptions = {
7 -
	yes?: boolean;
8 -
	typescript?: boolean;
9 -
	repo?: string;
10 -
	template?: string;
11 -
	branch?: string;
12 -
	rpc?: boolean;
13 -
	linter?: 'eslint' | 'biome';
7 +
  projectName?: string;
8 +
  yes?: boolean;
9 +
  typescript?: boolean;
10 +
  repo?: string;
11 +
  template?: string;
12 +
  branch?: string;
13 +
  rpc?: boolean;
14 +
  linter?: "eslint" | "biome";
14 15
};
15 16
16 17
export interface ProjectResult {
17 -
	projectName: string;
18 -
	gitInitialized: boolean;
19 -
	dependenciesInstalled: boolean;
20 -
	template: string;
18 +
  projectName: string;
19 +
  gitInitialized: boolean;
20 +
  dependenciesInstalled: boolean;
21 +
  template: string;
21 22
}