diff --git a/package-lock.json b/package-lock.json index 7f96678..c959c1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "chalk": "^5.4.1", + "commander": "^13.1.0", "gradient-string": "^3.0.0", "ora": "^8.2.0", "prompts": "^2.4.2", @@ -2044,12 +2045,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=18" } }, "node_modules/concat-map": { @@ -5357,6 +5358,15 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 46d018a..b593ea3 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "description": "", "dependencies": { "chalk": "^5.4.1", + "commander": "^13.1.0", "gradient-string": "^3.0.0", "ora": "^8.2.0", "prompts": "^2.4.2", diff --git a/src/__tests__/parameters.spec.ts b/src/__tests__/parameters.spec.ts index 41dbad0..748d00b 100644 --- a/src/__tests__/parameters.spec.ts +++ b/src/__tests__/parameters.spec.ts @@ -1,6 +1,10 @@ -import { validateNonEmptyString, validatePathDoesNotExist, validateStringIsNotPath } from "~/parameters"; import { vol } from "memfs"; import fs from "fs"; +import { + validateNonEmptyString, + validatePathDoesNotExist, + validateStringIsNotPath +} from "~/validations"; jest.mock("fs"); diff --git a/src/index.ts b/src/index.ts index b03d009..8e85f69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,11 +6,52 @@ import path from "path"; import pkg from "package.json"; import prompts from "prompts"; -import parameters from "~/parameters"; +import { addCommandLineArguments, programSchema, createPrompts, ProgramOption } from "~/parameters"; import generatePluginFiles from "~/generatePluginFiles"; import { IS_PRODUCTION, TARGET_BASE, TEMPLATE_BASE } from "~/constants"; import { createSpinner, error, renderCliInfo, renderGoodbye, renderMasthead, warn } from "~/vanity"; import { promiseWithSpinner } from "./helpers"; +import { program } from "commander"; + +program.version(pkg.version); +const lookup = addCommandLineArguments(program, programSchema); + +const parsedOpts = program.parse().opts(); + +const options = programSchema.reduce>((acc, s) => { + const opt = parsedOpts?.[lookup[s.key]]; + acc[s.key] = { + value: opt ?? (s.initial ?? null), + wasSet: opt != null, + } + + return acc; +}, {}); + +const positionalArgs = programSchema.filter(s => s.arg && s.arg.type === "argument"); +for (const [i, arg] of positionalArgs.entries()) { + if (!program.args[i]) continue; + + options[arg.key].value = program.args[i]; + options[arg.key].wasSet = true; +}; + +(() => { + if (options["forceInteractive"].value) return; + + let preanswered = Object.entries(options); + + const wereSet = preanswered.filter(o => o[1].wasSet); + if (wereSet.length <= 0) return; + + if (options["interactive"].value) { + preanswered = wereSet; + } + + preanswered = preanswered.map(o => [o[0], o[1].value]); + + prompts.override(Object.fromEntries(preanswered)); +})(); renderMasthead(); renderCliInfo(); @@ -53,7 +94,8 @@ const generateProject = new Promise(async (resolve, reject) => { return false; } - const answers = await prompts(parameters, { onCancel }); + const interactivePrompts = createPrompts(programSchema, options); + const answers = await prompts(interactivePrompts, { onCancel }); if (cancelled) { warn("Cancelled making a CounterStrikeSharp plugin."); return resolve(); diff --git a/src/parameters.ts b/src/parameters.ts index a8e77e5..c4ff0d5 100644 --- a/src/parameters.ts +++ b/src/parameters.ts @@ -1,113 +1,291 @@ -import { existsSync } from "fs"; -import path from "path"; import { PromptObject } from "prompts"; -import { TARGET_BASE } from "~/constants"; +import { program as commanderProgram } from "commander"; +import { + validateNonEmptyString, + validatePathDoesNotExist, + validateStringIsNotPath, + Validation, + validationBuilder +} from "./validations"; +import { Argument, Option } from "commander"; -type ValidationClosure = (input: T) => boolean | string; -type Validation = ValidationClosure; - -function validationBuilder(validations: Validation[]): Validation { - return (answer: string) => { - for (const validate of validations) { - const result = validate(answer); - if (result === true) continue; - - return result; - } - - return true; - } -} - -export function validateNonEmptyString(errorMsg: string): Validation { - return (answer) => { - return typeof answer === 'string' && answer.trim().length > 0 ? true : errorMsg; - } -} - -export function validateStringIsNotPath(errorMsg: string): Validation { - return (answer: string) => { - const found = answer.match(/[\/\\\:]/); - if (!found) return true; - - return found.length <= 0 ? true : errorMsg; - } +export type ProgramOption = { value: T, wasSet: boolean }; +type PromptOptions = Omit, "name">; +type CommandLineArgument = { + type: "argument"; + name?: string; + factory: (obj: Argument, schema: ProgramSchema) => void; +} | { + type: "option"; + flags: Option["flags"]; + factory?: (obj: Option, schema: ProgramSchema) => void; } - -export function validatePathDoesNotExist(errorMsg: string, base: string = TARGET_BASE): Validation { - return (answer) => { - return !existsSync(path.join(base, answer)) ? true : errorMsg; - } +type ProgramSchema = { + key: string; + description?: string; + initial?: PromptObject["initial"]; + validate?: Validation; + arg?: CommandLineArgument; + prompt?: () => PromptOptions; } - -export default [ +export const programSchema: ProgramSchema[] = [ { - type: 'text', - name: 'containingDirectoryName', - message: 'What do you want to name the project directory?', + key: "containingDirectoryName", + description: + "Project name used as the project directory and plugin namespace. Accepts a path relative to current working directory.", validate: validationBuilder([ validateNonEmptyString('Your project directory must have a name!'), validatePathDoesNotExist(`A directory with that name already exists!`), ]), + arg: { + type: "argument", + name: "projectDirectory", + factory(obj) { + obj.argOptional(); + }, + }, + prompt: () => ({ + type: 'text', + message: 'What do you want to name the project directory?', + }), }, { - type: 'toggle', - name: 'pluginSameName', - message: 'Do you want your plugin to have the same name as your project directory?', + key: "pluginSameName", initial: true, - active: 'yes', - inactive: 'no', + arg: { + type: "option", + flags: "--plugin-same-name", + factory(obj) { + obj.hidden = true; + }, + }, + prompt: () => ({ + type: 'toggle', + message: 'Do you want your plugin to have the same name as your project directory?', + active: 'yes', + inactive: 'no', + }), }, { - type: (_, values) => values.pluginSameName === false ? 'text' : null, - name: 'pluginName', - message: 'What do you want to name your plugin?', - initial: (_, values) => path.parse(values.containingDirectoryName).base, + key: "pluginName", + description: "Use this option to set a different name for your plugin's namespace.", validate: validationBuilder([ validateNonEmptyString('Your plugin must have a name!'), validateStringIsNotPath('Your plugin name cannot be a path!'), - ]) + ]), + arg: { + type: "option", + flags: "-p, --plugin-name ", + factory(obj) { + obj.implies({ pluginSameName: false }); + }, + }, + prompt: () => ({ + type: (_, values) => values.pluginSameName === false ? 'text' : null, + message: 'What do you want to name your plugin?', + }), }, { - type: 'text', - name: 'pluginAuthor', - message: 'Plugin author', + key: "pluginAuthor", initial: '', + arg: { + type: "option", + flags: "-a, --plugin-author ", + }, + prompt: () => ({ + type: 'text', + message: 'Plugin author', + }), + }, + { + key: "noPluginAuthor", + description: "Skip prompting for plugin author.", + initial: false, + arg: { + type: "option", + flags: "-A, --no-plugin-author", + factory(obj) { + obj.implies({ pluginAuthor: '' }) + }, + }, }, { - type: 'text', - name: 'pluginDescription', - message: 'Plugin description', + key: 'pluginDescription', initial: '', + arg: { + type: "option", + flags: "-d, --plugin-description ", + }, + prompt: () => ({ + type: 'text', + message: 'Plugin description', + }), }, { - type: 'text', - name: 'pluginVersion', - message: 'Initial version', + key: "noPluginAuthor", + description: "Skip prompting for plugin description.", + initial: false, + arg: { + type: "option", + flags: "-D, --no-plugin-description", + factory(obj) { + obj.implies({ pluginDescription: '' }) + }, + }, + }, + { + key: 'pluginVersion', + description: "Defaults to '0.0.1'", initial: '0.0.1', + arg: { + type: "option", + flags: "-v, --initial-version ", + }, + prompt: () => ({ + type: 'text', + message: 'Initial version', + }), }, { - type: 'toggle', - name: 'initGitRepo', - message: 'Initialize a git repository?', + key: 'initGitRepo', initial: true, - active: 'yes', - inactive: 'no', + arg: { + type: "option", + flags: "--init-git-repo", + factory(obj) { + obj.hidden = true; + }, + }, + prompt: () => ({ + type: 'toggle', + message: 'Initialize a git repository?', + active: 'yes', + inactive: 'no', + }), }, { - type: 'toggle', - name: 'setupUsingDotnetCli', - message: 'Setup plugin using dotnet?', + key: 'setupUsingDotnetCli', initial: true, - active: 'yes', - inactive: 'no', - onRender(kleur) { - //@ts-ignore - if (this.firstRender) { + arg: { + type: "option", + flags: "--setup-using-dotnet-cli", + factory(obj) { + obj.hidden = true; + }, + }, + prompt: () => ({ + type: 'toggle', + message: 'Setup plugin using dotnet?', + active: 'yes', + inactive: 'no', + onRender(kleur) { //@ts-ignore - this.msg = `Setup plugin using dotnet? ${kleur.gray('(You must have the dotnet CLI installed and accessible via `dotnet`)')}` + if (this.firstRender) { + //@ts-ignore + this.msg = `Setup plugin using dotnet? ${kleur.gray('(You must have the dotnet CLI installed and accessible via `dotnet`)')}` + } } - } + }), + }, + { + key: "interactive", + description: "Ask prompts while skipping values set using positional arguments and flags.", + arg: { + type: "option", + flags: "-i, --interactive", + }, + }, + { + key: "forceInteractive", + description: "Force ask all prompts. Options set via command-line are populated as prompt defaults.", + arg: { + type: "option", + flags: "-I, --force-interactive", + }, + }, + { + key: "runAllDefaultTasks", + description: "Skip all initial setup task prompts and run them. Ex. `git init`", + arg: { + type: "option", + flags: "-y, --run-all-tasks", + factory(obj) { + obj.implies({ + setupUsingDotnetCli: true, + initGitRepo: true, + }); + }, + }, }, ]; +export function addCommandLineArguments( + program: typeof commanderProgram, + optionsSchema: ProgramSchema[], +): Record { + const lookup: Record = {}; + + for (const schema of optionsSchema) { + if (!schema.arg) continue; + + const argDef = schema.arg; + const argOrOption = (() => { + if (argDef.type === "argument") + return new Argument(argDef.name ?? schema.key, schema.description); + + if (argDef.type === "option") + return new Option(argDef.flags, schema.description); + })(); + + if (!argOrOption) continue; + + if (!!schema.validate) + argOrOption.argParser((value) => { + const passedOrError = schema.validate!(value); + if (typeof passedOrError === "string") { + program.error(`${passedOrError}: ${value}`); + } + + if (passedOrError === false) { + program.help(); + } + + return value; + }); + + if (argDef.type === "argument") { + const argument = argOrOption as Argument; + if (argDef.factory) argDef.factory(argument, schema); + + program.addArgument(argument); + lookup[schema.key] = argument.name(); + } else if (argDef.type === "option") { + const option = argOrOption as Option; + if (argDef.factory) argDef.factory(option, schema); + + program.addOption(option); + lookup[schema.key] = option.attributeName(); + } + } + + return lookup; +} + +export function createPrompts(optionsSchema: ProgramSchema[], options: Record): PromptObject[] { + const prompts: PromptObject[] = []; + for (const schema of optionsSchema) { + if (!schema.prompt) continue; + + const prompt: PromptObject = { + name: schema.key, + initial: options[schema.key].value ?? (schema.initial ?? undefined), + message: schema.description, + validate: schema.validate, + ...schema.prompt(), + } + + prompts.push(prompt); + } + + return prompts; +} diff --git a/src/validations.ts b/src/validations.ts new file mode 100644 index 0000000..2eba529 --- /dev/null +++ b/src/validations.ts @@ -0,0 +1,41 @@ +import path from "path"; +import { existsSync } from "fs"; +import { TARGET_BASE } from "~/constants"; + +type ValidationClosure = (input: T) => boolean | string; +export type Validation = ValidationClosure; + +export function validationBuilder(validations: Validation[]): Validation { + return (answer: string) => { + for (const validate of validations) { + const result = validate(answer); + if (result === true) continue; + + return result; + } + + return true; + } +} + +export function validateNonEmptyString(errorMsg: string): Validation { + return (answer) => { + return typeof answer === 'string' && answer.trim().length > 0 ? true : errorMsg; + } +} + +export function validateStringIsNotPath(errorMsg: string): Validation { + return (answer: string) => { + const found = answer.match(/[\/\\\:]/); + if (!found) return true; + + return found.length <= 0 ? true : errorMsg; + } +} + +export function validatePathDoesNotExist(errorMsg: string, base: string = TARGET_BASE): Validation { + return (answer) => { + return !existsSync(path.join(base, answer)) ? true : errorMsg; + } +} +