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:
committed by
GitHub
parent
b0f581cc7c
commit
c895ed8013
5
.changeset/silent-keys-sniff.md
Normal file
5
.changeset/silent-keys-sniff.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"create-medusa-app": patch
|
||||
---
|
||||
|
||||
Feat/plugin scaffolding
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user