refactor(create): clean up logic when prompting for unspecified arguments (#7374)

This commit is contained in:
Joshua Chen 2022-05-08 22:00:28 +08:00 committed by GitHub
parent cfdd1f7e6d
commit c3880cc342
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -7,37 +7,39 @@
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import fs from 'fs-extra'; import fs from 'fs-extra';
import {fileURLToPath} from 'url';
import prompts, {type Choice} from 'prompts'; import prompts, {type Choice} from 'prompts';
import path from 'path'; import path from 'path';
import shell from 'shelljs'; import shell from 'shelljs';
import _ from 'lodash'; import _ from 'lodash';
import supportsColor from 'supports-color'; import supportsColor from 'supports-color';
import {fileURLToPath} from 'url';
const RecommendedTemplate = 'classic'; type CLIOptions = {
const TypeScriptTemplateSuffix = '-typescript'; packageManager?: PackageManager;
skipInstall?: boolean;
typescript?: boolean;
gitStrategy?: GitStrategy;
};
// Only used in the rare, rare case of running globally installed create + // Only used in the rare, rare case of running globally installed create +
// using --skip-install. We need a default name to show the tip text // using --skip-install. We need a default name to show the tip text
const DefaultPackageManager = 'npm'; const defaultPackageManager = 'npm';
const SupportedPackageManagers = { const lockfileNames = {
npm: 'package-lock.json', npm: 'package-lock.json',
yarn: 'yarn.lock', yarn: 'yarn.lock',
pnpm: 'pnpm-lock.yaml', pnpm: 'pnpm-lock.yaml',
}; };
type SupportedPackageManager = keyof typeof SupportedPackageManagers; type PackageManager = keyof typeof lockfileNames;
const PackageManagersList = Object.keys( const packageManagers = Object.keys(lockfileNames) as PackageManager[];
SupportedPackageManagers,
) as SupportedPackageManager[];
async function findPackageManagerFromLockFile(): Promise< async function findPackageManagerFromLockFile(
SupportedPackageManager | undefined rootDir: string,
> { ): Promise<PackageManager | undefined> {
for (const packageManager of PackageManagersList) { for (const packageManager of packageManagers) {
const lockFilePath = path.resolve(SupportedPackageManagers[packageManager]); const lockFilePath = path.join(rootDir, lockfileNames[packageManager]);
if (await fs.pathExists(lockFilePath)) { if (await fs.pathExists(lockFilePath)) {
return packageManager; return packageManager;
} }
@ -45,15 +47,13 @@ async function findPackageManagerFromLockFile(): Promise<
return undefined; return undefined;
} }
function findPackageManagerFromUserAgent(): function findPackageManagerFromUserAgent(): PackageManager | undefined {
| SupportedPackageManager return packageManagers.find((packageManager) =>
| undefined {
return PackageManagersList.find((packageManager) =>
process.env.npm_config_user_agent?.startsWith(packageManager), process.env.npm_config_user_agent?.startsWith(packageManager),
); );
} }
async function askForPackageManagerChoice(): Promise<SupportedPackageManager> { async function askForPackageManagerChoice(): Promise<PackageManager> {
const hasYarn = shell.exec('yarn --version', {silent: true}).code === 0; const hasYarn = shell.exec('yarn --version', {silent: true}).code === 0;
const hasPnpm = shell.exec('pnpm --version', {silent: true}).code === 0; const hasPnpm = shell.exec('pnpm --version', {silent: true}).code === 0;
@ -65,67 +65,121 @@ async function askForPackageManagerChoice(): Promise<SupportedPackageManager> {
.map((p) => ({title: p, value: p})); .map((p) => ({title: p, value: p}));
return ( return (
await prompts({ await prompts(
type: 'select', {
name: 'packageManager', type: 'select',
message: 'Select a package manager...', name: 'packageManager',
choices, message: 'Select a package manager...',
}) choices,
},
{
onCancel() {
logger.info`Falling back to name=${defaultPackageManager}`;
},
},
)
).packageManager; ).packageManager;
} }
async function getPackageManager( async function getPackageManager(
packageManagerChoice: SupportedPackageManager | undefined, dest: string,
skipInstall: boolean = false, {packageManager, skipInstall}: CLIOptions,
): Promise<SupportedPackageManager> { ): Promise<PackageManager> {
if ( if (packageManager && !packageManagers.includes(packageManager)) {
packageManagerChoice &&
!PackageManagersList.includes(packageManagerChoice)
) {
throw new Error( throw new Error(
`Invalid package manager choice ${packageManagerChoice}. Must be one of ${PackageManagersList.join( `Invalid package manager choice ${packageManager}. Must be one of ${packageManagers.join(
', ', ', ',
)}`, )}`,
); );
} }
return ( return (
packageManagerChoice ?? // If dest already contains a lockfile (e.g. if using a local template), we
(await findPackageManagerFromLockFile()) ?? // always use that instead
(await findPackageManagerFromLockFile(dest)) ??
packageManager ??
(await findPackageManagerFromLockFile('.')) ??
findPackageManagerFromUserAgent() ?? findPackageManagerFromUserAgent() ??
// This only happens if the user has a global installation in PATH // This only happens if the user has a global installation in PATH
(skipInstall ? DefaultPackageManager : askForPackageManagerChoice()) (skipInstall ? defaultPackageManager : askForPackageManagerChoice()) ??
defaultPackageManager
); );
} }
function isValidGitRepoUrl(gitRepoUrl: string) { const recommendedTemplate = 'classic';
return ['https://', 'git@'].some((item) => gitRepoUrl.startsWith(item)); const typeScriptTemplateSuffix = '-typescript';
} const templatesDir = fileURLToPath(new URL('../templates', import.meta.url));
async function updatePkg(pkgPath: string, obj: {[key: string]: unknown}) { type Template = {
const pkg = await fs.readJSON(pkgPath); name: string;
const newPkg = Object.assign(pkg, obj); path: string;
tsVariantPath: string | undefined;
};
await fs.outputFile(pkgPath, `${JSON.stringify(newPkg, null, 2)}\n`); async function readTemplates(): Promise<Template[]> {
} const dirContents = await fs.readdir(templatesDir);
const templates = await Promise.all(
async function readTemplates(templatesDir: string) { dirContents
const templates = (await fs.readdir(templatesDir)).filter( .filter(
(d) => (d) =>
!d.startsWith('.') && !d.startsWith('.') &&
!d.startsWith('README') && !d.startsWith('README') &&
!d.endsWith(TypeScriptTemplateSuffix) && !d.endsWith(typeScriptTemplateSuffix) &&
d !== 'shared', d !== 'shared',
)
.map(async (name) => {
const tsVariantPath = path.join(
templatesDir,
`${name}${typeScriptTemplateSuffix}`,
);
return {
name,
path: path.join(templatesDir, name),
tsVariantPath: (await fs.pathExists(tsVariantPath))
? tsVariantPath
: undefined,
};
}),
); );
// Classic should be first in list! // Classic should be first in list!
return _.sortBy(templates, (t) => t !== RecommendedTemplate); return _.sortBy(templates, (t) => t.name !== recommendedTemplate);
} }
function createTemplateChoices(templates: string[]) { async function copyTemplate(
function makeNameAndValueChoice(value: string): Choice { template: Template,
dest: string,
typescript: boolean,
): Promise<void> {
await fs.copy(path.join(templatesDir, 'shared'), dest);
// TypeScript variants will copy duplicate resources like CSS & config from
// base template
if (typescript) {
await fs.copy(template.path, dest, {
filter: async (filePath) =>
(await fs.stat(filePath)).isDirectory() ||
path.extname(filePath) === '.css' ||
path.basename(filePath) === 'docusaurus.config.js',
});
}
await fs.copy(typescript ? template.tsVariantPath! : template.path, dest, {
// Symlinks don't exist in published npm packages anymore, so this is only
// to prevent errors during local testing
filter: async (filePath) => !(await fs.lstat(filePath)).isSymbolicLink(),
});
}
function createTemplateChoices(templates: Template[]): Choice[] {
function makeNameAndValueChoice(value: string | Template): Choice {
if (typeof value === 'string') {
return {title: value, value};
}
const title = const title =
value === RecommendedTemplate ? `${value} (recommended)` : value; value.name === recommendedTemplate
? `${value.name} (recommended)`
: value.name;
return {title, value}; return {title, value};
} }
@ -136,55 +190,33 @@ function createTemplateChoices(templates: string[]) {
]; ];
} }
function getTypeScriptBaseTemplate(template: string): string | undefined { function isValidGitRepoUrl(gitRepoUrl: string): boolean {
if (template.endsWith(TypeScriptTemplateSuffix)) { return ['https://', 'git@'].some((item) => gitRepoUrl.startsWith(item));
return template.replace(TypeScriptTemplateSuffix, '');
}
return undefined;
}
async function copyTemplate(
templatesDir: string,
template: string,
dest: string,
) {
await fs.copy(path.join(templatesDir, 'shared'), dest);
// TypeScript variants will copy duplicate resources like CSS & config from
// base template
const tsBaseTemplate = getTypeScriptBaseTemplate(template);
if (tsBaseTemplate) {
const tsBaseTemplatePath = path.resolve(templatesDir, tsBaseTemplate);
await fs.copy(tsBaseTemplatePath, dest, {
filter: async (filePath) =>
(await fs.stat(filePath)).isDirectory() ||
path.extname(filePath) === '.css' ||
path.basename(filePath) === 'docusaurus.config.js',
});
}
await fs.copy(path.resolve(templatesDir, template), dest, {
// Symlinks don't exist in published npm packages anymore, so this is only
// to prevent errors during local testing
filter: async (filePath) => !(await fs.lstat(filePath)).isSymbolicLink(),
});
} }
const gitStrategies = ['deep', 'shallow', 'copy', 'custom'] as const; const gitStrategies = ['deep', 'shallow', 'copy', 'custom'] as const;
type GitStrategy = typeof gitStrategies[number];
async function getGitCommand(gitStrategy: typeof gitStrategies[number]) { async function getGitCommand(gitStrategy: GitStrategy): Promise<string> {
switch (gitStrategy) { switch (gitStrategy) {
case 'shallow': case 'shallow':
case 'copy': case 'copy':
return 'git clone --recursive --depth 1'; return 'git clone --recursive --depth 1';
case 'custom': { case 'custom': {
const {command} = await prompts({ const {command} = await prompts(
type: 'text', {
name: 'command', type: 'text',
message: name: 'command',
'Write your own git clone command. The repository URL and destination directory will be supplied. E.g. "git clone --depth 10"', message:
}); 'Write your own git clone command. The repository URL and destination directory will be supplied. E.g. "git clone --depth 10"',
return command; },
{
onCancel() {
logger.info`Falling back to code=${'git clone'}`;
},
},
);
return command ?? 'git clone';
} }
case 'deep': case 'deep':
default: default:
@ -192,178 +224,273 @@ async function getGitCommand(gitStrategy: typeof gitStrategies[number]) {
} }
} }
export default async function init( async function getSiteName(
reqName: string | undefined,
rootDir: string, rootDir: string,
siteName?: string, ): Promise<string> {
reqTemplate?: string, async function validateSiteName(siteName: string) {
cliOptions: Partial<{ if (!siteName) {
packageManager: SupportedPackageManager; return 'A website name is required.';
skipInstall: boolean; }
typescript: boolean; const dest = path.resolve(rootDir, siteName);
gitStrategy: typeof gitStrategies[number]; if (await fs.pathExists(dest)) {
}> = {}, return logger.interpolate`Directory already exists at path=${dest}!`;
): Promise<void> { }
const templatesDir = fileURLToPath(new URL('../templates', import.meta.url)); return true;
const templates = await readTemplates(templatesDir); }
const hasTS = (templateName: string) => if (reqName) {
fs.pathExists( const res = validateSiteName(reqName);
path.join(templatesDir, `${templateName}${TypeScriptTemplateSuffix}`), if (typeof res === 'string') {
); throw new Error(res);
let name = siteName; }
return reqName;
// Prompt if siteName is not passed from CLI. }
if (!name) { const {siteName} = await prompts(
const prompt = await prompts({ {
type: 'text', type: 'text',
name: 'name', name: 'siteName',
message: 'What should we name this site?', message: 'What should we name this site?',
initial: 'website', initial: 'website',
}); validate: validateSiteName,
name = prompt.name; },
} {
onCancel() {
if (!name) { logger.error('A website name is required.');
logger.error('A website name is required.'); process.exit(1);
process.exit(1);
}
const dest = path.resolve(rootDir, name);
if (await fs.pathExists(dest)) {
logger.error`Directory already exists at path=${dest}!`;
process.exit(1);
}
let template = reqTemplate;
let useTS = cliOptions.typescript;
// Prompt if template is not provided from CLI.
if (!template) {
const templatePrompt = await prompts({
type: 'select',
name: 'template',
message: 'Select a template below...',
choices: createTemplateChoices(templates),
});
template = templatePrompt.template;
if (template && !useTS && (await hasTS(template))) {
const tsPrompt = await prompts({
type: 'confirm',
name: 'useTS',
message:
'This template is available in TypeScript. Do you want to use the TS variant?',
initial: false,
});
useTS = tsPrompt.useTS;
}
}
let gitStrategy = cliOptions.gitStrategy ?? 'deep';
// If user choose Git repository, we'll prompt for the url.
if (template === 'Git repository') {
const repoPrompt = await prompts({
type: 'text',
name: 'gitRepoUrl',
validate: (url?: string) => {
if (url && isValidGitRepoUrl(url)) {
return true;
}
return logger.red('Invalid repository URL');
}, },
message: logger.interpolate`Enter a repository URL from GitHub, Bitbucket, GitLab, or any other public repo. },
(e.g: url=${'https://github.com/ownerName/repoName.git'})`, );
}); return siteName;
({gitStrategy} = await prompts({ }
type: 'select',
name: 'gitStrategy', type Source =
message: 'How should we clone this repo?', | {
choices: [ type: 'template';
{title: 'Deep clone: preserve full history', value: 'deep'}, template: Template;
{title: 'Shallow clone: clone with --depth=1', value: 'shallow'}, typescript: boolean;
{ }
title: 'Copy: do a shallow clone, but do not create a git repo', | {
value: 'copy', type: 'git';
}, url: string;
{title: 'Custom: enter your custom git clone command', value: 'custom'}, strategy: GitStrategy;
], }
})); | {
template = repoPrompt.gitRepoUrl; type: 'local';
} else if (template === 'Local template') { path: string;
const dirPrompt = await prompts({ };
type: 'text',
name: 'templateDir', async function getSource(
validate: async (dir?: string) => { reqTemplate: string | undefined,
if (dir) { templates: Template[],
const fullDir = path.resolve(dir); cliOptions: CLIOptions,
if (await fs.pathExists(fullDir)) { ): Promise<Source> {
if (reqTemplate) {
if (isValidGitRepoUrl(reqTemplate)) {
if (
cliOptions.gitStrategy &&
!gitStrategies.includes(cliOptions.gitStrategy)
) {
logger.error`Invalid git strategy: name=${
cliOptions.gitStrategy
}. Value must be one of ${gitStrategies.join(', ')}.`;
process.exit(1);
}
return {
type: 'git',
url: reqTemplate,
strategy: cliOptions.gitStrategy ?? 'deep',
};
} else if (await fs.pathExists(path.resolve(reqTemplate))) {
return {
type: 'local',
path: path.resolve(reqTemplate),
};
}
const template = templates.find((t) => t.name === reqTemplate);
if (!template) {
logger.error('Invalid template.');
process.exit(1);
}
if (cliOptions.typescript && !template.tsVariantPath) {
logger.error`Template name=${reqTemplate} doesn't provide the TypeScript variant.`;
process.exit(1);
}
return {
type: 'template',
template,
typescript: cliOptions.typescript ?? false,
};
}
const template = cliOptions.gitStrategy
? 'Git repository'
: (
await prompts(
{
type: 'select',
name: 'template',
message: 'Select a template below...',
choices: createTemplateChoices(templates),
},
{
onCancel() {
logger.error('A choice is required.');
process.exit(1);
},
},
)
).template;
if (template === 'Git repository') {
const {gitRepoUrl} = await prompts(
{
type: 'text',
name: 'gitRepoUrl',
validate: (url?: string) => {
if (url && isValidGitRepoUrl(url)) {
return true; return true;
} }
return logger.red( return logger.red('Invalid repository URL');
logger.interpolate`path=${fullDir} does not exist.`, },
); message: logger.interpolate`Enter a repository URL from GitHub, Bitbucket, GitLab, or any other public repo.
} (e.g: url=${'https://github.com/ownerName/repoName.git'})`,
return logger.red('Please enter a valid path.');
}, },
{
onCancel() {
logger.error('A git repo URL is required.');
process.exit(1);
},
},
);
let strategy = cliOptions.gitStrategy;
if (!strategy) {
({strategy} = await prompts(
{
type: 'select',
name: 'strategy',
message: 'How should we clone this repo?',
choices: [
{title: 'Deep clone: preserve full history', value: 'deep'},
{title: 'Shallow clone: clone with --depth=1', value: 'shallow'},
{
title: 'Copy: do a shallow clone, but do not create a git repo',
value: 'copy',
},
{
title: 'Custom: enter your custom git clone command',
value: 'custom',
},
],
},
{
onCancel() {
logger.info`Falling back to name=${'deep'}`;
},
},
));
}
return {
type: 'git',
url: gitRepoUrl,
strategy: strategy ?? 'deep',
};
} else if (template === 'Local template') {
const {templateDir} = await prompts(
{
type: 'text',
name: 'templateDir',
validate: async (dir?: string) => {
if (dir) {
const fullDir = path.resolve(dir);
if (await fs.pathExists(fullDir)) {
return true;
}
return logger.red(
logger.interpolate`path=${fullDir} does not exist.`,
);
}
return logger.red('Please enter a valid path.');
},
message:
'Enter a local folder path, relative to the current working directory.',
},
{
onCancel() {
logger.error('A file path is required.');
process.exit(1);
},
},
);
return {
type: 'local',
path: templateDir,
};
}
let useTS = cliOptions.typescript;
if (!useTS && template.tsVariantPath) {
({useTS} = await prompts({
type: 'confirm',
name: 'useTS',
message: message:
'Enter a local folder path, relative to the current working directory.', 'This template is available in TypeScript. Do you want to use the TS variant?',
}); initial: false,
template = dirPrompt.templateDir; }));
} }
return {
type: 'template',
template,
typescript: useTS ?? false,
};
}
if (!template) { async function updatePkg(pkgPath: string, obj: {[key: string]: unknown}) {
logger.error('Template should not be empty'); const pkg = await fs.readJSON(pkgPath);
process.exit(1); const newPkg = Object.assign(pkg, obj);
}
await fs.outputFile(pkgPath, `${JSON.stringify(newPkg, null, 2)}\n`);
}
export default async function init(
rootDir: string,
reqName?: string,
reqTemplate?: string,
cliOptions: CLIOptions = {},
): Promise<void> {
const templates = await readTemplates();
const siteName = await getSiteName(reqName, rootDir);
const dest = path.resolve(rootDir, siteName);
const source = await getSource(reqTemplate, templates, cliOptions);
logger.info('Creating new Docusaurus project...'); logger.info('Creating new Docusaurus project...');
if (isValidGitRepoUrl(template)) { if (source.type === 'git') {
logger.info`Cloning Git template url=${template}...`; logger.info`Cloning Git template url=${source.url}...`;
if (!gitStrategies.includes(gitStrategy)) { const command = await getGitCommand(source.strategy);
logger.error`Invalid git strategy: name=${gitStrategy}. Value must be one of ${gitStrategies.join( if (shell.exec(`${command} ${source.url} ${dest}`).code !== 0) {
', ', logger.error`Cloning Git template failed!`;
)}.`;
process.exit(1); process.exit(1);
} }
const command = await getGitCommand(gitStrategy); if (source.strategy === 'copy') {
if (shell.exec(`${command} ${template} ${dest}`).code !== 0) {
logger.error`Cloning Git template name=${template} failed!`;
process.exit(1);
}
if (gitStrategy === 'copy') {
await fs.remove(path.join(dest, '.git')); await fs.remove(path.join(dest, '.git'));
} }
} else if (templates.includes(template)) { } else if (source.type === 'template') {
// Docusaurus templates.
if (useTS) {
if (!(await hasTS(template))) {
logger.error`Template name=${template} doesn't provide the TypeScript variant.`;
process.exit(1);
}
template = `${template}${TypeScriptTemplateSuffix}`;
}
try { try {
await copyTemplate(templatesDir, template, dest); await copyTemplate(source.template, dest, source.typescript);
} catch (err) { } catch (err) {
logger.error`Copying Docusaurus template name=${template} failed!`; logger.error`Copying Docusaurus template name=${source.template.name} failed!`;
throw err;
}
} else if (await fs.pathExists(path.resolve(template))) {
const templateDir = path.resolve(template);
try {
await fs.copy(templateDir, dest);
} catch (err) {
logger.error`Copying local template path=${templateDir} failed!`;
throw err; throw err;
} }
} else { } else {
logger.error('Invalid template.'); try {
process.exit(1); await fs.copy(source.path, dest);
} catch (err) {
logger.error`Copying local template path=${source.path} failed!`;
throw err;
}
} }
// Update package.json info. // Update package.json info.
try { try {
await updatePkg(path.join(dest, 'package.json'), { await updatePkg(path.join(dest, 'package.json'), {
name: _.kebabCase(name), name: _.kebabCase(siteName),
version: '0.0.0', version: '0.0.0',
private: true, private: true,
}); });
@ -385,10 +512,7 @@ export default async function init(
// Display the most elegant way to cd. // Display the most elegant way to cd.
const cdpath = path.relative('.', dest); const cdpath = path.relative('.', dest);
const pkgManager = await getPackageManager( const pkgManager = await getPackageManager(dest, cliOptions);
cliOptions.packageManager,
cliOptions.skipInstall,
);
if (!cliOptions.skipInstall) { if (!cliOptions.skipInstall) {
shell.cd(dest); shell.cd(dest);
logger.info`Installing dependencies with name=${pkgManager}...`; logger.info`Installing dependencies with name=${pkgManager}...`;
@ -398,8 +522,8 @@ export default async function init(
{ {
env: { env: {
...process.env, ...process.env,
// Force coloring the output, since the command is invoked, // Force coloring the output, since the command is invoked by
// by shelljs which is not the interactive shell // shelljs, which is not an interactive shell
...(supportsColor.stdout ? {FORCE_COLOR: '1'} : {}), ...(supportsColor.stdout ? {FORCE_COLOR: '1'} : {}),
}, },
}, },