UNPKG

create-vanjs

Version:

🍦 Quick tool for scaffolding your first VanJS project

605 lines (559 loc) 14.1 kB
#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import spawn from "cross-spawn"; import minimist from "minimist"; import * as prompts from "@clack/prompts"; import colors from "picocolors"; const { blue, cyan, green, magenta, yellow, } = colors; const argv = minimist(process.argv.slice(2), { string: ["_"] }); const cwd = process.cwd(); // prettier-ignore const helpMessage = `\ Usage: create-vanjs [OPTION]... [DIRECTORY] Create a new VanJS project in JavaScript or TypeScript. With no arguments, start the CLI in interactive mode. Options: -t, --template NAME use a specific template Available templates: ${green("node-base-ts node-base")} ${green("node-routing-ts node-routing")} ${green("node-fs-routing-ts node-fs-routing")} ${green("node-jsx-ts node-jsx")} ${green("node-ssr-ts node-ssr")} ${green("node-ssr-jsx-ts node-ssr-jsx")} ${magenta("deno-base-ts deno-base")} ${magenta("deno-routing-ts deno-routing")} ${magenta("deno-fs-routing-ts deno-fs-routing")} ${magenta("deno-jsx-ts deno-jsx")} ${magenta("deno-ssr-ts deno-ssr")} ${magenta("deno-ssr-jsx-ts deno-ssr-jsx")} ${cyan("vike-ts vike")} ${cyan("vike-jsx-ts vike-jsx")} `; const ALL_TEMPLATES = [ { name: "node-base", color: green, variants: [ { name: "node-base", display: "JavaScript", color: yellow, }, { name: "node-base-ts", display: "TypeScript", color: blue, }, ], }, { name: "node-routing", color: green, variants: [ { name: "node-routing", display: "JavaScript", color: yellow, }, { name: "node-routing-ts", display: "TypeScript", color: blue, }, ], }, { name: "node-fs-routing", color: green, variants: [ { name: "node-fs-routing", display: "JavaScript", color: yellow, }, { name: "node-fs-routing-ts", display: "TypeScript", color: blue, }, ], }, { name: "node-jsx", color: green, variants: [ { name: "node-jsx", display: "JavaScript", color: yellow, }, { name: "node-jsx-ts", display: "TypeScript", color: blue, }, ], }, { name: "node-ssr", color: green, variants: [ { name: "node-ssr", display: "JavaScript", color: yellow, }, { name: "node-ssr-ts", display: "TypeScript", color: blue, }, ], }, { name: "node-ssr-jsx", color: green, variants: [ { name: "node-ssr-jsx", display: "JavaScript", color: yellow, }, { name: "node-ssr-jsx-ts", display: "TypeScript", color: blue, }, ], }, { name: "vike", color: cyan, variants: [ { name: "vike", display: "JavaScript", color: yellow, }, { name: "vike-ts", display: "TypeScript", color: blue, }, ], }, { name: "vike-jsx", color: cyan, variants: [ { name: "vike-jsx", display: "JavaScript", color: yellow, }, { name: "vike-jsx-ts", display: "TypeScript", color: blue, }, ], }, { name: "deno-base", color: magenta, variants: [ { name: "deno-base", display: "JavaScript", color: yellow, }, { name: "deno-base-ts", display: "TypeScript", color: blue, }, ], }, { name: "deno-routing", color: magenta, variants: [ { name: "deno-routing", display: "JavaScript", color: yellow, }, { name: "deno-routing-ts", display: "TypeScript", color: blue, }, ], }, { name: "deno-fs-routing", color: magenta, variants: [ { name: "deno-fs-routing", display: "JavaScript", color: yellow, }, { name: "deno-fs-routing-ts", display: "TypeScript", color: blue, }, ], }, { name: "deno-ssr", color: magenta, variants: [ { name: "deno-ssr", display: "JavaScript", color: yellow, }, { name: "deno-ssr-ts", display: "TypeScript", color: blue, }, ], }, { name: "deno-jsx", color: magenta, variants: [ { name: "deno-jsx", display: "JavaScript", color: yellow, }, { name: "deno-jsx-ts", display: "TypeScript", color: blue, }, ], }, { name: "deno-ssr-jsx", color: magenta, variants: [ { name: "deno-ssr-jsx", display: "JavaScript", color: yellow, }, { name: "deno-ssr-jsx-ts", display: "TypeScript", color: blue, }, ], }, ]; const TEMPLATES = ALL_TEMPLATES.map((f) => f.variants.map((v) => v.name)) .reduce( (a, b) => a.concat(b), [], ); const renameFiles = { _gitignore: ".gitignore", }; const defaultTargetDir = "vanjs-project"; async function init() { const argTargetDir = argv._[0] ? formatTargetDir(String(argv._[0])) : undefined; const argTemplate = argv.template; const argOverwrite = argv.overwrite; const help = argv.help; if (help) { console.log(helpMessage); return; } const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); const cancel = () => prompts.cancel("Operation cancelled"); // 1. Get project name and target dir let targetDir = argTargetDir; if (!targetDir) { const projectName = await prompts.text({ message: "Project name:", defaultValue: defaultTargetDir, placeholder: defaultTargetDir, }); if (prompts.isCancel(projectName)) return cancel(); targetDir = formatTargetDir(projectName); } // 2. Handle directory if exist and not empty if (fs.existsSync(targetDir) && !isEmpty(targetDir)) { const overwrite = argOverwrite ? "yes" : await prompts.select({ message: (targetDir === "." ? "Current directory" : `Target directory "${targetDir}"`) + ` is not empty. Please choose how to proceed:`, options: [ { label: "Cancel operation", value: "no", }, { label: "Remove existing files and continue", value: "yes", }, { label: "Ignore files and continue", value: "ignore", }, ], }); if (prompts.isCancel(overwrite)) return cancel(); switch (overwrite) { case "yes": emptyDir(targetDir); break; case "no": cancel(); return; } } // 3. Get package name let packageName = path.basename(path.resolve(targetDir)); if (!isValidPackageName(packageName)) { const packageNameResult = await prompts.text({ message: "Package name:", defaultValue: toValidPackageName(packageName), placeholder: toValidPackageName(packageName), validate(dir) { if (!isValidPackageName(dir)) { return "Invalid package.json name"; } }, }); if (prompts.isCancel(packageNameResult)) return cancel(); packageName = packageNameResult; } // 4. Choose a template and variant let template = argTemplate; let hasInvalidArgTemplate = false; if (argTemplate && !TEMPLATES.includes(argTemplate)) { template = undefined; hasInvalidArgTemplate = true; } if (!template) { const tplStack = await prompts.select({ message: hasInvalidArgTemplate ? `"${argTemplate}" isn't a valid template. Please choose from below: ` : "Select a template:", options: ALL_TEMPLATES.map((tpl) => { const tplColor = tpl.color; return { label: tplColor(tpl.display || tpl.name), value: tpl, }; }), }); if (prompts.isCancel(tplStack)) return cancel(); const variant = await prompts.select({ message: "Select a variant:", options: tplStack.variants.map((variant) => { const variantColor = variant.color; const command = variant.customCommand ? getFullCustomCommand(variant.customCommand, pkgInfo).replace( / TARGET_DIR$/, "", ) : undefined; return { label: variantColor(variant.display || variant.name), value: variant.name, hint: command, }; }), }); if (prompts.isCancel(variant)) return cancel(); template = variant; } const root = path.join(cwd, targetDir); fs.mkdirSync(root, { recursive: true }); const pkgManager = pkgInfo ? pkgInfo.name : "npm"; const { customCommand } = ALL_TEMPLATES.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}; if (customCommand) { const fullCustomCommand = getFullCustomCommand(customCommand, pkgInfo); const [command, ...args] = fullCustomCommand.split(" "); // we replace TARGET_DIR here because targetDir may include a space const replacedArgs = args.map((arg) => arg.replace("TARGET_DIR", () => targetDir) ); const { status } = spawn.sync(command, replacedArgs, { stdio: "inherit", }); process.exit(status ?? 0); } prompts.log.step(`Scaffolding project in ${root}...`); const templateDir = path.resolve( fileURLToPath(import.meta.url), "..", `template-${template}`, ); const write = (file, content) => { const targetPath = path.join(root, renameFiles[file] ?? file); if (content) { fs.writeFileSync(targetPath, content); } else { copy(path.join(templateDir, file), targetPath); } }; const files = fs.readdirSync(templateDir); for (const file of files.filter((f) => f !== "package.json")) { write(file); } const isDeno = template.startsWith("deno-"); if (!isDeno) { const pkg = JSON.parse( fs.readFileSync(path.join(templateDir, `package.json`), "utf-8"), ); pkg.name = packageName; let packageJSON = JSON.stringify(pkg, null, 2); if (pkgManager !== "npm") { packageJSON = packageJSON.replace(/npm run/gm, pkgManager + " run"); } write("package.json", packageJSON + "\n"); } let doneMessage = ""; const cdProjectName = path.relative(cwd, root); doneMessage += `Done. Now run:\n`; if (root !== cwd) { doneMessage += `\n cd ${ cdProjectName.includes(" ") ? `"${cdProjectName}"` : cdProjectName }`; } if (!isDeno) { switch (pkgManager) { case "yarn": doneMessage += "\n yarn"; doneMessage += "\n yarn dev"; break; default: doneMessage += `\n ${pkgManager} install`; doneMessage += `\n ${pkgManager} run dev`; break; } } else { doneMessage += `\n deno task dev`; } prompts.outro(doneMessage); } function formatTargetDir(targetDir) { return targetDir.trim().replace(/\/+$/g, ""); } function copy(src, dest) { const stat = fs.statSync(src); if (stat.isDirectory()) { copyDir(src, dest); } else { if ( !["pnpm-lock", "bun.lockb", "yarn.lock", "package-lock"] .some((str) => src.includes(str)) ) { fs.copyFileSync(src, dest); } } } function isValidPackageName(projectName) { return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( projectName, ); } function toValidPackageName(projectName) { return projectName .trim() .toLowerCase() .replace(/\s+/g, "-") .replace(/^[._]/, "") .replace(/[^a-z\d\-~]+/g, "-"); } function copyDir(srcDir, destDir) { // don't copy the node_modules and dist folders ? // if (["node_modules/", "dist/"].some((str) => srcDir.includes(str))) return; fs.mkdirSync(destDir, { recursive: true }); for (const file of fs.readdirSync(srcDir)) { const srcFile = path.resolve(srcDir, file); const destFile = path.resolve(destDir, file); copy(srcFile, destFile); } } function isEmpty(path) { const files = fs.readdirSync(path); return files.length === 0 || (files.length === 1 && files[0] === ".git"); } function emptyDir(dir) { if (!fs.existsSync(dir)) { return; } for (const file of fs.readdirSync(dir)) { if (file === ".git") { continue; } fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); } } function pkgFromUserAgent(userAgent) { if (!userAgent) return undefined; const pkgSpec = userAgent.split(" ")[0]; const pkgSpecArr = pkgSpec.split("/"); return { name: pkgSpecArr[0], version: pkgSpecArr[1], }; } function getFullCustomCommand(customCommand, pkgInfo) { const pkgManager = pkgInfo ? pkgInfo.name : "npm"; const isYarn1 = pkgManager === "yarn" && pkgInfo?.version.startsWith("1."); return ( customCommand .replace(/^npm create /, () => { // `bun create` uses it's own set of templates, // the closest alternative is using `bun x` directly on the package if (pkgManager === "bun") { return "bun x create-"; } return `${pkgManager} create `; }) // Only Yarn 1.x doesn't support `@version` in the `create` command .replace("@latest", () => (isYarn1 ? "" : "@latest")) .replace(/^npm exec/, () => { // Prefer `pnpm dlx`, `yarn dlx`, or `bun x` if (pkgManager === "pnpm") { return "pnpm dlx"; } if (pkgManager === "yarn" && !isYarn1) { return "yarn dlx"; } if (pkgManager === "bun") { return "bun x"; } // Use `npm exec` in all other cases, // including Yarn 1.x and other custom npm clients. return "npm exec"; }) ); } init().catch((e) => { console.error(e); });