feat: Scaffold plugin in create-medusa-app (#10908)

RESOLVES FRMW-2862

**What**
This PR enable the `create-medusa-app` CLI to accept a new `--plugin` option to scaffold a plugin. This is complementary to all the plugin commands being created/adjusted separately to that pr.
Also, this pr brings a little refactoring around resource scaffolding, the idea was to contain the refactoring to a little area and not expend it to the entire create-medusa-app package to not disrupt and expand the scope for which the purpose was to introduce the plugin scaffolding capabilities

**Addition**
- medusa project will get their package.json name changed to the project name
- Remove build step from medusa project creation

**Plugin flow**

- in the plugin
  - `npx create-medsa-app --plugin`
  - `yarn dev`
- in the project
  - `yalc add plugin-name`
  - `yarn dev`

Any changes on the plugin will publish, push in the local registry which will fire the hot reload of the app and include the new changes from the plugin
This commit is contained in:
Adrien de Peretti
2025-01-13 15:18:42 +01:00
committed by GitHub
parent b0f581cc7c
commit c895ed8013
10 changed files with 680 additions and 376 deletions

View File

@@ -0,0 +1,5 @@
---
"create-medusa-app": patch
---
Feat/plugin scaffolding

View File

@@ -1,347 +1,12 @@
import inquirer from "inquirer"
import slugifyType from "slugify"
import chalk from "chalk"
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 ora, { Ora } from "ora"
import fs from "fs"
import path from "path"
import logMessage from "../utils/log-message.js"
import createAbortController, {
isAbortError,
} from "../utils/create-abort-controller.js"
import { track } from "@medusajs/telemetry"
import boxen from "boxen"
import { emojify } from "node-emoji"
import ProcessManager from "../utils/process-manager.js"
import { displayFactBox, FactBoxOptions } from "../utils/facts.js"
import { EOL } from "os"
import { runCloneRepo } from "../utils/clone-repo.js"
import {
askForNextjsStarter,
installNextjsStarter,
startNextjsStarter,
} from "../utils/nextjs-utils.js"
import {
getNodeVersion,
MIN_SUPPORTED_NODE_VERSION,
} from "../utils/node-version.js"
ProjectCreatorFactory,
ProjectOptions,
} from "../utils/project-creator/index.js"
const slugify = slugifyType.default
export type CreateOptions = {
repoUrl?: string
seed?: boolean
skipDb?: boolean
dbUrl?: string
browser?: boolean
migrations?: boolean
directoryPath?: string
withNextjsStarter?: boolean
verbose?: boolean
}
export default async (
args: string[],
{
repoUrl = "",
seed,
skipDb,
dbUrl,
browser,
migrations,
directoryPath,
withNextjsStarter = false,
verbose = false,
}: CreateOptions
) => {
const nodeVersion = getNodeVersion()
if (nodeVersion < MIN_SUPPORTED_NODE_VERSION) {
logMessage({
message: `Medusa requires at least v20 of Node.js. You're using v${nodeVersion}. Please install at least v20 and try again: https://nodejs.org/en/download`,
type: "error",
})
}
track("CREATE_CLI_CMA")
const spinner: Ora = ora()
const processManager = new ProcessManager()
const abortController = createAbortController(processManager)
const factBoxOptions: FactBoxOptions = {
interval: null,
spinner,
processManager,
message: "",
title: "",
verbose,
}
let isProjectCreated = false
let isDbInitialized = false
let printedMessage = false
let nextjsDirectory = ""
processManager.onTerminated(async () => {
spinner.stop()
// prevent an error from occurring if
// client hasn't been declared yet
if (isDbInitialized && client) {
await client.end()
}
// 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
showSuccessMessage(projectName, undefined, nextjsDirectory)
}
return
})
let askProjectName = args.length === 0
if (args.length > 0) {
// check if project directory already exists
const projectPath = getProjectPath(args[0], directoryPath)
if (fs.existsSync(projectPath) && fs.lstatSync(projectPath).isDirectory()) {
logMessage({
message: `A directory already exists with the name ${args[0]}. Please enter a different project name.`,
type: "warn",
})
askProjectName = true
}
}
const projectName = askProjectName ? await askForProjectName(directoryPath) : args[0]
const projectPath = getProjectPath(projectName, directoryPath)
const installNextjs = withNextjsStarter || (await askForNextjsStarter())
let dbName = !skipDb && !dbUrl ? `medusa-${slugify(projectName)}` : ""
let { client, dbConnectionString, ...rest } = !skipDb
? await getDbClientAndCredentials({
dbName,
dbUrl,
verbose,
})
: { client: null, dbConnectionString: "" }
if ("dbName" in rest) {
dbName = rest.dbName as string
}
isDbInitialized = true
track("CMA_OPTIONS", {
repoUrl,
seed,
skipDb,
browser,
migrations,
installNextjs,
verbose,
})
logMessage({
message: `${emojify(
":rocket:"
)} Starting project setup, this may take a few minutes.`,
})
spinner.start()
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Setting up project...",
})
try {
await runCloneRepo({
projectName: projectPath,
repoUrl,
abortController,
spinner,
verbose,
})
} catch {
return
}
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
message: "Created project directory",
})
nextjsDirectory = installNextjs
? await installNextjsStarter({
directoryName: projectPath,
abortController,
factBoxOptions,
verbose,
processManager,
})
: ""
if (client && !dbUrl) {
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Creating database...",
})
client = await runCreateDb({ client, dbName, spinner })
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
message: `Database ${dbName} created`,
})
}
// prepare project
let inviteToken: string | undefined = undefined
try {
inviteToken = await prepareProject({
directory: projectPath,
dbName,
dbConnectionString,
seed,
spinner,
processManager,
abortController,
skipDb,
migrations,
onboardingType: installNextjs ? "nextjs" : "default",
nextjsDirectory,
client,
verbose,
})
} catch (e: any) {
if (isAbortError(e)) {
process.exit()
}
spinner.stop()
logMessage({
message: `An error occurred while preparing project: ${e}`,
type: "error",
})
return
} finally {
// close db connection
await client?.end()
}
spinner.succeed(chalk.green("Project Prepared"))
if (skipDb || !browser) {
showSuccessMessage(projectPath, inviteToken, nextjsDirectory)
process.exit()
}
// start backend
logMessage({
message: "Starting Medusa...",
})
try {
startMedusa({
directory: projectPath,
abortController,
})
if (installNextjs && nextjsDirectory) {
startNextjsStarter({
directory: nextjsDirectory,
abortController,
verbose,
})
}
} catch (e) {
if (isAbortError(e)) {
process.exit()
}
logMessage({
message: `An error occurred while starting Medusa`,
type: "error",
})
return
}
isProjectCreated = true
await waitOn({
resources: ["http://localhost:9000/health"],
}).then(async () => {
open(
inviteToken
? `http://localhost:9000/app/invite?token=${inviteToken}&first_run=true`
: "http://localhost:9000/app"
)
})
}
async function askForProjectName(directoryPath?: string): 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).toLowerCase()
},
validate: (input) => {
if (!input.length) {
return "Please enter a project name"
}
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."
: true
},
},
])
return projectName
}
function showSuccessMessage(
projectName: string,
inviteToken?: string,
nextjsDirectory?: 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}yarn dev${EOL}${EOL}${
inviteToken
? `After you start the Medusa app, you can set a password for your admin user with the URL ${getInviteUrl(
inviteToken
)}${EOL}${EOL}`
: ""
}${
nextjsDirectory?.length
? `The Next.js Starter storefront was installed in the \`${nextjsDirectory}\` directory. Change to that directory and start it with the following command:${EOL}${EOL}npm run dev${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`
/**
* Command handler to create a Medusa project or plugin
*/
export default async (args: string[], options: ProjectOptions) => {
const projectCreator = await ProjectCreatorFactory.create(args, options)
await projectCreator.create()
}

View File

@@ -3,8 +3,9 @@ import { program } from "commander"
import create from "./commands/create.js"
program
.description("Create a new Medusa project")
.description("Create a new Medusa project or plugin")
.argument("[project-name]", "Name of the project to create.")
.option("--plugin", "Create a plugin instead of a project.")
.option("--repo-url <url>", "URL of repository to use to setup project.")
.option("--seed", "Seed the created database with demo data.")
.option(

View File

@@ -10,20 +10,29 @@ type CloneRepoOptions = {
repoUrl?: string
abortController?: AbortController
verbose?: boolean
isPlugin?: boolean
}
const DEFAULT_REPO = "https://github.com/medusajs/medusa-starter-default"
const DEFAULT_PLUGIN_REPO = "https://github.com/medusajs/medusa-starter-plugin"
const BRANCH = "master"
const PLUGIN_BRANCH = "main"
export default async function cloneRepo({
directoryName = "",
repoUrl,
abortController,
verbose = false,
isPlugin = false,
}: CloneRepoOptions) {
const defaultRepo = isPlugin ? DEFAULT_PLUGIN_REPO : DEFAULT_REPO
const branch = isPlugin ? PLUGIN_BRANCH : BRANCH
await execute(
[
`git clone ${repoUrl || DEFAULT_REPO} -b ${BRANCH} ${directoryName} --depth 1`,
`git clone ${
repoUrl || defaultRepo
} -b ${branch} ${directoryName} --depth 1`,
{
signal: abortController?.signal,
},
@@ -38,12 +47,14 @@ export async function runCloneRepo({
abortController,
spinner,
verbose = false,
isPlugin = false,
}: {
projectName: string
repoUrl: string
abortController: AbortController
spinner: Ora
verbose?: boolean
isPlugin?: boolean
}) {
try {
await cloneRepo({
@@ -51,6 +62,7 @@ export async function runCloneRepo({
repoUrl,
abortController,
verbose,
isPlugin,
})
deleteGitDirectory(projectName)

View File

@@ -9,18 +9,29 @@ import type { Client } from "pg"
const ADMIN_EMAIL = "admin@medusa-test.com"
let STORE_CORS = "http://localhost:8000"
let ADMIN_CORS =
"http://localhost:5173,http://localhost:9000"
let ADMIN_CORS = "http://localhost:5173,http://localhost:9000"
const DOCS_CORS = "https://docs.medusajs.com"
const AUTH_CORS = [ADMIN_CORS, STORE_CORS, DOCS_CORS].join(",")
STORE_CORS += `,${DOCS_CORS}`
ADMIN_CORS += `,${DOCS_CORS}`
const DEFAULT_REDIS_URL = "redis://localhost:6379"
type PrepareOptions = {
type PreparePluginOptions = {
isPlugin: true
directory: string
projectName: string
spinner: Ora
processManager: ProcessManager
abortController?: AbortController
verbose?: boolean
}
type PrepareProjectOptions = {
isPlugin: false
directory: string
dbName?: string
dbConnectionString: string
projectName: string
seed?: boolean
spinner: Ora
processManager: ProcessManager
@@ -33,8 +44,86 @@ type PrepareOptions = {
verbose?: boolean
}
export default async ({
type PrepareOptions = PreparePluginOptions | PrepareProjectOptions
export default async <
T extends PrepareOptions,
Output = T extends { isPlugin: true } ? void : string | undefined
>(
prepareOptions: T
): Promise<Output> => {
if (prepareOptions.isPlugin) {
return preparePlugin(prepareOptions) as Output
}
return prepareProject(prepareOptions) as Output
}
async function preparePlugin({
directory,
projectName,
spinner,
processManager,
abortController,
verbose = false,
}: PreparePluginOptions) {
// initialize execution options
const execOptions = {
cwd: directory,
signal: abortController?.signal,
}
const factBoxOptions: FactBoxOptions = {
interval: null,
spinner,
processManager,
message: "",
title: "",
verbose,
}
// Update package.json
const packageJsonPath = path.join(directory, "package.json")
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
// Update name
packageJson.name = projectName
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
spinner,
title: "Installing dependencies...",
processManager,
})
await processManager.runProcess({
process: async () => {
try {
await execute([`yarn`, execOptions], { verbose })
} catch (e) {
// yarn isn't available
// use npm
await execute([`npm install --legacy-peer-deps`, execOptions], {
verbose,
})
}
},
ignoreERESOLVE: true,
})
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
message: "Installed Dependencies",
})
displayFactBox({ ...factBoxOptions, message: "Finished Preparation" })
}
async function prepareProject({
directory,
projectName,
dbName,
dbConnectionString,
seed,
@@ -47,7 +136,7 @@ export default async ({
nextjsDirectory = "",
client,
verbose = false,
}: PrepareOptions) => {
}: PrepareProjectOptions) {
// initialize execution options
const execOptions = {
cwd: directory,
@@ -71,6 +160,15 @@ export default async ({
verbose,
}
// Update package.json
const packageJsonPath = path.join(directory, "package.json")
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
// Update name
packageJson.name = projectName
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
// initialize the invite token to return
let inviteToken: string | undefined = undefined
@@ -80,7 +178,7 @@ export default async ({
if (!skipDb) {
if (dbName) {
env += `${EOL}DB_NAME=${dbName}`
dbConnectionString = dbConnectionString.replace(dbName, "$DB_NAME")
dbConnectionString = dbConnectionString!.replace(dbName, "$DB_NAME")
}
env += `${EOL}DATABASE_URL=${dbConnectionString}`
}
@@ -118,26 +216,6 @@ export default async ({
message: "Installed Dependencies",
})
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
title: "Building Project...",
})
await processManager.runProcess({
process: async () => {
try {
await execute([`yarn build`, execOptions], { verbose })
} catch (e) {
// yarn isn't available
// use npm
await execute([`npm run build`, execOptions], { verbose })
}
},
ignoreERESOLVE: true,
})
displayFactBox({ ...factBoxOptions, message: "Project Built" })
if (!skipDb && migrations) {
factBoxOptions.interval = displayFactBox({
...factBoxOptions,
@@ -147,10 +225,10 @@ export default async ({
// run migrations
await processManager.runProcess({
process: async () => {
const proc = await execute(
["npx medusa db:migrate", npxOptions],
{ verbose, needOutput: true }
)
const proc = await execute(["npx medusa db:migrate", npxOptions], {
verbose,
needOutput: true,
})
if (client) {
// check the migrations table is in the database

View File

@@ -0,0 +1,65 @@
import ora, { Ora } from "ora"
import path from "path"
import createAbortController from "../create-abort-controller.js"
import { FactBoxOptions } from "../facts.js"
import ProcessManager from "../process-manager.js"
export interface ProjectOptions {
repoUrl?: string
seed?: boolean
skipDb?: boolean
dbUrl?: string
browser?: boolean
migrations?: boolean
directoryPath?: string
withNextjsStarter?: boolean
verbose?: boolean
plugin?: boolean
}
export interface ProjectCreator {
create(): Promise<void>
}
// Base class for common project functionality
export abstract class BaseProjectCreator {
protected spinner: Ora
protected processManager: ProcessManager
protected abortController: AbortController
protected factBoxOptions: FactBoxOptions
protected projectName: string
protected projectPath: string
protected isProjectCreated: boolean = false
protected printedMessage: boolean = false
constructor(
projectName: string,
protected options: ProjectOptions,
protected args: string[]
) {
this.spinner = ora()
this.processManager = new ProcessManager()
this.abortController = createAbortController(this.processManager)
this.projectName = projectName
const basePath =
typeof options.directoryPath === "string" ? options.directoryPath : ""
this.projectPath = path.join(basePath, projectName)
this.factBoxOptions = {
interval: null,
spinner: this.spinner,
processManager: this.processManager,
message: "",
title: "",
verbose: options.verbose || false,
}
}
protected getProjectPath(projectName: string): string {
return path.join(this.options.directoryPath ?? "", projectName)
}
protected abstract showSuccessMessage(): void
protected abstract setupProcessManager(): void
}

View File

@@ -0,0 +1,4 @@
export * from "./creator.js"
export * from "./medusa-plugin-creator.js"
export * from "./medusa-project-creator.js"
export * from "./project-creator-factory.js"

View File

@@ -0,0 +1,118 @@
import { track } from "@medusajs/telemetry"
import boxen from "boxen"
import chalk from "chalk"
import { emojify } from "node-emoji"
import { EOL } from "os"
import slugifyType from "slugify"
import { runCloneRepo } from "../clone-repo.js"
import { isAbortError } from "../create-abort-controller.js"
import { displayFactBox } from "../facts.js"
import logMessage from "../log-message.js"
import prepareProject from "../prepare-project.js"
import {
BaseProjectCreator,
ProjectCreator,
ProjectOptions,
} from "./creator.js"
// Plugin Project Creator
export class PluginProjectCreator
extends BaseProjectCreator
implements ProjectCreator
{
constructor(projectName: string, options: ProjectOptions, args: string[]) {
super(projectName, options, args)
this.setupProcessManager()
}
async create(): Promise<void> {
track("CREATE_CLI_CMP")
logMessage({
message: `${emojify(
":rocket:"
)} Starting plugin setup, this may take a few minutes.`,
})
this.spinner.start()
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
title: "Setting up plugin...",
})
try {
await this.cloneAndPreparePlugin()
this.spinner.succeed(chalk.green("Plugin Prepared"))
this.showSuccessMessage()
} catch (e: any) {
this.handleError(e)
}
}
private async cloneAndPreparePlugin(): Promise<void> {
await runCloneRepo({
projectName: this.projectPath,
repoUrl: this.options.repoUrl ?? "",
abortController: this.abortController,
spinner: this.spinner,
verbose: this.options.verbose,
isPlugin: true,
})
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
message: "Created plugin directory",
})
await prepareProject({
isPlugin: true,
directory: this.projectPath,
projectName: this.projectName,
spinner: this.spinner,
processManager: this.processManager,
abortController: this.abortController,
verbose: this.options.verbose,
})
}
private handleError(e: any): void {
if (isAbortError(e)) {
process.exit()
}
this.spinner.stop()
logMessage({
message: `An error occurred while preparing plugin: ${e}`,
type: "error",
})
}
protected showSuccessMessage(): void {
logMessage({
message: boxen(
chalk.green(
`Change to the \`${this.projectName}\` directory to explore your Medusa plugin.${EOL}${EOL}Check out the Medusa plugin 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",
}
),
})
}
protected setupProcessManager(): void {
this.processManager.onTerminated(async () => {
this.spinner.stop()
if (!this.printedMessage && this.isProjectCreated) {
this.printedMessage = true
this.showSuccessMessage()
}
return
})
}
}

View File

@@ -0,0 +1,259 @@
import { track } from "@medusajs/telemetry"
import boxen from "boxen"
import chalk from "chalk"
import { emojify } from "node-emoji"
import open from "open"
import { EOL } from "os"
import slugifyType from "slugify"
import waitOn from "wait-on"
import { runCloneRepo } from "../clone-repo.js"
import { isAbortError } from "../create-abort-controller.js"
import { getDbClientAndCredentials, runCreateDb } from "../create-db.js"
import { displayFactBox } from "../facts.js"
import logMessage from "../log-message.js"
import {
askForNextjsStarter,
installNextjsStarter,
startNextjsStarter,
} from "../nextjs-utils.js"
import prepareProject from "../prepare-project.js"
import startMedusa from "../start-medusa.js"
import {
BaseProjectCreator,
ProjectCreator,
ProjectOptions,
} from "./creator.js"
const slugify = slugifyType.default
// Medusa Project Creator
export class MedusaProjectCreator
extends BaseProjectCreator
implements ProjectCreator
{
private client: any = null
private dbConnectionString: string = ""
private isDbInitialized: boolean = false
private nextjsDirectory: string = ""
private inviteToken?: string
constructor(projectName: string, options: ProjectOptions, args: string[]) {
super(projectName, options, args)
this.setupProcessManager()
}
async create(): Promise<void> {
track("CREATE_CLI_CMA")
try {
await this.initializeProject()
await this.setupProject()
await this.startServices()
} catch (e: any) {
this.handleError(e)
}
}
private async initializeProject(): Promise<void> {
const installNextjs =
this.options.withNextjsStarter || (await askForNextjsStarter())
if (!this.options.skipDb) {
await this.setupDatabase()
}
logMessage({
message: `${emojify(
":rocket:"
)} Starting project setup, this may take a few minutes.`,
})
this.spinner.start()
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
title: "Setting up project...",
})
try {
await runCloneRepo({
projectName: this.projectPath,
repoUrl: this.options.repoUrl ?? "",
abortController: this.abortController,
spinner: this.spinner,
verbose: this.options.verbose,
})
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
message: "Created project directory",
})
if (installNextjs) {
this.nextjsDirectory = await installNextjsStarter({
directoryName: this.projectPath,
abortController: this.abortController,
factBoxOptions: this.factBoxOptions,
verbose: this.options.verbose,
processManager: this.processManager,
})
}
} catch (e) {
throw e
}
}
private async setupDatabase(): Promise<void> {
const dbName = `medusa-${slugify(this.projectName)}`
const { client, dbConnectionString, ...rest } =
await getDbClientAndCredentials({
dbName,
dbUrl: this.options.dbUrl,
verbose: this.options.verbose,
})
this.client = client
this.dbConnectionString = dbConnectionString
this.isDbInitialized = true
if (!this.options.dbUrl) {
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
title: "Creating database...",
})
this.client = await runCreateDb({
client: this.client,
dbName,
spinner: this.spinner,
})
this.factBoxOptions.interval = displayFactBox({
...this.factBoxOptions,
message: `Database ${dbName} created`,
})
}
}
private async setupProject(): Promise<void> {
try {
this.inviteToken = await prepareProject({
isPlugin: false,
projectName: this.projectName,
directory: this.projectPath,
dbConnectionString: this.dbConnectionString,
seed: this.options.seed,
spinner: this.spinner,
processManager: this.processManager,
abortController: this.abortController,
skipDb: this.options.skipDb,
migrations: this.options.migrations,
onboardingType: this.nextjsDirectory ? "nextjs" : "default",
nextjsDirectory: this.nextjsDirectory,
client: this.client,
verbose: this.options.verbose,
})
} finally {
await this.client?.end()
}
this.spinner.succeed(chalk.green("Project Prepared"))
}
private async startServices(): Promise<void> {
if (this.options.skipDb || !this.options.browser) {
this.showSuccessMessage()
process.exit()
}
logMessage({
message: "Starting Medusa...",
})
startMedusa({
directory: this.projectPath,
abortController: this.abortController,
})
if (this.nextjsDirectory) {
startNextjsStarter({
directory: this.nextjsDirectory,
abortController: this.abortController,
verbose: this.options.verbose,
})
}
this.isProjectCreated = true
await this.openBrowser()
}
private async openBrowser(): Promise<void> {
await waitOn({
resources: ["http://localhost:9000/health"],
}).then(async () => {
open(
this.inviteToken
? `http://localhost:9000/app/invite?token=${this.inviteToken}&first_run=true`
: "http://localhost:9000/app"
)
})
}
private handleError(e: any): void {
if (isAbortError(e)) {
process.exit()
}
this.spinner.stop()
logMessage({
message: `An error occurred: ${e}`,
type: "error",
})
}
protected showSuccessMessage(): void {
logMessage({
message: boxen(
chalk.green(
`Change to the \`${
this.projectName
}\` directory to explore your Medusa project.${EOL}${EOL}Start your Medusa app again with the following command:${EOL}${EOL}yarn dev${EOL}${EOL}${
this.inviteToken
? `After you start the Medusa app, you can set a password for your admin user with the URL http://localhost:7001/invite?token=${this.inviteToken}&first_run=true${EOL}${EOL}`
: ""
}${
this.nextjsDirectory?.length
? `The Next.js Starter storefront was installed in the \`${this.nextjsDirectory}\` directory. Change to that directory and start it with the following command:${EOL}${EOL}npm run dev${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",
}
),
})
}
protected setupProcessManager(): void {
this.processManager.onTerminated(async () => {
this.spinner.stop()
// prevent an error from occurring if
// client hasn't been declared yet
if (this.isDbInitialized && this.client) {
await this.client.end()
}
if (!this.printedMessage && this.isProjectCreated) {
this.printedMessage = true
this.showSuccessMessage()
}
return
})
}
}

View File

@@ -0,0 +1,97 @@
import fs from "fs"
import inquirer from "inquirer"
import path from "path"
import slugifyType from "slugify"
import logMessage from "../log-message.js"
import { getNodeVersion, MIN_SUPPORTED_NODE_VERSION } from "../node-version.js"
import { ProjectCreator, ProjectOptions } from "./creator.js"
import { PluginProjectCreator } from "./medusa-plugin-creator.js"
import { MedusaProjectCreator } from "./medusa-project-creator.js"
const slugify = slugifyType.default
export class ProjectCreatorFactory {
static async create(
args: string[],
options: ProjectOptions
): Promise<ProjectCreator> {
ProjectCreatorFactory.validateNodeVersion()
const projectName = await ProjectCreatorFactory.getProjectName(
args,
options.directoryPath,
options.plugin
)
return options.plugin
? new PluginProjectCreator(projectName, options, args)
: new MedusaProjectCreator(projectName, options, args)
}
private static validateNodeVersion(): void {
const nodeVersion = getNodeVersion()
if (nodeVersion < MIN_SUPPORTED_NODE_VERSION) {
logMessage({
message: `Medusa requires at least v20 of Node.js. You're using v${nodeVersion}. Please install at least v20 and try again: https://nodejs.org/en/download`,
type: "error",
})
}
}
private static async getProjectName(
args: string[],
directoryPath?: string,
isPlugin?: boolean
): Promise<string> {
let askProjectName = args.length === 0
if (args.length > 0) {
const projectPath = path.join(directoryPath || "", args[0])
if (
fs.existsSync(projectPath) &&
fs.lstatSync(projectPath).isDirectory()
) {
logMessage({
message: `A directory already exists with the name ${
args[0]
}. Please enter a different ${isPlugin ? "plugin" : "project"} name.`,
type: "warn",
})
askProjectName = true
}
}
return askProjectName
? await askForProjectName(directoryPath, isPlugin)
: args[0]
}
}
async function askForProjectName(
directoryPath?: string,
isPlugin?: boolean
): Promise<string> {
const { projectName } = await inquirer.prompt([
{
type: "input",
name: "projectName",
message: `What's the name of your ${isPlugin ? "plugin" : "project"}?`,
default: isPlugin ? "my-medusa-plugin" : "my-medusa-store",
filter: (input) => {
return slugify(input).toLowerCase()
},
validate: (input) => {
if (!input.length) {
return `Please enter a ${isPlugin ? "plugin" : "project"} name`
}
const projectPath = path.join(directoryPath || "", input)
return fs.existsSync(projectPath) &&
fs.lstatSync(projectPath).isDirectory()
? `A directory already exists with the same name. Please enter a different ${
isPlugin ? "plugin" : "project"
} name.`
: true
},
},
])
return projectName
}