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 });