Skip to content

Commit 7452960

Browse files
committed
feat: create cli tool
1 parent ac92140 commit 7452960

File tree

3 files changed

+254
-0
lines changed

3 files changed

+254
-0
lines changed

index.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env node
2+
3+
import "./dist/index.mjs";

src/index.ts

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

tsconfig.json

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["dom", "es2018", "esnext.array"],
4+
"alwaysStrict": true,
5+
"allowSyntheticDefaultImports": true,
6+
"allowUnreachableCode": false,
7+
"declaration": true,
8+
"forceConsistentCasingInFileNames": true,
9+
"noImplicitAny": false,
10+
"noImplicitOverride": true,
11+
"baseUrl": ".",
12+
"pretty": true,
13+
"module": "esnext",
14+
"moduleResolution": "node",
15+
"resolveJsonModule": true,
16+
"sourceMap": true,
17+
"target": "ES6",
18+
"noImplicitReturns": true,
19+
"noUnusedLocals": true,
20+
"noUnusedParameters": false,
21+
},
22+
"exclude": ["**/starter-*", "**/starter-*/**/*"],
23+
}

0 commit comments

Comments
 (0)