feat(create-medusa-app): add database options (#4733)

## What

Adds new options for easier usage of the `create-medusa-app` command for regular medusa users.

The following options are added:

- `--skip-db`: Skips creating the database, running migrations, and seeding, and subsequently skips opening the browser. Useful if the developer wants to set the database URL at a later point in the configurations.
- `--db-url <url>`:  Skips database creation and sets the database URL to the provided URL. Throws an error if can't connect to the database. Will still run migrations and open the admin after project creation. Useful if the developer already has database created, locally or remotely.
- `--no-migrations`: Skips running migrations, creating admin user, and seeding. If used, it's expected that you pass the `--db-url` option with a url of a database that has all necessary migrations. Otherwise, unexpected errors will occur. Helpful only if combined with `--db-url`
- `--no-browser`: Disables opening the browser at the end of the project creation and only shows success message.
- `--directory-path <path>`: Allows specifying the directory path to install the project in. Useful for testing.
This commit is contained in:
Shahed Nasser
2023-08-15 11:08:54 +03:00
committed by GitHub
parent 4a448b68fd
commit 30ce35b163
15 changed files with 271 additions and 121 deletions

View File

@@ -0,0 +1,7 @@
---
"create-medusa-app": patch
"@medusajs/utils": patch
"@medusajs/medusa-cli": patch
---
feat(create-medusa-app, utils, medusa-cli): add database options + remove util from `@medusajs/utils`

View File

@@ -13,10 +13,10 @@
"prepublishOnly": "cross-env NODE_ENV=production tsc --build" "prepublishOnly": "cross-env NODE_ENV=production tsc --build"
}, },
"dependencies": { "dependencies": {
"@medusajs/utils": "^1.9.3",
"boxen": "^5", "boxen": "^5",
"chalk": "^5.2.0", "chalk": "^5.2.0",
"commander": "^10.0.1", "commander": "^10.0.1",
"glob": "^7.1.6",
"inquirer": "^9.2.2", "inquirer": "^9.2.2",
"medusa-telemetry": "^0.0.16", "medusa-telemetry": "^0.0.16",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",

View File

@@ -8,6 +8,7 @@ import open from "open"
import waitOn from "wait-on" import waitOn from "wait-on"
import ora, { Ora } from "ora" import ora, { Ora } from "ora"
import fs from "fs" import fs from "fs"
import path from "path"
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, {
@@ -31,6 +32,11 @@ export type CreateOptions = {
// commander passed --no-boilerplate as boilerplate // commander passed --no-boilerplate as boilerplate
boilerplate?: boolean boilerplate?: boolean
stable?: boolean stable?: boolean
skipDb?: boolean
dbUrl?: string
browser?: boolean
migrations?: boolean
directoryPath?: string
} }
export default async ({ export default async ({
@@ -38,6 +44,11 @@ export default async ({
seed, seed,
boilerplate, boilerplate,
stable, stable,
skipDb,
dbUrl,
browser,
migrations,
directoryPath,
}: CreateOptions) => { }: CreateOptions) => {
track("CREATE_CLI") track("CREATE_CLI")
if (repoUrl) { if (repoUrl) {
@@ -57,7 +68,7 @@ export default async ({
message: "", message: "",
title: "", title: "",
} }
const dbName = `medusa-${nanoid(4)}` const dbName = !skipDb && !dbUrl ? `medusa-${nanoid(4)}` : ""
let isProjectCreated = false let isProjectCreated = false
let isDbInitialized = false let isDbInitialized = false
let printedMessage = false let printedMessage = false
@@ -74,29 +85,24 @@ export default async ({
// this ensures that the message isn't printed twice to the user // this ensures that the message isn't printed twice to the user
if (!printedMessage && isProjectCreated) { if (!printedMessage && isProjectCreated) {
printedMessage = true printedMessage = true
logMessage({ showSuccessMessage(projectName)
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",
}
),
})
} }
return return
}) })
const projectName = await askForProjectName() const projectName = await askForProjectName(directoryPath)
const { client, dbConnectionString } = await getDbClientAndCredentials(dbName) const projectPath = getProjectPath(projectName, directoryPath)
const adminEmail =
!skipDb && migrations ? await askForAdminEmail(seed, boilerplate) : ""
const { client, dbConnectionString } = !skipDb
? await getDbClientAndCredentials({
dbName,
dbUrl,
})
: { client: null, dbConnectionString: "" }
isDbInitialized = true isDbInitialized = true
const adminEmail = await askForAdminEmail(seed, boilerplate)
logMessage({ logMessage({
message: `${emojify( message: `${emojify(
@@ -113,7 +119,7 @@ export default async ({
try { try {
await runCloneRepo({ await runCloneRepo({
projectName, projectName: projectPath,
repoUrl, repoUrl,
abortController, abortController,
spinner, spinner,
@@ -126,21 +132,26 @@ export default async ({
factBoxOptions.interval = displayFactBox({ factBoxOptions.interval = displayFactBox({
...factBoxOptions, ...factBoxOptions,
message: "Created project directory", message: "Created project directory",
title: "Creating database...",
}) })
await runCreateDb({ client, dbName, spinner }) if (client && !dbUrl) {
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Creating database...",
})
await runCreateDb({ client, dbName, spinner })
factBoxOptions.interval = displayFactBox({ factBoxOptions.interval = displayFactBox({
...factBoxOptions, ...factBoxOptions,
message: `Database ${dbName} created`, message: `Database ${dbName} created`,
}) })
}
// prepare project // prepare project
let inviteToken: string | undefined = undefined let inviteToken: string | undefined = undefined
try { try {
inviteToken = await prepareProject({ inviteToken = await prepareProject({
directory: projectName, directory: projectPath,
dbConnectionString, dbConnectionString,
admin: { admin: {
email: adminEmail, email: adminEmail,
@@ -150,6 +161,8 @@ export default async ({
spinner, spinner,
processManager, processManager,
abortController, abortController,
skipDb,
migrations,
}) })
} catch (e: any) { } catch (e: any) {
if (isAbortError(e)) { if (isAbortError(e)) {
@@ -170,6 +183,11 @@ export default async ({
spinner.succeed(chalk.green("Project Prepared")) spinner.succeed(chalk.green("Project Prepared"))
if (skipDb || !browser) {
showSuccessMessage(projectPath, inviteToken)
process.exit()
}
// start backend // start backend
logMessage({ logMessage({
message: "Starting Medusa...", message: "Starting Medusa...",
@@ -177,7 +195,7 @@ export default async ({
try { try {
startMedusa({ startMedusa({
directory: projectName, directory: projectPath,
abortController, abortController,
}) })
} catch (e) { } catch (e) {
@@ -208,7 +226,7 @@ export default async ({
) )
} }
async function askForProjectName(): Promise<string> { async function askForProjectName(directoryPath?: string): Promise<string> {
const { projectName } = await inquirer.prompt([ const { projectName } = await inquirer.prompt([
{ {
type: "input", type: "input",
@@ -222,7 +240,9 @@ async function askForProjectName(): Promise<string> {
if (!input.length) { if (!input.length) {
return "Please enter a project name" return "Please enter a project name"
} }
return fs.existsSync(input) && fs.lstatSync(input).isDirectory() const projectPath = getProjectPath(input, directoryPath)
return fs.existsSync(projectPath) &&
fs.lstatSync(projectPath).isDirectory()
? "A directory already exists with the same name. Please enter a different project name." ? "A directory already exists with the same name. Please enter a different project name."
: true : true
}, },
@@ -251,3 +271,29 @@ async function askForAdminEmail(
return adminEmail return adminEmail
} }
function showSuccessMessage(projectName: string, inviteToken?: string) {
logMessage({
message: boxen(
chalk.green(
// eslint-disable-next-line prettier/prettier
`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}${inviteToken ? `${EOL}${EOL}After you start the Medusa app, you can set a password for your admin user with the URL ${getInviteUrl(inviteToken)}${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",
}
),
})
}
function getProjectPath(projectName: string, directoryPath?: string) {
return path.join(directoryPath || "", projectName)
}
function getInviteUrl(inviteToken: string) {
return `http://localhost:7001/invite?token=${inviteToken}&first_run=true`
}

View File

@@ -14,6 +14,29 @@ program
"--stable", "--stable",
"Install the latest stable version. This removes all onboarding features" "Install the latest stable version. This removes all onboarding features"
) )
.option(
"--skip-db",
"Skips creating the database, running migrations, and seeding, and subsequently skips opening the browser.",
false
)
.option(
"--db-url <url>",
"Skips database creation and sets the database URL to the provided URL. Throws an error if can't connect to the database. Will still run migrations and open the admin after project creation."
)
.option(
"--no-migrations",
"Skips running migrations, creating admin user, and seeding. If used, it's expected that you pass the --db-url option with a url of a database that has all necessary migrations. Otherwise, unexpected errors will occur.",
true
)
.option(
"--no-browser",
"Disables opening the browser at the end of the project creation and only shows success message.",
true
)
.option(
"--directory-path <path>",
"Specify the directory path to install the project in."
)
.parse() .parse()
void create(program.opts()) void create(program.opts())

View File

@@ -8,10 +8,12 @@ export function clearProject(directory: string) {
path.join(directory, `src`, `**/onboarding/`) path.join(directory, `src`, `**/onboarding/`)
) )
const typeFiles = glob.sync(path.join(directory, `src`, `types`)) const typeFiles = glob.sync(path.join(directory, `src`, `types`))
const srcFiles = glob.sync(path.join(directory, `src`, `**/*.{ts,tsx,js,jsx}`)) const srcFiles = glob.sync(
path.join(directory, `src`, `**/*.{ts,tsx,js,jsx}`)
)
const files = [...adminFiles, ...onboardingFiles, ...typeFiles, ...srcFiles] const files = [...adminFiles, ...onboardingFiles, ...typeFiles, ...srcFiles]
files.forEach((file) => files.forEach((file) =>
fs.rmSync(file, { fs.rmSync(file, {
recursive: true, recursive: true,

View File

@@ -40,7 +40,7 @@ export async function runCreateDb({
} }
} }
export async function getDbClientAndCredentials(dbName: string): Promise<{ async function getForDbName(dbName: string): Promise<{
client: pg.Client client: pg.Client
dbConnectionString: string dbConnectionString: string
}> { }> {
@@ -101,3 +101,40 @@ export async function getDbClientAndCredentials(dbName: string): Promise<{
dbConnectionString, dbConnectionString,
} }
} }
async function getForDbUrl(dbUrl: string): Promise<{
client: pg.Client
dbConnectionString: string
}> {
let client!: pg.Client
try {
client = await postgresClient({
connectionString: dbUrl,
})
} catch (e) {
logMessage({
message: `Couldn't connect to PostgreSQL using the database URL you passed. Make sure it's correct and try again.`,
type: "error",
})
}
return {
client,
dbConnectionString: dbUrl,
}
}
export async function getDbClientAndCredentials({
dbName = "",
dbUrl = "",
}): Promise<{
client: pg.Client
dbConnectionString: string
}> {
if (dbName) {
return await getForDbName(dbName)
} else {
return await getForDbUrl(dbUrl)
}
}

View File

@@ -4,6 +4,7 @@ const { Client } = pg
type PostgresConnection = { type PostgresConnection = {
user?: string user?: string
password?: string password?: string
connectionString?: string
} }
export default async (connect: PostgresConnection) => { export default async (connect: PostgresConnection) => {

View File

@@ -5,8 +5,8 @@ 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 { displayFactBox, FactBoxOptions } from "./facts.js" import { displayFactBox, FactBoxOptions } from "./facts.js"
import { clearProject } from "@medusajs/utils"
import ProcessManager from "./process-manager.js" import ProcessManager from "./process-manager.js"
import { clearProject } from "./clear-project.js"
type PrepareOptions = { type PrepareOptions = {
directory: string directory: string
@@ -19,6 +19,8 @@ type PrepareOptions = {
spinner: Ora spinner: Ora
processManager: ProcessManager processManager: ProcessManager
abortController?: AbortController abortController?: AbortController
skipDb?: boolean
migrations?: boolean
} }
export default async ({ export default async ({
@@ -30,6 +32,8 @@ export default async ({
spinner, spinner,
processManager, processManager,
abortController, abortController,
skipDb,
migrations,
}: PrepareOptions) => { }: PrepareOptions) => {
// initialize execution options // initialize execution options
const execOptions = { const execOptions = {
@@ -56,11 +60,13 @@ export default async ({
// initialize the invite token to return // initialize the invite token to return
let inviteToken: string | undefined = undefined let inviteToken: string | undefined = undefined
// add connection string to project if (!skipDb) {
fs.appendFileSync( // add connection string to project
path.join(directory, `.env`), fs.appendFileSync(
`DATABASE_TYPE=postgres${EOL}DATABASE_URL=${dbConnectionString}` path.join(directory, `.env`),
) `DATABASE_TYPE=postgres${EOL}DATABASE_URL=${dbConnectionString}`
)
}
factBoxOptions.interval = displayFactBox({ factBoxOptions.interval = displayFactBox({
...factBoxOptions, ...factBoxOptions,
@@ -119,36 +125,39 @@ export default async ({
}) })
displayFactBox({ ...factBoxOptions, message: "Project Built" }) displayFactBox({ ...factBoxOptions, message: "Project Built" })
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Running Migrations...",
})
// run migrations if (!skipDb && migrations) {
await processManager.runProcess({ factBoxOptions.interval = displayFactBox({
process: async () => { ...factBoxOptions,
const proc = await promiseExec( title: "Running Migrations...",
"npx @medusajs/medusa-cli@latest migrations run", })
npxOptions
)
// ensure that migrations actually ran in case of an uncaught error // run migrations
if (!proc.stdout.includes("Migrations completed")) { await processManager.runProcess({
throw new Error( process: async () => {
`An error occurred while running migrations: ${ const proc = await promiseExec(
proc.stderr || proc.stdout "npx @medusajs/medusa-cli@latest migrations run",
}` npxOptions
) )
}
},
})
factBoxOptions.interval = displayFactBox({ // ensure that migrations actually ran in case of an uncaught error
...factBoxOptions, if (!proc.stdout.includes("Migrations completed")) {
message: "Ran Migrations", throw new Error(
}) `An error occurred while running migrations: ${
proc.stderr || proc.stdout
}`
)
}
},
})
if (admin) { factBoxOptions.interval = displayFactBox({
...factBoxOptions,
message: "Ran Migrations",
})
}
if (admin && !skipDb && migrations) {
// create admin user // create admin user
factBoxOptions.interval = displayFactBox({ factBoxOptions.interval = displayFactBox({
...factBoxOptions, ...factBoxOptions,
@@ -173,61 +182,63 @@ export default async ({
}) })
} }
if (seed || !boilerplate) { if (!skipDb && migrations) {
factBoxOptions.interval = displayFactBox({ if (seed || !boilerplate) {
...factBoxOptions, factBoxOptions.interval = displayFactBox({
title: "Seeding database...", ...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"))) {
spinner spinner
?.warn( ?.warn(
chalk.yellow( chalk.yellow(
"Seed file was not found in the project. Skipping seeding..." "Seed file was not found in the project. Skipping seeding..."
)
) )
) .start()
.start() return inviteToken
return inviteToken }
await processManager.runProcess({
process: async () => {
await promiseExec(
`npx @medusajs/medusa-cli@latest seed --seed-file=${path.join(
"data",
"seed.json"
)}`,
npxOptions
)
},
})
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
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Finish preparation...",
})
await processManager.runProcess({
process: async () => {
await promiseExec(
`npx @medusajs/medusa-cli@latest seed --seed-file=${path.join(
"data",
"seed-onboarding.json"
)}`,
npxOptions
)
},
})
} }
await processManager.runProcess({
process: async () => {
await promiseExec(
`npx @medusajs/medusa-cli@latest seed --seed-file=${path.join(
"data",
"seed.json"
)}`,
npxOptions
)
},
})
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
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Finish preparation...",
})
await processManager.runProcess({
process: async () => {
await promiseExec(
`npx @medusajs/medusa-cli@latest seed --seed-file=${path.join(
"data",
"seed-onboarding.json"
)}`,
npxOptions
)
},
})
displayFactBox({ ...factBoxOptions, message: "Finished Preparation" }) displayFactBox({ ...factBoxOptions, message: "Finished Preparation" })
} }

View File

@@ -44,6 +44,7 @@
"execa": "^5.1.1", "execa": "^5.1.1",
"fs-exists-cached": "^1.0.0", "fs-exists-cached": "^1.0.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"glob": "^7.1.6",
"hosted-git-info": "^4.0.2", "hosted-git-info": "^4.0.2",
"inquirer": "^8.0.0", "inquirer": "^8.0.0",
"is-valid-path": "^0.1.1", "is-valid-path": "^0.1.1",

View File

@@ -18,8 +18,8 @@ import inquirer from "inquirer"
import reporter from "../reporter" import reporter from "../reporter"
import { getPackageManager, setPackageManager } from "../util/package-manager" import { getPackageManager, setPackageManager } from "../util/package-manager"
import { clearProject } from "@medusajs/utils"
import { PanicId } from "../reporter/panic-handler" import { PanicId } from "../reporter/panic-handler"
import { clearProject } from "../util/clear-project"
const removeUndefined = (obj) => { const removeUndefined = (obj) => {
return Object.fromEntries( return Object.fromEntries(

View File

@@ -0,0 +1,25 @@
import fs from "fs"
import glob from "glob"
import path from "path"
export function clearProject(directory: string) {
const adminFiles = glob.sync(path.join(directory, `src`, `admin/**/*`))
const onboardingFiles = glob.sync(
path.join(directory, `src`, `**/onboarding/`)
)
const typeFiles = glob.sync(path.join(directory, `src`, `types`))
const srcFiles = glob.sync(
path.join(directory, `src`, `**/*.{ts,tsx,js,jsx}`)
)
const files = [...adminFiles, ...onboardingFiles, ...typeFiles, ...srcFiles]
files.forEach((file) =>
fs.rmSync(file, {
recursive: true,
force: true,
})
)
// add empty typescript file to avoid build errors
fs.openSync(path.join(directory, "src", "index.ts"), "w")
}

View File

@@ -29,7 +29,6 @@
}, },
"dependencies": { "dependencies": {
"awilix": "^8.0.1", "awilix": "^8.0.1",
"glob": "^7.1.6",
"ulid": "^2.3.0" "ulid": "^2.3.0"
}, },
"scripts": { "scripts": {

View File

@@ -1 +0,0 @@
export * from "./clear-project"

View File

@@ -1,5 +1,4 @@
export * from "./bundles" export * from "./bundles"
export * from "./cli"
export * from "./common" export * from "./common"
export * from "./decorators" export * from "./decorators"
export * from "./event-bus" export * from "./event-bus"

View File

@@ -6269,6 +6269,7 @@ __metadata:
execa: ^5.1.1 execa: ^5.1.1
fs-exists-cached: ^1.0.0 fs-exists-cached: ^1.0.0
fs-extra: ^10.0.0 fs-extra: ^10.0.0
glob: ^7.1.6
hosted-git-info: ^4.0.2 hosted-git-info: ^4.0.2
inquirer: ^8.0.0 inquirer: ^8.0.0
is-valid-path: ^0.1.1 is-valid-path: ^0.1.1
@@ -6572,7 +6573,6 @@ __metadata:
awilix: ^8.0.1 awilix: ^8.0.1
cross-env: ^5.2.1 cross-env: ^5.2.1
express: ^4.18.2 express: ^4.18.2
glob: ^7.1.6
jest: ^25.5.4 jest: ^25.5.4
rimraf: ^5.0.1 rimraf: ^5.0.1
ts-jest: ^25.5.1 ts-jest: ^25.5.1
@@ -17901,7 +17901,6 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "create-medusa-app@workspace:packages/create-medusa-app" resolution: "create-medusa-app@workspace:packages/create-medusa-app"
dependencies: dependencies:
"@medusajs/utils": ^1.9.3
"@types/chalk": ^2.2.0 "@types/chalk": ^2.2.0
"@types/commander": ^2.12.2 "@types/commander": ^2.12.2
"@types/configstore": ^6.0.0 "@types/configstore": ^6.0.0
@@ -17921,6 +17920,7 @@ __metadata:
eslint-config-google: ^0.14.0 eslint-config-google: ^0.14.0
eslint-config-prettier: ^8.8.0 eslint-config-prettier: ^8.8.0
eslint-plugin-prettier: ^4.2.1 eslint-plugin-prettier: ^4.2.1
glob: ^7.1.6
inquirer: ^9.2.2 inquirer: ^9.2.2
medusa-telemetry: ^0.0.16 medusa-telemetry: ^0.0.16
nanoid: ^4.0.2 nanoid: ^4.0.2