From adb43842a9a54c494d21a57c68eb54d668d77f26 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Mon, 14 Oct 2024 13:56:20 +0200 Subject: [PATCH] feat(create): Improved getting started experience (#3128) --- .../getting-started/installation/index.md | 2 - package-lock.json | 2 +- packages/core/src/cli/populate.ts | 6 +- packages/create/README.md | 34 +-- packages/create/package.json | 1 + packages/create/src/create-vendure-app.ts | 287 ++++++++++++++---- packages/create/src/gather-user-responses.ts | 88 ++++-- packages/create/src/helpers.ts | 267 +++++++++++----- packages/create/src/logger.ts | 24 ++ packages/create/src/types.ts | 4 +- packages/create/templates/Dockerfile.hbs | 6 +- packages/create/templates/docker-compose.hbs | 152 +++++++--- packages/create/templates/readme.hbs | 10 +- 13 files changed, 637 insertions(+), 246 deletions(-) create mode 100644 packages/create/src/logger.ts diff --git a/docs/docs/guides/getting-started/installation/index.md b/docs/docs/guides/getting-started/installation/index.md index 1e0184306b..3782700d8f 100644 --- a/docs/docs/guides/getting-started/installation/index.md +++ b/docs/docs/guides/getting-started/installation/index.md @@ -66,8 +66,6 @@ Follow the instructions to move into the new directory created for your project, ```bash cd my-shop -yarn dev -# or npm run dev ``` diff --git a/package-lock.json b/package-lock.json index b237fd1bbd..471e64c563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28269,7 +28269,6 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "license": "MIT", "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -36837,6 +36836,7 @@ "cross-spawn": "^7.0.3", "fs-extra": "^11.2.0", "handlebars": "^4.7.8", + "open": "^8.4.2", "picocolors": "^1.0.0", "semver": "^7.5.4", "tcp-port-used": "^1.0.2" diff --git a/packages/core/src/cli/populate.ts b/packages/core/src/cli/populate.ts index a2ccaf9ccd..d2c07fe83a 100644 --- a/packages/core/src/cli/populate.ts +++ b/packages/core/src/cli/populate.ts @@ -150,5 +150,9 @@ export async function importProductsFromCsv( languageCode, channelOrToken: channel, }); - return lastValueFrom(importer.parseAndImport(productData, ctx, true)); + const createEnvVar: import('@vendure/common/lib/shared-constants').CREATING_VENDURE_APP = + 'CREATING_VENDURE_APP'; + // Turn off progress bar when running in the context of the @vendure/create script + const reportProgress = process.env[createEnvVar] === 'true' ? false : true; + return lastValueFrom(importer.parseAndImport(productData, ctx, reportProgress)); } diff --git a/packages/create/README.md b/packages/create/README.md index ee9f4f248d..863a98fa8e 100644 --- a/packages/create/README.md +++ b/packages/create/README.md @@ -4,47 +4,17 @@ A CLI tool for rapidly scaffolding a new Vendure server application. Heavily ins ## Usage -Vendure Create requires [Node.js](https://nodejs.org/en/) v8.9.0+ to be installed. - -To create a new project, you may choose one of the following methods: - -### npx +Vendure Create requires [Node.js](https://nodejs.org/en/) v18+ to be installed. ```sh npx @vendure/create my-app ``` -*[npx](https://medium.com/@maybekatz/introducing-npx-an-npm-package-runner-55f7d4bd282b) comes with npm 5.2+ and higher.* - -### npm - -```sh -npm init @vendure my-app -``` - -*`npm init ` is available in npm 6+* - -### Yarn - -```sh -yarn create @vendure my-app -``` - -*`yarn create` is available in Yarn 0.25+* - - -It will create a directory called `my-app` inside the current folder. - ## Options -### `--use-npm` - -By default, Vendure Create will detect whether a compatible version of Yarn is installed, and if so will display a prompt to select the preferred package manager. -You can override this and force it to use npm with the `--use-npm` flag. - ### `--log-level` -You can control how much output is generated during the installation and setup with this flag. Valid options are `silent`, `info` and `verbose`. The default is `silent` +You can control how much output is generated during the installation and setup with this flag. Valid options are `silent`, `info` and `verbose`. The default is `info` Example: diff --git a/packages/create/package.json b/packages/create/package.json index be75c23ad3..3ff065cfb0 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -39,6 +39,7 @@ "cross-spawn": "^7.0.3", "fs-extra": "^11.2.0", "handlebars": "^4.7.8", + "open": "^8.4.2", "picocolors": "^1.0.0", "semver": "^7.5.4", "tcp-port-used": "^1.0.2" diff --git a/packages/create/src/create-vendure-app.ts b/packages/create/src/create-vendure-app.ts index 04073f4c6e..ac49889e08 100644 --- a/packages/create/src/create-vendure-app.ts +++ b/packages/create/src/create-vendure-app.ts @@ -1,24 +1,33 @@ -/* eslint-disable no-console */ import { intro, note, outro, select, spinner } from '@clack/prompts'; import { program } from 'commander'; import fs from 'fs-extra'; +import { ChildProcess, spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import open from 'open'; import os from 'os'; import path from 'path'; import pc from 'picocolors'; import { REQUIRED_NODE_VERSION, SERVER_PORT } from './constants'; -import { checkCancel, gatherCiUserResponses, gatherUserResponses } from './gather-user-responses'; import { + getCiConfiguration, + getManualConfiguration, + getQuickStartConfiguration, +} from './gather-user-responses'; +import { + checkCancel, checkDbConnection, checkNodeVersion, checkThatNpmCanReadCwd, + cleanUpDockerResources, getDependencies, installPackages, isSafeToCreateProjectIn, isServerPortInUse, scaffoldAlreadyExists, - yarnIsAvailable, + startPostgresDatabase, } from './helpers'; +import { log, setLogLevel } from './logger'; import { CliLogLevel, PackageManager } from './types'; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -44,14 +53,23 @@ program '--log-level ', "Log level, either 'silent', 'info', or 'verbose'", /^(silent|info|verbose)$/i, - 'silent', + 'info', + ) + .option('--verbose', 'Alias for --log-level verbose', false) + .option( + '--use-npm', + 'Uses npm rather than as the default package manager. DEPRECATED: Npm is now the default', ) - .option('--use-npm', 'Uses npm rather than Yarn as the default package manager') - .option('--ci', 'Runs without prompts for use in CI scenarios') + .option('--ci', 'Runs without prompts for use in CI scenarios', false) .parse(process.argv); const options = program.opts(); -void createVendureApp(projectName, options.useNpm, options.logLevel || 'silent', options.ci); +void createVendureApp( + projectName, + options.useNpm, + options.verbose ? 'verbose' : options.logLevel || 'info', + options.ci, +); export async function createVendureApp( name: string | undefined, @@ -59,6 +77,7 @@ export async function createVendureApp( logLevel: CliLogLevel, isCi: boolean = false, ) { + setLogLevel(logLevel); if (!runPreChecks(name, useNpm)) { return; } @@ -67,6 +86,22 @@ export async function createVendureApp( `Let's create a ${pc.blue(pc.bold('Vendure App'))} ✨ ${pc.dim(`v${packageJson.version as string}`)}`, ); + const mode = isCi + ? 'ci' + : ((await select({ + message: 'How should we proceed?', + options: [ + { label: 'Quick Start', value: 'quick', hint: 'Get up an running in a single step' }, + { + label: 'Manual Configuration', + value: 'manual', + hint: 'Customize your Vendure project with more advanced settings', + }, + ], + initialValue: 'quick' as 'quick' | 'manual', + })) as 'quick' | 'manual'); + checkCancel(mode); + const portSpinner = spinner(); let port = SERVER_PORT; const attemptedPortRange = 20; @@ -90,27 +125,15 @@ export async function createVendureApp( const appName = path.basename(root); const scaffoldExists = scaffoldAlreadyExists(root, name); - const yarnAvailable = yarnIsAvailable(); - let packageManager: PackageManager = 'npm'; - if (yarnAvailable && !useNpm) { - packageManager = (await select({ - message: 'Which package manager should be used?', - options: [ - { label: 'npm', value: 'npm' }, - { label: 'yarn', value: 'yarn' }, - ], - initialValue: 'yarn' as PackageManager, - })) as PackageManager; - checkCancel(packageManager); - } + const packageManager: PackageManager = 'npm'; if (scaffoldExists) { - console.log( + log( pc.yellow( 'It appears that a new Vendure project scaffold already exists. Re-using the existing files...', ), + { newline: 'after' }, ); - console.log(); } const { dbType, @@ -123,10 +146,12 @@ export async function createVendureApp( dockerfileSource, dockerComposeSource, populateProducts, - } = isCi - ? await gatherCiUserResponses(root, packageManager) - : await gatherUserResponses(root, scaffoldExists, packageManager); - const originalDirectory = process.cwd(); + } = + mode === 'ci' + ? await getCiConfiguration(root, packageManager) + : mode === 'manual' + ? await getManualConfiguration(root, packageManager) + : await getQuickStartConfiguration(root, packageManager); process.chdir(root); if (packageManager !== 'npm' && !checkThatNpmCanReadCwd()) { process.exit(1); @@ -139,11 +164,11 @@ export async function createVendureApp( scripts: { 'dev:server': 'ts-node ./src/index.ts', 'dev:worker': 'ts-node ./src/index-worker.ts', - dev: packageManager === 'yarn' ? 'concurrently yarn:dev:*' : 'concurrently npm:dev:*', + dev: 'concurrently npm:dev:*', build: 'tsc', 'start:server': 'node ./dist/index.js', 'start:worker': 'node ./dist/index-worker.js', - start: packageManager === 'yarn' ? 'concurrently yarn:start:*' : 'concurrently npm:start:*', + start: 'concurrently npm:start:*', }, }; @@ -152,7 +177,6 @@ export async function createVendureApp( `Setting up your new Vendure project in ${pc.green(root)}\nThis may take a few minutes...`, ); - const rootPathScript = (fileName: string): string => path.join(root, `${fileName}.ts`); const srcPathScript = (fileName: string): string => path.join(root, 'src', `${fileName}.ts`); fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(packageJsonContents, null, 2) + os.EOL); @@ -162,9 +186,9 @@ export async function createVendureApp( const installSpinner = spinner(); installSpinner.start(`Installing ${dependencies[0]} + ${dependencies.length - 1} more dependencies`); try { - await installPackages(root, packageManager === 'yarn', dependencies, false, logLevel, isCi); + await installPackages({ dependencies, logLevel }); } catch (e) { - outro(pc.red(`Failed to install dependencies. Please try again.`)); + outro(pc.red(`Failed to inst all dependencies. Please try again.`)); process.exit(1); } installSpinner.stop(`Successfully installed ${dependencies.length} dependencies`); @@ -175,7 +199,7 @@ export async function createVendureApp( `Installing ${devDependencies[0]} + ${devDependencies.length - 1} more dev dependencies`, ); try { - await installPackages(root, packageManager === 'yarn', devDependencies, true, logLevel, isCi); + await installPackages({ dependencies: devDependencies, isDevDependencies: true, logLevel }); } catch (e) { outro(pc.red(`Failed to install dev dependencies. Please try again.`)); process.exit(1); @@ -185,6 +209,10 @@ export async function createVendureApp( const scaffoldSpinner = spinner(); scaffoldSpinner.start(`Generating app scaffold`); + // We add this pause so that the above output is displayed before the + // potentially lengthy file operations begin, which can prevent that + // from displaying and thus make the user think that the process has hung. + await sleep(500); fs.ensureDirSync(path.join(root, 'src')); const assetPath = (fileName: string) => path.join(__dirname, '../assets', fileName); const configFile = srcPathScript('vendure-config'); @@ -199,34 +227,87 @@ export async function createVendureApp( .then(() => fs.writeFile(path.join(root, 'README.md'), readmeSource)) .then(() => fs.writeFile(path.join(root, 'Dockerfile'), dockerfileSource)) .then(() => fs.writeFile(path.join(root, 'docker-compose.yml'), dockerComposeSource)) - .then(() => fs.mkdir(path.join(root, 'src/plugins'))) + .then(() => fs.ensureDir(path.join(root, 'src/plugins'))) .then(() => fs.copyFile(assetPath('gitignore.template'), path.join(root, '.gitignore'))) .then(() => fs.copyFile(assetPath('tsconfig.template.json'), path.join(root, 'tsconfig.json'))) .then(() => createDirectoryStructure(root)) .then(() => copyEmailTemplates(root)); - } catch (e) { - outro(pc.red(`Failed to create app scaffold. Please try again.`)); + } catch (e: any) { + outro(pc.red(`Failed to create app scaffold: ${e.message as string}`)); process.exit(1); } scaffoldSpinner.stop(`Generated app scaffold`); + if (mode === 'quick' && dbType === 'postgres') { + cleanUpDockerResources(name); + await startPostgresDatabase(root); + } + const populateSpinner = spinner(); populateSpinner.start(`Initializing your new Vendure server`); + + // We want to display a set of tips and instructions to the user + // as the initialization process is running because it can take + // a few minutes to complete. + const tips = [ + populateProducts + ? 'We are populating sample data so that you can start testing right away' + : 'We are setting up your Vendure server', + '☕ This can take a minute or two, so grab a coffee', + `✨ We'd love it if you drop us a star on GitHub: https://github.com/vendure-ecommerce/vendure`, + `📖 Check out the Vendure documentation at https://docs.vendure.io`, + `💬 Join our Discord community to chat with other Vendure developers: https://vendure.io/community`, + '💡 In the mean time, here are some tips to get you started', + `Vendure provides dedicated GraphQL APIs for both the Admin and Shop`, + `Almost every aspect of Vendure is customizable via plugins`, + `You can run 'vendure add' from the command line to add new plugins & features`, + `Use the EventBus in your plugins to react to events in the system`, + `Vendure supports multiple languages & currencies out of the box`, + `☕ Did we mention this can take a while?`, + `Our custom fields feature allows you to add any kind of data to your entities`, + `Vendure is built with TypeScript, so you get full type safety`, + `Combined with GraphQL's static schema, your type safety is end-to-end`, + `☕ Almost there now... thanks for your patience!`, + `Collections allow you to group products together`, + `Our AssetServerPlugin allows you to dynamically resize & optimize images`, + `Order flows are fully customizable to suit your business requirements`, + `Role-based permissions allow you to control access to every part of the system`, + `Customers can be grouped for targeted promotions & custom pricing`, + `You can find integrations in the Vendure Hub: https://vendure.io/hub`, + ]; + + let tipIndex = 0; + let timer: any; + const tipInterval = 10_000; + + function displayTip() { + populateSpinner.message(tips[tipIndex]); + tipIndex++; + if (tipIndex >= tips.length) { + // skip the intro tips if looping + tipIndex = 3; + } + timer = setTimeout(displayTip, tipInterval); + } + + timer = setTimeout(displayTip, tipInterval); + // register ts-node so that the config file can be loaded // eslint-disable-next-line @typescript-eslint/no-var-requires require(path.join(root, 'node_modules/ts-node')).register(); + let superAdminCredentials: { identifier: string; password: string } | undefined; try { const { populate } = await import(path.join(root, 'node_modules/@vendure/core/cli/populate')); - const { bootstrap, DefaultLogger, LogLevel, JobQueueService } = await import( + const { bootstrap, DefaultLogger, LogLevel, JobQueueService, ConfigModule } = await import( path.join(root, 'node_modules/@vendure/core/dist/index') ); const { config } = await import(configFile); const assetsDir = path.join(__dirname, '../assets'); - + superAdminCredentials = config.authOptions.superadminCredentials; const initialDataPath = path.join(assetsDir, 'initial-data.json'); const vendureLogLevel = - logLevel === 'silent' + logLevel === 'info' || logLevel === 'silent' ? LogLevel.Error : logLevel === 'verbose' ? LogLevel.Verbose @@ -240,7 +321,6 @@ export async function createVendureApp( ...(config.apiOptions ?? {}), port, }, - silent: logLevel === 'silent', dbConnectionOptions: { ...config.dbConnectionOptions, synchronize: true, @@ -262,35 +342,116 @@ export async function createVendureApp( // Pause to ensure the worker jobs have time to complete. if (isCi) { - console.log('[CI] Pausing before close...'); + log('[CI] Pausing before close...'); } - await new Promise(resolve => setTimeout(resolve, isCi ? 30000 : 2000)); + await sleep(isCi ? 30000 : 2000); await app.close(); if (isCi) { - console.log('[CI] Pausing after close...'); - await new Promise(resolve => setTimeout(resolve, 10000)); + log('[CI] Pausing after close...'); + await sleep(10000); } - } catch (e) { - console.log(e); + populateSpinner.stop(`Server successfully initialized${populateProducts ? ' and populated' : ''}`); + clearTimeout(timer); + /** + * This is currently disabled because I am running into issues actually getting the server + * to quite properly in response to a SIGINT. + * This means that the server runs, but cannot be ended, without forcefully + * killing the process. + * + * Once this has been resolved, the following code can be re-enabled by + * setting `autoRunServer` to `true`. + */ + const autoRunServer = false; + if (mode === 'quick' && autoRunServer) { + // In quick-start mode, we want to now run the server and open up + // a browser window to the Admin UI. + try { + const adminUiUrl = `http://localhost:${port}/admin`; + const quickStartInstructions = [ + 'Use the following credentials to log in to the Admin UI:', + `Username: ${pc.green(config.authOptions.superadminCredentials?.identifier)}`, + `Password: ${pc.green(config.authOptions.superadminCredentials?.password)}`, + `Open your browser and navigate to: ${pc.green(adminUiUrl)}`, + '', + ]; + note(quickStartInstructions.join('\n')); + + const npmCommand = os.platform() === 'win32' ? 'npm.cmd' : 'npm'; + let quickStartProcess: ChildProcess | undefined; + try { + quickStartProcess = spawn(npmCommand, ['run', 'dev'], { + cwd: root, + stdio: 'inherit', + }); + } catch (e: any) { + /* empty */ + } + + // process.stdin.resume(); + process.on('SIGINT', function () { + displayOutro(root, name, superAdminCredentials); + quickStartProcess?.kill('SIGINT'); + process.exit(0); + }); + + // Give enough time for the server to get up and running + // before opening the window. + await sleep(10_000); + try { + await open(adminUiUrl, { + newInstance: true, + }); + } catch (e: any) { + /* empty */ + } + } catch (e: any) { + log(pc.red(`Failed to start the server: ${e.message as string}`), { + newline: 'after', + level: 'verbose', + }); + } + } else { + clearTimeout(timer); + displayOutro(root, name, superAdminCredentials); + process.exit(0); + } + } catch (e: any) { + log(e.toString()); outro(pc.red(`Failed to initialize server. Please try again.`)); process.exit(1); } - populateSpinner.stop(`Server successfully initialized${populateProducts ? ' and populated' : ''}`); +} - const startCommand = packageManager === 'yarn' ? 'yarn dev' : 'npm run dev'; +function displayOutro( + root: string, + name: string, + superAdminCredentials?: { identifier: string; password: string }, +) { + const startCommand = 'npm run dev'; const nextSteps = [ - `${pc.green('Success!')} Created a new Vendure server at:`, - `\n`, - pc.italic(root), - `\n`, - `We suggest that you start by typing:`, + `Your new Vendure server was created!`, + pc.gray(root), `\n`, + `Next, run:`, pc.gray('$ ') + pc.blue(pc.bold(`cd ${name}`)), pc.gray('$ ') + pc.blue(pc.bold(`${startCommand}`)), + `\n`, + `This will start the server in development mode.`, + `To access the Admin UI, open your browser and navigate to:`, + `\n`, + pc.green(`http://localhost:3000/admin`), + `\n`, + `Use the following credentials to log in:`, + `Username: ${pc.green(superAdminCredentials?.identifier ?? 'superadmin')}`, + `Password: ${pc.green(superAdminCredentials?.password ?? 'superadmin')}`, + '\n', + '➡️ Docs: https://docs.vendure.io', + '➡️ Discord community: https://vendure.io/community', + '➡️ Star us on GitHub:', + ' https://github.com/vendure-ecommerce/vendure', ]; - note(nextSteps.join('\n')); + note(nextSteps.join('\n'), pc.green('Setup complete!')); outro(`Happy hacking!`); - process.exit(0); } /** @@ -299,17 +460,21 @@ export async function createVendureApp( */ function runPreChecks(name: string | undefined, useNpm: boolean): name is string { if (typeof name === 'undefined') { - console.error('Please specify the project directory:'); - console.log(` ${pc.cyan(program.name())} ${pc.green('')}`); - console.log(); - console.log('For example:'); - console.log(` ${pc.cyan(program.name())} ${pc.green('my-vendure-app')}`); + log(pc.red(`Please specify the project directory:`)); + log(` ${pc.cyan(program.name())} ${pc.green('')}`, { newline: 'after' }); + log('For example:'); + log(` ${pc.cyan(program.name())} ${pc.green('my-vendure-app')}`); process.exit(1); return false; } const root = path.resolve(name); - fs.ensureDirSync(name); + try { + fs.ensureDirSync(name); + } catch (e: any) { + log(pc.red(`Could not create project directory ${name}: ${e.message as string}`)); + return false; + } if (!isSafeToCreateProjectIn(root, name)) { process.exit(1); } @@ -332,6 +497,6 @@ async function copyEmailTemplates(root: string) { try { await fs.copy(templateDir, path.join(root, 'static', 'email', 'templates')); } catch (err: any) { - console.error(pc.red('Failed to copy email templates.')); + log(pc.red('Failed to copy email templates.')); } } diff --git a/packages/create/src/gather-user-responses.ts b/packages/create/src/gather-user-responses.ts index ac6a1a14e5..970c6fc4af 100644 --- a/packages/create/src/gather-user-responses.ts +++ b/packages/create/src/gather-user-responses.ts @@ -1,10 +1,11 @@ -import { cancel, isCancel, select, text } from '@clack/prompts'; +import { select, text } from '@clack/prompts'; import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants'; import { randomBytes } from 'crypto'; import fs from 'fs-extra'; import Handlebars from 'handlebars'; import path from 'path'; +import { checkCancel, isDockerAvailable } from './helpers'; import { DbType, FileSources, PackageManager, UserResponses } from './types'; interface PromptAnswers { @@ -23,12 +24,72 @@ interface PromptAnswers { /* eslint-disable no-console */ +export async function getQuickStartConfiguration( + root: string, + packageManager: PackageManager, +): Promise { + // First we want to detect whether Docker is running + const { result: dockerStatus } = await isDockerAvailable(); + let usePostgres: boolean; + switch (dockerStatus) { + case 'running': + usePostgres = true; + break; + case 'not-found': + usePostgres = false; + break; + case 'not-running': { + let useSqlite = false; + let dockerIsNowRunning = false; + do { + const useSqliteResponse = await select({ + message: 'We could not automatically start Docker. How should we proceed?', + options: [ + { label: `Let's use SQLite as the database`, value: true }, + { label: 'I have manually started Docker', value: false }, + ], + initialValue: true, + }); + checkCancel(useSqlite); + useSqlite = useSqliteResponse as boolean; + if (useSqlite === false) { + const { result: dockerStatusManual } = await isDockerAvailable(); + dockerIsNowRunning = dockerStatusManual === 'running'; + } + } while (dockerIsNowRunning !== true && useSqlite === false); + usePostgres = !useSqlite; + break; + } + } + const quickStartAnswers: PromptAnswers = { + dbType: usePostgres ? 'postgres' : 'sqlite', + dbHost: usePostgres ? 'localhost' : '', + dbPort: usePostgres ? '6543' : '', + dbName: usePostgres ? 'vendure' : '', + dbUserName: usePostgres ? 'vendure' : '', + dbPassword: usePostgres ? randomBytes(16).toString('base64url') : '', + dbSchema: usePostgres ? 'public' : '', + populateProducts: true, + superadminIdentifier: SUPER_ADMIN_USER_IDENTIFIER, + superadminPassword: SUPER_ADMIN_USER_PASSWORD, + }; + + const responses = { + ...(await generateSources(root, quickStartAnswers, packageManager)), + dbType: quickStartAnswers.dbType, + populateProducts: quickStartAnswers.populateProducts as boolean, + superadminIdentifier: quickStartAnswers.superadminIdentifier as string, + superadminPassword: quickStartAnswers.superadminPassword as string, + }; + + return responses; +} + /** * Prompts the user to determine how the new Vendure app should be configured. */ -export async function gatherUserResponses( +export async function getManualConfiguration( root: string, - alreadyRanScaffold: boolean, packageManager: PackageManager, ): Promise { const dbType = (await select({ @@ -38,13 +99,12 @@ export async function gatherUserResponses( { label: 'MariaDB', value: 'mariadb' }, { label: 'Postgres', value: 'postgres' }, { label: 'SQLite', value: 'sqlite' }, - { label: 'SQL.js', value: 'sqljs' }, ], initialValue: 'sqlite' as DbType, })) as DbType; checkCancel(dbType); - const hasConnection = dbType !== 'sqlite' && dbType !== 'sqljs'; + const hasConnection = dbType !== 'sqlite'; const dbHost = hasConnection ? await text({ message: "What's the database host address?", @@ -146,7 +206,7 @@ export async function gatherUserResponses( /** * Returns mock "user response" without prompting, for use in CI */ -export async function gatherCiUserResponses( +export async function getCiConfiguration( root: string, packageManager: PackageManager, ): Promise { @@ -171,14 +231,6 @@ export async function gatherCiUserResponses( }; } -export function checkCancel(value: T | symbol): value is T { - if (isCancel(value)) { - cancel('Setup cancelled.'); - process.exit(0); - } - return true; -} - /** * Create the server index, worker and config source code based on the options specified by the CLI prompts. */ @@ -200,12 +252,10 @@ async function generateSources( const templateContext = { ...answers, - useYarn: packageManager === 'yarn', dbType: answers.dbType === 'sqlite' ? 'better-sqlite3' : answers.dbType, name: path.basename(root), isSQLite: answers.dbType === 'sqlite', - isSQLjs: answers.dbType === 'sqljs', - requiresConnection: answers.dbType !== 'sqlite' && answers.dbType !== 'sqljs', + requiresConnection: answers.dbType !== 'sqlite', cookieSecret: randomBytes(16).toString('base64url'), }; @@ -233,10 +283,6 @@ function defaultDBPort(dbType: DbType): number { return 3306; case 'postgres': return 5432; - case 'mssql': - return 1433; - case 'oracle': - return 1521; default: return 3306; } diff --git a/packages/create/src/helpers.ts b/packages/create/src/helpers.ts index 6a225add77..74956db2f5 100644 --- a/packages/create/src/helpers.ts +++ b/packages/create/src/helpers.ts @@ -1,12 +1,15 @@ -/* eslint-disable no-console */ -import { execSync } from 'child_process'; +import { cancel, isCancel, spinner } from '@clack/prompts'; import spawn from 'cross-spawn'; import fs from 'fs-extra'; +import { execFile, execSync, execFileSync } from 'node:child_process'; +import { platform } from 'node:os'; +import { promisify } from 'node:util'; import path from 'path'; import pc from 'picocolors'; import semver from 'semver'; -import { SERVER_PORT, TYPESCRIPT_VERSION } from './constants'; +import { TYPESCRIPT_VERSION } from './constants'; +import { log } from './logger'; import { CliLogLevel, DbType } from './types'; /** @@ -46,7 +49,6 @@ export function isSafeToCreateProjectIn(root: string, name: string) { 'tsconfig.json', 'yarn.lock', ]; - console.log(); const conflicts = fs .readdirSync(root) @@ -57,13 +59,13 @@ export function isSafeToCreateProjectIn(root: string, name: string) { .filter(file => !errorLogFilePatterns.some(pattern => file.indexOf(pattern) === 0)); if (conflicts.length > 0) { - console.log(`The directory ${pc.green(name)} contains files that could conflict:`); - console.log(); + log(`The directory ${pc.green(name)} contains files that could conflict:`, { newline: 'after' }); for (const file of conflicts) { - console.log(` ${file}`); + log(` ${file}`); } - console.log(); - console.log('Either try using a new directory name, or remove the files listed above.'); + log('Either try using a new directory name, or remove the files listed above.', { + newline: 'before', + }); return false; } @@ -89,38 +91,23 @@ export function scaffoldAlreadyExists(root: string, name: string): boolean { export function checkNodeVersion(requiredVersion: string) { if (!semver.satisfies(process.version, requiredVersion)) { - console.error( + log( pc.red( - 'You are running Node %s.\n' + - 'Vendure requires Node %s or higher. \n' + + `You are running Node ${process.version}.` + + `Vendure requires Node ${requiredVersion} or higher.` + 'Please update your version of Node.', ), - process.version, - requiredVersion, ); process.exit(1); } } -export function yarnIsAvailable() { - try { - const yarnVersion = execSync('yarnpkg --version'); - if (semver.major(yarnVersion.toString()) > 1) { - return true; - } else { - return false; - } - } catch (e: any) { - return false; - } -} - // Bun support should not be exposed yet, see // https://github.com/oven-sh/bun/issues/4947 // https://github.com/lovell/sharp/issues/3511 export function bunIsAvailable() { try { - execSync('bun --version', { stdio: 'ignore' }); + execFileSync('bun', ['--version'], { stdio: 'ignore' }); return true; } catch (e: any) { return false; @@ -160,7 +147,7 @@ export function checkThatNpmCanReadCwd() { if (npmCWD === cwd) { return true; } - console.error( + log( pc.red( 'Could not start an npm process in the right directory.\n\n' + `The current directory is: ${pc.bold(cwd)}\n` + @@ -169,7 +156,7 @@ export function checkThatNpmCanReadCwd() { ), ); if (process.platform === 'win32') { - console.error( + log( pc.red('On Windows, this can usually be fixed by running:\n\n') + ` ${pc.cyan('reg')} delete "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /f\n` + ` ${pc.cyan( @@ -185,61 +172,32 @@ export function checkThatNpmCanReadCwd() { } /** - * Install packages via npm or yarn. + * Install packages via npm. * Based on the install function from https://github.com/facebook/create-react-app */ -export function installPackages( - root: string, - useYarn: boolean, - dependencies: string[], - isDev: boolean, - logLevel: CliLogLevel, - isCi: boolean = false, -): Promise { +export function installPackages(options: { + dependencies: string[]; + isDevDependencies?: boolean; + logLevel: CliLogLevel; +}): Promise { + const { dependencies, isDevDependencies = false, logLevel } = options; return new Promise((resolve, reject) => { - let command: string; - let args: string[]; - if (useYarn) { - command = 'yarnpkg'; - args = ['add', '--exact', '--ignore-engines']; - if (isDev) { - args.push('--dev'); - } - if (isCi) { - // In CI, publish to Verdaccio - // See https://github.com/yarnpkg/yarn/issues/6029 - args.push('--registry http://localhost:4873/'); - // Increase network timeout - // See https://github.com/yarnpkg/yarn/issues/4890#issuecomment-358179301 - args.push('--network-timeout 300000'); - } - args = args.concat(dependencies); - - // Explicitly set cwd() to work around issues like - // https://github.com/facebook/create-react-app/issues/3326. - // Unfortunately we can only do this for Yarn because npm support for - // equivalent --prefix flag doesn't help with this issue. - // This is why for npm, we run checkThatNpmCanReadCwd() early instead. - args.push('--cwd'); - args.push(root); - } else { - command = 'npm'; - args = ['install', '--save', '--save-exact', '--loglevel', 'error'].concat(dependencies); - if (isDev) { - args.push('--save-dev'); - } + const command = 'npm'; + const args = ['install', '--save', '--save-exact', '--loglevel', 'error'].concat(dependencies); + if (isDevDependencies) { + args.push('--save-dev'); } if (logLevel === 'verbose') { args.push('--verbose'); } - const child = spawn(command, args, { stdio: logLevel === 'silent' ? 'ignore' : 'inherit' }); + const child = spawn(command, args, { stdio: logLevel === 'verbose' ? 'inherit' : 'ignore' }); child.on('close', code => { if (code !== 0) { let message = 'An error occurred when installing dependencies.'; if (logLevel === 'silent') { - message += ' Try running with `--log-level info` or `--log-level verbose` to diagnose.'; + message += ' Try running with `--log-level verbose` to diagnose.'; } reject({ message, @@ -285,15 +243,9 @@ function dbDriverPackage(dbType: DbType): string { return 'pg'; case 'sqlite': return 'better-sqlite3'; - case 'sqljs': - return 'sql.js'; - case 'mssql': - return 'mssql'; - case 'oracle': - return 'oracledb'; default: const n: never = dbType; - console.error(pc.red(`No driver package configured for type "${dbType as string}"`)); + log(pc.red(`No driver package configured for type "${dbType as string}"`)); return ''; } } @@ -383,6 +335,133 @@ async function checkPostgresDbExists(options: any, root: string): Promise return true; } +/** + * Check to see if Docker is installed and running. + * If not, attempt to start it. + * If that is not possible, return false. + * + * Refs: + * - https://stackoverflow.com/a/48843074/772859 + */ +export async function isDockerAvailable(): Promise<{ result: 'not-found' | 'not-running' | 'running' }> { + const dockerSpinner = spinner(); + + function isDaemonRunning(): boolean { + try { + execFileSync('docker', ['stats', '--no-stream'], { stdio: 'ignore' }); + return true; + } catch (e: any) { + return false; + } + } + + dockerSpinner.start('Checking for Docker'); + try { + execFileSync('docker', ['-v'], { stdio: 'ignore' }); + dockerSpinner.message('Docker was found!'); + } catch (e: any) { + dockerSpinner.stop('Docker was not found on this machine. We will use SQLite for the database.'); + return { result: 'not-found' }; + } + // Now we need to check if the docker daemon is running + const isRunning = isDaemonRunning(); + if (isRunning) { + dockerSpinner.stop('Docker is running'); + return { result: 'running' }; + } + dockerSpinner.message('Docker daemon is not running. Attempting to start'); + // detect the current OS + const currentPlatform = platform(); + try { + if (currentPlatform === 'win32') { + // https://stackoverflow.com/a/44182489/772859 + execSync('"C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe"', { stdio: 'ignore' }); + } else if (currentPlatform === 'darwin') { + execSync('open -a Docker', { stdio: 'ignore' }); + } else { + execSync('systemctl start docker', { stdio: 'ignore' }); + } + } catch (e: any) { + dockerSpinner.stop('Could not start Docker.'); + log(e.message, { level: 'verbose' }); + return { result: 'not-running' }; + } + // Verify that the daemon is now running + let attempts = 1; + do { + log(`Checking for Docker daemon... (attempt ${attempts})`, { level: 'verbose' }); + if (isDaemonRunning()) { + log(`Docker daemon is now running (after ${attempts} attempts).`, { level: 'verbose' }); + dockerSpinner.stop('Docker is running'); + return { result: 'running' }; + } + await new Promise(resolve => setTimeout(resolve, 50)); + attempts++; + } while (attempts < 100); + dockerSpinner.stop('Docker daemon could not be started'); + return { result: 'not-running' }; +} + +export async function startPostgresDatabase(root: string): Promise { + // Now we need to run the postgres database via Docker + let containerName: string | undefined; + const postgresContainerSpinner = spinner(); + postgresContainerSpinner.start('Starting PostgreSQL database'); + try { + const result = await promisify(execFile)(`docker`, [ + `compose`, + `-f`, + path.join(root, 'docker-compose.yml'), + `up`, + `-d`, + `postgres_db`, + ]); + containerName = result.stderr.match(/Container\s+(.+-postgres_db[^ ]*)/)?.[1]; + if (!containerName) { + // guess the container name based on the directory name + containerName = path.basename(root).replace(/[^a-z0-9]/gi, '') + '-postgres_db-1'; + postgresContainerSpinner.message( + 'Could not find container name. Guessing it is: ' + containerName, + ); + log(pc.red('Could not find container name. Guessing it is: ' + containerName), { + newline: 'before', + level: 'verbose', + }); + } else { + log(pc.green(`Started PostgreSQL database in container "${containerName}"`), { + newline: 'before', + level: 'verbose', + }); + } + } catch (e: any) { + log(pc.red(`Failed to start PostgreSQL database: ${e.message as string}`)); + postgresContainerSpinner.stop('Failed to start PostgreSQL database'); + return false; + } + postgresContainerSpinner.message(`Waiting for PostgreSQL database to be ready...`); + let attempts = 1; + let isReady = false; + do { + // We now need to ensure that the database is ready to accept connections + try { + const result = execFileSync(`docker`, [`exec`, `-i`, containerName, `pg_isready`]); + isReady = result?.toString().includes('accepting connections'); + if (!isReady) { + log(pc.yellow(`PostgreSQL database not yet ready. Attempt ${attempts}...`), { + level: 'verbose', + }); + } + } catch (e: any) { + // ignore + log('is_ready error:' + (e.message as string), { level: 'verbose', newline: 'before' }); + } + await new Promise(resolve => setTimeout(resolve, 50)); + attempts++; + } while (!isReady && attempts < 100); + postgresContainerSpinner.stop('PostgreSQL database is ready'); + return true; +} + function throwConnectionError(err: any) { throw new Error( 'Could not connect to the database. ' + @@ -420,7 +499,35 @@ export function isServerPortInUse(port: number): Promise { try { return tcpPortUsed.check(port); } catch (e: any) { - console.log(pc.yellow(`Warning: could not determine whether port ${port} is available`)); + log(pc.yellow(`Warning: could not determine whether port ${port} is available`)); return Promise.resolve(false); } } + +/** + * Checks if the response from a Clack prompt was a cancellation symbol, and if so, + * ends the interactive process. + */ +export function checkCancel(value: T | symbol): value is T { + if (isCancel(value)) { + cancel('Setup cancelled.'); + process.exit(0); + } + return true; +} + +export function cleanUpDockerResources(name: string) { + try { + execSync(`docker stop $(docker ps -a -q --filter "label=io.vendure.create.name=${name}")`, { + stdio: 'ignore', + }); + execSync(`docker rm $(docker ps -a -q --filter "label=io.vendure.create.name=${name}")`, { + stdio: 'ignore', + }); + execSync(`docker volume rm $(docker volume ls --filter "label=io.vendure.create.name=${name}" -q)`, { + stdio: 'ignore', + }); + } catch (e) { + log(pc.yellow(`Could not clean up Docker resources`), { level: 'verbose' }); + } +} diff --git a/packages/create/src/logger.ts b/packages/create/src/logger.ts new file mode 100644 index 0000000000..00cdab78f5 --- /dev/null +++ b/packages/create/src/logger.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-console */ +import { CliLogLevel } from './types'; + +let logLevel: CliLogLevel = 'info'; + +export function setLogLevel(level: CliLogLevel = 'info') { + logLevel = level; +} + +export function log( + message?: string, + options?: { level?: CliLogLevel; newline?: 'before' | 'after' | 'both' }, +) { + const { level = 'info' } = options || {}; + if (logLevel !== 'silent' && (logLevel === 'verbose' || level === 'info')) { + if (options?.newline === 'before' || options?.newline === 'both') { + console.log(); + } + console.log(' ' + (message ?? '')); + if (options?.newline === 'after' || options?.newline === 'both') { + console.log(); + } + } +} diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index 03c412285d..2677f641d1 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -1,4 +1,4 @@ -export type DbType = 'mysql' | 'mariadb' | 'postgres' | 'sqlite' | 'sqljs' | 'mssql' | 'oracle'; +export type DbType = 'mysql' | 'mariadb' | 'postgres' | 'sqlite'; export interface FileSources { indexSource: string; @@ -18,6 +18,6 @@ export interface UserResponses extends FileSources { superadminPassword: string; } -export type PackageManager = 'npm' | 'yarn'; +export type PackageManager = 'npm'; export type CliLogLevel = 'silent' | 'info' | 'verbose'; diff --git a/packages/create/templates/Dockerfile.hbs b/packages/create/templates/Dockerfile.hbs index acb0bac939..370a91e35d 100644 --- a/packages/create/templates/Dockerfile.hbs +++ b/packages/create/templates/Dockerfile.hbs @@ -3,7 +3,7 @@ FROM node:20 WORKDIR /usr/src/app COPY package.json ./ -COPY {{#if useYarn}}yarn.lock{{else}}package-lock.json{{/if}} ./ -RUN {{#if useYarn}}yarn{{else}}npm install{{/if}} --production +COPY package-lock.json ./ +RUN npm install --production COPY . . -RUN {{#if useYarn}}yarn{{else}}npm run{{/if}} build +RUN npm run build diff --git a/packages/create/templates/docker-compose.hbs b/packages/create/templates/docker-compose.hbs index 0deb1e1e5c..6db7161f17 100644 --- a/packages/create/templates/docker-compose.hbs +++ b/packages/create/templates/docker-compose.hbs @@ -1,39 +1,115 @@ -version: "3" +# INFORMATION +# We are not exposing the default ports for the services in this file. +# This is to avoid conflicts with existing services on your machine. +# In case you don't have any services running on the default ports, you can expose them by changing the +# ports section in the services block. Please don't forget to update the ports in the .env file as well. + services: - server: - build: - context: . - dockerfile: Dockerfile - ports: - - 3000:3000 - command: [{{#if useYarn}}"yarn"{{else}}"npm", "run"{{/if}}, "start:server"] - volumes: - - /usr/src/app - environment: - DB_HOST: database - DB_PORT: 5432 - DB_NAME: vendure - DB_USERNAME: postgres - DB_PASSWORD: password - worker: - build: - context: . - dockerfile: Dockerfile - command: [{{#if useYarn}}"yarn"{{else}}"npm", "run"{{/if}}, "start:worker"] - volumes: - - /usr/src/app - environment: - DB_HOST: database - DB_PORT: 5432 - DB_NAME: vendure - DB_USERNAME: postgres - DB_PASSWORD: password - database: - image: postgres - volumes: - - /var/lib/postgresql/data - ports: - - 5432:5432 - environment: - POSTGRES_PASSWORD: password - POSTGRES_DB: vendure + postgres_db: + image: postgres:16-alpine + volumes: + - postgres_db_data:/var/lib/postgresql/data + ports: + - "6543:5432" + environment: + POSTGRES_DB: {{{ escapeSingle dbName }}} + POSTGRES_USER: {{{ escapeSingle dbUserName }}} + POSTGRES_PASSWORD: {{{ escapeSingle dbPassword }}} + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + + mysql_db: + image: mysql:8 + volumes: + - mysql_db_data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: 'ROOT' + MYSQL_DATABASE: {{{ escapeSingle dbName }}} + MYSQL_USER: {{{ escapeSingle dbUserName }}} + MYSQL_PASSWORD: {{{ escapeSingle dbPassword }}} + ports: + - "4306:3306" + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + + mariadb_db: + image: mariadb:10 + volumes: + - mariadb_db_data:/var/lib/mysql + environment: + MARIADB_ROOT_PASSWORD: 'ROOT' + MARIADB_DATABASE: {{{ escapeSingle dbName }}} + MARIADB_USER: {{{ escapeSingle dbUserName }}} + MARIADB_PASSWORD: {{{ escapeSingle dbPassword }}} + ports: + - "3306:3306" + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + + # RECOMMENDED (especially for production) + # Want to use our BullMQ with Redis instead of our default database job queue? + # Checkout our BullMQ plugin: https://docs.vendure.io/reference/core-plugins/job-queue-plugin/bull-mqjob-queue-plugin/ + redis: + image: redis:7-alpine + ports: + - "6479:6379" + volumes: + - redis_data:/data + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + + # RECOMMENDED + # Want to use Typesense instead of our default search engine? + # Checkout our advanced search plugin: https://vendure.io/hub/vendure-plus-advanced-search-plugin + # To run the typesense container run "docker compose up -d typesense" + typesense: + image: typesense/typesense:27 + command: [ '--data-dir', '/data', '--api-key', 'SuperSecret' ] + ports: + - "8208:8108" + volumes: + - typesense_data:/data + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + + # Want to use Elasticsearch instead of our default database engine? + # Checkout our Elasticsearch plugin: https://docs.vendure.io/reference/core-plugins/elasticsearch-plugin/ + # To run the elasticsearch container run "docker compose up -d elasticsearch" + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:7.1.1 + environment: + discovery.type: single-node + bootstrap.memory_lock: true + ES_JAVA_OPTS: -Xms512m -Xmx512m + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + ports: + - "9300:9200" + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + +volumes: + postgres_db_data: + driver: local + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + mysql_db_data: + driver: local + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + mariadb_db_data: + driver: local + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + typesense_data: + driver: local + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + elasticsearch_data: + driver: local + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" + redis_data: + driver: local + labels: + - "io.vendure.create.name={{{ escapeSingle name }}}" diff --git a/packages/create/templates/readme.hbs b/packages/create/templates/readme.hbs index d1e9f601cd..08318f3281 100644 --- a/packages/create/templates/readme.hbs +++ b/packages/create/templates/readme.hbs @@ -17,7 +17,7 @@ Useful links: ## Development ``` -{{#if useYarn}}yarn dev{{else}}npm run dev{{/if}} +npm run dev ``` will start the Vendure server and [worker](https://www.vendure.io/docs/developer-guide/vendure-worker/) processes from @@ -26,7 +26,7 @@ the `src` directory. ## Build ``` -{{#if useYarn}}yarn build{{else}}npm run build{{/if}} +npm run build ``` will compile the TypeScript sources into the `/dist` directory. @@ -41,7 +41,7 @@ hosting environment. You can run the built files directly with the `start` script: ``` -{{#if useYarn}}yarn start{{else}}npm run start{{/if}} +npm run start ``` You could also consider using a process manager like [pm2](https://pm2.keymetrics.io/) to run and manage @@ -92,7 +92,7 @@ These should be located in the `./src/plugins` directory. To create a new plugin run: ``` -{{#if useYarn}}yarn{{else}}npx{{/if}} vendure add +npx vendure add ``` and select `[Plugin] Create a new Vendure plugin`. @@ -105,7 +105,7 @@ will be required whenever you make changes to the `customFields` config or defin To generate a new migration, run: ``` -{{#if useYarn}}yarn{{else}}npx{{/if}} vendure migrate +npx vendure migrate ``` The generated migration file will be found in the `./src/migrations/` directory, and should be committed to source control.