diff --git a/packages/create-toolpad-app/src/core.ts b/packages/create-toolpad-app/src/core.ts new file mode 100644 index 00000000000..54bed93df00 --- /dev/null +++ b/packages/create-toolpad-app/src/core.ts @@ -0,0 +1,52 @@ +import chalk from 'chalk'; +import { execa } from 'execa'; +import type { GenerateProjectOptions } from './types'; +import generateProject from './generateProject'; +import writeFiles from './writeFiles'; + +export async function scaffoldCoreProject(options: GenerateProjectOptions): Promise { + // eslint-disable-next-line no-console + console.log(); + // eslint-disable-next-line no-console + console.log( + `${chalk.cyan('info')} - Creating Toolpad Core project in ${chalk.cyan(options.absolutePath)}`, + ); + // eslint-disable-next-line no-console + console.log(); + + const packageManager = options.packageManager; + + const files = generateProject(options); + await writeFiles(options.absolutePath, files); + + if (options.install) { + // eslint-disable-next-line no-console + console.log(`${chalk.cyan('info')} - Installing dependencies`); + // eslint-disable-next-line no-console + console.log(); + + await execa(packageManager, ['install'], { + stdio: 'inherit', + cwd: options.absolutePath, + }); + + // eslint-disable-next-line no-console + console.log(); + } + + // eslint-disable-next-line no-console + console.log( + `${chalk.green('success')} - Created Toolpad Core project at ${chalk.cyan(options.absolutePath)}`, + ); + // eslint-disable-next-line no-console + console.log(); + + if (options.auth) { + // eslint-disable-next-line no-console + console.log( + `${chalk.cyan('info')} - Bootstrapped ${chalk.cyan('env.local')} with empty values. See https://authjs.dev/getting-started on how to add your credentials.`, + ); + // eslint-disable-next-line no-console + console.log(); + } +} diff --git a/packages/create-toolpad-app/src/index.ts b/packages/create-toolpad-app/src/index.ts index e7d1f578cde..9b7c6dc102c 100644 --- a/packages/create-toolpad-app/src/index.ts +++ b/packages/create-toolpad-app/src/index.ts @@ -1,39 +1,19 @@ #!/usr/bin/env node -import * as fs from 'fs/promises'; -import { constants as fsConstants } from 'fs'; import path from 'path'; import yargs from 'yargs'; import { input, confirm, select, checkbox } from '@inquirer/prompts'; import chalk from 'chalk'; -import { errorFrom } from '@toolpad/utils/errors'; -import { execa } from 'execa'; import { satisfies } from 'semver'; -import { readJsonFile } from '@toolpad/utils/fs'; import invariant from 'invariant'; -import { bashResolvePath } from '@toolpad/utils/cli'; import type { SupportedAuthProvider } from '@toolpad/core/SignInPage'; -import generateProject from './generateProject'; -import generateStudioProject from './generateStudioProject'; -import writeFiles from './writeFiles'; +import { bashResolvePath } from '@toolpad/utils/cli'; import { downloadAndExtractExample } from './examples'; -import type { PackageJson } from './templates/packageType'; -import type { - SupportedFramework, - SupportedRouter, - PackageManager, - GenerateProjectOptions, -} from './types'; - -/** - * Find package.json of the create-toolpad-app package - */ -async function findCtaPackageJson() { - const ctaPackageJsonPath = path.resolve(__dirname, '../package.json'); - const content = await fs.readFile(ctaPackageJsonPath, 'utf8'); - const packageJson = JSON.parse(content); - return packageJson; -} +import type { SupportedFramework, SupportedRouter, GenerateProjectOptions } from './types'; +import { findCtaPackageJson, getPackageManager } from './package'; +import { scaffoldCoreProject } from './core'; +import { scaffoldStudioProject } from './studio'; +import { validatePath } from './validation'; declare global { interface Error { @@ -41,180 +21,12 @@ declare global { } } -function getPackageManager(): PackageManager { - const userAgent = process.env.npm_config_user_agent; - - if (userAgent) { - if (userAgent.startsWith('yarn')) { - return 'yarn'; - } - if (userAgent.startsWith('pnpm')) { - return 'pnpm'; - } - if (userAgent.startsWith('npm')) { - return 'npm'; - } - } - return 'npm'; -} - -// From https://github.com/vercel/next.js/blob/canary/packages/create-next-app/helpers/is-folder-empty.ts -async function isFolderEmpty(pathDir: string): Promise { - const validFiles = [ - '.DS_Store', - '.git', - '.gitattributes', - '.gitignore', - '.gitlab-ci.yml', - '.hg', - '.hgcheck', - '.hgignore', - '.idea', - '.npmignore', - '.travis.yml', - 'LICENSE', - 'Thumbs.db', - 'docs', - 'mkdocs.yml', - 'npm-debug.log', - 'yarn-debug.log', - 'yarn-error.log', - 'yarnrc.yml', - '.yarn', - ]; - - const conflicts = await fs.readdir(pathDir); - - conflicts - .filter((file) => !validFiles.includes(file)) - // Support IntelliJ IDEA-based editors - .filter((file) => !/\.iml$/.test(file)); - - if (conflicts.length > 0) { - return false; - } - return true; -} - -// Detect the package manager -const packageManager = getPackageManager(); - -const validatePath = async (relativePath: string): Promise => { - const absolutePath = bashResolvePath(relativePath); - - try { - await fs.access(absolutePath, fsConstants.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.cyan( - 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 scaffoldStudioProject = async (absolutePath: string, installFlag: boolean): Promise => { - // eslint-disable-next-line no-console - console.log(); - // eslint-disable-next-line no-console - console.log( - `${chalk.cyan('info')} - Creating Toolpad Studio project in ${chalk.cyan(absolutePath)}`, - ); - // eslint-disable-next-line no-console - console.log(); - const options: GenerateProjectOptions = { - name: path.basename(absolutePath), - absolutePath, - projectType: 'studio', - packageManager, - }; - const files = generateStudioProject(options); - - await writeFiles(absolutePath, files); - - if (installFlag) { - // eslint-disable-next-line no-console - console.log(`${chalk.cyan('info')} - Installing dependencies`); - // eslint-disable-next-line no-console - console.log(); - - await execa(packageManager, ['install'], { 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(); - } -}; - -const scaffoldCoreProject = async (options: GenerateProjectOptions): Promise => { - // eslint-disable-next-line no-console - console.log(); - // eslint-disable-next-line no-console - console.log( - `${chalk.cyan('info')} - Creating Toolpad Core project in ${chalk.cyan(options.absolutePath)}`, - ); - // eslint-disable-next-line no-console - console.log(); - const pkg = await findCtaPackageJson(); - if (!options.coreVersion) { - options.coreVersion = pkg.version; - } - const files = generateProject(options); - await writeFiles(options.absolutePath, files); - - if (options.install) { - // eslint-disable-next-line no-console - console.log(`${chalk.cyan('info')} - Installing dependencies`); - // eslint-disable-next-line no-console - console.log(); - - await execa(packageManager, ['install'], { stdio: 'inherit', cwd: options.absolutePath }); - - // eslint-disable-next-line no-console - console.log(); - } - // eslint-disable-next-line no-console - console.log( - `${chalk.green('success')} - Created Toolpad Core project at ${chalk.cyan(options.absolutePath)}`, - ); - // eslint-disable-next-line no-console - console.log(); - - if (options.auth) { - // eslint-disable-next-line no-console - console.log( - `${chalk.cyan('info')} - Bootstrapped ${chalk.cyan('env.local')} with empty values. See https://authjs.dev/getting-started on how to add your credentials.`, - ); - // eslint-disable-next-line no-console - console.log(); - } -}; - -// Run the CLI interaction with Inquirer.js const run = async () => { - const pkgJson: PackageJson = (await readJsonFile( - path.resolve(__dirname, `../package.json`), - )) as any; + const pkgJson = await findCtaPackageJson(); + const packageManager = getPackageManager(); invariant(pkgJson.engines?.node, 'Missing node version in package.json'); - // check the node version before create if (!satisfies(process.version, pkgJson.engines.node)) { // eslint-disable-next-line no-console console.log( @@ -259,27 +71,29 @@ const run = async () => { const example = args.example as string; if (pathArg) { - const pathValidOrError = await validatePath(pathArg); - if (typeof pathValidOrError === 'string') { - // eslint-disable-next-line no-console - console.log(); - // eslint-disable-next-line no-console - console.log(pathValidOrError); - // eslint-disable-next-line no-console - console.log(); - process.exit(1); + if (!example) { + const pathValidOrError = await validatePath(pathArg, true); + if (typeof pathValidOrError === 'string') { + // eslint-disable-next-line no-console + console.log(); + // eslint-disable-next-line no-console + console.log(pathValidOrError); + // eslint-disable-next-line no-console + console.log(); + process.exit(1); + } + } else { + await validatePath(pathArg); } } - let projectPath = pathArg; + let projectPath = pathArg; if (!pathArg) { projectPath = await input({ message: example ? `Enter path of directory to download example "${chalk.cyan(example)}" into` : 'Enter path of directory to bootstrap new app', - // This check is only necessary if an empty app is being bootstrapped, - // not if an example is being downloaded. - validate: example ? () => true : validatePath, + validate: (pathInput) => validatePath(pathInput, !example), default: '.', }); } @@ -289,16 +103,11 @@ const run = async () => { let hasNodemailerProvider = false; let hasPasskeyProvider = false; - // If the user has provided an example, download and extract it if (example) { await downloadAndExtractExample(absolutePath, example); - } - - // If the studio flag is set, create a new project with Toolpad Studio - else if (studioFlag) { - await scaffoldStudioProject(absolutePath, installFlag); + } else if (studioFlag) { + await scaffoldStudioProject(absolutePath, installFlag, packageManager); } else { - // Otherwise, create a new project with Toolpad Core const frameworkOption: SupportedFramework = await select({ message: 'Which framework would you like to use?', default: 'nextjs', @@ -367,10 +176,10 @@ const run = async () => { hasPasskeyProvider = authProviderOptions?.includes('passkey'); } - const options = { + const options: GenerateProjectOptions = { name: path.basename(absolutePath), absolutePath, - coreVersion: args.coreVersion, + coreVersion: args.coreVersion ?? pkgJson.version, router: routerOption, framework: frameworkOption, auth: authFlag, @@ -379,18 +188,22 @@ const run = async () => { hasCredentialsProvider: authProviderOptions?.includes('credentials'), hasNodemailerProvider, hasPasskeyProvider, + packageManager, }; + await scaffoldCoreProject(options); } - 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)}\n` - : ''; + let changeDirectoryInstruction = ''; + if (example) { + if (!path.relative(process.cwd(), absolutePath)) { + changeDirectoryInstruction = ` cd ./${example}\n`; + } else { + changeDirectoryInstruction = ` cd ${path.basename(absolutePath)}/${example}\n`; + } + } else if (path.relative(process.cwd(), absolutePath)) { + changeDirectoryInstruction = ` cd ${path.relative(process.cwd(), absolutePath)}\n`; + } const installInstruction = example || !installFlag ? ` ${packageManager} install\n` : ''; diff --git a/packages/create-toolpad-app/src/package.ts b/packages/create-toolpad-app/src/package.ts new file mode 100644 index 00000000000..e13a5855ece --- /dev/null +++ b/packages/create-toolpad-app/src/package.ts @@ -0,0 +1,28 @@ +import path from 'path'; +import * as fs from 'fs/promises'; +import type { PackageManager } from './types'; +import type { PackageJson } from './packageType'; + +export function getPackageManager(): PackageManager { + const userAgent = process.env.npm_config_user_agent; + + if (userAgent) { + if (userAgent.startsWith('yarn')) { + return 'yarn'; + } + if (userAgent.startsWith('pnpm')) { + return 'pnpm'; + } + if (userAgent.startsWith('npm')) { + return 'npm'; + } + } + return 'pnpm'; +} + +export async function findCtaPackageJson(): Promise { + const ctaPackageJsonPath = path.resolve(__dirname, '../package.json'); + const content = await fs.readFile(ctaPackageJsonPath, 'utf8'); + const packageJson = JSON.parse(content); + return packageJson; +} diff --git a/packages/create-toolpad-app/src/templates/packageType.ts b/packages/create-toolpad-app/src/packageType.ts similarity index 100% rename from packages/create-toolpad-app/src/templates/packageType.ts rename to packages/create-toolpad-app/src/packageType.ts diff --git a/packages/create-toolpad-app/src/studio.ts b/packages/create-toolpad-app/src/studio.ts new file mode 100644 index 00000000000..834c0ff0506 --- /dev/null +++ b/packages/create-toolpad-app/src/studio.ts @@ -0,0 +1,48 @@ +import path from 'path'; +import chalk from 'chalk'; +import { execa } from 'execa'; + +import type { PackageManager, GenerateProjectOptions } from './types'; +import generateStudioProject from './generateStudioProject'; +import writeFiles from './writeFiles'; + +export async function scaffoldStudioProject( + absolutePath: string, + installFlag: boolean, + packageManager: PackageManager, +): Promise { + // eslint-disable-next-line no-console + console.log(); + // eslint-disable-next-line no-console + console.log( + `${chalk.cyan('info')} - Creating Toolpad Studio project in ${chalk.cyan(absolutePath)}`, + ); + // eslint-disable-next-line no-console + console.log(); + + const options: GenerateProjectOptions = { + name: path.basename(absolutePath), + absolutePath, + projectType: 'studio', + packageManager, + }; + + const files = generateStudioProject(options); + await writeFiles(absolutePath, files); + + if (installFlag) { + // eslint-disable-next-line no-console + console.log(`${chalk.cyan('info')} - Installing dependencies`); + // eslint-disable-next-line no-console + console.log(); + + await execa(packageManager, ['install'], { 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(); + } +} diff --git a/packages/create-toolpad-app/src/types.ts b/packages/create-toolpad-app/src/types.ts index 9f49b9fde76..e4ed06d01af 100644 --- a/packages/create-toolpad-app/src/types.ts +++ b/packages/create-toolpad-app/src/types.ts @@ -1,6 +1,5 @@ import type { SupportedAuthProvider } from '@toolpad/core/SignInPage'; - -import { PackageJson } from './templates/packageType'; +import { PackageJson } from './packageType'; export type SupportedRouter = 'nextjs-app' | 'nextjs-pages'; export type PackageManager = 'npm' | 'pnpm' | 'yarn'; @@ -21,7 +20,7 @@ export interface GenerateProjectOptions { framework?: SupportedFramework; coreVersion?: string; projectType?: ProjectType; - packageManager?: PackageManager; + packageManager: PackageManager; } export type Template = (options: GenerateProjectOptions) => string; diff --git a/packages/create-toolpad-app/src/validation.ts b/packages/create-toolpad-app/src/validation.ts new file mode 100644 index 00000000000..3d6e676404d --- /dev/null +++ b/packages/create-toolpad-app/src/validation.ts @@ -0,0 +1,65 @@ +import * as fs from 'fs/promises'; +import { constants as fsConstants } from 'fs'; +import chalk from 'chalk'; +import { errorFrom } from '@toolpad/utils/errors'; +import { bashResolvePath } from '@toolpad/utils/cli'; + +const VALID_FILES = [ + '.DS_Store', + '.git', + '.gitattributes', + '.gitignore', + '.gitlab-ci.yml', + '.hg', + '.hgcheck', + '.hgignore', + '.idea', + '.npmignore', + '.travis.yml', + 'LICENSE', + 'Thumbs.db', + 'docs', + 'mkdocs.yml', + 'npm-debug.log', + 'yarn-debug.log', + 'yarn-error.log', + 'yarnrc.yml', + '.yarn', +]; + +export async function isFolderEmpty(pathDir: string): Promise { + const conflicts = await fs.readdir(pathDir); + + conflicts.filter((file) => !VALID_FILES.includes(file)).filter((file) => !/\.iml$/.test(file)); + + return conflicts.length === 0; +} + +export async function validatePath( + relativePath: string, + emptyCheck?: boolean, +): Promise { + const absolutePath = bashResolvePath(relativePath); + + try { + await fs.access(absolutePath, fsConstants.F_OK); + + if (emptyCheck) { + if (await isFolderEmpty(absolutePath)) { + return true; + } + + return `${chalk.red('error')} - The directory at ${chalk.cyan( + absolutePath, + )} contains files that could conflict. Either use a new directory, or remove conflicting files.`; + } + return true; + } catch (rawError: unknown) { + const error = errorFrom(rawError); + if (error.code === 'ENOENT') { + await fs.mkdir(absolutePath, { recursive: true }); + return true; + } + throw error; + } +}