/ src / index.ts
index.ts
  1  import fs from 'node:fs';
  2  import path from 'node:path';
  3  import { fileURLToPath } from 'node:url';
  4  import minimist from 'minimist';
  5  import prompts from 'prompts';
  6  
  7  const argv = minimist<{
  8    t?: string;
  9    template?: string;
 10  }>(process.argv.slice(2), { string: ['_'] });
 11  const cwd = process.cwd();
 12  
 13  const TEMPLATES = [
 14    'react-shadcn',
 15    'react-tailwind',
 16    // 'svelte-tailwind',
 17  ];
 18  
 19  const renameFiles: Record<string, string | undefined> = {
 20    _gitignore: '.gitignore',
 21  };
 22  
 23  const defaultTargetDir = 'osty-project';
 24  
 25  async function init() {
 26    const argTargetDir = formatTargetDir(argv._[0]);
 27    const argTemplate = argv.template || argv.t;
 28  
 29    let targetDir = argTargetDir || defaultTargetDir;
 30    const getProjectName = () => (targetDir === '.' ? path.basename(path.resolve()) : targetDir);
 31  
 32    let result: prompts.Answers<'projectName' | 'overwrite' | 'packageName' | 'template'>;
 33  
 34    try {
 35      result = await prompts(
 36        [
 37          {
 38            type: argTargetDir ? null : 'text',
 39            name: 'projectName',
 40            message: 'Project name:',
 41            initial: defaultTargetDir,
 42            onState: (state) => {
 43              targetDir = formatTargetDir(state.value) || defaultTargetDir;
 44            },
 45          },
 46          {
 47            type: () => (!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'select'),
 48            name: 'overwrite',
 49            message: () =>
 50              (targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`) +
 51              ` is not empty. Please choose how to proceed:`,
 52            initial: 0,
 53            choices: [
 54              {
 55                title: 'Remove existing files and continue',
 56                value: 'yes',
 57              },
 58              {
 59                title: 'Cancel operation',
 60                value: 'no',
 61              },
 62              {
 63                title: 'Ignore files and continue',
 64                value: 'ignore',
 65              },
 66            ],
 67          },
 68          {
 69            type: (_, { overwrite }: { overwrite?: string }) => {
 70              if (overwrite === 'no') {
 71                throw new Error('Operation cancelled!');
 72              }
 73              return null;
 74            },
 75            name: 'overwriteChecker',
 76          },
 77          {
 78            type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
 79            name: 'packageName',
 80            message: 'Package name:',
 81            initial: () => toValidPackageName(getProjectName()),
 82            validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name',
 83          },
 84          {
 85            type: argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select',
 86            name: 'template',
 87            message:
 88              typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate)
 89                ? `"${argTemplate}" isn't a valid template. Please choose from below: `
 90                : 'Select a template:',
 91            initial: 0,
 92            choices: TEMPLATES.map((template) => {
 93              return {
 94                title: template,
 95                value: template,
 96              };
 97            }),
 98          },
 99        ],
100        {
101          onCancel: () => {
102            throw new Error('Operation cancelled!');
103          },
104        }
105      );
106    } catch (cancelled: any) {
107      console.log(cancelled.message);
108      return;
109    }
110  
111    // user choice associated with prompts
112    const { template = argTemplate, overwrite, packageName } = result;
113  
114    const root = path.join(cwd, targetDir);
115  
116    if (overwrite === 'yes') {
117      emptyDir(root);
118    } else if (!fs.existsSync(root)) {
119      fs.mkdirSync(root, { recursive: true });
120    }
121  
122    const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent);
123    const pkgManager = pkgInfo ? pkgInfo.name : 'npm';
124  
125    console.log(`\nCreating project in ${root}...`);
126  
127    const templateDir = path.resolve(
128      fileURLToPath(import.meta.url),
129      '../../templates',
130      `${template}`
131    );
132  
133    const write = (file: string, content?: string) => {
134      const targetPath = path.join(root, renameFiles[file] ?? file);
135      if (content) {
136        fs.writeFileSync(targetPath, content);
137      } else {
138        copy(path.join(templateDir, file), targetPath);
139      }
140    };
141  
142    const files = fs.readdirSync(templateDir);
143    for (const file of files.filter((f) => f !== 'package.json')) {
144      write(file);
145    }
146  
147    const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'));
148  
149    pkg.name = packageName || getProjectName();
150  
151    write('package.json', JSON.stringify(pkg, null, 2) + '\n');
152  
153    const cdProjectName = path.relative(cwd, root);
154    console.log(`\nDone. Now run:\n`);
155    if (root !== cwd) {
156      console.log(`  cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`);
157    }
158    switch (pkgManager) {
159      case 'yarn':
160        console.log('  yarn');
161        console.log('  yarn dev');
162        break;
163      default:
164        console.log(`  ${pkgManager} install`);
165        console.log(`  ${pkgManager} run dev`);
166        break;
167    }
168    console.log();
169  }
170  
171  function formatTargetDir(targetDir: string | undefined) {
172    return targetDir?.trim().replace(/\/+$/g, '');
173  }
174  
175  function copy(src: string, dest: string) {
176    const stat = fs.statSync(src);
177    if (stat.isDirectory()) {
178      copyDir(src, dest);
179    } else {
180      fs.copyFileSync(src, dest);
181    }
182  }
183  
184  function isValidPackageName(projectName: string) {
185    return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(projectName);
186  }
187  
188  function toValidPackageName(projectName: string) {
189    return projectName
190      .trim()
191      .toLowerCase()
192      .replace(/\s+/g, '-')
193      .replace(/^[._]/, '')
194      .replace(/[^a-z\d\-~]+/g, '-');
195  }
196  
197  function copyDir(srcDir: string, destDir: string) {
198    fs.mkdirSync(destDir, { recursive: true });
199    for (const file of fs.readdirSync(srcDir)) {
200      const srcFile = path.resolve(srcDir, file);
201      const destFile = path.resolve(destDir, file);
202      copy(srcFile, destFile);
203    }
204  }
205  
206  function isEmpty(path: string) {
207    const files = fs.readdirSync(path);
208    return files.length === 0 || (files.length === 1 && files[0] === '.git');
209  }
210  
211  function emptyDir(dir: string) {
212    if (!fs.existsSync(dir)) {
213      return;
214    }
215    for (const file of fs.readdirSync(dir)) {
216      if (file === '.git') {
217        continue;
218      }
219      fs.rmSync(path.resolve(dir, file), { recursive: true, force: true });
220    }
221  }
222  
223  function pkgFromUserAgent(userAgent: string | undefined) {
224    if (!userAgent) return undefined;
225    const pkgSpec = userAgent.split(' ')[0];
226    const pkgSpecArr = pkgSpec.split('/');
227    return {
228      name: pkgSpecArr[0],
229      version: pkgSpecArr[1],
230    };
231  }
232  
233  init().catch((e) => {
234    console.error(e);
235  });