From ec6d16e945f4b8a99e9dcc8ae2e92a2318fbc709 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Fri, 10 Sep 2021 16:02:54 +0200 Subject: [PATCH] feat: adds create-medusa-app (#377) * feat: copied from medusa-cli * chore: gitignore * fix: add admin + storefront * fix: dev experience enhancement --- packages/create-medusa-app/.gitignore | 3 + packages/create-medusa-app/.npmignore | 37 ++ packages/create-medusa-app/cli.js | 7 + packages/create-medusa-app/package.json | 60 +++ .../create-medusa-app/src/get-config-store.ts | 17 + packages/create-medusa-app/src/index.ts | 138 +++++++ packages/create-medusa-app/src/new-starter.js | 377 ++++++++++++++++++ .../create-medusa-app/src/panic-handler.js | 25 ++ packages/create-medusa-app/src/reporter.ts | 18 + packages/create-medusa-app/src/track.ts | 47 +++ packages/create-medusa-app/src/types.d.ts | 4 + packages/create-medusa-app/tsconfig.json | 15 + 12 files changed, 748 insertions(+) create mode 100644 packages/create-medusa-app/.gitignore create mode 100644 packages/create-medusa-app/.npmignore create mode 100755 packages/create-medusa-app/cli.js create mode 100644 packages/create-medusa-app/package.json create mode 100644 packages/create-medusa-app/src/get-config-store.ts create mode 100644 packages/create-medusa-app/src/index.ts create mode 100644 packages/create-medusa-app/src/new-starter.js create mode 100644 packages/create-medusa-app/src/panic-handler.js create mode 100644 packages/create-medusa-app/src/reporter.ts create mode 100644 packages/create-medusa-app/src/track.ts create mode 100644 packages/create-medusa-app/src/types.d.ts create mode 100644 packages/create-medusa-app/tsconfig.json diff --git a/packages/create-medusa-app/.gitignore b/packages/create-medusa-app/.gitignore new file mode 100644 index 0000000000..57db47f86a --- /dev/null +++ b/packages/create-medusa-app/.gitignore @@ -0,0 +1,3 @@ +node_modules +/dist +yarn.lock diff --git a/packages/create-medusa-app/.npmignore b/packages/create-medusa-app/.npmignore new file mode 100644 index 0000000000..243a16a6c5 --- /dev/null +++ b/packages/create-medusa-app/.npmignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules +*.un~ +yarn.lock +src +flow-typed +coverage +decls +examples + +# tests +__tests__ diff --git a/packages/create-medusa-app/cli.js b/packages/create-medusa-app/cli.js new file mode 100755 index 0000000000..5283a3da31 --- /dev/null +++ b/packages/create-medusa-app/cli.js @@ -0,0 +1,7 @@ +#! /usr/bin/env node + +const { run } = require("./dist") + +run().catch((e) => { + console.warn(e) +}) diff --git a/packages/create-medusa-app/package.json b/packages/create-medusa-app/package.json new file mode 100644 index 0000000000..d7b36a305d --- /dev/null +++ b/packages/create-medusa-app/package.json @@ -0,0 +1,60 @@ +{ + "name": "create-medusa-app", + "version": "0.0.0", + "main": "dist/index.js", + "bin": "cli.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" + }, + "dependencies": { + "@babel/runtime": "^7.15.4" + }, + "peerDependencies": {}, + "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.12", + "@types/node": "^14.17.14", + "ansi-wordwrap": "^1.0.2", + "chalk": "^4.1.2", + "commander": "^8.1.0", + "common-tags": "^1.8.0", + "configstore": "^6.0.0", + "enquirer": "^2.3.6", + "eslint": "^7.32.0", + "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", + "joi": "^17.4.2", + "microbundle": "^0.13.3", + "node-fetch": "^2.6.1", + "prettier": "^2.3.2", + "prompts": "^2.4.1", + "string-length": "^4.0.2", + "terminal-link": "^2.1.1", + "tiny-spin": "^1.0.2", + "url": "^0.11.0", + "uuid": "3.4.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa.git", + "directory": "packages/create-medusa-app" + }, + "author": "Sebastian Rindom ", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/create-medusa-app/src/get-config-store.ts b/packages/create-medusa-app/src/get-config-store.ts new file mode 100644 index 0000000000..77407d5165 --- /dev/null +++ b/packages/create-medusa-app/src/get-config-store.ts @@ -0,0 +1,17 @@ +import Configstore from "configstore" + +let config: Configstore + +export const getConfigStore = (): Configstore => { + if (!config) { + config = new Configstore( + `medusa`, + {}, + { + globalConfigPath: true, + } + ) + } + + return config +} diff --git a/packages/create-medusa-app/src/index.ts b/packages/create-medusa-app/src/index.ts new file mode 100644 index 0000000000..c2943eb39c --- /dev/null +++ b/packages/create-medusa-app/src/index.ts @@ -0,0 +1,138 @@ +import path from "path" +import Commander from "commander" +import chalk from "chalk" + +import { prompt } from "enquirer" +import { newStarter } from "./new-starter" +import { track } from "./track" + +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)", + }, + seed: { + type: "confirm", + name: "seed", + message: "Should we attempt to seed your database?", + }, + storefront: { + type: "select", + name: "storefront", + message: "Which storefront starter would you like to install?", + choices: ["Gatsby Starter", "Next.js Starter", "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( + `--seed`, + `If run with the seed flag the script will automatically attempt to seed the database upon setup` + ) + .option(`-v --verbose`, `Show all installation output`) + .parse(process.argv) + +export const run = async (): Promise => { + 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 seed = progOptions.seed + track("SEED_SELECTED", { seed }) + + const { storefront } = (await prompt(questions.storefront)) as { + storefront: string + } + track("STOREFRONT_SELECTED", { storefront }) + + await newStarter({ + starter, + root: path.join(projectRoot, `backend`), + seed, + verbose: progOptions.verbose, + }) + + const hasStorefront = storefront.toLowerCase() !== "none" + if (hasStorefront) { + const storefrontStarter = + storefront.toLowerCase() === "gatsby starter" + ? "https://github.com/medusajs/gatsby-starter-medusa" + : "https://github.com/medusajs/nextjs-starter-medusa" + await newStarter({ + starter: storefrontStarter, + root: path.join(projectRoot, `storefront`), + verbose: progOptions.verbose, + }) + } + await newStarter({ + starter: "https://github.com/medusajs/admin", + root: path.join(projectRoot, `admin`), + keepGit: true, + verbose: progOptions.verbose, + }) + + console.log(` + Your project is ready 🚀. The available commands are: + + Medusa API + cd ${projectRoot}/backend + yarn start + + Admin + cd ${projectRoot}/admin + yarn start + `) + + if (hasStorefront) { + console.log(` + Storefront + cd ${projectRoot}/storefront + yarn start + `) + } +} diff --git a/packages/create-medusa-app/src/new-starter.js b/packages/create-medusa-app/src/new-starter.js new file mode 100644 index 0000000000..9be0d52384 --- /dev/null +++ b/packages/create-medusa-app/src/new-starter.js @@ -0,0 +1,377 @@ +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 removeUndefined = (obj) => { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, v]) => v != null) + .map(([k, v]) => [k, v === Object(v) ? removeEmpty(v) : v]) + ) +} + +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) + + const npmConfigUserAgent = process.env.npm_config_user_agent + + 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 getMedusaConfig = (rootPath) => { + try { + const configPath = sysPath.join(rootPath, "medusa-config.js") + if (existsSync(configPath)) { + const resolved = sysPath.resolve(configPath) + const configModule = require(resolved) + return configModule + } + throw Error() + } catch (err) { + return null + } + return {} +} + +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 successMessage = (path) => { + reporter.info(`Your new Medusa project is ready for you! To start developing run: + + cd ${path} + medusa develop +`) +} + +const setupEnvVars = async (rootPath) => { + const templatePath = sysPath.join(rootPath, ".env.template") + const destination = sysPath.join(rootPath, ".env") + if (existsSync(templatePath)) { + fs.renameSync(templatePath, destination) + } +} + +const attemptSeed = async (rootPath) => { + const stop = spin("Seeding database") + + const pkgPath = sysPath.resolve(rootPath, "package.json") + if (existsSync(pkgPath)) { + const pkg = require(pkgPath) + if (pkg.scripts && pkg.scripts.seed) { + await setupEnvVars(rootPath) + + const proc = execa(getPackageManager(), [`run`, `seed`], { + cwd: rootPath, + }) + + // Useful for development + proc.stdout.pipe(process.stdout) + + await proc + .then(() => { + stop() + console.log() + reporter.success("Seed completed") + }) + .catch((err) => { + stop() + console.log() + reporter.error("Failed to complete seed; skipping") + console.error(err) + }) + } else { + stop() + console.log() + reporter.error("Starter doesn't provide a seed command; skipping.") + } + } else { + stop() + console.log() + reporter.error("Could not find package.json") + } +} + +/** + * Main function that clones or copies the starter. + */ +export const newStarter = async (args) => { + const { starter, root, verbose, seed, 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) + } + + const medusaConfig = getMedusaConfig(rootPath) + if (medusaConfig) { + let isPostgres = false + if (medusaConfig.projectConfig) { + const databaseType = medusaConfig.projectConfig.database_type + isPostgres = databaseType === "postgres" + } + + if (!isPostgres && seed) { + await attemptSeed(rootPath) + } + } +} diff --git a/packages/create-medusa-app/src/panic-handler.js b/packages/create-medusa-app/src/panic-handler.js new file mode 100644 index 0000000000..588e96b12a --- /dev/null +++ b/packages/create-medusa-app/src/panic-handler.js @@ -0,0 +1,25 @@ +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", + } + } +} diff --git a/packages/create-medusa-app/src/reporter.ts b/packages/create-medusa-app/src/reporter.ts new file mode 100644 index 0000000000..57cc44673b --- /dev/null +++ b/packages/create-medusa-app/src/reporter.ts @@ -0,0 +1,18 @@ +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), +} diff --git a/packages/create-medusa-app/src/track.ts b/packages/create-medusa-app/src/track.ts new file mode 100644 index 0000000000..2ddf44fa31 --- /dev/null +++ b/packages/create-medusa-app/src/track.ts @@ -0,0 +1,47 @@ +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 */ +} diff --git a/packages/create-medusa-app/src/types.d.ts b/packages/create-medusa-app/src/types.d.ts new file mode 100644 index 0000000000..ec8f69389c --- /dev/null +++ b/packages/create-medusa-app/src/types.d.ts @@ -0,0 +1,4 @@ +declare module "stream-filter" +declare module "ansi-wordwrap" +declare module "uuid/v4" +declare module "node-fetch" diff --git a/packages/create-medusa-app/tsconfig.json b/packages/create-medusa-app/tsconfig.json new file mode 100644 index 0000000000..ec10870fc1 --- /dev/null +++ b/packages/create-medusa-app/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "commonjs", + "allowJs": true, + "declaration": false, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.json", "src/**/*.js"], + "exclude": ["**/__tests__/**/*"] +}