From 2e7c0b2ac702ca69a91a6c7a39c0a3698a519dc9 Mon Sep 17 00:00:00 2001 From: Bharat Kashyap Date: Thu, 20 Apr 2023 08:16:33 -0700 Subject: [PATCH] Add folder as argument to `create-toolpad-app` (#1795) Co-authored-by: Jan Potoms <2109932+Janpot@users.noreply.github.com> --- README.md | 6 +- packages/create-toolpad-app/src/index.ts | 289 +++++++++++++--------- packages/create-toolpad-app/tsconfig.json | 1 + test/integration/editor/new.spec.ts | 2 +- 4 files changed, 181 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 6cc2aadae5d..e09f2993e89 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,11 @@ MUI Toolpad is in its alpha stages of development. Feel free to run this applica Run: ```sh -npx create-toolpad-app +npx create-toolpad-app my-app # or -yarn create toolpad-app +yarn create toolpad-app my-app # or -pnpm create toolpad-app +pnpm create toolpad-app my-app ``` ## Documentation diff --git a/packages/create-toolpad-app/src/index.ts b/packages/create-toolpad-app/src/index.ts index de9710dfbe4..e15efe1d2fa 100644 --- a/packages/create-toolpad-app/src/index.ts +++ b/packages/create-toolpad-app/src/index.ts @@ -2,9 +2,76 @@ import * as fs from 'fs/promises'; import path from 'path'; +import yargs from 'yargs'; type PackageManager = 'npm' | 'pnpm' | 'yarn'; +type Require = T & { [P in K]-?: T[P] }; + +type Ensure = K extends keyof U ? Require : U & Record; + +declare global { + interface Error { + code?: unknown; + } +} +/** + * Type aware version of Object.protoype.hasOwnProperty. + * See https://fettblog.eu/typescript-hasownproperty/ + */ + +function hasOwnProperty(obj: X, prop: Y): obj is Ensure { + return obj.hasOwnProperty(prop); +} + +/** + * Limits the length of a string and adds ellipsis if necessary. + */ + +function truncate(str: string, maxLength: number, dots: string = '...') { + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength) + dots; +} + +/** + * Creates a javascript `Error` from an unkown value if it's not already an error. + * Does a best effort at inferring a message. Intended to be used typically in `catch` + * blocks, as there is no way to enforce only `Error` objects being thrown. + * + * ``` + * try { + * // ... + * } catch (rawError) { + * const error = errorFrom(rawError); + * console.assert(error instancof Error); + * } + * ``` + */ + +function errorFrom(maybeError: unknown): Error { + if (maybeError instanceof Error) { + return maybeError; + } + + if ( + typeof maybeError === 'object' && + maybeError && + hasOwnProperty(maybeError, 'message') && + typeof maybeError.message! === 'string' + ) { + return new Error(maybeError.message, { cause: maybeError }); + } + + if (typeof maybeError === 'string') { + return new Error(maybeError, { cause: maybeError }); + } + + const message = truncate(JSON.stringify(maybeError), 500); + return new Error(message, { cause: maybeError }); +} + function getPackageManager(): PackageManager { const userAgent = process.env.npm_config_user_agent; @@ -24,8 +91,7 @@ function getPackageManager(): PackageManager { // From https://github.com/vercel/next.js/blob/canary/packages/create-next-app/helpers/is-folder-empty.ts -async function isFolderEmpty(root: string, name: string): Promise { - const { default: chalk } = await import('chalk'); +async function isFolderEmpty(pathDir: string): Promise { const validFiles = [ '.DS_Store', '.git', @@ -49,7 +115,7 @@ async function isFolderEmpty(root: string, name: string): Promise { '.yarn', ]; - const conflicts = await fs.readdir(root); + const conflicts = await fs.readdir(pathDir); conflicts .filter((file) => !validFiles.includes(file)) @@ -57,28 +123,6 @@ async function isFolderEmpty(root: string, name: string): Promise { .filter((file) => !/\.iml$/.test(file)); if (conflicts.length > 0) { - // eslint-disable-next-line no-console - console.log(`The directory ${chalk.green(name)} contains files that could conflict:`); - // eslint-disable-next-line no-console - console.log(); - for (const file of conflicts) { - try { - // eslint-disable-next-line no-await-in-loop - const stats = await fs.lstat(path.join(root, file)); - if (stats.isDirectory()) { - // eslint-disable-next-line no-console - console.log(` ${chalk.blue(file)}/`); - } else { - // eslint-disable-next-line no-console - console.log(` ${file}`); - } - } catch { - // eslint-disable-next-line no-console - console.log(` ${file}`); - } - } - // eslint-disable-next-line no-console - console.log(); return false; } return true; @@ -87,116 +131,137 @@ async function isFolderEmpty(root: string, name: string): Promise { // Detect the package manager const packageManager = getPackageManager(); -// Install the dependencies -const installDeps = async (projectName: string, cwd: string) => { - const { execaCommand } = await import('execa'); +const validatePath = async (relativePath: string): Promise => { + const { default: chalk } = await import('chalk'); + + const absolutePath = path.join(process.cwd(), relativePath); + + try { + await fs.access(absolutePath, fs.constants.F_OK); + + // Directory exists, verify if it's empty to proceed + if (await isFolderEmpty(absolutePath)) { + return true; + } + return `${chalk.red('error')} - The directory at ${chalk.blue( + absolutePath, + )} contains files that could conflict. Either use a new directory, or remove conflicting files.`; + } catch (rawError: unknown) { + // Directory does not exist, create it + const error = errorFrom(rawError); + if (error.code === 'ENOENT') { + await fs.mkdir(absolutePath, { recursive: true }); + return true; + } + // Unexpected error, let it bubble up and crash the process + throw error; + } +}; + +// Create a new `package.json` file and install dependencies +const scaffoldProject = async (absolutePath: string): Promise => { const { default: chalk } = await import('chalk'); + const { execaCommand } = await import('execa'); + // eslint-disable-next-line no-console - console.log(`${chalk.blue('info')} - Installing dependencies`); + console.log(); + // eslint-disable-next-line no-console + console.log(`${chalk.blue('info')} - Creating Toolpad project in ${chalk.blue(absolutePath)}`); + // eslint-disable-next-line no-console + console.log(); + + const packageJson = { + name: path.basename(absolutePath), + version: '0.1.0', + private: true, + scripts: { + dev: 'toolpad dev', + build: 'toolpad build', + start: 'toolpad start', + }, + }; + + await fs.writeFile(path.join(absolutePath, 'package.json'), JSON.stringify(packageJson, null, 2)); + + // eslint-disable-next-line no-console + console.log( + `${chalk.blue('info')} - Installing the following dependencies: ${chalk.magenta( + '@mui/toolpad', + )}`, + ); // eslint-disable-next-line no-console console.log(); const installVerb = packageManager === 'yarn' ? 'add' : 'install'; const command = `${packageManager} ${installVerb} @mui/toolpad`; - await execaCommand(command, { stdio: 'inherit', cwd: path.join(cwd, projectName) }); + await execaCommand(command, { stdio: 'inherit', cwd: absolutePath }); + // eslint-disable-next-line no-console + console.log(); // eslint-disable-next-line no-console console.log(`${chalk.green('success')} - Dependencies installed successfully!`); // eslint-disable-next-line no-console console.log(); }; -// Create a new directory and initialize a new project -const scaffoldProject = async (projectName: string, cwd: string): Promise => { +// Run the CLI interaction with Inquirer.js +const run = async () => { const { default: chalk } = await import('chalk'); const { default: inquirer } = await import('inquirer'); - // eslint-disable-next-line no-console - console.log(`Creating a new MUI Toolpad project in ${chalk.blue(projectName)}`); - // eslint-disable-next-line no-console - console.log(); - try { - await fs.mkdir(projectName); - - const packageJson = { - name: projectName, - version: '0.1.0', - private: true, - scripts: { - dev: 'toolpad dev', - build: 'toolpad build', - start: 'toolpad start', - }, - }; - - await fs.writeFile(`./${projectName}/package.json`, JSON.stringify(packageJson, null, 2)); - return projectName; - } catch (error) { - // Directory exists, verify if it is empty to continue - if (!(await isFolderEmpty(path.join(cwd, projectName), projectName))) { - // eslint-disable-next-line no-console - console.log('Either try using a new directory name, or remove the files listed above.'); + + const args = await yargs(process.argv.slice(2)) + .scriptName('create-toolpad-app') + .usage('$0 [path]') + .positional('path', { + type: 'string', + describe: 'The path where the Toolpad project directory will be created', + }) + .help().argv; + + const pathArg = args._?.[0] as string; + + if (pathArg) { + const pathValidOrError = await validatePath(pathArg); + if (typeof pathValidOrError === 'string') { // eslint-disable-next-line no-console console.log(); - return null; - } - try { - await fs.access(projectName); - - const installDependenciesConsent = await inquirer.prompt([ - { - type: 'confirm', - name: 'installInExisting', - message: `The following dependencies will be installed: ${chalk.magentaBright( - '@mui/toolpad', - )}. Do you want to continue?`, - default: false, - }, - ]); - - if (installDependenciesConsent.installInExisting) { - return projectName; - } - console.error(`${chalk.red('error')} - Dependencies are required to be installed.`); // eslint-disable-next-line no-console - console.log(); - process.exit(1); - } catch (err) { - console.error( - `Unable to create directory ${chalk.red(projectName)}. Please provide a different name.`, - ); + console.log(pathValidOrError); // eslint-disable-next-line no-console console.log(); + process.exit(1); } - return null; } -}; -// Run the CLI interaction with Inquirer.js -const run = async () => { - const { default: chalk } = await import('chalk'); - const { default: inquirer } = await import('inquirer'); - let projectName; - const cwd = process.cwd(); - do { - // eslint-disable-next-line no-await-in-loop - const name = await inquirer.prompt([ - { - type: 'input', - name: 'projectName', - message: 'Enter the name of your project:', - default: 'my-toolpad-app', - }, - ]); - - // eslint-disable-next-line no-await-in-loop - projectName = await scaffoldProject(name.projectName, cwd); - } while (!projectName); - - await installDeps(projectName, cwd); - - const message = `\nRun the following to get started: \n\n ${chalk.magentaBright( - `cd ${projectName} && ${packageManager}${packageManager === 'yarn' ? '' : ' run'} dev`, - )}\n`; + const questions = [ + { + type: 'input', + name: 'path', + message: 'Enter path for new project directory:', + validate: (input: string) => validatePath(input), + when: !pathArg, + default: '.', + }, + ]; + + const answers = await inquirer.prompt(questions); + + const absolutePath = path.join(process.cwd(), answers.path || pathArg); + + await scaffoldProject(absolutePath); + + const changeDirectoryInstruction = + /* `path.relative` is truth-y if the relative path + * between `absolutePath` and `process.cwd()` + * is not empty + */ + path.relative(process.cwd(), absolutePath) + ? `cd ${path.relative(process.cwd(), absolutePath)} && ` + : ''; + + const message = `Run the following to get started: \n\n${chalk.magentaBright( + `${changeDirectoryInstruction}${packageManager}${packageManager === 'yarn' ? '' : ' run'} dev`, + )}`; // eslint-disable-next-line no-console console.log(message); // eslint-disable-next-line no-console @@ -207,5 +272,3 @@ run().catch((error) => { console.error(error.message); process.exit(1); }); - -// Define the questions to be asked during the CLI interaction diff --git a/packages/create-toolpad-app/tsconfig.json b/packages/create-toolpad-app/tsconfig.json index 91a7b21518c..0bc1b60ca4a 100644 --- a/packages/create-toolpad-app/tsconfig.json +++ b/packages/create-toolpad-app/tsconfig.json @@ -3,6 +3,7 @@ "target": "es2020", "module": "commonjs", "moduleResolution": "node16", + "lib": ["esnext"], "isolatedModules": true, "strict": true, "resolveJsonModule": true, diff --git a/test/integration/editor/new.spec.ts b/test/integration/editor/new.spec.ts index d8b64300a07..e772c81ff07 100644 --- a/test/integration/editor/new.spec.ts +++ b/test/integration/editor/new.spec.ts @@ -53,7 +53,7 @@ test('can create/delete page', async ({ page, localApp }) => { await editorModel.createPage('somePage'); - const pageMenuItem = editorModel.hierarchyItem('pages', 'somePage'); + const pageMenuItem = editorModel.getHierarchyItem('pages', 'somePage'); const pageFolder = path.resolve(localApp.dir, './toolpad/pages/somePage'); const pageFile = path.resolve(pageFolder, './page.yml');