chore(create-medusa-app): Cleanup the main script for readability and maintanability (#4369)

* chore(create-medusa-app): Cleanup the main script for readability and maintanability

* update types

* cleanup

* Create polite-queens-kiss.md
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2023-07-10 16:27:16 +02:00
1 parent 4264302f21
commit d363da2b72
11 files changed
+373 -223

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"create-medusa-app": patch
---
chore(create-medusa-app): Cleanup the main script for readability and maintanability
+2 -1
View File
@@ -28,7 +28,8 @@
"slugify": "^1.6.6", "slugify": "^1.6.6",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"validator": "^13.9.0", "validator": "^13.9.0",
"wait-on": "^7.0.1" "wait-on": "^7.0.1",
"winston": "^3.9.0"
}, },
"devDependencies": { "devDependencies": {
"@types/chalk": "^2.2.0", "@types/chalk": "^2.2.0",
+116 -167
View File
@@ -1,33 +1,31 @@
import inquirer from "inquirer" import inquirer from "inquirer"
import slugifyType from "slugify" import slugifyType from "slugify"
import chalk from "chalk" import chalk from "chalk"
import pg from "pg" import { getDbClientAndCredentials, runCreateDb } from "../utils/create-db.js"
import createDb from "../utils/create-db.js"
import postgresClient from "../utils/postgres-client.js"
import cloneRepo from "../utils/clone-repo.js"
import prepareProject from "../utils/prepare-project.js" import prepareProject from "../utils/prepare-project.js"
import startMedusa from "../utils/start-medusa.js" import startMedusa from "../utils/start-medusa.js"
import open from "open" import open from "open"
import waitOn from "wait-on" import waitOn from "wait-on"
import formatConnectionString from "../utils/format-connection-string.js" import ora, { Ora } from "ora"
import ora from "ora"
import fs from "fs" import fs from "fs"
import { nanoid } from "nanoid"
import isEmailImported from "validator/lib/isEmail.js" import isEmailImported from "validator/lib/isEmail.js"
import logMessage from "../utils/log-message.js" import logMessage from "../utils/log-message.js"
import createAbortController, { import createAbortController, {
isAbortError, isAbortError,
} from "../utils/create-abort-controller.js" } from "../utils/create-abort-controller.js"
import { track } from "medusa-telemetry" import { track } from "medusa-telemetry"
import { createFactBox, resetFactBox } from "../utils/facts.js"
import boxen from "boxen" import boxen from "boxen"
import { emojify } from "node-emoji" import { emojify } from "node-emoji"
import ProcessManager from "../utils/process-manager.js" 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 slugify = slugifyType.default
const isEmail = isEmailImported.default const isEmail = isEmailImported.default
type CreateOptions = { export type CreateOptions = {
repoUrl?: string repoUrl?: string
seed?: boolean seed?: boolean
// commander passed --no-boilerplate as boilerplate // commander passed --no-boilerplate as boilerplate
@@ -42,90 +40,53 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => {
if (seed) { if (seed) {
track("SEED_SELECTED", { seed }) track("SEED_SELECTED", { seed })
} }
const spinner: Ora = ora()
const processManager = new ProcessManager() const processManager = new ProcessManager()
const abortController = createAbortController(processManager) const abortController = createAbortController(processManager)
const factBoxOptions: FactBoxOptions = {
const { projectName } = await inquirer.prompt([ interval: null,
{ spinner,
type: "input", processManager,
name: "projectName", message: "",
message: "What's the name of your project?", title: "",
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() const dbName = `medusa-${nanoid(4)}`
? "A directory already exists with the same name. Please enter a different project name." let isProjectCreated = false
: true let printedMessage = false
},
},
])
let client: pg.Client | undefined processManager.onTerminated(async () => {
let dbConnectionString = "" spinner.stop()
let postgresUsername = "postgres" if (client) {
let postgresPassword = "" await client.end()
}
// try to log in with default db username and password // the SIGINT event is triggered twice once the backend runs
try { // this ensures that the message isn't printed twice to the user
client = await postgresClient({ if (!printedMessage && isProjectCreated) {
user: postgresUsername, printedMessage = true
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({ logMessage({
message: message: boxen(
"Couldn't connect to PostgreSQL. Make sure you have PostgreSQL installed and the credentials you provided are correct.\n\n" + chalk.green(
"You can learn how to install PostgreSQL here: https://docs.medusajs.com/development/backend/prepare-environment#postgresql", `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`
type: "error", ),
{
titleAlignment: "center",
textAlignment: "center",
padding: 1,
margin: 1,
float: "center",
}
),
}) })
} }
}
const { adminEmail } = await inquirer.prompt([ return
{ })
type: "input",
name: "adminEmail", const projectName = await askForProjectName()
message: "Enter an email for your admin dashboard user", const { client, dbConnectionString } = await getDbClientAndCredentials(dbName)
default: !seed && boilerplate ? "admin@medusa-test.com" : undefined, const adminEmail = await askForAdminEmail(seed, boilerplate)
validate: (input) => {
return typeof input === "string" && input.length > 0 && isEmail(input)
? true
: "Please enter a valid email"
},
},
])
logMessage({ logMessage({
message: `${emojify( message: `${emojify(
@@ -133,74 +94,36 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => {
)} Starting project setup, this may take a few minutes.`, )} 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 { try {
await cloneRepo({ await runCloneRepo({
directoryName: projectName, projectName,
repoUrl, repoUrl,
abortController, 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, 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",
}) })
} catch {
return
} }
// format connection string factBoxOptions.interval = displayFactBox({
dbConnectionString = formatConnectionString({ ...factBoxOptions,
user: postgresUsername, message: "Created project directory",
password: postgresPassword, title: "Creating database...",
host: client.host,
db: dbName,
}) })
resetFactBox( await runCreateDb({ client, dbName, spinner })
interval,
spinner, factBoxOptions.interval = displayFactBox({
`Database ${dbName} created`, ...factBoxOptions,
processManager message: `Database ${dbName} created`,
) })
}
// prepare project // prepare project
let inviteToken: string | undefined = undefined let inviteToken: string | undefined = undefined
@@ -227,13 +150,15 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => {
message: `An error occurred while preparing project: ${e}`, message: `An error occurred while preparing project: ${e}`,
type: "error", type: "error",
}) })
return
} finally {
// close db connection
await client?.end()
} }
spinner.succeed(chalk.green("Project Prepared")) spinner.succeed(chalk.green("Project Prepared"))
// close db connection
await client?.end()
// start backend // start backend
logMessage({ logMessage({
message: "Starting Medusa...", message: "Starting Medusa...",
@@ -253,31 +178,11 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => {
message: `An error occurred while starting Medusa`, message: `An error occurred while starting Medusa`,
type: "error", type: "error",
}) })
return
} }
// the SIGINT event is triggered twice once the backend runs isProjectCreated = true
// 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",
}
)
)
}
})
await waitOn({ await waitOn({
resources: ["http://localhost:9000/health"], resources: ["http://localhost:9000/health"],
@@ -289,3 +194,47 @@ export default async ({ repoUrl = "", seed, boilerplate }: CreateOptions) => {
) )
) )
} }
async function askForProjectName(): Promise<string> {
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<string> {
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
}
@@ -1,4 +1,7 @@
import promiseExec from "./promise-exec.js" 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 = { type CloneRepoOptions = {
directoryName?: string directoryName?: string
@@ -9,12 +12,42 @@ type CloneRepoOptions = {
const DEFAULT_REPO = const DEFAULT_REPO =
"https://github.com/medusajs/medusa-starter-default -b feat/onboarding" "https://github.com/medusajs/medusa-starter-default -b feat/onboarding"
export default async ({ export default async function cloneRepo({
directoryName = "", directoryName = "",
repoUrl, repoUrl,
abortController, abortController,
}: CloneRepoOptions) => { }: CloneRepoOptions) {
await promiseExec(`git clone ${repoUrl || DEFAULT_REPO} ${directoryName}`, { await promiseExec(`git clone ${repoUrl || DEFAULT_REPO} ${directoryName}`, {
signal: abortController?.signal, 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",
})
}
}
@@ -1,10 +1,103 @@
import pg from "pg" 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 = { type CreateDbOptions = {
client: pg.Client client: pg.Client
db: string db: string
} }
export default async ({ client, db }: CreateDbOptions) => { export default async function createDb({ client, db }: CreateDbOptions) {
await client.query(`CREATE DATABASE "${db}"`) 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,
}
}
@@ -4,6 +4,14 @@ import { Ora } from "ora"
import { emojify } from "node-emoji" import { emojify } from "node-emoji"
import ProcessManager from "./process-manager.js" import ProcessManager from "./process-manager.js"
export type FactBoxOptions = {
interval: NodeJS.Timer | null
spinner: Ora
processManager: ProcessManager
message?: string
title?: string
}
const facts = [ const facts = [
"Plugins allow you to integrate third-party services for payment, fulfillment, notifications, and more.", "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.", "You can specify a product's availability in one or more sales channels.",
@@ -79,3 +87,17 @@ export const resetFactBox = (
return newInterval 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)
}
@@ -1,5 +1,6 @@
import chalk from "chalk" import chalk from "chalk"
import { program } from "commander" import { program } from "commander"
import { logger } from "./logger.js"
type LogOptions = { type LogOptions = {
message: string message: string
@@ -9,13 +10,13 @@ type LogOptions = {
export default ({ message, type = "info" }: LogOptions) => { export default ({ message, type = "info" }: LogOptions) => {
switch (type) { switch (type) {
case "info": case "info":
console.log(chalk.white(message)) logger.info(chalk.white(message))
break break
case "success": case "success":
console.log(chalk.green(message)) logger.info(chalk.green(message))
break break
case "warning": case "warning":
console.log(chalk.yellow(message)) logger.warning(chalk.yellow(message))
break break
case "error": case "error":
program.error(chalk.bold.red(message)) program.error(chalk.bold.red(message))
@@ -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)
@@ -4,7 +4,7 @@ import path from "path"
import { Ora } from "ora" import { Ora } from "ora"
import promiseExec from "./promise-exec.js" import promiseExec from "./promise-exec.js"
import { EOL } from "os" import { EOL } from "os"
import { createFactBox, resetFactBox } from "./facts.js" import { displayFactBox, FactBoxOptions } from "./facts.js"
import { clearProject } from "@medusajs/utils" import { clearProject } from "@medusajs/utils"
import ProcessManager from "./process-manager.js" 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 // initialize the invite token to return
let inviteToken: string | undefined = undefined let inviteToken: string | undefined = undefined
@@ -54,11 +62,12 @@ export default async ({
`DATABASE_TYPE=postgres${EOL}DATABASE_URL=${dbConnectionString}` `DATABASE_TYPE=postgres${EOL}DATABASE_URL=${dbConnectionString}`
) )
let interval: NodeJS.Timer | null = createFactBox( factBoxOptions.interval = displayFactBox({
...factBoxOptions,
spinner, spinner,
"Installing dependencies...", title: "Installing dependencies...",
processManager processManager,
) })
await processManager.runProcess({ await processManager.runProcess({
process: async () => { process: async () => {
@@ -73,30 +82,29 @@ export default async ({
ignoreERESOLVE: true, ignoreERESOLVE: true,
}) })
interval = resetFactBox( factBoxOptions.interval = displayFactBox({
interval, ...factBoxOptions,
spinner, message: "Installed Dependencies",
"Installed Dependencies", })
processManager
)
if (!boilerplate) { if (!boilerplate) {
interval = createFactBox( factBoxOptions.interval = displayFactBox({
spinner, ...factBoxOptions,
"Preparing Project Directory...", title: "Preparing Project Directory...",
processManager })
)
// delete files and directories related to onboarding // delete files and directories related to onboarding
clearProject(directory) clearProject(directory)
interval = resetFactBox( displayFactBox({
interval, ...factBoxOptions,
spinner, message: "Prepared Project Directory",
"Prepared Project Directory", })
processManager
)
} }
interval = createFactBox(spinner, "Building Project...", processManager) factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Building Project...",
})
await processManager.runProcess({ await processManager.runProcess({
process: async () => { process: async () => {
try { try {
@@ -110,9 +118,11 @@ export default async ({
ignoreERESOLVE: true, ignoreERESOLVE: true,
}) })
interval = resetFactBox(interval, spinner, "Project Built", processManager) displayFactBox({ ...factBoxOptions, message: "Project Built" })
factBoxOptions.interval = displayFactBox({
interval = createFactBox(spinner, "Running Migrations...", processManager) ...factBoxOptions,
title: "Running Migrations...",
})
// run migrations // run migrations
await processManager.runProcess({ 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) { if (admin) {
// create admin user // create admin user
interval = createFactBox( factBoxOptions.interval = displayFactBox({
spinner, ...factBoxOptions,
"Creating an admin user...", title: "Creating an admin user...",
processManager })
)
await processManager.runProcess({ await processManager.runProcess({
process: async () => { process: async () => {
@@ -155,16 +167,17 @@ export default async ({
}, },
}) })
interval = resetFactBox( factBoxOptions.interval = displayFactBox({
interval, ...factBoxOptions,
spinner, message: "Created admin user",
"Created admin user", })
processManager
)
} }
if (seed || !boilerplate) { 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 // check if a seed file exists in the project
if (!fs.existsSync(path.join(directory, "data", "seed.json"))) { if (!fs.existsSync(path.join(directory, "data", "seed.json"))) {
@@ -189,17 +202,19 @@ export default async ({
) )
}, },
}) })
resetFactBox(
interval, displayFactBox({
spinner, ...factBoxOptions,
"Seeded database with demo data", message: "Seeded database with demo data",
processManager })
)
} else if ( } else if (
fs.existsSync(path.join(directory, "data", "seed-onboarding.json")) fs.existsSync(path.join(directory, "data", "seed-onboarding.json"))
) { ) {
// seed the database with onboarding seed // seed the database with onboarding seed
interval = createFactBox(spinner, "Finish preparation...", processManager) factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Finish preparation...",
})
await processManager.runProcess({ await processManager.runProcess({
process: async () => { process: async () => {
@@ -212,7 +227,8 @@ export default async ({
) )
}, },
}) })
resetFactBox(interval, spinner, "Finished Preparation", processManager)
displayFactBox({ ...factBoxOptions, message: "Finished Preparation" })
} }
return inviteToken return inviteToken
@@ -15,7 +15,7 @@ export default class ProcessManager {
}) })
} }
onTerminated(fn: Function) { onTerminated(fn: () => Promise<void> | void) {
process.on("SIGTERM", () => fn()) process.on("SIGTERM", () => fn())
process.on("SIGINT", () => fn()) process.on("SIGINT", () => fn())
} }
@@ -32,7 +32,7 @@ export default class ProcessManager {
let processError = false let processError = false
let retries = 0 let retries = 0
do { do {
retries++ ++retries
try { try {
await process() await process()
} catch (error) { } catch (error) {
+20
View File
@@ -17878,6 +17878,7 @@ __metadata:
uuid: ^9.0.0 uuid: ^9.0.0
validator: ^13.9.0 validator: ^13.9.0
wait-on: ^7.0.1 wait-on: ^7.0.1
winston: ^3.9.0
bin: bin:
create-medusa-app: dist/index.js create-medusa-app: dist/index.js
languageName: unknown languageName: unknown
@@ -43151,6 +43152,25 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "word-wrap@npm:^1.2.3, word-wrap@npm:~1.2.3":
version: 1.2.3 version: 1.2.3
resolution: "word-wrap@npm:1.2.3" resolution: "word-wrap@npm:1.2.3"