@mr_hugo/boredom
Version:
Another boring JavaScript framework.
445 lines (398 loc) • 11.6 kB
text/typescript
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import spawn from "cross-spawn";
import mri from "mri";
import * as prompts from "@clack/prompts";
import colors from "picocolors";
const {
blue,
// blueBright,
cyan,
green,
// greenBright,
// magenta,
// red,
// redBright,
// reset,
yellow,
} = colors;
const argv = mri<{
template?: string;
help?: boolean;
overwrite?: boolean;
}>(process.argv.slice(2), {
alias: { h: "help", t: "template" },
boolean: ["help", "overwrite"],
string: ["template"],
});
const cwd = process.cwd();
// prettier-ignore
const helpMessage = `\
Usage: create-boredom [OPTION]... [DIRECTORY]
Create a new boreDOM project in JavaScript.
With no arguments, start the CLI in interactive mode.
Options:
-t, --template NAME use a specific template
Available templates:
${yellow("minimal")}
`;
// ${green("demo")}
// ${cyan("library")}
type ColorFunc = (str: string | number) => string;
type Template = {
name: string;
display: string;
color: ColorFunc;
variants: TemplateVariant[];
};
type TemplateVariant = {
name: string;
display: string;
color: ColorFunc;
customCommand?: string;
};
const TEMPLATES_WITH_VARIANTS: Template[] = [
{
name: "minimal",
display: "Minimal",
color: yellow,
variants: [
{
name: "minimal",
display: "Minimal",
color: yellow,
},
],
},
// {
// name: "demo",
// display: "Demo",
// color: green,
// variants: [
// {
// name: "demo",
// display: "Demo",
// color: green,
// },
// ],
// },
// {
// name: "library",
// display: "Library",
// color: cyan,
// variants: [
// {
// name: "library",
// display: "Library",
// color: blue,
// },
// ],
// },
];
const TEMPLATES = TEMPLATES_WITH_VARIANTS.map((t) =>
t.variants.map((v) => v.name)
).reduce(
(a, b) => a.concat(b),
[],
);
const renameFiles: Record<string, string | undefined> = {
_gitignore: ".gitignore",
};
const defaultTargetDir = "boredom-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,
validate: (value) => {
return value.length === 0 || formatTargetDir(value).length > 0
? undefined
: "Invalid project name";
},
});
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 framework and variant
let frameworkOptions = TEMPLATES_WITH_VARIANTS.map((framework) => {
const frameworkColor = framework.color;
return {
label: frameworkColor(framework.display || framework.name),
value: framework,
};
});
let template = argTemplate;
// If there is only 1 option, choose it right away:
if (frameworkOptions.length === 1) template = TEMPLATES[0];
let hasInvalidArgTemplate = false;
if (argTemplate && !TEMPLATES.includes(argTemplate)) {
template = undefined;
hasInvalidArgTemplate = true;
}
if (!template) {
const framework = await prompts.select({
message: hasInvalidArgTemplate
? `"${argTemplate}" isn't a valid template. Please choose from below: `
: "Select a framework:",
options: TEMPLATES_WITH_VARIANTS.map((framework) => {
const frameworkColor = framework.color;
return {
label: frameworkColor(framework.display || framework.name),
value: framework,
};
}),
});
if (prompts.isCancel(framework)) return cancel();
const variant = await prompts.select({
message: "Select a variant:",
options: framework.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 } =
TEMPLATES_WITH_VARIANTS.flatMap((t) => t.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: string, content?: string) => {
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 pkg = JSON.parse(
fs.readFileSync(path.join(templateDir, `package.json`), "utf-8"),
);
pkg.name = packageName;
write("package.json", JSON.stringify(pkg, null, 2) + "\n");
let doneMessage = "";
const cdProjectName = path.relative(cwd, root);
doneMessage += `Done. Now run:\n`;
if (root !== cwd) {
doneMessage += `\n cd ${
cdProjectName.includes(" ") ? `"${cdProjectName}"` : cdProjectName
}`;
}
switch (pkgManager) {
case "yarn":
doneMessage += "\n yarn";
doneMessage += "\n yarn dev";
break;
default:
doneMessage += `\n ${pkgManager} install`;
doneMessage += `\n ${pkgManager} run dev`;
break;
}
prompts.outro(doneMessage);
}
function formatTargetDir(targetDir: string) {
return targetDir.trim().replace(/\/+$/g, "");
}
function copy(src: string, dest: string) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
copyDir(src, dest);
} else {
fs.copyFileSync(src, dest);
}
}
function isValidPackageName(projectName: string) {
return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(
projectName,
);
}
function toValidPackageName(projectName: string) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/^[._]/, "")
.replace(/[^a-z\d\-~]+/g, "-");
}
function copyDir(srcDir: string, destDir: string) {
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: string) {
const files = fs.readdirSync(path);
return files.length === 0 || (files.length === 1 && files[0] === ".git");
}
function emptyDir(dir: string) {
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 });
}
}
interface PkgInfo {
name: string;
version: string;
}
function pkgFromUserAgent(userAgent: string | undefined): PkgInfo | undefined {
if (!userAgent) return undefined;
const pkgSpec = userAgent.split(" ")[0];
const pkgSpecArr = pkgSpec.split("/");
return {
name: pkgSpecArr[0],
version: pkgSpecArr[1],
};
}
function editFile(file: string, callback: (content: string) => string) {
const content = fs.readFileSync(file, "utf-8");
fs.writeFileSync(file, callback(content), "utf-8");
}
function getFullCustomCommand(customCommand: string, pkgInfo?: 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-";
}
// pnpm doesn't support the -- syntax
if (pkgManager === "pnpm") {
return "pnpm create ";
}
// For other package managers, preserve the original format
return customCommand.startsWith("npm create -- ")
? `${pkgManager} create -- `
: `${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);
});