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
@@ -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
}
@@ -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"
@@ -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
})
}
}
@@ -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
})
}
}
@@ -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
}