diff --git a/.changeset/silent-keys-sniff.md b/.changeset/silent-keys-sniff.md new file mode 100644 index 0000000000000..270a92aa6c1ad --- /dev/null +++ b/.changeset/silent-keys-sniff.md @@ -0,0 +1,5 @@ +--- +"create-medusa-app": patch +--- + +Feat/plugin scaffolding diff --git a/packages/cli/create-medusa-app/src/commands/create.ts b/packages/cli/create-medusa-app/src/commands/create.ts index 69cf0b0235bff..1a1f8bce4b8c6 100644 --- a/packages/cli/create-medusa-app/src/commands/create.ts +++ b/packages/cli/create-medusa-app/src/commands/create.ts @@ -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" - -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 { - 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` + ProjectCreatorFactory, + ProjectOptions, +} from "../utils/project-creator/index.js" + +/** + * 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() } diff --git a/packages/cli/create-medusa-app/src/index.ts b/packages/cli/create-medusa-app/src/index.ts index 36db681b7d180..e0219c53fe9a4 100644 --- a/packages/cli/create-medusa-app/src/index.ts +++ b/packages/cli/create-medusa-app/src/index.ts @@ -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 of repository to use to setup project.") .option("--seed", "Seed the created database with demo data.") .option( diff --git a/packages/cli/create-medusa-app/src/utils/clone-repo.ts b/packages/cli/create-medusa-app/src/utils/clone-repo.ts index 18e169483e34b..9177f8e13b676 100644 --- a/packages/cli/create-medusa-app/src/utils/clone-repo.ts +++ b/packages/cli/create-medusa-app/src/utils/clone-repo.ts @@ -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) diff --git a/packages/cli/create-medusa-app/src/utils/prepare-project.ts b/packages/cli/create-medusa-app/src/utils/prepare-project.ts index 728494e62459f..c4dd2dd3cd84f 100644 --- a/packages/cli/create-medusa-app/src/utils/prepare-project.ts +++ b/packages/cli/create-medusa-app/src/utils/prepare-project.ts @@ -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 => { + 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 diff --git a/packages/cli/create-medusa-app/src/utils/project-creator/creator.ts b/packages/cli/create-medusa-app/src/utils/project-creator/creator.ts new file mode 100644 index 0000000000000..fc15326b1ce09 --- /dev/null +++ b/packages/cli/create-medusa-app/src/utils/project-creator/creator.ts @@ -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 +} + +// 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 +} diff --git a/packages/cli/create-medusa-app/src/utils/project-creator/index.ts b/packages/cli/create-medusa-app/src/utils/project-creator/index.ts new file mode 100644 index 0000000000000..9781bacb7d236 --- /dev/null +++ b/packages/cli/create-medusa-app/src/utils/project-creator/index.ts @@ -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" diff --git a/packages/cli/create-medusa-app/src/utils/project-creator/medusa-plugin-creator.ts b/packages/cli/create-medusa-app/src/utils/project-creator/medusa-plugin-creator.ts new file mode 100644 index 0000000000000..420befc16c34f --- /dev/null +++ b/packages/cli/create-medusa-app/src/utils/project-creator/medusa-plugin-creator.ts @@ -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 { + 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 { + 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 + }) + } +} diff --git a/packages/cli/create-medusa-app/src/utils/project-creator/medusa-project-creator.ts b/packages/cli/create-medusa-app/src/utils/project-creator/medusa-project-creator.ts new file mode 100644 index 0000000000000..3ab3a21f0a905 --- /dev/null +++ b/packages/cli/create-medusa-app/src/utils/project-creator/medusa-project-creator.ts @@ -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 { + track("CREATE_CLI_CMA") + + try { + await this.initializeProject() + await this.setupProject() + await this.startServices() + } catch (e: any) { + this.handleError(e) + } + } + + private async initializeProject(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 + }) + } +} diff --git a/packages/cli/create-medusa-app/src/utils/project-creator/project-creator-factory.ts b/packages/cli/create-medusa-app/src/utils/project-creator/project-creator-factory.ts new file mode 100644 index 0000000000000..c33368a68e42f --- /dev/null +++ b/packages/cli/create-medusa-app/src/utils/project-creator/project-creator-factory.ts @@ -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 { + 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 { + 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 { + 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 +}