index.js 13.4 K raw
1
#!/usr/bin/env node
2
3
import fs from 'fs-extra';
4
import path from 'path';
5
import { fileURLToPath } from 'url';
6
import prompts from 'prompts';
7
import { program } from 'commander';
8
import chalk from 'chalk';
9
import ora from 'ora';
10
import { execa } from 'execa';
11
import degit from 'degit';
12
import figlet from 'figlet';
13
14
const __filename = fileURLToPath(import.meta.url);
15
const __dirname = path.dirname(__filename);
16
17
// GitHub repository for the template
18
const DEFAULT_REPO = 'stevedylandev/bhvr';
19
20
// Available templates
21
const TEMPLATES = {
22
  default: { branch: 'main', description: 'Basic setup with Bun, Hono, Vite and React' },
23
  tailwind: { branch: 'tailwindcss', description: 'Basic setup + TailwindCSS' },
24
  shadcn: { branch: 'shadcn-ui', description: 'Basic setup + TailwindCSS + shadcn/ui' }
25
};
26
27
// Function to display a fun banner
28
function displayBanner() {
29
  const text = figlet.textSync('bhvr', {
30
    font: 'Big',
31
    horizontalLayout: 'default',
32
    verticalLayout: 'default',
33
    width: 80,
34
    whitespaceBreak: true
35
  });
36
37
  console.log('\n');
38
  console.log(chalk.yellowBright(text));
39
  console.log(`\n${chalk.cyan('🦫 Lets build 🦫')}\n`);
40
  console.log(`${chalk.blue('https://github.com/stevedylandev/bhvr')}\n`);
41
}
42
43
// Set up the CLI program
44
program
45
  .name('create-bhvr')
46
  .description('Create a bhvr monorepo starter project')
47
  .argument('[project-directory]', 'directory to create the project in')
48
  .option('-y, --yes', 'skip confirmation prompts')
49
  .option('--ts, --typescript', 'use TypeScript (default)')
50
  .option('--repo <repo>', 'specify a custom GitHub repository as source', DEFAULT_REPO)
51
  .option('--template <template>', 'specify a template (default, tailwind, shadcn)', 'default')
52
  .option('--branch <branch>', 'specify a branch to use from the repository')
53
  .option('--rpc', 'use Hono RPC client for type-safe API communication')
54
  .action(async (projectDirectory, options) => {
55
    try {
56
      displayBanner();
57
      const result = await createProject(projectDirectory, options);
58
      if (result) {
59
        console.log(chalk.green.bold('🎉 Project created successfully!'));
60
        console.log('\nNext steps:');
61
62
        if (!result.dependenciesInstalled) {
63
          console.log(chalk.cyan(`  cd ${result.projectName}`));
64
          console.log(chalk.cyan('  bun install'));
65
        } else {
66
          console.log(chalk.cyan(`  cd ${result.projectName}`));
67
        }
68
69
        console.log(chalk.cyan('  bun run dev:client   # Start the client'));
70
        console.log(chalk.cyan('  bun run dev:server   # Start the server in another terminal'));
71
        console.log(chalk.cyan('  bun run dev          # Start all'));
72
        process.exit(0);
73
      }
74
    } catch (err) {
75
      console.error(chalk.red('Error creating project:'), err);
76
      process.exit(1);
77
    }
78
  });
79
80
program.parse();
81
82
async function createProject(projectDirectory, options) {
83
  // If project directory not provided, prompt for it
84
  let projectName = projectDirectory;
85
86
  if (!projectName && !options.yes) {
87
    const response = await prompts({
88
      type: 'text',
89
      name: 'projectName',
90
      message: 'What is the name of your project?',
91
      initial: 'my-bhvr-app'
92
    });
93
94
    if (!response.projectName) {
95
      console.log(chalk.yellow('Project creation cancelled.'));
96
      return null;
97
    }
98
99
    projectName = response.projectName;
100
  } else if (!projectName) {
101
    projectName = 'my-bhvr-app';
102
  }
103
104
  // Template selection
105
  let templateChoice = options.template;
106
107
  if (!options.yes && !options.branch) {
108
    const templateChoices = Object.keys(TEMPLATES).map(key => ({
109
      title: `${key} (${TEMPLATES[key].description})`,
110
      value: key
111
    }));
112
113
    const templateResponse = await prompts({
114
      type: 'select',
115
      name: 'template',
116
      message: 'Select a template:',
117
      choices: templateChoices,
118
      initial: 0
119
    });
120
121
    if (templateResponse.template === undefined) {
122
      console.log(chalk.yellow('Project creation cancelled.'));
123
      return null;
124
    }
125
126
    templateChoice = templateResponse.template;
127
  }
128
129
  // Create the project directory
130
  const projectPath = path.resolve(process.cwd(), projectName);
131
132
  // Check if directory exists and is not empty
133
  if (fs.existsSync(projectPath)) {
134
    const files = fs.readdirSync(projectPath);
135
136
    if (files.length > 0 && !options.yes) {
137
      const { overwrite } = await prompts({
138
        type: 'confirm',
139
        name: 'overwrite',
140
        message: `The directory ${projectName} already exists and is not empty. Do you want to overwrite it?`,
141
        initial: false
142
      });
143
144
      if (!overwrite) {
145
        console.log(chalk.yellow('Project creation cancelled.'));
146
        return null;
147
      }
148
149
      // Clear directory if overwriting
150
      await fs.emptyDir(projectPath);
151
    }
152
  }
153
154
  // Create directory if it doesn't exist
155
  fs.ensureDirSync(projectPath);
156
157
  // Clone template from GitHub
158
  const repoPath = options.repo || DEFAULT_REPO;
159
  // Use provided branch, template branch, or default
160
  const branch = options.branch || (TEMPLATES[templateChoice] ? TEMPLATES[templateChoice].branch : 'main');
161
  const repoUrl = `${repoPath}#${branch}`;
162
163
  const spinner = ora('Downloading template...').start();
164
165
  try {
166
    const emitter = degit(repoUrl, {
167
      cache: false,
168
      force: true,
169
      verbose: false,
170
    });
171
172
    await emitter.clone(projectPath);
173
    spinner.succeed(`Template downloaded successfully (${templateChoice} template)`);
174
175
    // Update package.json with project name
176
    const pkgJsonPath = path.join(projectPath, 'package.json');
177
    if (fs.existsSync(pkgJsonPath)) {
178
      const pkgJson = await fs.readJson(pkgJsonPath);
179
      pkgJson.name = projectName;
180
      await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
181
    }
182
183
    // Remove the .git directory if it exists
184
    const gitDir = path.join(projectPath, '.git');
185
    if (fs.existsSync(gitDir)) {
186
      await fs.remove(gitDir);
187
      console.log(chalk.blue('Removed .git directory'));
188
    }
189
190
    if (options.rpc) {
191
      await patchFilesForRPC(projectPath);
192
    }
193
194
    // Initialize git repository?
195
    let gitInitialized = false;
196
197
    if (!options.yes) {
198
      const gitResponse = await prompts({
199
        type: 'confirm',
200
        name: 'initGit',
201
        message: 'Initialize a git repository?',
202
        initial: true
203
      });
204
205
      if (gitResponse.initGit) {
206
        try {
207
          spinner.start('Initializing git repository...');
208
          await execa('git', ['init'], { cwd: projectPath });
209
          await execa('git', ['add', '.'], { cwd: projectPath });
210
          await execa('git', ['commit', '-m', 'Initial commit from create-bhvr'], { cwd: projectPath });
211
          spinner.succeed('Git repository initialized');
212
          gitInitialized = true;
213
        } catch (err) {
214
          spinner.fail('Failed to initialize git repository. Is git installed?');
215
          console.error(chalk.red('Git error:'), err.message);
216
        }
217
      }
218
    } else {
219
      // If using --yes, automatically initialize git
220
      try {
221
        spinner.start('Initializing git repository...');
222
        await execa('git', ['init'], { cwd: projectPath });
223
        await execa('git', ['add', '.'], { cwd: projectPath });
224
        await execa('git', ['commit', '-m', 'Initial commit from create-bhvr'], { cwd: projectPath });
225
        spinner.succeed('Git repository initialized');
226
        gitInitialized = true;
227
      } catch (err) {
228
        spinner.fail('Failed to initialize git repository. Is git installed?');
229
      }
230
    }
231
232
    // Install dependencies?
233
    let dependenciesInstalled = false;
234
235
    if (!options.yes) {
236
      const depsResponse = await prompts({
237
        type: 'confirm',
238
        name: 'installDeps',
239
        message: 'Install dependencies?',
240
        initial: true
241
      });
242
243
      if (depsResponse.installDeps) {
244
        spinner.start('Installing dependencies...');
245
        try {
246
          // Try with bun first
247
          await execa('bun', ['install'], { cwd: projectPath });
248
          spinner.succeed('Dependencies installed with bun');
249
          dependenciesInstalled = true;
250
        } catch (bunErr) {
251
          // If bun fails, try with npm
252
          try {
253
            spinner.text = 'Installing dependencies with npm...';
254
            await execa('npm', ['install'], { cwd: projectPath });
255
            spinner.succeed('Dependencies installed with npm');
256
            dependenciesInstalled = true;
257
          } catch (npmErr) {
258
            spinner.fail('Failed to install dependencies.');
259
            console.log(chalk.yellow('You can install them manually after navigating to the project directory.'));
260
          }
261
        }
262
      }
263
    } else {
264
      // If using --yes, automatically install dependencies
265
      spinner.start('Installing dependencies...');
266
      try {
267
        await execa('bun', ['install'], { cwd: projectPath });
268
        spinner.succeed('Dependencies installed with bun');
269
        dependenciesInstalled = true;
270
      } catch (bunErr) {
271
        try {
272
          spinner.text = 'Installing dependencies with npm...';
273
          await execa('npm', ['install'], { cwd: projectPath });
274
          spinner.succeed('Dependencies installed with npm');
275
          dependenciesInstalled = true;
276
        } catch (npmErr) {
277
          spinner.fail('Failed to install dependencies. You can install them manually later.');
278
        }
279
      }
280
    }
281
282
    if (!options.yes && !options.rpc) {
283
      const { useRpc } = await prompts({
284
        type: 'confirm',
285
        name: 'useRpc',
286
        message: 'Use Hono RPC client for type-safe API communication?',
287
        initial: false
288
      });
289
290
      if (useRpc) {
291
        await patchFilesForRPC(projectPath);
292
      }
293
    }
294
295
    return {
296
      projectName,
297
      gitInitialized,
298
      dependenciesInstalled,
299
      template: templateChoice,
300
    };
301
  } catch (err) {
302
    spinner.fail('Failed to download template');
303
    throw err;
304
  }
305
}
306
307
async function patchFilesForRPC(projectPath) {
308
  const spinner = ora('Setting up RPC client...').start();
309
310
  try {
311
    // 1. Update client package.json to ensure hono client is installed
312
    const clientPkgPath = path.join(projectPath, 'client', 'package.json');
313
    const clientPkg = await fs.readJson(clientPkgPath);
314
315
    // Make sure hono client is in dependencies
316
    if (!clientPkg.dependencies.hono) {
317
      clientPkg.dependencies.hono = "^4.7.7";
318
    }
319
320
    await fs.writeJson(clientPkgPath, clientPkg, { spaces: 2 });
321
322
    // 2. Server modification - targeted approach based on known structure
323
    const serverIndexPath = path.join(projectPath, 'server', 'src', 'index.ts');
324
    let serverContent = await fs.readFile(serverIndexPath, 'utf8');
325
326
    // If the server doesn't already have the RPC structure, update it
327
    if (!serverContent.includes('export type AppType')) {
328
      // Create the target server content based on the template
329
      const updatedServerContent = `import { Hono } from 'hono'
330
import { cors } from 'hono/cors'
331
import type { ApiResponse } from 'shared/dist'
332
333
const app = new Hono()
334
335
app.use(cors())
336
337
const routes = app.get('/', (c) => {
338
  return c.text('Hello Hono!')
339
})
340
341
.get('/hello', async (c) => {
342
343
  const data: ApiResponse = {
344
    message: "Hello BHVR!",
345
    success: true
346
  }
347
348
  return c.json(data, { status: 200 })
349
})
350
351
export type AppType = typeof routes
352
export default app`;
353
354
      await fs.writeFile(serverIndexPath, updatedServerContent, 'utf8');
355
    }
356
357
    // 3. Update App.tsx with RPC implementation
358
    const appTsxPath = path.join(projectPath, 'client', 'src', 'App.tsx');
359
    let appTsxContent = await fs.readFile(appTsxPath, 'utf8');
360
361
    // Only make changes if RPC isn't already set up
362
    if (!appTsxContent.includes('import { hc } from \'hono/client\'')) {
363
      // Find the key parts of the file we need to preserve
364
      const importReactMatch = appTsxContent.match(/import\s+{\s*useState\s*}.*?from\s+['"]react['"]/);
365
      const importBeaverMatch = appTsxContent.match(/import\s+beaver\s+from\s+['"]\.\/assets\/beaver\.svg['"]/);
366
      const importSharedMatch = appTsxContent.match(/import.*?from\s+['"]shared['"]/);
367
      const importCssMatch = appTsxContent.match(/import\s+['"]\.\/App\.css['"]/);
368
369
      // Make sure we found the required parts
370
      if (importReactMatch && importBeaverMatch && importCssMatch) {
371
        // Get the current return JSX part
372
        const returnJsxMatch = appTsxContent.match(/return\s*\(\s*<>([\s\S]*?)<\/>/);
373
374
        if (returnJsxMatch) {
375
          // Create the updated App.tsx content
376
          const updatedAppContent = `import { useState } from 'react'
377
import beaver from './assets/beaver.svg'
378
import type { AppType } from '../../server/src'
379
import { hc } from 'hono/client'
380
${importSharedMatch ? importSharedMatch[0] : 'import { ApiResponse } from \'shared\''}
381
import './App.css'
382
383
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000"
384
385
const client = hc<AppType>(SERVER_URL);
386
387
type ResponseType = Awaited<ReturnType<typeof client.hello.$get>>;
388
389
function App() {
390
  const [data, setData] = useState<Awaited<ReturnType<ResponseType["json"]>> | undefined>()
391
392
  async function sendRequest() {
393
    try {
394
      const res = await client.hello.$get()
395
396
      if(!res.ok){
397
        console.log("Error fetching data")
398
        return
399
      }
400
401
      const data = await res.json()
402
      setData(data)
403
    } catch (error) {
404
      console.log(error)
405
    }
406
  }
407
408
  ${returnJsxMatch[0]}
409
410
  )
411
}
412
export default App`;
413
414
          await fs.writeFile(appTsxPath, updatedAppContent, 'utf8');
415
        }
416
      }
417
    }
418
419
    spinner.succeed('RPC client setup completed');
420
    return true;
421
  } catch (err) {
422
    spinner.fail('Failed to set up RPC client');
423
    console.error(chalk.red('Error:'), err.message);
424
    return false;
425
  }
426
}