Skip to content

Commit da35b8b

Browse files
committed
feat: add create-skyroc
1 parent 16537fb commit da35b8b

File tree

13 files changed

+688
-0
lines changed

13 files changed

+688
-0
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "create-skyroc",
3+
"type": "module",
4+
"version": "0.0.1",
5+
"description": "Skyroc's command line to create different project templates",
6+
"author": {
7+
"name": "Ohh",
8+
"email": "1509326266@qq.com",
9+
"url": "https://github.com/Ohh-889"
10+
},
11+
"license": "MIT",
12+
"homepage": "https://github.com/Ohh-889/soybean-react-ui",
13+
"repository": {
14+
"url": "https://github.com/Ohh-889/soybean-react-ui.git"
15+
},
16+
"bugs": {
17+
"url": "https://github.com/Ohh-889/soybean-react-ui/issues"
18+
},
19+
"publishConfig": {
20+
"registry": "https://registry.npmjs.org/",
21+
"bin": {
22+
"create-skyroc": "dist/index.js"
23+
}
24+
},
25+
"bin": {
26+
"create-skyroc": "./src/bin.js"
27+
},
28+
"files": [
29+
"dist",
30+
"template-*"
31+
],
32+
"scripts": {
33+
"build": "pnpm typecheck && pnpm build-only",
34+
"build-only": "tsdown",
35+
"publish-pkg": "pnpm publish --access public --no-git-checks",
36+
"typecheck": "tsc --noEmit --skipLibCheck"
37+
},
38+
"dependencies": {
39+
"@clack/prompts": "0.11.0",
40+
"consola": "3.4.2",
41+
"cross-spawn": "7.0.6",
42+
"kolorist": "1.8.0",
43+
"mri": "1.2.0",
44+
"ora": "^8.2.0"
45+
},
46+
"devDependencies": {
47+
"@types/cross-spawn": "6.0.6",
48+
"tsdown": "0.12.9",
49+
"tsx": "4.20.3",
50+
"typescript": "5.8.3"
51+
}
52+
}

internal/create-skyroc/src/bin.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { require } from 'tsx/cjs/api';
2+
3+
require('./index.ts', import.meta.url);
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
#!/usr/bin/env node
2+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3+
import { basename, join, relative, resolve } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
import * as prompts from '@clack/prompts';
7+
import { consola } from 'consola';
8+
import { cyan, green, reset, yellow } from 'kolorist';
9+
import mri from 'mri';
10+
11+
import { installDeps, printNextSteps, runDev } from './installUtils';
12+
import type { PMName } from './installUtils';
13+
import {
14+
copy,
15+
emptyDir,
16+
formatTargetDir,
17+
isEmpty,
18+
isValidPackageName,
19+
pkgFromUserAgent,
20+
toValidPackageName
21+
} from './shared';
22+
23+
type TemplateType = 'ui-primitives';
24+
25+
type ColorFunc = (str: string | number) => string;
26+
27+
interface Template {
28+
color: ColorFunc;
29+
name: string;
30+
type: TemplateType;
31+
}
32+
33+
interface Args {
34+
help?: boolean;
35+
install?: boolean;
36+
overwrite?: boolean;
37+
pm?: PMName;
38+
'run-dev'?: boolean;
39+
template?: string;
40+
}
41+
42+
const templates: Template[] = [
43+
{
44+
color: green,
45+
name: 'UI Primitives',
46+
type: 'ui-primitives'
47+
}
48+
];
49+
50+
const TEMPLATES = templates.map(t => t.type);
51+
52+
const renameFiles: Record<string, string | undefined> = {
53+
_gitignore: '.gitignore'
54+
};
55+
56+
const argv = mri<Args>(process.argv.slice(2), {
57+
alias: { h: 'help', i: 'install', t: 'template' },
58+
boolean: ['help', 'overwrite', 'install', 'run-dev'],
59+
string: ['template', 'pm']
60+
});
61+
62+
const cwd = process.cwd();
63+
64+
// prettier-ignore
65+
const helpMessage = `\
66+
Usage: create-vite [OPTION]... [DIRECTORY]
67+
68+
Create a new Vite project in JavaScript or TypeScript.
69+
With no arguments, start the CLI in interactive mode.
70+
71+
Options:
72+
-t, --template NAME use a specific template
73+
74+
Available templates:
75+
${yellow ('vanilla-ts vanilla' )}
76+
${green ('vue-ts vue' )}
77+
${cyan ('react-ts react' )}
78+
${cyan ('react-swc-ts react-swc')}`
79+
80+
const defaultTargetDir = 'skyroc-project';
81+
82+
// eslint-disable-next-line complexity
83+
async function setupCli() {
84+
const help = argv.help;
85+
86+
if (help) {
87+
console.log(helpMessage);
88+
89+
return;
90+
}
91+
92+
const argTargetDir = argv._[0] ? formatTargetDir(String(argv._[0])) : undefined;
93+
94+
const argTemplate = argv.template;
95+
96+
const argOverwrite = argv.overwrite;
97+
98+
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent);
99+
100+
const cancel = () => prompts.cancel('Operation cancelled');
101+
102+
// 1. Get project name and target dir
103+
let targetDir = argTargetDir;
104+
105+
if (!targetDir) {
106+
const projectName = await prompts.text({
107+
defaultValue: defaultTargetDir,
108+
message: 'Project name:',
109+
placeholder: defaultTargetDir,
110+
validate: value => {
111+
if (!value) return 'Invalid project name';
112+
113+
const formatted = formatTargetDir(value);
114+
115+
return formatted && formatted.length > 0 ? undefined : 'Invalid project name';
116+
}
117+
});
118+
119+
// eslint-disable-next-line consistent-return
120+
if (prompts.isCancel(projectName)) return cancel();
121+
122+
targetDir = formatTargetDir(projectName);
123+
}
124+
125+
// 2. Handle directory if exist and not empty
126+
if (existsSync(targetDir) && !isEmpty(targetDir)) {
127+
const overwrite = argOverwrite
128+
? 'yes'
129+
: await prompts.select({
130+
message: `${
131+
targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`
132+
} is not empty. Please choose how to proceed:`,
133+
options: [
134+
{
135+
label: 'Cancel operation',
136+
value: 'no'
137+
},
138+
{
139+
label: 'Remove existing files and continue',
140+
value: 'yes'
141+
},
142+
{
143+
label: 'Ignore files and continue',
144+
value: 'ignore'
145+
}
146+
]
147+
});
148+
149+
// eslint-disable-next-line consistent-return
150+
if (prompts.isCancel(overwrite)) return cancel();
151+
152+
switch (overwrite) {
153+
case 'yes':
154+
emptyDir(targetDir);
155+
break;
156+
case 'no':
157+
cancel();
158+
return;
159+
default:
160+
break;
161+
}
162+
}
163+
164+
// 3. Get package name
165+
let packageName = basename(resolve(targetDir));
166+
167+
if (!isValidPackageName(packageName)) {
168+
const packageNameResult = await prompts.text({
169+
defaultValue: toValidPackageName(packageName),
170+
message: reset('Package name:'),
171+
placeholder: toValidPackageName(packageName),
172+
validate: dir => {
173+
if (!isValidPackageName(dir)) {
174+
return 'Invalid package.json name';
175+
}
176+
177+
return undefined;
178+
}
179+
});
180+
181+
// eslint-disable-next-line consistent-return
182+
if (prompts.isCancel(packageNameResult)) return cancel();
183+
packageName = packageNameResult;
184+
}
185+
186+
// 4. Choose a framework and variant
187+
let template = argTemplate;
188+
189+
let hasInvalidArgTemplate = false;
190+
191+
if (argTemplate && !TEMPLATES.includes(argTemplate as TemplateType)) {
192+
template = undefined;
193+
hasInvalidArgTemplate = true;
194+
}
195+
196+
if (!template) {
197+
const framework = await prompts.select({
198+
message: hasInvalidArgTemplate
199+
? `"${argTemplate}" isn't a valid template. Please choose from below: `
200+
: 'Select a framework:',
201+
202+
options: templates.map(t => {
203+
const templateColor = t.color;
204+
205+
return {
206+
label: templateColor(t.name),
207+
value: t.type
208+
};
209+
})
210+
});
211+
212+
// eslint-disable-next-line consistent-return
213+
if (prompts.isCancel(framework)) return cancel();
214+
215+
template = framework;
216+
}
217+
218+
const root = join(cwd, targetDir);
219+
220+
mkdirSync(root, { recursive: true });
221+
222+
// 5. Create project
223+
224+
prompts.log.step(`Scaffolding project in ${root}...`);
225+
226+
const templateDir = resolve(fileURLToPath(import.meta.url), '../..', `template-${template}`);
227+
228+
const write = (file: string, content?: string) => {
229+
const targetPath = join(root, renameFiles[file] ?? file);
230+
231+
if (content) {
232+
writeFileSync(targetPath, content);
233+
} else {
234+
copy(join(templateDir, file), targetPath);
235+
}
236+
};
237+
238+
const files = readdirSync(templateDir);
239+
240+
for (const file of files.filter(f => f !== 'package.json')) {
241+
write(file);
242+
}
243+
244+
const pkg = JSON.parse(readFileSync(join(templateDir, `package.json`), 'utf-8'));
245+
246+
pkg.name = packageName;
247+
248+
write('package.json', `${JSON.stringify(pkg, null, 2)}\n`);
249+
250+
const pkgManager = argv.pm || pkgInfo ? pkgInfo?.name : 'npm';
251+
252+
let shouldInstall = false;
253+
254+
if (argv.install) {
255+
shouldInstall = true;
256+
} else {
257+
const confirmed = await prompts.confirm({
258+
initialValue: true,
259+
message: `Install dependencies with ${pkgManager}?`
260+
});
261+
262+
// eslint-disable-next-line consistent-return
263+
if (prompts.isCancel(confirmed)) return cancel();
264+
265+
shouldInstall = confirmed;
266+
}
267+
268+
if (shouldInstall) {
269+
await installDeps(root, pkgManager as PMName);
270+
271+
let shouldRunDev = Boolean(argv['run-dev']);
272+
273+
if (!shouldRunDev) {
274+
const confirmedRun = await prompts.confirm({
275+
initialValue: false,
276+
message: 'Run dev server now?'
277+
});
278+
279+
// eslint-disable-next-line consistent-return
280+
if (prompts.isCancel(confirmedRun)) return cancel();
281+
282+
shouldRunDev = confirmedRun;
283+
}
284+
285+
if (shouldRunDev) {
286+
await runDev(root, pkgManager as PMName);
287+
}
288+
} else {
289+
printNextSteps(root, pkgManager as PMName, packageName);
290+
}
291+
292+
const cdProjectName = relative(cwd, root);
293+
294+
if (root !== cwd) {
295+
consola.info(` cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`);
296+
}
297+
}
298+
299+
setupCli().catch(err => {
300+
console.error(err);
301+
302+
process.exit(1);
303+
});

0 commit comments

Comments
 (0)