feat(create-medusa-app): update command for a better onboarding experience (#4215)

* feat(create-medusa-app): update command for a better onboarding experience

* use medusa-telemetry for tracking

* update used snapshot

* added changeset

* update yarn.lock

* increased facts timer

* updated snapshot version

* show facts throughout installation + add first_run to url

* added message after server termination

* print message only once

* added github to process terminated message

* address pr feedback

* added onboarding seeding

* fix for npm install
This commit is contained in:
Shahed Nasser
2023-06-07 21:34:50 +03:00
committed by GitHub
parent 8676ee7a2e
commit c04d93cd04
29 changed files with 1856 additions and 660 deletions

View File

@@ -0,0 +1,5 @@
---
"create-medusa-app": minor
---
feat(create-medusa-app): update command for a better onboarding experience

View File

@@ -19,6 +19,7 @@ packages/*
!packages/stock-location
!packages/cache-redis
!packages/cache-inmemory
!packages/create-medusa-app
**/models/*

View File

@@ -94,6 +94,7 @@ module.exports = {
"./packages/stock-location/tsconfig.spec.json",
"./packages/cache-redis/tsconfig.spec.json",
"./packages/cache-inmemory/tsconfig.spec.json",
"./packages/create-medusa-app/tsconfig.json",
],
},
rules: {

View File

@@ -1,3 +1,4 @@
node_modules
/dist
yarn.lock
my-medusa-store

View File

@@ -0,0 +1,58 @@
<p align="center">
<a href="https://www.medusajs.com">
<img alt="Medusa" src="https://user-images.githubusercontent.com/7554214/153162406-bf8fd16f-aa98-4604-b87b-e13ab4baf604.png" width="100" />
</a>
</p>
<h1 align="center">
create-medusa-app
</h1>
<h4 align="center">
<a href="https://docs.medusajs.com">Documentation</a> |
<a href="https://www.medusajs.com">Website</a>
</h4>
<p align="center">
An open source composable commerce engine built for developers.
</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/LICENSE">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="Medusa is released under the MIT license." />
</a>
<a href="https://circleci.com/gh/medusajs/medusa">
<img src="https://circleci.com/gh/medusajs/medusa.svg?style=shield" alt="Current CircleCI build status." />
</a>
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
## Overview
Using this NPX command, you can setup a Medusa backend and admin along with a PostgreSQL database in simple steps.
---
## Usage
Run the following command in your terminal:
```bash
npx create-medusa-app@latest
```
Then, answer the prompted questions to setup your PostgreSQL database and Medusa project. Once the setup is done, the Medusa admin dashboard will open in your default browser.
### Options
| Option | Description | Default value |
|--------------------|-------------------------------------------------------|------------------------------------------------------|
| `--repo-url <url>` | Create Medusa project from a different repository URL | `https://github.com/medusajs/medusa-starter-default` |
| `--seed` | Using this option seeds the database with demo data | false |

View File

@@ -1,7 +0,0 @@
#! /usr/bin/env node
const { run } = require("./dist")
run().catch((e) => {
console.warn(e)
})

View File

@@ -1,55 +1,62 @@
{
"name": "create-medusa-app",
"version": "0.0.10",
"main": "dist/index.js",
"bin": "cli.js",
"description": "Create a Medusa project using a single command.",
"type": "module",
"exports": "./dist/index.js",
"bin": "dist/index.js",
"license": "MIT",
"files": [
"dist/index.js",
"cli.js"
],
"scripts": {
"build": "microbundle -i src/index.ts --no-pkg-main --target=node -f=cjs --sourcemap=false --compress --alias worker_threads=@ascorbic/worker-threads-shim",
"watch": "microbundle -i src/index.ts --no-pkg-main --target=node -f=cjs --sourcemap=false --alias worker_threads=@ascorbic/worker-threads-shim --watch",
"prepare": "yarn build"
"dev": "ts-node --esm src/index.ts",
"build": "tsc",
"watch": "tsc --watch",
"prepare": "cross-env NODE_ENV=production yarn run build"
},
"dependencies": {
"@babel/runtime": "^7.15.4"
"boxen": "^7.1.0",
"chalk": "^5.2.0",
"commander": "^10.0.1",
"inquirer": "^9.2.2",
"medusa-telemetry": "^0.0.16",
"nanoid": "^4.0.2",
"node-fetch": "^3.3.1",
"open": "^9.1.0",
"ora": "^6.3.0",
"pg": "^8.10.0",
"slugify": "^1.6.6",
"uuid": "^9.0.0",
"validator": "^13.9.0",
"wait-on": "^7.0.1"
},
"devDependencies": {
"@ascorbic/worker-threads-shim": "^1.0.0",
"@babel/runtime": "^7.15.4",
"@types/chalk": "^2.2.0",
"@types/commander": "^2.12.2",
"@types/configstore": "^4.0.0",
"@types/fs-extra": "^9.0.13",
"@types/node": "^14.18.36",
"ansi-wordwrap": "^1.0.2",
"chalk": "^4.1.2",
"commander": "^8.1.0",
"common-tags": "^1.8.2",
"@types/configstore": "^6.0.0",
"@types/inquirer": "^9.0.3",
"@types/pg": "^8.6.6",
"@types/uuid": "^9.0.1",
"@types/validator": "^13.7.17",
"@types/wait-on": "^5.3.1",
"@typescript-eslint/eslint-plugin": "^5.59.5",
"@typescript-eslint/parser": "^5.59.5",
"configstore": "^6.0.0",
"enquirer": "^2.3.6",
"execa": "^5.1.1",
"fs-exists-cached": "^1.0.0",
"fs-extra": "^10.0.0",
"hosted-git-info": "^4.0.2",
"is-valid-path": "^0.1.1",
"microbundle": "^0.13.3",
"node-fetch": "^2.6.9",
"prompts": "^2.4.2",
"string-length": "^4.0.2",
"terminal-link": "^2.1.1",
"tiny-spin": "^1.0.2",
"url": "^0.11.0",
"uuid": "3.4.0"
"eslint": "^8.40.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.8.8",
"ts-node": "^10.9.1",
"typescript": "^5.0.4"
},
"engines": {
"node": ">=14.16"
},
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa.git",
"directory": "packages/create-medusa-app"
},
"author": "Sebastian Rindom <seb@medusa-commerce.com>",
"author": "Medusa",
"publishConfig": {
"access": "public"
},

View File

@@ -0,0 +1,278 @@
import inquirer from "inquirer"
import slugifyType from "slugify"
import chalk from "chalk"
import pg from "pg"
import createDb from "../utils/create-db.js"
import postgresClient from "../utils/postgres-client.js"
import cloneRepo from "../utils/clone-repo.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 formatConnectionString from "../utils/format-connection-string.js"
import ora from "ora"
import fs from "fs"
import { nanoid } from "nanoid"
import isEmailImported from "validator/lib/isEmail.js"
import logMessage from "../utils/log-message.js"
import onProcessTerminated from "../utils/on-process-terminated.js"
import createAbortController, {
isAbortError,
} from "../utils/create-abort-controller.js"
import { track } from "medusa-telemetry"
import { createFactBox, resetFactBox } from "../utils/facts.js"
import boxen from "boxen"
const slugify = slugifyType.default
const isEmail = isEmailImported.default
type CreateOptions = {
repoUrl?: string
seed?: boolean
}
export default async ({ repoUrl = "", seed }: CreateOptions) => {
track("CREATE_CLI")
if (repoUrl) {
track("STARTER_SELECTED", { starter: repoUrl })
}
if (seed) {
track("SEED_SELECTED", { seed })
}
const abortController = createAbortController()
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)
},
validate: (input) => {
if (!input.length) {
return "Please enter a project name"
}
return fs.existsSync(input) && fs.lstatSync(input).isDirectory()
? "A directory already exists with the same name. Please enter a different project name."
: true
},
},
])
let client: pg.Client | undefined
let dbConnectionString = ""
let postgresUsername = "postgres"
let postgresPassword = ""
// try to log in with default db username and password
try {
client = await postgresClient({
user: postgresUsername,
password: postgresPassword,
})
} catch (e) {
// ask for the user's postgres credentials
const answers = await inquirer.prompt([
{
type: "input",
name: "postgresUsername",
message: "Enter your Postgres username",
default: "postgres",
validate: (input) => {
return typeof input === "string" && input.length > 0
},
},
{
type: "password",
name: "postgresPassword",
message: "Enter your Postgres password",
},
])
postgresUsername = answers.postgresUsername
postgresPassword = answers.postgresPassword
try {
client = await postgresClient({
user: postgresUsername,
password: postgresPassword,
})
} catch (e) {
logMessage({
message:
"Couldn't connect to PostgreSQL. Make sure you have PostgreSQL installed and the credentials you provided are correct.\n\n" +
"You can learn how to install PostgreSQL here: https://docs.medusajs.com/development/backend/prepare-environment#postgresql",
type: "error",
})
}
}
const { adminEmail } = await inquirer.prompt([
{
type: "input",
name: "adminEmail",
message: "Enter an email for your admin dashboard user",
default: !seed ? "admin@medusa-test.com" : undefined,
validate: (input) => {
return typeof input === "string" && input.length > 0 && isEmail(input)
? true
: "Please enter a valid email"
},
},
])
const spinner = ora().start()
onProcessTerminated(() => spinner.stop())
let interval: NodeJS.Timer | null = createFactBox(
spinner,
"Setting up project..."
)
// clone repository
try {
await cloneRepo({
directoryName: projectName,
repoUrl,
abortController,
})
} catch (e) {
if (isAbortError(e)) {
process.exit()
}
spinner.stop()
logMessage({
message: `An error occurred while setting up your project: ${e}`,
type: "error",
})
}
interval = resetFactBox(
interval,
spinner,
"Created project directory",
"Creating database..."
)
if (client) {
const dbName = `medusa-${nanoid(4)}`
// create postgres database
try {
await createDb({
client,
db: dbName,
})
} catch (e) {
spinner.stop()
logMessage({
message: `An error occurred while trying to create your database: ${e}`,
type: "error",
})
}
// format connection string
dbConnectionString = formatConnectionString({
user: postgresUsername,
password: postgresPassword,
host: client.host,
db: dbName,
})
resetFactBox(interval, spinner, `Database ${dbName} created`)
}
// prepare project
let inviteToken: string | undefined = undefined
try {
inviteToken = await prepareProject({
directory: projectName,
dbConnectionString,
admin: {
email: adminEmail,
},
seed,
spinner,
abortController,
})
} catch (e: any) {
if (isAbortError(e)) {
process.exit()
}
spinner.stop()
logMessage({
message: `An error occurred while preparing project: ${e}`,
type: "error",
})
}
spinner.succeed(chalk.green("Project Prepared"))
// close db connection
await client?.end()
// start backend
logMessage({
message: "Starting Medusa...",
})
try {
startMedusa({
directory: projectName,
abortController,
})
} catch (e) {
if (isAbortError(e)) {
process.exit()
}
logMessage({
message: `An error occurred while starting Medusa`,
type: "error",
})
}
// the SIGINT event is triggered twice once the backend runs
// this ensures that the message isn't printed twice to the user
let printedMessage = false
onProcessTerminated(() => {
if (!printedMessage) {
printedMessage = true
console.log(
boxen(
chalk.green(
`Change to the \`${projectName}\` directory to explore your Medusa project.
Check out the Medusa documentation to start your development:
https://docs.medusajs.com/
Star us on GitHub if you like what we're building:
https://github.com/medusajs/medusa/stargazers`
),
{
titleAlignment: "center",
textAlignment: "center",
padding: 1,
margin: 1,
float: "center",
}
)
)
}
})
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"
)
)
}

View File

@@ -1,136 +1,11 @@
import Commander from "commander"
import path from "path"
#!/usr/bin/env node
import { program } from "commander"
import create from "./commands/create.js"
import { prompt } from "enquirer"
import { newStarter } from "./new-starter"
import { track } from "./track"
program
.description("Create a new Medusa project")
.option("--repo-url <url>", "URL of repository to use to setup project.")
.option("--seed", "Seed the created database with demo data.")
.parse()
import pkg from "../package.json"
let projectPath: string = ""
const questions = {
projectRoot: {
type: "input",
name: "projectRoot",
message: "Where should your project be installed?",
initial: "my-medusa-store",
},
starter: {
type: "select",
name: "starter",
message: "Which Medusa starter would you like to install?",
choices: ["medusa-starter-default", "medusa-starter-contentful", "Other"],
},
starterUrl: {
type: "input",
name: "starterUrl",
message: "Where is the starter located? (URL or path)",
},
storefront: {
type: "select",
name: "storefront",
message: "Which storefront starter would you like to install?",
choices: [
"Next.js Starter",
"medusa.express (Next.js)",
"None",
],
},
}
const program = new Commander.Command(pkg.name)
.version(pkg.version)
.action((name) => (projectPath = name))
.option(`-r --root`, `The directory to install your Medusa app`)
.option(
`-s --starter-url`,
`A GitHub URL to a repository that contains a Medusa starter project to bootstrap from`
)
.option(`-v --verbose`, `Show all installation output`)
.parse(process.argv)
const getStorefrontStarter = (starter: string): string => {
const selected = starter.toLowerCase()
switch (selected) {
case "next.js starter":
return "https://github.com/medusajs/nextjs-starter-medusa"
case "medusa.express (next.js)":
return "https://github.com/medusajs/medusa-express-nextjs"
default:
return ""
}
}
export const run = async (): Promise<void> => {
track("CREATE_CLI")
if (typeof projectPath === "string") {
projectPath = projectPath.trim()
}
const { projectRoot } = (await prompt(questions.projectRoot)) as {
projectRoot: string
}
let { starter } = (await prompt(questions.starter)) as {
starter: string
}
if (starter === "Other") {
const { starterUrl } = (await prompt(questions.starterUrl)) as {
starterUrl: string
}
starter = starterUrl
} else {
starter = `medusajs/${starter}`
}
track("STARTER_SELECTED", { starter })
const progOptions = program.opts()
const { storefront } = (await prompt(questions.storefront)) as {
storefront: string
}
track("STOREFRONT_SELECTED", { storefront })
await newStarter({
starter,
root: path.join(projectRoot, `backend`),
verbose: progOptions.verbose,
})
const hasStorefront = storefront.toLowerCase() !== "none"
if (hasStorefront) {
const storefrontStarter = getStorefrontStarter(storefront)
await newStarter({
starter: storefrontStarter,
root: path.join(projectRoot, `storefront`),
verbose: progOptions.verbose,
})
}
console.log(`
Your project is ready 🚀. To start your Medusa project:
Medusa Backend
1. Change to the backend directory: cd ${projectRoot}/backend
2. Create a PostgreSQL database and make sure your PostgreSQL server is running.
3. Add the following environment variable to the \`.env\` file:
DATABASE_URL=postgres://localhost/medusa-store # This is the default URL, change it to your own database URL
4. Run migrations:
npx @medusajs/medusa-cli@latest migrations run
5. Optionally seed database with dummy data:
npx @medusajs/medusa-cli@latest seed -f ./data/seed.json
6. Start backend:
npx @medusajs/medusa-cli@latest develop
`)
if (hasStorefront) {
console.log(`
Storefront
1. Run the backend as explained above.
2. Change to the storefront directory: cd ${projectRoot}/storefront
3. Run storefront: yarn dev
`)
}
}
void create(program.opts())

View File

@@ -1,293 +0,0 @@
import { execSync } from "child_process"
import execa from "execa"
import { spin } from "tiny-spin"
import { sync as existsSync } from "fs-exists-cached"
import fs from "fs-extra"
import hostedGitInfo from "hosted-git-info"
import isValid from "is-valid-path"
import sysPath from "path"
import url from "url"
import { reporter } from "./reporter"
import { getConfigStore } from "./get-config-store"
const packageManagerConfigKey = `cli.packageManager`
export const getPackageManager = (npmConfigUserAgent) => {
const configStore = getConfigStore()
const actualPackageManager = configStore.get(packageManagerConfigKey)
if (actualPackageManager) {
return actualPackageManager
}
if (npmConfigUserAgent?.includes(`yarn`)) {
configStore.set(packageManagerConfigKey, `yarn`)
return `yarn`
}
configStore.set(packageManagerConfigKey, `npm`)
return `npm`
}
const spawnWithArgs = (file, args, options) =>
execa(file, args, { stdio: "ignore", preferLocal: false, ...options })
const spawn = (cmd, options) => {
const [file, ...args] = cmd.split(/\s+/)
return spawnWithArgs(file, args, options)
}
// Checks the existence of yarn package
// We use yarnpkg instead of yarn to avoid conflict with Hadoop yarn
// Refer to https://github.com/yarnpkg/yarn/issues/673
const checkForYarn = () => {
try {
execSync(`yarnpkg --version`, { stdio: `ignore` })
return true
} catch (e) {
return false
}
}
const isAlreadyGitRepository = async () => {
try {
return await spawn(`git rev-parse --is-inside-work-tree`, {
stdio: `pipe`,
}).then((output) => output.stdout === `true`)
} catch (err) {
return false
}
}
// Initialize newly cloned directory as a git repo
const gitInit = async (rootPath) => {
reporter.info(`Initialising git in ${rootPath}`)
return await spawn(`git init`, { cwd: rootPath })
}
// Create a .gitignore file if it is missing in the new directory
const maybeCreateGitIgnore = async (rootPath) => {
if (existsSync(sysPath.join(rootPath, `.gitignore`))) {
return
}
reporter.info(`Creating minimal .gitignore in ${rootPath}`)
await fs.writeFile(
sysPath.join(rootPath, `.gitignore`),
`.cache\nnode_modules\npublic\n`
)
reporter.success(`Created .gitignore in ${rootPath}`)
}
// Create an initial git commit in the new directory
const createInitialGitCommit = async (rootPath, starterUrl) => {
reporter.info(`Create initial git commit in ${rootPath}`)
await spawn(`git add -A`, { cwd: rootPath })
// use execSync instead of spawn to handle git clients using
// pgp signatures (with password)
try {
execSync(`git commit -m "Initial commit from medusa: (${starterUrl})"`, {
cwd: rootPath,
})
} catch {
// Remove git support if initial commit fails
reporter.warn(`Initial git commit failed - removing git support\n`)
fs.removeSync(sysPath.join(rootPath, `.git`))
}
}
// Executes `npm install` or `yarn install` in rootPath.
const install = async (rootPath, verbose) => {
const prevDir = process.cwd()
const stop = spin(`Installing packages...`)
console.log() // Add some space
process.chdir(rootPath)
try {
if (getPackageManager() === `yarn` && checkForYarn()) {
await fs.remove(`package-lock.json`)
await spawn(`yarnpkg`, { stdio: verbose ? `inherit` : `ignore` })
} else {
await fs.remove(`yarn.lock`)
await spawn(`npm install`, { stdio: verbose ? `inherit` : `ignore` })
}
} finally {
stop()
console.log()
reporter.success(`Packages installed`)
process.chdir(prevDir)
}
}
const ignored = (path) => !/^\.(git|hg)$/.test(sysPath.basename(path))
// Copy starter from file system.
const copy = async (starterPath, rootPath) => {
// Chmod with 755.
// 493 = parseInt('755', 8)
await fs.ensureDir(rootPath, { mode: 493 })
if (!existsSync(starterPath)) {
throw new Error(`starter ${starterPath} doesn't exist`)
}
if (starterPath === `.`) {
throw new Error(
`You can't create a starter from the existing directory. If you want to
create a new project in the current directory, the trailing dot isn't
necessary. If you want to create a project from a local starter, run
something like "medusa new my-medusa-store ../local-medusa-starter"`
)
}
const stop = spin(`Creating new site from local starter: ${starterPath}`)
reporter.info(`Copying local starter to ${rootPath} ...`)
await fs.copy(starterPath, rootPath, { filter: ignored })
stop()
console.log()
reporter.success(`Created starter directory layout`)
console.log() // Add some space
await install(rootPath)
return true
}
// Clones starter from URI.
const clone = async (hostInfo, rootPath, keepGit, verbose = false) => {
let url
// Let people use private repos accessed over SSH.
if (hostInfo.getDefaultRepresentation() === `sshurl`) {
url = hostInfo.ssh({ noCommittish: true })
// Otherwise default to normal git syntax.
} else {
url = hostInfo.https({ noCommittish: true, noGitPlus: true })
}
const branch = hostInfo.committish ? [`-b`, hostInfo.committish] : []
const stop = spin(`Creating new project from git: ${url}`)
const args = [
`clone`,
...branch,
url,
rootPath,
`--recursive`,
`--depth=1`,
].filter((arg) => Boolean(arg))
await execa(`git`, args, {})
.then(() => {
stop()
console.log()
reporter.success(`Created starter directory layout`)
})
.catch((err) => {
stop()
console.log()
reporter.error(`Failed to clone repository`)
throw err
})
if (!keepGit) {
await fs.remove(sysPath.join(rootPath, `.git`))
}
await install(rootPath, verbose)
const isGit = await isAlreadyGitRepository()
if (!isGit) await gitInit(rootPath)
await maybeCreateGitIgnore(rootPath)
if (!isGit) await createInitialGitCommit(rootPath, url)
}
const getPaths = async (starterPath, rootPath) => {
let selectedOtherStarter = false
// set defaults if no root or starter has been set yet
rootPath = rootPath || process.cwd()
starterPath = starterPath || `medusajs/medusa-starter-default`
return { starterPath, rootPath, selectedOtherStarter }
}
const setupEnvVars = async (rootPath) => {
const templatePath = sysPath.join(rootPath, ".env.template")
const destination = sysPath.join(rootPath, ".env")
if (existsSync(templatePath)) {
fs.renameSync(templatePath, destination)
}
}
/**
* Main function that clones or copies the starter.
*/
export const newStarter = async (args) => {
const { starter, root, verbose, keepGit } = args
const { starterPath, rootPath, selectedOtherStarter } = await getPaths(
starter,
root
)
const urlObject = url.parse(rootPath)
if (urlObject.protocol && urlObject.host) {
const isStarterAUrl =
starter && !url.parse(starter).hostname && !url.parse(starter).protocol
if (/medusa-starter/gi.test(rootPath) && isStarterAUrl) {
reporter.panic({
id: `10000`,
context: {
starter,
rootPath,
},
})
return
}
reporter.panic({
id: `10001`,
context: {
rootPath,
},
})
return
}
if (!isValid(rootPath)) {
reporter.panic({
id: `10002`,
context: {
path: sysPath.resolve(rootPath),
},
})
return
}
if (existsSync(sysPath.join(rootPath, `package.json`))) {
reporter.panic({
id: `10003`,
context: {
rootPath,
},
})
return
}
const hostedInfo = hostedGitInfo.fromUrl(starterPath)
if (hostedInfo) {
await clone(hostedInfo, rootPath, keepGit, verbose)
} else {
await copy(starterPath, rootPath, verbose)
}
await setupEnvVars(rootPath)
}

View File

@@ -1,25 +0,0 @@
export const panicHandler = (panicData = {}) => {
const { id, context } = panicData
switch (id) {
case "10000":
return {
message: `Looks like you provided a URL as your project name. Try "medusa new my-medusa-store ${context.rootPath}" instead.`,
}
case "10001":
return {
message: `Looks like you provided a URL as your project name. Try "medusa new my-medusa-store ${context.rootPath}" instead.`,
}
case "10002":
return {
message: `Could not create project because ${context.path} is not a valid path.`,
}
case "10003":
return {
message: `Directory ${context.rootPath} is already a Node project.`,
}
default:
return {
message: "Unknown error",
}
}
}

View File

@@ -1,18 +0,0 @@
import c from "ansi-colors"
import { panicHandler } from "./panic-handler"
export const reporter = {
info: (message: string): void => console.log(message),
verbose: (message: string): void => console.log(message),
log: (message: string): void => console.log(message),
success: (message: string): void =>
console.log(c.green(c.symbols.check + ` `) + message),
error: (message: string): void =>
console.error(c.red(c.symbols.cross + ` `) + message),
panic: (panicData: { id: string; context: any }): never => {
const { message } = panicHandler(panicData)
console.error(message)
process.exit(1)
},
warn: (message: string): void => console.warn(message),
}

View File

@@ -1,47 +0,0 @@
import fetch from "node-fetch"
import uuidv4 from "uuid/v4"
import { getConfigStore } from "./get-config-store"
const store = getConfigStore()
const medusaCliVersion = require(`../package.json`).version
const analyticsApi =
process.env.MEDUSA_TELEMETRY_API ||
`https://telemetry.medusa-commerce.com/batch`
const getMachineId = (): string => {
let machineId = store.get(`telemetry.machine_id`)
if (typeof machineId !== `string`) {
machineId = uuidv4()
store.set(`telemetry.machine_id`, machineId)
}
return machineId
}
const sessionId = uuidv4()
export const track = (eventType: string, args?: any): void => {
fetch(analyticsApi, {
method: `POST`,
headers: {
"content-type": `application/json`,
"user-agent": `create-medusa-app:${medusaCliVersion}`,
},
body: JSON.stringify({
timestamp: new Date(),
batch: [
{
type: eventType,
timestamp: new Date(),
sessionId,
machine_id: getMachineId(),
component_id: `create-medusa-app`,
cli_version: medusaCliVersion,
properties: args,
},
],
}),
}).catch(() => {}) /* do nothing, it's telemetry */
}

View File

@@ -1,4 +1 @@
declare module "stream-filter"
declare module "ansi-wordwrap"
declare module "uuid/v4"
declare module "node-fetch"
declare module "medusa-telemetry"

View File

@@ -0,0 +1,21 @@
import promiseExec from "./promise-exec.js"
type CloneRepoOptions = {
directoryName?: string
repoUrl?: string
abortController?: AbortController
}
// TODO change default repo URL
const DEFAULT_REPO =
"https://github.com/medusajs/medusa-starter-default -b feat/onboarding"
export default async ({
directoryName = "",
repoUrl,
abortController,
}: CloneRepoOptions) => {
await promiseExec(`git clone ${repoUrl || DEFAULT_REPO} ${directoryName}`, {
signal: abortController?.signal,
})
}

View File

@@ -0,0 +1,10 @@
import onProcessTerminated from "./on-process-terminated.js"
export default () => {
const abortController = new AbortController()
onProcessTerminated(() => abortController.abort())
return abortController
}
export const isAbortError = (e: any) =>
e !== null && "code" in e && e.code === "ABORT_ERR"

View File

@@ -0,0 +1,10 @@
import pg from "pg"
type CreateDbOptions = {
client: pg.Client
db: string
}
export default async ({ client, db }: CreateDbOptions) => {
await client.query(`CREATE DATABASE "${db}"`)
}

View File

@@ -0,0 +1,76 @@
import boxen from "boxen"
import chalk from "chalk"
import { Ora } from "ora"
import onProcessTerminated from "./on-process-terminated.js"
const facts = [
"Plugins allow you to integrate third-party services for payment, fulfillment, notifications, and more.",
"You can specify a product's availability in one or more sales channels.",
"Payment and shipping options and providers can be configured per region.",
"Tax-inclusive pricing allows you to set prices for products, shipping options, and more without having to worry about calculating taxes.",
"Medusa provides multi-currency and region support, with full control over prices for each currency and region.",
"You can organize customers by customer groups and set special prices for them.",
"You can specify the inventory of products per location and sales channel.",
"Publishable-API Keys allow you to send requests to the backend within a scoped resource.",
"You can create custom endpoints by creating a TypeScript file under the src/api directory.",
"You can listen to events to perform asynchronous actions using Subscribers.",
"An entity represents a table in the database. You can create a table by creating a custom entity and migration.",
"Medusa's store endpoint paths are prefixed by /store. The admin endpoints are prefixed by /admin.",
"Medusa provides a JavaScript client and a React library that you can use to build a storefront or a custom admin.",
"Services are classes with methods related to an entity or functionality. You can create a custom service in a TypeScript file under src/services.",
"Modules allow you to replace an entire functionality with your custom logic.",
"The event bus module is responsible for triggering events and relaying them to subscribers.",
"The cache module is responsible for caching data that requires heavy computation.",
]
export const getFact = () => {
const randIndex = Math.floor(Math.random() * facts.length)
return facts[randIndex]
}
export const showFact = (spinner: Ora, title: string) => {
const fact = getFact()
spinner.text = `${boxen(fact, {
title: chalk.cyan(title),
titleAlignment: "center",
textAlignment: "center",
padding: 1,
margin: 1,
float: "center",
})}`
}
export const createFactBox = (spinner: Ora, title: string): NodeJS.Timer => {
spinner.spinner = {
frames: [""],
}
showFact(spinner, title)
const interval = setInterval(() => {
showFact(spinner, title)
}, 10000)
onProcessTerminated(() => clearInterval(interval))
return interval
}
export const resetFactBox = (
interval: NodeJS.Timer | null,
spinner: Ora,
successMessage: string,
newTitle?: string
): NodeJS.Timer | null => {
if (interval) {
clearInterval(interval)
}
spinner.spinner = "dots"
spinner.succeed(chalk.green(successMessage)).start()
let newInterval = null
if (newTitle) {
newInterval = createFactBox(spinner, newTitle)
}
return newInterval
}

View File

@@ -0,0 +1,25 @@
type ConnectionStringOptions = {
user?: string
password?: string
host?: string
db: string
}
export default ({ user, password, host, db }: ConnectionStringOptions) => {
let connection = `postgres://`
if (user) {
connection += user
}
if (password) {
connection += `:${password}`
}
if (user || password) {
connection += "@"
}
connection += `${host}/${db}`
return connection
}

View File

@@ -0,0 +1,23 @@
import chalk from "chalk"
import { program } from "commander"
type LogOptions = {
message: string
type?: "error" | "success" | "info" | "warning"
}
export default ({ message, type = "info" }: LogOptions) => {
switch (type) {
case "info":
console.log(chalk.white(message))
break
case "success":
console.log(chalk.green(message))
break
case "warning":
console.log(chalk.yellow(message))
break
case "error":
program.error(chalk.bold.red(message))
}
}

View File

@@ -0,0 +1,4 @@
export default (fn: Function) => {
process.on("SIGTERM", () => fn())
process.on("SIGINT", () => fn())
}

View File

@@ -0,0 +1,15 @@
import pg from "pg"
const { Client } = pg
type PostgresConnection = {
user?: string
password?: string
}
export default async (connect: PostgresConnection) => {
const client = new Client(connect)
await client.connect()
return client
}

View File

@@ -0,0 +1,149 @@
import chalk from "chalk"
import fs from "fs"
import path from "path"
import { Ora } from "ora"
import promiseExec from "./promise-exec.js"
import { EOL } from "os"
import runProcess from "./run-process.js"
import { createFactBox, resetFactBox } from "./facts.js"
type PrepareOptions = {
directory: string
dbConnectionString: string
admin?: {
email: string
}
seed?: boolean
spinner: Ora
abortController?: AbortController
}
export default async ({
directory,
dbConnectionString,
admin,
seed,
spinner,
abortController,
}: PrepareOptions) => {
// initialize execution options
const execOptions = {
cwd: directory,
signal: abortController?.signal,
}
// initialize the invite token to return
let inviteToken: string | undefined = undefined
// add connection string to project
fs.appendFileSync(
path.join(directory, `.env`),
`DATABASE_TYPE=postgres${EOL}DATABASE_URL=${dbConnectionString}`
)
let interval: NodeJS.Timer | null = createFactBox(
spinner,
"Installing dependencies..."
)
await runProcess({
process: async () => {
try {
await promiseExec(`yarn`, execOptions)
} catch (e) {
// yarn isn't available
// use npm
await promiseExec(`npm install --legacy-peer-deps`, execOptions)
}
},
ignoreERESOLVE: true,
})
interval = resetFactBox(
interval,
spinner,
"Installed Dependencies",
"Running Migrations...."
)
// run migrations
await runProcess({
process: async () => {
await promiseExec(
"npx -y @medusajs/medusa-cli@latest migrations run",
execOptions
)
},
})
interval = resetFactBox(interval, spinner, "Ran Migrations")
if (admin) {
// create admin user
interval = createFactBox(spinner, "Creating an admin user...")
await runProcess({
process: async () => {
const proc = await promiseExec(
// TODO replace with latest version
`npx -y @medusajs/medusa-cli@1.3.16-snapshot-20230605093446 user -e ${admin.email} --invite`,
execOptions
)
// get invite token from stdout
const match = proc.stdout.match(/Invite token: (?<token>.+)/)
inviteToken = match?.groups?.token
},
})
interval = resetFactBox(interval, spinner, "Created admin user")
}
if (seed) {
interval = createFactBox(spinner, "Seeding database...")
// check if a seed file exists in the project
if (!fs.existsSync(path.join(directory, "data", "seed.json"))) {
spinner
?.warn(
chalk.yellow(
"Seed file was not found in the project. Skipping seeding..."
)
)
.start()
return inviteToken
}
await runProcess({
process: async () => {
await promiseExec(
`npx -y @medusajs/medusa-cli@latest seed --seed-file=${path.join(
"data",
"seed.json"
)}`,
execOptions
)
},
})
resetFactBox(interval, spinner, "Seeded database with demo data")
} else if (
fs.existsSync(path.join(directory, "data", "seed-onboarding.json"))
) {
// seed the database with onboarding seed
interval = createFactBox(spinner, "Finish preparation...")
await runProcess({
process: async () => {
await promiseExec(
`npx -y @medusajs/medusa-cli@latest seed --seed-file=${path.join(
"data",
"seed-onboarding.json"
)}`,
execOptions
)
},
})
resetFactBox(interval, spinner, "Finished Preparation")
}
return inviteToken
}

View File

@@ -0,0 +1,6 @@
import { exec } from "child_process"
import util from "util"
const promiseExec = util.promisify(exec)
export default promiseExec

View File

@@ -0,0 +1,36 @@
type ProcessOptions = {
process: Function
ignoreERESOLVE?: boolean
}
// when running commands with npx or npm sometimes they
// terminate with EAGAIN error unexpectedly
// this utility function allows retrying the process if
// EAGAIN occurs, or otherwise throw the error that occurs
export default async ({ process, ignoreERESOLVE }: ProcessOptions) => {
let processError = false
do {
try {
await process()
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error?.code === "EAGAIN"
) {
processError = true
} else if (
ignoreERESOLVE &&
typeof error === "object" &&
error !== null &&
"code" in error &&
error?.code === "ERESOLVE"
) {
// ignore error
} else {
throw error
}
}
} while (processError)
}

View File

@@ -0,0 +1,15 @@
import { exec } from "child_process"
type StartOptions = {
directory: string
abortController?: AbortController
}
export default ({ directory, abortController }: StartOptions) => {
const childProcess = exec(`npx -y @medusajs/medusa-cli develop`, {
cwd: directory,
signal: abortController?.signal,
})
childProcess.stdout?.pipe(process.stdout)
}

View File

@@ -1,15 +1,18 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs",
"allowJs": true,
"declaration": false,
"outDir": "./dist",
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node16",
"outDir": "./dist",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.json", "src/**/*.js"],
"exclude": ["**/__tests__/**/*"]
"include": ["src"],
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node",
"transpileOnly": true
}
}

1148
yarn.lock

File diff suppressed because it is too large Load Diff