diff --git a/.changeset/polite-queens-kiss.md b/.changeset/polite-queens-kiss.md new file mode 100644 index 0000000000..82cda10aa1 --- /dev/null +++ b/.changeset/polite-queens-kiss.md @@ -0,0 +1,5 @@ +--- +"create-medusa-app": patch +--- + +chore(create-medusa-app): Cleanup the main script for readability and maintanability diff --git a/packages/create-medusa-app/package.json b/packages/create-medusa-app/package.json index 98b9a0b780..03ae65985b 100644 --- a/packages/create-medusa-app/package.json +++ b/packages/create-medusa-app/package.json @@ -28,7 +28,8 @@ "slugify": "^1.6.6", "uuid": "^9.0.0", "validator": "^13.9.0", - "wait-on": "^7.0.1" + "wait-on": "^7.0.1", + "winston": "^3.9.0" }, "devDependencies": { "@types/chalk": "^2.2.0", diff --git a/packages/create-medusa-app/src/commands/create.ts b/packages/create-medusa-app/src/commands/create.ts index 96c733b775..6a658d0ebe 100644 --- a/packages/create-medusa-app/src/commands/create.ts +++ b/packages/create-medusa-app/src/commands/create.ts @@ -1,33 +1,31 @@ import inquirer from "inquirer" import slugifyType from "slugify" import chalk from "chalk" -import pg from "pg" -import createDb from "../utils/create-db.js" -import postgresClient from "../utils/postgres-client.js" -import cloneRepo from "../utils/clone-repo.js" +import { getDbClientAndCredentials, runCreateDb } from "../utils/create-db.js" import prepareProject from "../utils/prepare-project.js" import startMedusa from "../utils/start-medusa.js" import open from "open" import waitOn from "wait-on" -import formatConnectionString from "../utils/format-connection-string.js" -import ora from "ora" +import ora, { Ora } from "ora" import fs from "fs" -import { nanoid } from "nanoid" import isEmailImported from "validator/lib/isEmail.js" import logMessage from "../utils/log-message.js" import createAbortController, { isAbortError, } from "../utils/create-abort-controller.js" import { track } from "medusa-telemetry" -import { createFactBox, resetFactBox } from "../utils/facts.js" import boxen from "boxen" import { emojify } from "node-emoji" import ProcessManager from "../utils/process-manager.js" +import { nanoid } from "nanoid" +import { displayFactBox, FactBoxOptions } from "../utils/facts.js" +import { EOL } from "os" +import { runCloneRepo } from "../utils/clone-repo.js" const slugify = slugifyType.default const isEmail = isEmailImported.default -type CreateOptions = { +export type CreateOptions = { repoUrl?: string seed?: boolean // commander passed --no-boilerplate as boilerplate @@ -42,90 +40,53 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => { if (seed) { track("SEED_SELECTED", { seed }) } + + const spinner: Ora = ora() const processManager = new ProcessManager() const abortController = createAbortController(processManager) + const factBoxOptions: FactBoxOptions = { + interval: null, + spinner, + processManager, + message: "", + title: "", + } + const dbName = `medusa-${nanoid(4)}` + let isProjectCreated = false + let printedMessage = false - const { projectName } = await inquirer.prompt([ - { - type: "input", - name: "projectName", - message: "What's the name of your project?", - default: "my-medusa-store", - filter: (input) => { - return slugify(input) - }, - validate: (input) => { - if (!input.length) { - return "Please enter a project name" - } - return fs.existsSync(input) && fs.lstatSync(input).isDirectory() - ? "A directory already exists with the same name. Please enter a different project name." - : true - }, - }, - ]) + processManager.onTerminated(async () => { + spinner.stop() + if (client) { + await client.end() + } - let client: pg.Client | undefined - let dbConnectionString = "" - let postgresUsername = "postgres" - let postgresPassword = "" - - // try to log in with default db username and password - try { - client = await postgresClient({ - user: postgresUsername, - password: postgresPassword, - }) - } catch (e) { - // ask for the user's postgres credentials - const answers = await inquirer.prompt([ - { - type: "input", - name: "postgresUsername", - message: "Enter your Postgres username", - default: "postgres", - validate: (input) => { - return typeof input === "string" && input.length > 0 - }, - }, - { - type: "password", - name: "postgresPassword", - message: "Enter your Postgres password", - }, - ]) - - postgresUsername = answers.postgresUsername - postgresPassword = answers.postgresPassword - - try { - client = await postgresClient({ - user: postgresUsername, - password: postgresPassword, - }) - } catch (e) { + // the SIGINT event is triggered twice once the backend runs + // this ensures that the message isn't printed twice to the user + if (!printedMessage && isProjectCreated) { + printedMessage = true logMessage({ - message: - "Couldn't connect to PostgreSQL. Make sure you have PostgreSQL installed and the credentials you provided are correct.\n\n" + - "You can learn how to install PostgreSQL here: https://docs.medusajs.com/development/backend/prepare-environment#postgresql", - type: "error", + message: boxen( + chalk.green( + `Change to the \`${projectName}\` directory to explore your Medusa project.${EOL}${EOL}Start your Medusa app again with the following command:${EOL}${EOL}npx @medusajs/medusa-cli develop${EOL}${EOL}Check out the Medusa documentation to start your development:${EOL}${EOL}https://docs.medusajs.com/${EOL}${EOL}Star us on GitHub if you like what we're building:${EOL}${EOL}https://github.com/medusajs/medusa/stargazers` + ), + { + titleAlignment: "center", + textAlignment: "center", + padding: 1, + margin: 1, + float: "center", + } + ), }) } - } - const { adminEmail } = await inquirer.prompt([ - { - type: "input", - name: "adminEmail", - message: "Enter an email for your admin dashboard user", - default: !seed && boilerplate ? "admin@medusa-test.com" : undefined, - validate: (input) => { - return typeof input === "string" && input.length > 0 && isEmail(input) - ? true - : "Please enter a valid email" - }, - }, - ]) + return + }) + + const projectName = await askForProjectName() + const { client, dbConnectionString } = await getDbClientAndCredentials(dbName) + const adminEmail = await askForAdminEmail(seed, boilerplate) logMessage({ message: `${emojify( @@ -133,75 +94,37 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => { )} Starting project setup, this may take a few minutes.`, }) - const spinner = ora().start() + spinner.start() - processManager.onTerminated(() => spinner.stop()) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + title: "Setting up project...", + }) - let interval: NodeJS.Timer | null = createFactBox( - spinner, - "Setting up project...", - processManager - ) - - // clone repository try { - await cloneRepo({ - directoryName: projectName, + await runCloneRepo({ + projectName, repoUrl, abortController, - }) - } catch (e) { - if (isAbortError(e)) { - process.exit() - } - - spinner.stop() - logMessage({ - message: `An error occurred while setting up your project: ${e}`, - type: "error", - }) - } - - interval = resetFactBox( - interval, - spinner, - "Created project directory", - processManager, - "Creating database..." - ) - - if (client) { - const dbName = `medusa-${nanoid(4)}` - // create postgres database - try { - await createDb({ - client, - db: dbName, - }) - } catch (e) { - spinner.stop() - logMessage({ - message: `An error occurred while trying to create your database: ${e}`, - type: "error", - }) - } - - // format connection string - dbConnectionString = formatConnectionString({ - user: postgresUsername, - password: postgresPassword, - host: client.host, - db: dbName, - }) - - resetFactBox( - interval, spinner, - `Database ${dbName} created`, - processManager - ) + }) + } catch { + return } + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + message: "Created project directory", + title: "Creating database...", + }) + + await runCreateDb({ client, dbName, spinner }) + + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + message: `Database ${dbName} created`, + }) + // prepare project let inviteToken: string | undefined = undefined try { @@ -227,13 +150,15 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => { message: `An error occurred while preparing project: ${e}`, type: "error", }) + + return + } finally { + // close db connection + await client?.end() } spinner.succeed(chalk.green("Project Prepared")) - // close db connection - await client?.end() - // start backend logMessage({ message: "Starting Medusa...", @@ -253,31 +178,11 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => { message: `An error occurred while starting Medusa`, type: "error", }) + + return } - // the SIGINT event is triggered twice once the backend runs - // this ensures that the message isn't printed twice to the user - let printedMessage = false - - processManager.onTerminated(() => { - if (!printedMessage) { - printedMessage = true - console.log( - boxen( - chalk.green( - `Change to the \`${projectName}\` directory to explore your Medusa project.\n\nStart your Medusa app again with the following command:\n\nnpx @medusajs/medusa-cli develop\n\nCheck out the Medusa documentation to start your development:\n\nhttps://docs.medusajs.com/\n\nStar us on GitHub if you like what we're building:\n\nhttps://github.com/medusajs/medusa/stargazers` - ), - { - titleAlignment: "center", - textAlignment: "center", - padding: 1, - margin: 1, - float: "center", - } - ) - ) - } - }) + isProjectCreated = true await waitOn({ resources: ["http://localhost:9000/health"], @@ -289,3 +194,47 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => { ) ) } + +async function askForProjectName(): Promise { + const { projectName } = await inquirer.prompt([ + { + type: "input", + name: "projectName", + message: "What's the name of your project?", + default: "my-medusa-store", + filter: (input) => { + return slugify(input) + }, + validate: (input) => { + if (!input.length) { + return "Please enter a project name" + } + return fs.existsSync(input) && fs.lstatSync(input).isDirectory() + ? "A directory already exists with the same name. Please enter a different project name." + : true + }, + }, + ]) + return projectName +} + +async function askForAdminEmail( + seed?: boolean, + boilerplate?: boolean +): Promise { + const { adminEmail } = await inquirer.prompt([ + { + type: "input", + name: "adminEmail", + message: "Enter an email for your admin dashboard user", + default: !seed && boilerplate ? "admin@medusa-test.com" : undefined, + validate: (input) => { + return typeof input === "string" && input.length > 0 && isEmail(input) + ? true + : "Please enter a valid email" + }, + }, + ]) + + return adminEmail +} diff --git a/packages/create-medusa-app/src/utils/clone-repo.ts b/packages/create-medusa-app/src/utils/clone-repo.ts index e099c91684..ef4f1fec15 100644 --- a/packages/create-medusa-app/src/utils/clone-repo.ts +++ b/packages/create-medusa-app/src/utils/clone-repo.ts @@ -1,4 +1,7 @@ import promiseExec from "./promise-exec.js" +import { Ora } from "ora" +import { isAbortError } from "./create-abort-controller.js" +import logMessage from "./log-message.js" type CloneRepoOptions = { directoryName?: string @@ -9,12 +12,42 @@ type CloneRepoOptions = { const DEFAULT_REPO = "https://github.com/medusajs/medusa-starter-default -b feat/onboarding" -export default async ({ +export default async function cloneRepo({ directoryName = "", repoUrl, abortController, -}: CloneRepoOptions) => { +}: CloneRepoOptions) { await promiseExec(`git clone ${repoUrl || DEFAULT_REPO} ${directoryName}`, { signal: abortController?.signal, }) } + +export async function runCloneRepo({ + projectName, + repoUrl, + abortController, + spinner, +}: { + projectName: string + repoUrl: string + abortController: AbortController + spinner: Ora +}) { + try { + await cloneRepo({ + directoryName: projectName, + repoUrl, + abortController, + }) + } catch (e) { + if (isAbortError(e)) { + process.exit() + } + + spinner.stop() + logMessage({ + message: `An error occurred while setting up your project: ${e}`, + type: "error", + }) + } +} diff --git a/packages/create-medusa-app/src/utils/create-db.ts b/packages/create-medusa-app/src/utils/create-db.ts index d4abac337e..d0f06f17c0 100644 --- a/packages/create-medusa-app/src/utils/create-db.ts +++ b/packages/create-medusa-app/src/utils/create-db.ts @@ -1,10 +1,103 @@ import pg from "pg" +import postgresClient from "./postgres-client.js" +import inquirer from "inquirer" +import logMessage from "./log-message.js" +import formatConnectionString from "./format-connection-string.js" +import { Ora } from "ora" type CreateDbOptions = { client: pg.Client db: string } -export default async ({ client, db }: CreateDbOptions) => { +export default async function createDb({ client, db }: CreateDbOptions) { await client.query(`CREATE DATABASE "${db}"`) } + +export async function runCreateDb({ + client, + dbName, + spinner, +}: { + client: pg.Client + dbName: string + spinner: Ora +}) { + // create postgres database + try { + await createDb({ + client, + db: dbName, + }) + } catch (e) { + spinner.stop() + logMessage({ + message: `An error occurred while trying to create your database: ${e}`, + type: "error", + }) + } +} + +export async function getDbClientAndCredentials(dbName: string): Promise<{ + client: pg.Client + dbConnectionString: string +}> { + let client!: pg.Client + let postgresUsername = "postgres" + let postgresPassword = "" + + try { + client = await postgresClient({ + user: postgresUsername, + password: postgresPassword, + }) + } catch (e) { + // ask for the user's postgres credentials + const answers = await inquirer.prompt([ + { + type: "input", + name: "postgresUsername", + message: "Enter your Postgres username", + default: "postgres", + validate: (input) => { + return typeof input === "string" && input.length > 0 + }, + }, + { + type: "password", + name: "postgresPassword", + message: "Enter your Postgres password", + }, + ]) + + postgresUsername = answers.postgresUsername + postgresPassword = answers.postgresPassword + + try { + client = await postgresClient({ + user: postgresUsername, + password: postgresPassword, + }) + } catch (e) { + logMessage({ + message: + "Couldn't connect to PostgreSQL. Make sure you have PostgreSQL installed and the credentials you provided are correct.${EOL}${EOL}" + + "You can learn how to install PostgreSQL here: https://docs.medusajs.com/development/backend/prepare-environment#postgresql", + type: "error", + }) + } + } + + // format connection string + const dbConnectionString = formatConnectionString({ + user: postgresUsername, + password: postgresPassword, + host: client!.host, + db: dbName, + }) + + return { + client, + dbConnectionString, + } +} diff --git a/packages/create-medusa-app/src/utils/facts.ts b/packages/create-medusa-app/src/utils/facts.ts index 2f33555127..0df8593810 100644 --- a/packages/create-medusa-app/src/utils/facts.ts +++ b/packages/create-medusa-app/src/utils/facts.ts @@ -4,6 +4,14 @@ import { Ora } from "ora" import { emojify } from "node-emoji" import ProcessManager from "./process-manager.js" +export type FactBoxOptions = { + interval: NodeJS.Timer | null + spinner: Ora + processManager: ProcessManager + message?: string + title?: string +} + const facts = [ "Plugins allow you to integrate third-party services for payment, fulfillment, notifications, and more.", "You can specify a product's availability in one or more sales channels.", @@ -79,3 +87,17 @@ export const resetFactBox = ( return newInterval } + +export function displayFactBox({ + interval, + spinner, + processManager, + title = "", + message = "", +}: FactBoxOptions): NodeJS.Timer | null { + if (!message) { + return createFactBox(spinner, title, processManager) + } + + return resetFactBox(interval, spinner, message, processManager, title) +} diff --git a/packages/create-medusa-app/src/utils/log-message.ts b/packages/create-medusa-app/src/utils/log-message.ts index 2f11f04581..c9955dd851 100644 --- a/packages/create-medusa-app/src/utils/log-message.ts +++ b/packages/create-medusa-app/src/utils/log-message.ts @@ -1,5 +1,6 @@ import chalk from "chalk" import { program } from "commander" +import { logger } from "./logger.js" type LogOptions = { message: string @@ -9,13 +10,13 @@ type LogOptions = { export default ({ message, type = "info" }: LogOptions) => { switch (type) { case "info": - console.log(chalk.white(message)) + logger.info(chalk.white(message)) break case "success": - console.log(chalk.green(message)) + logger.info(chalk.green(message)) break case "warning": - console.log(chalk.yellow(message)) + logger.warning(chalk.yellow(message)) break case "error": program.error(chalk.bold.red(message)) diff --git a/packages/create-medusa-app/src/utils/logger.ts b/packages/create-medusa-app/src/utils/logger.ts new file mode 100644 index 0000000000..e5aeaa78c5 --- /dev/null +++ b/packages/create-medusa-app/src/utils/logger.ts @@ -0,0 +1,10 @@ +import winston from "winston" + +const consoleTransport = new winston.transports.Console({ + format: winston.format.printf((log) => log.message), +}) +const options = { + transports: [consoleTransport], +} + +export const logger = winston.createLogger(options) diff --git a/packages/create-medusa-app/src/utils/prepare-project.ts b/packages/create-medusa-app/src/utils/prepare-project.ts index 0a8cd753d3..d561f4db49 100644 --- a/packages/create-medusa-app/src/utils/prepare-project.ts +++ b/packages/create-medusa-app/src/utils/prepare-project.ts @@ -4,7 +4,7 @@ import path from "path" import { Ora } from "ora" import promiseExec from "./promise-exec.js" import { EOL } from "os" -import { createFactBox, resetFactBox } from "./facts.js" +import { displayFactBox, FactBoxOptions } from "./facts.js" import { clearProject } from "@medusajs/utils" import ProcessManager from "./process-manager.js" @@ -45,6 +45,14 @@ export default async ({ }, } + const factBoxOptions: FactBoxOptions = { + interval: null, + spinner, + processManager, + message: "", + title: "", + } + // initialize the invite token to return let inviteToken: string | undefined = undefined @@ -54,11 +62,12 @@ export default async ({ `DATABASE_TYPE=postgres${EOL}DATABASE_URL=${dbConnectionString}` ) - let interval: NodeJS.Timer | null = createFactBox( + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, spinner, - "Installing dependencies...", - processManager - ) + title: "Installing dependencies...", + processManager, + }) await processManager.runProcess({ process: async () => { @@ -73,30 +82,29 @@ export default async ({ ignoreERESOLVE: true, }) - interval = resetFactBox( - interval, - spinner, - "Installed Dependencies", - processManager - ) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + message: "Installed Dependencies", + }) if (!boilerplate) { - interval = createFactBox( - spinner, - "Preparing Project Directory...", - processManager - ) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + title: "Preparing Project Directory...", + }) // delete files and directories related to onboarding clearProject(directory) - interval = resetFactBox( - interval, - spinner, - "Prepared Project Directory", - processManager - ) + displayFactBox({ + ...factBoxOptions, + message: "Prepared Project Directory", + }) } - interval = createFactBox(spinner, "Building Project...", processManager) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + title: "Building Project...", + }) + await processManager.runProcess({ process: async () => { try { @@ -110,9 +118,11 @@ export default async ({ ignoreERESOLVE: true, }) - interval = resetFactBox(interval, spinner, "Project Built", processManager) - - interval = createFactBox(spinner, "Running Migrations...", processManager) + displayFactBox({ ...factBoxOptions, message: "Project Built" }) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + title: "Running Migrations...", + }) // run migrations await processManager.runProcess({ @@ -133,15 +143,17 @@ export default async ({ }, }) - interval = resetFactBox(interval, spinner, "Ran Migrations", processManager) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + message: "Ran Migrations", + }) if (admin) { // create admin user - interval = createFactBox( - spinner, - "Creating an admin user...", - processManager - ) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + title: "Creating an admin user...", + }) await processManager.runProcess({ process: async () => { @@ -155,16 +167,17 @@ export default async ({ }, }) - interval = resetFactBox( - interval, - spinner, - "Created admin user", - processManager - ) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + message: "Created admin user", + }) } if (seed || !boilerplate) { - interval = createFactBox(spinner, "Seeding database...", processManager) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + title: "Seeding database...", + }) // check if a seed file exists in the project if (!fs.existsSync(path.join(directory, "data", "seed.json"))) { @@ -189,17 +202,19 @@ export default async ({ ) }, }) - resetFactBox( - interval, - spinner, - "Seeded database with demo data", - processManager - ) + + displayFactBox({ + ...factBoxOptions, + message: "Seeded database with demo data", + }) } else if ( fs.existsSync(path.join(directory, "data", "seed-onboarding.json")) ) { // seed the database with onboarding seed - interval = createFactBox(spinner, "Finish preparation...", processManager) + factBoxOptions.interval = displayFactBox({ + ...factBoxOptions, + title: "Finish preparation...", + }) await processManager.runProcess({ process: async () => { @@ -212,7 +227,8 @@ export default async ({ ) }, }) - resetFactBox(interval, spinner, "Finished Preparation", processManager) + + displayFactBox({ ...factBoxOptions, message: "Finished Preparation" }) } return inviteToken diff --git a/packages/create-medusa-app/src/utils/process-manager.ts b/packages/create-medusa-app/src/utils/process-manager.ts index 08b65c4548..1bf45245f6 100644 --- a/packages/create-medusa-app/src/utils/process-manager.ts +++ b/packages/create-medusa-app/src/utils/process-manager.ts @@ -15,7 +15,7 @@ export default class ProcessManager { }) } - onTerminated(fn: Function) { + onTerminated(fn: () => Promise | void) { process.on("SIGTERM", () => fn()) process.on("SIGINT", () => fn()) } @@ -32,7 +32,7 @@ export default class ProcessManager { let processError = false let retries = 0 do { - retries++ + ++retries try { await process() } catch (error) { diff --git a/yarn.lock b/yarn.lock index 9540319dbc..54f1eba451 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17878,6 +17878,7 @@ __metadata: uuid: ^9.0.0 validator: ^13.9.0 wait-on: ^7.0.1 + winston: ^3.9.0 bin: create-medusa-app: dist/index.js languageName: unknown @@ -43151,6 +43152,25 @@ __metadata: languageName: node linkType: hard +"winston@npm:^3.9.0": + version: 3.9.0 + resolution: "winston@npm:3.9.0" + dependencies: + "@colors/colors": 1.5.0 + "@dabh/diagnostics": ^2.0.2 + async: ^3.2.3 + is-stream: ^2.0.0 + logform: ^2.4.0 + one-time: ^1.0.0 + readable-stream: ^3.4.0 + safe-stable-stringify: ^2.3.1 + stack-trace: 0.0.x + triple-beam: ^1.3.0 + winston-transport: ^4.5.0 + checksum: e789b0c57bc3b0173c6fbd7caa4d60816a1a45cd796e5643e1bea65161d44c82a139b69f6ffc738a2919d80b5afc6df46c01947972173fc847ebcc668285fab3 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.3, word-wrap@npm:~1.2.3": version: 1.2.3 resolution: "word-wrap@npm:1.2.3"