diff --git a/@types/sao.d.ts b/@types/sao.d.ts index 248f85c2..447566d4 100644 --- a/@types/sao.d.ts +++ b/@types/sao.d.ts @@ -309,6 +309,7 @@ interface IExtras { debug: boolean; paths: IPaths; projectType: string; + presetAnswers?: Record; } interface Options$1 { appName?: string; diff --git a/README.MD b/README.MD index 5e4a494f..8504f1b1 100644 --- a/README.MD +++ b/README.MD @@ -29,11 +29,12 @@ ## About -Superplate lets you start rock-solid, production-ready *React* and *Next.JS* projects just in seconds. The command-line interface guides the user through setup and no additional build configurations are required. +Superplate lets you start rock-solid, production-ready _React_ and _Next.JS_ projects just in seconds. The command-line interface guides the user through setup and no additional build configurations are required. Superplate ships with more than 30 plugins including popular UIKits, testing frameworks and many useful developer tools. ## Available Integrations + @@ -56,9 +57,9 @@ For more detailed information and usage, you may refer to our [documentation pag ## Quick Start -To use superplate, make sure you have *npx* is installed on your system (npx is shipped by default since npm 5.2.0). +To use superplate, make sure you have _npx_ is installed on your system (npx is shipped by default since npm 5.2.0). -To create a new app, run the following command: +To create a new app, run the following command: ```bash npx superplate-cli @@ -134,11 +135,15 @@ Options: -d, --debug prints additional logs -s, --source Use this option to target a custom source of plugins Source path can be a remote git repository or a local path. + -p, --project In sources with multiple types, you can use this option to preset the type. + -b, --branch If your source is a git repository, you can define a custom branch for `superplate` to use. + -o, --preset If your source includes presets, you can select one of them to prefill the answers. + -l, --lucky You can select random choices with this option, if you are feeling lucky. ``` ## Development mode commands -Watches for changes in the code; builds the project and then globally installs superplate for testing. +Watches for changes in the code; builds the project and then globally installs superplate for testing. ``` npm run dev:global diff --git a/documentation/docs/development/references.md b/documentation/docs/development/references.md index 2d54aab7..41c5ad58 100644 --- a/documentation/docs/development/references.md +++ b/documentation/docs/development/references.md @@ -10,6 +10,8 @@ CLI has two built-in prompts; app name and the package manager questions are pro You can also apply ignore patterns for your plugins, those ignore glob patterns can be applied project-wide or only for specified plugins. Provide a function with prompt answers to whether the ignore patterns will apply or not. +Also you can define presets, those will be available to use with `--preset` option. Presets can include all or part of the choices. + ```ts prompts: { type: string; @@ -27,6 +29,11 @@ ignores: { pattern: string[] }[]; ``` +```ts +presets: Array<{ + name: string; + answers: Record; +}>; **Example** @@ -63,6 +70,14 @@ module.exports = { }, pattern: ["src/components/**", "pages/index.tsx"], }, + ], + presets: [ + { + name: "with-antd", + answers: { + ui: "antd" + } + } ] }; diff --git a/jest.config.ts b/jest.config.ts index 8dea2567..b0233d23 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -151,9 +151,7 @@ export default { // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], + testPathIgnorePatterns: ["/node_modules/", "/lib"], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], diff --git a/package-lock.json b/package-lock.json index f1c19616..549eff83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "superplate-cli", - "version": "1.3.1", + "version": "1.6.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "superplate-cli", - "version": "1.3.1", + "version": "1.6.1", "license": "MIT", "dependencies": { "analytics-node": "^6.0.0", diff --git a/package.json b/package.json index d736e2db..a3f40763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superplate-cli", - "version": "1.5.1", + "version": "1.6.1", "description": "The frontend boilerplate with superpowers", "license": "MIT", "repository": { diff --git a/src/Helper/binary/index.ts b/src/Helper/binary/index.ts index f8d2b06f..d80ea7ef 100644 --- a/src/Helper/binary/index.ts +++ b/src/Helper/binary/index.ts @@ -17,4 +17,9 @@ export const BinaryHelper = { return false; } }, + CanUseDirAsName: (projectDir: string): boolean => { + // eslint-disable-next-line no-useless-escape + const invalidChars = /[\\\/,.]/; + return !invalidChars.test(projectDir); + }, }; diff --git a/src/Helper/git/index.spec.ts b/src/Helper/git/index.spec.ts index 10899d02..dc2578ef 100644 --- a/src/Helper/git/index.spec.ts +++ b/src/Helper/git/index.spec.ts @@ -1,11 +1,5 @@ import { GitHelper } from "./"; -import { promisify } from "util"; -jest.mock("util", () => ({ - promisify: jest.fn(() => { - throw new Error(); - }), -})); describe("Git Helper", () => { it("not found git url IsRepoExist", async () => { const isRepoExist = await GitHelper.IsRepoExist( @@ -22,23 +16,16 @@ describe("Git Helper", () => { }); it("valid git url CloneAndGetPath", async () => { - (promisify as any).mockImplementation(() => - jest - .fn() - .mockResolvedValue({ stdout: "git@github.com:mock/url.git" }), - ); - - const cloneAndPath = await GitHelper.CloneAndGetPath( + const cloneAndPath = GitHelper.CloneAndGetPath( "https://github.com/pankod/action-test", ); - expect(cloneAndPath).not.toBeFalsy(); + await expect(cloneAndPath).resolves.not.toBeUndefined(); }); it("invalid git url CloneAndGetPath", async () => { - (promisify as any).mockImplementation(() => new Error()); - await expect( - GitHelper.CloneAndGetPath("https://pankod.com"), - ).rejects.toThrowError(); + const cloneAndPath = GitHelper.CloneAndGetPath("https://pankod.com"); + + await expect(cloneAndPath).rejects.toThrowError(); }); }); diff --git a/src/Helper/git/index.ts b/src/Helper/git/index.ts index e4c1956e..5adb700b 100644 --- a/src/Helper/git/index.ts +++ b/src/Helper/git/index.ts @@ -20,17 +20,17 @@ export const GitHelper = { } return { exists: false, error: "Source path not valid" }; }, - CloneAndGetPath: async (path: string): Promise => { + CloneAndGetPath: async (path: string, branch?: string): Promise => { try { const tempInfo = await promisify(mkdir)(""); await promisify(exec)( - `git clone --depth 1 ${UrlHelper.GetGitUrl( - path, - )} "${tempInfo}"`, + `git clone --depth 1 ${ + branch ? `--branch ${branch}` : "" + } ${UrlHelper.GetGitUrl(path)} "${tempInfo}"`, ); return tempInfo; } catch (e) { - throw Error(e); + throw new Error(e instanceof Error ? e.message : (e as string)); } }, }; diff --git a/src/Helper/index.ts b/src/Helper/index.ts index 74f2047e..d3f3eac8 100644 --- a/src/Helper/index.ts +++ b/src/Helper/index.ts @@ -12,9 +12,20 @@ export { concatExtend, handleIgnore, } from "./plugin"; -export { get_source } from "./source"; +export { + get_source, + get_project_types, + is_multi_type, + prompt_project_types, +} from "./source"; export { UrlHelper } from "./url"; export { GitHelper } from "./git"; export { FSHelper } from "./fs"; export { tips } from "./tips"; export { BinaryHelper } from "./binary"; +export { get_presets } from "./preset"; +export { + get_prompts_and_choices, + get_random_answer, + get_random_answers, +} from "./lucky"; diff --git a/src/Helper/lucky/index.ts b/src/Helper/lucky/index.ts new file mode 100644 index 00000000..537a47bd --- /dev/null +++ b/src/Helper/lucky/index.ts @@ -0,0 +1,54 @@ +import path from "path"; + +export type ProjectPrompt = { + name: string; + type: "select"; + choices: { name?: string; message: string; value?: string }[]; + default?: string; + skip?: ({ answers }: { answers: Record }) => boolean; +}; + +export const get_prompts_and_choices = async ( + source: string, +): Promise => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const sourcePrompts = require(path.resolve(source, "prompt.js")); + + return (sourcePrompts.prompts ?? []) as ProjectPrompt[]; + } catch (e) { + return []; + } +}; + +export const get_random_answer = ( + projectPrompt: ProjectPrompt, + currentAnswers: Record, +): [key: string, value: string | undefined] | undefined => { + if (projectPrompt.skip && projectPrompt.skip({ answers: currentAnswers })) { + return undefined; + } + + const randomIndex = Math.floor( + Math.random() * projectPrompt.choices.length, + ); + + const { name, value } = projectPrompt.choices[randomIndex]; + + return [projectPrompt.name, name ?? value ?? undefined]; +}; + +export const get_random_answers = ( + projectPrompts: ProjectPrompt[], +): Record => { + const answers: Record = {}; + + for (const prompt of projectPrompts) { + const [key, value] = get_random_answer(prompt, answers) ?? []; + if (key && value) { + answers[key] = value; + } + } + + return { ...answers }; +}; diff --git a/src/Helper/preset/index.ts b/src/Helper/preset/index.ts new file mode 100644 index 00000000..881b399c --- /dev/null +++ b/src/Helper/preset/index.ts @@ -0,0 +1,17 @@ +import path from "path"; + +export type Preset = { + name: string; + answers: Record; +}; + +export const get_presets = async (source: string): Promise => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const sourcePrompts = require(path.resolve(source, "prompt.js")); + + return (sourcePrompts.presets ?? []) as Preset[]; + } catch (e) { + return []; + } +}; diff --git a/src/Helper/source/index.ts b/src/Helper/source/index.ts index dce1fa62..4112132a 100644 --- a/src/Helper/source/index.ts +++ b/src/Helper/source/index.ts @@ -8,16 +8,22 @@ import ora from "ora"; import chalk from "chalk"; import { GitHelper, FSHelper } from "@Helper"; +import prompts, { Choice } from "prompts"; type SourceResponse = { path?: string; error?: string }; -type GetSourceFn = (source: string | undefined) => Promise; +type GetSourceFn = ( + source: string | undefined, + branch?: string, +) => Promise; -export const get_source: GetSourceFn = async (source) => { +export const get_source: GetSourceFn = async (source, branch) => { /** * Replace path if default */ const sourceSpinner = ora( - `Checking provided source ${chalk.bold`"${source}"`}`, + `Checking provided source ${chalk.bold`"${source}${ + branch ? ` - ${branch}` : "" + }"`}`, ); sourceSpinner.start(); @@ -40,16 +46,86 @@ export const get_source: GetSourceFn = async (source) => { const repoStatus = await GitHelper.IsRepoExist(sourcePath); if (repoStatus.exists === true) { sourceSpinner.text = "Remote source found. Cloning..."; - const cloneResponse = await GitHelper.CloneAndGetPath(sourcePath); - if (cloneResponse) { - sourceSpinner.succeed("Cloned remote source successfully."); - return { path: cloneResponse }; + try { + const cloneResponse = await GitHelper.CloneAndGetPath( + sourcePath, + branch, + ); + if (cloneResponse) { + sourceSpinner.succeed("Cloned remote source successfully."); + return { path: cloneResponse }; + } + sourceSpinner.fail("Could not retrieve source repository."); + return { error: "Could not retrieve source repository." }; + } catch (e) { + `${e}`; + sourceSpinner.fail("Could not retrieve source repository."); + return { error: "Could not retrieve source repository." }; } - sourceSpinner.fail("Could not retrieve source repository."); - return { error: "Could not retrieve source repository." }; } else { sourceSpinner.fail("Could not found source repository."); return { error: repoStatus.error }; } } }; + +export const is_multi_type = async ( + source: string | undefined, +): Promise => { + if (source) { + const checkRootPrompt = await FSHelper.IsPathExists( + `${source}/prompt.js`, + ); + + return !checkRootPrompt; + } + return false; +}; + +export const get_project_types = async (source: string): Promise => { + const projectTypes: Choice[] = []; + + // get project types => react,nextjs,refine ...etc + const files = await FSHelper.ReadDir(source); + + for (const file of files) { + const existPromptFile = await FSHelper.IsPathExists( + `${source}/${file}/prompt.js`, + ); + + if (existPromptFile) { + projectTypes.push({ + title: file, + value: file, + }); + } + } + + return projectTypes; +}; + +export const prompt_project_types = async ( + source: string, + types: Choice[], + typeFromArgs?: string, +): Promise<[projectTypePath: string, projectType: string]> => { + let projectType = ""; + + if ( + types.find((p) => p.title === typeFromArgs) && + typeof typeFromArgs === "string" + ) { + projectType = typeFromArgs; + } else { + const { projectType: projectTypeFromPrompts } = await prompts({ + type: "select", + name: "projectType", + message: "Select your project type", + choices: types, + }); + + projectType = projectTypeFromPrompts; + } + + return [`${source}/${projectType}`, projectType]; +}; diff --git a/src/cli.ts b/src/cli.ts index 54e06612..76846a34 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,10 +4,17 @@ import path from "path"; import commander from "commander"; import { cleanupSync } from "temp"; import { Options, SAO } from "sao"; -import prompts, { Choice } from "prompts"; -import { get_source, FSHelper } from "@Helper"; import packageData from "../package.json"; +import { + get_source, + get_project_types, + is_multi_type, + prompt_project_types, + get_presets, + get_prompts_and_choices, + get_random_answers, +} from "@Helper"; const generator = path.resolve(__dirname, "./"); @@ -23,6 +30,18 @@ const cli = async (): Promise => { "-s, --source ", "specify a custom source of plugins", ) + .option( + "-b, --branch ", + "specify a custom branch in source of plugins", + ) + .option( + "-o, --preset ", + "specify a preset to use for the project", + ) + .option( + "-l, --lucky", + "use this option to generate a project with random answers", + ) .option("-p, --project ", "specify a project type to use") .option("-d, --debug", "print additional logs and skip install script") .on("--help", () => { @@ -46,6 +65,21 @@ const cli = async (): Promise => { "https://github.com/my-plugin-source.git", )}`, ); + console.log( + ` - if your source is a git repo you can also define a custom branch in it: ${chalk.green( + "--branch canary or -b canary", + )}`, + ); + console.log( + ` - if your source includes any presets, you can set them to prefill choices: ${chalk.green( + "--preset cool-stack or -o cool-stack", + )}`, + ); + console.log( + ` - if you are feeling lucky, you can always try your chance with random selected choices: ${chalk.green( + "--lucky or -l", + )}`, + ); console.log( ` - a local path relative to the current working directory: ${chalk.green( "../my-source", @@ -77,14 +111,18 @@ const cli = async (): Promise => { /** * get source path */ - const source = await get_source(program.source); + const source = await get_source(program.source, program.branch); let { path: sourcePath } = source; const { error: sourceError } = source; if (sourceError) { console.error(`${chalk.bold`${sourceError}`}`); - console.log("Source can be a remote git repository or a local path."); + console.log( + `Source can be a remote git repository or a local path. ${ + program.branch ? "Make sure your specified branch exists." : "" + }`, + ); console.log(); console.log("You provided:"); console.log(`${chalk.blueBright(program.source)}`); @@ -96,61 +134,68 @@ const cli = async (): Promise => { process.exit(1); } - // check root prompt.js - const checkRootPrompt = await FSHelper.IsPathExists( - `${sourcePath}/prompt.js`, - ); + const isMultiType = await is_multi_type(sourcePath); let projectType = ""; - if (sourcePath && !checkRootPrompt) { - const projectTypes: Choice[] = []; + if (sourcePath && isMultiType) { + // get project types + const projectTypes = await get_project_types(sourcePath); + + const [ + finalSourcePath, + selectedProjectType, + ] = await prompt_project_types( + sourcePath, + projectTypes, + program.project, + ); - // get project types => react,nextjs,refine ...etc - const files = await FSHelper.ReadDir(sourcePath); + sourcePath = finalSourcePath; + projectType = selectedProjectType; + } - for (const file of files) { - const existPromptFile = await FSHelper.IsPathExists( - `${sourcePath}/${file}/prompt.js`, - ); + /** handle presets, can either be partial or fully provided answers from `prompt.js > presets` */ + let presetAnswers: Record | undefined = undefined; + const selectedPreset = program.preset; + const isLucky = program.lucky; - if (existPromptFile) { - projectTypes.push({ - title: file, - value: file, - }); - } - } + if (selectedPreset && sourcePath && !isLucky) { + const presets = await get_presets(sourcePath); - const projectTypeFromArgs = program.project; + const preset = presets.find((p) => p.name === selectedPreset); - if (projectTypes.find((p) => p.title === projectTypeFromArgs)) { - projectType = projectTypeFromArgs; + if (preset) { + presetAnswers = preset.answers; } else { - const { projectType: projectTypeFromPrompts } = await prompts({ - type: "select", - name: "projectType", - message: "Select your project type", - choices: projectTypes, - }); - - projectType = projectTypeFromPrompts; + console.log( + `${chalk.bold`${selectedPreset}`} is not a valid preset.`, + ); } - - sourcePath = `${sourcePath}/${projectType}`; } + if (isLucky && sourcePath) { + const promptsAndChoices = await get_prompts_and_choices(sourcePath); + presetAnswers = get_random_answers(promptsAndChoices); + } + + const withAnswers = + presetAnswers && Object.keys(presetAnswers).length > 0 + ? true + : undefined; const sao = new SAO({ generator, outDir: projectDir, logLevel: program.debug ? 4 : 1, appName: projectDir, + answers: withAnswers, extras: { debug: !!program.debug, projectType, paths: { sourcePath, }, + presetAnswers, }, } as Options); diff --git a/src/saofile.ts b/src/saofile.ts index dcbe1c31..7ebd94e1 100644 --- a/src/saofile.ts +++ b/src/saofile.ts @@ -22,11 +22,13 @@ import { BinaryHelper, } from "@Helper"; +import { ProjectPrompt } from "@Helper/lucky"; + const saoConfig: GeneratorConfig = { prompts(sao) { const { appName, - extras: { paths }, + extras: { paths, presetAnswers }, } = sao.opts; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -75,7 +77,10 @@ const saoConfig: GeneratorConfig = { }, ] : []), - ...(sourcePrompts?.prompts ?? []), + ...(sourcePrompts?.prompts ?? []).map((el: ProjectPrompt) => ({ + ...el, + default: presetAnswers?.[el.name] ?? el.default, + })), { name: "telemetry", message: